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.
This commit is contained in:
Marc Billow committed 2026-07-29 19:14:51 +00:00
1 parent 119a4f443c
commit c181a8b447
33 files changed
+5277 -125

No files matched your search

+31 -11
View File
@@ -254,8 +254,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
def _legacy_convenient(self) -> dict:
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
/mode/vs/0's options, for boards that have no convenient resource."""
options = (self.coordinator.resource(MODE_HREF) or {}).get(
'x.com.samsung.da.options') or []
options = self._rep(MODE_HREF).get('x.com.samsung.da.options') or []
for option in options:
if isinstance(option, str) and option.startswith('Comode_'):
return {_MODES_FIELD: [option.split('_', 1)[1]],
@@ -268,18 +267,26 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
Delegates the board-generation test to is_legacy_board (the same
test capabilities/airconditioner.py's token entities are gated on)
instead of re-implementing it. Uses last_resources rather than a
two-key presence dict built from coordinator.resource()'s truthiness
-- resource() collapses "href absent" and "href present with an
empty {} rep" to the same falsy value, while is_legacy_board (and
discover()'s own binding) test key membership, not truthiness. A
instead of re-implementing it. Uses self._resources (this unit's own
canonical view, issue #177 -- see LocalThingsEntity._resources)
rather than a two-key presence dict built from coordinator.resource()'s
truthiness -- resource() collapses "href absent" and "href present
with an empty {} rep" to the same falsy value, while is_legacy_board
(and discover()'s own binding) test key membership, not truthiness. A
presence dict built from truthiness alone would disagree with the
token entities on a board reporting a genuinely empty /airflow/vs/0,
silently reintroducing the drift this delegation exists to prevent.
Reads the actual href through self._rep rather than
coordinator.resource() directly -- on a sub-unit (a legacy-board
sibling has its own /airflow/vs/1, or /<id>/airflow/vs/0), the
canonical AIRFLOW_HREF must be translated through this bound
entity's own sub_unit first, exactly like every other sibling read
below.
"""
if not is_legacy_board(self.coordinator.last_resources):
if not is_legacy_board(self._resources):
return {}
return self.coordinator.resource(AIRFLOW_HREF) or {}
return self._rep(AIRFLOW_HREF)
def _legacy_preset(self) -> bool:
"""Whether presets come from the Comode_* token rather than a resource.
@@ -288,12 +295,25 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
rep being empty alone: newer boards carry Comode tokens too, so a
momentarily empty /mode/convenient/vs/0 there must not silently switch
the preset read (and write) over to the token path.
Deliberately reads the *raw* href (translated through this bound
entity's own sub_unit, not through self._rep) rather than going
through _rep's own CONVENIENT_HREF fallback branch -- that fallback
is exactly the legacy_convenient() rep this method is deciding
whether to use, so routing through it here would make the resource
never look empty and this always resolve to the wrong side.
"""
return (not self.coordinator.resource(CONVENIENT_HREF)
convenient_href = self._bound.sub_unit.to_actual(CONVENIENT_HREF)
return (not self.coordinator.resource(convenient_href)
and bool(self._legacy_airflow()))
def _rep(self, href: str) -> dict:
rep = self.coordinator.resource(href) or {}
"""`href` is one of this module's canonical HREF_* constants --
translated through this bound entity's own sub_unit (issue #177) to
the real, on-the-wire href before the single-href cache lookup
(identity for MAIN, so a device with no sub-units reads exactly the
href it always did)."""
rep = self.coordinator.resource(self._bound.sub_unit.to_actual(href)) or {}
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
return self._legacy_convenient()
return rep
+288 -16
View File
@@ -30,10 +30,14 @@ from .registry.capabilities.common import (
remote_control_enabled,
remote_control_required_for_write,
)
from .registry.discovery import discover, BoundEntity
from .registry.discovery import BoundEntity
from .registry import CAPABILITIES
from .registry.adapter import flatten
from .registry.identity import read_identity, DeviceIdentity
from .registry.subunits import (
MAIN, SubUnit, canonical_view, discover_partitioned, enumerate_sub_units,
normalize_seed_batch,
)
from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
from .const import (
@@ -161,6 +165,39 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._identity: DeviceIdentity | None = None
self._discovered = False
self.bound = []
# Sibling indoor units discovered on this connection (issue #177) --
# candidates set once, at first discovery, by
# _enumerate_sub_units_blocking; narrowed by _run_discovery to the
# ones that actually produced live primary state (see
# subunits.discover_partitioned). MAIN itself is never in this list
# (see subunits.canonical_view's docstring for why that's safe):
# it's the *other* units sharing this DTLS session, if any.
self.sub_units: list[SubUnit] = []
# Candidates _run_discovery's gate rejected (an unused SmartThings
# slot that still answers its seed, e.g. HJcom's /device/2) --
# surfaced in diagnostics alongside the materialized ones so a
# report shows what was found and why it didn't become an entity.
self._skipped_sub_units: list = []
# Those rejected candidates' raw reps, kept aside for diagnostics
# only (see _live_unit_resources). They are deliberately not in the
# state cache: nothing polls them again, so anything applied there
# would sit frozen at its first-discovery value while looking as
# live as every other href in `last_resources`.
self._skipped_sub_unit_resources: dict[str, dict] = {}
# /multidevice/vs/0's rep, if this board answers it -- a plain unit
# count that corroborates the liveness gate without deciding it.
# Deliberately outside `resources`; see _enumerate_sub_units_blocking.
self._multidevice: dict = {}
# What each sub-unit probe found, keyed by the seed href attempted --
# surfaced in diagnostics so a report can tell "checked, nothing
# there" apart from "never checked" (the same posture the
# speculative-probe code this replaced documented in identity.py).
self._sub_unit_probes: dict[str, bool] = {}
# canonical_resources() memo, keyed by (sub_unit.kind, sub_unit.key).
# Invalidated in _on_cache_changed -- climate.py reads this on every
# property access (is_legacy_board and friends), so it must not
# rebuild an O(hrefs) view from scratch on every single property.
self._canonical_cache: dict[tuple[str, str], dict] = {}
self._cache = StateCache(_NoOpDescriptor())
self._cache.set_on_change(self._on_cache_changed)
self._observe = ObserveManager(self._cache, logger=self._log)
@@ -197,6 +234,69 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
direct O(1) cache lookup."""
return self._cache.get(href) or {}
def canonical_resources(self, sub_unit: SubUnit) -> dict[str, dict]:
"""`sub_unit`'s own view of the live snapshot, rewritten into the
canonical hrefs (issue #177) the registry/platforms are written
against -- see subunits.canonical_view. A platform property that
needs the *whole* resources dict (as opposed to one href via
`resource()`/`last_resources.get(href)`) must use this instead of
`last_resources`, or a sibling unit's own `/mode/vs/1` would leak
into MAIN's canonical `/mode/vs/0` view (or vice versa) under
exists_fn/is_legacy_board-style checks that scan the whole dict.
Memoized per cache generation: climate.py calls this on every
property read (is_legacy_board and friends), and building it is
O(hrefs) -- _on_cache_changed clears the memo whenever the
snapshot actually changes, not on every property access.
"""
view_key = (sub_unit.kind, sub_unit.key)
cached = self._canonical_cache.get(view_key)
if cached is not None:
return cached
view = canonical_view(sub_unit, self._cache.snapshot(), self.sub_units)
self._canonical_cache[view_key] = view
return view
def device_info_for(self, sub_unit: SubUnit) -> DeviceInfo:
"""DeviceInfo for one logical unit sharing this connection (issue
#177) -- the master's own (unchanged) device_info for MAIN, or a
linked child device for a discovered sub-unit.
Identifiers derive from the *master's* serial (device_serial) plus
this unit's stable key, never from whatever serial the sub-unit
itself reports (or fails to) -- deterministic across reconnects
whether or not this unit's own identity resource
(/information/vs/<n>, or /<id>/information/vs/0) answered on the
poll that first created the HA device. `serial_number` is set from
that resource when present anyway -- it's informational, not an
identifier.
"""
if sub_unit.kind == 'main':
return self.device_info
info = self.canonical_resources(sub_unit).get('/information/vs/0', {})
model_num = info.get('x.com.samsung.da.modelNum', '')
model = model_num.split('|', 1)[0] if model_num else ''
serial = info.get('x.com.samsung.da.serialNum') or None
base_name = self.device_info.get('name') or 'Samsung Appliance'
if model:
label = model.replace('_', ' ').title()
else:
# This poll never got (or never will get) the sub-unit's own
# identity resource -- fall back to a generic per-unit label
# rather than leaving the device unnamed. 'Unit <n>' only makes
# sense for an indexed unit (the key is a small ordinal);
# jhkwon19-pattern (prefixed) units are never more than one per
# connection today, so there's no ordinal to show.
label = f'Unit {sub_unit.key}' if sub_unit.kind == 'indexed' else 'Secondary Unit'
return DeviceInfo(
identifiers={(DOMAIN, f"{self.device_serial}_{sub_unit.key}")},
via_device=(DOMAIN, self.device_serial),
name=f"{base_name} {label}",
manufacturer=self.device_info.get('manufacturer') or 'Samsung',
model=model or None,
serial_number=serial,
)
@property
def observe_mode(self) -> str:
return self._observe.mode
@@ -245,6 +345,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
into a single push instead of one hass.add_job per href."""
if not changed:
return
self._canonical_cache.clear()
with self._push_pending_lock:
if self._push_pending:
return
@@ -292,7 +393,33 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
body = cbor2.loads(payload)
except Exception as e:
raise RuntimeError(f"poll cbor decode: {e}") from e
return parse_device0_batch(body) if isinstance(body, list) else {}
result = parse_device0_batch(body) if isinstance(body, list) else {}
# Refresh every already-enumerated sibling unit's seed collection on
# this same summary poll (issue #177) -- without this, a sub-unit's
# climate card would show only its enumeration-time snapshot forever.
for sub_unit in self.sub_units:
result.update(self._poll_sub_unit_seed(sub_unit))
return result
def _poll_sub_unit_seed(self, sub_unit: SubUnit) -> dict[str, dict]:
"""GET one sub-unit's seed Collection and return its batch,
normalized to real hrefs. A sibling failing to answer is a debug
log, never a failed poll -- the master unit must not go unavailable
because a sibling timed out or dropped off (e.g. HJcom's
/device/2, a SmartThings-unused component that may not always
respond). Blocking -- called from _poll_once, already in executor."""
sess = self._session
if sess is None:
return {}
try:
code, payload = sess.get(list(sub_unit.seed_path), timeout=10.0)
if code == 0x45 and payload:
body = cbor2.loads(payload)
if isinstance(body, list):
return normalize_seed_batch(sub_unit, parse_device0_batch(body))
except Exception as e:
self._log.debug("sub-unit %s seed poll failed: %s", sub_unit.key, e)
return {}
def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]:
"""GET individual hrefs sequentially. Does not reconnect on failure. Blocking."""
@@ -351,6 +478,71 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Discovery (runs once on first successful poll)
# ------------------------------------------------------------------
def _enumerate_sub_units_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
"""One-time (first discovery only) probe for sibling indoor units
sharing this connection (issue #177) -- see
registry.subunits.enumerate_sub_units for the two detection
patterns. Blocking -- runs in executor, under the session lock
(shares the same DTLS session _poll_once just used this cycle).
Sets self.sub_units to every *candidate* the probes turned up
(self._sub_unit_probes as a side effect too) and returns `resources`
merged with whatever each candidate's seed returned, so this cycle's
_run_discovery sees every candidate's state without a second poll
round trip. `_run_discovery` is what narrows self.sub_units down to
the ones that are actually live (see discover_partitioned) -- this
method doesn't know how to tell an unused SmartThings slot (HJcom's
/device/2) from a real sibling, only that something answered.
"""
if self._session is None:
self._connect_session()
sess = self._session
if sess is None:
return resources
oic_res = self._identity.raw.get('/oic/res', []) if self._identity else []
probes: dict[str, bool] = {}
sub_units, extra = enumerate_sub_units(
sess, resources, oic_res,
probe_log=lambda href, found: probes.__setitem__(href, found),
)
self.sub_units = sub_units
self._sub_unit_probes = probes
# /multidevice/vs/0 is corroborating metadata, not appliance state,
# and it is probed on *every* device -- so it must not join the
# returned resources dict. Two things go wrong if it does. It would
# reach discovery on families whose registry doesn't ignore that
# href (only the AC one does), binding to nothing and raising a
# spurious "incomplete capability coverage" repair for every washer
# or fridge whose firmware happens to answer it. And it is fetched
# once here and never polled again, so applying it to the state
# cache would freeze it there exactly like a rejected candidate's
# reps (see _live_unit_resources). Kept aside for diagnostics and
# for the numofsubdevice cross-check in _run_discovery instead.
self._multidevice = extra.pop('/multidevice/vs/0', {})
return {**resources, **extra}
def _live_unit_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
"""`resources` minus every href belonging to a candidate sub-unit the
liveness gate rejected (issue #177).
Called once, between _run_discovery and the first cache apply, so a
rejected slot's reps are seen by the gate and then dropped rather
than frozen into the cache forever -- see the call site. The reps
themselves are kept in _skipped_sub_unit_resources for diagnostics,
which is the only thing that still wants them.
"""
if not self._skipped_sub_units:
return resources
kept: dict[str, dict] = {}
skipped: dict[str, dict] = {}
for href, rep in resources.items():
bucket = skipped if any(
skip.sub_unit.owns(href) for skip in self._skipped_sub_units
) else kept
bucket[href] = rep
self._skipped_sub_unit_resources = skipped
return kept
def _run_discovery(self, resources: dict[str, dict]) -> None:
# Reported for diagnostics only -- it names the firmware generation
# ('7.0 Air conditioner' is Tizen Lite), which is useful when triaging
@@ -360,7 +552,6 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
.get('swVersionInfo', {})
.get('oneUiVersion', ''))
info = resources.get('/information/vs/0', {})
reg = resolve_registry(resources)
unbound: list[str] = []
hot, warm = set(), set()
@@ -372,11 +563,53 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
model_num = info.get('x.com.samsung.da.modelNum', '')
description = info.get('x.com.samsung.da.description', '')
if reg is not None:
self._log.debug("device type: %s (modelNum=%r)", reg.name, model_num)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities,
log=unbound.append, tier_log=_tier_log)
self.device_type_name = reg.name
# Partitioned discovery (issue #177): the main pass binds every href
# owned by no sub-unit; one further pass per *candidate* sub-unit
# binds its own canonical view, resolving its own device type from
# its own /information/vs/0 when it reports one and falling back to
# the master's registry otherwise. See subunits.discover_partitioned
# -- it also gates each candidate down to whether it actually
# produced live primary state (HJcom's /device/2, an unused
# SmartThings slot, answers its seed but never does), so
# self.sub_units below is narrowed to the ones that passed, not
# every candidate _enumerate_sub_units_blocking found. For a device
# with no candidates (self.sub_units == []) this is exactly the
# single discover() call this method used to make.
bound, device_type_name, materialized, skipped = discover_partitioned(
resources, self.sub_units, resolve_registry, CAPABILITIES,
log=unbound.append, tier_log=_tier_log,
)
self.sub_units = materialized
self._skipped_sub_units = skipped
for skip in skipped:
self._log.info(
"sub-unit %s (%s) answered its seed but produced no live "
"primary state; not materialized (hrefs=%s)",
skip.sub_unit.key, skip.sub_unit.kind, list(skip.hrefs),
)
# Corroborating signal, not a gate (DESIGN-177.md section 4):
# /multidevice/vs/0's numofsubdevice is a plain count HJcom's board
# reports independently of the liveness gate above. Log, don't
# raise, on a disagreement -- only this one board family is known to
# expose the resource at all, so a mismatch is a "look into this"
# signal for triage, not proof either side is wrong.
numofsubdevice = self._multidevice.get(
'x.com.samsung.da.numofsubdevice')
if numofsubdevice is not None:
try:
reported = int(numofsubdevice)
except (TypeError, ValueError):
reported = None
unit_count = len(materialized) + 1 # +1 for the master itself
if reported is not None and reported != unit_count:
self._log.debug(
"/multidevice/vs/0 reports numofsubdevice=%r but %d "
"unit(s) materialized (including the master)",
numofsubdevice, unit_count,
)
if device_type_name is not None:
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
else:
# Both fields: detection reads each of them (board token, then
# consumer-model code), and this line is what a user pastes into
@@ -385,8 +618,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"unknown device type modelNum=%r description=%r; using common caps",
model_num, description,
)
bound = discover(resources, CAPABILITIES, log=unbound.append, tier_log=_tier_log)
self.device_type_name = None
self.device_type_name = device_type_name
self.bound = bound
self._unbound_hrefs = unbound
@@ -396,7 +628,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.device_serial = serial
ident = self._identity
device_type = reg.name.replace('_', ' ').title() if reg else 'Appliance'
device_type = device_type_name.replace('_', ' ').title() if device_type_name else 'Appliance'
model = model_num.split('|', 1)[0] if model_num else (ident.model if ident else '')
name = f"Samsung {device_type} ({model})" if model else f"Samsung {device_type}"
mfr = (ident.manufacturer if ident else '') or 'Samsung'
@@ -407,15 +639,16 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
manufacturer=mfr,
model=model,
)
self._update_coverage_gap_issue(reg is None, unbound, name)
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
self._hot_hrefs = sorted(hot)
self._warm_hrefs = sorted(warm)
self._discovered = True
self._log.info(
"discovered %d entities (serial=%s) hot=%s warm=%s",
"discovered %d entities (serial=%s) hot=%s warm=%s sub_units=%s",
len(bound), serial, self._hot_hrefs, self._warm_hrefs,
[su.key for su in self.sub_units],
)
def _update_coverage_gap_issue(
@@ -568,7 +801,37 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._observe.downgrade_to_poll()
just_downgraded_from_observe = True
if not self._discovered:
# One-time (issue #177): find out whether this connection has
# sibling indoor units before the first discovery pass, and fold
# their seed resources into this cycle's snapshot so discovery
# sees every unit's state on the very first poll rather than
# waiting a cycle. Runs under its own session-lock scope (the
# poll above already released the lock) since it shares the same
# DTLS session.
async with self._session_lock:
resources = await self.hass.async_add_executor_job(
self._enumerate_sub_units_blocking, resources
)
source = 'sweep' if self._discovered else 'poll'
first_cycle = not self._discovered
if first_cycle:
# Discovery runs *before* the apply loop below, not after it, so
# a rejected candidate's resources never reach the state cache
# at all (issue #177). Enumeration has to fetch every candidate's
# seed to evaluate the liveness gate, but only the units that
# pass it are ever polled again -- applying the rest would freeze
# ~14 hrefs per rejected slot into the cache on this one cycle
# and leave them there forever, indistinguishable from live
# state in `last_resources` and in the diagnostics dump built
# from it. StateCache has no eviction, so the only way to keep
# them out is to not put them in. Safe to reorder: _run_discovery
# reads the dict passed to it and never the cache, and
# log_sweep_discrepancies below can't fire on a first cycle
# (observe mode is only ever attempted after discovery).
self._run_discovery(resources)
resources = self._live_unit_resources(resources)
sweep_mismatch = False
if self._observe.mode == MODE_OBSERVE:
# A sweep/cache mismatch never tears down a still-live OBSERVE
@@ -584,8 +847,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
for href, rep in resources.items():
self._observe.apply(href, rep, source=source)
if not self._discovered:
self._run_discovery(resources)
if first_cycle:
await self._attempt_observe_mode()
elif just_downgraded_from_observe:
await self._attempt_observe_mode()
@@ -671,7 +933,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# state until the next real read of that resource -- the 20-60s lag
# in issues #17/#53, which survived the earlier optimistic-apply fix
# (issue #27) because that fix applied to the wrong href too.
write_href = '/' + '/'.join(path_segs)
#
# write_fn's path_segs are canonical (issue #177) -- a sub-unit's
# ClimateDesc is bound to its own *actual* /mode/vs/1 (or
# /<id>/mode/vs/0) href, but _climate_write only knows the canonical
# sibling hrefs (e.g. ['power', 'vs', '0']). Translate through this
# bound entity's own sub_unit so the optimistic apply, the settle
# guard and the POST below all target that unit's real resource --
# to_actual is the identity transform for MAIN, so a device with no
# sub-units writes exactly where it always did.
write_href = bound_entity.sub_unit.to_actual('/' + '/'.join(path_segs))
path_segs = [s for s in write_href.strip('/').split('/') if s]
# Apply the write optimistically before starting the settle guard,
# not after -- mark_write_pending gates every source (poll, sweep,
+89 -1
View File
@@ -18,6 +18,7 @@ from homeassistant.loader import async_get_integration
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.redact import redact_resources
from .registry.subunits import MAIN
async def async_get_config_entry_diagnostics(
@@ -39,6 +40,33 @@ async def async_get_config_entry_diagnostics(
# a single physical unit exposes more than one logical Device. See
# registry/identity.py.
identity = coordinator._identity
def _sub_unit_diag(su) -> dict:
# One pass over coordinator.bound for both fields below (count and
# the distinct hrefs), and one redaction of this unit's canonical
# view -- `model` reads modelNum off the already-redacted `resources`
# rather than redacting /information/vs/0 a second time. modelNum
# itself never matches redact.py's substring rules, so which side of
# redact_resources it's read from doesn't change the value.
matching = [b for b in coordinator.bound if b.sub_unit == su]
res = redact_resources(coordinator.canonical_resources(su))
return {
"kind": su.kind,
"key": su.key,
"seed_path": "/" + "/".join(su.seed_path),
"bound_entity_count": len(matching),
"hrefs": sorted({b.href for b in matching}),
"model": res.get('/information/vs/0', {}).get('x.com.samsung.da.modelNum', ''),
# Keyed by this unit's *canonical* hrefs, not the real ones
# it answers on -- '/mode/vs/0' rather than '/mode/vs/1' or
# '/<uuid>/mode/vs/0'. That's the form the registry and every
# capability are written against, so a sibling's block can be
# read (or pasted into the skill's standalone-discovery
# recipe) exactly like the master's `resources` above,
# instead of having to be de-indexed by hand first.
"resources": res,
}
return {
"device_type": coordinator.device_type_name or "unknown",
"one_ui_version": coordinator.one_ui_version,
@@ -49,7 +77,67 @@ async def async_get_config_entry_diagnostics(
"resources": redact_resources(identity.raw),
} if identity is not None else None,
"unbound_hrefs": sorted(coordinator._unbound_hrefs),
"resources": redact_resources(coordinator.last_resources),
# This unit's own resources, and only this unit's -- what the
# module docstring and the adding-device-support skill have always
# described it as ("the parsed /device/0 snapshot"). On a composite
# device (issue #177) `last_resources` is the union across every
# live unit keyed by real hrefs, so reporting it raw here would mix
# a sibling's /mode/vs/1 in with the master's /mode/vs/0 under no
# attribution at all. Each sibling reports its own resources in its
# own `sub_units` entry below instead. For a device with no sub-units
# -- almost every device -- this is byte-identical to `last_resources`.
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
# Sibling indoor units discovered on this connection (issue #177) --
# per-unit kind/key/seed path plus what actually bound to it, so a
# report shows whether a composite device's sub-unit was found at
# all and what it resolved to. subdeviceIdList (the UUID a prefixed
# unit's key comes from) is deliberately NOT redacted here even
# though the field matches redact.py's 'deviceid' substring rule
# elsewhere in `resources` above -- it's an appliance-internal
# pairing id, not account data, and reporting the key is what makes
# this block actionable.
"sub_units": [_sub_unit_diag(su) for su in coordinator.sub_units],
# Candidates that answered their seed but that discover_partitioned's
# entity-level liveness gate rejected -- an unused SmartThings slot
# (HJcom's /device/2) that still answers a same-shaped batch, not a
# real second unit. Reported alongside sub_units above so a report
# shows what was found *and* why it didn't become an entity, not
# just silence where a third climate card might otherwise be
# expected.
"sub_units_skipped": [
{
"kind": skip.sub_unit.kind,
"key": skip.sub_unit.key,
"seed_path": "/" + "/".join(skip.sub_unit.seed_path),
"hrefs": list(skip.hrefs),
# The reps the liveness gate actually judged, canonicalized
# like the materialized units above. These are the one thing
# a reader needs to second-guess a skip ("is my second unit
# really absent, or did the gate get it wrong?"), and they
# exist nowhere else in this dump: a rejected candidate is
# never polled again and never enters the state cache, so
# `resources` above cannot contain them by construction.
"resources": redact_resources({
canon: rep
for href, rep in coordinator._skipped_sub_unit_resources.items()
if (canon := skip.sub_unit.to_canonical(href)) is not None
}),
}
for skip in coordinator._skipped_sub_units
],
# What each enumeration probe returned ({} vs a batch), keyed by the
# seed href attempted -- lets a report distinguish "checked, nothing
# there" from "never checked", the same posture the speculative
# /device/1 //device/2 probe this replaced used to document directly
# in identity.py before it moved to registry/subunits.py.
"sub_unit_probes": dict(sorted(coordinator._sub_unit_probes.items())),
# /multidevice/vs/0's rep ({} when the board doesn't answer it).
# Reported on its own rather than inside `resources` because it is
# metadata about the connection rather than state of any one unit --
# and because nothing polls it after discovery, so it would go stale
# in there. Its numofsubdevice count is what independently
# corroborates the sub_units/sub_units_skipped split above.
"multidevice": redact_resources(coordinator._multidevice),
"integration_version": integration.version,
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
+22 -3
View File
@@ -33,12 +33,19 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
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.last_resources)
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
@@ -122,9 +129,21 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
"""
tk = self._bound.desc.translation_key
if callable(tk):
return tk(self.coordinator.last_resources)
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
return self.coordinator.device_info_for(self._bound.sub_unit)
+3 -3
View File
@@ -134,7 +134,7 @@ class LocalThingsRangeHoodFan(LocalThingsEntity, FanEntity):
def _power_payload(self, enabled: bool) -> tuple[str, bool, str]:
"""Target whichever power resource this hood actually exposes."""
resources = self.coordinator.last_resources
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
@@ -237,7 +237,7 @@ class LocalThingsAirPurifierFan(LocalThingsEntity, FanEntity):
Writing a hardcoded href here would silently no-op on a board that
only reports the other one, even though is_on already falls back
correctly."""
resources = self.coordinator.last_resources
resources = self._resources
target = POWER_VS_HREF if POWER_VS_HREF in resources else POWER_HREF
return 'power', enabled, target
@@ -344,7 +344,7 @@ class LocalThingsAirflowFan(LocalThingsEntity, FanEntity):
resources -- disagreeing until the next poll refreshes the other
one (the same optimistic-apply lag coordinator.py's own comments
warn about)."""
resources = self.coordinator.last_resources
resources = self._resources
target = POWER_HREF if POWER_HREF in resources else POWER_VS_HREF
return 'power', enabled, target
@@ -4,19 +4,44 @@ from __future__ import annotations
from typing import Any
from .discovery import BoundEntity
from .subunits import SubUnit, canonical_view
def _key(b: BoundEntity) -> str:
return f"{b.key_override or b.desc.key}{b.instance}"
# b.sub_unit.key_prefix is '' for MAIN, so a device with no sub-units
# (every device this integration shipped before issue #177) gets a
# byte-identical key to before -- a hard regression guard, not a nicety
# (see test_unique_ids.py and every golden file under tests/fixtures/golden/).
return f"{b.sub_unit.key_prefix}{b.key_override or b.desc.key}{b.instance}"
def flatten(bound: list[BoundEntity], resources: dict) -> dict[str, Any]:
"""Map bound entities to their current scalar values."""
"""Map bound entities to their current scalar values.
`exists_fn(rep, resources)` receives that entity's own sub-unit's
*canonical* resources view (see subunits.canonical_view), not the raw
actual-href snapshot -- an exists_fn that scans the whole resources dict
for a sibling href (e.g. is_legacy_board) must judge each unit on its
own resources, not see another unit's hrefs bleed in under the same
canonical key. Views are built once per distinct sub-unit per call, not
once per entity -- O(sub-units), not O(bound entities).
"""
out: dict[str, Any] = {}
# SubUnit is a frozen dataclass (hashable, equal by value), so it can key
# `views` directly -- no need to re-derive an identity for it out of
# (kind, key) first.
all_units = list(dict.fromkeys(b.sub_unit for b in bound))
views: dict[SubUnit, dict] = {}
for b in bound:
rep = resources.get(b.href) or {}
if b.desc.exists_fn is not None and not b.desc.exists_fn(rep, resources):
continue
if b.desc.exists_fn is not None:
view = views.get(b.sub_unit)
if view is None:
view = canonical_view(b.sub_unit, resources, all_units)
views[b.sub_unit] = view
if not b.desc.exists_fn(rep, view):
continue
if b.desc.rep_fn is not None:
out[_key(b)] = b.desc.rep_fn(rep)
elif b.desc.field:
@@ -907,6 +907,15 @@ _AC_IGNORED = [
# Undocumented single int (runningMode: 0 on every dump seen), no
# supported-values list to interpret it against -- 'don't guess'.
'/runn/vs/0',
# 2-in-1/multi-indoor-unit systems (issue #177, HJcom's
# ARTIK051_DONGLE_FAC_18K): x.com.samsung.da.numofsubdevice, a plain
# corroborating count of indoor units on this connection. Confirmed
# read-only (a write attempt returned CoAP 4.00). Absent from
# /device/0's batch entirely -- registry.subunits.enumerate_sub_units
# fetches it with its own RETRIEVE and folds it into the resources dict
# for diagnostics, which is why it needs an entry here rather than
# surfacing as an unbound-href gap on every board that has it.
'/multidevice/vs/0',
]
# Built as bare no-entity caps; folded into the AC registry (not global).
@@ -18,6 +18,7 @@ from typing import Callable, Iterable, Optional
from .capability import Capability
from .entities import SamsungEntityDescription
from .subunits import MAIN, SubUnit
@dataclass
@@ -28,6 +29,11 @@ class BoundEntity:
instance: str = ''
key_override: Optional[str] = None
instance_name: Optional[str] = None
# Which logical indoor unit (issue #177) this entity belongs to. `href`
# above is always the *actual*, on-the-wire href for that unit -- MAIN's
# to_actual is the identity transform, so every device with no sub-units
# behaves exactly as before this field existed.
sub_unit: SubUnit = MAIN
def _snake_to_title(s: str) -> str:
@@ -57,13 +63,20 @@ def instance_suffix(href: str) -> str:
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
key_prefix: Optional[str] = None) -> list[BoundEntity]:
key_prefix: Optional[str] = None,
sub_unit: SubUnit = MAIN) -> list[BoundEntity]:
"""Build one BoundEntity per entity on `cap`, sharing the instance/
key-prefix/instance-name computed once by the caller."""
key-prefix/instance-name computed once by the caller.
`href` here is the *canonical* href discover() is iterating over;
`sub_unit.to_actual` maps it to the real, on-the-wire href the entity
actually reads/writes (identity for MAIN, so single-unit devices are
unaffected -- see subunits.py)."""
return [
BoundEntity(href=href, capability=cap, desc=desc, instance=inst,
BoundEntity(href=sub_unit.to_actual(href), capability=cap, desc=desc,
instance=inst,
key_override=f'{key_prefix}_{desc.key}' if key_prefix else None,
instance_name=inst_name)
instance_name=inst_name, sub_unit=sub_unit)
for desc in cap.entities
]
@@ -74,6 +87,7 @@ def discover(
pattern_caps: Iterable[Capability] = (),
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
sub_unit: SubUnit = MAIN,
) -> list[BoundEntity]:
"""`tier_log(href, poll_tier)` fires for every href a capability actually
matches, even a no-entity "coverage-only" capability (see COVERAGE lists
@@ -81,7 +95,18 @@ def discover(
Callers that need a href's poll cadence (the coordinator's hot/warm
sub-poll and OBSERVE-attempt lists) must use this, not `bound` -- a
coverage-only capability's `poll_tier` would otherwise be silently
dropped since it never appears in `bound`."""
dropped since it never appears in `bound`.
`resources` is always keyed by *canonical* hrefs -- for a sub-unit
(issue #177) that means its own canonical view (see
subunits.canonical_view), the same shape as a plain single-unit device's
resources dict, so registry lookups/rt_filter/match_fn/instance_suffix
all behave identically regardless of which unit is being discovered.
`sub_unit` only affects the *href* stamped onto each BoundEntity (via
`_bind`, see above) and the href `log`/`tier_log` report -- both the
real, subscribable/pollable path, not the canonical one the registry is
keyed on.
"""
out: list[BoundEntity] = []
for href, rep in resources.items():
@@ -97,10 +122,10 @@ def discover(
if cap.match_fn is not None and not cap.match_fn(rep, resources):
continue
inst = instance_suffix(href)
out.extend(_bind(cap, href, inst, _instance_name(cap, rep)))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), sub_unit=sub_unit))
matched = True
if tier_log is not None:
tier_log(href, cap.poll_tier)
tier_log(sub_unit.to_actual(href), cap.poll_tier)
if matched:
continue
@@ -117,13 +142,14 @@ def discover(
# Auto-derive key prefix from href segments (skip digits and 'vs')
src = href[len(cap.href_prefix):] if (cap.strip_prefix_in_key and cap.href_prefix) else href
segs = [s for s in src.strip('/').split('/') if s and not s.isdigit() and s != 'vs']
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs)))
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), '_'.join(segs),
sub_unit=sub_unit))
matched = True
if tier_log is not None:
tier_log(href, cap.poll_tier)
tier_log(sub_unit.to_actual(href), cap.poll_tier)
break
if not matched and not caps and log is not None:
log(href)
log(sub_unit.to_actual(href))
return out
@@ -6,20 +6,6 @@ from typing import Optional
import cbor2
from .batch import parse_device0_batch
# Speculative /device/<n> siblings to probe alongside the coordinator's own
# /device/0 seed poll (issue #177). Confirmed against a real dump: /oic/res's
# baseline-Interface response only lists resources with the discoverable
# policy bit set, and /device/0's whole x.com.samsung.da.* tree is registered
# without it -- so a second logical Device's Collection, if one exists, would
# be just as invisible to /oic/res as /device/0 is. A direct GET is the only
# way left to check, and it's a plain RETRIEVE (non-mutating, tolerated-404
# already the norm throughout this module) -- not the kind of guess the
# write-contract 'don't guess' rule is about. Bounded to a couple of indices;
# widen only if a real Composite Device ever turns out to need more.
_SPECULATIVE_DEVICE_INDICES = (1, 2)
@dataclass(frozen=True)
class DeviceIdentity:
@@ -56,24 +42,6 @@ def _get_links(sess, path) -> list:
return []
def _get_device_batch(sess, index: int) -> dict[str, dict]:
"""GET /device/<index> and parse it the same way the coordinator parses
/device/0 -- a Samsung Collection RETRIEVE returns
[devcol-rep, {href, rep}, {href, rep}, ...], not a bare Property map or
Link array. Missing/malformed responses fall through to {} (via
parse_device0_batch on an empty/non-list body), same tolerated-absence
posture as _get/_get_links above."""
try:
code, pl = sess.get(['device', str(index)], timeout=10.0)
if code == 0x45 and pl:
body = cbor2.loads(pl)
if isinstance(body, list):
return parse_device0_batch(body)
except Exception:
pass
return {}
def _device_types(d: dict) -> tuple[str, ...]:
"""/oic/d's `rt` -- the device's own OCF device-type declaration.
@@ -104,14 +72,13 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
# Relevant for the OCF "Composite Device" model (issue #177: a single
# physical unit -- one IP, one /oic/p -- exposing more than one logical
# Device, each as its own Collection resource, same rt shape as our own
# /device/0). Nothing consumes this yet; captured so a report from a
# multi-unit device shows us whether its firmware actually implements
# that model before any code assumes it does.
# /device/0). This is what registry.subunits.enumerate_sub_units reads
# to find a board's `/device/<n>` siblings (Pattern A -- HJcom's
# ARTIK051_DONGLE_FAC_18K) -- that probing, plus the /device/1 and
# /device/2 speculative fallback it used to run right here on every
# _connect_session (including every reconnect), moved to that module so
# it only runs once, at first discovery, instead of on every reconnect.
res = _get_links(sess, ['oic', 'res'])
extra_devices = {
f'/device/{n}': _get_device_batch(sess, n)
for n in _SPECULATIVE_DEVICE_INDICES
}
return DeviceIdentity(
manufacturer=p.get('mnmn') or 'Samsung',
model=p.get('mnmo') or '',
@@ -121,8 +88,5 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
# Kept whole rather than field-by-field: these resources are outside
# the /device/0 dump diagnostics already captures, and we don't yet
# know which of their fields will turn out to identify a device type.
# /device/1 and /device/2 are always present here (empty {} when the
# device didn't answer) so a diagnostics reader can tell "checked,
# nothing there" apart from "never checked".
raw={'/oic/p': p, '/oic/d': d, '/oic/res': res, **extra_devices},
raw={'/oic/p': p, '/oic/d': d, '/oic/res': res},
)
@@ -0,0 +1,492 @@
"""Sub-unit ("composite device") support for one physical connection exposing
more than one logical indoor unit -- issue #177.
Two reporters, two different board families, two genuinely different
mechanisms for exposing a second indoor unit over one IP / one DTLS session
(see DESIGN-177.md section 1 for the full evidence trail; the two diagnostics
dumps this was built against are HJcom's and jhkwon19's -- they each filed
one of the two reports this module unifies):
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, HJcom's board).
`/oic/res` lists three complete parallel resource sets whose trailing path
segment is the index (`/mode/vs/0`, `/mode/vs/1`, `/mode/vs/2`, ... on both
OCF-standard and vendor hrefs), and `/device/0`'s batch carries only the
index-0 hrefs -- the sibling units are reachable only via their own
`/device/<n>` collection.
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, jhkwon19's board).
`/oic/res` hides the whole appliance tree; `/device/0`'s batch instead
carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that
same UUID appears as a literal href prefix in `/oic/res`
(`/<uuid>/file/list/vs/0`, ...). `GET /<uuid>/device/0` returns the second
unit's own Collection batch, confirmed live by the reporter to carry a
different model/serial than the master (`TP2X_FAC_BORA_RAC_21K`, the
wall-mounted unit, vs. the master's `TP2X_FAC_BORA_21K`, the floor unit).
Both are "the same thing wearing different clothes": a logical unit is a
seed collection path to poll, plus an href transform between the canonical
href the registry knows (`/mode/vs/0`) and the actual on-the-wire href. The
detection signals don't overlap on either captured board (HJcom's has no
`/subdevices/vs/0` at all; jhkwon19's has no `/device/1`), so no
disambiguation logic is needed -- `enumerate_sub_units` checks both and
materializes any candidate whose seed answers with a non-empty batch.
A non-empty seed batch is necessary but not sufficient for the *candidate*
to actually be a live second unit, though: HJcom's own board also has a
`/device/2` -- a third, unused SmartThings slot -- that answers with the
exact same 14-href shape as the real `/device/1` sibling, populated with
three constant/echoed/shape-only reps (a region code identical to every
other unit's, an /information rep echoing the *same* model string as unit
1, and a /temperatures items[] entry with an id/description but no
current/desired/minimum/maximum reading) and nothing resembling live
climate state. Gating on *resource* shape/hrefs turned out to be the wrong
layer -- it would need per-family domain knowledge (which hrefs mean "in
use" for a washer's second drum, a fridge's second compartment, ...) baked
into a registry field before any of those families could use this module
at all. `discover_partitioned` instead gates at the *entity* layer, after
discovery+flattening: a candidate is only kept if it produced at least one
*primary* (no `entity_category`) bound entity whose flattened value isn't
`None` -- e.g. HJcom's /device/2 does flatten to an `alarm_code` value, but
that entity is diagnostic-category and derived from an empty /alarms/vs/2,
so it doesn't count. This reuses the same primary/config/diagnostic
taxonomy every registry already declares (see the adding-device-support
skill's entity-taxonomy section) instead of adding a second, parallel
domain-knowledge mechanism.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Callable, Optional
import cbor2
from .batch import parse_device0_batch
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
# second logical Device's Collection on this board (moved here from
# identity.py, issue #177 -- see enumerate_sub_units' docstring for why: the
# old read_identity fired these two extra RETRIEVEs on *every* _connect_session,
# including every reconnect, for information enumeration only needs once).
# Same bound as before: a plain, tolerated-404 RETRIEVE, not the kind of
# guess the write-contract 'don't guess' rule is about. Widen only if a real
# board ever turns out to need more than two siblings.
_SPECULATIVE_DEVICE_INDICES = (1, 2)
@dataclass(frozen=True)
class SubUnit:
"""One logical indoor unit reachable over a single physical connection.
`kind='main'` is the unit this config entry actually connects to and
always exists (see MAIN below) -- its `to_actual`/`to_canonical` are the
identity transform, so every existing single-unit device keeps behaving
exactly as it did before this module existed. `'indexed'`/`'prefixed'`
are Pattern A/B above; `key` is the trailing index string ('1', '2', ...)
or the full subdevice UUID, and `seed_path` is the Collection href (as
path segments) whose batch response enumerates/refreshes that unit.
"""
kind: str # 'main' | 'indexed' | 'prefixed'
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
seed_path: tuple[str, ...]
def to_actual(self, canonical: str) -> str:
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
on-the-wire href for this unit."""
if self.kind == 'indexed':
head, sep, tail = canonical.rpartition('/')
# Only the index-0 trailing segment is ours to rewrite --
# deliberately not a "replace any trailing digit" rule, which
# would misread a genuine multi-instance resource (the fridge's
# pattern-cap hrefs, e.g. '/door/vs/1') as a sub-unit's. No
# registry declares a non-zero trailing index today and no
# fixture in the corpus contains one (verified across the whole
# corpus), so the strict rule costs nothing.
if tail == '0':
return f'{head}{sep}{self.key}'
return canonical
if self.kind == 'prefixed':
return f'/{self.key}{canonical}'
return canonical
def to_canonical(self, actual: str) -> Optional[str]:
"""Inverse of to_actual, or None when `actual` isn't this unit's."""
if self.kind == 'indexed':
head, sep, tail = actual.rpartition('/')
if tail == self.key:
return f'{head}{sep}0'
return None
if self.kind == 'prefixed':
prefix = f'/{self.key}'
if actual.startswith(prefix + '/'):
return actual[len(prefix):]
return None
return actual
def owns(self, actual: str) -> bool:
"""True if `actual` belongs to this unit's namespace. MAIN never
"owns" anything by this definition -- it gets whatever's left after
every other sub-unit's hrefs are excluded (see canonical_view)."""
if self.kind == 'main':
return False
return self.to_canonical(actual) is not None
@property
def key_prefix(self) -> str:
"""Prefix that guarantees a unique entity key/unique_id (see
adapter._key). '' for MAIN -- the master unit's flattened state
keys must stay byte-identical to every device this integration
shipped before issue #177, so no golden file changes. The full
subdevice UUID is used verbatim (non-alphanumerics stripped, not
truncated or replaced with an ordinal) because it's device-reported
and stable across reconnects/restarts, unlike an ordinal assigned by
enumeration order -- and it never appears in a user-visible string
(see DESIGN-177.md section 6): HA derives the visible entity_id from
the device name + entity name, not from unique_id.
"""
if self.kind == 'indexed':
return f'unit{self.key}_'
if self.kind == 'prefixed':
slug = re.sub(r'[^a-zA-Z0-9]', '', self.key)
return f'sub_{slug}_'
return ''
MAIN = SubUnit(kind='main', key='', seed_path=('device', '0'))
def canonical_view(
sub_unit: SubUnit, resources: dict[str, dict], sub_units: list['SubUnit'],
) -> dict[str, dict]:
"""Rewrite `resources` (real, on-the-wire hrefs) into `sub_unit`'s own
canonical namespace -- what discover()/exists_fn/rep_fn/is_legacy_board
and friends are written against.
For MAIN this is the snapshot *minus* every href owned by one of the
other units in `sub_units` -- otherwise a sibling's own `/mode/vs/1`
would leak into the master's view under the same canonical key
('/mode/vs/0') that the master's actual `/mode/vs/0` also maps to,
silently mixing two units' state together. For an indexed/prefixed unit
it's the reverse: only the hrefs that unit owns, rewritten back through
`to_canonical`.
`sub_units` may or may not include MAIN itself -- MAIN.owns() is always
False, so including it is harmless.
"""
if sub_unit.kind == 'main':
owned_elsewhere = {
href for href in resources
if any(su.owns(href) for su in sub_units)
}
return {h: r for h, r in resources.items() if h not in owned_elsewhere}
return {
canon: resources[actual]
for actual in resources
if (canon := sub_unit.to_canonical(actual)) is not None
}
def normalize_seed_batch(sub_unit: SubUnit, batch: dict[str, dict]) -> dict[str, dict]:
"""Real, on-the-wire hrefs from one sub-unit's seed-collection batch,
normalized so every href actually carries this unit's prefix/index.
Indexed units need no change -- the device echoes the real `/x/<n>`
href in its own `/device/<n>` batch (confirmed against HJcom's dump).
A prefixed unit's batch entries may or may not already carry the
`/<id>` prefix (unconfirmed which -- jhkwon19's board was never probed
live before the sub-unit id was known), so it's added when missing.
"""
if sub_unit.kind != 'prefixed':
return batch
prefix = f'/{sub_unit.key}'
return {
(href if href.startswith(prefix + '/') else f'{prefix}{href}'): rep
for href, rep in batch.items()
}
def _iter_oic_res_hrefs(oic_res):
"""Flatten /oic/res's raw shape into a flat iterable of link dicts.
Both captured dumps group links by `di` (`[{'di': ..., 'links': [...]}]`
-- see identity.py's read_identity/_get_links), so that's the shape
handled here. Tolerant of a flat link-list too (nothing in the OCF spec
rules it out, and _get_links' own posture already treats any list-shaped
body as possible) and of anything else by yielding nothing.
"""
for entry in (oic_res or []):
if not isinstance(entry, dict):
continue
if 'links' in entry:
for link in entry.get('links') or []:
if isinstance(link, dict):
yield link
elif 'href' in entry:
yield entry
def _seed_href(path_segs: tuple[str, ...]) -> str:
"""('device', '1') -> '/device/1' -- the leading-slash href form
`probe_log` and diagnostics report, built from the path-segment form
`sess.get` takes."""
return '/' + '/'.join(path_segs)
def _get_raw(sess, path_segs: tuple[str, ...]):
"""GET `path_segs` and CBOR-decode the payload, or None on any
missing/malformed response (a 4.04, a timeout, an empty payload) --
shared tolerated-absence posture for both callers below, which differ
only in which body shape they accept."""
try:
code, pl = sess.get(list(path_segs), timeout=10.0)
if code == 0x45 and pl:
return cbor2.loads(pl)
except Exception:
pass
return None
def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
"""GET a Samsung Collection resource and parse it the same way
/device/0 itself is parsed (parse_device0_batch): a [devcol-rep,
{href, rep}, ...] CBOR list, not a bare Property map."""
body = _get_raw(sess, path_segs)
return parse_device0_batch(body) if isinstance(body, list) else {}
def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
"""GET a plain OCF Property-map resource (a bare dict, not a Collection
batch). Used for `/multidevice/vs/0` (issue #177 follow-up): listed in
`/oic/res` on HJcom's board but absent from `/device/0`'s batch, so it
needs its own RETRIEVE, and it answers a single Property map, not a
[devcol-rep, ...] list."""
body = _get_raw(sess, path_segs)
return body if isinstance(body, dict) else {}
def enumerate_sub_units(
sess,
resources: dict[str, dict],
oic_res_links,
probe_log: Optional[Callable[[str, bool], None]] = None,
) -> tuple[list['SubUnit'], dict[str, dict]]:
"""Discover every sibling indoor unit reachable over `sess`'s connection.
Runs once, at first discovery, in an executor, under the coordinator's
session lock -- every GET here is a plain RETRIEVE (the write-contract
'don't guess' rule doesn't apply to reading an extra resource to find
out whether it's there). Returns the *candidate* units and the resources
already fetched while probing them (already normalized to real hrefs),
so the coordinator's first discovery poll doesn't need to re-poll them.
`probe_log(seed_href, found)` fires for every seed attempted, whether or
not it answered -- so diagnostics (see diagnostics.py's sub_unit_probes)
can tell "checked, nothing there" apart from "never checked", the same
posture the speculative-probe code this replaces used to document in
identity.py.
Every candidate whose seed answers with a non-empty batch is returned
here -- this function has no way to tell a real sibling from an unused
SmartThings slot that merely answers the same shape (HJcom's
`/device/2`); that requires discovering+flattening the candidate's own
entities first, which is `discover_partitioned`'s job, not this one's.
See this module's docstring.
"""
units: list[SubUnit] = []
fetched: dict[str, dict] = {}
def _probed(seed_href: str, batch: dict) -> None:
if probe_log is not None:
probe_log(seed_href, bool(batch))
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
'x.com.samsung.da.subdeviceIdList')
# Tolerate anything but a list of strings -- this field is redaction-prone
# (it matches the 'deviceid' substring rule in redact.py) and the existing
# airconditioner_fac_bora fixture carries the literal string
# '**REDACTED**'/'REDACTED' there. That must yield zero sub-units, not a
# crash -- issue #177 is additive, it must never break an already-working
# single-climate-entity device.
ids = raw_ids if isinstance(raw_ids, list) else []
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
seed = (sub_id, 'device', '0')
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
if not batch:
continue
unit = SubUnit(kind='prefixed', key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(unit, batch))
units.append(unit)
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted({
int(m.group(1))
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_INDEXED_HREF_RE.match(link.get('href', ''))]
if m and int(m.group(1)) >= 1
})
if not indices:
# A board that hides its whole tree from /oic/res (Pattern B's
# jhkwon19 board does this too, but it has no /device/<n> to find
# regardless) gives us nothing to enumerate from -- fall back to the
# bounded speculative probe this replaces from identity.py.
indices = list(_SPECULATIVE_DEVICE_INDICES)
for n in indices:
seed = ('device', str(n))
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
if not batch:
continue
unit = SubUnit(kind='indexed', key=str(n), seed_path=seed)
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
units.append(unit)
# /multidevice/vs/0 (issue #177 follow-up): HJcom's board lists it in
# /oic/res but it never appears in /device/0's batch, so it needs its
# own RETRIEVE. It's a plain corroborating count
# (x.com.samsung.da.numofsubdevice), confirmed read-only (a write
# attempt returned CoAP 4.00) -- captured for diagnostics only, folded
# into the merged resources dict like any other href (see
# airconditioner._AC_IGNORED, which is what keeps it from surfacing as
# an unbound-href gap). NOT a gate: discover_partitioned's entity-level
# liveness check decides materialization correctly without it, and only
# this one board family is known to expose it at all. Whether it agrees
# with the number of units actually materialized is the coordinator's
# call to log (it owns the logger; this module doesn't), not this
# function's.
multidevice_seed = ('multidevice', 'vs', '0')
multidevice = _get_property(sess, multidevice_seed)
_probed(_seed_href(multidevice_seed), multidevice)
if multidevice:
fetched['/multidevice/vs/0'] = multidevice
return units, fetched
@dataclass(frozen=True)
class SkippedSubUnit:
"""A candidate `enumerate_sub_units` found whose seed answered, but that
`discover_partitioned`'s entity-level liveness gate rejected -- an
unused SmartThings slot (HJcom's `/device/2`), not a real second unit.
Kept around (rather than silently dropped) so a caller can log/report
what was skipped and why."""
sub_unit: SubUnit
hrefs: tuple[str, ...]
def _has_live_primary_entity(bound, state: dict) -> bool:
"""True if flattening `bound` (one candidate sub-unit's BoundEntity
list) produced at least one non-`None` value for a *primary* entity --
`entity_category` unset, HA's own "the user acts on or watches this"
tier (see the adding-device-support skill's entity-taxonomy section).
This is the materialization gate itself (see this module's docstring):
HJcom's `/device/2` does flatten to one non-`None` value
(`alarm_code`), but that entity is `diagnostic`-category and derived
from an empty `/alarms/vs/2` -- a config/diagnostic entity reading
"something" proves nothing about whether a physical unit is actually
installed there, so it's deliberately excluded from this check.
"""
from .adapter import _key # see discover_partitioned's deferred-import note
return any(
not b.desc.entity_category and state.get(_key(b)) is not None
for b in bound
)
def discover_partitioned(
resources: dict[str, dict],
sub_units: list['SubUnit'],
resolve_registry: Callable[[dict], object],
fallback_capabilities: dict,
log: Optional[Callable[[str], None]] = None,
tier_log: Optional[Callable[[str, str], None]] = None,
):
"""Bind every href in `resources` (the merged, real-href snapshot -- main
plus every enumerated sub-unit's seed) to entities, partitioned by which
unit owns it.
Main pass runs over hrefs owned by no sub-unit -- otherwise every
`/mode/vs/1` would land in `unbound_hrefs` too (nothing in the main
device's registry claims that literal href) and raise a spurious
coverage-gap repair. Then one pass per *candidate* sub-unit over its own
canonical view, resolving that unit's own device type from its own
`/information/vs/0` when it reports one (e.g. jhkwon19's wall unit
reports `TP2X_FAC_BORA_RAC_21K` -> the 'RAC' board token ->
airconditioner), falling back to the master's registry otherwise --
every AC family shares the same resource surface, and a sibling that
fails to answer its own identity resource is still the same appliance
type as the unit this config entry was set up against.
Each candidate is discovered and flattened *twice*: once silently to
evaluate `_has_live_primary_entity` (this module's materialization
gate -- see its docstring and this module's own), and, only if that
passes, a second time with `log`/`tier_log` wired so its coverage gaps
and poll tiers actually count. A candidate that fails the gate
contributes nothing at all -- no bound entities, no unbound-href
report, no hot/warm href -- as if it had never answered its seed.
Discovering an unused slot's small, fixed resource set twice at
first-discovery time only is a non-issue; getting a phantom sub-unit
silently counted into unbound_hrefs or hot/warm tiers is not.
Returns `(bound, device_type_name, materialized, skipped)`:
- `bound`: the concatenated BoundEntity list (main + every materialized
sub-unit).
- `device_type_name`: the *master's* resolved device type (used for
logging/device naming; each sub-unit's own resolved type only affects
which capabilities bind its hrefs, not this).
- `materialized`: the subset of `sub_units` that passed the gate, in the
same order -- what the caller should keep as its live sub-unit roster
going forward (poll seeds, canonical_resources, device_info_for, ...).
- `skipped`: `SkippedSubUnit` entries for every candidate that didn't.
"""
# Deferred import: discovery.py imports SubUnit/MAIN from this module at
# module scope, so importing discover() back here at module scope would
# be a circular import. By the time this function actually runs both
# modules are fully loaded. adapter.py imports discovery.py, so the same
# applies to flatten()/_key().
from .adapter import flatten
from .discovery import discover
# Same computation canonical_view does for MAIN (snapshot minus every
# other sub-unit's owned hrefs) -- reuse it rather than re-deriving
# owned_elsewhere here too.
main_view = canonical_view(MAIN, resources, sub_units)
reg = resolve_registry(main_view)
caps, pats = (
(reg.capabilities, reg.pattern_capabilities) if reg is not None
else (fallback_capabilities, [])
)
# MAIN is never gated -- the config entry's own physical connection
# always materializes regardless of what its entities' values are.
bound = discover(main_view, caps, pats, log=log, tier_log=tier_log, sub_unit=MAIN)
device_type_name = reg.name if reg is not None else None
materialized: list[SubUnit] = []
skipped: list[SkippedSubUnit] = []
for su in sub_units:
view = canonical_view(su, resources, sub_units)
su_reg = resolve_registry(view) or reg
su_caps, su_pats = (
(su_reg.capabilities, su_reg.pattern_capabilities) if su_reg is not None
else (fallback_capabilities, [])
)
probe_bound = discover(view, su_caps, su_pats, sub_unit=su)
probe_state = flatten(probe_bound, resources)
if _has_live_primary_entity(probe_bound, probe_state):
materialized.append(su)
bound = bound + discover(
view, su_caps, su_pats, log=log, tier_log=tier_log, sub_unit=su,
)
else:
skipped.append(SkippedSubUnit(
sub_unit=su,
hrefs=tuple(sorted({b.href for b in probe_bound})),
))
return bound, device_type_name, materialized, skipped
+3 -1
View File
@@ -104,7 +104,9 @@ class LocalThingsSelect(LocalThingsEntity, SelectEntity):
# list decoded from a sibling resource. There is no static
# fallback: when that resource isn't populated the callable
# returns [] and the entity's exists_fn suppresses it entirely.
return list(desc.options(self.coordinator.last_resources) or [])
# This entity's own sub-unit's canonical view (issue #177), not
# the raw actual-href snapshot -- see LocalThingsEntity._resources.
return list(desc.options(self._resources) or [])
if desc.options_field:
rep = self.coordinator.last_resources.get(self._bound.href) or {}
return list(rep.get(desc.options_field) or [])