diff --git a/custom_components/lock_code_manager/domain/coordinator.py b/custom_components/lock_code_manager/domain/coordinator.py index 7b87b0782..20f9057f6 100644 --- a/custom_components/lock_code_manager/domain/coordinator.py +++ b/custom_components/lock_code_manager/domain/coordinator.py @@ -59,6 +59,13 @@ def __init__(self, hass: HomeAssistant, lock: BaseLock, config_entry: Any) -> No config_entry=config_entry, ) self.data: dict[int, SlotCredential] = {} + # Per-slot "verified" flag, kept in lockstep with ``data``. A slot is + # unverified only while an optimistic (ambiguous-but-treated-as-completed) + # write awaits confirmation; every other source -- genuine push events, + # polls, hard refreshes, and authoritative writes -- is verified. Absent + # slots read as verified, so poll/cloud providers (which never push an + # optimistic update) are unaffected. See the Phase 2 push-as-commit spec. + self._verified: dict[int, bool] = {} self._config_entry = config_entry self._lock_breaker = CircuitBreaker( BACKOFF_FAILURE_THRESHOLD, @@ -112,14 +119,155 @@ def _normalize_keys( """Coerce slot keys to ``int``. Raises ValueError/TypeError if a key cannot be cast.""" return {int(k): v for k, v in data.items()} + def _apply_read( + self, observed: dict[int, SlotCredential] + ) -> dict[int, SlotCredential]: + """ + Resolve a genuine read (poll or hard refresh) against pending writes. + + A read is the dropped-push backstop for the verified-credential + lifecycle: for a slot with an outstanding optimistic write, observing + the slot present confirms our write -- keep the believed value and mark + it verified. The one exception (mirroring ``BaseLock._confirm_slot``) is + a *readable* observation of a different code: that is an external change + racing our write, so take the observation rather than masking it with + the believed value -- otherwise a drift refresh, whose whole purpose is + to surface out-of-band changes, would silently overwrite one. Observing + the slot still absent means the write has not landed yet, so keep waiting + (stay unverified, pending intact). Slots with no pending write are + genuine observations and are marked verified. See the Phase 2 + push-as-commit spec. + """ + out: dict[int, SlotCredential] = {} + for slot, cred in observed.items(): + pending = self._lock._pending_writes.get(slot) + if pending is not None and cred.is_present: + pin, _deadline = pending + del self._lock._pending_writes[slot] + if cred.is_readable and cred.readable_pin != pin: + out[slot] = cred + else: + out[slot] = SlotCredential.known(pin) + self._verified[slot] = True + elif pending is not None: + out[slot] = cred + self._verified[slot] = False + else: + out[slot] = cred + self._verified.pop(slot, None) + # Keep the verified map in lockstep with the read. + self._verified = { + slot: flag for slot, flag in self._verified.items() if slot in out + } + return out + + def is_verified(self, slot: int) -> bool: + """ + Return whether the slot's credential is a confirmed observation. + + Absent slots default to verified: a slot is only unverified while an + optimistic write awaits confirmation (push event or hard refresh). + """ + return self._verified.get(slot, True) + + @callback + def mark_verified(self, slot: int) -> None: + """ + Clear a slot's unverified flag (it defaults back to verified). + + Called when a write is confirmed by the lock (an authoritative + ``WriteResult.CONFIRMED``), so a stale unverified flag from a prior + optimistic write on the same slot cannot strand it. + """ + self._verified.pop(slot, None) + + async def async_confirm_pending_writes(self) -> None: + """ + Actively read the lock back to confirm outstanding optimistic writes. + + An ambiguous write (``WriteResult.OPTIMISTIC``) gets no confirming push + on some stacks: node-zwave-js, for one, emits no ``credential + added/modified`` event when its own post-write verification fails on a + lock that reports codes back masked -- the event and the ``ERROR_UNKNOWN`` + result are mutually exclusive. Waiting for the hourly drift refresh would + let the breaker suspend a slot whose code actually landed (~3 attempts in + the 5-minute window, long before the hourly backstop). So the seam calls + this immediately after recording an optimistic write. + + This is the order-independent confirmation path: it does not depend on + receiving any event. A hard read observes the slot present-but-masked + (LCM projects masked codes to ``unreadable`` rather than repeating the + driver's ``userCode == codeData`` check) and ``_apply_read`` confirms it; + a genuinely-absent slot stays pending and re-syncs on the next tick. + + A failed read is non-fatal and does not apply backoff: the slot stays + pending and the sync tick reconciles it within the TTL. + """ + if not self._lock._pending_writes: + return + try: + new_data = self._apply_read( + self._normalize_keys( + await self._lock.async_internal_hard_refresh_codes() + ) + ) + except LockCodeManagerError as err: + _LOGGER.debug( + "On-demand confirmation read failed for %s: %s; leaving pending " + "writes for the sync tick to reconcile", + self._lock.lock.entity_id, + err, + ) + return + except Exception: + # The confirmation read is a best-effort backstop, never fatal: it + # must not escape into the set seam and suspend the slot. The pending + # write stays recorded and the sync tick reconciles it via the TTL. + _LOGGER.exception( + "Unexpected error during on-demand confirmation read for %s; " + "leaving pending writes for the sync tick to reconcile", + self._lock.lock.entity_id, + ) + return + # _apply_read already cleared pending + flipped the verified flag in + # place, so the confirmation takes effect even when the data is + # unchanged; only the listener notification is gated on a real delta. + if new_data != self.data: + self.async_set_updated_data(new_data) + @callback - def push_update(self, updates: dict[int, SlotCredential]) -> None: - """Push one or more slot updates and notify listening entities.""" + def push_update( + self, updates: dict[int, SlotCredential], *, optimistic: bool = False + ) -> None: + """ + Push one or more slot updates and notify listening entities. + + ``optimistic=True`` marks the pushed slots unverified (an ambiguous + write we are treating as completed but have not yet confirmed). The + default, ``False``, marks them verified -- every existing caller keeps + today's behavior. + """ if not updates: return - new_data = {**self.data, **self._normalize_keys(updates)} + normalized = self._normalize_keys(updates) + new_data = {**self.data, **normalized} + verified = not optimistic + + # Record the verified flag for the pushed slots regardless of whether + # the value changed: an optimistic re-push of the same value still + # flips the slot to unverified. + for slot in normalized: + self._verified[slot] = verified + # Keep the verified map in lockstep with data. + self._verified = { + slot: flag for slot, flag in self._verified.items() if slot in new_data + } + if new_data == self.data: + # Verified-flag-only change: the sync layer reads ``is_verified`` + # directly on its next tick, and entities don't render the flag, so + # there's nothing to notify and no reachability proof (no new data). return # A successful push update proves the lock is reachable, so reset @@ -218,7 +366,7 @@ async def async_get_usercodes(self) -> dict[int, SlotCredential]: raise UpdateFailed from err self._reset_backoff() - return self._normalize_keys(data) + return self._apply_read(self._normalize_keys(data)) async def _async_drift_check(self, now: datetime) -> None: """Perform a hard refresh to detect out-of-band code changes.""" @@ -238,8 +386,10 @@ async def _async_drift_check(self, now: datetime) -> None: self._lock.lock.entity_id, ) try: - new_data = self._normalize_keys( - await self._lock.async_internal_hard_refresh_codes() + new_data = self._apply_read( + self._normalize_keys( + await self._lock.async_internal_hard_refresh_codes() + ) ) except LockCodeManagerError as err: self._apply_backoff() diff --git a/custom_components/lock_code_manager/domain/credentials.py b/custom_components/lock_code_manager/domain/credentials.py index 0283f9d1f..1fa6b2e36 100644 --- a/custom_components/lock_code_manager/domain/credentials.py +++ b/custom_components/lock_code_manager/domain/credentials.py @@ -187,6 +187,35 @@ def credential_for(self, credential_type: CredentialType) -> Credential | None: return next(iter(self.credentials_of_type(credential_type)), None) +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 = "no_change" + CONFIRMED = "confirmed" + OPTIMISTIC = "optimistic" + + @property + def changed(self) -> bool: + """Return whether a write actually occurred (CONFIRMED or OPTIMISTIC).""" + return self is not WriteResult.NO_CHANGE + + @dataclass(frozen=True, slots=True) class SetUserResult: """ diff --git a/custom_components/lock_code_manager/domain/models.py b/custom_components/lock_code_manager/domain/models.py index 728bcd0b5..024949699 100644 --- a/custom_components/lock_code_manager/domain/models.py +++ b/custom_components/lock_code_manager/domain/models.py @@ -32,6 +32,10 @@ class SyncState(StrEnum): IN_SYNC: desired state matches actual state on the lock. OUT_OF_SYNC: mismatch detected, pending sync on next tick. SYNCING: sync operation in progress. + PENDING_CONFIRMATION: an optimistic (ambiguous-but-treated-as-completed) + write was issued and we are waiting for the lock to confirm it (a push + event or hard-refresh read). The tick does not re-write while waiting; + confirmation -> IN_SYNC, timeout -> re-sync. SUSPENDED: circuit breaker tripped or unexpected error; awaiting coordinator recovery (suspended flag cleared). """ @@ -40,6 +44,7 @@ class SyncState(StrEnum): IN_SYNC = "in_sync" OUT_OF_SYNC = "out_of_sync" SYNCING = "syncing" + PENDING_CONFIRMATION = "pending_confirmation" SUSPENDED = "suspended" diff --git a/custom_components/lock_code_manager/domain/sync.py b/custom_components/lock_code_manager/domain/sync.py index 0abc590ca..9fdb0cf78 100644 --- a/custom_components/lock_code_manager/domain/sync.py +++ b/custom_components/lock_code_manager/domain/sync.py @@ -17,6 +17,7 @@ from dataclasses import dataclass from datetime import datetime import logging +import time from typing import TYPE_CHECKING, Any from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN @@ -401,7 +402,16 @@ def calculate_in_sync(self, slot_state: SlotState) -> bool: that PIN changes trigger a re-set even when the lock code is unreadable, and that taking over a slot with an existing masked code triggers a set. + + An unverified slot (an optimistic write still awaiting confirmation, + or one whose confirmation never arrived) is never in sync: the + coordinator holds the believed value but the lock has not confirmed it, + so the tick must keep watching (PENDING_CONFIRMATION) or re-sync rather + than declare success. Slots with no recorded flag read as verified, so + this is a no-op for providers that never write optimistically. """ + if not self._coordinator.is_verified(self._slot_num): + return False credential = slot_state.coordinator_code if slot_state.active_state == STATE_ON: if credential is not None: @@ -699,6 +709,48 @@ async def _async_tick_impl(self) -> None: self._write_state() return + # -- PENDING_CONFIRMATION: an optimistic write is awaiting confirmation. + # Don't re-write while waiting; a push event / hard refresh resolves it + # (clearing the pending entry). On timeout, count a breaker failure and + # fall through to re-sync -- so a write the lock never committed ends in + # a visible suspend, never a silent in-sync. + pending = self._lock._pending_writes.get(self._slot_num) + if pending is not None: + believed_pin, deadline = pending + # A pending write only gates reconciliation while it still reflects + # the desired state. If the user changed the PIN or disabled the slot + # while the write was outstanding, the entry is stale: drop it and + # reconcile the new target instead of holding PENDING_CONFIRMATION + # (and re-syncing the old value) until the deadline. + still_wanted = ( + slot_state.active_state == STATE_ON + and slot_state.pin_state == believed_pin + ) + if still_wanted and time.monotonic() < deadline: + if self._state is not SyncState.PENDING_CONFIRMATION: + self._state = SyncState.PENDING_CONFIRMATION + self._write_state() + return + # Drop the entry and re-sync on the NEXT tick. Returning here (rather + # than falling through to _perform_sync this tick) lets a confirming + # push that lands between ticks resolve the slot first, and avoids + # double-charging the breaker when the re-sync itself also fails. + del self._lock._pending_writes[self._slot_num] + if still_wanted: + # Genuine timeout: the write we still want never confirmed. + self._slot_breaker.record_failure() + _LOGGER.warning( + "%s: optimistic write not confirmed within the timeout; " + "re-syncing (attempt %s)", + self._log_prefix, + self._slot_breaker.failure_count, + ) + # A stale entry (desired state changed) is not a sync failure, so it + # does not charge the breaker. + self._state = SyncState.OUT_OF_SYNC + self._write_state() + return + # -- OUT_OF_SYNC: check lock reachability, then attempt sync -- if self._coordinator.unreachable: self._state = SyncState.SUSPENDED @@ -822,6 +874,16 @@ async def _async_tick_impl(self) -> None: self._state = SyncState.OUT_OF_SYNC return + # An optimistic (ambiguous) set records a pending write: don't judge it + # now -- wait for the lock to confirm it (a push event or hard refresh) + # in PENDING_CONFIRMATION. The breaker is only charged when that wait + # times out (handled at the top of the tick), so a masked-but-accepted + # write is not penalised before its confirming event arrives. + if self._slot_num in self._lock._pending_writes: + self._state = SyncState.PENDING_CONFIRMATION + self._write_state() + return + # Check if sync actually worked. slot_state = self._resolve_slot_state() if slot_state is not None and self.calculate_in_sync(slot_state): diff --git a/custom_components/lock_code_manager/providers/_base.py b/custom_components/lock_code_manager/providers/_base.py index dca7e81d2..92717b390 100644 --- a/custom_components/lock_code_manager/providers/_base.py +++ b/custom_components/lock_code_manager/providers/_base.py @@ -47,6 +47,7 @@ LockCapabilities, SetUserResult, User, + WriteResult, credential_from_slot, user_from_slot, ) @@ -68,6 +69,11 @@ _LOGGER = logging.getLogger(__name__) MIN_OPERATION_DELAY = 2.0 + +# How long an optimistic write waits for confirmation (push event or hard-refresh +# presence) before the sync layer gives up waiting and re-syncs. See the Phase 2 +# push-as-commit spec. +PENDING_WRITE_TTL = 60.0 _OPERATION_MESSAGES: dict[Literal["get", "set", "clear", "refresh"], str] = { "get": "get from", "set": "set on", @@ -205,6 +211,15 @@ class BaseLock: _setup_running: bool = field(default=False, init=False) _lcm_config_entry: ConfigEntry | None = field(default=None, init=False) _rejected_code_slots: set[int] = field(default_factory=set, init=False) + # Slots with an outstanding optimistic (ambiguous-but-treated-as-completed) + # write awaiting confirmation, mapped to (believed_pin, monotonic_deadline). + # A confirmation -- a push event or a hard-refresh read observing the slot + # present -- clears the entry and re-pushes the believed value as verified; + # if none arrives before the deadline, the sync layer re-syncs. See the + # Phase 2 push-as-commit spec. + _pending_writes: dict[int, tuple[str, float]] = field( + default_factory=dict, init=False + ) # Reconnect task spawned by the config-entry state listener when the lock # integration transitions to LOADED. Tracked so async_unload can cancel it # before teardown -- otherwise a late reconnect can call @@ -368,12 +383,64 @@ def mask_pin(self, pin: str | None, code_slot: int | str = 0) -> str: @final @callback def _push_credential_update( - self, code_slot: int, credential: SlotCredential + self, code_slot: int, credential: SlotCredential, *, optimistic: bool = False ) -> None: - """Push a coordinator credential update; no-op when no coordinator is attached.""" - if self.coordinator is not None: + """ + Push a coordinator credential update; no-op when no coordinator is attached. + + ``optimistic=True`` marks the slot unverified (an ambiguous write we are + treating as completed but have not confirmed). The default keeps the + slot verified. + """ + if self.coordinator is None: + return + # Only pass the kwarg when optimistic, so the common verified push keeps + # its plain call shape (and existing call-shape assertions hold). + if optimistic: + self.coordinator.push_update({code_slot: credential}, optimistic=True) + else: self.coordinator.push_update({code_slot: credential}) + @callback + def _record_optimistic_write(self, code_slot: int, pin: str) -> None: + """ + Record an outstanding optimistic write and push its believed value. + + Called by the seam when ``async_set_credential`` returns OPTIMISTIC. + The slot is pushed as ``known(pin)`` but marked unverified; it awaits + a confirmation (push event or hard-refresh presence) via + ``_confirm_slot``, or re-syncs once the deadline passes. + """ + self._pending_writes[code_slot] = (pin, time.monotonic() + PENDING_WRITE_TTL) + self._push_credential_update( + code_slot, SlotCredential.known(pin), optimistic=True + ) + + @callback + def _confirm_slot(self, code_slot: int, observed: SlotCredential) -> None: + """ + Resolve an observation (push event or hard-refresh read) for a slot. + + When an optimistic write for the slot is outstanding and the observed + state shows a code present, the observation confirms our write: keep + the believed value (even if the observation itself is masked/unreadable) + and mark it verified. The one exception is a *readable* observation of a + different code -- that is an external change racing our write, so we take + the observation rather than masking it with our believed value. + Otherwise -- no pending write, or the slot is now empty -- take the + observation as the verified state. Either way the pending entry is + cleared. + """ + pending = self._pending_writes.pop(code_slot, None) + if pending is not None and observed.is_present: + pin, _deadline = pending + if observed.is_readable and observed.readable_pin != pin: + self._push_credential_update(code_slot, observed) + else: + self._push_credential_update(code_slot, SlotCredential.known(pin)) + return + self._push_credential_update(code_slot, observed) + @final def is_slot_managed(self, code_slot: int) -> bool: """Return whether a code slot is managed by any LCM config entry for this lock.""" @@ -854,17 +921,15 @@ async def async_set_usercode( usercode: str, name: str | None = None, source: Literal["sync", "direct"] = "direct", - ) -> bool: + ) -> WriteResult: """ Set a usercode on a code slot via the User->Credential primitives. Projects the slot to a single Personal Identification Number credential. Native-user providers run the create-on-first user lifecycle via ``_set_credential``; slot-only providers write the - credential directly, addressing it by slot. Returns True if the value - changed, False if it was already set to this value -- and True when the - provider cannot determine whether a change occurred, so the coordinator - refreshes and verifies the actual state. + credential directly, addressing it by slot. Returns the provider's + ``WriteResult`` (NO_CHANGE / CONFIRMED / OPTIMISTIC). """ state = SlotCredential.known(usercode) credential = credential_from_slot(code_slot, state) @@ -911,7 +976,7 @@ def _pre_execute_checks() -> None: lock_entity_id=self.lock.entity_id, ) - changed = await self._execute_rate_limited( + result: WriteResult = await self._execute_rate_limited( "set", self.async_set_usercode, code_slot, @@ -920,10 +985,29 @@ def _pre_execute_checks() -> None: name=name, source=source, ) + if result is WriteResult.OPTIMISTIC: + # Ambiguous write: record it pending and push the believed value as + # unverified, then actively read the lock back to confirm it. Some + # stacks send no confirming push for an ambiguous write (node-zwave-js + # emits no credential event when its post-write verify fails on a + # masked lock), so waiting passively would let the breaker suspend a + # slot whose code actually landed before the hourly drift refresh can + # run. The read confirms a present-but-masked slot; a genuinely-absent + # slot stays pending and the sync tick re-syncs after the TTL. + self._record_optimistic_write(code_slot, str(usercode)) + if self.coordinator is not None: + await self.coordinator.async_confirm_pending_writes() + elif result is WriteResult.CONFIRMED: + # The lock acknowledged the write: supersede any pending optimistic + # state and clear a stale unverified flag from a prior optimistic + # write, so the slot can converge instead of churning to a suspend. + self._pending_writes.pop(code_slot, None) + if self.coordinator is not None: + self.coordinator.mark_verified(code_slot) # Skip coordinator refresh for push providers — they update optimistically # via push_update(), and refreshing from cache could overwrite with stale # data when the driver defers cache updates until device confirmation. - if changed and self.coordinator and not self.supports_push: + if result.changed and self.coordinator and not self.supports_push: await self.coordinator.async_request_refresh() async def async_clear_usercode(self, code_slot: int) -> bool: @@ -1009,6 +1093,10 @@ async def async_internal_clear_usercode( code_slot, source, ) + # A clear supersedes any outstanding optimistic set on this slot, so the + # stale pending entry must not keep gating reconciliation (the sync tick + # keys PENDING_CONFIRMATION on this dict). + self._pending_writes.pop(code_slot, None) changed = await self._execute_rate_limited( "clear", self.async_clear_usercode, code_slot ) @@ -1223,7 +1311,7 @@ async def _set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: """ Run the create-on-first user lifecycle around a credential write. @@ -1367,9 +1455,9 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: """ - Set or update one credential, returning whether the lock changed. + Set or update one credential, returning the write outcome. Every migrated provider implements this. ``user_id`` identifies the owning user for native-user providers; slot-only providers ignore it @@ -1384,11 +1472,12 @@ async def async_set_credential( code name). A ``name`` of ``None`` means leave any existing name unchanged, never clear it. - Return True if the value changed, False if it was already set to this - value. When the provider cannot determine whether a change occurred - (for example a write-only lock), return True: the returned flag drives - the coordinator refresh, so reporting True makes it re-read and verify - rather than leaving stale state. + Return ``WriteResult.NO_CHANGE`` if 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 -- e.g. a write-only/masked lock). The + outcome drives the coordinator refresh and the verified/unverified + slot lifecycle. """ self._raise_not_implemented( "async_set_credential", diff --git a/custom_components/lock_code_manager/providers/_zwave_js_uc.py b/custom_components/lock_code_manager/providers/_zwave_js_uc.py index 01ca7a080..2f4561381 100644 --- a/custom_components/lock_code_manager/providers/_zwave_js_uc.py +++ b/custom_components/lock_code_manager/providers/_zwave_js_uc.py @@ -64,6 +64,7 @@ CredentialTypeCapability, LockCapabilities, User, + WriteResult, ) from ..domain.exceptions import ( CodeRejectedError, @@ -316,16 +317,23 @@ async def _async_refresh_usercode_cache(self) -> None: except BaseZwaveJSServerError as err: raise LockDisconnected(f"usercode cache refresh failed: {err}") from err - async def _async_uc_set_usercode(self, code_slot: int, usercode: str) -> bool: + async def _async_uc_set_usercode( + self, code_slot: int, usercode: str + ) -> WriteResult: """ Write a usercode through the legacy User Code CC value path. - Returns False without writing when the cached value already - matches (masked codes never match, so they are always - rewritten). After a successful write, V1 locks are polled to - force-update the value DB (they don't reliably report back), - and the new state is pushed optimistically so the next sync - tick doesn't read a stale cache and loop. + Returns ``WriteResult.NO_CHANGE`` without writing when the cached + value already matches (masked codes never match, so they are + always rewritten). After a successful write, V1 locks are polled + to force-update the value DB (they don't reliably report back), + and the new state is pushed optimistically so the next sync tick + doesn't read a stale cache and loop. + + Returns ``WriteResult.CONFIRMED`` for a completed write. Note this + path does not yet participate in the verified-credential lifecycle + (it confirms inline via the V1 poll rather than via a later push), + so a masked/unverifiable write is trusted here as it was in 3.x. """ try: current = get_usercode(self.node, code_slot) @@ -339,7 +347,7 @@ async def _async_uc_set_usercode(self, code_slot: int, usercode: str) -> bool: self.lock.entity_id, code_slot, ) - return False + return WriteResult.NO_CHANGE self._set_in_progress_code_slot = code_slot try: @@ -378,7 +386,7 @@ async def _async_uc_set_usercode(self, code_slot: int, usercode: str) -> bool: # Optimistic update: the value cache updates asynchronously via push # notification; push now to prevent sync loops from reading stale cache. self._push_credential_update(code_slot, SlotCredential.known(usercode)) - return True + return WriteResult.CONFIRMED async def _async_uc_clear_usercode(self, code_slot: int) -> bool: """ diff --git a/custom_components/lock_code_manager/providers/akuvox.py b/custom_components/lock_code_manager/providers/akuvox.py index 9d1970c3b..892a4e185 100644 --- a/custom_components/lock_code_manager/providers/akuvox.py +++ b/custom_components/lock_code_manager/providers/akuvox.py @@ -20,7 +20,13 @@ from homeassistant.config_entries import ConfigEntry -from ..domain.credentials import Credential, CredentialRef, User, user_from_slot +from ..domain.credentials import ( + Credential, + CredentialRef, + User, + WriteResult, + user_from_slot, +) from ..domain.exceptions import ( LockCodeManagerProviderError, LockDisconnected, @@ -334,7 +340,7 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: """ Set a Personal Identification Number credential on a slot. @@ -380,7 +386,7 @@ async def async_set_credential( self.lock.entity_id, code_slot, ) - return True + return WriteResult.CONFIRMED async def async_delete_credential(self, ref: CredentialRef) -> bool: """ diff --git a/custom_components/lock_code_manager/providers/matter.py b/custom_components/lock_code_manager/providers/matter.py index cece53559..026cbb2be 100644 --- a/custom_components/lock_code_manager/providers/matter.py +++ b/custom_components/lock_code_manager/providers/matter.py @@ -43,6 +43,7 @@ LockCapabilities, SetUserResult, User, + WriteResult, ) from ..domain.exceptions import ( CodeRejectedError, @@ -759,7 +760,7 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: """ Write a Personal Identification Number credential to the lock. @@ -871,7 +872,7 @@ async def async_set_credential( ) from retry_err self._push_credential_update(slot, SlotCredential.unreadable()) - return True + return WriteResult.CONFIRMED async def async_delete_credential(self, ref: CredentialRef) -> bool: """ diff --git a/custom_components/lock_code_manager/providers/schlage.py b/custom_components/lock_code_manager/providers/schlage.py index d900300d6..a36385920 100644 --- a/custom_components/lock_code_manager/providers/schlage.py +++ b/custom_components/lock_code_manager/providers/schlage.py @@ -26,7 +26,13 @@ from homeassistant.config_entries import ConfigEntry -from ..domain.credentials import Credential, CredentialRef, User, user_from_slot +from ..domain.credentials import ( + Credential, + CredentialRef, + User, + WriteResult, + user_from_slot, +) from ..domain.exceptions import ( LockCodeManagerProviderError, LockDisconnected, @@ -356,7 +362,7 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: """ Set a Personal Identification Number credential on a slot. @@ -418,7 +424,7 @@ async def async_set_credential( self.lock.entity_id, code_slot, ) - return True + return WriteResult.CONFIRMED async def async_delete_credential(self, ref: CredentialRef) -> bool: """ diff --git a/custom_components/lock_code_manager/providers/virtual.py b/custom_components/lock_code_manager/providers/virtual.py index efb0ac596..27deb747f 100644 --- a/custom_components/lock_code_manager/providers/virtual.py +++ b/custom_components/lock_code_manager/providers/virtual.py @@ -10,7 +10,13 @@ from homeassistant.helpers.storage import Store from ..const import DOMAIN -from ..domain.credentials import Credential, CredentialRef, User, user_from_slot +from ..domain.credentials import ( + Credential, + CredentialRef, + User, + WriteResult, + user_from_slot, +) from ..domain.models import SlotCredential from ._base import BaseLock from ._util import parse_slot_num @@ -73,7 +79,7 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: """ Set a Personal Identification Number credential on a code slot. @@ -83,9 +89,9 @@ async def async_set_credential( slot_key = str(credential.slot) new_data = CodeSlotData(code=pin, name=name) if slot_key in self._data and self._data[slot_key] == new_data: - return False + return WriteResult.NO_CHANGE self._data[slot_key] = new_data - return True + return WriteResult.CONFIRMED async def async_delete_credential(self, ref: CredentialRef) -> bool: """ diff --git a/custom_components/lock_code_manager/providers/zha.py b/custom_components/lock_code_manager/providers/zha.py index 6976e81d9..2ed558bdb 100644 --- a/custom_components/lock_code_manager/providers/zha.py +++ b/custom_components/lock_code_manager/providers/zha.py @@ -24,7 +24,13 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import callback -from ..domain.credentials import Credential, CredentialRef, User, user_from_slot +from ..domain.credentials import ( + Credential, + CredentialRef, + User, + WriteResult, + user_from_slot, +) from ..domain.exceptions import CodeRejectedError, LockDisconnected from ..domain.models import SlotCredential from ._base import BaseLock @@ -228,7 +234,7 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: """ Set a Personal Identification Number credential on a slot. @@ -267,7 +273,7 @@ async def async_set_credential( reason=f"set_pin_code rejected: status {result.status}", ) self._push_credential_update(code_slot, SlotCredential.known(pin)) - return True + return WriteResult.CONFIRMED async def async_delete_credential(self, ref: CredentialRef) -> bool: """ diff --git a/custom_components/lock_code_manager/providers/zigbee2mqtt.py b/custom_components/lock_code_manager/providers/zigbee2mqtt.py index ba89994de..0367b2f2f 100644 --- a/custom_components/lock_code_manager/providers/zigbee2mqtt.py +++ b/custom_components/lock_code_manager/providers/zigbee2mqtt.py @@ -19,7 +19,13 @@ from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from ..domain.credentials import Credential, CredentialRef, User, user_from_slot +from ..domain.credentials import ( + Credential, + CredentialRef, + User, + WriteResult, + user_from_slot, +) from ..domain.exceptions import LockDisconnected, LockOperationFailed from ..domain.models import SlotCredential from ._base import BaseLock @@ -411,7 +417,7 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: """ Set a Personal Identification Number credential on a code slot. @@ -474,7 +480,7 @@ async def async_set_credential( ) # Optimistic coordinator update after publish (MQTT QoS 0); hard_refresh mitigates drift. self._push_credential_update(code_slot, SlotCredential.known(pin)) - return True + return WriteResult.CONFIRMED async def async_delete_credential(self, ref: CredentialRef) -> bool: """ diff --git a/custom_components/lock_code_manager/providers/zwave_js.py b/custom_components/lock_code_manager/providers/zwave_js.py index ae42bca6c..b42b5d07c 100644 --- a/custom_components/lock_code_manager/providers/zwave_js.py +++ b/custom_components/lock_code_manager/providers/zwave_js.py @@ -50,6 +50,7 @@ LockCapabilities, SetUserResult, User, + WriteResult, ) from ..domain.exceptions import ( CodeRejectedError, @@ -433,7 +434,7 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: """ Write the PIN credential under user_id; map device rejections. @@ -481,19 +482,21 @@ async def async_set_credential( if key == "credential_rejected_unknown": _LOGGER.debug( "Lock %s slot %s: driver returned ERROR_UNKNOWN; treating " - "as a completed set pending reconciliation (the lock may " - "report the code back masked -- see issue #1251): %s", + "as an optimistic (unconfirmed) set -- the lock may report " + "the code back masked (see issue #1251). The seam records it " + "pending until a credential event or hard refresh confirms " + "it; otherwise it re-syncs: %s", self.lock.entity_id, credential.slot, err, ) - return True + return WriteResult.OPTIMISTIC raise CodeRejectedError( code_slot=credential.slot, lock_entity_id=self.lock.entity_id, reason=str(err), ) from err - return True + return WriteResult.CONFIRMED async def async_delete_credential(self, ref: CredentialRef) -> bool: """ @@ -595,7 +598,10 @@ def _on_credential_changed(self, event: dict[str, Any]) -> None: args = event["args"] # CredentialChangedArgs (pre-parsed by the library) if args.credential_type != UserCredentialType.PIN_CODE: return - self._push_credential_update(args.credential_slot, self._pin_state(args.data)) + # Route through _confirm_slot: a credential event confirms a pending + # optimistic write (keeping the believed value even when the lock + # reports it masked); otherwise it is an external change taken as-is. + self._confirm_slot(args.credential_slot, self._pin_state(args.data)) @callback def _on_credential_deleted(self, event: dict[str, Any]) -> None: @@ -603,7 +609,7 @@ def _on_credential_deleted(self, event: dict[str, Any]) -> None: args = event["args"] # CredentialDeletedArgs (pre-parsed by the library) if args.credential_type != UserCredentialType.PIN_CODE: return - self._push_credential_update(args.credential_slot, SlotCredential.empty()) + self._confirm_slot(args.credential_slot, SlotCredential.empty()) @callback def teardown_push_subscription(self) -> None: diff --git a/custom_components/lock_code_manager/www/generated/lock-code-manager.js b/custom_components/lock_code_manager/www/generated/lock-code-manager.js index 434c8977d..e5f23fe50 100644 --- a/custom_components/lock_code_manager/www/generated/lock-code-manager.js +++ b/custom_components/lock_code_manager/www/generated/lock-code-manager.js @@ -1 +1 @@ -var n,e,t,i,o,r,a,s,l,c,d,h,p,u,v,g,_,f,m,y,b,w,x,k,C,E,S,A,$,L,P,M,z,N,O,R,H,T,D,I,U,j,V,F,B,W,q,Z,K,G,Y,X,J,Q,nn,en,tn,on,rn,an,sn,ln,cn,dn,hn,pn,un,vn,gn,_n,fn,mn,yn,bn,wn,xn,kn,Cn,En,Sn,An,$n,Ln,Pn,Mn,zn,Nn,On,Rn,Hn,Tn,Dn,In,Un,jn,Vn,Fn=["config_entry_title"];function Bn(n,e){return e||(e=n.slice(0)),Object.freeze(Object.defineProperties(n,{raw:{value:Object.freeze(e)}}))}function Wn(n,e){var t=Object.keys(n);if(Object.getOwnPropertySymbols){var i=Object.getOwnPropertySymbols(n);e&&(i=i.filter((function(e){return Object.getOwnPropertyDescriptor(n,e).enumerable}))),t.push.apply(t,i)}return t}function qn(n){for(var e=1;en.length)&&(e=n.length);for(var t=0,i=Array(e);t1?e-1:0),i=1;ie+(n=>{if(!0===n._$cssResult$)return n.cssText;if("number"==typeof n)return n;throw Error("Value passed to 'css' function must be a 'css' function result: "+n+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(t)+n[i+1]),n[0]);return new te(o,n,ne)},oe=Qn?n=>n:n=>n instanceof CSSStyleSheet?(n=>{var e="";for(var t of n.cssRules)e+=t.cssText;return(n=>new te("string"==typeof n?n:n+"",void 0,ne))(e)})(n):n,re=Object.is,ae=Object.defineProperty,se=Object.getOwnPropertyDescriptor,le=Object.getOwnPropertyNames,ce=Object.getOwnPropertySymbols,de=Object.getPrototypeOf,he=globalThis,pe=he.trustedTypes,ue=pe?pe.emptyScript:"",ve=he.reactiveElementPolyfillSupport,ge=(n,e)=>n,_e={toAttribute(n,e){switch(e){case Boolean:n=n?ue:null;break;case Object:case Array:n=null==n?n:JSON.stringify(n)}return n},fromAttribute(n,e){var t=n;switch(e){case Boolean:t=null!==n;break;case Number:t=null===n?null:Number(n);break;case Object:case Array:try{t=JSON.parse(n)}catch(n){t=null}}return t}},fe=(n,e)=>!re(n,e),me={attribute:!0,type:String,converter:_e,reflect:!1,useDefault:!1,hasChanged:fe};null!==(n=Symbol.metadata)&&void 0!==n||(Symbol.metadata=Symbol("metadata")),null!==(e=he.litPropertyMetadata)&&void 0!==e||(he.litPropertyMetadata=new WeakMap);var ye=class extends HTMLElement{static addInitializer(n){var e;this._$Ei(),(null!==(e=this.l)&&void 0!==e?e:this.l=[]).push(n)}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(n){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:me;if(e.state&&(e.attribute=!1),this._$Ei(),this.prototype.hasOwnProperty(n)&&((e=Object.create(e)).wrapped=!0),this.elementProperties.set(n,e),!e.noAccessor){var t=Symbol(),i=this.getPropertyDescriptor(n,t,e);void 0!==i&&ae(this.prototype,n,i)}}static getPropertyDescriptor(n,e,t){var i,o=null!==(i=se(this.prototype,n))&&void 0!==i?i:{get(){return this[e]},set(n){this[e]=n}},r=o.get,a=o.set;return{get:r,set(e){var i=null==r?void 0:r.call(this);null!=a&&a.call(this,e),this.requestUpdate(n,i,t)},configurable:!0,enumerable:!0}}static getPropertyOptions(n){var e;return null!==(e=this.elementProperties.get(n))&&void 0!==e?e:me}static _$Ei(){if(!this.hasOwnProperty(ge("elementProperties"))){var n=de(this);n.finalize(),void 0!==n.l&&(this.l=[...n.l]),this.elementProperties=new Map(n.elementProperties)}}static finalize(){if(!this.hasOwnProperty(ge("finalized"))){if(this.finalized=!0,this._$Ei(),this.hasOwnProperty(ge("properties"))){var n=this.properties,e=[...le(n),...ce(n)];for(var t of e)this.createProperty(t,n[t])}var i=this[Symbol.metadata];if(null!==i){var o=litPropertyMetadata.get(i);if(void 0!==o)for(var r of o){var a=Yn(r,2),s=a[0],l=a[1];this.elementProperties.set(s,l)}}for(var c of(this._$Eh=new Map,this.elementProperties)){var d=Yn(c,2),h=d[0],p=d[1],u=this._$Eu(h,p);void 0!==u&&this._$Eh.set(u,h)}this.elementStyles=this.finalizeStyles(this.styles)}}static finalizeStyles(n){var e=[];if(Array.isArray(n)){var t=new Set(n.flat(1/0).reverse());for(var i of t)e.unshift(oe(i))}else void 0!==n&&e.push(oe(n));return e}static _$Eu(n,e){var t=e.attribute;return!1===t?void 0:"string"==typeof t?t:"string"==typeof n?n.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=!1,this.hasUpdated=!1,this._$Em=null,this._$Ev()}_$Ev(){var n;this._$ES=new Promise((n=>this.enableUpdating=n)),this._$AL=new Map,this._$E_(),this.requestUpdate(),null===(n=this.constructor.l)||void 0===n||n.forEach((n=>n(this)))}addController(n){var e,t;(null!==(e=this._$EO)&&void 0!==e?e:this._$EO=new Set).add(n),void 0!==this.renderRoot&&this.isConnected&&(null===(t=n.hostConnected)||void 0===t||t.call(n))}removeController(n){var e;null===(e=this._$EO)||void 0===e||e.delete(n)}_$E_(){var n=new Map,e=this.constructor.elementProperties;for(var t of e.keys())this.hasOwnProperty(t)&&(n.set(t,this[t]),delete this[t]);n.size>0&&(this._$Ep=n)}createRenderRoot(){var n,e=null!==(n=this.shadowRoot)&&void 0!==n?n:this.attachShadow(this.constructor.shadowRootOptions);return((n,e)=>{if(Qn)n.adoptedStyleSheets=e.map((n=>n instanceof CSSStyleSheet?n:n.styleSheet));else for(var t of e){var i=document.createElement("style"),o=Jn.litNonce;void 0!==o&&i.setAttribute("nonce",o),i.textContent=t.cssText,n.appendChild(i)}})(e,this.constructor.elementStyles),e}connectedCallback(){var n,e;null!==(n=this.renderRoot)&&void 0!==n||(this.renderRoot=this.createRenderRoot()),this.enableUpdating(!0),null===(e=this._$EO)||void 0===e||e.forEach((n=>{var e;return null===(e=n.hostConnected)||void 0===e?void 0:e.call(n)}))}enableUpdating(n){}disconnectedCallback(){var n;null===(n=this._$EO)||void 0===n||n.forEach((n=>{var e;return null===(e=n.hostDisconnected)||void 0===e?void 0:e.call(n)}))}attributeChangedCallback(n,e,t){this._$AK(n,t)}_$ET(n,e){var t=this.constructor.elementProperties.get(n),i=this.constructor._$Eu(n,t);if(void 0!==i&&!0===t.reflect){var o,r=(void 0!==(null===(o=t.converter)||void 0===o?void 0:o.toAttribute)?t.converter:_e).toAttribute(e,t.type);this._$Em=n,null==r?this.removeAttribute(i):this.setAttribute(i,r),this._$Em=null}}_$AK(n,e){var t=this.constructor,i=t._$Eh.get(n);if(void 0!==i&&this._$Em!==i){var o,r,a,s=t.getPropertyOptions(i),l="function"==typeof s.converter?{fromAttribute:s.converter}:void 0!==(null===(o=s.converter)||void 0===o?void 0:o.fromAttribute)?s.converter:_e;this._$Em=i;var c=l.fromAttribute(e,s.type);this[i]=null!==(r=null!=c?c:null===(a=this._$Ej)||void 0===a?void 0:a.get(i))&&void 0!==r?r:c,this._$Em=null}}requestUpdate(n,e,t){if(void 0!==n){var i,o,r=this.constructor,a=this[n];if(null!=t||(t=r.getPropertyOptions(n)),!((null!==(i=t.hasChanged)&&void 0!==i?i:fe)(a,e)||t.useDefault&&t.reflect&&a===(null===(o=this._$Ej)||void 0===o?void 0:o.get(n))&&!this.hasAttribute(r._$Eu(n,t))))return;this.C(n,e,t)}!1===this.isUpdatePending&&(this._$ES=this._$EP())}C(n,e,t,i){var o,r,a,s=t.useDefault,l=t.reflect,c=t.wrapped;s&&!(null!==(o=this._$Ej)&&void 0!==o?o:this._$Ej=new Map).has(n)&&(this._$Ej.set(n,null!==(r=null!=i?i:e)&&void 0!==r?r:this[n]),!0!==c||void 0!==i)||(this._$AL.has(n)||(this.hasUpdated||s||(e=void 0),this._$AL.set(n,e)),!0===l&&this._$Em!==n&&(null!==(a=this._$Eq)&&void 0!==a?a:this._$Eq=new Set).add(n))}_$EP(){var n=this;return Gn((function*(){n.isUpdatePending=!0;try{yield n._$ES}catch(e){Promise.reject(e)}var e=n.scheduleUpdate();return null!=e&&(yield e),!n.isUpdatePending}))()}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(this.isUpdatePending){if(!this.hasUpdated){var n;if(null!==(n=this.renderRoot)&&void 0!==n||(this.renderRoot=this.createRenderRoot()),this._$Ep){for(var e of this._$Ep){var t=Yn(e,2),i=t[0],o=t[1];this[i]=o}this._$Ep=void 0}var r=this.constructor.elementProperties;if(r.size>0)for(var a of r){var s=Yn(a,2),l=s[0],c=s[1],d=c.wrapped,h=this[l];!0!==d||this._$AL.has(l)||void 0===h||this.C(l,void 0,c,h)}}var p=!1,u=this._$AL;try{var v;(p=this.shouldUpdate(u))?(this.willUpdate(u),null!==(v=this._$EO)&&void 0!==v&&v.forEach((n=>{var e;return null===(e=n.hostUpdate)||void 0===e?void 0:e.call(n)})),this.update(u)):this._$EM()}catch(u){throw p=!1,this._$EM(),u}p&&this._$AE(u)}}willUpdate(n){}_$AE(n){var e;null!==(e=this._$EO)&&void 0!==e&&e.forEach((n=>{var e;return null===(e=n.hostUpdated)||void 0===e?void 0:e.call(n)})),this.hasUpdated||(this.hasUpdated=!0,this.firstUpdated(n)),this.updated(n)}_$EM(){this._$AL=new Map,this.isUpdatePending=!1}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(n){return!0}update(n){this._$Eq&&(this._$Eq=this._$Eq.forEach((n=>this._$ET(n,this[n])))),this._$EM()}updated(n){}firstUpdated(n){}};ye.elementStyles=[],ye.shadowRootOptions={mode:"open"},ye[ge("elementProperties")]=new Map,ye[ge("finalized")]=new Map,null!=ve&&ve({ReactiveElement:ye}),(null!==(t=he.reactiveElementVersions)&&void 0!==t?t:he.reactiveElementVersions=[]).push("2.1.1");var be=globalThis,we=be.trustedTypes,xe=we?we.createPolicy("lit-html",{createHTML:n=>n}):void 0,ke="$lit$",Ce="lit$".concat(Math.random().toFixed(9).slice(2),"$"),Ee="?"+Ce,Se="<".concat(Ee,">"),Ae=document,$e=()=>Ae.createComment(""),Le=n=>null===n||"object"!=typeof n&&"function"!=typeof n,Pe=Array.isArray,Me="[ \t\n\f\r]",ze=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,Ne=/-->/g,Oe=/>/g,Re=RegExp(">|".concat(Me,"(?:([^\\s\"'>=/]+)(").concat(Me,"*=").concat(Me,"*(?:[^ \t\n\f\r\"'`<>=]|(\"|')|))|$)"),"g"),He=/'/g,Te=/"/g,De=/^(?:script|style|textarea|title)$/i,Ie=(n=>function(e){for(var t=arguments.length,i=new Array(t>1?t-1:0),o=1;o{for(var t,i=n.length-1,o=[],r=2===e?"":3===e?"":"",a=ze,s=0;s"===d[0]?(a=null!=t?t:ze,h=-1):void 0===d[1]?h=-2:(h=a.lastIndex-d[2].length,c=d[1],a=void 0===d[3]?Re:'"'===d[3]?Te:He):a===Te||a===He?a=Re:a===Ne||a===Oe?a=ze:(a=Re,t=void 0);var u=a===Re&&n[s+1].startsWith("/>")?" ":"";r+=a===ze?l+Se:h>=0?(o.push(c),l.slice(0,h)+ke+l.slice(h)+Ce+u):l+Ce+(-2===h?s:u)}return[Be(n,r+(n[i]||"")+(2===e?"":3===e?"":"")),o]};class qe{constructor(n,e){var t,i=n.strings,o=n._$litType$;this.parts=[];var r=0,a=0,s=i.length-1,l=this.parts,c=Yn(We(i,o),2),d=c[0],h=c[1];if(this.el=qe.createElement(d,e),Fe.currentNode=this.el.content,2===o||3===o){var p=this.el.content.firstChild;p.replaceWith(...p.childNodes)}for(;null!==(t=Fe.nextNode())&&l.length0){t.textContent=we?we.emptyScript:"";for(var y=0;y2&&void 0!==arguments[2]?arguments[2]:n,l=arguments.length>3?arguments[3]:void 0;if(e===Ue)return e;var c=void 0!==l?null===(t=s._$Co)||void 0===t?void 0:t[l]:s._$Cl,d=Le(e)?void 0:e._$litDirective$;return(null===(i=c)||void 0===i?void 0:i.constructor)!==d&&(null!==(o=c)&&void 0!==o&&null!==(r=o._$AO)&&void 0!==r&&r.call(o,!1),void 0===d?c=void 0:(c=new d(n))._$AT(n,s,l),void 0!==l?(null!==(a=s._$Co)&&void 0!==a?a:s._$Co=[])[l]=c:s._$Cl=c),void 0!==c&&(e=Ze(n,c._$AS(n,e.values),c,l)),e}class Ke{constructor(n,e){this._$AV=[],this._$AN=void 0,this._$AD=n,this._$AM=e}get parentNode(){return this._$AM.parentNode}get _$AU(){return this._$AM._$AU}u(n){var e,t=this._$AD,i=t.el.content,o=t.parts,r=(null!==(e=null==n?void 0:n.creationScope)&&void 0!==e?e:Ae).importNode(i,!0);Fe.currentNode=r;for(var a=Fe.nextNode(),s=0,l=0,c=o[0];void 0!==c;){var d;if(s===c.index){var h=void 0;2===c.type?h=new Ge(a,a.nextSibling,this,n):1===c.type?h=new c.ctor(a,c.name,c.strings,this,n):6===c.type&&(h=new nt(a,this,n)),this._$AV.push(h),c=o[++l]}s!==(null===(d=c)||void 0===d?void 0:d.index)&&(a=Fe.nextNode(),s++)}return Fe.currentNode=Ae,r}p(n){var e=0;for(var t of this._$AV)void 0!==t&&(void 0!==t.strings?(t._$AI(n,t,e),e+=t.strings.length-2):t._$AI(n[e])),e++}}class Ge{get _$AU(){var n,e;return null!==(n=null===(e=this._$AM)||void 0===e?void 0:e._$AU)&&void 0!==n?n:this._$Cv}constructor(n,e,t,i){var o;this.type=2,this._$AH=je,this._$AN=void 0,this._$AA=n,this._$AB=e,this._$AM=t,this.options=i,this._$Cv=null===(o=null==i?void 0:i.isConnected)||void 0===o||o}get parentNode(){var n,e=this._$AA.parentNode,t=this._$AM;return void 0!==t&&11===(null===(n=e)||void 0===n?void 0:n.nodeType)&&(e=t.parentNode),e}get startNode(){return this._$AA}get endNode(){return this._$AB}_$AI(n){n=Ze(this,n,arguments.length>1&&void 0!==arguments[1]?arguments[1]:this),Le(n)?n===je||null==n||""===n?(this._$AH!==je&&this._$AR(),this._$AH=je):n!==this._$AH&&n!==Ue&&this._(n):void 0!==n._$litType$?this.$(n):void 0!==n.nodeType?this.T(n):(n=>Pe(n)||"function"==typeof(null==n?void 0:n[Symbol.iterator]))(n)?this.k(n):this._(n)}O(n){return this._$AA.parentNode.insertBefore(n,this._$AB)}T(n){this._$AH!==n&&(this._$AR(),this._$AH=this.O(n))}_(n){this._$AH!==je&&Le(this._$AH)?this._$AA.nextSibling.data=n:this.T(Ae.createTextNode(n)),this._$AH=n}$(n){var e,t=n.values,i=n._$litType$,o="number"==typeof i?this._$AC(n):(void 0===i.el&&(i.el=qe.createElement(Be(i.h,i.h[0]),this.options)),i);if((null===(e=this._$AH)||void 0===e?void 0:e._$AD)===o)this._$AH.p(t);else{var r=new Ke(o,this),a=r.u(this.options);r.p(t),this.T(a),this._$AH=r}}_$AC(n){var e=Ve.get(n.strings);return void 0===e&&Ve.set(n.strings,e=new qe(n)),e}k(n){Pe(this._$AH)||(this._$AH=[],this._$AR());var e,t=this._$AH,i=0;for(var o of n)i===t.length?t.push(e=new Ge(this.O($e()),this.O($e()),this,this.options)):e=t[i],e._$AI(o),i++;i0&&void 0!==arguments[0]?arguments[0]:this._$AA.nextSibling,e=arguments.length>1?arguments[1]:void 0;for(null===(t=this._$AP)||void 0===t||t.call(this,!1,!0,e);n!==this._$AB;){var t,i=n.nextSibling;n.remove(),n=i}}setConnected(n){var e;void 0===this._$AM&&(this._$Cv=n,null===(e=this._$AP)||void 0===e||e.call(this,n))}}class Ye{get tagName(){return this.element.tagName}get _$AU(){return this._$AM._$AU}constructor(n,e,t,i,o){this.type=1,this._$AH=je,this._$AN=void 0,this.element=n,this.name=e,this._$AM=i,this.options=o,t.length>2||""!==t[0]||""!==t[1]?(this._$AH=Array(t.length-1).fill(new String),this.strings=t):this._$AH=je}_$AI(n){var e=arguments.length>1&&void 0!==arguments[1]?arguments[1]:this,t=arguments.length>2?arguments[2]:void 0,i=arguments.length>3?arguments[3]:void 0,o=this.strings,r=!1;if(void 0===o)n=Ze(this,n,e,0),(r=!Le(n)||n!==this._$AH&&n!==Ue)&&(this._$AH=n);else{var a,s,l=n;for(n=o[0],a=0;a1&&void 0!==arguments[1]?arguments[1]:this,0))&&void 0!==e?e:je)!==Ue){var t=this._$AH,i=n===je&&t!==je||n.capture!==t.capture||n.once!==t.once||n.passive!==t.passive,o=n!==je&&(t===je||i);i&&this.element.removeEventListener(this.name,this,t),o&&this.element.addEventListener(this.name,this,n),this._$AH=n}}handleEvent(n){var e,t;"function"==typeof this._$AH?this._$AH.call(null!==(e=null===(t=this.options)||void 0===t?void 0:t.host)&&void 0!==e?e:this.element,n):this._$AH.handleEvent(n)}}class nt{constructor(n,e,t){this.element=n,this.type=6,this._$AN=void 0,this._$AM=e,this.options=t}get _$AU(){return this._$AM._$AU}_$AI(n){Ze(this,n)}}var et=be.litHtmlPolyfillSupport;null!=et&&et(qe,Ge),(null!==(i=be.litHtmlVersions)&&void 0!==i?i:be.litHtmlVersions=[]).push("3.3.3");var tt=globalThis,it=class extends ye{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0}createRenderRoot(){var n,e,t=super.createRenderRoot();return null!==(e=(n=this.renderOptions).renderBefore)&&void 0!==e||(n.renderBefore=t.firstChild),t}update(n){var e=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(n),this._$Do=((n,e,t)=>{var i,o=null!==(i=null==t?void 0:t.renderBefore)&&void 0!==i?i:e,r=o._$litPart$;if(void 0===r){var a,s=null!==(a=null==t?void 0:t.renderBefore)&&void 0!==a?a:null;o._$litPart$=r=new Ge(e.insertBefore($e(),s),s,void 0,null!=t?t:{})}return r._$AI(n),r})(e,this.renderRoot,this.renderOptions)}connectedCallback(){var n;super.connectedCallback(),null===(n=this._$Do)||void 0===n||n.setConnected(!0)}disconnectedCallback(){var n;super.disconnectedCallback(),null===(n=this._$Do)||void 0===n||n.setConnected(!1)}render(){return Ue}};it._$litElement$=!0,it.finalized=!0,null===(o=tt.litElementHydrateSupport)||void 0===o||o.call(tt,{LitElement:it});var ot=tt.litElementPolyfillSupport;null==ot||ot({LitElement:it}),(null!==(r=tt.litElementVersions)&&void 0!==r?r:tt.litElementVersions=[]).push("4.2.1");var rt="code",at="pin_used",st="active",lt="in_sync",ct=["calendar","condition_entity"],dt={type:"divider"},ht="masked_with_reveal",pt=!0,ut=!0,vt=!0,gt=!0,_t=!0;function ft(n){var e,t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:"-",i="àáâäæãåāăąçćčđďèéêëēėęěğǵḧîïíīįìıİłḿñńǹňôöòóœøōõőṕŕřßśšşșťțûüùúūǘůűųẃẍÿýžźż·",o="aaaaaaaaaacccddeeeeeeeegghiiiiiiiilmnnnnoooooooooprrsssssttuuuuuuuuuwxyyzzz".concat(t),r=new RegExp(i.split("").join("|"),"g");return""===n?e="":(e=n.toString().toLowerCase().replace(r,(n=>o.charAt(i.indexOf(n)))).replace(/(\d),(?=\d)/g,"$1").replace(/[^a-z0-9]+/g,t).replace(new RegExp("(".concat(t,")\\1+"),"g"),"$1").replace(new RegExp("^".concat(t,"+")),"").replace(new RegExp("".concat(t,"+$")),""),""===e&&(e="unknown")),e}function mt(n){return{cards:[{content:n,type:"markdown"}],title:arguments.length>1&&void 0!==arguments[1]?arguments[1]:"Lock Code Manager"}}function yt(n,e,t,i){var o,r=arguments.length,a=r<3?e:null===i?i=Object.getOwnPropertyDescriptor(e,t):i;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)a=Reflect.decorate(n,e,t,i);else for(var s=n.length-1;s>=0;s--)(o=n[s])&&(a=(r<3?o(a):r>3?o(e,t,a):o(e,t))||a);return r>3&&a&&Object.defineProperty(e,t,a),a}"function"==typeof SuppressedError&&SuppressedError;var bt="M21,7L9,19L3.5,13.5L4.91,12.09L9,16.17L19.59,5.59L21,7Z",wt="M12,20A8,8 0 0,0 20,12A8,8 0 0,0 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20M12,2A10,10 0 0,1 22,12A10,10 0 0,1 12,22C6.47,22 2,17.5 2,12A10,10 0 0,1 12,2M12.5,7V12.25L17,14.92L16.25,16.15L11,13V7H12.5Z",xt="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z",kt="M12,9A3,3 0 0,0 9,12A3,3 0 0,0 12,15A3,3 0 0,0 15,12A3,3 0 0,0 12,9M12,17A5,5 0 0,1 7,12A5,5 0 0,1 12,7A5,5 0 0,1 17,12A5,5 0 0,1 12,17M12,4.5C7,4.5 2.73,7.61 1,12C2.73,16.39 7,19.5 12,19.5C17,19.5 21.27,16.39 23,12C21.27,7.61 17,4.5 12,4.5Z",Ct="M11.83,9L15,12.16C15,12.11 15,12.05 15,12A3,3 0 0,0 12,9C11.94,9 11.89,9 11.83,9M7.53,9.8L9.08,11.35C9.03,11.56 9,11.77 9,12A3,3 0 0,0 12,15C12.22,15 12.44,14.97 12.65,14.92L14.2,16.47C13.53,16.8 12.79,17 12,17A5,5 0 0,1 7,12C7,11.21 7.2,10.47 7.53,9.8M2,4.27L4.28,6.55L4.73,7C3.08,8.3 1.78,10 1,12C2.73,16.39 7,19.5 12,19.5C13.55,19.5 15.03,19.2 16.38,18.66L16.81,19.08L19.73,22L21,20.73L3.27,3M12,7A5,5 0 0,1 17,12C17,12.64 16.87,13.26 16.64,13.82L19.57,16.75C21.07,15.5 22.27,13.86 23,12C21.27,7.61 17,4.5 12,4.5C10.6,4.5 9.26,4.75 8,5.2L10.17,7.35C10.74,7.13 11.35,7 12,7Z",Et={attribute:!0,type:String,converter:_e,reflect:!1,hasChanged:fe},St=function(){var n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:Et,e=arguments.length>1?arguments[1]:void 0,t=arguments.length>2?arguments[2]:void 0,i=t.kind,o=t.metadata,r=globalThis.litPropertyMetadata.get(o);if(void 0===r&&globalThis.litPropertyMetadata.set(o,r=new Map),"setter"===i&&((n=Object.create(n)).wrapped=!0),r.set(t.name,n),"accessor"===i){var a=t.name;return{set(t){var i=e.get.call(this);e.set.call(this,t),this.requestUpdate(a,i,n)},init(e){return void 0!==e&&this.C(a,void 0,n,e),e}}}if("setter"===i){var s=t.name;return function(t){var i=this[s];e.call(this,t),this.requestUpdate(s,i,n)}}throw Error("Unsupported decorator location: "+i)};function At(n){return(e,t)=>"object"==typeof t?St(n,e,t):((n,e,t)=>{var i=e.hasOwnProperty(t);return e.constructor.createProperty(t,n),i?Object.getOwnPropertyDescriptor(e,t):void 0})(n,e,t)}function $t(n){return At(qn(qn({},n),{},{state:!0,attribute:!1}))}var Lt=n=>null!=n?n:je,Pt=ie(a||(a=Bn(["\n :host {\n /* Section backgrounds */\n --lcm-section-bg: rgba(var(--rgb-primary-text-color), 0.03);\n --lcm-section-bg-hover: rgba(var(--rgb-primary-text-color), 0.06);\n\n /* Active states */\n --lcm-active-bg: rgba(var(--rgb-primary-color), 0.1);\n --lcm-active-bg-gradient: linear-gradient(\n 135deg,\n rgba(var(--rgb-primary-color), 0.1),\n rgba(var(--rgb-primary-color), 0.04)\n );\n\n /* Status colors */\n --lcm-success-color: var(--success-color, #4caf50);\n --lcm-warning-color: var(--warning-color, #ff9800);\n --lcm-error-color: var(--error-color, #f44336);\n --lcm-disabled-color: var(--disabled-text-color, #9e9e9e);\n\n /* Badge styling */\n --lcm-badge-radius: 999px;\n --lcm-badge-font-size: 10px;\n --lcm-badge-font-weight: 600;\n --lcm-badge-letter-spacing: 0.02em;\n --lcm-badge-padding: 2px 6px;\n\n /* Section header typography */\n --lcm-section-header-size: 11px;\n --lcm-section-header-weight: 600;\n --lcm-section-header-spacing: 0.05em;\n\n /* Code/PIN typography */\n --lcm-code-font: 'Roboto Mono', monospace;\n --lcm-code-font-size: 16px;\n --lcm-code-font-weight: 600;\n --lcm-code-letter-spacing: 1px;\n\n /* Border colors */\n --lcm-border-color: rgba(var(--rgb-primary-text-color), 0.06);\n --lcm-border-color-strong: rgba(var(--rgb-primary-text-color), 0.12);\n }\n"]))),Mt=ie(s||(s=Bn(["\n /* Base badge — used by identity tags (Managed/Unmanaged/Empty).\n Compact uppercase for category labels. */\n .lcm-badge {\n border-radius: var(--lcm-badge-radius);\n font-size: var(--lcm-badge-font-size);\n font-weight: var(--lcm-badge-font-weight);\n letter-spacing: var(--lcm-badge-letter-spacing);\n padding: var(--lcm-badge-padding);\n text-transform: uppercase;\n }\n\n /* State badges (active/inactive/disabled) align visually with the slot\n card's .state-chip: pill shape, sentence-case, optional colored dot\n prefix, 16% color tint. Same colors as the slot card so a state reads\n the same regardless of which card you're on. */\n .lcm-badge.active,\n .lcm-badge.inactive,\n .lcm-badge.disabled {\n align-items: center;\n border-radius: 12px;\n display: inline-flex;\n font-size: 10px;\n font-weight: 600;\n gap: 5px;\n letter-spacing: normal;\n padding: 3px 8px;\n text-transform: none;\n }\n .lcm-badge .dot {\n border-radius: 50%;\n flex-shrink: 0;\n height: 5px;\n width: 5px;\n }\n\n .lcm-badge.active {\n background: rgba(var(--rgb-success-color, 67, 160, 71), 0.16);\n color: var(--success-color, #43a047);\n }\n .lcm-badge.active .dot {\n background: var(--success-color, #43a047);\n }\n\n .lcm-badge.inactive {\n background: rgba(var(--rgb-warning-color, 255, 167, 38), 0.16);\n color: var(--warning-color, #ffa726);\n }\n .lcm-badge.inactive .dot {\n background: var(--warning-color, #ffa726);\n }\n\n .lcm-badge.disabled {\n background: rgba(var(--rgb-disabled-color, 117, 117, 117), 0.2);\n color: var(--secondary-text-color);\n }\n .lcm-badge.disabled .dot {\n background: var(--disabled-color, #757575);\n }\n\n .lcm-badge.empty {\n background: rgba(var(--rgb-primary-text-color), 0.08);\n color: var(--secondary-text-color);\n }\n\n .lcm-badge.managed {\n background: rgba(var(--rgb-primary-color), 0.16);\n color: var(--primary-color);\n }\n\n .lcm-badge.external {\n background: rgba(var(--rgb-primary-text-color), 0.08);\n color: var(--secondary-text-color);\n }\n"]))),zt=ie(l||(l=Bn(["\n .lcm-sync-icon {\n --mdc-icon-size: 18px;\n }\n\n .lcm-sync-icon.synced {\n color: var(--lcm-success-color);\n }\n\n .lcm-sync-icon.pending {\n color: var(--lcm-warning-color);\n }\n\n .lcm-sync-icon.syncing {\n color: var(--lcm-warning-color);\n }\n\n .lcm-sync-icon.suspended {\n color: var(--lcm-error-color);\n }\n\n .lcm-sync-icon.unknown {\n color: var(--lcm-disabled-color);\n }\n"]))),Nt=ie(c||(c=Bn(["\n .lcm-code {\n color: var(--primary-text-color);\n font-family: var(--lcm-code-font);\n font-size: var(--lcm-code-font-size);\n font-weight: var(--lcm-code-font-weight);\n letter-spacing: var(--lcm-code-letter-spacing);\n }\n\n .lcm-code.masked {\n color: var(--secondary-text-color);\n }\n\n /* Slot disabled by user — PIN exists in config but is intentionally not on\n the lock. Heavily dimmed dots in a muted pill, no strikethrough. */\n .lcm-code.off {\n background: var(--lcm-section-bg, rgba(127, 127, 127, 0.05));\n border-radius: 6px;\n color: var(--disabled-text-color);\n padding: 2px 8px;\n }\n\n /* Slot enabled but lock doesn't have the code yet (out-of-sync, syncing, etc.).\n Dim dots with a clock-icon prefix. No strikethrough. */\n .lcm-code.pending {\n align-items: center;\n color: var(--secondary-text-color);\n display: inline-flex;\n gap: 4px;\n }\n\n .lcm-code.pending .lcm-code-pending-icon {\n --mdc-icon-size: 12px;\n color: var(--secondary-text-color);\n flex-shrink: 0;\n }\n\n .lcm-code.no-code {\n color: var(--disabled-text-color);\n font-family: inherit;\n font-size: 12px;\n font-style: italic;\n font-weight: 400;\n letter-spacing: normal;\n }\n"]))),Ot=ie(d||(d=Bn(["\n .lcm-section {\n background: var(--lcm-section-bg);\n border-radius: 12px;\n padding: 16px;\n }\n\n .lcm-section-header {\n color: var(--secondary-text-color);\n font-size: var(--lcm-section-header-size);\n font-weight: var(--lcm-section-header-weight);\n letter-spacing: var(--lcm-section-header-spacing);\n margin-bottom: 12px;\n text-transform: uppercase;\n }\n"]))),Rt=ie(h||(h=Bn(["\n .lcm-reveal-button {\n /* 32px hit target — bumped from 28px to be a comfortable middle\n between the WCAG 2.2 SC 2.5.8 AA minimum (24px) and the\n SC 2.5.5 AAA recommendation (44px). */\n --mdc-icon-button-size: 32px;\n --mdc-icon-size: 16px;\n color: var(--secondary-text-color);\n }\n"]))),Ht=ie(p||(p=Bn(['\n .collapsible-section {\n background: var(--lcm-section-bg);\n border-radius: 12px;\n overflow: hidden;\n }\n\n .collapsible-header {\n align-items: center;\n cursor: pointer;\n display: flex;\n justify-content: space-between;\n padding: 12px 16px;\n user-select: none;\n }\n\n .collapsible-header:hover {\n background: var(--lcm-section-bg-hover);\n }\n\n .collapsible-title {\n align-items: center;\n color: var(--secondary-text-color);\n display: flex;\n font-size: var(--lcm-section-header-size);\n font-weight: var(--lcm-section-header-weight);\n gap: 8px;\n letter-spacing: var(--lcm-section-header-spacing);\n text-transform: uppercase;\n }\n\n .collapsible-badge {\n align-items: center;\n background: var(--lcm-active-bg);\n border-radius: 10px;\n color: var(--primary-color);\n display: inline-flex;\n font-size: var(--lcm-badge-font-size);\n gap: 4px;\n padding: 2px 8px;\n }\n\n /* Icon prefix on a collapsible badge — sized down to 12px so it pairs\n with the 10px badge text without dominating it. Color inherits from\n the badge color so success/warning modifiers carry through. */\n .collapsible-badge-icon {\n --mdc-icon-size: 12px;\n color: inherit;\n flex-shrink: 0;\n }\n\n .collapsible-badge.primary {\n background: var(--primary-color);\n color: var(--text-primary-color, #fff);\n }\n\n .collapsible-badge.warning {\n background: var(--warning-color, #ffa600);\n color: var(--text-primary-color, #fff);\n }\n\n /* Success modifier — used for the "allowing" condition summary so that\n allowing reads as green and blocking reads as warning everywhere\n across the cards. 16% follows the canonical chip/badge opacity stop. */\n .collapsible-badge.success {\n background: rgba(var(--rgb-success-color, 67, 160, 71), 0.16);\n color: var(--success-color, #43a047);\n }\n\n .collapsible-badge.muted {\n background: var(--lcm-section-bg);\n color: var(--secondary-text-color);\n }\n\n .collapsible-chevron {\n --mdc-icon-size: 20px;\n color: var(--secondary-text-color);\n transition: transform 0.2s ease;\n }\n\n .collapsible-content {\n max-height: 0;\n opacity: 0;\n overflow: hidden;\n padding: 0 16px;\n transition:\n max-height 0.3s ease,\n opacity 0.2s ease,\n padding 0.3s ease;\n }\n\n .collapsible-content.expanded {\n /* 1000px ceiling — was 500px, which clipped when many helpers +\n a calendar entity row stacked. A grid-rows transition to auto\n is the proper fix but more invasive; bumping the ceiling is\n the lower-risk shim. */\n max-height: 1000px;\n opacity: 1;\n padding: 0 16px 16px;\n }\n']))),Tt=ie(u||(u=Bn(["\n .editable {\n border-radius: 4px;\n cursor: pointer;\n margin: -4px -8px;\n padding: 4px 8px;\n text-decoration: underline dashed;\n text-decoration-color: var(--secondary-text-color);\n text-underline-offset: 3px;\n transition: background-color 0.2s;\n }\n\n .editable:hover {\n background: var(--lcm-active-bg);\n }\n\n .edit-input {\n background: var(--card-background-color, #fff);\n border: 1px solid var(--primary-color);\n border-radius: 4px;\n color: var(--primary-text-color);\n font-family: inherit;\n font-size: inherit;\n outline: none;\n padding: 4px 8px;\n width: 100%;\n }\n\n .edit-input:focus {\n box-shadow: 0 0 0 1px var(--primary-color);\n }\n\n .edit-help {\n color: var(--secondary-text-color);\n font-size: var(--lcm-section-header-size);\n margin-top: 4px;\n }\n"]))),Dt=ie(v||(v=Bn(["\n .visually-hidden {\n border: 0;\n clip: rect(0 0 0 0);\n height: 1px;\n margin: -1px;\n overflow: hidden;\n padding: 0;\n position: absolute;\n width: 1px;\n }\n"]))),It=ie(g||(g=Bn(["\n @media (prefers-reduced-motion: reduce) {\n .collapsible-content,\n .collapsible-chevron,\n .slot-chip.clickable,\n .editable,\n .hero-name-value.editable,\n .hero-pin-value.editable,\n .lcm-code.editable,\n .event-row {\n transition: none !important;\n }\n }\n"])));ie(_||(_=Bn(["\n ","\n ","\n ","\n ","\n ","\n ","\n ","\n ","\n ","\n ","\n"])),Pt,Mt,zt,Nt,Ot,Rt,Ht,Tt,Dt,It);var Ut=[Pt,Mt,Nt,Rt,Dt,It,ie(f||(f=Bn(['\n :host {\n display: block;\n }\n\n ha-card {\n padding: 0;\n }\n\n .card-header {\n align-items: center;\n border-bottom: 1px solid var(--lcm-border-color);\n display: flex;\n gap: 12px;\n padding: 16px;\n }\n\n .header-icon {\n align-items: center;\n background: var(--lcm-active-bg);\n border-radius: 50%;\n color: var(--primary-color);\n display: flex;\n height: 40px;\n justify-content: center;\n width: 40px;\n }\n\n .header-icon ha-icon {\n --mdc-icon-size: 24px;\n }\n\n .card-header-title {\n color: var(--primary-text-color);\n font-size: 18px;\n font-weight: 500;\n margin: 0;\n }\n\n .card-content {\n padding: 16px;\n }\n\n .slots-grid {\n display: grid;\n gap: 10px;\n grid-template-columns: repeat(2, 1fr);\n }\n\n @media (max-width: 400px) {\n .slots-grid {\n grid-template-columns: 1fr;\n }\n }\n\n .slot-chip {\n background: var(--lcm-section-bg);\n border-radius: 12px;\n display: flex;\n flex-direction: column;\n gap: 6px;\n min-width: 0;\n overflow: hidden;\n padding: 12px 12px 14px;\n position: relative;\n }\n\n /* Active Lock Code Manager Managed: no special tint (inherits the\n chip\'s --lcm-section-bg). The state badge inside the chip carries\n the "active" signal — color the exception, not the norm. */\n\n /* Active Unmanaged (not Lock Code Manager): subtle warm-gray accent\n so an occupied unmanaged slot reads as different from an Empty\n chip (which uses the same --lcm-section-bg at 3%). 5% sits between\n the empty 3% and the inactive/disabled 6% tints. */\n .slot-chip.active.unmanaged {\n background: rgba(var(--rgb-primary-text-color), 0.05);\n }\n\n /* Inactive Lock Code Manager Managed: warning tint (orange) to match\n the slot card\'s inactive treatment — communicates "blocked by\n conditions" without shouting. 6% follows the canonical background\n opacity stop. */\n .slot-chip.inactive.managed {\n background: rgba(var(--rgb-warning-color, 255, 167, 38), 0.06);\n opacity: 0.9;\n }\n\n /* Disabled Lock Code Manager Managed: muted neutral tint, clear\n disabled state. 6% follows the canonical background opacity stop. */\n .slot-chip.disabled.managed {\n background: rgba(var(--rgb-primary-text-color), 0.06);\n opacity: 0.65;\n }\n\n .slot-chip.empty {\n background: var(--lcm-section-bg);\n opacity: 0.7;\n }\n\n .slot-chip.full-width {\n grid-column: 1 / -1;\n justify-self: center;\n max-width: 360px;\n width: 100%;\n }\n\n .slot-chip.clickable {\n cursor: pointer;\n transition:\n transform 0.1s ease,\n box-shadow 0.2s ease;\n }\n\n .slot-chip.clickable:hover {\n box-shadow: 0 2px 8px rgba(var(--rgb-primary-color), 0.25);\n transform: translateY(-1px);\n }\n\n .slot-chip.clickable:active {\n transform: translateY(0);\n }\n\n .slot-chip.clickable:focus-visible {\n outline: 2px solid var(--primary-color);\n outline-offset: 2px;\n }\n\n .slot-top {\n /* Align both children to the top so the slot label sits in a stable\n position regardless of how tall the badge stack grows. With\n align-items: center, a 2-badge stack would push the label down\n to sit between the badges; flex-start keeps it aligned with the\n first (top) badge — matching the empty-chip layout where the\n label and the single Empty badge share one row. */\n align-items: flex-start;\n display: flex;\n gap: 12px;\n justify-content: space-between;\n }\n\n .slot-badges {\n align-items: flex-end;\n display: inline-flex;\n flex-direction: column;\n flex-shrink: 0;\n gap: 4px;\n }\n\n .slot-label {\n color: var(--secondary-text-color);\n flex: 1;\n font-size: var(--lcm-section-header-size);\n font-weight: 500;\n letter-spacing: 0.03em;\n min-width: 0;\n overflow: hidden;\n text-overflow: ellipsis;\n text-transform: uppercase;\n white-space: nowrap;\n }\n\n /* Config entry title appended to "Slot N · {entry}" — matches the slot-card kicker style. */\n .slot-entry-title {\n color: var(--secondary-text-color);\n font-weight: 500;\n }\n\n /* Two stacked rows per chip: name on top, PIN on the bottom. The chip is\n narrow enough that competing for one row truncated long names too\n aggressively (e.g. "Raman" → "R."). Stacking gives the name the full\n chip width and lets the PIN row anchor the eye icon to the right.\n This intentionally diverges from the slot-card hero, which uses a\n prominent single-line name; in the lock card the name is a label, not\n the focal point. */\n .slot-content-row {\n display: flex;\n flex-direction: column;\n gap: 4px;\n margin-top: 6px;\n min-width: 0;\n }\n\n .slot-name {\n color: var(--primary-text-color);\n font-size: 14px;\n font-weight: 400;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n }\n\n .slot-name.unnamed {\n color: var(--secondary-text-color);\n font-weight: 400;\n }\n\n /* Wrapper around the slot name; lays out the optional pending icon\n alongside the name with a small gap. Inline-flex so the row only takes\n the space it needs and the chip\'s column layout still controls width. */\n .slot-name-row {\n align-items: center;\n display: inline-flex;\n gap: 4px;\n max-width: 100%;\n min-width: 0;\n }\n\n /* Slot disabled by user — name shown in a muted pill, no strikethrough.\n Mirrors the .lcm-code.off treatment for visual consistency. */\n .slot-chip.disabled .slot-name {\n background: var(--lcm-section-bg, rgba(127, 127, 127, 0.05));\n border-radius: 6px;\n color: var(--disabled-text-color);\n padding: 2px 8px;\n }\n\n /* Slot enabled but lock doesn\'t have the code yet — clock-icon prefix on\n the name. Mirrors the .lcm-code.pending treatment. The disabled rule\'s\n pill background takes precedence if a slot is somehow both. */\n .slot-chip.pending .slot-name-pending-icon {\n --mdc-icon-size: 12px;\n color: var(--secondary-text-color);\n flex-shrink: 0;\n }\n\n .slot-chip.pending .slot-name {\n color: var(--secondary-text-color);\n }\n\n .slot-code-row {\n align-items: center;\n display: flex;\n gap: 8px;\n justify-content: space-between;\n }\n\n .slot-code-actions {\n display: inline-flex;\n }\n\n /* Editable code for unmanaged slots */\n .slot-code-edit {\n display: flex;\n flex-direction: column;\n gap: 4px;\n width: 100%;\n }\n\n .slot-code-edit-row {\n align-items: center;\n display: flex;\n gap: 8px;\n }\n\n .slot-code-input {\n background: var(--card-background-color, #fff);\n border: 1px solid var(--primary-color);\n border-radius: 6px;\n color: var(--primary-text-color);\n flex: 1;\n font-family: var(--lcm-code-font);\n font-size: 14px;\n font-weight: 500;\n letter-spacing: var(--lcm-code-letter-spacing);\n min-width: 0;\n outline: none;\n padding: 6px 10px;\n }\n\n .slot-code-input:focus {\n box-shadow: 0 0 0 1px var(--primary-color);\n }\n\n .slot-code-input::placeholder {\n color: var(--secondary-text-color);\n font-weight: 400;\n letter-spacing: normal;\n }\n\n .slot-code-edit-buttons {\n display: flex;\n gap: 4px;\n }\n\n .slot-code-edit-buttons ha-icon-button {\n --mdc-icon-button-size: 32px;\n --mdc-icon-size: 18px;\n }\n\n .slot-edit-help {\n color: var(--secondary-text-color);\n font-size: 10px;\n }\n\n /* Editable code display (click to edit) */\n .lcm-code.editable {\n border-radius: 4px;\n cursor: pointer;\n margin: -2px -4px;\n padding: 2px 4px;\n transition: background-color 0.2s;\n }\n\n .lcm-code.editable:hover {\n background: var(--lcm-active-bg);\n }\n\n .empty-summary {\n align-items: center;\n background: var(--lcm-section-bg);\n border: 1px dashed var(--lcm-border-color-strong);\n border-radius: 10px;\n color: var(--secondary-text-color);\n display: flex;\n font-size: 12px;\n gap: 8px;\n grid-column: 1 / -1;\n padding: 8px 12px;\n }\n\n .empty-summary ha-icon {\n --mdc-icon-size: 16px;\n color: var(--secondary-text-color);\n }\n\n .empty-summary-label {\n color: var(--secondary-text-color);\n font-size: var(--lcm-section-header-size);\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n }\n\n .empty-summary-range {\n color: var(--primary-text-color);\n font-size: 13px;\n font-weight: 500;\n }\n\n .message {\n color: var(--secondary-text-color);\n font-style: italic;\n }\n\n /* Summary table */\n .summary-table {\n border-collapse: collapse;\n font-size: 12px;\n margin-top: 16px;\n table-layout: fixed;\n width: 100%;\n }\n\n .summary-table th,\n .summary-table td {\n overflow: hidden;\n padding: 6px 4px;\n text-align: center;\n text-overflow: ellipsis;\n }\n\n .summary-table th {\n background: rgba(var(--rgb-primary-text-color), 0.04);\n color: var(--secondary-text-color);\n font-size: 10px;\n font-weight: 600;\n letter-spacing: 0.04em;\n text-transform: uppercase;\n }\n\n .summary-table th:first-child {\n border-radius: 6px 0 0 0;\n text-align: left;\n }\n\n .summary-table th:last-child {\n border-radius: 0 6px 0 0;\n }\n\n .summary-table td,\n .summary-table tbody th {\n border-top: 1px solid var(--lcm-border-color);\n color: var(--primary-text-color);\n font-weight: 500;\n }\n\n .summary-table tbody th[scope=\'row\'] {\n color: var(--secondary-text-color);\n font-size: var(--lcm-section-header-size);\n font-weight: 600;\n letter-spacing: 0.03em;\n text-align: left;\n text-transform: uppercase;\n }\n\n .summary-table tbody tr:last-child th[scope=\'row\'] {\n border-radius: 0 0 0 6px;\n }\n\n .summary-table tbody tr:last-child td:last-child {\n border-radius: 0 0 6px 0;\n }\n\n .summary-table .total-row td,\n .summary-table .total-row th {\n background: rgba(var(--rgb-primary-text-color), 0.02);\n border-top: 2px solid var(--lcm-border-color-strong);\n font-weight: 600;\n }\n\n .summary-cell-zero {\n color: var(--disabled-text-color) !important;\n font-weight: 400 !important;\n }\n\n ha-card.suspended {\n border: 1px solid var(--lcm-error-color);\n opacity: 0.85;\n }\n\n .suspended-banner {\n align-items: center;\n background: rgba(244, 67, 54, 0.08);\n border-bottom: 1px solid var(--lcm-border-color);\n color: var(--lcm-error-color);\n display: flex;\n font-size: 12px;\n font-weight: 500;\n gap: 8px;\n padding: 8px 16px;\n }\n\n .suspended-banner ha-icon {\n --mdc-icon-size: 16px;\n }\n'])))];function jt(n){class e extends n{constructor(){super(...arguments),this._revealed=!1,this._isStub=!1,this._subscribing=!1}connectedCallback(){super.connectedCallback(),this._subscribe()}disconnectedCallback(){super.disconnectedCallback(),this._unsubscribe()}_formatSubscriptionError(n){return n instanceof Error?n.message:"object"==typeof n&&null!==n&&"message"in n?String(n.message):"Failed to subscribe: ".concat(JSON.stringify(n))}_shouldReveal(){var n,e,t=null!==(n=null===(e=this._config)||void 0===e?void 0:e.code_display)&&void 0!==n?n:this._getDefaultCodeDisplay();return"unmasked"===t||"masked_with_reveal"===t&&this._revealed}_subscribe(){var n=this;return Gn((function*(){var e;if(!n._isStub&&n._hass&&n._config&&!n._unsub&&!n._subscribing)if(null!==(e=n._hass.connection)&&void 0!==e&&e.subscribeMessage){n._subscribing=!0;try{var t=n._buildSubscribeMessage();n._unsub=yield n._hass.connection.subscribeMessage((e=>{n._handleSubscriptionData(e),n._error=void 0,n.requestUpdate()}),t)}catch(e){n._data=void 0,n._error=n._formatSubscriptionError(e),n.requestUpdate()}finally{n._subscribing=!1}}else n._error="Websocket connection unavailable"}))()}_toggleReveal(){this._revealed=!this._revealed,this._unsubscribe(),this._subscribe()}_unsubscribe(){this._unsub&&(this._unsub(),this._unsub=void 0)}}return yt([$t()],e.prototype,"_revealed",void 0),e}var Vt="empty",Ft="unreadable_code";function Bt(n){return null===n||""===n||n===Vt}function Wt(n,e){return n===Ft||(!Bt(n)||!!e)}var qt="masked_with_reveal",Zt=jt(it);class Kt extends Zt{constructor(){super(...arguments),this._editingSlot=null,this._editValue="",this._saving=!1,this._wasRevealedBeforeEdit=!1}set hass(n){this._hass=n,this._subscribe()}static getConfigElement(){return document.createElement("lcm-lock-codes-editor")}static getStubConfig(n){return Gn((function*(){var e={lock_entity_id:"lock.stub",type:"custom:lcm-lock-codes"};try{return yield Promise.race([Gn((function*(){var t=yield n.callWS({domain:"lock_code_manager",type:"config_entries/get"});if(t.length>0){var i=yield n.callWS({config_entry_id:t[0].entry_id,type:"lock_code_manager/get_config_entry_data"});if(i.locks.length>0)return{lock_entity_id:i.locks[0].entity_id,type:"custom:lcm-lock-codes"}}return e}))(),new Promise((n=>setTimeout((()=>n(e)),2e3)))])}catch(n){return e}}))()}setConfig(n){var e;if(!n.lock_entity_id)throw new Error("lock_entity_id is required");null!==(e=this._config)&&void 0!==e&&e.lock_entity_id&&this._config.lock_entity_id!==n.lock_entity_id&&(this._unsubscribe(),this._data=void 0),this._config=n,this._isStub="lock.stub"===n.lock_entity_id,this._isStub||this._subscribe()}_getDefaultCodeDisplay(){return qt}_buildSubscribeMessage(){if(!this._config)throw new Error("Config not set");return{lock_entity_id:this._config.lock_entity_id,reveal:this._shouldReveal(),type:"lock_code_manager/subscribe_lock_codes"}}_handleSubscriptionData(n){this._data=n}render(){var n,e,t,i,o,r,a,s,l,c,d,h;if(this._isStub)return Ie(m||(m=Bn(['\n
\n \n

Lock Code Manager Lock Codes

\n
\n
'])));var p=null===(n=this._hass)||void 0===n||null===(n=n.states[null!==(e=null===(t=this._config)||void 0===t?void 0:t.lock_entity_id)&&void 0!==e?e:""])||void 0===n||null===(n=n.attributes)||void 0===n?void 0:n.friendly_name,u=null!==(i=null!==(o=null!==(r=null===(a=this._data)||void 0===a?void 0:a.lock_name)&&void 0!==r?r:p)&&void 0!==o?o:null===(s=this._config)||void 0===s?void 0:s.lock_entity_id)&&void 0!==i?i:"",v=null!==(l=null!==(c=null===(d=this._config)||void 0===d?void 0:d.title)&&void 0!==c?c:u)&&void 0!==l?l:"Lock Codes",g="suspended"===(null===(h=this._data)||void 0===h?void 0:h.sync_status);return Ie(y||(y=Bn(['\n \n
\n \n

',"

\n
\n ",'\n
\n ',"\n ","\n
\n
\n "])),g?"suspended":"",v,g?Ie(b||(b=Bn(['
\n \n Sync suspended — lock is unreachable\n
']))):je,this._error?Ie(w||(w=Bn(['
',"
"])),this._error):this._renderSlots(),this._renderSummaryTable())}_startEditing(n,e){n.stopPropagation(),this._wasRevealedBeforeEdit=this._revealed,this._revealed||(this._revealed=!0,this._unsubscribe(),this._subscribe());var t=Wt(e.code)&&e.code!==Ft?String(e.code):"";this._editValue=t,this._editingSlot=e.slot,this.updateComplete.then((()=>{var n,e=null===(n=this.shadowRoot)||void 0===n?void 0:n.querySelector(".slot-code-input");null==e||e.focus(),null==e||e.select()}))}_handleEditInput(n){var e=n.target;this._editValue=e.value}_handleEditKeydown(n){"Enter"===n.key&&null!==this._editingSlot?this._saveCode(this._editingSlot):"Escape"===n.key&&this._cancelEdit()}_cancelEdit(){this._editingSlot=null,this._editValue="",this._revealed!==this._wasRevealedBeforeEdit&&(this._revealed=this._wasRevealedBeforeEdit,this._unsubscribe(),this._subscribe())}_saveCode(n){var e=this;return Gn((function*(){if(e._hass&&e._config&&!e._saving){e._saving=!0;var t=e._editValue.trim();try{var i="string"==typeof n?parseInt(n,10):n;t?yield e._hass.connection.sendMessagePromise({code_slot:i,lock_entity_id:e._config.lock_entity_id,type:"lock_code_manager/set_usercode",usercode:t}):yield e._hass.connection.sendMessagePromise({code_slot:i,lock_entity_id:e._config.lock_entity_id,type:"lock_code_manager/clear_usercode"}),e._editingSlot=null,e._editValue=""}catch(n){console.error("Failed to set usercode:",n)}finally{e._saving=!1}}}))()}_navigateToSlot(n){if(n){var e="/config/integrations/integration/lock_code_manager#config_entry=".concat(n);history.pushState(null,"",e),window.dispatchEvent(new CustomEvent("location-changed"))}}_renderSlots(){var n,e,t=null!==(n=null===(e=this._data)||void 0===e?void 0:e.slots)&&void 0!==n?n:[];if(0===t.length)return Ie(x||(x=Bn(['
No codes reported
'])));var i=this._groupSlots(t),o=this._identifyBorrowedSlots(i),r=this._renderGroupsWithBorrowing(i,o);return Ie(k||(k=Bn(['
',"
"])),r)}_identifyBorrowedSlots(n){for(var e=new Set,t=0;t0?n[t-1]:null,s=t0?e.add(s.slots[0].slot):!r&&"empty"===(null==a?void 0:a.type)&&a.slots.length>0&&e.add(a.slots[a.slots.length-1].slot)}}return e}_renderGroupsWithBorrowing(n,e){for(var t=[],i=0;i0?n[i-1]:null,a=i0?(t.push(this._renderSlotChip(s,!1)),t.push(this._renderEmptySlotChip(a.slots[0]))):!l&&"empty"===(null==r?void 0:r.type)&&r.slots.length>0?(t.push(this._renderEmptySlotChip(r.slots[r.slots.length-1])),t.push(this._renderSlotChip(s,!1))):t.push(this._renderSlotChip(s,!0))}else for(var c of o.slots)t.push(this._renderSlotChip(c,!1));else{var d=o.slots.filter((n=>!e.has(n.slot)));d.length>0&&t.push(this._renderEmptySummary(qn(qn({},o),{},{rangeLabel:this._formatSlotRange(d),slots:d})))}}return t}_renderEmptySlotChip(n){return Ie(C||(C=Bn(['\n
\n
\n Slot ','\n
\n Empty\n
\n
\n
\n '])),n.slot)}_groupSlots(n){var e=[],t=[],i=[],o=()=>{t.length>0&&(e.push({rangeLabel:this._formatSlotRange(t),slots:t,type:"empty"}),t=[])},r=()=>{i.length>0&&(e.push({slots:i,type:"active"}),i=[])};for(var a of n){var s=!(!a.configured_code&&!a.configured_code_length),l=!0===a.managed&&(void 0!==a.enabled||void 0!==a.active);this._hasCode(a)||!0===a.managed&&(s||l)?(o(),i.push(a)):(r(),t.push(a))}return r(),o(),e}_formatSlotRange(n){if(0===n.length)return"";if(1===n.length)return"".concat(n[0].slot);var e=n.map((n=>Number(n.slot))).filter((n=>!isNaN(n)));if(e.length!==n.length)return"".concat(n.map((n=>n.slot)).join(", "));for(var t=[],i=Yn(e,1)[0],o=i,r=i,a=1;a\n
\n \n Slot\n ',"",'\n \n
\n \n ',"\n ","\n \n ",'\n
\n
\n
\n ',"\n ","\n
\n \n "])),r,y,f,m,e?"full-width":"",Lt(v?"Click to manage this slot":void 0),Lt(v?"button":void 0),Lt(v?"0":void 0),v?"Manage slot ".concat(n.slot).concat(n.config_entry_title?" · ".concat(n.config_entry_title):""):je,v?()=>this._navigateToSlot(n.config_entry_id):je,v?e=>{"Enter"!==e.key&&" "!==e.key||(e.preventDefault(),this._navigateToSlot(n.config_entry_id))}:je,n.slot,n.config_entry_title?Ie(S||(S=Bn([' ·\n ',""])),n.config_entry_title):je,s,"active"===s||"inactive"===s||"disabled"===s?Ie(A||(A=Bn(['']))):je,a,void 0===d?je:Ie($||($=Bn(['\n ',"\n "])),d?"managed":"external",d?"Managed":"Unmanaged"),u?Ie(L||(L=Bn(['\n ','\n \n ',"\n \n "])),_?Ie(P||(P=Bn(['