From 311f289d27808e9836b3a25f71224afa9e4061cc Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:55:20 -0400 Subject: [PATCH 1/7] feat(phase2): coordinator verified-credential flag + WriteResult enum (steps 1-2) First two behavior-neutral steps of the push-as-commit / verified-credential lifecycle (docs/phase2-push-as-commit-spec.md), closing the residual silent-failure window left open by PR #1258. Step 1 - coordinator verified state: - LockUsercodeUpdateCoordinator gains a parallel ``_verified: dict[int, bool]`` kept in lockstep with ``data``, plus ``is_verified(slot)`` (absent -> True). - ``push_update`` takes ``optimistic: bool = False``; the default marks slots verified (every existing caller unchanged), ``optimistic=True`` marks them unverified. A verified-flag-only change updates the map without re-notifying. Step 2 - WriteResult enum: - ``async_set_credential`` / ``async_set_usercode`` / ``_set_credential`` return ``WriteResult`` (NO_CHANGE / CONFIRMED / OPTIMISTIC) instead of ``bool``. - All seven providers migrated to CONFIRMED/NO_CHANGE (pure rename; no behavior change yet -- OPTIMISTIC is wired in a later step). - ``async_internal_set_usercode`` consumes ``result.changed`` for the refresh decision. Tests/mocks updated to the enum. Nothing reads ``is_verified`` or returns ``OPTIMISTIC`` yet, so behavior is identical; the lifecycle switch lands in the state-machine step. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 18c469f4c1f4 --- .../lock_code_manager/domain/coordinator.py | 47 +++++++++++++++- .../lock_code_manager/domain/credentials.py | 29 ++++++++++ .../lock_code_manager/providers/_base.py | 30 +++++----- .../providers/_zwave_js_uc.py | 9 ++- .../lock_code_manager/providers/akuvox.py | 12 +++- .../lock_code_manager/providers/matter.py | 5 +- .../lock_code_manager/providers/schlage.py | 12 +++- .../lock_code_manager/providers/virtual.py | 14 +++-- .../lock_code_manager/providers/zha.py | 12 +++- .../providers/zigbee2mqtt.py | 12 +++- .../lock_code_manager/providers/zwave_js.py | 7 ++- tests/common.py | 9 +-- tests/providers/akuvox/test_e2e.py | 5 +- tests/providers/akuvox/test_provider.py | 5 +- tests/providers/matter/test_e2e.py | 3 +- tests/providers/matter/test_provider.py | 5 +- tests/providers/schlage/test_e2e.py | 3 +- tests/providers/schlage/test_provider.py | 9 +-- tests/providers/test_seam.py | 13 +++-- tests/providers/virtual/test_provider.py | 9 +-- tests/providers/zha/test_provider.py | 3 +- tests/providers/zigbee2mqtt/test_provider.py | 3 +- tests/providers/zwave_js/test_e2e.py | 3 +- tests/providers/zwave_js/test_provider.py | 7 ++- tests/providers/zwave_js/test_uc_fallback.py | 13 +++-- tests/test_coordinator.py | 55 +++++++++++++++++++ tests/test_sync.py | 5 +- 27 files changed, 258 insertions(+), 81 deletions(-) diff --git a/custom_components/lock_code_manager/domain/coordinator.py b/custom_components/lock_code_manager/domain/coordinator.py index 7b87b0782..3300f286a 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,48 @@ 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 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 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 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/providers/_base.py b/custom_components/lock_code_manager/providers/_base.py index dca7e81d2..0875558c7 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, ) @@ -854,17 +855,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 +910,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, @@ -923,7 +922,7 @@ def _pre_execute_checks() -> None: # 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: @@ -1223,7 +1222,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 +1366,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 +1383,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..44a37849c 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,7 +317,9 @@ 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. @@ -339,7 +342,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 +381,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..d8ec5c183 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. @@ -487,13 +488,13 @@ async def async_set_credential( credential.slot, err, ) - return True + return WriteResult.CONFIRMED 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: """ diff --git a/tests/common.py b/tests/common.py index a1224c942..9a757d1e3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -19,6 +19,7 @@ CONF_SLOTS, DOMAIN, ) +from custom_components.lock_code_manager.domain.credentials import WriteResult from custom_components.lock_code_manager.domain.models import SlotCredential from custom_components.lock_code_manager.providers import BaseLock @@ -95,17 +96,17 @@ 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. - Returns True if the value was changed, False if already set. + Returns CONFIRMED if the value was changed, NO_CHANGE if already set. """ if self.codes.get(code_slot) == usercode: - return False + return WriteResult.NO_CHANGE self.codes[code_slot] = usercode self.service_calls["set_usercode"].append((code_slot, usercode, name)) - return True + return WriteResult.CONFIRMED async def async_clear_usercode(self, code_slot: int) -> bool: """ diff --git a/tests/providers/akuvox/test_e2e.py b/tests/providers/akuvox/test_e2e.py index 134bbdd5f..e1c7b87b5 100644 --- a/tests/providers/akuvox/test_e2e.py +++ b/tests/providers/akuvox/test_e2e.py @@ -12,6 +12,7 @@ from custom_components.lock_code_manager.domain.credentials import ( CredentialRef, CredentialType, + WriteResult, credential_from_slot, ) from custom_components.lock_code_manager.domain.models import SlotCredential @@ -66,7 +67,7 @@ async def test_set_credential_new( source="direct", ) - assert result is True + assert result is WriteResult.CONFIRMED assert akuvox_mock_services["add_user"].call_count >= 1 async def test_set_credential_existing( @@ -100,7 +101,7 @@ async def test_set_credential_existing( source="direct", ) - assert result is True + assert result is WriteResult.CONFIRMED assert akuvox_mock_services["modify_user"].call_count >= 1 async def test_delete_credential( diff --git a/tests/providers/akuvox/test_provider.py b/tests/providers/akuvox/test_provider.py index ca1778090..d249a877c 100644 --- a/tests/providers/akuvox/test_provider.py +++ b/tests/providers/akuvox/test_provider.py @@ -17,6 +17,7 @@ Credential, CredentialRef, CredentialType, + WriteResult, credential_from_slot, ) from custom_components.lock_code_manager.domain.exceptions import ( @@ -376,7 +377,7 @@ async def _capture_modify(call): source="direct", ) - assert result is True + assert result is WriteResult.CONFIRMED assert modify_calls[0]["name"] == "lcm:1:Guest" async def test_set_credential_migrates_legacy_format_tag_on_write( @@ -420,7 +421,7 @@ async def _capture_modify(call): source="direct", ) - assert result is True + assert result is WriteResult.CONFIRMED # Friendly portion preserved verbatim; only the format changed. assert modify_calls[0]["name"] == "lcm:1:Guest" diff --git a/tests/providers/matter/test_e2e.py b/tests/providers/matter/test_e2e.py index c812442a0..6b8b288d6 100644 --- a/tests/providers/matter/test_e2e.py +++ b/tests/providers/matter/test_e2e.py @@ -9,6 +9,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from custom_components.lock_code_manager.domain.credentials import WriteResult from custom_components.lock_code_manager.domain.models import SlotCredential from custom_components.lock_code_manager.providers.matter import MatterLock @@ -68,7 +69,7 @@ async def test_set_usercode( ): result = await e2e_matter_lock.async_set_usercode(4, "5678", "Test User") - assert result is True + assert result is WriteResult.CONFIRMED assert matter_mock_helpers["set_lock_credential"].call_count >= 1 async def test_clear_usercode( diff --git a/tests/providers/matter/test_provider.py b/tests/providers/matter/test_provider.py index 2a450919c..beb2b40a9 100644 --- a/tests/providers/matter/test_provider.py +++ b/tests/providers/matter/test_provider.py @@ -24,6 +24,7 @@ LockCapabilities, SetUserResult, User, + WriteResult, ) from custom_components.lock_code_manager.domain.exceptions import ( CodeRejectedError, @@ -2912,7 +2913,7 @@ async def test_set_credential_success_returns_true( name="Alice", source="direct", ) - assert result is True + assert result is WriteResult.CONFIRMED async def test_set_credential_pushes_unreadable_optimistically( self, hass: HomeAssistant, matter_lock_simple: MatterLock @@ -3017,7 +3018,7 @@ async def test_set_credential_duplicate_sync_retries_and_succeeds( 1, credential, "1234", name=None, source="sync" ) - assert result is True + assert result is WriteResult.CONFIRMED assert mock_set_credential.call_count == 2 assert mock_clear.call_count == 1 # First call uses the existing credential_index (MODIFY); retry uses diff --git a/tests/providers/schlage/test_e2e.py b/tests/providers/schlage/test_e2e.py index 75f22ceea..d4df1b4a6 100644 --- a/tests/providers/schlage/test_e2e.py +++ b/tests/providers/schlage/test_e2e.py @@ -12,6 +12,7 @@ from custom_components.lock_code_manager.domain.credentials import ( CredentialRef, CredentialType, + WriteResult, credential_from_slot, ) from custom_components.lock_code_manager.domain.models import SlotCredential @@ -64,7 +65,7 @@ async def test_set_credential( source="direct", ) - assert result is True + assert result is WriteResult.CONFIRMED assert schlage_mock_services["add_code"].call_count >= 1 add_call = schlage_mock_services["add_code"].call_args[0][0] assert add_call.data["name"] == "lcm:1:Test User" diff --git a/tests/providers/schlage/test_provider.py b/tests/providers/schlage/test_provider.py index 1dc04c665..7a5cc9c09 100644 --- a/tests/providers/schlage/test_provider.py +++ b/tests/providers/schlage/test_provider.py @@ -16,6 +16,7 @@ Credential, CredentialRef, CredentialType, + WriteResult, credential_from_slot, ) from custom_components.lock_code_manager.domain.exceptions import ( @@ -539,7 +540,7 @@ async def test_set_credential_replaces_existing( source="direct", ) - assert result is True + assert result is WriteResult.CONFIRMED # add_code called with new tagged name add_call = add_handler.call_args[0][0] assert add_call.data["name"] == "lcm:1:New Name" @@ -584,7 +585,7 @@ async def test_set_credential_preserves_existing_name( source="direct", ) - assert result is True + assert result is WriteResult.CONFIRMED # Name unchanged so existing_full_name == tagged_name, deduplicated to 1 delete assert delete_handler.call_count == 1 delete_call = delete_handler.call_args[0][0] @@ -632,7 +633,7 @@ async def test_set_credential_migrates_legacy_format_tag_on_write( source="direct", ) - assert result is True + assert result is WriteResult.CONFIRMED add_call = add_handler.call_args[0][0] # The new value is written under the canonical tag, NOT the legacy tag. assert add_call.data["name"] == "lcm:1:Guest" @@ -673,7 +674,7 @@ async def test_set_credential_already_exists_treated_as_success( source="direct", ) - assert result is True + assert result is WriteResult.CONFIRMED async def test_set_credential_non_exists_error_still_raises( diff --git a/tests/providers/test_seam.py b/tests/providers/test_seam.py index 0aec08f82..e49e23e39 100644 --- a/tests/providers/test_seam.py +++ b/tests/providers/test_seam.py @@ -21,6 +21,7 @@ LockCapabilities, SetUserResult, User, + WriteResult, credential_from_slot, user_from_slot, ) @@ -83,7 +84,7 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: self.calls.append(("set_credential", user_id, credential.slot)) self.last_set_credential = { "user_id": user_id, @@ -93,7 +94,7 @@ async def async_set_credential( "source": source, } self._users[user_id].credentials = [credential] - return True + return WriteResult.CONFIRMED async def async_delete_credential(self, ref: CredentialRef) -> bool: self.calls.append(("delete_credential", ref.user_id, ref.slot)) @@ -144,10 +145,10 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: self.calls.append(("set_credential", user_id, credential.slot)) self._slots[credential.slot] = credential.state - return True + return WriteResult.CONFIRMED async def async_delete_credential(self, ref: CredentialRef) -> bool: self.calls.append(("delete_credential", ref.user_id, ref.slot)) @@ -673,7 +674,7 @@ async def test_set_usercode_native_user_first_and_threads_id( """Native set creates the user first, then its credential, threading the id.""" lock = _make_lock(hass, _NativeStubLock, "seam_set_native") changed = await lock.async_set_usercode(3, "9999", name="alice") - assert changed is True + assert changed is WriteResult.CONFIRMED assert lock.calls == [ ("set_user", 3, "lcm:3:alice"), ("set_credential", 3, 3), @@ -685,7 +686,7 @@ async def test_set_usercode_degenerate_skips_user(hass: HomeAssistant) -> None: """Slot-only set writes the credential directly, no user operation.""" lock = _make_lock(hass, _DegenerateStubLock, "seam_set_degen") changed = await lock.async_set_usercode(3, "9999") - assert changed is True + assert changed is WriteResult.CONFIRMED assert lock.calls == [("set_credential", 3, 3)] assert lock._slots[3].matches("9999") diff --git a/tests/providers/virtual/test_provider.py b/tests/providers/virtual/test_provider.py index 2443708a7..6d115a700 100644 --- a/tests/providers/virtual/test_provider.py +++ b/tests/providers/virtual/test_provider.py @@ -5,6 +5,7 @@ CredentialRef, CredentialType, User, + WriteResult, credential_from_slot, user_from_slot, ) @@ -27,7 +28,7 @@ def _make_credential(slot: int, pin: str) -> Credential: name="test", source="direct", ) - assert changed is True + assert changed is WriteResult.CONFIRMED assert lock._data["1"] == {"code": "1234", "name": "test"} # Setting the same value should return False (no change) @@ -38,7 +39,7 @@ def _make_credential(slot: int, pin: str) -> Credential: name="test", source="direct", ) - assert changed is False + assert changed is WriteResult.NO_CHANGE # Changing the code should return True changed = await lock.async_set_credential( @@ -48,7 +49,7 @@ def _make_credential(slot: int, pin: str) -> Credential: name="test", source="direct", ) - assert changed is True + assert changed is WriteResult.CONFIRMED assert lock._data["1"] == {"code": "5678", "name": "test"} # Changing the name should return True @@ -59,7 +60,7 @@ def _make_credential(slot: int, pin: str) -> Credential: name="new_name", source="direct", ) - assert changed is True + assert changed is WriteResult.CONFIRMED assert lock._data["1"] == {"code": "5678", "name": "new_name"} diff --git a/tests/providers/zha/test_provider.py b/tests/providers/zha/test_provider.py index 0e6d68a41..4f3aca322 100644 --- a/tests/providers/zha/test_provider.py +++ b/tests/providers/zha/test_provider.py @@ -15,6 +15,7 @@ from custom_components.lock_code_manager.domain.credentials import ( CredentialRef, CredentialType, + WriteResult, credential_from_slot, ) from custom_components.lock_code_manager.domain.exceptions import ( @@ -171,7 +172,7 @@ async def test_set_credential( 3, credential, "5678", name="Test User", source="direct" ) - assert result is True + assert result is WriteResult.CONFIRMED cluster.set_pin_code.assert_called_once_with( 3, DoorLock.UserStatus.Enabled, diff --git a/tests/providers/zigbee2mqtt/test_provider.py b/tests/providers/zigbee2mqtt/test_provider.py index d4acaa791..c1b5093a0 100644 --- a/tests/providers/zigbee2mqtt/test_provider.py +++ b/tests/providers/zigbee2mqtt/test_provider.py @@ -16,6 +16,7 @@ from custom_components.lock_code_manager.domain.credentials import ( CredentialRef, CredentialType, + WriteResult, credential_from_slot, ) from custom_components.lock_code_manager.domain.exceptions import ( @@ -606,7 +607,7 @@ async def test_async_set_credential_without_coordinator_still_true( name=None, source="direct", ) - is True + is WriteResult.CONFIRMED ) async def test_async_set_credential_publish_oserror_raises_lock_disconnected( diff --git a/tests/providers/zwave_js/test_e2e.py b/tests/providers/zwave_js/test_e2e.py index 1023d0e4f..255bc00db 100644 --- a/tests/providers/zwave_js/test_e2e.py +++ b/tests/providers/zwave_js/test_e2e.py @@ -24,6 +24,7 @@ DOMAIN, EVENT_LOCK_STATE_CHANGED, ) +from custom_components.lock_code_manager.domain.credentials import WriteResult from custom_components.lock_code_manager.domain.models import SlotCredential from custom_components.lock_code_manager.providers.zwave_js import ZWaveJSLock @@ -99,7 +100,7 @@ async def test_set_usercode_calls_lock_helpers( result = await zwave_js_lock.async_set_usercode(4, "5678", "Test User") - assert result is True + assert result is WriteResult.CONFIRMED mock_lock_helpers["async_set_user"].assert_called_once() mock_lock_helpers["async_set_credential"].assert_called_once() diff --git a/tests/providers/zwave_js/test_provider.py b/tests/providers/zwave_js/test_provider.py index 466bd5355..97a4fc82f 100644 --- a/tests/providers/zwave_js/test_provider.py +++ b/tests/providers/zwave_js/test_provider.py @@ -35,6 +35,7 @@ LockCapabilities, SetUserResult, User, + WriteResult, ) from custom_components.lock_code_manager.domain.exceptions import ( CodeRejectedError, @@ -851,7 +852,7 @@ async def test_async_set_credential_returns_true_on_success( source="sync", ) - assert result is True + assert result is WriteResult.CONFIRMED mock_lock_helpers["async_set_credential"].assert_called_once_with( zwave_js_lock.node, 1, @@ -953,7 +954,7 @@ async def test_async_set_credential_tolerates_error_unknown_as_completed_set( source="sync", ) - assert result is True + assert result is WriteResult.CONFIRMED async def test_async_set_credential_maps_failed_command_to_lock_disconnected( @@ -1144,7 +1145,7 @@ async def test_set_usercode_user_code_cc_skips_set_user_and_writes_credential_on changed = await zwave_js_lock.async_set_usercode(5, "9999", name="alice") - assert changed is True + assert changed is WriteResult.CONFIRMED mock_lock_helpers["async_set_user"].assert_not_called() mock_lock_helpers["async_set_credential"].assert_called_once() diff --git a/tests/providers/zwave_js/test_uc_fallback.py b/tests/providers/zwave_js/test_uc_fallback.py index b175a0cf7..e354d4c0a 100644 --- a/tests/providers/zwave_js/test_uc_fallback.py +++ b/tests/providers/zwave_js/test_uc_fallback.py @@ -38,6 +38,7 @@ Credential, CredentialRef, CredentialType, + WriteResult, ) from custom_components.lock_code_manager.domain.exceptions import ( CodeRejectedError, @@ -172,7 +173,7 @@ async def test_uc_set_credential_uses_user_code_cc_util( user_id=5, credential=credential, pin="4321", name=None, source="sync" ) - assert result is True + assert result is WriteResult.CONFIRMED mock_uc_utils["set_usercode"].assert_awaited_once_with( uc_fallback_lock.node, 5, "4321" ) @@ -200,7 +201,7 @@ async def test_uc_set_credential_skips_when_code_matches( user_id=5, credential=credential, pin="4321", name=None, source="sync" ) - assert result is False + assert result is WriteResult.NO_CHANGE mock_uc_utils["set_usercode"].assert_not_called() @@ -224,7 +225,7 @@ async def test_uc_set_credential_proceeds_when_masked( user_id=5, credential=credential, pin="4321", name=None, source="sync" ) - assert result is True + assert result is WriteResult.CONFIRMED mock_uc_utils["set_usercode"].assert_awaited_once() @@ -258,7 +259,7 @@ async def test_uc_set_fail_status_logs_and_continues( user_id=5, credential=credential, pin="4321", name=None, source="sync" ) - assert result is True + assert result is WriteResult.CONFIRMED mock_coordinator.push_update.assert_called_once_with( {5: SlotCredential.known("4321")} ) @@ -853,7 +854,7 @@ async def test_setup_succeeds_and_writes_route_through_uc_utils( # Setting a code skips the user lifecycle and writes via set_usercode result = await lock.async_set_usercode(4, "5678", "Test User") - assert result is True + assert result is WriteResult.CONFIRMED mock_lock_helpers["async_set_user"].assert_not_called() mock_uc_utils["set_usercode"].assert_awaited_once_with(lock.node, 4, "5678") mock_access_control.set_credential.assert_not_called() @@ -1118,7 +1119,7 @@ async def test_uc_v1_set_verify_failure_is_non_fatal( user_id=5, credential=credential, pin="4321", name=None, source="sync" ) - assert result is True + assert result is WriteResult.CONFIRMED mock_coordinator.push_update.assert_called_once_with( {5: SlotCredential.known("4321")} ) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py index df638d044..e56b7901e 100644 --- a/tests/test_coordinator.py +++ b/tests/test_coordinator.py @@ -199,6 +199,61 @@ async def test_push_update_bulk_updates( assert push_coordinator.data[3] == SlotCredential.empty() # Cleared +async def test_is_verified_defaults_true_for_absent_slot( + push_coordinator: LockUsercodeUpdateCoordinator, +): + """A slot with no recorded verified flag reads as verified.""" + assert push_coordinator.is_verified(7) is True + + +async def test_push_update_default_marks_verified( + push_coordinator: LockUsercodeUpdateCoordinator, +): + """The default (optimistic=False) push marks the slot verified.""" + push_coordinator.push_update({1: SlotCredential.known("9999")}) + assert push_coordinator.is_verified(1) is True + + +async def test_push_update_optimistic_marks_unverified( + push_coordinator: LockUsercodeUpdateCoordinator, +): + """An optimistic push marks the slot unverified.""" + push_coordinator.push_update({1: SlotCredential.known("9999")}, optimistic=True) + assert push_coordinator.data[1] == SlotCredential.known("9999") + assert push_coordinator.is_verified(1) is False + + +async def test_push_update_verified_push_clears_unverified( + push_coordinator: LockUsercodeUpdateCoordinator, +): + """A later verified push of a new value re-verifies the slot.""" + push_coordinator.push_update({1: SlotCredential.known("9999")}, optimistic=True) + assert push_coordinator.is_verified(1) is False + + push_coordinator.push_update({1: SlotCredential.unreadable()}) + assert push_coordinator.is_verified(1) is True + + +async def test_push_update_optimistic_same_value_flips_flag_without_notifying( + push_coordinator: LockUsercodeUpdateCoordinator, +): + """An optimistic re-push of the same value flips the flag but doesn't notify.""" + push_coordinator.data = {1: SlotCredential.known("9999")} + with patch.object(push_coordinator, "async_set_updated_data") as mock_set_updated: + push_coordinator.push_update({1: SlotCredential.known("9999")}, optimistic=True) + mock_set_updated.assert_not_called() + assert push_coordinator.is_verified(1) is False + + +async def test_verified_map_pruned_with_data( + push_coordinator: LockUsercodeUpdateCoordinator, +): + """The verified map drops slots no longer present in data.""" + push_coordinator.push_update({1: SlotCredential.known("1111")}, optimistic=True) + # The internal map should only track slots still in data. + assert set(push_coordinator._verified) <= set(push_coordinator.data) + + async def test_push_update_ignores_empty_updates( push_coordinator: LockUsercodeUpdateCoordinator, ): diff --git a/tests/test_sync.py b/tests/test_sync.py index de3edfe41..e07c2335c 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -21,6 +21,7 @@ DOMAIN, MAX_SYNC_ATTEMPTS, ) +from custom_components.lock_code_manager.domain.credentials import WriteResult from custom_components.lock_code_manager.domain.exceptions import ( CodeRejectedError, LockDisconnected, @@ -1514,7 +1515,9 @@ async def test_tick_records_failure_on_set_verification_miss( # branch must then record a failure. with ( patch.object( - lock_provider, "async_set_usercode", AsyncMock(return_value=None) + lock_provider, + "async_set_usercode", + AsyncMock(return_value=WriteResult.CONFIRMED), ), patch.object( manager._coordinator, "async_refresh", AsyncMock(return_value=None) From 5a4c40fbe4ae3d940f93465f53ce6fff07b95792 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:01:11 -0400 Subject: [PATCH 2/7] feat(phase2): base optimistic-write tracking + confirmation helpers (step 3) Adds the BaseLock plumbing for the verified-credential lifecycle (dormant until a provider returns WriteResult.OPTIMISTIC in a later step): - _pending_writes: slot -> (believed_pin, monotonic deadline); PENDING_WRITE_TTL=60s. - _push_credential_update(..., optimistic=) threads the flag (kwarg only passed when optimistic, so verified pushes keep their call shape). - _record_optimistic_write / _confirm_slot / _expire_pending_writes. - The seam records an optimistic write when async_set_credential returns OPTIMISTIC (no provider does yet, so behavior is unchanged). Direct unit tests cover record/confirm/expire. Full suite green (1259). Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 0a470d2b2493 --- .../lock_code_manager/providers/_base.py | 83 ++++++++++++++++- tests/providers/test_seam.py | 91 ++++++++++++++++++- 2 files changed, 169 insertions(+), 5 deletions(-) diff --git a/custom_components/lock_code_manager/providers/_base.py b/custom_components/lock_code_manager/providers/_base.py index 0875558c7..f91dac3e5 100644 --- a/custom_components/lock_code_manager/providers/_base.py +++ b/custom_components/lock_code_manager/providers/_base.py @@ -69,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", @@ -206,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 @@ -369,12 +383,70 @@ 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. 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 + self._push_credential_update(code_slot, SlotCredential.known(pin)) + return + self._push_credential_update(code_slot, observed) + + @callback + def _expire_pending_writes(self) -> None: + """Drop optimistic writes whose confirmation deadline has passed.""" + now = time.monotonic() + expired = [ + slot + for slot, (_pin, deadline) in self._pending_writes.items() + if deadline <= now + ] + for slot in expired: + del self._pending_writes[slot] + @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.""" @@ -919,6 +991,11 @@ 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. A push event or hard refresh confirms it via + # _confirm_slot; otherwise the sync tick re-syncs after the TTL. + self._record_optimistic_write(code_slot, str(usercode)) # 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. diff --git a/tests/providers/test_seam.py b/tests/providers/test_seam.py index e49e23e39..b3fb35961 100644 --- a/tests/providers/test_seam.py +++ b/tests/providers/test_seam.py @@ -3,8 +3,9 @@ from __future__ import annotations from dataclasses import replace +import time from typing import Literal -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry @@ -930,7 +931,7 @@ async def async_set_credential( *, name: str | None, source: Literal["sync", "direct"], - ) -> bool: + ) -> WriteResult: self.calls.append(("set_credential", user_id, credential.slot)) raise CodeRejectedError( code_slot=credential.slot, lock_entity_id=self.lock.entity_id @@ -1021,3 +1022,89 @@ async def async_delete_user(self, user_id: int) -> None: assert ("delete_user", 3) in lock.calls assert "failed to roll back newly created user 3" in caplog.text + + +# --------------------------------------------------------------------------- +# Phase 2: optimistic-write pending tracking + confirmation (base helpers) +# --------------------------------------------------------------------------- + + +def _slot_only_lock_with_coordinator(hass: HomeAssistant): + """Build a _DegenerateStubLock with a mock coordinator that records pushes.""" + lock = _make_lock(hass, _DegenerateStubLock, "seam_optimistic") + coord = MagicMock() + coord.data = {} + pushed: list[tuple[dict, bool]] = [] + + def _push(updates, *, optimistic=False): + coord.data = {**coord.data, **updates} + pushed.append((dict(updates), optimistic)) + + coord.push_update.side_effect = _push + lock.coordinator = coord + return lock, pushed + + +async def test_record_optimistic_write_pushes_unverified_and_tracks_pending( + hass: HomeAssistant, +) -> None: + """An optimistic write pushes the believed value unverified and records pending.""" + lock, pushed = _slot_only_lock_with_coordinator(hass) + lock._record_optimistic_write(4, "1234") + + assert pushed == [({4: SlotCredential.known("1234")}, True)] + assert 4 in lock._pending_writes + assert lock._pending_writes[4][0] == "1234" + + +async def test_confirm_slot_keeps_believed_value_on_present_observation( + hass: HomeAssistant, +) -> None: + """A present (even masked) observation confirms a pending write as verified.""" + lock, pushed = _slot_only_lock_with_coordinator(hass) + lock._record_optimistic_write(4, "1234") + pushed.clear() + + # The lock reports the slot present but unreadable (masked) -- still confirms. + lock._confirm_slot(4, SlotCredential.unreadable()) + + assert pushed == [({4: SlotCredential.known("1234")}, False)] + assert 4 not in lock._pending_writes + + +async def test_confirm_slot_takes_observation_when_no_pending( + hass: HomeAssistant, +) -> None: + """With no pending write, the observation is taken verbatim as verified.""" + lock, pushed = _slot_only_lock_with_coordinator(hass) + lock._confirm_slot(2, SlotCredential.unreadable()) + + assert pushed == [({2: SlotCredential.unreadable()}, False)] + + +async def test_confirm_slot_empty_observation_clears_pending( + hass: HomeAssistant, +) -> None: + """An empty observation (slot cleared) overrides a pending write.""" + lock, pushed = _slot_only_lock_with_coordinator(hass) + lock._record_optimistic_write(4, "1234") + pushed.clear() + + lock._confirm_slot(4, SlotCredential.empty()) + + assert pushed == [({4: SlotCredential.empty()}, False)] + assert 4 not in lock._pending_writes + + +async def test_expire_pending_writes_drops_only_past_deadline( + hass: HomeAssistant, +) -> None: + """Expiry drops pending writes whose deadline has passed, keeps the rest.""" + lock = _make_lock(hass, _DegenerateStubLock, "seam_expire") + lock._pending_writes = { + 1: ("1111", time.monotonic() - 1.0), # expired + 2: ("2222", time.monotonic() + 100.0), # still pending + } + lock._expire_pending_writes() + + assert set(lock._pending_writes) == {2} From f50423c66aba6616dd4f384d4e5a9cbb83dc2801 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:08:00 -0400 Subject: [PATCH 3/7] feat(phase2): PENDING_CONFIRMATION state + verified gate + expiry (step 4) Wires the verified-credential lifecycle into the sync state machine (still dormant -- no provider returns OPTIMISTIC until step 5, so behavior is unchanged for all current providers): - SyncState.PENDING_CONFIRMATION: an optimistic write awaiting confirmation. - calculate_in_sync gates on coordinator.is_verified(slot): an unverified slot is never in sync (absent flag -> verified, so a no-op for non-optimistic providers). - The tick parks a slot with an unexpired pending write in PENDING_CONFIRMATION (no re-write while waiting); on timeout it records a slot-breaker failure and falls through to re-sync, so an unconfirmed write ends in a visible suspend rather than a silent in-sync. Tests: verified gate, PENDING_CONFIRMATION hold, expiry-records-failure+resync. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 84612b50267b --- .../lock_code_manager/domain/models.py | 5 ++ .../lock_code_manager/domain/sync.py | 35 ++++++++ tests/test_sync.py | 83 ++++++++++++++++++- 3 files changed, 121 insertions(+), 2 deletions(-) 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..a95392f78 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,31 @@ 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: + _pin, deadline = pending + if time.monotonic() < deadline: + if self._state is not SyncState.PENDING_CONFIRMATION: + self._state = SyncState.PENDING_CONFIRMATION + self._write_state() + return + # Expired without confirmation: treat as a failed sync attempt. + del self._lock._pending_writes[self._slot_num] + 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, + ) + # expected_in_sync was computed before this; the slot is still + # unverified, so it is False -- fall through to the sync path. + # -- OUT_OF_SYNC: check lock reachability, then attempt sync -- if self._coordinator.unreachable: self._state = SyncState.SUSPENDED diff --git a/tests/test_sync.py b/tests/test_sync.py index e07c2335c..f431a8c6f 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -4,6 +4,7 @@ import asyncio import logging +import time from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -60,10 +61,15 @@ def _slot( ) -def _manager(last_set_pin: str | None = None) -> SlotSyncManager: - """Build a mock SlotSyncManager with _last_set_pin set.""" +def _manager( + last_set_pin: str | None = None, *, verified: bool = True, slot_num: int = 1 +) -> SlotSyncManager: + """Build a mock SlotSyncManager with _last_set_pin and a verified coordinator.""" mgr = MagicMock(spec=SlotSyncManager) mgr._last_set_pin = last_set_pin + mgr._slot_num = slot_num + mgr._coordinator = MagicMock() + mgr._coordinator.is_verified.return_value = verified mgr.calculate_in_sync = SlotSyncManager.calculate_in_sync.__get__(mgr) return mgr @@ -214,6 +220,22 @@ def test_calculate_in_sync( is expected ) + def test_unverified_slot_is_never_in_sync(self) -> None: + """An unverified slot is not in sync even when the value would match. + + Phase 2: an optimistic write awaiting confirmation holds the believed + value in the coordinator, but the lock has not confirmed it, so the + tick must keep watching / re-sync rather than declare success. + """ + slot = _slot(active=STATE_ON, pin="1234", coordinator_code="1234") + assert ( + _manager(last_set_pin="1234", verified=True).calculate_in_sync(slot) is True + ) + assert ( + _manager(last_set_pin="1234", verified=False).calculate_in_sync(slot) + is False + ) + class TestTryUpgradeStateTracking: """Tests for upgrading catch-all to targeted state tracking.""" @@ -666,6 +688,63 @@ async def test_out_of_sync_to_synced_after_successful_sync( await async_trigger_sync_tick(hass, SLOT_1_IN_SYNC_ENTITY, set_dirty=False) assert manager._state is SyncState.IN_SYNC + async def test_pending_optimistic_write_holds_in_pending_confirmation( + self, + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, + ) -> None: + """An unexpired pending write parks the slot in PENDING_CONFIRMATION. + + The tick must not re-write while waiting for the lock to confirm. + """ + entity_obj = get_in_sync_entity_obj(hass, SLOT_1_IN_SYNC_ENTITY) + manager = entity_obj._sync_manager + await async_trigger_sync_tick(hass, SLOT_1_IN_SYNC_ENTITY, set_dirty=False) + + # Arrange an outstanding (unexpired) optimistic write. + manager._lock._pending_writes[1] = ("1234", time.monotonic() + 100.0) + manager._coordinator.push_update( + {1: SlotCredential.known("1234")}, optimistic=True + ) + manager.request_sync_check() + + with patch.object( + manager, "_perform_sync", new_callable=AsyncMock + ) as mock_sync: + await manager._async_tick() + + assert manager._state is SyncState.PENDING_CONFIRMATION + mock_sync.assert_not_called() + + async def test_pending_optimistic_write_expiry_records_failure_and_resyncs( + self, + hass: HomeAssistant, + mock_lock_config_entry, + lock_code_manager_config_entry, + ) -> None: + """An expired pending write counts a breaker failure and re-syncs.""" + entity_obj = get_in_sync_entity_obj(hass, SLOT_1_IN_SYNC_ENTITY) + manager = entity_obj._sync_manager + await async_trigger_sync_tick(hass, SLOT_1_IN_SYNC_ENTITY, set_dirty=False) + + # Arrange an already-expired optimistic write. + manager._lock._pending_writes[1] = ("1234", time.monotonic() - 1.0) + manager._coordinator.push_update( + {1: SlotCredential.known("1234")}, optimistic=True + ) + manager.request_sync_check() + before = manager._slot_breaker.failure_count + + with patch.object( + manager, "_perform_sync", new_callable=AsyncMock, return_value=True + ) as mock_sync: + await manager._async_tick() + + assert 1 not in manager._lock._pending_writes + assert manager._slot_breaker.failure_count > before + mock_sync.assert_called() + async def test_syncing_to_out_of_sync_on_lock_disconnected( self, hass: HomeAssistant, From 21cac236036b965936363ab6d1b2d1ac374c48e7 Mon Sep 17 00:00:00 2001 From: raman325 <7243222+raman325@users.noreply.github.com> Date: Sat, 13 Jun 2026 16:23:39 -0400 Subject: [PATCH 4/7] feat(phase2): close the silent-failure hole; confirm-on-read + frontend (step 5) Makes the verified-credential lifecycle live for the Z-Wave unified path and adds the dropped-push backstop: - zwave async_set_credential returns WriteResult.OPTIMISTIC (was CONFIRMED) for a driver ERROR_UNKNOWN: the seam records it pending + pushes unverified, so a masked-but-unconfirmed write is reconciled rather than trusted. - zwave credential push handlers route through _confirm_slot: a credential event confirms a pending write (keeping the believed value even when the lock reports it masked); an external change is taken as-is. - coordinator._apply_read: a genuine read (poll or hard refresh) confirms a pending write when the slot is observed present (the dropped-push backstop), keeps waiting when still absent, and marks non-pending reads verified. - sync tick: an optimistic write parks in PENDING_CONFIRMATION after _perform_sync instead of being judged immediately -- so a masked write is not penalised before its confirming event arrives. The breaker is charged only on timeout. - frontend: slot-card renders the new pending_confirmation status as "Confirming" (mdi:progress-clock); +1 frontend test. End-to-end: optimistic write -> PENDING_CONFIRMATION -> (confirm -> IN_SYNC) or (repeated timeout -> visible suspend), never a silent in-sync. Full suite green (1263) + 709 frontend tests. Co-Authored-By: Claude Fable 5 Entire-Checkpoint: 6e93c13cbac8 --- .../lock_code_manager/domain/coordinator.py | 42 ++++++++++++++++-- .../lock_code_manager/domain/sync.py | 10 +++++ .../lock_code_manager/providers/zwave_js.py | 15 ++++--- .../www/generated/lock-code-manager.js | 2 +- tests/providers/zwave_js/test_provider.py | 9 ++-- tests/test_sync.py | 43 ++++++++++++++++++- ts/slot-card.integration.test.ts | 17 ++++++++ ts/slot-card.ts | 8 ++++ 8 files changed, 132 insertions(+), 14 deletions(-) diff --git a/custom_components/lock_code_manager/domain/coordinator.py b/custom_components/lock_code_manager/domain/coordinator.py index 3300f286a..d19fb66ee 100644 --- a/custom_components/lock_code_manager/domain/coordinator.py +++ b/custom_components/lock_code_manager/domain/coordinator.py @@ -119,6 +119,40 @@ 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. Observing it 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] + 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. @@ -259,7 +293,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.""" @@ -279,8 +313,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/sync.py b/custom_components/lock_code_manager/domain/sync.py index a95392f78..0ec6d0996 100644 --- a/custom_components/lock_code_manager/domain/sync.py +++ b/custom_components/lock_code_manager/domain/sync.py @@ -857,6 +857,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/zwave_js.py b/custom_components/lock_code_manager/providers/zwave_js.py index d8ec5c183..b42b5d07c 100644 --- a/custom_components/lock_code_manager/providers/zwave_js.py +++ b/custom_components/lock_code_manager/providers/zwave_js.py @@ -482,13 +482,15 @@ 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 WriteResult.CONFIRMED + return WriteResult.OPTIMISTIC raise CodeRejectedError( code_slot=credential.slot, lock_entity_id=self.lock.entity_id, @@ -596,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: @@ -604,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(['