fix(review): fix(review): pick station device by entity count, drop ghost devices
parent
8c297cf90c
commit
c510b64260
|
|
@ -182,17 +182,42 @@ def _adopt_devices(hass: HomeAssistant, entry: ConfigEntry, old_entry: ConfigEnt
|
||||||
|
|
||||||
A predecessor entry can own more than one device (v2.0.0pre1 created a second one per
|
A predecessor entry can own more than one device (v2.0.0pre1 created a second one per
|
||||||
Ecowitt channel), and only one device may hold a given identifier: passing the same
|
Ecowitt channel), and only one device may hold a given identifier: passing the same
|
||||||
set twice raises `DeviceIdentifierCollisionError` and would fail setup for good. The
|
set twice raises `DeviceIdentifierCollisionError` and would fail setup for good. So
|
||||||
extra devices therefore keep their own identifiers and are only repointed, which
|
exactly one of them can be the device our platforms resolve, and which one must not
|
||||||
leaves them attached to this entry and reapable once they hold nothing.
|
be left to the order the registry happens to yield. Once the domain really changes,
|
||||||
|
nothing holds the target identifiers yet, so a per-channel row that happened to come
|
||||||
|
first would become the station device and every adopted entity would be moved onto
|
||||||
|
it, stranding the real one with the user's name, area and labels on it. The device
|
||||||
|
carrying the most entities wins instead, ties broken on `device.id` so the outcome
|
||||||
|
does not depend on storage order at all. The others keep their own identifiers and
|
||||||
|
are only repointed.
|
||||||
|
|
||||||
|
A device carrying no entities is left alone entirely. Attaching this entry to it
|
||||||
|
would keep it alive forever: `device_registry.async_cleanup` subtracts
|
||||||
|
`references_config_entries` from the orphan set, so a device linked to any live
|
||||||
|
config entry is never reaped, and an empty device page would sit under this
|
||||||
|
integration for good. Left untouched, `async_remove(old_entry)` takes its last config
|
||||||
|
entry away and deletes it. Nothing rides on it - the remove branch of
|
||||||
|
`entity_registry.async_device_modified` only deletes entities whose `config_entry_id`
|
||||||
|
is one of the removed device's own, and by this point the adopted ones point at this
|
||||||
|
entry.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
device_registry = dr.async_get(hass)
|
device_registry = dr.async_get(hass)
|
||||||
|
registry = er.async_get(hass)
|
||||||
identifiers = set(build_device_info(entry)["identifiers"])
|
identifiers = set(build_device_info(entry)["identifiers"])
|
||||||
|
|
||||||
for device in dr.async_entries_for_config_entry(device_registry, old_entry.entry_id):
|
devices = dr.async_entries_for_config_entry(device_registry, old_entry.entry_id)
|
||||||
|
held = {
|
||||||
|
device.id: len(er.async_entries_for_device(registry, device.id, include_disabled_entities=True))
|
||||||
|
for device in devices
|
||||||
|
}
|
||||||
|
populated = [device for device in devices if held[device.id]]
|
||||||
|
canonical = min(populated, key=lambda device: (-held[device.id], device.id), default=None)
|
||||||
|
|
||||||
|
for device in populated:
|
||||||
holder = device_registry.async_get_device(identifiers=identifiers)
|
holder = device_registry.async_get_device(identifiers=identifiers)
|
||||||
if holder is not None and holder.id != device.id:
|
if device is not canonical or (holder is not None and holder.id != device.id):
|
||||||
device_registry.async_update_device(device.id, add_config_entry_id=entry.entry_id)
|
device_registry.async_update_device(device.id, add_config_entry_id=entry.entry_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -280,6 +305,13 @@ async def async_adopt_predecessor(
|
||||||
"Restart Home Assistant and try again",
|
"Restart Home Assistant and try again",
|
||||||
old_entry.entry_id,
|
old_entry.entry_id,
|
||||||
)
|
)
|
||||||
|
# Counted as left behind rather than passed over in silence. This is the
|
||||||
|
# run where *nothing* moved, so it is also where deleting the old entry
|
||||||
|
# by hand would cost the most - exactly what the repair notice is for.
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
result.live.extend(
|
||||||
|
old.entity_id for old in er.async_entries_for_config_entry(registry, old_entry.entry_id)
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Scoped to this predecessor entry, then folded in: whether an entry may be
|
# Scoped to this predecessor entry, then folded in: whether an entry may be
|
||||||
|
|
|
||||||
|
|
@ -365,6 +365,80 @@ async def test_a_second_device_does_not_collide(hass: HomeAssistant, entries) ->
|
||||||
assert new.entry_id in kept.config_entries
|
assert new.entry_id in kept.config_entries
|
||||||
|
|
||||||
|
|
||||||
|
async def test_the_station_device_wins_regardless_of_registry_order(hass: HomeAssistant, entries) -> None:
|
||||||
|
"""Which device becomes ours must not be decided by storage order.
|
||||||
|
|
||||||
|
Registered here in the order that punishes "first one claims the identifiers": the
|
||||||
|
single-entity channel row comes first. After the rename nothing holds the target
|
||||||
|
identifiers yet, so nothing else would stop it - and the station device, carrying the
|
||||||
|
user's own name, area and labels, would be stranded on the old-domain identifier
|
||||||
|
while every adopted entity is moved onto the channel row.
|
||||||
|
"""
|
||||||
|
old, new = entries
|
||||||
|
device_registry = dr.async_get(hass)
|
||||||
|
channel = device_registry.async_get_or_create(
|
||||||
|
config_entry_id=old.entry_id,
|
||||||
|
identifiers={(OLD_DOMAIN, "ecowitt_ch1")},
|
||||||
|
name="Channel 1",
|
||||||
|
)
|
||||||
|
station = device_registry.async_get_or_create(
|
||||||
|
config_entry_id=old.entry_id,
|
||||||
|
identifiers={(OLD_DOMAIN, "station")},
|
||||||
|
name="Weather Station",
|
||||||
|
)
|
||||||
|
ordered = [d.id for d in dr.async_entries_for_config_entry(device_registry, old.entry_id)]
|
||||||
|
assert ordered[0] == channel.id, "fixture no longer puts the smaller device first"
|
||||||
|
|
||||||
|
_seed(hass, old, device_id=station.id)
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
registry.async_get_or_create("sensor", OLD_DOMAIN, "ch1_temp", config_entry=old, device_id=channel.id)
|
||||||
|
|
||||||
|
await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
|
||||||
|
|
||||||
|
holder = device_registry.async_get_device(identifiers=set(build_device_info(new)["identifiers"]))
|
||||||
|
assert holder is not None
|
||||||
|
assert holder.id == station.id, "the device holding most of the entities has to be the one we adopt"
|
||||||
|
stayed = device_registry.async_get(channel.id)
|
||||||
|
assert stayed is not None
|
||||||
|
assert stayed.identifiers == {(OLD_DOMAIN, "ecowitt_ch1")}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_an_empty_device_is_not_carried_over(hass: HomeAssistant, entries) -> None:
|
||||||
|
"""An emptied device must not be kept alive by us, or it never goes away.
|
||||||
|
|
||||||
|
`device_registry.async_cleanup` subtracts every device referenced by a live config
|
||||||
|
entry from the orphan set, so attaching this entry to a device that holds nothing
|
||||||
|
pins it in place permanently - an empty device page under the new integration that
|
||||||
|
no cleanup pass will ever reap. Leaving it alone lets `async_remove(old_entry)` take
|
||||||
|
its last config entry and delete it.
|
||||||
|
"""
|
||||||
|
old, new = entries
|
||||||
|
device_registry = dr.async_get(hass)
|
||||||
|
station = device_registry.async_get_or_create(
|
||||||
|
config_entry_id=old.entry_id,
|
||||||
|
identifiers={(OLD_DOMAIN,)}, # type: ignore[arg-type]
|
||||||
|
name="Weather Station",
|
||||||
|
)
|
||||||
|
ghost = device_registry.async_get_or_create(
|
||||||
|
config_entry_id=old.entry_id,
|
||||||
|
identifiers={(OLD_DOMAIN, "ecowitt_ch1")},
|
||||||
|
name="Channel 1",
|
||||||
|
)
|
||||||
|
created = _seed(hass, old, device_id=station.id)
|
||||||
|
|
||||||
|
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
|
||||||
|
|
||||||
|
assert result.complete
|
||||||
|
assert device_registry.async_get(ghost.id) is None, "the empty device outlived the migration"
|
||||||
|
survived = device_registry.async_get(station.id)
|
||||||
|
assert survived is not None
|
||||||
|
registry = er.async_get(hass)
|
||||||
|
for entity_id in created.values():
|
||||||
|
entry = registry.async_get(entity_id)
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.device_id == station.id
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# More than one predecessor entry
|
# More than one predecessor entry
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -419,6 +493,38 @@ async def test_a_stalled_migration_raises_a_repair_issue(hass: HomeAssistant, en
|
||||||
assert issue.translation_placeholders == {"entities": blocked}
|
assert issue.translation_placeholders == {"entities": blocked}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_predecessor_that_will_not_unload_raises_a_repair_issue(
|
||||||
|
hass: HomeAssistant, entries, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""The run where nothing at all moved is the one that most needs the warning.
|
||||||
|
|
||||||
|
Passed over in silence, an unloadable predecessor contributes nothing to the result,
|
||||||
|
so `attempted` stays False and the notice is deleted instead of raised - leaving the
|
||||||
|
user with a leftover entry, no explanation, and the one irreversible action a click
|
||||||
|
away.
|
||||||
|
"""
|
||||||
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
|
|
||||||
|
old, new = entries
|
||||||
|
created = _seed(hass, old)
|
||||||
|
old.mock_state(hass, ConfigEntryState.LOADED)
|
||||||
|
|
||||||
|
async def _refuse(*_a: Any, **_kw: Any) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
monkeypatch.setattr(hass.config_entries, "async_unload", _refuse)
|
||||||
|
|
||||||
|
result = await async_adopt_predecessor(hass, new, predecessor_domain=OLD_DOMAIN)
|
||||||
|
update_predecessor_adoption_issue(hass, new, result)
|
||||||
|
|
||||||
|
assert not result.adopted
|
||||||
|
assert sorted(result.live) == sorted(created.values())
|
||||||
|
assert not result.complete
|
||||||
|
issue = ir.async_get(hass).async_get_issue(DOMAIN, f"predecessor_adoption_{new.entry_id}")
|
||||||
|
assert issue is not None
|
||||||
|
assert issue.translation_placeholders == {"entities": ", ".join(sorted(created.values()))}
|
||||||
|
|
||||||
|
|
||||||
async def test_a_finished_migration_raises_no_issue(hass: HomeAssistant, entries) -> None:
|
async def test_a_finished_migration_raises_no_issue(hass: HomeAssistant, entries) -> None:
|
||||||
"""Nothing was left behind, so there is nothing to warn about."""
|
"""Nothing was left behind, so there is nothing to warn about."""
|
||||||
old, new = entries
|
old, new = entries
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue