diff --git a/homeassistant/components/tailscale/coordinator.py b/homeassistant/components/tailscale/coordinator.py index 1b29cfbf4be..d1a0b540f47 100644 --- a/homeassistant/components/tailscale/coordinator.py +++ b/homeassistant/components/tailscale/coordinator.py @@ -8,6 +8,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -27,6 +28,7 @@ class TailscaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): api_key=config_entry.data[CONF_API_KEY], tailnet=config_entry.data[CONF_TAILNET], ) + self.previous_devices: set[str] = set() super().__init__( hass, @@ -37,8 +39,32 @@ class TailscaleDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Device]]): ) async def _async_update_data(self) -> dict[str, Device]: - """Fetch devices from Tailscale.""" + """Fetch devices from Tailscale and remove stale devices from HA.""" try: - return await self.tailscale.devices() + devices = await self.tailscale.devices() except TailscaleAuthenticationError as err: raise ConfigEntryAuthFailed from err + + # Get current device IDs + current_device_ids = set(devices.keys()) + + # Find devices that were removed from Tailscale + if self.previous_devices: + stale_device_ids = self.previous_devices - current_device_ids + if stale_device_ids: + await self._remove_stale_devices(stale_device_ids) + + # Update previous devices set for next comparison + self.previous_devices = current_device_ids + + return devices + + async def _remove_stale_devices(self, stale_device_ids: set[str]) -> None: + """Remove devices that no longer exist in Tailscale.""" + device_registry = dr.async_get(self.hass) + + for device_id in stale_device_ids: + device = device_registry.async_get_device(identifiers={(DOMAIN, device_id)}) + if device: + LOGGER.debug("Removing stale device: %s", device_id) + device_registry.async_remove_device(device.id) diff --git a/tests/components/tailscale/test_coordinator.py b/tests/components/tailscale/test_coordinator.py new file mode 100644 index 00000000000..363aab0d69f --- /dev/null +++ b/tests/components/tailscale/test_coordinator.py @@ -0,0 +1,76 @@ +"""Tests for the Tailscale coordinator.""" + +from datetime import timedelta +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from tests.common import MockConfigEntry + + +async def test_remove_stale_devices( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_tailscale: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that devices removed from Tailscale are removed from device registry.""" + # Verify initial devices exist (should be 3 from fixture) + devices = dr.async_entries_for_config_entry( + device_registry, init_integration.entry_id + ) + assert len(devices) == 3 + + # Store device IDs for later verification + device_ids = [list(device.identifiers)[0][1] for device in devices] + assert "123456" in device_ids + assert "123457" in device_ids + assert "123458" in device_ids + + # Simulate device removal in Tailscale (only device 123456 remains) + # Get the original device data from the mock + original_devices = mock_tailscale.devices.return_value + mock_tailscale.devices.return_value = {"123456": original_devices["123456"]} + + # Trigger natural refresh by advancing time + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + # Verify devices 123457 and 123458 were removed + remaining_devices = dr.async_entries_for_config_entry( + device_registry, init_integration.entry_id + ) + assert len(remaining_devices) == 1 + + remaining_device = remaining_devices[0] + remaining_id = list(remaining_device.identifiers)[0][1] + assert remaining_id == "123456" + assert remaining_device.name == "frencks-iphone" + + +async def test_no_devices_removed_when_all_present( + hass: HomeAssistant, + init_integration: MockConfigEntry, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test that no devices are removed when all Tailscale devices still exist.""" + # Verify initial devices exist (should be 3 from fixture) + initial_devices = dr.async_entries_for_config_entry( + device_registry, init_integration.entry_id + ) + assert len(initial_devices) == 3 + + # Trigger natural refresh (devices unchanged) + freezer.tick(timedelta(seconds=60)) + await hass.async_block_till_done() + + # Verify no devices were removed + final_devices = dr.async_entries_for_config_entry( + device_registry, init_integration.entry_id + ) + assert len(final_devices) == 3