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:
@@ -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)
|
||||
|
||||
76
tests/components/tailscale/test_coordinator.py
Normal file
76
tests/components/tailscale/test_coordinator.py
Normal 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
|
||||
Reference in New Issue
Block a user