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:
Marc Billow
2026-08-04 00:12:40 +00:00
parent d5adf311da
commit b5699badbe
5 changed files with 147 additions and 20 deletions
+35 -14
View File
@@ -13,32 +13,45 @@ from homeassistant.helpers import entity_registry as er
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
from .registry.identity import resolve_serial
_LOGGER = logging.getLogger(__name__)
def _serial_from_unique_id(entry: ConfigEntry) -> str | None:
def _serial_from_unique_id(entry: ConfigEntry) -> str:
"""The device identity a pre-v2 entry was created with.
The config flow has always keyed the entry's unique_id on the serial the
probe read (`localthings_<serial>`), so that string is the identity the
entry's registry entries were minted from -- there is no need to reach the
device to recover it.
device to recover it. Anything we can't recover one from resolves to the
host, which is what the coordinator seeded such an entry with anyway.
One wrinkle: for a board reporting a placeholder serial (issues #83/#189)
the two sides used to disagree. The config flow fell back to `host:port`
while the coordinator fell back to `host`, so the entry and its own
devices/entities were keyed differently. Collapse that to the
coordinator's form, which is the one the registry actually holds.
The recovered string goes back through resolve_serial rather than being
taken at face value, because the unique_id records what the flow believed
at the time it ran, not what the registry holds now. Entries created
before the placeholder rules landed (issues #83/#189) were keyed on the
placeholder itself -- `localthings_Nothing(SVC)`, `localthings_FFFF...` --
while the coordinator has since been resolving those same boards to the
host. Re-keying the registry onto the placeholder to match the unique_id
would reintroduce the collision those issues are about: two units of that
family report the *same* placeholder, so they'd share entity unique_ids
again.
A later wrinkle, same root cause: for a stretch the two sides disagreed on
which fallback to use, the flow writing `host:port` while the coordinator
wrote `host`. Collapse that to the coordinator's form too -- the registry
is what has to keep working.
"""
host = entry.data[CONF_HOST]
prefix = f"{DOMAIN}_"
unique_id = entry.unique_id or ""
if not unique_id.startswith(prefix):
return None
return host
serial = unique_id[len(prefix) :]
if serial == f"{entry.data[CONF_HOST]}:{entry.data.get(CONF_PORT)}":
return entry.data[CONF_HOST]
return serial or None
if serial == f"{host}:{entry.data.get(CONF_PORT)}":
return host
return resolve_serial(serial, host)
@callback
@@ -92,6 +105,16 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
fresh = {(DOMAIN, f"{serial}{ident[1][len(host) :]}") for ident in stale}
existing = dev_reg.async_get_device(identifiers=fresh)
if existing is not None and existing.id != device.id:
# Removing a device takes its entities with it. Anything still
# attached here came through the pass above re-keyed rather than
# removed -- i.e. it's the surviving copy, not a duplicate -- so
# move it onto the device it now belongs to first. Otherwise the
# rewrite that was supposed to preserve an entity_id, name and
# area destroys them a few lines later.
for entity in er.async_entries_for_device(
ent_reg, device.id, include_disabled_entities=True
):
ent_reg.async_update_entity(entity.entity_id, device_id=existing.id)
_LOGGER.debug("removing orphaned device %s", device.id)
dev_reg.async_remove_device(device.id)
else:
@@ -113,9 +136,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return False
if entry.version == 1:
serial = (
entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry) or entry.data[CONF_HOST]
)
serial = entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry)
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_SERIAL: serial},
+5 -5
View File
@@ -600,7 +600,7 @@ def _read_device(sess, host: str, port: int) -> dict:
from .registry.batch import parse_device0_batch
from .registry.by_type import resolve as resolve_registry
from .registry.identity import read_identity, resolve_serial
from .registry.identity import read_identity, resolve_model, resolve_serial
identity = read_identity(sess, None)
@@ -617,14 +617,14 @@ def _read_device(sess, host: str, port: int) -> dict:
resources = parse_device0_batch(body) if isinstance(body, list) else {}
info = resources.get("/information/vs/0", {})
model_num = info.get("x.com.samsung.da.modelNum", "")
registry = resolve_registry(resources, device_types=identity.device_types)
return {
"port": port,
# Resolved through the same helpers _run_discovery uses, so the device
# the coordinator registers up front is the one discovery would have
# produced -- no rename, and no re-key, once the first poll lands.
"serial": resolve_serial(info.get("x.com.samsung.da.serialNum"), host),
# Same derivation _run_discovery uses, so the device the coordinator
# registers up front is the one discovery would have produced.
"model": model_num.split("|", 1)[0] if model_num else identity.model,
"model": resolve_model(info.get("x.com.samsung.da.modelNum", ""), identity),
"manufacturer": identity.manufacturer or "Samsung",
"device_type_name": registry.name if registry is not None else None,
"device_type_recognized": registry is not None,
+2 -1
View File
@@ -53,6 +53,7 @@ from .registry.identity import (
DeviceIdentity,
device_display_name,
read_identity,
resolve_model,
resolve_serial,
)
from .registry.subdevices import (
@@ -777,7 +778,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.device_serial = serial
ident = self._identity
model = model_num.split("|", 1)[0] if model_num else (ident.model if ident else "")
model = resolve_model(model_num, ident)
name = device_display_name(device_type_name, model)
mfr = (ident.manufacturer if ident else "") or "Samsung"
@@ -62,6 +62,24 @@ def resolve_serial(raw_serial: str | None, host: str) -> str:
return s
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
"""The model string to name and register a device under.
`model_num` is /information/vs/0's x.com.samsung.da.modelNum, which many
boards report as `<model>|<board>` -- only the part before the pipe is the
model a user would recognize. A board that reports no modelNum at all
falls back to /oic/p's mnmo, which read_identity already parsed.
Shared with resolve_serial's motivation: the config flow resolves this
once and persists it on the entry, and the coordinator recomputes it after
the first poll. Two copies of the split rule would let those two disagree,
and a device that renames itself on the first poll is the visible symptom.
"""
if model_num:
return model_num.split("|", 1)[0]
return identity.model if identity else ""
def device_display_name(device_type_name: str | None, model: str) -> str:
"""The HA device name for a resolved device type + model.
+87
View File
@@ -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: