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:
@@ -13,32 +13,45 @@ from homeassistant.helpers import entity_registry as er
|
|||||||
|
|
||||||
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
from .const import CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||||
from .coordinator import LocalThingsCoordinator
|
from .coordinator import LocalThingsCoordinator
|
||||||
|
from .registry.identity import resolve_serial
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_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 device identity a pre-v2 entry was created with.
|
||||||
|
|
||||||
The config flow has always keyed the entry's unique_id on the serial the
|
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
|
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
|
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 recovered string goes back through resolve_serial rather than being
|
||||||
the two sides used to disagree. The config flow fell back to `host:port`
|
taken at face value, because the unique_id records what the flow believed
|
||||||
while the coordinator fell back to `host`, so the entry and its own
|
at the time it ran, not what the registry holds now. Entries created
|
||||||
devices/entities were keyed differently. Collapse that to the
|
before the placeholder rules landed (issues #83/#189) were keyed on the
|
||||||
coordinator's form, which is the one the registry actually holds.
|
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}_"
|
prefix = f"{DOMAIN}_"
|
||||||
unique_id = entry.unique_id or ""
|
unique_id = entry.unique_id or ""
|
||||||
if not unique_id.startswith(prefix):
|
if not unique_id.startswith(prefix):
|
||||||
return None
|
return host
|
||||||
serial = unique_id[len(prefix) :]
|
serial = unique_id[len(prefix) :]
|
||||||
if serial == f"{entry.data[CONF_HOST]}:{entry.data.get(CONF_PORT)}":
|
if serial == f"{host}:{entry.data.get(CONF_PORT)}":
|
||||||
return entry.data[CONF_HOST]
|
return host
|
||||||
return serial or None
|
return resolve_serial(serial, host)
|
||||||
|
|
||||||
|
|
||||||
@callback
|
@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}
|
fresh = {(DOMAIN, f"{serial}{ident[1][len(host) :]}") for ident in stale}
|
||||||
existing = dev_reg.async_get_device(identifiers=fresh)
|
existing = dev_reg.async_get_device(identifiers=fresh)
|
||||||
if existing is not None and existing.id != device.id:
|
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)
|
_LOGGER.debug("removing orphaned device %s", device.id)
|
||||||
dev_reg.async_remove_device(device.id)
|
dev_reg.async_remove_device(device.id)
|
||||||
else:
|
else:
|
||||||
@@ -113,9 +136,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
if entry.version == 1:
|
if entry.version == 1:
|
||||||
serial = (
|
serial = entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry)
|
||||||
entry.data.get(CONF_SERIAL) or _serial_from_unique_id(entry) or entry.data[CONF_HOST]
|
|
||||||
)
|
|
||||||
hass.config_entries.async_update_entry(
|
hass.config_entries.async_update_entry(
|
||||||
entry,
|
entry,
|
||||||
data={**entry.data, CONF_SERIAL: serial},
|
data={**entry.data, CONF_SERIAL: serial},
|
||||||
|
|||||||
@@ -600,7 +600,7 @@ def _read_device(sess, host: str, port: int) -> dict:
|
|||||||
|
|
||||||
from .registry.batch import parse_device0_batch
|
from .registry.batch import parse_device0_batch
|
||||||
from .registry.by_type import resolve as resolve_registry
|
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)
|
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 {}
|
resources = parse_device0_batch(body) if isinstance(body, list) else {}
|
||||||
|
|
||||||
info = resources.get("/information/vs/0", {})
|
info = resources.get("/information/vs/0", {})
|
||||||
model_num = info.get("x.com.samsung.da.modelNum", "")
|
|
||||||
registry = resolve_registry(resources, device_types=identity.device_types)
|
registry = resolve_registry(resources, device_types=identity.device_types)
|
||||||
return {
|
return {
|
||||||
"port": port,
|
"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),
|
"serial": resolve_serial(info.get("x.com.samsung.da.serialNum"), host),
|
||||||
# Same derivation _run_discovery uses, so the device the coordinator
|
"model": resolve_model(info.get("x.com.samsung.da.modelNum", ""), identity),
|
||||||
# registers up front is the one discovery would have produced.
|
|
||||||
"model": model_num.split("|", 1)[0] if model_num else identity.model,
|
|
||||||
"manufacturer": identity.manufacturer or "Samsung",
|
"manufacturer": identity.manufacturer or "Samsung",
|
||||||
"device_type_name": registry.name if registry is not None else None,
|
"device_type_name": registry.name if registry is not None else None,
|
||||||
"device_type_recognized": registry is not None,
|
"device_type_recognized": registry is not None,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from .registry.identity import (
|
|||||||
DeviceIdentity,
|
DeviceIdentity,
|
||||||
device_display_name,
|
device_display_name,
|
||||||
read_identity,
|
read_identity,
|
||||||
|
resolve_model,
|
||||||
resolve_serial,
|
resolve_serial,
|
||||||
)
|
)
|
||||||
from .registry.subdevices import (
|
from .registry.subdevices import (
|
||||||
@@ -777,7 +778,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self.device_serial = serial
|
self.device_serial = serial
|
||||||
|
|
||||||
ident = self._identity
|
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)
|
name = device_display_name(device_type_name, model)
|
||||||
mfr = (ident.manufacturer if ident else "") or "Samsung"
|
mfr = (ident.manufacturer if ident else "") or "Samsung"
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,24 @@ def resolve_serial(raw_serial: str | None, host: str) -> str:
|
|||||||
return s
|
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:
|
def device_display_name(device_type_name: str | None, model: str) -> str:
|
||||||
"""The HA device name for a resolved device type + model.
|
"""The HA device name for a resolved device type + model.
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,93 @@ async def test_migration_collapses_the_host_port_unique_id(
|
|||||||
assert entry.unique_id == f"{DOMAIN}_{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(
|
async def test_migration_rekeys_an_ip_keyed_device_and_entity(
|
||||||
hass: HomeAssistant, mock_coordinator_session
|
hass: HomeAssistant, mock_coordinator_session
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user