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
138 lines
6.2 KiB
Python
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)
|