Key devices on the OCF device ID instead of the serial number
Two Samsung air purifiers of the same model report the identical, well formed serialNum `BS7SP9AW400114A` (issue #381). Since the entry's unique_id, the device registry identifiers and every entity unique_id were all minted from that string, the second unit was refused as already configured, and would have collided entity-for-entity even if it hadn't been. This is the third firmware family to ship an unusable serialNum, after `Nothing(SVC)` (#83) and the flash-unset sentinel (#189), and the first one no heuristic can catch: the value is well formed, it's just shared. `is_placeholder_serial` was a dead end. So identity moves onto /oic/d's `di`, falling back to /oic/p's `pi`, then the serial, then the host. `di` is what the protocol already uses to address the endpoint -- if it were wrong or shared, OCF discovery and the DTLS association wouldn't work at all -- and it's device-scoped, where `pi` is platform-scoped and would be shared by a board hosting several logical devices. Both units in #381 report a distinct `di`. A board that answers neither resource lands exactly where it did before, so no existing hardware regresses. The re-key can't happen in async_migrate_entry: the UUID is only readable from the device, and an entry can load entirely from its snapshot while the appliance is off (#295). So v3 -> v4 only records the legacy key, and the coordinator adopts the UUID on the first live poll, rewriting the entity registry, the device registry (including subdevice identifiers) and the entry's unique_id together. Rewriting rather than recreating is what lets a user keep entity_ids, names, areas, statistics and every automation that references them. Three rules keep that adoption from misfiring: - A poll that reads no UUID never demotes a UUID-keyed entry back onto its serial, so one failed reconnect doesn't re-key every entity. - A changed UUID is followed only when the serial still corroborates it (a factory reset may regenerate `di`) or when the entry was keyed on its IP, which was never an identity to defend. - When the identity is rejected as a different appliance, the serial isn't adopted either -- otherwise the intruder would gain exactly the corroboration needed to win the next poll. Also stop redacting `di`/`pi` from diagnostics. They're randomly assigned per-unit UUIDs, not account data, and blanking them is what made the first #381 diagnostics download unable to answer the only question it was requested to answer. The owner-set device name stays redacted. Fixes #381
This commit is contained in:
@@ -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,11 @@ 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']}")
|
||||
# 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,16 @@ 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"
|
||||
# The identity this entry's devices and entities are actually keyed on --
|
||||
# registry.identity.resolve_device_key's output, normally the OCF device
|
||||
# UUID (issue #381). Distinct from CONF_SERIAL, which stays as (a) the key
|
||||
# a pre-v4 entry was minted with, so the one-time re-key knows what to
|
||||
# rewrite from, and (b) the corroborating identity that tells a device
|
||||
# whose `di` rotated across a factory reset apart from a different
|
||||
# appliance that moved onto this address. Absent on an entry that has not
|
||||
# polled since upgrading: the OCF resources are only readable from the
|
||||
# device, so the coordinator adopts this on the first live poll.
|
||||
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,22 @@ 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]
|
||||
#
|
||||
# CONF_DEVICE_KEY is what a v4 entry stores (the OCF device UUID,
|
||||
# issue #381); CONF_SERIAL is both the pre-v4 key and the fallback
|
||||
# for a board that answers no OCF identity resource; and the host
|
||||
# covers a pre-migration entry, matching what resolve_serial itself
|
||||
# returns for a placeholder-serial board (issues #83/#189). The
|
||||
# chain is ordered so an entry that has not polled since upgrading
|
||||
# keeps loading under the key its registry entries already carry.
|
||||
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 +778,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 +1103,120 @@ 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.
|
||||
|
||||
Returns both together because they have to agree: the serial is what
|
||||
corroborates a later change of key, so writing this poll's serial
|
||||
while *defending* the stored key against a different appliance would
|
||||
quietly hand that appliance the corroboration it needs to win the
|
||||
next poll. The serial is therefore only adopted alongside a key we
|
||||
accepted -- never on a poll whose identity we just rejected.
|
||||
|
||||
The stored key wins by default: re-keying an entry that already has
|
||||
registry entries orphans them unless the rewrite goes with it
|
||||
(issue #236), so nothing here changes a key without calling
|
||||
`rekey_entry` in the same breath.
|
||||
|
||||
Four things can happen:
|
||||
|
||||
* A snapshot replay (issue #295) never re-keys. That path did not
|
||||
reach the device, so its "reported" identity is only whatever the
|
||||
snapshot happened to preserve.
|
||||
* Nothing stored (a pre-v4 entry, or a legacy migration that could
|
||||
not recover an identity) -- adopt what the device reports and
|
||||
rewrite the registries off the legacy key onto it. This is the
|
||||
one-time move onto the OCF device UUID (issue #381), deferred to
|
||||
here because the UUID is only readable from the device itself.
|
||||
* A key is stored and this poll produced no UUID -- keep it. The
|
||||
device saying nothing is not the device saying something
|
||||
different, and demoting a UUID-keyed entry back onto its serial
|
||||
because one reconnect couldn't read /oic/d would re-key every
|
||||
entity the user has for the duration of an outage.
|
||||
* A key is stored and the UUID differs. Either this appliance's
|
||||
`di` rotated (an OCF hard reset is allowed to regenerate it) or a
|
||||
*different* appliance now answers on this address. The serialNum
|
||||
tells them apart: a usable one that still matches what this entry
|
||||
was registered with means same unit, new UUID, so follow it.
|
||||
Anything else keeps the registered identity and warns -- re-adding
|
||||
is the user's call, not something a poll gets to decide.
|
||||
"""
|
||||
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)
|
||||
|
||||
if stored_key is None:
|
||||
# `polled_serial` is already resolve_serial's output, so this is
|
||||
# exactly resolve_device_key's chain: the UUID where the device
|
||||
# reports one, today's serial/host answer where it doesn't.
|
||||
polled_key = polled_ocf or polled_serial
|
||||
legacy_key = stored_serial or host
|
||||
if legacy_key != polled_key:
|
||||
self._log.info(
|
||||
"moving this device's registry entries from %r onto %r",
|
||||
legacy_key,
|
||||
polled_key,
|
||||
)
|
||||
rekey_entry(self.hass, self._entry, legacy_key, polled_key)
|
||||
return polled_key, polled_serial
|
||||
|
||||
if polled_ocf == stored_key:
|
||||
# Confirmed the registered appliance, so this poll's serial is
|
||||
# authoritative -- firmware is allowed to correct it.
|
||||
return stored_key, polled_serial
|
||||
if polled_ocf is None:
|
||||
# Nothing to compare against: this may or may not be the
|
||||
# registered appliance, so neither half is updated.
|
||||
return stored_key, stored_serial or polled_serial
|
||||
polled_key = polled_ocf
|
||||
|
||||
# Excluding the host answer is what keeps this from firing on two
|
||||
# *different* placeholder-serial units: `polled_serial` is already
|
||||
# resolved, so anything that fell back to the address is not an
|
||||
# identity and can't corroborate anything.
|
||||
same_unit = polled_serial == stored_serial and polled_serial != host
|
||||
# An entry keyed on the host never made an identity claim to defend
|
||||
# -- it is registered against "whatever answers at this address"
|
||||
# (issues #83/#189). Any real UUID is a strict improvement on that,
|
||||
# so it is adopted without needing the serial to corroborate it;
|
||||
# requiring corroboration would strand exactly the placeholder-serial
|
||||
# boards this whole change is meant to rescue, since their serial
|
||||
# resolves to the host and can never match.
|
||||
if not (stored_key == host or same_unit):
|
||||
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,
|
||||
stored_key,
|
||||
polled_serial,
|
||||
stored_serial,
|
||||
)
|
||||
return stored_key, stored_serial or polled_serial
|
||||
|
||||
# Corroborated: same unit with a new UUID (a factory reset is
|
||||
# allowed to regenerate `di`), or an address-keyed entry finally
|
||||
# learning a real identity. Following it keeps the user's
|
||||
# entity_ids, history and automations rather than stranding them on
|
||||
# a key the device will never report again.
|
||||
self._log.info(
|
||||
"device %s (serial %r) changed key from %r to %r; following it",
|
||||
host,
|
||||
polled_serial,
|
||||
stored_key,
|
||||
polled_key,
|
||||
)
|
||||
rekey_entry(self.hass, self._entry, stored_key, polled_key)
|
||||
return polled_key, polled_serial
|
||||
|
||||
def _persist_identity(
|
||||
self,
|
||||
device_key: str | None,
|
||||
serial: str,
|
||||
model: str,
|
||||
manufacturer: str,
|
||||
@@ -1108,9 +1230,16 @@ 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_key` 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 +1324,11 @@ 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_key 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 +1480,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 +1492,19 @@ 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 it
|
||||
# has no standing to write a device key. Persisting `self.device_key`
|
||||
# there would freeze a pre-v4 entry's legacy key into CONF_DEVICE_KEY
|
||||
# as if a poll had confirmed it, and _resolve_key would then treat
|
||||
# the real UUID -- when a live poll finally produced one -- as a
|
||||
# changed identity to defend against rather than the one-time
|
||||
# adoption it is.
|
||||
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 +1518,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,83 @@ 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.
|
||||
|
||||
Firmware that never had a UUID assigned reports OCF's nil UUID
|
||||
(all-zero, dashes aside), which is identical on every unit of the
|
||||
family -- the #189 failure mode transplanted onto a new field, and not
|
||||
something `is_placeholder_serial`'s repeated-hex-digit rule catches,
|
||||
because the dashes make more than one distinct character.
|
||||
|
||||
Past that, the same known-junk rules that disqualify a serialNum
|
||||
disqualify a UUID: a board firmware-flashed with 'Nothing(SVC)' in one
|
||||
identity field is not a board to trust in another. Anything else is
|
||||
accepted as-is. A *shared but well-formed* `di` would be undetectable
|
||||
here, exactly as issue #381's shared-but-well-formed serialNum is --
|
||||
heuristics can't see that, which is the whole reason this moves onto a
|
||||
field the protocol itself has to keep distinct.
|
||||
"""
|
||||
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 "the device has no UUID" and "the device has this
|
||||
UUID" are different answers to a caller holding an existing key: the
|
||||
coordinator must never demote an entry from a UUID back onto a
|
||||
serialNum just because one poll couldn't read /oic/d (a reconnect, a
|
||||
timeout), which is exactly what it would do if it only saw the
|
||||
collapsed string resolve_device_key returns.
|
||||
|
||||
Normalized because the result is compared against its stored form on
|
||||
every poll; firmware that changes case between reads would otherwise
|
||||
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 the identifier the protocol we are already
|
||||
speaking uses to address this endpoint -- if it were wrong or shared,
|
||||
OCF discovery and the DTLS association would not work in the first
|
||||
place. serialNum, by contrast, is a vendor-populated string no part of
|
||||
the stack depends on, which is why three separate firmware families
|
||||
have shipped it unusable: 'Nothing(SVC)' (#83), a flash-unset sentinel
|
||||
(#189), and -- unfixable by any heuristic -- a well-formed serial
|
||||
duplicated across every unit of the model (#381).
|
||||
|
||||
`pi` is only the fallback despite the OCF spec calling it immutable:
|
||||
it identifies the *platform*, so a board hosting more than one logical
|
||||
OCF device shares one `pi` across all of them, reintroducing the very
|
||||
collision this exists to prevent. `di` is device-scoped, which is the
|
||||
granularity of a config entry.
|
||||
|
||||
The serial stays in the chain below both so a board that answers
|
||||
neither OCF resource lands exactly where it did before this existed,
|
||||
and the host stays last for the same reason -- it is an address rather
|
||||
than an identity (a new DHCP lease silently makes it someone else's),
|
||||
so it is strictly a last resort.
|
||||
"""
|
||||
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 +226,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,24 @@ _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.
|
||||
#
|
||||
# OCF's /oic/d `di` and /oic/p `pi` used to be redacted here too. They are
|
||||
# not account data: they're randomly-assigned per-unit UUIDs, carrying no
|
||||
# more about their owner than the appliance-internal subdeviceIdList
|
||||
# diagnostics already reports for the same reason (see diagnostics.py).
|
||||
# Redacting them cost more than it bought -- issue #381 was two units
|
||||
# colliding on a duplicated serialNum, and the first diagnostics download
|
||||
# asking whether their `di`/`pi` differed came back with both values
|
||||
# blanked, so the question could only be answered by walking the reporter
|
||||
# through a manual read_resource call. They are now also what
|
||||
# resolve_device_key mints registry keys from, so a report that hides them
|
||||
# hides the identity every entity in it is named after.
|
||||
_SENSITIVE_EXACT = frozenset({"n"})
|
||||
|
||||
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Move an entry's registry entries from one device key to another.
|
||||
|
||||
The key a device's registry entries are minted from (see
|
||||
registry.identity.resolve_device_key) appears in three permanent places:
|
||||
the config entry's unique_id, the device registry's identifiers, and every
|
||||
entity's unique_id. Changing it therefore can't be a matter of writing a
|
||||
new value and restarting -- everything already in the registries would be
|
||||
orphaned, and the user would find a duplicate device whose entities all
|
||||
carry a `_2` suffix, with their history, area and automations attached to
|
||||
the dead copy.
|
||||
|
||||
So the key change is performed *on* the registries instead. Rewriting
|
||||
beats deleting: an entity keeps its entity_id, and with it its name, area,
|
||||
long-term statistics and every automation and dashboard that references
|
||||
it.
|
||||
|
||||
Lives in its own module rather than in __init__.py because both callers
|
||||
need it and they sit on opposite sides of an import edge: the v1 -> v2
|
||||
migration in __init__.py, and the coordinator's first-poll adoption of the
|
||||
OCF device UUID (issue #381), which can only happen once the device has
|
||||
actually been reached.
|
||||
"""
|
||||
|
||||
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`.
|
||||
|
||||
Covers all three places the key is permanent: the entity registry
|
||||
(unique_ids are f"{DOMAIN}_{key}_{state_key}"), the device registry
|
||||
(identifiers are (DOMAIN, key) for the device itself and
|
||||
(DOMAIN, f"{key}_{subdevice}") for each subdevice of a composite
|
||||
appliance -- see coordinator.device_info_for), and the config entry's
|
||||
own unique_id.
|
||||
|
||||
Leaving the entry's unique_id behind would half-migrate it: the config
|
||||
flow's duplicate check (_abort_if_unique_id_configured) would still be
|
||||
comparing new devices against the key this entry no longer uses, so
|
||||
re-adding this very appliance would be waved through as a second entry.
|
||||
|
||||
Idempotent: a second call finds nothing left under `old_key` and does
|
||||
nothing, which is what makes it safe to attempt on every poll rather
|
||||
than having to track whether it has already run.
|
||||
|
||||
Where both keys somehow already exist, the `old_key` copy is the dead
|
||||
one -- unavailable since whichever restart created the split -- 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_unique_id = f"{DOMAIN}_{new_key}"
|
||||
if entry.unique_id != new_unique_id:
|
||||
hass.config_entries.async_update_entry(entry, unique_id=new_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