1
0
mirror of https://github.com/home-assistant/core.git synced 2026-04-02 08:26:41 +01:00

Implement stale devices and update Liebherr to gold (#164666)

This commit is contained in:
mettolen
2026-03-20 17:31:09 +02:00
committed by GitHub
parent 229e1ee26b
commit 0c98bf2676
4 changed files with 168 additions and 6 deletions

View File

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

View File

@@ -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": [
{

View File

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

View File

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