Files
localthings/custom_components/localthings/entity.py
T
Marc Billow 81dbc6fa03 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
2026-08-17 05:24:22 +00:00

138 lines
6.2 KiB
Python

"""Base entity for Local Things."""
from __future__ import annotations
import re
from homeassistant.const import EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.adapter import _key
from .registry.batch import is_stub_rep
from .registry.discovery import BoundEntity, _snake_to_title
def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> bool:
"""Return False if the entity should not be registered for this device.
Explicit exists_fn takes priority. Otherwise, if the entity has a
field, require that field to be present in the resource rep so that
optional fields on shared resources don't create phantom entities.
A stub rep (is_stub_rep) is included anyway so it can be populated by
sub-polls. A genuinely empty {} rep is included too by this default
gate: whether empty means "not populated yet" or "permanently
unsupported" needs per-field domain knowledge this generic gate
doesn't have (e.g. /alarms/vs/0's {} is fridge.py's documented normal
no-alarm state, not an absence signal). Only a capability whose author
has verified a field is genuinely never populated opts into stricter
gating with its own exists_fn (see common.ENERGY_METER, issue #127).
`bound.href` is already the actual href (issue #177); `exists_fn` gets
`bound`'s own subdevice's canonical view instead of the raw snapshot,
same rule as everywhere else a whole-resources-dict scan happens --
this is a free function, so it can't use self._resources.
Reads `discovery_resources`, not `last_resources`: on an offline load
(issue #295) the live cache is still empty, and judging existence
against it would filter every rehydrated entity away.
"""
rep = coordinator.discovery_resources.get(bound.href)
if rep is None:
return False
if bound.desc.exists_fn is not None:
return bound.desc.exists_fn(rep, coordinator.discovery_canonical(bound.subdevice))
if bound.desc.field:
if not rep or is_stub_rep(rep):
return True
return bound.desc.field in rep
return True # rep_fn or no-field entities (ButtonDesc) are always included
def _derive_name(state_key: str) -> str:
"""Turn a snake_case state key into a title-cased label.
Strips a trailing instance number of 0 (singleton), promotes any other
instance number with a space: "door_cooler_open1" → "Door Cooler Open 1".
Entity names themselves come from the translation catalog; this only
builds the {instance_name} placeholder those translations interpolate,
for a device that named its own compartments/ice makers.
"""
name = re.sub(r"(\d+)$", lambda m: f" {m.group()}" if int(m.group()) > 0 else "", state_key)
return _snake_to_title(name).strip()
def _instance_display_name(bound: BoundEntity, state_key: str) -> str:
"""Return the stable vendor/href instance label used in a name placeholder."""
if bound.instance_name:
return bound.instance_name
source = bound.key_override or state_key
suffix = f"_{bound.desc.key}"
if source.endswith(suffix):
source = source[: -len(suffix)]
elif bound.instance and source.endswith(bound.instance):
source = source[: -len(bound.instance)] + bound.instance.replace("_", " ")
return _derive_name(source)
class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
"""Base class for all Local Things entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: LocalThingsCoordinator, bound: BoundEntity) -> None:
super().__init__(coordinator)
self._bound = bound
self._state_key = _key(bound)
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:
self._attr_translation_placeholders = {
"instance_name": _instance_display_name(bound, self._state_key)
}
# _attr_name is deliberately left unset: HA gives an explicitly-set
# name precedence over the translation catalog, so setting it here
# would make every entity untranslatable. A platform that wants the
# bare device name sets _attr_name = None itself (see fan.py).
self._attr_icon = bound.desc.icon
raw_cat = bound.desc.entity_category
self._attr_entity_category = EntityCategory(raw_cat) if raw_cat else None
self._attr_entity_registry_enabled_default = bound.desc.enabled_default
@property
def translation_key(self) -> str | None:
"""The descriptor's catalog key, defaulting to its own `key`.
Overrides Entity.translation_key so a callable descriptor (e.g.
laundry.cycle_select's table-id-gated resolver) is re-evaluated
against live coordinator data on every access, not resolved once
at construction time -- a static resolution would risk baking in
a permanent None if the first poll handed a sibling an empty stub
rep (see _is_included's docstring) before it populated.
"""
tk = self._bound.desc.translation_key
if callable(tk):
return tk(self._resources)
return tk if tk is not None else self._bound.desc.key
@property
def _resources(self) -> dict:
"""This entity's own subdevice's canonical resources view (issue
#177) -- see coordinator.canonical_resources. Any platform property
needing the whole resources dict, not one href via
`coordinator.resource(href)`, must read through this instead of
`coordinator.last_resources`, or a sibling subdevice's own hrefs
could leak into this entity's view. For MAIN this is exactly
`coordinator.last_resources`."""
return self.coordinator.canonical_resources(self._bound.subdevice)
@property
def device_info(self) -> DeviceInfo:
return self.coordinator.device_info_for(self._bound.subdevice)