diff --git a/custom_components/lock_code_manager/providers/zwave_js.py b/custom_components/lock_code_manager/providers/zwave_js.py index 4e657a16..c6bb2c26 100644 --- a/custom_components/lock_code_manager/providers/zwave_js.py +++ b/custom_components/lock_code_manager/providers/zwave_js.py @@ -15,14 +15,21 @@ from typing import Any, Literal from zwave_js_server.client import Client -from zwave_js_server.const import NodeStatus +from zwave_js_server.const import CommandClass, NodeStatus from zwave_js_server.const.command_class.access_control import UserCredentialType +from zwave_js_server.const.command_class.lock import ( + ATTR_IN_USE, + LOCK_USERCODE_PROPERTY, + LOCK_USERCODE_STATUS_PROPERTY, + CodeSlotStatus, +) from zwave_js_server.const.command_class.notification import ( AccessControlNotificationEvent, NotificationType, ) -from zwave_js_server.exceptions import BaseZwaveJSServerError +from zwave_js_server.exceptions import BaseZwaveJSServerError, NotFoundError from zwave_js_server.model.node import Node +from zwave_js_server.util.lock import get_usercode from homeassistant.components.zwave_js import lock_helpers from homeassistant.components.zwave_js.const import ( @@ -119,6 +126,11 @@ class ZWaveJSLock(BaseLock): write verification) -- guaranteed by the integration's minimum Home Assistant version. The legacy User Code CC value-path fallback that worked around the pre-15.24.3 capability bug (#1251) has been removed. + + One temporary bridge remains: the User Code CC report shim (grep + ``_uc_``), which compensates for report-driven User Code CC changes + emitting no unified credential events on released drivers. See the + shim section below for details and the removal recipe. """ lock_config_entry: ConfigEntry = field(repr=False) @@ -555,6 +567,13 @@ def setup_push_subscription(self) -> None: from its unified ``access_control`` API for both User Code CC and User Credential CC locks. The handlers are self-filtering and pushes are idempotent. + + On nodes that advertise User Code CC, also subscribe to raw + ``value updated`` events for the User Code CC report shim (see + that section below): released drivers emit no unified events for + report-driven User Code CC changes, and both listener sets are + safe together because the handlers self-filter and pushes are + idempotent. """ if self._push_unsubs: return @@ -568,6 +587,8 @@ def setup_push_subscription(self) -> None: ("credential modified", self._on_credential_changed), ("credential deleted", self._on_credential_deleted), ] + if self._node_advertises_user_code_cc(): + subscriptions.append(("value updated", self._on_uc_value_updated)) try: for name, handler in subscriptions: @@ -602,6 +623,120 @@ def _on_credential_deleted(self, event: dict[str, Any]) -> None: return self._confirm_slot(args.credential_slot, SlotCredential.empty()) + # ------------------------------------------------------------------ + # User Code CC report shim + # + # Bridges an upstream gap on released drivers: on locks the driver + # dispatches to User Code CC (User Code CC-only locks, and dual-CC + # locks whose User Credential CC advertises zero users), any change + # that arrives as a *report* -- keypad programming, zwave-js-ui + # edits, Home Assistant's ``zwave_js.set/clear_lock_usercode`` + # services, and the driver's own post-write verification polls -- + # updates the driver's value database without emitting a unified + # credential event. LCM disables periodic polling for push + # providers, so without this shim those changes would never reach + # the coordinator and sync could not reconcile them. + # + # zwave-js/zwave-js#8930 (merged, unreleased) fixes this by making + # the access-control API the source of truth for User Code CC. It + # also makes these User Code CC values internal, so once a driver + # with it ships the value events stop arriving and the unified + # events we already subscribe to take over -- the shim goes dormant + # on its own, no LCM change needed. To remove it once the minimum + # supported driver includes #8930: + # + # 1. Delete everything from this comment through + # ``_uc_slot_in_use``. + # 2. Delete the ``_node_advertises_user_code_cc`` branch in + # ``setup_push_subscription`` (and its docstring paragraph). + # 3. Delete the shim tests in + # ``tests/providers/zwave_js/test_events.py`` (grep ``_uc_``) + # and restore ``_EXPECTED_PUSH_UNSUB_COUNT`` to 3. + # ------------------------------------------------------------------ + + def _node_advertises_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) + + @callback + def _on_uc_value_updated(self, event: dict[str, Any]) -> None: + """Handle ``value updated`` node events for User Code CC values.""" + args: dict[str, Any] = event["args"] + if args.get("commandClass") != CommandClass.USER_CODE: + return + + property_name = args.get("property") + if property_name not in ( + LOCK_USERCODE_PROPERTY, + LOCK_USERCODE_STATUS_PROPERTY, + ): + return + + code_slot = int(args["propertyKey"]) + # Slot 0 is not a valid user code slot. + if code_slot == 0: + return + + if property_name == LOCK_USERCODE_STATUS_PROPERTY: + self._handle_uc_status_update(code_slot, args.get("newValue")) + else: + self._handle_uc_code_update(code_slot, args.get("newValue")) + + @callback + def _handle_uc_status_update(self, code_slot: int, status: Any) -> None: + """Handle a userIdStatus value update for a code slot.""" + if status != CodeSlotStatus.AVAILABLE: + # Occupied statuses carry no code; the paired userCode update + # delivers the value. + return + # Ignore AVAILABLE when Lock Code Manager expects a PIN on this + # slot. Some locks send stale AVAILABLE events after a code was + # set, which would cause infinite sync loops. + if ( + self.coordinator is not None + and self.coordinator.desired_credential(code_slot).is_present + ): + _LOGGER.debug( + "Lock %s: ignoring userIdStatus=AVAILABLE for slot %s " + "(LCM expects PIN on this slot)", + self.lock.entity_id, + code_slot, + ) + return + self._confirm_slot(code_slot, SlotCredential.empty()) + + @callback + def _handle_uc_code_update(self, code_slot: int, new_value: Any) -> None: + """Handle a userCode value update for a code slot.""" + if not new_value: + resolved = SlotCredential.empty() + else: + value = str(new_value) + slot_in_use = self._uc_slot_in_use(code_slot) + # Asymmetric in_use checks: masked codes count as unreadable + # even when in_use is None (some firmwares mask before + # reporting status), but all-zeros only counts as empty when + # in_use is explicitly False (zeros from a partially-loaded + # cache must not be misread as cleared). + if value == "*" * len(value) and slot_in_use is not False: + resolved = SlotCredential.unreadable() + elif value.strip("0") == "" and slot_in_use is False: + resolved = SlotCredential.empty() + else: + resolved = SlotCredential.known(value) + # Route through _confirm_slot like the unified handlers: the + # driver's post-write verification report doubles as the + # confirming push for a pending optimistic write. + self._confirm_slot(code_slot, resolved) + + def _uc_slot_in_use(self, code_slot: int) -> bool | None: + """Return whether a User Code CC slot is in use, None when unknown.""" + try: + in_use = get_usercode(self.node, code_slot).get(ATTR_IN_USE) + except NotFoundError: + return None + return in_use if isinstance(in_use, bool) else None + @callback def teardown_push_subscription(self) -> None: """Unsubscribe from credential change events.""" diff --git a/tests/providers/zwave_js/test_events.py b/tests/providers/zwave_js/test_events.py index 2be447d7..13c72b2c 100644 --- a/tests/providers/zwave_js/test_events.py +++ b/tests/providers/zwave_js/test_events.py @@ -2,11 +2,14 @@ from __future__ import annotations +import time from unittest.mock import MagicMock, patch import pytest from pytest_homeassistant_custom_component.common import MockConfigEntry +from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.access_control import UserCredentialType +from zwave_js_server.const.command_class.lock import CodeSlotStatus from zwave_js_server.event import Event as ZwaveEvent from zwave_js_server.model.node import Node @@ -167,11 +170,13 @@ async def test_subscribe_push_cleans_up_partial_subscription_on_error( await zwave_js_lock.async_unload(False) -# Three subscriptions registered (credential added, modified, deleted) -_EXPECTED_PUSH_UNSUB_COUNT = 3 +# Four subscriptions registered on a User Code CC node: credential +# added/modified/deleted plus the User Code CC report shim's value-updated +# listener. Restore to 3 when the shim is removed. +_EXPECTED_PUSH_UNSUB_COUNT = 4 -async def test_subscribe_registers_three_credential_listeners( +async def test_subscribe_registers_credential_and_shim_listeners( hass: HomeAssistant, zwave_js_lock: ZWaveJSLock, zwave_integration: MockConfigEntry, @@ -179,7 +184,7 @@ async def test_subscribe_registers_three_credential_listeners( mock_access_control: MagicMock, mock_lock_helpers: dict, ) -> None: - """Test that subscribing registers listeners for all three credential events.""" + """Test that subscribing registers the credential and shim listeners.""" lcm_entry = MockConfigEntry(domain=DOMAIN, data={CONF_LOCKS: [], CONF_SLOTS: {}}) lcm_entry.add_to_hass(hass) await zwave_js_lock.async_setup_internal(lcm_entry) @@ -191,6 +196,28 @@ async def test_subscribe_registers_three_credential_listeners( await zwave_js_lock.async_unload(False) +async def test_subscribe_skips_shim_listener_without_user_code_cc( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + zwave_integration: MockConfigEntry, + lock_schlage_be469: Node, + mock_access_control: MagicMock, + mock_lock_helpers: dict, +) -> None: + """A node without User Code CC gets only the unified credential listeners.""" + lcm_entry = MockConfigEntry(domain=DOMAIN, data={CONF_LOCKS: [], CONF_SLOTS: {}}) + lcm_entry.add_to_hass(hass) + await zwave_js_lock.async_setup_internal(lcm_entry) + + zwave_js_lock.unsubscribe_push_updates() + with patch.object(ZWaveJSLock, "_node_advertises_user_code_cc", return_value=False): + zwave_js_lock.subscribe_push_updates() + assert len(zwave_js_lock._push_unsubs) == 3 + + zwave_js_lock.unsubscribe_push_updates() + await zwave_js_lock.async_unload(False) + + # --------------------------------------------------------------------------- # Event filter tests # --------------------------------------------------------------------------- @@ -599,3 +626,294 @@ async def test_credential_deleted_non_pin_ignored( mock_coordinator.push_update.assert_not_called() zwave_js_lock.unsubscribe_push_updates() + + +# --------------------------------------------------------------------------- +# User Code CC report shim tests (delete with the shim; grep _uc_) +# --------------------------------------------------------------------------- + + +def _make_uc_value_event( + node_id: int, property_name: str, code_slot: int, new_value +) -> ZwaveEvent: + """Create a User Code CC value-updated ZwaveEvent.""" + return ZwaveEvent( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node_id, + "args": { + "commandClass": CommandClass.USER_CODE, + "property": property_name, + "propertyKey": code_slot, + "newValue": new_value, + }, + }, + ) + + +async def test_uc_shim_plain_code_pushes_known( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + lock_schlage_be469: Node, + mock_access_control: MagicMock, + mock_lock_helpers: dict, +) -> None: + """A readable userCode value update pushes SlotCredential.known().""" + mock_coordinator = MagicMock() + mock_coordinator.data = {} + zwave_js_lock.coordinator = mock_coordinator + + zwave_js_lock.subscribe_push_updates() + + lock_schlage_be469.receive_event( + _make_uc_value_event(lock_schlage_be469.node_id, "userCode", 2, "8642") + ) + await hass.async_block_till_done() + + mock_coordinator.push_update.assert_called_once_with( + {2: SlotCredential.known("8642")} + ) + + zwave_js_lock.unsubscribe_push_updates() + + +async def test_uc_shim_masked_code_pushes_unreadable( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + lock_schlage_be469: Node, + mock_access_control: MagicMock, + mock_lock_helpers: dict, +) -> None: + """A masked userCode value update on an occupied slot pushes unreadable.""" + mock_coordinator = MagicMock() + mock_coordinator.data = {} + zwave_js_lock.coordinator = mock_coordinator + + zwave_js_lock.subscribe_push_updates() + + # Fixture slot 2 has userIdStatus=ENABLED, so in_use is True. + lock_schlage_be469.receive_event( + _make_uc_value_event(lock_schlage_be469.node_id, "userCode", 2, "****") + ) + await hass.async_block_till_done() + + mock_coordinator.push_update.assert_called_once_with( + {2: SlotCredential.unreadable()} + ) + + zwave_js_lock.unsubscribe_push_updates() + + +async def test_uc_shim_zeros_on_available_slot_pushes_empty( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + lock_schlage_be469: Node, + mock_access_control: MagicMock, + mock_lock_helpers: dict, +) -> None: + """All-zeros on a slot whose in_use is explicitly False pushes empty.""" + mock_coordinator = MagicMock() + mock_coordinator.data = {} + zwave_js_lock.coordinator = mock_coordinator + + zwave_js_lock.subscribe_push_updates() + + # Fixture slot 3 has userIdStatus=AVAILABLE, so in_use is False. + lock_schlage_be469.receive_event( + _make_uc_value_event(lock_schlage_be469.node_id, "userCode", 3, "0000") + ) + await hass.async_block_till_done() + + mock_coordinator.push_update.assert_called_once_with({3: SlotCredential.empty()}) + + zwave_js_lock.unsubscribe_push_updates() + + +async def test_uc_shim_empty_code_pushes_empty( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + lock_schlage_be469: Node, + mock_access_control: MagicMock, + mock_lock_helpers: dict, +) -> None: + """An empty userCode value update pushes SlotCredential.empty().""" + mock_coordinator = MagicMock() + mock_coordinator.data = {2: SlotCredential.known("1234")} + zwave_js_lock.coordinator = mock_coordinator + + zwave_js_lock.subscribe_push_updates() + + lock_schlage_be469.receive_event( + _make_uc_value_event(lock_schlage_be469.node_id, "userCode", 2, "") + ) + await hass.async_block_till_done() + + mock_coordinator.push_update.assert_called_once_with({2: SlotCredential.empty()}) + + zwave_js_lock.unsubscribe_push_updates() + + +async def test_uc_shim_status_available_pushes_empty( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + lock_schlage_be469: Node, + mock_access_control: MagicMock, + mock_lock_helpers: dict, +) -> None: + """A userIdStatus=AVAILABLE update pushes empty when no PIN is expected.""" + mock_coordinator = MagicMock() + mock_coordinator.data = {2: SlotCredential.known("1234")} + mock_coordinator.desired_credential.return_value = SlotCredential.empty() + zwave_js_lock.coordinator = mock_coordinator + + zwave_js_lock.subscribe_push_updates() + + lock_schlage_be469.receive_event( + _make_uc_value_event( + lock_schlage_be469.node_id, "userIdStatus", 2, CodeSlotStatus.AVAILABLE + ) + ) + await hass.async_block_till_done() + + mock_coordinator.push_update.assert_called_once_with({2: SlotCredential.empty()}) + + zwave_js_lock.unsubscribe_push_updates() + + +async def test_uc_shim_status_available_ignored_when_pin_expected( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + lock_schlage_be469: Node, + mock_access_control: MagicMock, + mock_lock_helpers: dict, +) -> None: + """A stale AVAILABLE status is ignored when LCM expects a PIN on the slot. + + Some locks send stale AVAILABLE events after a code was set; acting + on them would cause infinite sync loops. + """ + mock_coordinator = MagicMock() + mock_coordinator.data = {2: SlotCredential.known("1234")} + mock_coordinator.desired_credential.return_value = SlotCredential.known("1234") + zwave_js_lock.coordinator = mock_coordinator + + zwave_js_lock.subscribe_push_updates() + + lock_schlage_be469.receive_event( + _make_uc_value_event( + lock_schlage_be469.node_id, "userIdStatus", 2, CodeSlotStatus.AVAILABLE + ) + ) + await hass.async_block_till_done() + + mock_coordinator.push_update.assert_not_called() + + zwave_js_lock.unsubscribe_push_updates() + + +async def test_uc_shim_status_occupied_ignored( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + lock_schlage_be469: Node, + mock_access_control: MagicMock, + mock_lock_helpers: dict, +) -> None: + """Occupied userIdStatus updates are ignored; the userCode update carries the value.""" + mock_coordinator = MagicMock() + mock_coordinator.data = {} + zwave_js_lock.coordinator = mock_coordinator + + zwave_js_lock.subscribe_push_updates() + + lock_schlage_be469.receive_event( + _make_uc_value_event( + lock_schlage_be469.node_id, "userIdStatus", 2, CodeSlotStatus.ENABLED + ) + ) + await hass.async_block_till_done() + + mock_coordinator.push_update.assert_not_called() + + zwave_js_lock.unsubscribe_push_updates() + + +async def test_uc_shim_ignores_unrelated_value_events( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + lock_schlage_be469: Node, + mock_access_control: MagicMock, + mock_lock_helpers: dict, +) -> None: + """Other command classes, other properties, and slot 0 are all ignored.""" + mock_coordinator = MagicMock() + mock_coordinator.data = {} + zwave_js_lock.coordinator = mock_coordinator + + zwave_js_lock.subscribe_push_updates() + + # Wrong command class + lock_schlage_be469.receive_event( + ZwaveEvent( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": lock_schlage_be469.node_id, + "args": { + "commandClass": CommandClass.DOOR_LOCK, + "property": "userCode", + "propertyKey": 2, + "newValue": "1234", + }, + }, + ) + ) + # Wrong property + lock_schlage_be469.receive_event( + _make_uc_value_event(lock_schlage_be469.node_id, "keypadMode", 2, 1) + ) + # Slot 0 is not a valid user code slot + lock_schlage_be469.receive_event( + _make_uc_value_event(lock_schlage_be469.node_id, "userCode", 0, "1234") + ) + await hass.async_block_till_done() + + mock_coordinator.push_update.assert_not_called() + + zwave_js_lock.unsubscribe_push_updates() + + +async def test_uc_shim_confirms_pending_optimistic_write( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + lock_schlage_be469: Node, + mock_access_control: MagicMock, + mock_lock_helpers: dict, +) -> None: + """A masked verification report confirms a pending optimistic write. + + The driver's post-write verification GET produces a report (and thus a + value event) but no unified credential event on released drivers; the + shim must route it through _confirm_slot so the believed value is kept + and marked verified. + """ + mock_coordinator = MagicMock() + mock_coordinator.data = {} + zwave_js_lock.coordinator = mock_coordinator + + zwave_js_lock.subscribe_push_updates() + zwave_js_lock._pending_writes[2] = ("8642", time.monotonic() + 60) + + lock_schlage_be469.receive_event( + _make_uc_value_event(lock_schlage_be469.node_id, "userCode", 2, "****") + ) + await hass.async_block_till_done() + + mock_coordinator.push_update.assert_called_once_with( + {2: SlotCredential.known("8642")} + ) + assert 2 not in zwave_js_lock._pending_writes + + zwave_js_lock.unsubscribe_push_updates()