Files
localthings/custom_components/localthings/entity.py
T
Marc Billow c181a8b447 feat(subdevices): support multi-indoor-unit systems (#177)
Samsung 2-in-1 air conditioners put more than one logical indoor unit
behind a single IP and a single DTLS session. Only the unit the config
entry was set up against was ever discovered; the second one -- a whole
physical appliance the user can see in SmartThings -- had no entities at
all. Two reporters turned out to have two different mechanisms:

  ARTIK051_DONGLE_FAC_18K -- indexed siblings. /oic/res registers the
  whole tree discoverable and lists three complete parallel resource
  sets whose trailing path segment is the index (/mode/vs/0, /mode/vs/1,
  ...), on OCF-standard and vendor hrefs alike. /device/0's batch
  carries only the index-0 hrefs, so a sibling is reachable only through
  its own /device/<n> collection.

  TP2X_FAC_BORA_21K -- UUID-prefixed tree. /oic/res hides the appliance
  tree entirely (which is why a direct /device/1 probe returns nothing
  on this board). /subdevices/vs/0 carries subdeviceIdList instead, and
  that UUID appears as a literal href prefix; /<uuid>/information/vs/0
  was confirmed live to return the wall unit's own model and serial
  (TP2X_FAC_BORA_RAC_21K) against the master's TP2X_FAC_BORA_21K.

The detection signals don't overlap on either board, so no
disambiguation is needed -- enumeration checks both and takes what
answers.

Both patterns are the same thing underneath: a logical unit is a seed
collection path to poll plus an href transform between the canonical
href the registry knows and the actual on-the-wire href. That is the
whole abstraction (SubUnit), applied at four boundaries -- discovery,
the coordinator, the adapter, and the platforms. Capabilities, the
registry and the climate composite stay written against canonical hrefs
and are untouched.

Uniqueness comes from a key_prefix inside the flattened state key, so
the master unit's keys are byte-identical to every release before this
and every existing golden file is an unchanged regression guard. Each
sub-unit gets its own device-registry entry linked by via_device and
named from its own /information/vs/<n>, so it lands in its own room
rather than crowding the master's device page.

A sub-unit materializes only when it yields at least one primary
(non-diagnostic) entity with a populated value. That gate is not
decoration: the reporter's /device/2 is an unused slot that SmartThings
shows disabled, yet it answers with a full 14-href batch, and it
flattens to exactly one non-None value -- a diagnostic alarm_code
derived from an empty /alarms/vs/2. Without the entity-category filter
it becomes a phantom third climate card. The rule is deliberately
domain-agnostic rather than a list of HVAC hrefs, so a multi-drum
washer (#19) gets the same treatment with no new curation. Units that
answer but fail the gate are logged and reported in diagnostics, so a
genuinely missing unit stays diagnosable from a dump.

Enumeration fetches things that must not then be treated as appliance
state. A rejected candidate's seed has to be read to evaluate the gate,
but only units that pass are polled again, and StateCache has no
eviction -- so discovery runs before the first cache apply and those
reps are held aside for diagnostics rather than frozen into the cache
forever. /multidevice/vs/0 is probed on every device regardless of
family, so merging it into the resources dict would have reached
discovery on any board whose registry doesn't ignore that href -- only
the air conditioner one does -- raising a spurious coverage-gap repair
for a washer or fridge whose firmware answers it. It is corroborating
metadata (numofsubdevice, confirmed read-only) and now lives beside the
resources rather than in them.

Diagnostics reports each unit separately: top-level `resources` is this
unit's own and only its own, which is what the module docstring and the
adding-device-support skill have always claimed it was, and each
sibling or rejected candidate carries its own reps canonicalized so a
block reads exactly like the master's instead of needing to be
de-indexed by hand.

Fixtures are real captures. The ARTIK051_DONGLE_FAC_18K one is entirely
verbatim, both sibling seeds and the hand-read /multidevice/vs/0
included. The TP2X_FAC_BORA one has a real device0, oic_res and
sub-unit /information/vs/0, with the remainder of that unit's tree
constructed and documented as such in seeds_note; /<uuid>/device/0 is
the one part of that pattern still inferred rather than observed, and
can't be tested through the debug panel because a Collection returns a
list.
2026-07-29 19:14:51 +00:00

150 lines
6.9 KiB
Python

"""Base entity for Local Things."""
from __future__ import annotations
import re
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.const import EntityCategory
from .registry.adapter import _key
from .registry.batch import is_stub_rep
from .registry.discovery import BoundEntity, _snake_to_title
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
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 — /device/0's "resource exists, no data fetched
yet" marker) 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: /alarms/vs/0's
{} is fridge.py's documented *normal* no-alarm state (see
_active_alarm_codes), not an absence signal, and it's far from the only
resource like that. Only a capability whose author has actually verified
a field is genuinely never populated on unsupported hardware opts into
stricter gating with its own is_stub_rep-based exists_fn (see
common.ENERGY_METER, issue #127) -- this default stays permissive.
`bound.href` is already the *actual* href (issue #177 -- see
BoundEntity/SubUnit), so the direct cache lookup below is correct as-is;
`exists_fn` gets `bound`'s own sub-unit's *canonical* view instead of the
raw snapshot, same rule as everywhere else a whole-resources-dict scan
happens (coordinator.canonical_resources) -- this is a free function, not
an LocalThingsEntity method, so it can't use self._resources.
"""
rep = coordinator.last_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.canonical_resources(bound.sub_unit))
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_serial}_{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: Home Assistant gives an
# explicitly-set name precedence over the translation catalog, so
# setting it here would make every entity untranslatable. Every
# descriptor resolves to a catalog entry (see translation_key below);
# a platform that wants the bare device name instead sets
# _attr_name = None itself, as fan.py does for the hood's main entity.
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 (a property upstream, not a plain
attribute) 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.
Discovery runs on the first /device/0 poll, which the entity
registry already documents can hand a sibling resource an empty
stub rep before it's actually been fetched (see _is_included's
docstring) -- a static one-time resolution here would risk baking
in a permanent None (no translation) for the entity's whole
lifetime if that stub hadn't populated yet, even once the real
value arrives on a later poll.
"""
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 sub-unit's canonical resources view (issue
#177) -- see coordinator.canonical_resources. Every platform
property that needs the *whole* resources dict, as opposed to one
href via `coordinator.resource(href)`, must read through this
instead of `coordinator.last_resources`, or a sibling unit's own
actual hrefs would leak into (or be missing from) this entity's
view. For MAIN (every device with no sub-units) this is exactly
`coordinator.last_resources`."""
return self.coordinator.canonical_resources(self._bound.sub_unit)
@property
def device_info(self) -> DeviceInfo:
return self.coordinator.device_info_for(self._bound.sub_unit)