From c42b50418ed2c2a8d52dbbca99a611da16a96978 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:19:20 +0200 Subject: [PATCH] Add stale device removal support to UniFi Access (#166792) Co-authored-by: RaHehl --- .../components/unifi_access/coordinator.py | 21 +++++++ .../unifi_access/quality_scale.yaml | 2 +- tests/components/unifi_access/test_init.py | 56 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/unifi_access/coordinator.py b/homeassistant/components/unifi_access/coordinator.py index af29b9e2ae4..480b5f81902 100644 --- a/homeassistant/components/unifi_access/coordinator.py +++ b/homeassistant/components/unifi_access/coordinator.py @@ -37,6 +37,7 @@ from unifi_access_api.models.websocket import ( from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN @@ -194,6 +195,9 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): supports_lock_rules = bool(door_lock_rules) or bool(unconfirmed_lock_rule_doors) + current_ids = {door.id for door in doors} | {self.config_entry.entry_id} + self._remove_stale_devices(current_ids) + return UnifiAccessData( doors={door.id: door for door in doors}, emergency=emergency, @@ -221,6 +225,23 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]): except ApiNotFoundError: return None + @callback + def _remove_stale_devices(self, current_ids: set[str]) -> None: + """Remove devices for doors that no longer exist on the hub.""" + device_registry = dr.async_get(self.hass) + for device in dr.async_entries_for_config_entry( + device_registry, self.config_entry.entry_id + ): + if any( + identifier[0] == DOMAIN and identifier[1] in current_ids + for identifier in device.identifiers + ): + continue + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) + def _on_ws_connect(self) -> None: """Handle WebSocket connection established.""" _LOGGER.debug("WebSocket connected to UniFi Access") diff --git a/homeassistant/components/unifi_access/quality_scale.yaml b/homeassistant/components/unifi_access/quality_scale.yaml index f666252ce50..66dc50f69d2 100644 --- a/homeassistant/components/unifi_access/quality_scale.yaml +++ b/homeassistant/components/unifi_access/quality_scale.yaml @@ -64,7 +64,7 @@ rules: repair-issues: status: exempt comment: Integration raises ConfigEntryAuthFailed and relies on Home Assistant core to surface reauth/repair issues, no custom repairs are defined. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/tests/components/unifi_access/test_init.py b/tests/components/unifi_access/test_init.py index 889fad4a32c..733a72be5f9 100644 --- a/tests/components/unifi_access/test_init.py +++ b/tests/components/unifi_access/test_init.py @@ -23,8 +23,10 @@ from unifi_access_api.models.websocket import ( WebsocketMessage, ) +from homeassistant.components.unifi_access.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from tests.common import MockConfigEntry @@ -353,3 +355,57 @@ async def test_ws_location_update_thumbnail_only_no_state( # Door state unchanged, thumbnail updated assert hass.states.get(FRONT_DOOR_BINARY_SENSOR).state == state_before assert hass.states.get(FRONT_DOOR_IMAGE).state != image_state_before + + +async def test_stale_device_removed_on_refresh( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + init_integration: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test that stale devices are automatically removed on data refresh.""" + # Verify both doors exist after initial setup + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-001")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-002")}) + + # Simulate door-002 being removed from the hub + mock_client.get_doors.return_value = [ + door for door in mock_client.get_doors.return_value if door.id != "door-002" + ] + + # Trigger natural refresh via WebSocket reconnect + on_disconnect = mock_client.start_websocket.call_args[1]["on_disconnect"] + on_connect = mock_client.start_websocket.call_args[1]["on_connect"] + on_disconnect() + await hass.async_block_till_done() + on_connect() + await hass.async_block_till_done() + + # door-001 still exists, door-002 was removed + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-001")}) + assert not device_registry.async_get_device(identifiers={(DOMAIN, "door-002")}) + + +async def test_stale_device_removed_on_startup( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, + mock_client: MagicMock, +) -> None: + """Test stale devices present before setup are removed on initial refresh.""" + mock_config_entry.add_to_hass(hass) + + # Create a stale door device that no longer exists on the hub + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "door-003")}, + ) + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-003")}) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Valid doors from the hub should exist, stale device should be removed + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-001")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "door-002")}) + assert not device_registry.async_get_device(identifiers={(DOMAIN, "door-003")})