Stop the v1 migration re-keying devices onto a placeholder serial
_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.
This commit is contained in:
5 files changed
+147
-20
No files matched your search
@@ -58,6 +58,93 @@ async def test_migration_collapses_the_host_port_unique_id(
|
||||
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:
|
||||
|
||||
Reference in new issue
Block a user