diff --git a/custom_components/lock_code_manager/coordinator.py b/custom_components/lock_code_manager/coordinator.py index 6befcea8..ee02ed72 100644 --- a/custom_components/lock_code_manager/coordinator.py +++ b/custom_components/lock_code_manager/coordinator.py @@ -68,9 +68,13 @@ def push_update(self, updates: dict[int, int | str]) -> None: if not updates: return - # Merge updates into a new dict to ensure proper change detection - # (avoids passing the same object reference to listeners) - self.async_set_updated_data({**self.data, **updates}) + new_data = {**self.data, **updates} + # Skip update if data hasn't actually changed to avoid redundant logging + # and unnecessary listener notifications + if new_data == self.data: + return + + self.async_set_updated_data(new_data) async def async_get_usercodes(self) -> dict[int, int | str]: """Update usercodes.""" diff --git a/custom_components/lock_code_manager/providers/zwave_js.py b/custom_components/lock_code_manager/providers/zwave_js.py index dcec0fcb..1ec0743d 100644 --- a/custom_components/lock_code_manager/providers/zwave_js.py +++ b/custom_components/lock_code_manager/providers/zwave_js.py @@ -145,26 +145,13 @@ def code_slot_in_use(self, code_slot: int) -> bool | None: except (KeyError, ValueError): return None - def _resolve_pin_if_masked(self, value: str, code_slot: int) -> str | None: - """Resolve a PIN value, looking up expected PIN if masked. - - Some locks return masked values (all asterisks) instead of the actual PIN. - This method returns the value as-is if not masked, or looks up the expected - PIN from LCM entities if masked. - + def _get_slot_entity_states(self, code_slot: int) -> tuple[str, str, str] | None: """ - slot_in_use = self.code_slot_in_use(code_slot) + Get active state, PIN value, and PIN entity ID for a managed slot. - if value == "0" * len(value) and slot_in_use is False: - # Some locks return all zeros instead of a blank value when cleared - treat - # as unmasked cleared value - return "" - - # If not masked, return as-is - if not value or not (value == "*" * len(value) and slot_in_use): - return value - - # Masked - look up expected PIN from LCM entities + Returns tuple of (active_state, pin_value, pin_entity_id) if the slot is + managed by LCM and entities exist, None otherwise. + """ try: config_entry = next( entry @@ -175,9 +162,6 @@ def _resolve_pin_if_masked(self, value: str, code_slot: int) -> str | None: except StopIteration: return None - # Use ATTR_ACTIVE binary sensor (not CONF_ENABLED switch) to match what - # sync logic expects. ATTR_ACTIVE considers conditions, so we only resolve - # masked codes when sync expects a PIN on the lock. base_unique_id = f"{config_entry.entry_id}|{code_slot}" active_entity_id = self.ent_reg.async_get_entity_id( BINARY_SENSOR_DOMAIN, DOMAIN, f"{base_unique_id}|{ATTR_ACTIVE}" @@ -195,14 +179,60 @@ def _resolve_pin_if_masked(self, value: str, code_slot: int) -> str | None: if not active_state or not pin_state: return None - if active_state.state == STATE_ON: + return (active_state.state, pin_state.state, pin_entity_id) + + def _slot_expects_pin(self, code_slot: int) -> bool: + """ + Check if LCM expects a PIN on this slot (active=ON with PIN set). + + Used to ignore stale userIdStatus=AVAILABLE events from locks that report + old status after a code was successfully set. + """ + states = self._get_slot_entity_states(code_slot) + if states is None: + return False + + active_state, pin_value, _ = states + return active_state == STATE_ON and bool(pin_value) + + def _resolve_pin_if_masked(self, value: str, code_slot: int) -> str | None: + """ + Resolve a PIN value, looking up expected PIN if masked. + + Some locks return masked values (all asterisks) instead of the actual PIN. + This method returns the value as-is if not masked, or looks up the expected + PIN from LCM entities if masked. + + """ + slot_in_use = self.code_slot_in_use(code_slot) + + if value == "0" * len(value) and slot_in_use is False: + # Some locks return all zeros instead of a blank value when cleared - treat + # as unmasked cleared value + return "" + + # If not masked, return as-is + if not value or not (value == "*" * len(value) and slot_in_use): + return value + + # Masked - look up expected PIN from LCM entities + # Use ATTR_ACTIVE binary sensor (not CONF_ENABLED switch) to match what + # sync logic expects. ATTR_ACTIVE considers conditions, so we only resolve + # masked codes when sync expects a PIN on the lock. + states = self._get_slot_entity_states(code_slot) + if states is None: + return None + + active_state, pin_value, pin_entity_id = states + + if active_state == STATE_ON: _LOGGER.debug( "PIN is masked for lock %s code slot %s, assuming value from PIN entity %s", self.lock.entity_id, code_slot, pin_entity_id, ) - return pin_state.state + return pin_value # Fall back to returning masked value if active state is not ON (e.g. slot not # enabled) - we don't care what the value is, just that there is one so that @@ -251,6 +281,18 @@ def on_value_updated(event: dict[str, Any]) -> None: if property_name == LOCK_USERCODE_STATUS_PROPERTY: status = args.get("newValue") if status == CodeSlotStatus.AVAILABLE: + # Ignore AVAILABLE status if LCM 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._slot_expects_pin(code_slot): + _LOGGER.debug( + "Lock %s: ignoring userIdStatus=AVAILABLE for slot %s " + "(LCM expects PIN on this slot)", + self.lock.entity_id, + code_slot, + ) + return + # Slot was cleared - update coordinator if needed if self.coordinator and self.coordinator.data.get(code_slot) != "": _LOGGER.debug( diff --git a/tests/providers/test_zwave_js.py b/tests/providers/test_zwave_js.py index d457527a..2d8a99b3 100644 --- a/tests/providers/test_zwave_js.py +++ b/tests/providers/test_zwave_js.py @@ -1529,3 +1529,220 @@ async def test_resolve_pin_if_masked_all_zeros_slot_unknown( # All zeros with unknown slot status → returned as-is result = zwave_js_lock._resolve_pin_if_masked("0000", 1) assert result == "0000" + + +# _slot_expects_pin tests + + +async def test_slot_expects_pin_returns_true_when_active_with_pin( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + zwave_integration: MockConfigEntry, +) -> None: + """Test _slot_expects_pin returns True when active=ON and PIN is set.""" + lcm_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LOCKS: [zwave_js_lock.lock.entity_id], + CONF_SLOTS: {"2": {}}, + }, + ) + lcm_entry.add_to_hass(hass) + await zwave_js_lock.async_setup(lcm_entry) + + # Create the active and PIN entities + ent_reg = er.async_get(hass) + base_unique_id = f"{lcm_entry.entry_id}|2" + + active_entry = ent_reg.async_get_or_create( + "binary_sensor", + DOMAIN, + f"{base_unique_id}|active", + config_entry=lcm_entry, + ) + pin_entry = ent_reg.async_get_or_create( + "text", + DOMAIN, + f"{base_unique_id}|pin", + config_entry=lcm_entry, + ) + + # Set states: active=ON, pin="1234" + hass.states.async_set(active_entry.entity_id, "on") + hass.states.async_set(pin_entry.entity_id, "1234") + await hass.async_block_till_done() + + assert zwave_js_lock._slot_expects_pin(2) is True + + await zwave_js_lock.async_unload(False) + + +async def test_slot_expects_pin_returns_false_when_inactive( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + zwave_integration: MockConfigEntry, +) -> None: + """Test _slot_expects_pin returns False when active=OFF.""" + lcm_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LOCKS: [zwave_js_lock.lock.entity_id], + CONF_SLOTS: {"2": {}}, + }, + ) + lcm_entry.add_to_hass(hass) + await zwave_js_lock.async_setup(lcm_entry) + + # Create the active and PIN entities + ent_reg = er.async_get(hass) + base_unique_id = f"{lcm_entry.entry_id}|2" + + active_entry = ent_reg.async_get_or_create( + "binary_sensor", + DOMAIN, + f"{base_unique_id}|active", + config_entry=lcm_entry, + ) + pin_entry = ent_reg.async_get_or_create( + "text", + DOMAIN, + f"{base_unique_id}|pin", + config_entry=lcm_entry, + ) + + # Set states: active=OFF, pin="1234" + hass.states.async_set(active_entry.entity_id, "off") + hass.states.async_set(pin_entry.entity_id, "1234") + await hass.async_block_till_done() + + assert zwave_js_lock._slot_expects_pin(2) is False + + await zwave_js_lock.async_unload(False) + + +async def test_slot_expects_pin_returns_false_for_unmanaged_slot( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + zwave_integration: MockConfigEntry, +) -> None: + """Test _slot_expects_pin returns False for unmanaged slot.""" + lcm_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LOCKS: [zwave_js_lock.lock.entity_id], + CONF_SLOTS: {"1": {}}, # Only slot 1 managed + }, + ) + lcm_entry.add_to_hass(hass) + await zwave_js_lock.async_setup(lcm_entry) + + # Slot 99 is not managed + assert zwave_js_lock._slot_expects_pin(99) is False + + await zwave_js_lock.async_unload(False) + + +async def test_push_update_user_id_status_available_ignored_when_slot_expects_pin( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + zwave_integration: MockConfigEntry, + lock_schlage_be469: Node, +) -> None: + """Test that userIdStatus=AVAILABLE is ignored when slot expects a PIN. + + This prevents sync loops where the lock sends stale AVAILABLE status + after a code was successfully set. + """ + lcm_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LOCKS: [zwave_js_lock.lock.entity_id], + CONF_SLOTS: {"2": {}}, + }, + ) + lcm_entry.add_to_hass(hass) + await zwave_js_lock.async_setup(lcm_entry) + + # Set up a mock coordinator with existing PIN + mock_coordinator = MagicMock() + mock_coordinator.data = {2: "1234"} # Slot has a PIN + zwave_js_lock.coordinator = mock_coordinator + + # Subscribe to push updates + zwave_js_lock.subscribe_push_updates() + + # Mock _slot_expects_pin to return True (LCM expects a PIN on this slot) + with patch.object(zwave_js_lock, "_slot_expects_pin", return_value=True): + # Simulate userIdStatus=AVAILABLE event (stale status from lock) + event = ZwaveEvent( + type="value updated", + data={ + "args": { + "commandClass": CommandClass.USER_CODE, + "property": LOCK_USERCODE_STATUS_PROPERTY, + "propertyKey": 2, + "newValue": CodeSlotStatus.AVAILABLE, + }, + }, + ) + lock_schlage_be469.emit("value updated", event.data) + await hass.async_block_till_done() + + # Coordinator should NOT be updated (AVAILABLE ignored) + mock_coordinator.push_update.assert_not_called() + + zwave_js_lock.unsubscribe_push_updates() + await zwave_js_lock.async_unload(False) + + +async def test_push_update_user_id_status_available_clears_when_slot_inactive( + hass: HomeAssistant, + zwave_js_lock: ZWaveJSLock, + zwave_integration: MockConfigEntry, + lock_schlage_be469: Node, +) -> None: + """Test that userIdStatus=AVAILABLE clears slot when LCM doesn't expect a PIN. + + When the slot is inactive (active=OFF), AVAILABLE status should clear + the coordinator as expected. + """ + lcm_entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_LOCKS: [zwave_js_lock.lock.entity_id], + CONF_SLOTS: {"2": {}}, + }, + ) + lcm_entry.add_to_hass(hass) + await zwave_js_lock.async_setup(lcm_entry) + + # Set up a mock coordinator with existing PIN + mock_coordinator = MagicMock() + mock_coordinator.data = {2: "1234"} # Slot has a PIN + zwave_js_lock.coordinator = mock_coordinator + + # Subscribe to push updates + zwave_js_lock.subscribe_push_updates() + + # Mock _slot_expects_pin to return False (slot is inactive) + with patch.object(zwave_js_lock, "_slot_expects_pin", return_value=False): + # Simulate userIdStatus=AVAILABLE event + event = ZwaveEvent( + type="value updated", + data={ + "args": { + "commandClass": CommandClass.USER_CODE, + "property": LOCK_USERCODE_STATUS_PROPERTY, + "propertyKey": 2, + "newValue": CodeSlotStatus.AVAILABLE, + }, + }, + ) + lock_schlage_be469.emit("value updated", event.data) + await hass.async_block_till_done() + + # Coordinator SHOULD be updated (slot cleared) + mock_coordinator.push_update.assert_called_once_with({2: ""}) + + zwave_js_lock.unsubscribe_push_updates() + await zwave_js_lock.async_unload(False)