From f4e2d68a261604dce22b146b7b515d9b447ffae4 Mon Sep 17 00:00:00 2001 From: Rodrigo Roque Date: Fri, 7 Aug 2026 08:04:01 -0300 Subject: [PATCH] fix: remove registry devices for locks that leave the entry's key set Reauth and reconfigure replace the stored keys wholesale with whatever the cloud returns, so a lock removed from the TTLock account lingered as a dead device that the UI refused to delete. Setup now prunes registry devices whose MAC is no longer among the entry's keys, and async_remove_config_entry_device lets the UI delete any device whose lock is gone while protecting the configured ones. --- custom_components/ttlock_ble/__init__.py | 57 ++++++++++ tests/test_init.py | 128 +++++++++++++++++++++++ 2 files changed, 185 insertions(+) diff --git a/custom_components/ttlock_ble/__init__.py b/custom_components/ttlock_ble/__init__.py index 702ea2f..6e14a41 100644 --- a/custom_components/ttlock_ble/__init__.py +++ b/custom_components/ttlock_ble/__init__.py @@ -7,6 +7,13 @@ from homeassistant.const import CONF_SCAN_INTERVAL, Platform from homeassistant.core import callback +from homeassistant.helpers.device_registry import ( + async_entries_for_config_entry, + format_mac, +) +from homeassistant.helpers.device_registry import ( + async_get as async_get_device_registry, +) from ttlock_ble import VirtualKey @@ -18,12 +25,14 @@ DEFAULT_RECONNECT_INTERVAL_SECONDS, DEFAULT_SCAN_INTERVAL_SECONDS, DOMAIN, + LOGGER, ) from .coordinator import TtlockBleDataUpdateCoordinator from .data import TtlockBleData if TYPE_CHECKING: from homeassistant.core import HomeAssistant + from homeassistant.helpers.device_registry import DeviceEntry from .data import ( TtlockBleConfigData, @@ -39,12 +48,60 @@ ] +def _configured_macs(config: TtlockBleConfigData) -> set[str]: + """Return the formatted MAC of every lock the entry currently holds.""" + return {format_mac(key["lockMac"]) for key in config["keys"]} + + +def _device_macs(device: DeviceEntry) -> set[str]: + """Return the formatted MACs this integration stamped on a device.""" + return {identifier for domain, identifier in device.identifiers if domain == DOMAIN} + + +@callback +def _async_prune_stale_devices( + hass: HomeAssistant, + entry: TtlockBleConfigEntry, + config: TtlockBleConfigData, +) -> None: + """ + Drop registry devices whose lock left the entry's key set. + + Reauth and reconfigure replace `keys` wholesale with whatever the + cloud returns, so a lock removed from the account would otherwise + linger as a dead device until the user deletes it by hand. + """ + device_registry = async_get_device_registry(hass) + configured = _configured_macs(config) + for device in async_entries_for_config_entry(device_registry, entry.entry_id): + if not _device_macs(device) & configured: + LOGGER.info( + "Removing device %s: its lock is no longer in the entry's keys", + device.name or device.id, + ) + device_registry.async_update_device( + device.id, + remove_config_entry_id=entry.entry_id, + ) + + +async def async_remove_config_entry_device( + hass: HomeAssistant, # noqa: ARG001 + entry: TtlockBleConfigEntry, + device_entry: DeviceEntry, +) -> bool: + """Allow deleting a device once its lock is gone from the entry's keys.""" + config = cast("TtlockBleConfigData", entry.data) + return not _device_macs(device_entry) & _configured_macs(config) + + async def async_setup_entry( hass: HomeAssistant, entry: TtlockBleConfigEntry, ) -> bool: """Set up TTLock BLE from a config entry.""" config = cast("TtlockBleConfigData", entry.data) + _async_prune_stale_devices(hass, entry, config) stored_keys: list[TtlockBleStoredKey] = list(config["keys"]) virtual_keys = [VirtualKey.from_dict(dict(k)) for k in stored_keys] diff --git a/tests/test_init.py b/tests/test_init.py index 26ef3cd..2d950b7 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -226,6 +226,134 @@ async def test_reconnect_options_reach_the_connections( ) +async def test_setup_prunes_devices_for_removed_locks( + hass, + sample_stored_key, + enable_bluetooth, + enable_custom_integrations, + mock_cloud, + mock_ttlock_connection, +) -> None: + """A device whose lock left the entry's keys is removed on setup.""" + from homeassistant.helpers import device_registry + from pytest_homeassistant_custom_component.common import MockConfigEntry + + from custom_components.ttlock_ble.const import DOMAIN + + entry = MockConfigEntry( + domain=DOMAIN, + data={"username": "u", "password": "p", "keys": [sample_stored_key]}, + unique_id="u", + ) + entry.add_to_hass(hass) + registry = device_registry.async_get(hass) + stale_mac = "aa:bb:cc:dd:ee:00" + registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, stale_mac)}, + name="Removed lock", + ) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert registry.async_get_device(identifiers={(DOMAIN, stale_mac)}) is None + assert ( + registry.async_get_device(identifiers={(DOMAIN, "aa:bb:cc:dd:ee:ff")}) + is not None + ) + + +async def test_remove_config_entry_device_denies_a_configured_lock( + hass, setup_integration +) -> None: + """The lock is still in the entry's keys, so the device must stay.""" + from homeassistant.helpers import device_registry + + from custom_components.ttlock_ble import async_remove_config_entry_device + from custom_components.ttlock_ble.const import DOMAIN + + registry = device_registry.async_get(hass) + device = registry.async_get_device(identifiers={(DOMAIN, "aa:bb:cc:dd:ee:ff")}) + assert device is not None + assert not await async_remove_config_entry_device(hass, setup_integration, device) + + +async def test_remove_config_entry_device_allows_a_stale_lock( + hass, setup_integration +) -> None: + """A device with no lock in the entry's keys can be deleted from the UI.""" + from homeassistant.helpers import device_registry + + from custom_components.ttlock_ble import async_remove_config_entry_device + from custom_components.ttlock_ble.const import DOMAIN + + registry = device_registry.async_get(hass) + device = registry.async_get_or_create( + config_entry_id=setup_integration.entry_id, + identifiers={(DOMAIN, "aa:bb:cc:dd:ee:00")}, + name="Removed lock", + ) + assert await async_remove_config_entry_device(hass, setup_integration, device) + + +async def test_reauth_key_refresh_prunes_the_replaced_lock( + hass, + sample_stored_key, + sample_virtual_key, + enable_bluetooth, + enable_custom_integrations, + mock_cloud, + mock_ttlock_connection, +) -> None: + """Reauth replacing the key set drops the devices the cloud stopped listing.""" + from dataclasses import replace + + from homeassistant.helpers import device_registry + from pytest_homeassistant_custom_component.common import MockConfigEntry + + from custom_components.ttlock_ble.const import DOMAIN + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + "username": "user@example.com", + "password": "pass", + "keys": [sample_stored_key], + }, + unique_id="user_example_com", + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + registry = device_registry.async_get(hass) + assert ( + registry.async_get_device(identifiers={(DOMAIN, "aa:bb:cc:dd:ee:ff")}) + is not None + ) + + replacement_key = replace( + sample_virtual_key, keyId=2, lockId=43, lockMac="11:22:33:44:55:66" + ) + mock_cloud.list_keys.return_value = [replacement_key] + result = await entry.start_reauth_flow(hass) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"username": "user@example.com", "password": "new-pass"}, + ) + await hass.async_block_till_done() + + assert result["type"] == "abort" + assert result["reason"] == "reauth_successful" + assert ( + registry.async_get_device(identifiers={(DOMAIN, "aa:bb:cc:dd:ee:ff")}) is None + ) + assert ( + registry.async_get_device(identifiers={(DOMAIN, "11:22:33:44:55:66")}) + is not None + ) + + async def test_failed_platform_setup_still_stops_the_connections( hass, sample_stored_key,