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.
150 lines
6.9 KiB
Python
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)
|