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:
Marc Billow authored and GitHub committed 2026-08-17 22:03:49 -05:00
commit f780cc6069
35 files changed
+1719 -171

No files matched your search

+40 -50
View File
@@ -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
+48 -5
View File
@@ -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)
+5
View File
@@ -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"
+117 -30
View File
@@ -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],
+1 -1
View File
@@ -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:
+90
View File
@@ -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
)
+1 -1
View File
@@ -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: