1
0
mirror of https://github.com/home-assistant/core.git synced 2026-02-15 07:36:16 +00:00

Fix removal of stale Tailscale devices (#161084)

Co-authored-by: Stan Cope <976785+scopey@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
This commit is contained in:
Stan
2026-01-26 14:17:07 -05:00
committed by GitHub
parent 980c9bd9a0
commit 015df950f2
2 changed files with 104 additions and 2 deletions

View File

@@ -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)

View File

@@ -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