From 3e0f6562c7ee5c7a89828d6c517867ca22d5c8f8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 26 Jan 2025 21:57:32 +0100 Subject: [PATCH] Cleanup stale devices on incomfort integration startup (#136566) --- .../components/incomfort/__init__.py | 44 +++++++++++++- tests/components/incomfort/test_init.py | 58 ++++++++++++++++++- 2 files changed, 97 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/incomfort/__init__.py b/homeassistant/components/incomfort/__init__.py index 249a0ae9085..4d05a57bcfa 100644 --- a/homeassistant/components/incomfort/__init__.py +++ b/homeassistant/components/incomfort/__init__.py @@ -7,12 +7,12 @@ from incomfortclient import InvalidGateway, InvalidHeaterList from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers import device_registry as dr from .const import DOMAIN -from .coordinator import InComfortDataCoordinator, async_connect_gateway +from .coordinator import InComfortData, InComfortDataCoordinator, async_connect_gateway from .errors import InComfortTimeout, InComfortUnknownError, NoHeaters, NotFound PLATFORMS = ( @@ -27,6 +27,43 @@ INTEGRATION_TITLE = "Intergas InComfort/Intouch Lan2RF gateway" type InComfortConfigEntry = ConfigEntry[InComfortDataCoordinator] +@callback +def async_cleanup_stale_devices( + hass: HomeAssistant, + entry: InComfortConfigEntry, + data: InComfortData, + gateway_device: dr.DeviceEntry, +) -> None: + """Cleanup stale heater devices and climates.""" + heater_serial_numbers = {heater.serial_no for heater in data.heaters} + device_registry = dr.async_get(hass) + device_entries = device_registry.devices.get_devices_for_config_entry_id( + entry.entry_id + ) + stale_heater_serial_numbers: list[str] = [ + device_entry.serial_number + for device_entry in device_entries + if device_entry.id != gateway_device.id + and device_entry.serial_number is not None + and device_entry.serial_number not in heater_serial_numbers + ] + if not stale_heater_serial_numbers: + return + cleanup_devices: list[str] = [] + # Find stale heater and climate devices + for serial_number in stale_heater_serial_numbers: + cleanup_list = [f"{serial_number}_{index}" for index in range(1, 4)] + cleanup_list.append(serial_number) + cleanup_identifiers = [{(DOMAIN, cleanup_id)} for cleanup_id in cleanup_list] + cleanup_devices.extend( + device_entry.id + for device_entry in device_entries + if device_entry.identifiers in cleanup_identifiers + ) + for device_id in cleanup_devices: + device_registry.async_remove_device(device_id) + + async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> bool: """Set up a config entry.""" try: @@ -46,7 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> # Register discovered gateway device device_registry = dr.async_get(hass) - device_registry.async_get_or_create( + gateway_device = device_registry.async_get_or_create( config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, connections={(dr.CONNECTION_NETWORK_MAC, entry.unique_id)} @@ -55,6 +92,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: InComfortConfigEntry) -> manufacturer="Intergas", name="RFGateway", ) + async_cleanup_stale_devices(hass, entry, data, gateway_device) coordinator = InComfortDataCoordinator(hass, data, entry.entry_id) entry.runtime_data = coordinator await coordinator.async_config_entry_first_refresh() diff --git a/tests/components/incomfort/test_init.py b/tests/components/incomfort/test_init.py index a9b3a8e4e3a..92ce0afa448 100644 --- a/tests/components/incomfort/test_init.py +++ b/tests/components/incomfort/test_init.py @@ -1,6 +1,7 @@ """Tests for Intergas InComfort integration.""" from datetime import timedelta +from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from aiohttp import ClientResponseError, RequestInfo @@ -8,13 +9,17 @@ from freezegun.api import FrozenDateTimeFactory from incomfortclient import InvalidGateway, InvalidHeaterList import pytest +from homeassistant.components.incomfort import DOMAIN from homeassistant.components.incomfort.coordinator import UPDATE_INTERVAL from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.device_registry import DeviceRegistry -from tests.common import async_fire_time_changed +from .conftest import MOCK_HEATER_STATUS + +from tests.common import MockConfigEntry, async_fire_time_changed @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -22,13 +27,62 @@ async def test_setup_platforms( hass: HomeAssistant, mock_incomfort: MagicMock, entity_registry: er.EntityRegistry, - mock_config_entry: ConfigEntry, + mock_config_entry: MockConfigEntry, ) -> None: """Test the incomfort integration is set up correctly.""" await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "mock_heater_status", [MOCK_HEATER_STATUS | {"serial_no": "c01d00c0ffee"}] +) +async def test_stale_devices_cleanup( + hass: HomeAssistant, + device_registry: DeviceRegistry, + mock_incomfort: MagicMock, + entity_registry: er.EntityRegistry, + mock_config_entry: MockConfigEntry, + mock_heater_status: dict[str, Any], +) -> None: + """Test the incomfort integration is cleaning up stale devices.""" + # Setup an old heater with serial_no c01d00c0ffee + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + await hass.config_entries.async_unload(mock_config_entry.entry_id) + old_entries = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(old_entries) == 3 + old_heater = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee")}) + assert old_heater is not None + assert old_heater.serial_number == "c01d00c0ffee" + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_heater is not None + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_climate is not None + + mock_heater_status["serial_no"] = "c0ffeec0ffee" + await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert mock_config_entry.state is ConfigEntryState.LOADED + + new_entries = device_registry.devices.get_devices_for_config_entry_id( + mock_config_entry.entry_id + ) + assert len(new_entries) == 3 + new_heater = device_registry.async_get_device({(DOMAIN, "c0ffeec0ffee")}) + assert new_heater is not None + assert new_heater.serial_number == "c0ffeec0ffee" + new_climate = device_registry.async_get_device({(DOMAIN, "c0ffeec0ffee_1")}) + assert new_climate is not None + + old_heater = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee")}) + assert old_heater is None + old_climate = device_registry.async_get_device({(DOMAIN, "c01d00c0ffee_1")}) + assert old_climate is None + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_coordinator_updates( hass: HomeAssistant,