From 0c98bf2676152f6d977ae2018ba0d3f3ec4c3532 Mon Sep 17 00:00:00 2001 From: mettolen <1007649+mettolen@users.noreply.github.com> Date: Fri, 20 Mar 2026 17:31:09 +0200 Subject: [PATCH] Implement stale devices and update Liebherr to gold (#164666) --- homeassistant/components/liebherr/__init__.py | 23 +++ .../components/liebherr/manifest.json | 2 +- .../components/liebherr/quality_scale.yaml | 2 +- tests/components/liebherr/test_init.py | 147 +++++++++++++++++- 4 files changed, 168 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/liebherr/__init__.py b/homeassistant/components/liebherr/__init__.py index 90c0c953ffa..647fa90b133 100644 --- a/homeassistant/components/liebherr/__init__.py +++ b/homeassistant/components/liebherr/__init__.py @@ -15,6 +15,7 @@ from pyliebherrhomeapi.exceptions import ( from homeassistant.const import CONF_API_KEY, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_time_interval @@ -83,6 +84,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) -> _LOGGER.exception("Unexpected error scanning for new devices") return + # Remove stale devices no longer returned by the API + current_device_ids = {device.device_id for device in devices} + device_registry = dr.async_get(hass) + for device_entry in dr.async_entries_for_config_entry( + device_registry, entry.entry_id + ): + device_ids = { + identifier[1] + for identifier in device_entry.identifiers + if identifier[0] == DOMAIN + } + if device_ids - current_device_ids: + # Shut down coordinator if one exists + for device_id in device_ids: + if coordinator := data.coordinators.pop(device_id, None): + await coordinator.async_shutdown() + device_registry.async_update_device( + device_id=device_entry.id, + remove_config_entry_id=entry.entry_id, + ) + + # Add new devices new_coordinators: list[LiebherrCoordinator] = [] for device in devices: if device.device_id not in data.coordinators: diff --git a/homeassistant/components/liebherr/manifest.json b/homeassistant/components/liebherr/manifest.json index 0a6fe54d068..08c69a441a1 100644 --- a/homeassistant/components/liebherr/manifest.json +++ b/homeassistant/components/liebherr/manifest.json @@ -7,7 +7,7 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["pyliebherrhomeapi"], - "quality_scale": "silver", + "quality_scale": "gold", "requirements": ["pyliebherrhomeapi==0.4.0"], "zeroconf": [ { diff --git a/homeassistant/components/liebherr/quality_scale.yaml b/homeassistant/components/liebherr/quality_scale.yaml index befd61046e4..712bedd1c2a 100644 --- a/homeassistant/components/liebherr/quality_scale.yaml +++ b/homeassistant/components/liebherr/quality_scale.yaml @@ -68,7 +68,7 @@ rules: repair-issues: status: exempt comment: No repair issues to implement at this time. - stale-devices: todo + stale-devices: done # Platinum async-dependency: done diff --git a/tests/components/liebherr/test_init.py b/tests/components/liebherr/test_init.py index 21e4a84d785..85009e10fbe 100644 --- a/tests/components/liebherr/test_init.py +++ b/tests/components/liebherr/test_init.py @@ -27,6 +27,7 @@ from homeassistant.components.liebherr.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE @@ -194,11 +195,33 @@ async def test_dynamic_device_discovery_api_error( assert mock_config_entry.state is ConfigEntryState.LOADED +@pytest.mark.usefixtures("init_integration") +async def test_dynamic_device_discovery_unexpected_error( + hass: HomeAssistant, + mock_liebherr_client: MagicMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test device scan gracefully handles unexpected errors.""" + mock_liebherr_client.get_devices.side_effect = RuntimeError("Unexpected") + + initial_states = len(hass.states.async_all()) + + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # No crash, no new entities + assert len(hass.states.async_all()) == initial_states + assert mock_config_entry.state is ConfigEntryState.LOADED + + @pytest.mark.usefixtures("init_integration") async def test_dynamic_device_discovery_coordinator_setup_failure( hass: HomeAssistant, mock_liebherr_client: MagicMock, mock_config_entry: MockConfigEntry, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test device scan skips devices that fail coordinator setup.""" @@ -217,7 +240,7 @@ async def test_dynamic_device_discovery_coordinator_setup_failure( await hass.async_block_till_done() # New device should NOT be added - assert "new_device_id" not in mock_config_entry.runtime_data.coordinators + assert not device_registry.async_get_device(identifiers={(DOMAIN, "new_device_id")}) assert mock_config_entry.state is ConfigEntryState.LOADED @@ -225,6 +248,7 @@ async def test_dynamic_device_discovery( hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_liebherr_client: MagicMock, + device_registry: dr.DeviceRegistry, freezer: FrozenDateTimeFactory, ) -> None: """Test new devices are automatically discovered on all platforms.""" @@ -268,6 +292,121 @@ async def test_dynamic_device_discovery( # Original device should still exist assert hass.states.get("sensor.test_fridge_top_zone") is not None - # Runtime data should have both coordinators - assert "new_device_id" in mock_config_entry.runtime_data.coordinators - assert "test_device_id" in mock_config_entry.runtime_data.coordinators + # Both devices should be in the device registry + assert device_registry.async_get_device(identifiers={(DOMAIN, "new_device_id")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) + + +async def test_stale_device_removal( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_liebherr_client: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test stale devices are removed when no longer returned by the API.""" + mock_config_entry.add_to_hass(hass) + + all_platforms = [ + Platform.SENSOR, + Platform.NUMBER, + Platform.SWITCH, + Platform.SELECT, + ] + + # Start with two devices + mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, NEW_DEVICE] + mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: ( + copy.deepcopy( + NEW_DEVICE_STATE if device_id == "new_device_id" else MOCK_DEVICE_STATE + ) + ) + + with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", all_platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Both devices should exist + assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "new_device_id")}) + assert hass.states.get("sensor.test_fridge_top_zone") is not None + assert hass.states.get("sensor.new_fridge") is not None + + # Verify both devices are in the device registry + assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) + new_device_entry = device_registry.async_get_device( + identifiers={(DOMAIN, "new_device_id")} + ) + assert new_device_entry + + # Simulate the new device being removed from the account. + # Make get_device_state raise for new_device_id so we can detect + # if the stale coordinator is still polling after shutdown. + mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE] + + def _get_device_state_after_removal(device_id: str, **kw: Any) -> DeviceState: + if device_id == "new_device_id": + raise AssertionError( + "get_device_state called for removed device new_device_id" + ) + return copy.deepcopy(MOCK_DEVICE_STATE) + + mock_liebherr_client.get_device_state.side_effect = _get_device_state_after_removal + + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Stale device should be removed from device registry + assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) + assert not device_registry.async_get_device(identifiers={(DOMAIN, "new_device_id")}) + + # Advance past the coordinator update interval to confirm the stale + # coordinator is no longer polling (would raise AssertionError above) + freezer.tick(timedelta(seconds=61)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Original device should still work + assert hass.states.get("sensor.test_fridge_top_zone") is not None + assert mock_config_entry.state is ConfigEntryState.LOADED + + +async def test_stale_device_removal_without_coordinator( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_liebherr_client: MagicMock, + device_registry: dr.DeviceRegistry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test stale devices removed before startup are cleaned up on scan.""" + mock_config_entry.add_to_hass(hass) + + # Create a device registry entry for a device that was previously known + # but is no longer returned by the API (removed while HA was offline). + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, "old_device_id")}, + name="Old Appliance", + ) + assert device_registry.async_get_device(identifiers={(DOMAIN, "old_device_id")}) + + # Start integration — only MOCK_DEVICE is returned, so no coordinator + # is created for "old_device_id". + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # The orphaned device still exists in the registry after setup + assert device_registry.async_get_device(identifiers={(DOMAIN, "old_device_id")}) + assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) + + # Trigger the periodic device scan + freezer.tick(timedelta(minutes=5, seconds=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # The orphaned device should now be removed from the registry + assert not device_registry.async_get_device(identifiers={(DOMAIN, "old_device_id")}) + # The active device should still be present + assert device_registry.async_get_device(identifiers={(DOMAIN, "test_device_id")}) + assert mock_config_entry.state is ConfigEntryState.LOADED