From 9595759fd18d5051941634f78e75f1178027dc2f Mon Sep 17 00:00:00 2001 From: Brett Adams Date: Mon, 11 Aug 2025 21:54:44 +1000 Subject: [PATCH] Add stale device cleanup to Teslemetry (#144523) --- .../components/teslemetry/__init__.py | 22 +++++ tests/components/teslemetry/test_init.py | 94 ++++++++++++++++++- 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/teslemetry/__init__.py b/homeassistant/components/teslemetry/__init__.py index 688a254a731..af4ce26a0cc 100644 --- a/homeassistant/components/teslemetry/__init__.py +++ b/homeassistant/components/teslemetry/__init__.py @@ -97,6 +97,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - # Create the stream stream: TeslemetryStream | None = None + # Remember each device identifier we create + current_devices: set[tuple[str, str]] = set() + for product in products: if ( "vin" in product @@ -116,6 +119,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - model=api.model, serial_number=vin, ) + current_devices.add((DOMAIN, vin)) # Create stream if required if not stream: @@ -171,6 +175,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - name=product.get("site_name", "Energy Site"), serial_number=str(site_id), ) + current_devices.add((DOMAIN, str(site_id))) + + if wall_connector: + for connector in product["components"]["wall_connectors"]: + current_devices.add((DOMAIN, connector["din"])) # Check live status endpoint works before creating its coordinator try: @@ -235,6 +244,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) - config_entry_id=entry.entry_id, **energysite.device ) + # Remove devices that are no longer present + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + if not any( + identifier in current_devices for identifier in device_entry.identifiers + ): + LOGGER.debug("Removing stale device %s", device_entry.id) + device_registry.async_update_device( + device_id=device_entry.id, + remove_config_entry_id=entry.entry_id, + ) + # Setup Platforms entry.runtime_data = TeslemetryData(vehicles, energysites, scopes, stream) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/tests/components/teslemetry/test_init.py b/tests/components/teslemetry/test_init.py index e177865d2f9..00e8d54c9fe 100644 --- a/tests/components/teslemetry/test_init.py +++ b/tests/components/teslemetry/test_init.py @@ -1,6 +1,6 @@ """Test the Teslemetry init.""" -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch from freezegun.api import FrozenDateTimeFactory import pytest @@ -11,6 +11,7 @@ from tesla_fleet_api.exceptions import ( TeslaFleetError, ) +from homeassistant.components.teslemetry.const import DOMAIN from homeassistant.components.teslemetry.coordinator import VEHICLE_INTERVAL from homeassistant.components.teslemetry.models import TeslemetryData from homeassistant.config_entries import ConfigEntryState @@ -187,3 +188,94 @@ async def test_modern_no_poll( assert mock_vehicle_data.called is False freezer.tick(VEHICLE_INTERVAL) assert mock_vehicle_data.called is False + + +async def test_stale_device_removal( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_products: AsyncMock, +) -> None: + """Test removal of stale devices.""" + + # Setup the entry first to get a valid config_entry_id + entry = await setup_platform(hass) + + # Create a device that should be removed (with the valid entry_id) + device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, "stale-vin")}, + manufacturer="Tesla", + name="Stale Vehicle", + ) + + # Verify the stale device exists + pre_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + stale_identifiers = { + identifier for device in pre_devices for identifier in device.identifiers + } + assert (DOMAIN, "stale-vin") in stale_identifiers + + # Update products with an empty response (no devices) and reload entry + with patch( + "tesla_fleet_api.teslemetry.Teslemetry.products", + return_value={"response": []}, + ): + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Get updated devices after reload + post_devices = dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ) + post_identifiers = { + identifier for device in post_devices for identifier in device.identifiers + } + + # Verify the stale device has been removed + assert (DOMAIN, "stale-vin") not in post_identifiers + + # Verify the device itself has been completely removed from the registry + # since it had no other config entries + updated_device = device_registry.async_get_device( + identifiers={(DOMAIN, "stale-vin")} + ) + assert updated_device is None + + +async def test_device_retention_during_reload( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + mock_products: AsyncMock, +) -> None: + """Test that valid devices are retained during a config entry reload.""" + # Setup entry with normal devices + entry = await setup_platform(hass) + + # Get initial device count and identifiers + pre_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + pre_count = len(pre_devices) + pre_identifiers = { + identifier for device in pre_devices for identifier in device.identifiers + } + + # Make sure we have some devices + assert pre_count > 0 + + # Save the original identifiers to compare after reload + original_identifiers = pre_identifiers.copy() + + # Reload the config entry with the same products data + # The mock_products fixture will return the same data as during setup + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + + # Verify device count and identifiers after reload match pre-reload + post_devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + post_count = len(post_devices) + post_identifiers = { + identifier for device in post_devices for identifier in device.identifiers + } + + # Since the products data didn't change, we should have the same devices + assert post_count == pre_count + assert post_identifiers == original_identifiers