Merge pull request #383 from mbillow/claude/unique-id-strategy-nofs1c
Key devices on the OCF device ID instead of the serial number
This commit is contained in:
35 files changed
+1719
-171
No files matched your search
@@ -19,6 +19,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
|
||||
from .coordinator import LocalThingsCoordinator, snapshot_store
|
||||
from .registry.identity import resolve_serial
|
||||
from .rekey import rekey_entry
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -82,67 +83,20 @@ def _repair_placeholder_keys(hass: HomeAssistant, entry: ConfigEntry, serial: st
|
||||
"""Re-key registry entries this entry minted from the placeholder identity.
|
||||
|
||||
Before the identity moved onto the config entry, the coordinator seeded
|
||||
`device_serial` with the host and only replaced it after the first poll.
|
||||
its device key with the host and only replaced it after the first poll.
|
||||
Anything that registered in between -- the connection-mode sensor
|
||||
especially, added unconditionally rather than from `bound` -- was
|
||||
written into the registry keyed on the IP permanently, orphaned the
|
||||
moment the serial-keyed identity appeared (issue #236). Deleting the
|
||||
orphans by hand didn't help: the next restart that lost the same race
|
||||
recreated them.
|
||||
|
||||
Rewriting beats deleting where possible -- an entity keeps its
|
||||
entity_id, name, area and automations. Only possible when the
|
||||
serial-keyed key is still free; where both exist the placeholder-keyed
|
||||
one is the dead duplicate (unavailable since the restart that created
|
||||
it), so it goes.
|
||||
"""
|
||||
host = entry.data[CONF_HOST]
|
||||
if serial == host:
|
||||
# A board with no usable serial resolves to the host, so its keys
|
||||
# were never placeholders.
|
||||
return
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
stale_prefix = f"{DOMAIN}_{host}_"
|
||||
for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
|
||||
if not entity.unique_id.startswith(stale_prefix):
|
||||
continue
|
||||
new_unique_id = f"{DOMAIN}_{serial}_{entity.unique_id[len(stale_prefix) :]}"
|
||||
if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id):
|
||||
_LOGGER.debug("removing orphaned entity %s", entity.entity_id)
|
||||
ent_reg.async_remove(entity.entity_id)
|
||||
else:
|
||||
_LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id)
|
||||
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)):
|
||||
# `host` for the master, `host_<key>` for a subdevice (device_info_for).
|
||||
stale = {
|
||||
ident
|
||||
for ident in device.identifiers
|
||||
if ident[0] == DOMAIN and (ident[1] == host or ident[1].startswith(f"{host}_"))
|
||||
}
|
||||
if not stale:
|
||||
continue
|
||||
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 was re-keyed rather than removed above -- the
|
||||
# surviving copy, not a duplicate -- so move it onto the device
|
||||
# it now belongs to before the removal destroys it too.
|
||||
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:
|
||||
_LOGGER.debug("re-keying device %s to %s", device.id, fresh)
|
||||
dev_reg.async_update_device(
|
||||
device.id, new_identifiers=(device.identifiers - stale) | fresh
|
||||
)
|
||||
rekey_entry(hass, entry, host, serial)
|
||||
|
||||
|
||||
# Registries whose Dust/FineDust/SuperFineDust sensors gained pm10/pm25/pm1
|
||||
@@ -249,8 +203,22 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
v2 -> v3 relabels the recorded statistics for the particulate sensors,
|
||||
which gained a device_class/unit in the same release (issue #325).
|
||||
|
||||
v3 -> v4 moves the entry off the serialNum as its identity and onto the
|
||||
OCF device UUID (issue #381). Deliberately almost a no-op here: the
|
||||
UUID lives on the device, and this runs before any I/O -- and before
|
||||
the device is even known to be reachable, since an entry can load
|
||||
entirely from its snapshot while the appliance is off (issue #295).
|
||||
So the migration only guarantees CONF_SERIAL is populated, which is
|
||||
what the coordinator rewrites *from* once a live poll finally hands it
|
||||
a UUID to rewrite *to*.
|
||||
|
||||
The version is still bumped now rather than at that point, because the
|
||||
bump's real job is the `entry.version > 4` downgrade guard below: an
|
||||
entry re-keyed onto a UUID and then loaded by a release that reads
|
||||
CONF_SERIAL as the key would silently orphan every entity it has.
|
||||
"""
|
||||
if entry.version > 3:
|
||||
if entry.version > 4:
|
||||
return False # downgrade: this release doesn't know the newer shape
|
||||
|
||||
if entry.version == 1:
|
||||
@@ -268,6 +236,28 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.config_entries.async_update_entry(entry, version=3)
|
||||
_LOGGER.debug("migrated entry %s to version 3", entry.entry_id)
|
||||
|
||||
if entry.version == 3:
|
||||
# `or host` mirrors what the coordinator has always fallen back to,
|
||||
# so the recorded legacy key is the one this entry's registry
|
||||
# entries were actually minted under even if CONF_SERIAL never got
|
||||
# written (a v1 entry whose migration predates it). Both being
|
||||
# absent shouldn't happen for an entry the config flow created, but
|
||||
# an exception raised here fails the whole entry -- so it bumps the
|
||||
# version and leaves the data alone rather than taking that risk
|
||||
# for a value the coordinator re-derives on its next poll anyway.
|
||||
legacy_key = entry.data.get(CONF_SERIAL) or entry.data.get(CONF_HOST)
|
||||
hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data={**entry.data, CONF_SERIAL: legacy_key} if legacy_key else entry.data,
|
||||
version=4,
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"migrated entry %s to version 4 (legacy key=%s, awaiting a poll to adopt "
|
||||
"the OCF device id)",
|
||||
entry.entry_id,
|
||||
legacy_key,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ from .const import (
|
||||
CONF_CA_CERT_PEM,
|
||||
CONF_CA_KEY_PEM,
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||
CONF_HOST,
|
||||
@@ -662,7 +663,12 @@ 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_model, resolve_serial
|
||||
from .registry.identity import (
|
||||
read_identity,
|
||||
resolve_device_key,
|
||||
resolve_model,
|
||||
resolve_serial,
|
||||
)
|
||||
|
||||
identity = read_identity(sess, None)
|
||||
|
||||
@@ -680,12 +686,19 @@ def _read_device(sess, host: str, port: int) -> dict:
|
||||
|
||||
info = resources.get("/information/vs/0", {})
|
||||
registry = resolve_registry(resources, device_types=identity.device_types)
|
||||
raw_serial = info.get("x.com.samsung.da.serialNum")
|
||||
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),
|
||||
#
|
||||
# `device_key` is what the entry is actually keyed on; the serial is
|
||||
# kept alongside it because the coordinator corroborates a later
|
||||
# change of key against it (issue #381). read_identity has already
|
||||
# fetched /oic/p and /oic/d above, so this costs no extra round trip.
|
||||
"device_key": resolve_device_key(identity, raw_serial, host),
|
||||
"serial": resolve_serial(raw_serial, host),
|
||||
"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,
|
||||
@@ -798,7 +811,10 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
# v3 relabels the particulate sensors' recorded statistics; a freshly
|
||||
# created entry has none to relabel, so it starts at the migrated
|
||||
# version rather than walking through v2 (see async_migrate_entry).
|
||||
VERSION = 3
|
||||
# v4 keys the entry on the OCF device UUID (issue #381), which the probe
|
||||
# below resolves up front -- so a new entry is already on the v4 shape
|
||||
# and has nothing to re-key either.
|
||||
VERSION = 4
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._host: str = ""
|
||||
@@ -817,7 +833,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
"""Persist everything the probe resolved, identity included.
|
||||
|
||||
The identity fields aren't decoration: the coordinator seeds
|
||||
`device_serial` and its DeviceInfo from them at construction time,
|
||||
`device_key` and its DeviceInfo from them at construction time,
|
||||
so entity unique_ids are correct from the first entity that
|
||||
registers, even if the first poll is slow or fails (issue #236).
|
||||
"""
|
||||
@@ -832,6 +848,7 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
CONF_CA_KEY_PEM: self._ca_key_pem,
|
||||
CONF_LEAF_CERT_PEM: info["leaf_cert_pem"],
|
||||
CONF_LEAF_KEY_PEM: info["leaf_key_pem"],
|
||||
CONF_DEVICE_KEY: info["device_key"],
|
||||
CONF_SERIAL: info["serial"],
|
||||
CONF_MODEL: info["model"],
|
||||
CONF_MANUFACTURER: info["manufacturer"],
|
||||
@@ -878,7 +895,33 @@ class LocalThingsConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected error during device probe")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(f"localthings_{info['serial']}")
|
||||
# An entry created before v4 still carries the serial-keyed
|
||||
# unique_id until its first *live* poll adopts the UUID
|
||||
# (coordinator._resolve_identity) -- which can be a long
|
||||
# while for an appliance that is off, since an entry loads
|
||||
# from its snapshot in the meantime (issue #295). The UUID
|
||||
# check below can't see such an entry, so re-adding this
|
||||
# very appliance during that window would be waved through
|
||||
# as a second entry; the two would then collide the moment
|
||||
# the older one re-keyed, and rekey_entry resolves a
|
||||
# collision by *deleting* the duplicate rows -- taking the
|
||||
# original entry's entity_ids, history and automations with
|
||||
# them. Matched on the legacy key together with the host, so
|
||||
# issue #381's two units (same serial, different addresses)
|
||||
# stay separable.
|
||||
legacy_unique_id = f"localthings_{info['serial']}"
|
||||
if any(
|
||||
other.unique_id == legacy_unique_id
|
||||
and other.data.get(CONF_HOST) == self._host
|
||||
and CONF_DEVICE_KEY not in other.data
|
||||
for other in existing
|
||||
):
|
||||
return self.async_abort(reason="already_configured")
|
||||
# Keyed on the OCF device UUID rather than the serialNum
|
||||
# (issue #381): two units of a model that ship the same
|
||||
# well-formed serial are indistinguishable here otherwise,
|
||||
# and the second one is turned away as already configured.
|
||||
await self.async_set_unique_id(f"localthings_{info['device_key']}")
|
||||
self._abort_if_unique_id_configured()
|
||||
if info["device_type_recognized"]:
|
||||
return self._create_entry(info)
|
||||
|
||||
@@ -30,6 +30,11 @@ CONF_LEAF_KEY_PEM = "leaf_key_pem"
|
||||
# output, the host itself for a placeholder-serial board -- issues
|
||||
# #83/#189), so it matches what _run_discovery computes on the first poll.
|
||||
CONF_SERIAL = "serial"
|
||||
# What this entry's devices and entities are keyed on -- normally the OCF
|
||||
# device UUID (issue #381). CONF_SERIAL stays alongside it as the pre-v4
|
||||
# key to re-key from, and as what corroborates a later change of UUID.
|
||||
# Absent until the first live poll, since only the device can report it.
|
||||
CONF_DEVICE_KEY = "device_key"
|
||||
CONF_MODEL = "model"
|
||||
CONF_MANUFACTURER = "manufacturer"
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
|
||||
@@ -31,6 +31,7 @@ from .const import (
|
||||
CONF_BYPASS_REMOTE_CONTROL,
|
||||
CONF_CLOUD_COURSES,
|
||||
CONF_CLOUD_COURSES_ENABLED,
|
||||
CONF_DEVICE_KEY,
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
@@ -66,6 +67,7 @@ from .registry.entities import ClimateDesc
|
||||
from .registry.identity import (
|
||||
DeviceIdentity,
|
||||
device_display_name,
|
||||
ocf_device_key,
|
||||
read_identity,
|
||||
resolve_model,
|
||||
resolve_serial,
|
||||
@@ -78,6 +80,7 @@ from .registry.subdevices import (
|
||||
enumerate_subdevices,
|
||||
normalize_seed_batch,
|
||||
)
|
||||
from .rekey import rekey_entry
|
||||
|
||||
# Sentinel for apply_cloud_courses: "leave this field as it is",
|
||||
# distinct from None which means "clear it".
|
||||
@@ -205,7 +208,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
bound: list[BoundEntity]
|
||||
device_info: DeviceInfo
|
||||
device_serial: str
|
||||
device_key: str
|
||||
|
||||
# Class-level so tests can shrink these via patch.object() without
|
||||
# touching the production defaults.
|
||||
@@ -310,15 +313,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._push_pending = False
|
||||
self._push_pending_lock = threading.Lock()
|
||||
# Identity is resolved once by the config flow's probe (issue #236).
|
||||
# device_serial mints permanent registry keys, so it must be correct
|
||||
# device_key mints permanent registry keys, so it must be correct
|
||||
# before the first entity registers -- a placeholder corrected once
|
||||
# the first poll lands orphans the first device/entity pair instead.
|
||||
# The host fallback covers a pre-migration entry and matches what
|
||||
# resolve_serial itself returns for a placeholder-serial board
|
||||
# (issues #83/#189).
|
||||
self.device_serial = entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST]
|
||||
# Ordered so an entry that has not polled since upgrading still
|
||||
# loads under the key its registry rows already carry: the v4 UUID
|
||||
# (issue #381), else the pre-v4 serial, else the host (#83/#189).
|
||||
self.device_key = (
|
||||
entry.data.get(CONF_DEVICE_KEY) or entry.data.get(CONF_SERIAL) or entry.data[CONF_HOST]
|
||||
)
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, self.device_serial)},
|
||||
identifiers={(DOMAIN, self.device_key)},
|
||||
name=device_display_name(
|
||||
entry.data.get(CONF_DEVICE_TYPE), entry.data.get(CONF_MODEL) or ""
|
||||
),
|
||||
@@ -768,8 +773,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
else "Secondary Subdevice"
|
||||
)
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.device_serial}_{subdevice.key}")},
|
||||
via_device=(DOMAIN, self.device_serial),
|
||||
identifiers={(DOMAIN, f"{self.device_key}_{subdevice.key}")},
|
||||
via_device=(DOMAIN, self.device_key),
|
||||
name=f"{base_name} {label}",
|
||||
manufacturer=self.device_info.get("manufacturer") or "Samsung",
|
||||
model=model or None,
|
||||
@@ -1093,8 +1098,87 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._skipped_subdevice_resources = skipped
|
||||
return kept
|
||||
|
||||
def _resolve_identity(self, polled_serial: str, *, from_snapshot: bool) -> tuple[str, str]:
|
||||
"""The (key, serial) this entry should be registered under, re-keying
|
||||
the registries if the key has moved.
|
||||
|
||||
Key and serial are returned together because they have to agree: the
|
||||
serial corroborates a later change of key, so adopting it while
|
||||
*defending* the stored key would hand a different appliance the
|
||||
corroboration it needs to win the next poll.
|
||||
|
||||
Nothing changes a key without calling `rekey_entry` in the same
|
||||
breath, or the existing registry rows are orphaned (issue #236).
|
||||
Two things are therefore never treated as a change of identity: a
|
||||
snapshot replay (issue #295), which never reached the device, and a
|
||||
poll that read no UUID, since the device saying nothing is not the
|
||||
device saying something different.
|
||||
|
||||
A genuine difference is either the registered appliance under a new
|
||||
identity -- the move onto the OCF device UUID (issue #381), or a
|
||||
`di` regenerated by a hard reset -- or a different appliance on this
|
||||
address, and the serialNum is what tells them apart. That test is
|
||||
deliberately the same before and after an entry has adopted a UUID:
|
||||
a pre-v4 entry has been running longest, so it is the last one that
|
||||
should lose its rows to whatever now answers at its address.
|
||||
"""
|
||||
host = self._entry.data[CONF_HOST]
|
||||
stored_serial = self._entry.data.get(CONF_SERIAL)
|
||||
if from_snapshot:
|
||||
return self.device_key, stored_serial or polled_serial
|
||||
|
||||
polled_ocf = ocf_device_key(self._identity)
|
||||
stored_key = self._entry.data.get(CONF_DEVICE_KEY)
|
||||
# What this entry's rows carry today: a pre-v4 entry has no
|
||||
# CONF_DEVICE_KEY, so it is whatever __init__ fell back to.
|
||||
current_key = stored_key if stored_key is not None else (stored_serial or host)
|
||||
polled_key = polled_ocf or polled_serial
|
||||
|
||||
if polled_key == current_key:
|
||||
# Returned rather than short-circuited so a pre-v4 entry records
|
||||
# the key it has always had, and the next poll sees no change.
|
||||
return current_key, polled_serial
|
||||
|
||||
if stored_key is not None and polled_ocf is None:
|
||||
return stored_key, stored_serial or polled_serial
|
||||
|
||||
# Excluding the host answer keeps this from firing on two *different*
|
||||
# placeholder-serial units: an address is not an identity and can't
|
||||
# corroborate anything.
|
||||
same_unit = polled_serial == stored_serial and polled_serial != host
|
||||
# A host-keyed entry (issues #83/#189) is registered against whatever
|
||||
# answers at this address, so it has no claim to defend and needs no
|
||||
# corroboration -- demanding it would strand exactly the
|
||||
# placeholder-serial boards this exists to rescue. Same for an entry
|
||||
# with no stored serial to compare against.
|
||||
if not (current_key == host or same_unit or stored_serial is None):
|
||||
self._log.warning(
|
||||
"device at %s identifies as %r but this entry is registered as %r "
|
||||
"(serial %r, registered %r); keeping the registered identity",
|
||||
host,
|
||||
polled_key,
|
||||
current_key,
|
||||
polled_serial,
|
||||
stored_serial,
|
||||
)
|
||||
return current_key, stored_serial or polled_serial
|
||||
|
||||
# Corroborated: a pre-v4 entry moving onto its UUID, the same unit
|
||||
# with a regenerated `di`, or a host-keyed entry learning a real
|
||||
# identity. Following it keeps the user's entity_ids and history.
|
||||
self._log.info(
|
||||
"device %s (serial %r) changed key from %r to %r; following it",
|
||||
host,
|
||||
polled_serial,
|
||||
current_key,
|
||||
polled_key,
|
||||
)
|
||||
rekey_entry(self.hass, self._entry, current_key, polled_key)
|
||||
return polled_key, polled_serial
|
||||
|
||||
def _persist_identity(
|
||||
self,
|
||||
device_key: str | None,
|
||||
serial: str,
|
||||
model: str,
|
||||
manufacturer: str,
|
||||
@@ -1108,9 +1192,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
means the next restart names the device fully instead of renaming it
|
||||
again once a poll lands.
|
||||
|
||||
`device_key` is what the registries are keyed on; `serial` is stored
|
||||
alongside it rather than replaced by it, because it is what
|
||||
`_resolve_identity` corroborates a changed UUID against on a later
|
||||
poll.
|
||||
None leaves whatever key is already stored untouched -- see the
|
||||
caller for why a snapshot replay must not write one.
|
||||
|
||||
Runs on the event loop, which async_update_entry requires.
|
||||
"""
|
||||
identity = {
|
||||
**({CONF_DEVICE_KEY: device_key} if device_key is not None else {}),
|
||||
CONF_SERIAL: serial,
|
||||
CONF_MODEL: model,
|
||||
CONF_MANUFACTURER: manufacturer,
|
||||
@@ -1195,6 +1287,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
model=ident.get("model") or "",
|
||||
name=ident.get("name") or "",
|
||||
serial=ident.get("serial"),
|
||||
# Absent from a snapshot written before these fields
|
||||
# existed; _resolve_identity never re-keys from a
|
||||
# replay, so a missing UUID here costs nothing beyond
|
||||
# diagnostics.
|
||||
device_id=ident.get("device_id"),
|
||||
platform_id=ident.get("platform_id"),
|
||||
device_types=tuple(ident.get("device_types") or ()),
|
||||
raw=ident.get("raw") or {},
|
||||
)
|
||||
@@ -1346,26 +1444,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
self._unbound_hrefs = unbound
|
||||
self._refresh_learnable_hrefs()
|
||||
|
||||
# The entry's stored identity wins; this poll's answer is only
|
||||
# adopted when nothing is stored (a legacy migration couldn't
|
||||
# recover it), then written back. Re-keying an entry with existing
|
||||
# registry entries orphans them (issue #236).
|
||||
polled_serial = resolve_serial(
|
||||
info.get("x.com.samsung.da.serialNum"), self._entry.data[CONF_HOST]
|
||||
)
|
||||
serial = self._entry.data.get(CONF_SERIAL) or polled_serial
|
||||
if serial != polled_serial:
|
||||
# Same IP, different appliance (or firmware that changed what it
|
||||
# reports) -- keep the registered identity; re-adding is the
|
||||
# user's call.
|
||||
self._log.warning(
|
||||
"device at %s reports serial %r but this entry is registered "
|
||||
"as %r; keeping the registered identity",
|
||||
self._entry.data[CONF_HOST],
|
||||
polled_serial,
|
||||
serial,
|
||||
)
|
||||
self.device_serial = serial
|
||||
key, serial = self._resolve_identity(polled_serial, from_snapshot=from_snapshot)
|
||||
self.device_key = key
|
||||
|
||||
ident = self._identity
|
||||
model = resolve_model(model_num, ident)
|
||||
@@ -1373,12 +1456,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
mfr = (ident.manufacturer if ident else "") or "Samsung"
|
||||
|
||||
self.device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, serial)},
|
||||
identifiers={(DOMAIN, key)},
|
||||
name=name,
|
||||
manufacturer=mfr,
|
||||
model=model,
|
||||
)
|
||||
self._persist_identity(serial, model, mfr, device_type_name)
|
||||
# A snapshot replay passes None: it never reached the device, so
|
||||
# writing a key here would freeze a pre-v4 entry's legacy key in as
|
||||
# if a poll had confirmed it, and the real UUID would later look
|
||||
# like an identity to defend against rather than one to adopt.
|
||||
self._persist_identity(None if from_snapshot else key, serial, model, mfr, device_type_name)
|
||||
if not from_snapshot:
|
||||
# A coverage gap is a claim about what the device reports, so only
|
||||
# a live poll gets to make it. Replaying a snapshot would restate
|
||||
@@ -1392,9 +1479,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
self._discovered = True
|
||||
self._log.info(
|
||||
"discovered %d entities (serial=%s) hot=%s warm=%s subdevices=%s",
|
||||
"discovered %d entities (key=%s) hot=%s warm=%s subdevices=%s",
|
||||
len(bound),
|
||||
serial,
|
||||
key,
|
||||
self._hot_hrefs,
|
||||
self._warm_hrefs,
|
||||
[su.key for su in self.subdevices],
|
||||
|
||||
@@ -88,7 +88,7 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
super().__init__(coordinator)
|
||||
self._bound = bound
|
||||
self._state_key = _key(bound)
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_{self._state_key}"
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_key}_{self._state_key}"
|
||||
if bound.desc.translation_placeholders is not None:
|
||||
self._attr_translation_placeholders = dict(bound.desc.translation_placeholders)
|
||||
elif bound.desc.use_instance_name:
|
||||
|
||||
@@ -13,6 +13,12 @@ class DeviceIdentity:
|
||||
model: str
|
||||
name: str
|
||||
serial: str | None
|
||||
# /oic/d's `di` and /oic/p's `pi` -- OCF's own device and platform
|
||||
# UUIDs. Promoted out of `raw` into named fields because
|
||||
# resolve_device_key mints permanent registry keys from them; see its
|
||||
# docstring for why `di` leads.
|
||||
device_id: str | None = None
|
||||
platform_id: str | None = None
|
||||
device_types: tuple[str, ...] = ()
|
||||
raw: dict[str, dict | list] = field(default_factory=dict)
|
||||
|
||||
@@ -57,6 +63,63 @@ def resolve_serial(raw_serial: str | None, host: str) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def is_usable_device_id(value: str | None) -> bool:
|
||||
"""True for an OCF `di`/`pi` that actually identifies one unit.
|
||||
|
||||
Rejects OCF's nil UUID, which firmware that never had one assigned
|
||||
reports on every unit of the family -- the #189 failure mode on a new
|
||||
field, and one `is_placeholder_serial`'s repeated-digit rule misses
|
||||
because the dashes make more than one distinct character. Past that the
|
||||
same known-junk rules apply: a board flashed with 'Nothing(SVC)' in one
|
||||
identity field is not one to trust in another.
|
||||
"""
|
||||
s = (value or "").strip()
|
||||
if not s:
|
||||
return False
|
||||
if not set(s) - {"0", "-"}:
|
||||
return False
|
||||
return not is_placeholder_serial(s)
|
||||
|
||||
|
||||
def ocf_device_key(identity: DeviceIdentity | None) -> str | None:
|
||||
"""The OCF-derived half of resolve_device_key's chain, or None when the
|
||||
device reported no usable UUID.
|
||||
|
||||
Split out because "no UUID" and "this UUID" are different answers to a
|
||||
caller holding an existing key: the coordinator must never demote an
|
||||
entry off its UUID just because one poll couldn't read /oic/d.
|
||||
Normalized so firmware that changes case between reads doesn't look
|
||||
like a different appliance.
|
||||
"""
|
||||
if identity is None:
|
||||
return None
|
||||
for candidate in (identity.device_id, identity.platform_id):
|
||||
if candidate is not None and is_usable_device_id(candidate):
|
||||
return candidate.strip().lower()
|
||||
return None
|
||||
|
||||
|
||||
def resolve_device_key(identity: DeviceIdentity | None, raw_serial: str | None, host: str) -> str:
|
||||
"""The identity to mint this device's permanent registry keys from.
|
||||
|
||||
Tried in order: /oic/d's `di`, /oic/p's `pi`, the serialNum, the host.
|
||||
|
||||
`di` leads because it is what the protocol already uses to address this
|
||||
endpoint: if it were wrong or shared, OCF discovery and the DTLS
|
||||
association would not work at all. serialNum is a vendor-populated
|
||||
string nothing depends on, which is why three firmware families have
|
||||
shipped it unusable -- 'Nothing(SVC)' (#83), a flash-unset sentinel
|
||||
(#189), and a well-formed serial duplicated across every unit (#381).
|
||||
|
||||
`pi` is only the fallback despite the spec calling it immutable: it is
|
||||
*platform*-scoped, so a board hosting several logical OCF devices
|
||||
shares one across all of them. `di` is device-scoped, the granularity
|
||||
of a config entry. The serial and host stay below both so a board
|
||||
answering neither resource lands where it always did.
|
||||
"""
|
||||
return ocf_device_key(identity) or resolve_serial(raw_serial, host)
|
||||
|
||||
|
||||
def resolve_model(model_num: str, identity: DeviceIdentity | None) -> str:
|
||||
"""The model string to name and register a device under.
|
||||
|
||||
@@ -143,6 +206,8 @@ def read_identity(sess, serial: str | None) -> DeviceIdentity:
|
||||
model=p.get("mnmo") or "",
|
||||
name=d.get("n") or "",
|
||||
serial=serial,
|
||||
device_id=d.get("di") if isinstance(d.get("di"), str) else None,
|
||||
platform_id=p.get("pi") if isinstance(p.get("pi"), str) else None,
|
||||
device_types=_device_types(d),
|
||||
# Kept whole rather than field-by-field: outside the /device/0 dump
|
||||
# diagnostics already captures, and we don't yet know which fields
|
||||
|
||||
@@ -30,13 +30,18 @@ _SENSITIVE_SUBSTRINGS = (
|
||||
"secret",
|
||||
)
|
||||
|
||||
# Matched whole, not as substrings: OCF's /oic/d and /oic/p identify the
|
||||
# unit with bare one/two-letter keys too short for the substring rules above
|
||||
# ('di' is a substring of 'condition', 'display', ...). 'di'/'pi' are the
|
||||
# device/platform UUIDs; 'n' is /oic/d's free-text device name, which may
|
||||
# carry a person's name -- the device-type signal we actually want from
|
||||
# that resource is `rt`, which is not redacted.
|
||||
_SENSITIVE_EXACT = frozenset({"di", "pi", "n"})
|
||||
# Matched whole, not as substrings: these are bare one/two-letter keys too
|
||||
# short for the substring rules above ('n' is a substring of very nearly
|
||||
# everything). 'n' is /oic/d's free-text device name, which the owner sets
|
||||
# from the SmartThings app and can carry a person's name -- the device-type
|
||||
# signal we actually want from that resource is `rt`, which is not redacted.
|
||||
#
|
||||
# /oic/d's `di` and /oic/p's `pi` are deliberately not redacted: they're
|
||||
# randomly-assigned per-unit UUIDs rather than account data, and they are
|
||||
# what registry keys are minted from (issue #381), so blanking them hides
|
||||
# the identity every entity in a report is named after -- which is exactly
|
||||
# what made #381's first diagnostics download unable to answer it.
|
||||
_SENSITIVE_EXACT = frozenset({"n"})
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Move an entry's registry entries from one device key to another.
|
||||
|
||||
The key (registry.identity.resolve_device_key) is permanent in three
|
||||
places, so changing it means rewriting the registries rather than storing
|
||||
a new value -- anything left behind is orphaned. Rewriting rather than
|
||||
recreating is what keeps an entity's entity_id, and with it its history,
|
||||
area and automations.
|
||||
|
||||
Its own module because both callers need it: the v1 -> v2 migration in
|
||||
__init__.py and the coordinator's first-poll adoption (issue #381).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@callback
|
||||
def rekey_entry(hass: HomeAssistant, entry: ConfigEntry, old_key: str, new_key: str) -> None:
|
||||
"""Rewrite everything this entry registered under `old_key` to `new_key`.
|
||||
|
||||
All three permanent places move together: entity unique_ids
|
||||
(f"{DOMAIN}_{key}_{state_key}"), device identifiers ((DOMAIN, key), plus
|
||||
(DOMAIN, f"{key}_{subdevice}") per subdevice -- see device_info_for),
|
||||
and the entry's own unique_id. Leaving that last one behind would let
|
||||
the config flow's duplicate check wave through a re-add of this very
|
||||
appliance.
|
||||
|
||||
Idempotent, so it is safe to attempt on every poll rather than tracking
|
||||
whether it has run. Where both keys already exist the `old_key` copy is
|
||||
the dead one, so it is removed rather than rewritten over the live entry.
|
||||
|
||||
Must run on the event loop; the registry helpers require it.
|
||||
"""
|
||||
if old_key == new_key:
|
||||
return
|
||||
|
||||
new_entry_unique_id = f"{DOMAIN}_{new_key}"
|
||||
if entry.unique_id != new_entry_unique_id:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=new_entry_unique_id)
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
stale_prefix = f"{DOMAIN}_{old_key}_"
|
||||
for entity in list(er.async_entries_for_config_entry(ent_reg, entry.entry_id)):
|
||||
if not entity.unique_id.startswith(stale_prefix):
|
||||
continue
|
||||
new_unique_id = f"{DOMAIN}_{new_key}_{entity.unique_id[len(stale_prefix) :]}"
|
||||
if ent_reg.async_get_entity_id(entity.domain, DOMAIN, new_unique_id):
|
||||
_LOGGER.debug("removing orphaned entity %s", entity.entity_id)
|
||||
ent_reg.async_remove(entity.entity_id)
|
||||
else:
|
||||
_LOGGER.debug("re-keying entity %s to %s", entity.entity_id, new_unique_id)
|
||||
ent_reg.async_update_entity(entity.entity_id, new_unique_id=new_unique_id)
|
||||
|
||||
dev_reg = dr.async_get(hass)
|
||||
for device in list(dr.async_entries_for_config_entry(dev_reg, entry.entry_id)):
|
||||
stale = {
|
||||
ident
|
||||
for ident in device.identifiers
|
||||
if ident[0] == DOMAIN and (ident[1] == old_key or ident[1].startswith(f"{old_key}_"))
|
||||
}
|
||||
if not stale:
|
||||
continue
|
||||
fresh = {(DOMAIN, f"{new_key}{ident[1][len(old_key) :]}") 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 was re-keyed rather than removed above -- the
|
||||
# surviving copy, not a duplicate -- so move it onto the device
|
||||
# it now belongs to before the removal destroys it too.
|
||||
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:
|
||||
_LOGGER.debug("re-keying device %s to %s", device.id, fresh)
|
||||
dev_reg.async_update_device(
|
||||
device.id, new_identifiers=(device.identifiers - stale) | fresh
|
||||
)
|
||||
@@ -187,7 +187,7 @@ class LocalThingsConnectionModeSensor(CoordinatorEntity[LocalThingsCoordinator],
|
||||
|
||||
def __init__(self, coordinator: LocalThingsCoordinator) -> None:
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_serial}_connection_mode"
|
||||
self._attr_unique_id = f"{DOMAIN}_{coordinator.device_key}_connection_mode"
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
|
||||
Reference in new issue
Block a user