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:
33 files changed
+5277
-125
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 [])
|
||||
|
||||
Reference in new issue
Block a user