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:
@@ -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:
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user