_serial_from_unique_id took the entry's unique_id at face value. That is right for an entry whose unique_id holds a real serial, but the unique_id records what the config flow believed when it ran, not what the registry holds now -- and for two firmware families those are different things. Entries added before the placeholder rules landed (issues #83/#189) were keyed on the placeholder itself: `localthings_Nothing(SVC)` for the ARTIK051_DONGLE_REF dongles, `localthings_FFFFFFFFFFFFFFF` for the DA_WM_A51_20_COMMON laundry boards. The coordinator has been resolving those same boards to the host ever since, so their devices and entities are host-keyed today. Migration read the placeholder back off the unique_id, decided the host-keyed rows were the stale ones, and rewrote them onto the placeholder -- reintroducing exactly the collision those issues exist to prevent, since every unit of the family reports the same placeholder and would go back to sharing entity unique_ids. Run the recovered string through resolve_serial, which is the whole point of that helper being shared. The old `host:port` special case stays: it's a config-flow-history artifact rather than a device-reported serial, so resolve_serial can't recognize it. The repair pass had a second, narrower way to lose data. Removing a device takes its entities with it (entity_registry.async_device_modified), and the removal branch ran after the entity pass -- so an entity that had just been re-keyed rather than removed, because its serial-keyed key was free, was destroyed a few lines later along with the entity_id, name and area the rewrite existed to preserve. Move surviving entities onto the device they now belong to before removing the duplicate. Reachable when the serial-keyed device exists but a given entity's serial-keyed key doesn't -- e.g. the user deleted the visible duplicate by hand, which is the first thing anyone hitting #236 tries. Also fold the modelNum `<model>|<board>` split into resolve_model beside resolve_serial. The config flow and _run_discovery each had their own copy under a comment promising they matched; a device that renames itself on the first poll is what a drift there looks like.
278 lines
10 KiB
Python
278 lines
10 KiB
Python
"""Config-entry migration and the placeholder-identity repair (issue #236)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import device_registry as dr
|
|
from homeassistant.helpers import entity_registry as er
|
|
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
|
|
|
from custom_components.localthings.const import (
|
|
CONF_HOST,
|
|
CONF_SERIAL,
|
|
DOMAIN,
|
|
)
|
|
|
|
from .conftest import LEGACY_ENTRY_DATA, MOCK_HOST, MOCK_PORT, MOCK_SERIAL
|
|
|
|
|
|
def _legacy_entry(hass: HomeAssistant, unique_id: str) -> MockConfigEntry:
|
|
entry = MockConfigEntry(
|
|
domain=DOMAIN,
|
|
data=LEGACY_ENTRY_DATA,
|
|
unique_id=unique_id,
|
|
version=1,
|
|
)
|
|
entry.add_to_hass(hass)
|
|
return entry
|
|
|
|
|
|
async def test_migration_recovers_serial_from_unique_id(
|
|
hass: HomeAssistant, mock_coordinator_session
|
|
) -> None:
|
|
"""A v1 entry's identity is recoverable without reaching the device: the
|
|
config flow has always keyed the entry's unique_id on the serial its probe
|
|
read."""
|
|
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_SERIAL}")
|
|
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert entry.version == 2
|
|
assert entry.data[CONF_SERIAL] == MOCK_SERIAL
|
|
|
|
|
|
async def test_migration_collapses_the_host_port_unique_id(
|
|
hass: HomeAssistant, mock_coordinator_session
|
|
) -> None:
|
|
"""A board with no usable serial (issues #83/#189) used to be keyed two
|
|
different ways at once: `host:port` on the config entry, `host` in the
|
|
device and entity registries. Migration collapses the entry onto the
|
|
registry's form, so the two finally name the same thing."""
|
|
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_HOST}:{MOCK_PORT}")
|
|
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
|
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|
|
|
|
|
|
async def test_migration_resolves_a_placeholder_serial_unique_id(
|
|
hass: HomeAssistant, mock_coordinator_session
|
|
) -> None:
|
|
"""An entry created before the placeholder rules landed was keyed on the
|
|
placeholder itself (issues #83/#189), while the coordinator has been
|
|
resolving those boards to the host ever since. The unique_id records what
|
|
the flow believed then, not what the registry holds -- taking it at face
|
|
value would re-key working devices back onto a string every unit of the
|
|
family reports, which is the collision those issues are about."""
|
|
entry = _legacy_entry(hass, f"{DOMAIN}_Nothing(SVC)")
|
|
dev_reg = dr.async_get(hass)
|
|
device = dev_reg.async_get_or_create(
|
|
config_entry_id=entry.entry_id,
|
|
identifiers={(DOMAIN, MOCK_HOST)},
|
|
)
|
|
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
|
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|
|
unchanged = dev_reg.async_get(device.id)
|
|
assert unchanged is not None
|
|
assert unchanged.identifiers == {(DOMAIN, MOCK_HOST)}
|
|
|
|
|
|
async def test_migration_resolves_an_all_hex_placeholder_unique_id(
|
|
hass: HomeAssistant, mock_coordinator_session
|
|
) -> None:
|
|
"""The issue #189 flash-unset sentinel, same reasoning."""
|
|
entry = _legacy_entry(hass, f"{DOMAIN}_FFFFFFFFFFFFFFF")
|
|
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
|
|
|
|
|
async def test_migration_keeps_a_survivor_off_a_removed_duplicate_device(
|
|
hass: HomeAssistant,
|
|
) -> None:
|
|
"""Removing a device takes its entities with it (entity_registry's
|
|
async_device_modified), so an entity that came through the pass above
|
|
re-keyed rather than removed has to move to the surviving device first --
|
|
otherwise the rewrite that exists to preserve an entity_id, name and area
|
|
destroys all three a few lines later.
|
|
|
|
Migration is called directly here: what the repair leaves behind is the
|
|
contract, and going through async_setup would let the platform re-adding
|
|
its entities hide a row that had in fact been deleted."""
|
|
from custom_components.localthings import async_migrate_entry
|
|
|
|
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_SERIAL}")
|
|
dev_reg = dr.async_get(hass)
|
|
ent_reg = er.async_get(hass)
|
|
|
|
real_device = dev_reg.async_get_or_create(
|
|
config_entry_id=entry.entry_id,
|
|
identifiers={(DOMAIN, MOCK_SERIAL)},
|
|
)
|
|
orphan_device = dev_reg.async_get_or_create(
|
|
config_entry_id=entry.entry_id,
|
|
identifiers={(DOMAIN, MOCK_HOST)},
|
|
)
|
|
# The serial-keyed device exists, but this entity's serial-keyed *key* is
|
|
# free -- e.g. the user deleted the visible duplicate by hand -- so the
|
|
# entity pass rewrites it instead of removing it.
|
|
survivor = ent_reg.async_get_or_create(
|
|
"sensor",
|
|
DOMAIN,
|
|
f"{DOMAIN}_{MOCK_HOST}_connection_mode",
|
|
config_entry=entry,
|
|
device_id=orphan_device.id,
|
|
suggested_object_id="kitchen_fridge_connection",
|
|
)
|
|
|
|
assert await async_migrate_entry(hass, entry) is True
|
|
await hass.async_block_till_done()
|
|
|
|
assert dev_reg.async_get(orphan_device.id) is None
|
|
kept = ent_reg.async_get(survivor.entity_id)
|
|
assert kept is not None
|
|
assert kept.entity_id == "sensor.kitchen_fridge_connection"
|
|
assert kept.unique_id == f"{DOMAIN}_{MOCK_SERIAL}_connection_mode"
|
|
assert kept.device_id == real_device.id
|
|
|
|
|
|
async def test_migration_rekeys_an_ip_keyed_device_and_entity(
|
|
hass: HomeAssistant, mock_coordinator_session
|
|
) -> None:
|
|
"""The registry entries the old placeholder identity minted are rewritten
|
|
in place, so an orphan keeps its entity_id, name, area and every
|
|
automation that referenced it -- rather than being replaced by a
|
|
serial-keyed duplicate with a `_2` suffix while it sits permanently
|
|
unavailable (issue #236)."""
|
|
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_SERIAL}")
|
|
dev_reg = dr.async_get(hass)
|
|
ent_reg = er.async_get(hass)
|
|
|
|
orphan_device = dev_reg.async_get_or_create(
|
|
config_entry_id=entry.entry_id,
|
|
identifiers={(DOMAIN, MOCK_HOST)},
|
|
name=f"Samsung Appliance ({MOCK_HOST})",
|
|
)
|
|
orphan_entity = ent_reg.async_get_or_create(
|
|
"sensor",
|
|
DOMAIN,
|
|
f"{DOMAIN}_{MOCK_HOST}_connection_mode",
|
|
config_entry=entry,
|
|
device_id=orphan_device.id,
|
|
)
|
|
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
# Same registry rows, now keyed on the real identity.
|
|
rekeyed_device = dev_reg.async_get(orphan_device.id)
|
|
assert rekeyed_device is not None
|
|
assert rekeyed_device.identifiers == {(DOMAIN, MOCK_SERIAL)}
|
|
rekeyed = ent_reg.async_get(orphan_entity.entity_id)
|
|
assert rekeyed is not None
|
|
assert rekeyed.unique_id == f"{DOMAIN}_{MOCK_SERIAL}_connection_mode"
|
|
# And nothing is left keyed on the IP.
|
|
assert dev_reg.async_get_device(identifiers={(DOMAIN, MOCK_HOST)}) is None
|
|
|
|
|
|
async def test_migration_removes_an_orphan_that_is_already_duplicated(
|
|
hass: HomeAssistant, mock_coordinator_session
|
|
) -> None:
|
|
"""Where the serial-keyed entry already exists, the IP-keyed one is the
|
|
dead duplicate the race left behind -- it has been unavailable since the
|
|
restart that created it and nothing will ever update it, so it goes
|
|
rather than being rewritten onto a key that is taken."""
|
|
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_SERIAL}")
|
|
dev_reg = dr.async_get(hass)
|
|
ent_reg = er.async_get(hass)
|
|
|
|
real_device = dev_reg.async_get_or_create(
|
|
config_entry_id=entry.entry_id,
|
|
identifiers={(DOMAIN, MOCK_SERIAL)},
|
|
)
|
|
real_entity = ent_reg.async_get_or_create(
|
|
"sensor",
|
|
DOMAIN,
|
|
f"{DOMAIN}_{MOCK_SERIAL}_connection_mode",
|
|
config_entry=entry,
|
|
device_id=real_device.id,
|
|
)
|
|
orphan_device = dev_reg.async_get_or_create(
|
|
config_entry_id=entry.entry_id,
|
|
identifiers={(DOMAIN, MOCK_HOST)},
|
|
)
|
|
orphan_entity = ent_reg.async_get_or_create(
|
|
"sensor",
|
|
DOMAIN,
|
|
f"{DOMAIN}_{MOCK_HOST}_connection_mode",
|
|
config_entry=entry,
|
|
device_id=orphan_device.id,
|
|
)
|
|
assert orphan_entity.entity_id != real_entity.entity_id
|
|
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert ent_reg.async_get(orphan_entity.entity_id) is None
|
|
assert dev_reg.async_get(orphan_device.id) is None
|
|
# The working pair is untouched.
|
|
assert ent_reg.async_get(real_entity.entity_id) is not None
|
|
assert dev_reg.async_get(real_device.id) is not None
|
|
|
|
|
|
async def test_migration_leaves_a_host_identity_device_alone(
|
|
hass: HomeAssistant, mock_coordinator_session
|
|
) -> None:
|
|
"""A board whose serial resolves *to* the host was never keyed on a
|
|
placeholder -- its host-keyed device is the real one, and re-keying or
|
|
removing it would orphan a working device to fix a problem it doesn't
|
|
have."""
|
|
entry = _legacy_entry(hass, f"{DOMAIN}_{MOCK_HOST}")
|
|
dev_reg = dr.async_get(hass)
|
|
device = dev_reg.async_get_or_create(
|
|
config_entry_id=entry.entry_id,
|
|
identifiers={(DOMAIN, MOCK_HOST)},
|
|
)
|
|
|
|
await hass.config_entries.async_setup(entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert entry.data[CONF_SERIAL] == MOCK_HOST
|
|
unchanged = dev_reg.async_get(device.id)
|
|
assert unchanged is not None
|
|
assert unchanged.identifiers == {(DOMAIN, MOCK_HOST)}
|
|
|
|
|
|
async def test_migration_rejects_a_future_entry_version(hass: HomeAssistant) -> None:
|
|
"""A downgrade must fail the entry rather than silently mangling data
|
|
written by a newer release."""
|
|
from custom_components.localthings import async_migrate_entry
|
|
|
|
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=3)
|
|
entry.add_to_hass(hass)
|
|
|
|
assert await async_migrate_entry(hass, entry) is False
|
|
|
|
|
|
async def test_migration_without_a_unique_id_falls_back_to_host(hass: HomeAssistant) -> None:
|
|
"""Nothing to recover the identity from means the host, which is exactly
|
|
what the coordinator used to seed -- so the registry keys such an entry
|
|
already holds stay valid."""
|
|
from custom_components.localthings import async_migrate_entry
|
|
|
|
entry = MockConfigEntry(domain=DOMAIN, data=LEGACY_ENTRY_DATA, version=1)
|
|
entry.add_to_hass(hass)
|
|
|
|
assert await async_migrate_entry(hass, entry) is True
|
|
assert entry.data[CONF_SERIAL] == entry.data[CONF_HOST]
|
|
assert entry.unique_id == f"{DOMAIN}_{MOCK_HOST}"
|