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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 40 additions & 31 deletions custom_components/lock_code_manager/providers/_zwave_js_uc.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@ def node(self) -> Node:
"""Return the Z-Wave JS node; the concrete provider supplies this."""
raise NotImplementedError

def _pin_state(self, data: str | bytes | None) -> SlotCredential:
"""
Project raw credential data to a SlotCredential.

Universal masked/withheld-aware projection, implemented by the
concrete provider (``ZWaveJSLock._pin_state``) and reused here so
the UC fallback's read path matches the unified path exactly.
"""
raise NotImplementedError

def _node_supports_user_code_cc(self) -> bool:
"""Return whether the node's endpoint 0 advertises User Code CC."""
return any(cc.id == CommandClass.USER_CODE for cc in self.node.command_classes)
Expand Down Expand Up @@ -173,15 +183,14 @@ async def _async_uc_fallback_active(self) -> bool:

def _uc_fallback_capabilities(self) -> LockCapabilities | None:
"""
Detect the fallback and build slot-only capabilities for it.
Build slot-only capabilities for a degenerate-capability lock.

Called by ``async_get_capabilities`` after the unified API
reported no usable PIN support. Only falls back when the node
advertises User Code CC -- without it the legacy utilities
cannot work either, and the lock genuinely has no PIN support
LCM can manage -- and when the User Code CC value DB walk finds
slots. Sets ``_uc_fallback`` accordingly and returns None when
no fallback is possible.
Called by ``async_get_capabilities`` only when the unified API
reports no usable PIN slots (the #1251 zero-slot variant). Falls
back to the legacy User Code CC value-DB walk for the slot count.
Returns None -- and clears ``_uc_fallback`` -- when the node has
no User Code CC, so a true U3C lock with degenerate caps (which
the legacy utilities can't help) is not mis-routed here.

``get_usercodes`` walks slot 1, 2, 3, ... in the value DB until
``NotFoundError``, so the returned list length is the lock's
Expand All @@ -190,18 +199,19 @@ def _uc_fallback_capabilities(self) -> LockCapabilities | None:
walking, so we let any unexpected exception surface rather
than silently mis-routing the lock to "no PIN support".
"""
uc_slots = (
get_usercodes(self.node) if self._node_supports_user_code_cc() else []
)
if not uc_slots:
if not self._node_supports_user_code_cc():
self._uc_fallback = False
return None
num_slots = len(get_usercodes(self.node))
if not num_slots:
self._uc_fallback = False
return None
_LOGGER.warning(
"Lock %s: unified access-control API reports no usable PIN "
"capabilities but the node supports User Code CC with %s slots; "
"falling back to legacy User Code CC handling (see issue #1251)",
_LOGGER.debug(
"Lock %s: unified API reports no usable PIN slots but the node "
"supports User Code CC (%s slots); using the legacy User Code "
"CC value path (see issue #1251)",
self.lock.entity_id,
len(uc_slots),
num_slots,
)
self._uc_fallback = True
return LockCapabilities(
Expand All @@ -214,7 +224,7 @@ def _uc_fallback_capabilities(self) -> LockCapabilities | None:
max_users=0,
credential_types={
CredentialType.PIN: CredentialTypeCapability(
num_slots=len(uc_slots),
num_slots=num_slots,
# UC spec allows 4-10 ASCII digits per User Code CC v1+.
min_length=4,
max_length=10,
Expand All @@ -224,18 +234,20 @@ def _uc_fallback_capabilities(self) -> LockCapabilities | None:
max_user_name_length=0,
)

@staticmethod
def _uc_slot_state(in_use: bool | None, usercode: str | None) -> SlotCredential:
def _uc_slot_state(
self, in_use: bool | None, usercode: str | None
) -> SlotCredential:
"""
Project a User Code CC slot to a ``SlotCredential``.

Slots count as empty only when ``in_use`` is explicitly ``False``,
or when ``in_use`` is unknown (``None``) with no cached value --
the latter matches the legacy 3.x reader. Masked codes (all
asterisks) and occupied slots without a cached value count as
unreadable; an unknown ``in_use`` with a present value is treated
as occupied so a partially populated cache cannot erase a live
PIN (mirrors the push-path rule at ``_handle_uc_value_update``).
Adds the User Code CC ``in_use`` (userIdStatus) gate on top of the
shared ``_pin_state`` projection. Slots count as empty only when
``in_use`` is explicitly ``False``, or when ``in_use`` is unknown
(``None``) with no cached value -- the latter matches the legacy
3.x reader. An occupied (or unknown-but-present) slot defers to
``_pin_state`` so masked/withheld codes map to unreadable exactly
as on the unified path, and a partially populated cache cannot
erase a live PIN.
"""
if in_use is False:
return SlotCredential.empty()
Expand All @@ -245,10 +257,7 @@ def _uc_slot_state(in_use: bool | None, usercode: str | None) -> SlotCredential:
if in_use is None
else SlotCredential.unreadable()
)
code = str(usercode)
if code == "*" * len(code):
return SlotCredential.unreadable()
return SlotCredential.known(code)
return self._pin_state(usercode)

async def _async_uc_users_from_value_db(self) -> list[User]:
"""
Expand Down
69 changes: 65 additions & 4 deletions custom_components/lock_code_manager/providers/matter.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from datetime import timedelta
from typing import Any, Literal

from matter_server.client.exceptions import MatterClientException
from matter_server.common.errors import MatterError
from matter_server.common.models import EventType

Expand Down Expand Up @@ -71,6 +72,39 @@
_DATA_OP_MODIFY = 2


def _is_transient_credential_status(status: str) -> bool:
"""
Return True for a SetCredential status that should be retried, not rejected.

HA's matter ``lock_helpers.set_lock_credential`` maps recognized DlStatus
values to names (``success`` / ``failure`` / ``duplicate`` / ``occupied``)
and formats anything else as ``unknown(<code>)`` --
``SET_CREDENTIAL_STATUS_MAP.get(status_code, f"unknown({status_code})")`` --
discarding the raw code. So ``unknown(`` is the only signal LCM has for "the
lock returned a status the helper did not recognize", and an unmapped status
is treated as transient (route to the retry path) -- notably ``unknown(133)``
observed while a lock is not fully ready right after startup (issue #1257) --
rather than a definitive rejection that permanently disables the slot.

FRAGILE COUPLING: this depends on HA's private ``unknown(<code>)`` format,
which has no stability contract. If that helper ever changes the format or
starts mapping a code we rely on (e.g. 133 -> a real name), this predicate
silently returns False and the #1257 not-ready case regresses to a permanent
suspend. ``tests/providers/matter/test_provider.py`` pins the current format.
"""
return status.startswith("unknown(")


def _transient_status_disconnect(
entity_id: str, slot: int, status: str
) -> LockDisconnected:
"""Build the LockDisconnected raised for a transient SetCredential status."""
return LockDisconnected(
f"Matter set_lock_credential returned a transient status "
f"'{status}' for {entity_id} slot {slot}"
)


def _lcm_slot_from_raw_users_by_user_index(
raw_users: list[dict[str, Any]], user_index: int | None
) -> int | None:
Expand Down Expand Up @@ -684,8 +718,12 @@ async def _send_set_credential(
lock_entity_id=self.lock.entity_id,
reason=str(err),
) from err
except HomeAssistantError as err:
# Transport / endpoint failure -> route to the retry path.
except (HomeAssistantError, MatterError, MatterClientException) as err:
# Transport / connectivity / server failure -> route to the retry
# path. MatterError and MatterClientException are independent of
# HomeAssistantError (e.g. ``InvalidState: Not connected`` during
# startup, issue #1257), so they must be caught explicitly or they
# escape to the generic handler and suspend the slot.
raise LockDisconnected(
f"Matter set_lock_credential failed for {self.lock.entity_id}: {err}"
) from err
Expand Down Expand Up @@ -749,6 +787,15 @@ async def async_set_credential(
)
except SetCredentialFailedError as err:
status = (err.translation_placeholders or {}).get("status", "")
if _is_transient_credential_status(status):
# Unmapped/unknown status (e.g. ``unknown(133)`` seen while the
# lock is not fully ready at startup, issue #1257). Route to the
# retry path rather than permanently disabling the slot: a later
# tick after Matter is ready will succeed. Recognized rejections
# (occupied/failure) fall through to CodeRejectedError below.
raise _transient_status_disconnect(
self.lock.entity_id, slot, status
) from err
if status != "duplicate":
raise CodeRejectedError(
code_slot=slot,
Expand Down Expand Up @@ -787,7 +834,15 @@ async def async_set_credential(
f"Matter clear_lock_credential rejected input for "
f"{self.lock.entity_id} during sync-duplicate retry: {clear_err}"
) from clear_err
except HomeAssistantError as clear_err:
except (
HomeAssistantError,
MatterError,
MatterClientException,
) as clear_err:
# Same connectivity-vs-HomeAssistantError split as the other
# clear/set sites (issue #1257): a MatterClientException here
# (e.g. ``InvalidState: Not connected`` mid-retry) must route to
# retry, not escape to the generic handler and suspend the slot.
raise LockDisconnected(
f"Matter clear_lock_credential failed for "
f"{self.lock.entity_id} during sync-duplicate retry: {clear_err}"
Expand All @@ -805,6 +860,10 @@ async def async_set_credential(
code_slot=slot,
lock_entity_id=self.lock.entity_id,
) from retry_err
if _is_transient_credential_status(retry_status):
raise _transient_status_disconnect(
self.lock.entity_id, slot, retry_status
) from retry_err
raise CodeRejectedError(
code_slot=slot,
lock_entity_id=self.lock.entity_id,
Expand Down Expand Up @@ -846,7 +905,9 @@ async def async_delete_credential(self, ref: CredentialRef) -> bool:
f"Matter clear_lock_credential rejected input for "
f"{self.lock.entity_id}: {err}"
) from err
except HomeAssistantError as err:
except (HomeAssistantError, MatterError, MatterClientException) as err:
# Connectivity/server failure (incl. ``InvalidState: Not connected``
# at startup, issue #1257) -> retry rather than suspend.
raise LockDisconnected(
f"Matter clear_lock_credential failed for {self.lock.entity_id}: {err}"
) from err
Expand Down
Loading
Loading