rename: unit/sub-unit -> subdevice, matching OCF terminology
"Unit"/"sub-unit" from #199's multi-indoor-device support wasn't OCF idiomatic -- OCF calls each component of a composite device a "subdevice" (see subdeviceIdList), so rename SubUnit -> Subdevice throughout: the registry module, coordinator state, BoundEntity's subdevice field, diagnostics keys (subdevices/subdevices_skipped/ subdevice_probes), entity unique_id prefixes (unit1_/sub_<uuid>_ -> subdevice1_/subdevice_<uuid>_), device-name fallback labels, golden fixtures, tests, README, and the adding-device-support skill. Breaking change to entity unique_ids and diagnostics keys, acceptable since this hasn't been released yet.
This commit is contained in:
32 files changed
+676
-659
No files matched your search
@@ -278,10 +278,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
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
|
||||
coordinator.resource() directly -- on a subdevice (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
|
||||
entity's own subdevice first, exactly like every other sibling read
|
||||
below.
|
||||
"""
|
||||
if not is_legacy_board(self._resources):
|
||||
@@ -297,23 +297,23 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
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
|
||||
entity's own subdevice, 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.
|
||||
"""
|
||||
convenient_href = self._bound.sub_unit.to_actual(CONVENIENT_HREF)
|
||||
convenient_href = self._bound.subdevice.to_actual(CONVENIENT_HREF)
|
||||
return (not self.coordinator.resource(convenient_href)
|
||||
and bool(self._legacy_airflow()))
|
||||
|
||||
def _rep(self, href: str) -> dict:
|
||||
"""`href` is one of this module's canonical HREF_* constants --
|
||||
translated through this bound entity's own sub_unit (issue #177) to
|
||||
translated through this bound entity's own subdevice (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
|
||||
(identity for MAIN, so a device with no subdevices reads exactly the
|
||||
href it always did)."""
|
||||
rep = self.coordinator.resource(self._bound.sub_unit.to_actual(href)) or {}
|
||||
rep = self.coordinator.resource(self._bound.subdevice.to_actual(href)) or {}
|
||||
if not rep and href == CONVENIENT_HREF and self._legacy_airflow():
|
||||
return self._legacy_convenient()
|
||||
return rep
|
||||
|
||||
@@ -34,8 +34,8 @@ 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,
|
||||
from .registry.subdevices import (
|
||||
MAIN, Subdevice, canonical_view, discover_partitioned, enumerate_subdevices,
|
||||
normalize_seed_batch,
|
||||
)
|
||||
from .observe import ObserveManager, MODE_OBSERVE, MODE_POLL, GRACE_PERIOD_S
|
||||
@@ -165,35 +165,35 @@ 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
|
||||
# Sibling indoor subdevices discovered on this connection (issue
|
||||
# #177) -- candidates set once, at first discovery, by
|
||||
# _enumerate_subdevices_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] = []
|
||||
# subdevices.discover_partitioned). MAIN itself is never in this list
|
||||
# (see subdevices.canonical_view's docstring for why that's safe):
|
||||
# it's the *other* subdevices sharing this DTLS session, if any.
|
||||
self.subdevices: list[Subdevice] = []
|
||||
# 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 = []
|
||||
self._skipped_subdevices: list = []
|
||||
# Those rejected candidates' raw reps, kept aside for diagnostics
|
||||
# only (see _live_unit_resources). They are deliberately not in the
|
||||
# only (see _live_subdevice_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._skipped_subdevice_resources: dict[str, dict] = {}
|
||||
# /multidevice/vs/0's rep, if this board answers it -- a plain
|
||||
# subdevice count that corroborates the liveness gate without deciding it.
|
||||
# Deliberately outside `resources`; see _enumerate_subdevices_blocking.
|
||||
self._multidevice: dict = {}
|
||||
# What each sub-unit probe found, keyed by the seed href attempted --
|
||||
# What each subdevice 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).
|
||||
self._subdevice_probes: dict[str, bool] = {}
|
||||
# canonical_resources() memo, keyed by (subdevice.kind, subdevice.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.
|
||||
@@ -234,13 +234,13 @@ 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
|
||||
def canonical_resources(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||
"""`subdevice`'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
|
||||
against -- see subdevices.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
|
||||
`last_resources`, or a sibling subdevice'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.
|
||||
|
||||
@@ -249,31 +249,31 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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)
|
||||
view_key = (subdevice.kind, subdevice.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)
|
||||
view = canonical_view(subdevice, self._cache.snapshot(), self.subdevices)
|
||||
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.
|
||||
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
|
||||
"""DeviceInfo for one logical subdevice sharing this connection
|
||||
(issue #177) -- the master's own (unchanged) device_info for MAIN, or
|
||||
a linked child device for a discovered subdevice.
|
||||
|
||||
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
|
||||
this subdevice's stable key, never from whatever serial the
|
||||
subdevice itself reports (or fails to) -- deterministic across
|
||||
reconnects whether or not this subdevice'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':
|
||||
if subdevice.kind == 'main':
|
||||
return self.device_info
|
||||
info = self.canonical_resources(sub_unit).get('/information/vs/0', {})
|
||||
info = self.canonical_resources(subdevice).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
|
||||
@@ -281,15 +281,18 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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'
|
||||
# This poll never got (or never will get) the subdevice's own
|
||||
# identity resource -- fall back to a generic per-subdevice label
|
||||
# rather than leaving the device unnamed. 'Subdevice <n>' only
|
||||
# makes sense for an indexed subdevice (the key is a small
|
||||
# ordinal); jhkwon19-pattern (prefixed) subdevices are never more
|
||||
# than one per connection today, so there's no ordinal to show.
|
||||
label = (
|
||||
f'Subdevice {subdevice.key}' if subdevice.kind == 'indexed'
|
||||
else 'Secondary Subdevice'
|
||||
)
|
||||
return DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{self.device_serial}_{sub_unit.key}")},
|
||||
identifiers={(DOMAIN, f"{self.device_serial}_{subdevice.key}")},
|
||||
via_device=(DOMAIN, self.device_serial),
|
||||
name=f"{base_name} {label}",
|
||||
manufacturer=self.device_info.get('manufacturer') or 'Samsung',
|
||||
@@ -394,17 +397,17 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"poll cbor decode: {e}") from e
|
||||
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
|
||||
# Refresh every already-enumerated sibling subdevice's seed collection
|
||||
# on this same summary poll (issue #177) -- without this, a subdevice'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))
|
||||
for subdevice in self.subdevices:
|
||||
result.update(self._poll_subdevice_seed(subdevice))
|
||||
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,
|
||||
def _poll_subdevice_seed(self, subdevice: Subdevice) -> dict[str, dict]:
|
||||
"""GET one subdevice'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
|
||||
log, never a failed poll -- the master 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."""
|
||||
@@ -412,13 +415,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
if sess is None:
|
||||
return {}
|
||||
try:
|
||||
code, payload = sess.get(list(sub_unit.seed_path), timeout=10.0)
|
||||
code, payload = sess.get(list(subdevice.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))
|
||||
return normalize_seed_batch(subdevice, parse_device0_batch(body))
|
||||
except Exception as e:
|
||||
self._log.debug("sub-unit %s seed poll failed: %s", sub_unit.key, e)
|
||||
self._log.debug("subdevice %s seed poll failed: %s", subdevice.key, e)
|
||||
return {}
|
||||
|
||||
def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]:
|
||||
@@ -478,18 +481,18 @@ 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
|
||||
def _enumerate_subdevices_blocking(self, resources: dict[str, dict]) -> dict[str, dict]:
|
||||
"""One-time (first discovery only) probe for sibling indoor subdevices
|
||||
sharing this connection (issue #177) -- see
|
||||
registry.subunits.enumerate_sub_units for the two detection
|
||||
registry.subdevices.enumerate_subdevices 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`
|
||||
Sets self.subdevices to every *candidate* the probes turned up
|
||||
(self._subdevice_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
|
||||
round trip. `_run_discovery` is what narrows self.subdevices 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.
|
||||
@@ -501,12 +504,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
return resources
|
||||
oic_res = self._identity.raw.get('/oic/res', []) if self._identity else []
|
||||
probes: dict[str, bool] = {}
|
||||
sub_units, extra = enumerate_sub_units(
|
||||
subdevices, extra = enumerate_subdevices(
|
||||
sess, resources, oic_res,
|
||||
probe_log=lambda href, found: probes.__setitem__(href, found),
|
||||
)
|
||||
self.sub_units = sub_units
|
||||
self._sub_unit_probes = probes
|
||||
self.subdevices = subdevices
|
||||
self._subdevice_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
|
||||
@@ -516,31 +519,31 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# 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
|
||||
# reps (see _live_subdevice_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
|
||||
def _live_subdevice_resources(self, resources: dict[str, dict]) -> dict[str, dict]:
|
||||
"""`resources` minus every href belonging to a candidate subdevice 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,
|
||||
themselves are kept in _skipped_subdevice_resources for diagnostics,
|
||||
which is the only thing that still wants them.
|
||||
"""
|
||||
if not self._skipped_sub_units:
|
||||
if not self._skipped_subdevices:
|
||||
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
|
||||
skip.subdevice.owns(href) for skip in self._skipped_subdevices
|
||||
) else kept
|
||||
bucket[href] = rep
|
||||
self._skipped_sub_unit_resources = skipped
|
||||
self._skipped_subdevice_resources = skipped
|
||||
return kept
|
||||
|
||||
def _run_discovery(self, resources: dict[str, dict]) -> None:
|
||||
@@ -565,28 +568,28 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
description = info.get('x.com.samsung.da.description', '')
|
||||
|
||||
# Partitioned discovery (issue #177): the main pass binds every href
|
||||
# owned by no sub-unit; one further pass per *candidate* sub-unit
|
||||
# owned by no subdevice; one further pass per *candidate* subdevice
|
||||
# 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
|
||||
# the master's registry otherwise. See subdevices.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
|
||||
# self.subdevices below is narrowed to the ones that passed, not
|
||||
# every candidate _enumerate_subdevices_blocking found. For a device
|
||||
# with no candidates (self.subdevices == []) 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,
|
||||
resources, self.subdevices, resolve_registry, CAPABILITIES,
|
||||
log=unbound.append, tier_log=_tier_log,
|
||||
)
|
||||
self.sub_units = materialized
|
||||
self._skipped_sub_units = skipped
|
||||
self.subdevices = materialized
|
||||
self._skipped_subdevices = skipped
|
||||
for skip in skipped:
|
||||
self._log.info(
|
||||
"sub-unit %s (%s) answered its seed but produced no live "
|
||||
"subdevice %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),
|
||||
skip.subdevice.key, skip.subdevice.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
|
||||
@@ -601,12 +604,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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:
|
||||
subdevice_count = len(materialized) + 1 # +1 for the master itself
|
||||
if reported is not None and reported != subdevice_count:
|
||||
self._log.debug(
|
||||
"/multidevice/vs/0 reports numofsubdevice=%r but %d "
|
||||
"unit(s) materialized (including the master)",
|
||||
numofsubdevice, unit_count,
|
||||
"subdevice(s) materialized (including the master)",
|
||||
numofsubdevice, subdevice_count,
|
||||
)
|
||||
if device_type_name is not None:
|
||||
self._log.debug("device type: %s (modelNum=%r)", device_type_name, model_num)
|
||||
@@ -646,9 +649,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
self._discovered = True
|
||||
self._log.info(
|
||||
"discovered %d entities (serial=%s) hot=%s warm=%s sub_units=%s",
|
||||
"discovered %d entities (serial=%s) hot=%s warm=%s subdevices=%s",
|
||||
len(bound), serial, self._hot_hrefs, self._warm_hrefs,
|
||||
[su.key for su in self.sub_units],
|
||||
[su.key for su in self.subdevices],
|
||||
)
|
||||
|
||||
def _update_coverage_gap_issue(
|
||||
@@ -803,15 +806,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
|
||||
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.
|
||||
# sibling indoor subdevices before the first discovery pass, and
|
||||
# fold their seed resources into this cycle's snapshot so
|
||||
# discovery sees every subdevice'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
|
||||
self._enumerate_subdevices_blocking, resources
|
||||
)
|
||||
|
||||
source = 'sweep' if self._discovered else 'poll'
|
||||
@@ -820,7 +823,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# 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
|
||||
# seed to evaluate the liveness gate, but only the subdevices 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
|
||||
@@ -831,7 +834,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# 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)
|
||||
resources = self._live_subdevice_resources(resources)
|
||||
sweep_mismatch = False
|
||||
if self._observe.mode == MODE_OBSERVE:
|
||||
# A sweep/cache mismatch never tears down a still-live OBSERVE
|
||||
@@ -934,15 +937,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
# in issues #17/#53, which survived the earlier optimistic-apply fix
|
||||
# (issue #27) because that fix applied to the wrong href too.
|
||||
#
|
||||
# write_fn's path_segs are canonical (issue #177) -- a sub-unit's
|
||||
# write_fn's path_segs are canonical (issue #177) -- a subdevice'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 --
|
||||
# bound entity's own subdevice so the optimistic apply, the settle
|
||||
# guard and the POST below all target that subdevice'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))
|
||||
# subdevices writes exactly where it always did.
|
||||
write_href = bound_entity.subdevice.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,
|
||||
|
||||
@@ -18,7 +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
|
||||
from .registry.subdevices import MAIN
|
||||
|
||||
|
||||
async def async_get_config_entry_diagnostics(
|
||||
@@ -37,18 +37,18 @@ async def async_get_config_entry_diagnostics(
|
||||
# is OCF's standard device-type declaration; /oic/res is OCF's
|
||||
# discovery endpoint, listing every href/Collection the connection
|
||||
# hosts -- relevant to the "Composite Device" model (issue #177) where
|
||||
# a single physical unit exposes more than one logical Device. See
|
||||
# a single physical device exposes more than one logical subdevice. See
|
||||
# registry/identity.py.
|
||||
identity = coordinator._identity
|
||||
|
||||
def _sub_unit_diag(su) -> dict:
|
||||
def _subdevice_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
|
||||
# the distinct hrefs), and one redaction of this subdevice'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]
|
||||
matching = [b for b in coordinator.bound if b.subdevice == su]
|
||||
res = redact_resources(coordinator.canonical_resources(su))
|
||||
return {
|
||||
"kind": su.kind,
|
||||
@@ -57,7 +57,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"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
|
||||
# Keyed by this subdevice'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
|
||||
@@ -77,66 +77,70 @@ 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),
|
||||
# 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`.
|
||||
# This subdevice's own resources, and only this subdevice'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 subdevice 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 `subdevices` entry below instead. For a
|
||||
# device with no subdevices -- 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
|
||||
# Sibling indoor subdevices discovered on this connection (issue
|
||||
# #177) -- per-subdevice kind/key/seed path plus what actually bound
|
||||
# to it, so a report shows whether a composite device's subdevice
|
||||
# was found at all and what it resolved to. subdeviceIdList (the
|
||||
# UUID a prefixed subdevice'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],
|
||||
"subdevices": [_subdevice_diag(su) for su in coordinator.subdevices],
|
||||
# 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
|
||||
# real second subdevice. Reported alongside subdevices 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": [
|
||||
"subdevices_skipped": [
|
||||
{
|
||||
"kind": skip.sub_unit.kind,
|
||||
"key": skip.sub_unit.key,
|
||||
"seed_path": "/" + "/".join(skip.sub_unit.seed_path),
|
||||
"kind": skip.subdevice.kind,
|
||||
"key": skip.subdevice.key,
|
||||
"seed_path": "/" + "/".join(skip.subdevice.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.
|
||||
# like the materialized subdevices above. These are the one
|
||||
# thing a reader needs to second-guess a skip ("is my second
|
||||
# subdevice 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 href, rep in coordinator._skipped_subdevice_resources.items()
|
||||
if (canon := skip.subdevice.to_canonical(href)) is not None
|
||||
}),
|
||||
}
|
||||
for skip in coordinator._skipped_sub_units
|
||||
for skip in coordinator._skipped_subdevices
|
||||
],
|
||||
# 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())),
|
||||
# in identity.py before it moved to registry/subdevices.py.
|
||||
"subdevice_probes": dict(sorted(coordinator._subdevice_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.
|
||||
# metadata about the connection rather than state of any one
|
||||
# subdevice -- and because nothing polls it after discovery, so it
|
||||
# would go stale in there. Its numofsubdevice count is what
|
||||
# independently corroborates the subdevices/subdevices_skipped split
|
||||
# above.
|
||||
"multidevice": redact_resources(coordinator._multidevice),
|
||||
"integration_version": integration.version,
|
||||
"smartthings_local_version": stl_version,
|
||||
|
||||
@@ -35,8 +35,8 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
|
||||
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
|
||||
BoundEntity/Subdevice), so the direct cache lookup below is correct as-is;
|
||||
`exists_fn` gets `bound`'s own subdevice's *canonical* view instead of the
|
||||
raw snapshot, same rule as everywhere else a whole-resources-dict scan
|
||||
happens (coordinator.canonical_resources) -- this is a free function, not
|
||||
an LocalThingsEntity method, so it can't use self._resources.
|
||||
@@ -45,7 +45,7 @@ def _is_included(bound: BoundEntity, coordinator: 'LocalThingsCoordinator') -> b
|
||||
if rep is None:
|
||||
return False
|
||||
if bound.desc.exists_fn is not None:
|
||||
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.sub_unit))
|
||||
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
|
||||
if bound.desc.field:
|
||||
if not rep or is_stub_rep(rep):
|
||||
return True
|
||||
@@ -134,16 +134,16 @@ class LocalThingsEntity(CoordinatorEntity[LocalThingsCoordinator]):
|
||||
|
||||
@property
|
||||
def _resources(self) -> dict:
|
||||
"""This entity's own sub-unit's canonical resources view (issue
|
||||
"""This entity's own subdevice'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
|
||||
instead of `coordinator.last_resources`, or a sibling subdevice'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
|
||||
view. For MAIN (every device with no subdevices) this is exactly
|
||||
`coordinator.last_resources`."""
|
||||
return self.coordinator.canonical_resources(self._bound.sub_unit)
|
||||
return self.coordinator.canonical_resources(self._bound.subdevice)
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
return self.coordinator.device_info_for(self._bound.sub_unit)
|
||||
return self.coordinator.device_info_for(self._bound.subdevice)
|
||||
@@ -4,42 +4,42 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from .discovery import BoundEntity
|
||||
from .subunits import SubUnit, canonical_view
|
||||
from .subdevices import Subdevice, canonical_view
|
||||
|
||||
|
||||
def _key(b: BoundEntity) -> str:
|
||||
# b.sub_unit.key_prefix is '' for MAIN, so a device with no sub-units
|
||||
# b.subdevice.key_prefix is '' for MAIN, so a device with no subdevices
|
||||
# (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}"
|
||||
return f"{b.subdevice.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.
|
||||
|
||||
`exists_fn(rep, resources)` receives that entity's own sub-unit's
|
||||
*canonical* resources view (see subunits.canonical_view), not the raw
|
||||
`exists_fn(rep, resources)` receives that entity's own subdevice's
|
||||
*canonical* resources view (see subdevices.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).
|
||||
for a sibling href (e.g. is_legacy_board) must judge each subdevice on its
|
||||
own resources, not see another subdevice's hrefs bleed in under the same
|
||||
canonical key. Views are built once per distinct subdevice per call, not
|
||||
once per entity -- O(subdevices), not O(bound entities).
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
# SubUnit is a frozen dataclass (hashable, equal by value), so it can key
|
||||
# Subdevice 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] = {}
|
||||
all_subdevices = list(dict.fromkeys(b.subdevice for b in bound))
|
||||
views: dict[Subdevice, dict] = {}
|
||||
|
||||
for b in bound:
|
||||
rep = resources.get(b.href) or {}
|
||||
if b.desc.exists_fn is not None:
|
||||
view = views.get(b.sub_unit)
|
||||
view = views.get(b.subdevice)
|
||||
if view is None:
|
||||
view = canonical_view(b.sub_unit, resources, all_units)
|
||||
views[b.sub_unit] = view
|
||||
view = canonical_view(b.subdevice, resources, all_subdevices)
|
||||
views[b.subdevice] = view
|
||||
if not b.desc.exists_fn(rep, view):
|
||||
continue
|
||||
if b.desc.rep_fn is not None:
|
||||
|
||||
@@ -939,29 +939,30 @@ _AC_IGNORED = [
|
||||
'/stepcontrol/vs/0',
|
||||
'/reserverulesets/vs/0', # opaque hex-encoded schedule reservation blob
|
||||
'/welcome/temperature/vs/0', # welcome-cooling plumbing
|
||||
# System-AC-only (multi-indoor-unit commercial installs, e.g.
|
||||
# System-AC-only (multi-indoor-subdevice commercial installs, e.g.
|
||||
# A-CAWW-TP2-20-COMMON, issue #52): opaque hex-encoded installation
|
||||
# topology -- indoor/outdoor unit pairing, per-unit serials, MCU info.
|
||||
# Commissioning-time plumbing, not user-actionable appliance state.
|
||||
# topology -- indoor/outdoor unit pairing, per-subdevice serials, MCU
|
||||
# info. Commissioning-time plumbing, not user-actionable appliance state.
|
||||
'/sac/installationinfo/vs/0',
|
||||
# Wind-Free 2-in-1 systems (one outdoor unit driving a floor-standing
|
||||
# *and* a wall-mounted indoor unit over one shared local IP, e.g.
|
||||
# *and* a wall-mounted indoor subdevice over one shared local IP, e.g.
|
||||
# TP2X_FAC_BORA_21K, issues #150/#153): an opaque paired-subdevice id
|
||||
# list, same "remote device ids, not user-actionable locally" role as
|
||||
# /remotedeviceinfo/vs/0 above. This integration talks to whichever
|
||||
# single local endpoint the config entry was set up against; the
|
||||
# second indoor unit isn't independently reachable through this
|
||||
# resource (or any other in the dump) -- it would need its own local
|
||||
# DTLS session/IP, which SmartThings pairing doesn't expose here.
|
||||
# /remotedeviceinfo/vs/0 above -- true whenever this field is redacted
|
||||
# (the shipped airconditioner_fac_bora fixture) or absent. When it does
|
||||
# carry a real id (issue #177's airconditioner_fac_bora_2in1 fixture),
|
||||
# registry/subdevices.py's enumerate_subdevices reads this same
|
||||
# subdeviceIdList to reach the second indoor subdevice over this same
|
||||
# connection instead -- see that module's Pattern B.
|
||||
'/subdevices/vs/0',
|
||||
# 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
|
||||
# 2-in-1/multi-indoor-subdevice 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
|
||||
# corroborating count of indoor subdevices 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
|
||||
# /device/0's batch entirely -- registry.subdevices.enumerate_subdevices
|
||||
# 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.
|
||||
|
||||
@@ -18,7 +18,7 @@ from typing import Callable, Iterable, Optional
|
||||
|
||||
from .capability import Capability
|
||||
from .entities import SamsungEntityDescription
|
||||
from .subunits import MAIN, SubUnit
|
||||
from .subdevices import MAIN, Subdevice
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -29,11 +29,12 @@ 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
|
||||
# Which logical indoor subdevice (issue #177) this entity belongs to.
|
||||
# `href` above is always the *actual*, on-the-wire href for that
|
||||
# subdevice -- MAIN's
|
||||
# to_actual is the identity transform, so every device with no subdevices
|
||||
# behaves exactly as before this field existed.
|
||||
sub_unit: SubUnit = MAIN
|
||||
subdevice: Subdevice = MAIN
|
||||
|
||||
|
||||
def _snake_to_title(s: str) -> str:
|
||||
@@ -64,19 +65,19 @@ def instance_suffix(href: str) -> str:
|
||||
|
||||
def _bind(cap: Capability, href: str, inst: str, inst_name: Optional[str],
|
||||
key_prefix: Optional[str] = None,
|
||||
sub_unit: SubUnit = MAIN) -> list[BoundEntity]:
|
||||
subdevice: Subdevice = MAIN) -> list[BoundEntity]:
|
||||
"""Build one BoundEntity per entity on `cap`, sharing the instance/
|
||||
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)."""
|
||||
`subdevice.to_actual` maps it to the real, on-the-wire href the entity
|
||||
actually reads/writes (identity for MAIN, so single-subdevice devices are
|
||||
unaffected -- see subdevices.py)."""
|
||||
return [
|
||||
BoundEntity(href=sub_unit.to_actual(href), capability=cap, desc=desc,
|
||||
BoundEntity(href=subdevice.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, sub_unit=sub_unit)
|
||||
instance_name=inst_name, subdevice=subdevice)
|
||||
for desc in cap.entities
|
||||
]
|
||||
|
||||
@@ -87,7 +88,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,
|
||||
subdevice: Subdevice = 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
|
||||
@@ -97,12 +98,13 @@ def discover(
|
||||
coverage-only capability's `poll_tier` would otherwise be silently
|
||||
dropped since it never appears in `bound`.
|
||||
|
||||
`resources` is always keyed by *canonical* hrefs -- for a sub-unit
|
||||
`resources` is always keyed by *canonical* hrefs -- for a subdevice
|
||||
(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
|
||||
subdevices.canonical_view), the same shape as a plain single-subdevice
|
||||
device's resources dict, so registry lookups/rt_filter/match_fn/
|
||||
instance_suffix all behave identically regardless of which subdevice is
|
||||
being discovered.
|
||||
`subdevice` 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.
|
||||
@@ -122,10 +124,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), sub_unit=sub_unit))
|
||||
out.extend(_bind(cap, href, inst, _instance_name(cap, rep), subdevice=subdevice))
|
||||
matched = True
|
||||
if tier_log is not None:
|
||||
tier_log(sub_unit.to_actual(href), cap.poll_tier)
|
||||
tier_log(subdevice.to_actual(href), cap.poll_tier)
|
||||
|
||||
if matched:
|
||||
continue
|
||||
@@ -143,13 +145,13 @@ def discover(
|
||||
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),
|
||||
sub_unit=sub_unit))
|
||||
subdevice=subdevice))
|
||||
matched = True
|
||||
if tier_log is not None:
|
||||
tier_log(sub_unit.to_actual(href), cap.poll_tier)
|
||||
tier_log(subdevice.to_actual(href), cap.poll_tier)
|
||||
break
|
||||
|
||||
if not matched and not caps and log is not None:
|
||||
log(sub_unit.to_actual(href))
|
||||
log(subdevice.to_actual(href))
|
||||
|
||||
return out
|
||||
@@ -70,9 +70,9 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
|
||||
# RETRIEVE on it returns every Resource/Collection href this endpoint
|
||||
# hosts, not just the one /device/0 seed path the coordinator polls.
|
||||
# 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). This is what registry.subunits.enumerate_sub_units reads
|
||||
# physical device -- one IP, one /oic/p -- exposing more than one logical
|
||||
# subdevice, each as its own Collection resource, same rt shape as our own
|
||||
# /device/0). This is what registry.subdevices.enumerate_subdevices 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
|
||||
|
||||
+103
-99
@@ -1,17 +1,17 @@
|
||||
"""Sub-unit ("composite device") support for one physical connection exposing
|
||||
more than one logical indoor unit -- issue #177.
|
||||
"""Subdevice ("composite device") support for one physical connection exposing
|
||||
more than one logical indoor subdevice -- 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):
|
||||
mechanisms for exposing a second indoor subdevice 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
|
||||
index-0 hrefs -- the sibling subdevices are reachable only via their own
|
||||
`/device/<n>` collection.
|
||||
|
||||
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, jhkwon19's board).
|
||||
@@ -19,25 +19,26 @@ Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, jhkwon19's board).
|
||||
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
|
||||
subdevice'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).
|
||||
wall-mounted subdevice, vs. the master's `TP2X_FAC_BORA_21K`, the floor
|
||||
subdevice).
|
||||
|
||||
Both are "the same thing wearing different clothes": a logical unit is a
|
||||
Both are "the same thing wearing different clothes": a logical subdevice 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
|
||||
disambiguation logic is needed -- `enumerate_subdevices` 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
|
||||
to actually be a live second subdevice, 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
|
||||
other subdevice's, an /information rep echoing the *same* model string as
|
||||
subdevice 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
|
||||
@@ -66,8 +67,8 @@ 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
|
||||
# second logical subdevice's Collection on this board (moved here from
|
||||
# identity.py, issue #177 -- see enumerate_subdevices' 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
|
||||
@@ -77,16 +78,18 @@ _SPECULATIVE_DEVICE_INDICES = (1, 2)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SubUnit:
|
||||
"""One logical indoor unit reachable over a single physical connection.
|
||||
class Subdevice:
|
||||
"""One logical indoor subdevice reachable over a single physical
|
||||
connection.
|
||||
|
||||
`kind='main'` is the unit this config entry actually connects to and
|
||||
`kind='main'` is the subdevice 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.
|
||||
identity transform, so every existing single-subdevice 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 subdevice.
|
||||
"""
|
||||
kind: str # 'main' | 'indexed' | 'prefixed'
|
||||
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
@@ -94,13 +97,13 @@ class SubUnit:
|
||||
|
||||
def to_actual(self, canonical: str) -> str:
|
||||
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
|
||||
on-the-wire href for this unit."""
|
||||
on-the-wire href for this subdevice."""
|
||||
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
|
||||
# pattern-cap hrefs, e.g. '/door/vs/1') as a subdevice'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.
|
||||
@@ -112,7 +115,7 @@ class SubUnit:
|
||||
return canonical
|
||||
|
||||
def to_canonical(self, actual: str) -> Optional[str]:
|
||||
"""Inverse of to_actual, or None when `actual` isn't this unit's."""
|
||||
"""Inverse of to_actual, or None when `actual` isn't this subdevice's."""
|
||||
if self.kind == 'indexed':
|
||||
head, sep, tail = actual.rpartition('/')
|
||||
if tail == self.key:
|
||||
@@ -126,9 +129,9 @@ class SubUnit:
|
||||
return actual
|
||||
|
||||
def owns(self, actual: str) -> bool:
|
||||
"""True if `actual` belongs to this unit's namespace. MAIN never
|
||||
"""True if `actual` belongs to this subdevice'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)."""
|
||||
every other subdevice's hrefs are excluded (see canonical_view)."""
|
||||
if self.kind == 'main':
|
||||
return False
|
||||
return self.to_canonical(actual) is not None
|
||||
@@ -136,7 +139,7 @@ class SubUnit:
|
||||
@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
|
||||
adapter._key). '' for MAIN -- the master'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
|
||||
@@ -147,60 +150,60 @@ class SubUnit:
|
||||
the device name + entity name, not from unique_id.
|
||||
"""
|
||||
if self.kind == 'indexed':
|
||||
return f'unit{self.key}_'
|
||||
return f'subdevice{self.key}_'
|
||||
if self.kind == 'prefixed':
|
||||
slug = re.sub(r'[^a-zA-Z0-9]', '', self.key)
|
||||
return f'sub_{slug}_'
|
||||
return f'subdevice_{slug}_'
|
||||
return ''
|
||||
|
||||
|
||||
MAIN = SubUnit(kind='main', key='', seed_path=('device', '0'))
|
||||
MAIN = Subdevice(kind='main', key='', seed_path=('device', '0'))
|
||||
|
||||
|
||||
def canonical_view(
|
||||
sub_unit: SubUnit, resources: dict[str, dict], sub_units: list['SubUnit'],
|
||||
subdevice: Subdevice, resources: dict[str, dict], subdevices: list['Subdevice'],
|
||||
) -> dict[str, dict]:
|
||||
"""Rewrite `resources` (real, on-the-wire hrefs) into `sub_unit`'s own
|
||||
"""Rewrite `resources` (real, on-the-wire hrefs) into `subdevice`'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`
|
||||
other subdevices in `subdevices` -- 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`.
|
||||
silently mixing two subdevices' state together. For an indexed/prefixed
|
||||
subdevice it's the reverse: only the hrefs that subdevice owns, rewritten
|
||||
back through `to_canonical`.
|
||||
|
||||
`sub_units` may or may not include MAIN itself -- MAIN.owns() is always
|
||||
`subdevices` may or may not include MAIN itself -- MAIN.owns() is always
|
||||
False, so including it is harmless.
|
||||
"""
|
||||
if sub_unit.kind == 'main':
|
||||
if subdevice.kind == 'main':
|
||||
owned_elsewhere = {
|
||||
href for href in resources
|
||||
if any(su.owns(href) for su in sub_units)
|
||||
if any(su.owns(href) for su in subdevices)
|
||||
}
|
||||
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
|
||||
if (canon := subdevice.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.
|
||||
def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[str, dict]:
|
||||
"""Real, on-the-wire hrefs from one subdevice's seed-collection batch,
|
||||
normalized so every href actually carries this subdevice's prefix/index.
|
||||
|
||||
Indexed units need no change -- the device echoes the real `/x/<n>`
|
||||
Indexed subdevices 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
|
||||
A prefixed subdevice'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.
|
||||
live before the subdevice id was known), so it's added when missing.
|
||||
"""
|
||||
if sub_unit.kind != 'prefixed':
|
||||
if subdevice.kind != 'prefixed':
|
||||
return batch
|
||||
prefix = f'/{sub_unit.key}'
|
||||
prefix = f'/{subdevice.key}'
|
||||
return {
|
||||
(href if href.startswith(prefix + '/') else f'{prefix}{href}'): rep
|
||||
for href, rep in batch.items()
|
||||
@@ -266,23 +269,24 @@ def _get_property(sess, path_segs: tuple[str, ...]) -> dict:
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def enumerate_sub_units(
|
||||
def enumerate_subdevices(
|
||||
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.
|
||||
) -> tuple[list['Subdevice'], dict[str, dict]]:
|
||||
"""Discover every sibling indoor subdevice 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
|
||||
out whether it's there). Returns the *candidate* subdevices 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)
|
||||
not it answered -- so diagnostics (see diagnostics.py's subdevice_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.
|
||||
@@ -294,7 +298,7 @@ def enumerate_sub_units(
|
||||
entities first, which is `discover_partitioned`'s job, not this one's.
|
||||
See this module's docstring.
|
||||
"""
|
||||
units: list[SubUnit] = []
|
||||
subdevices: list[Subdevice] = []
|
||||
fetched: dict[str, dict] = {}
|
||||
|
||||
def _probed(seed_href: str, batch: dict) -> None:
|
||||
@@ -307,7 +311,7 @@ def enumerate_sub_units(
|
||||
# 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
|
||||
# '**REDACTED**'/'REDACTED' there. That must yield zero subdevices, 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 []
|
||||
@@ -317,9 +321,9 @@ def enumerate_sub_units(
|
||||
_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)
|
||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
|
||||
fetched.update(normalize_seed_batch(subdevice, batch))
|
||||
subdevices.append(subdevice)
|
||||
|
||||
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
|
||||
indices = sorted({
|
||||
@@ -340,9 +344,9 @@ def enumerate_sub_units(
|
||||
_probed(_seed_href(seed), batch)
|
||||
if not batch:
|
||||
continue
|
||||
unit = SubUnit(kind='indexed', key=str(n), seed_path=seed)
|
||||
subdevice = Subdevice(kind='indexed', key=str(n), seed_path=seed)
|
||||
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
|
||||
units.append(unit)
|
||||
subdevices.append(subdevice)
|
||||
|
||||
# /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
|
||||
@@ -354,31 +358,31 @@ def enumerate_sub_units(
|
||||
# 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.
|
||||
# with the number of subdevices 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
|
||||
return subdevices, fetched
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SkippedSubUnit:
|
||||
"""A candidate `enumerate_sub_units` found whose seed answered, but that
|
||||
class SkippedSubdevice:
|
||||
"""A candidate `enumerate_subdevices` 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.
|
||||
unused SmartThings slot (HJcom's `/device/2`), not a real second subdevice.
|
||||
Kept around (rather than silently dropped) so a caller can log/report
|
||||
what was skipped and why."""
|
||||
sub_unit: SubUnit
|
||||
subdevice: Subdevice
|
||||
hrefs: tuple[str, ...]
|
||||
|
||||
|
||||
def _has_live_primary_entity(bound, state: dict) -> bool:
|
||||
"""True if flattening `bound` (one candidate sub-unit's BoundEntity
|
||||
"""True if flattening `bound` (one candidate subdevice'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).
|
||||
@@ -387,7 +391,7 @@ def _has_live_primary_entity(bound, state: dict) -> bool:
|
||||
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
|
||||
"something" proves nothing about whether a physical subdevice is actually
|
||||
installed there, so it's deliberately excluded from this check.
|
||||
"""
|
||||
from .adapter import _key # see discover_partitioned's deferred-import note
|
||||
@@ -399,27 +403,27 @@ def _has_live_primary_entity(bound, state: dict) -> bool:
|
||||
|
||||
def discover_partitioned(
|
||||
resources: dict[str, dict],
|
||||
sub_units: list['SubUnit'],
|
||||
subdevices: list['Subdevice'],
|
||||
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.
|
||||
plus every enumerated subdevice's seed) to entities, partitioned by which
|
||||
subdevice owns it.
|
||||
|
||||
Main pass runs over hrefs owned by no sub-unit -- otherwise every
|
||||
Main pass runs over hrefs owned by no subdevice -- 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
|
||||
coverage-gap repair. Then one pass per *candidate* subdevice over its own
|
||||
canonical view, resolving that subdevice's own device type from its own
|
||||
`/information/vs/0` when it reports one (e.g. jhkwon19's wall subdevice
|
||||
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.
|
||||
type as the subdevice 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
|
||||
@@ -429,21 +433,21 @@ def discover_partitioned(
|
||||
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
|
||||
first-discovery time only is a non-issue; getting a phantom subdevice
|
||||
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).
|
||||
subdevice).
|
||||
- `device_type_name`: the *master's* resolved device type (used for
|
||||
logging/device naming; each sub-unit's own resolved type only affects
|
||||
logging/device naming; each subdevice'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
|
||||
- `materialized`: the subset of `subdevices` that passed the gate, in the
|
||||
same order -- what the caller should keep as its live subdevice roster
|
||||
going forward (poll seeds, canonical_resources, device_info_for, ...).
|
||||
- `skipped`: `SkippedSubUnit` entries for every candidate that didn't.
|
||||
- `skipped`: `SkippedSubdevice` entries for every candidate that didn't.
|
||||
"""
|
||||
# Deferred import: discovery.py imports SubUnit/MAIN from this module at
|
||||
# Deferred import: discovery.py imports Subdevice/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
|
||||
@@ -452,9 +456,9 @@ def discover_partitioned(
|
||||
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
|
||||
# other subdevice's owned hrefs) -- reuse it rather than re-deriving
|
||||
# owned_elsewhere here too.
|
||||
main_view = canonical_view(MAIN, resources, sub_units)
|
||||
main_view = canonical_view(MAIN, resources, subdevices)
|
||||
|
||||
reg = resolve_registry(main_view)
|
||||
caps, pats = (
|
||||
@@ -463,29 +467,29 @@ def discover_partitioned(
|
||||
)
|
||||
# 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)
|
||||
bound = discover(main_view, caps, pats, log=log, tier_log=tier_log, subdevice=MAIN)
|
||||
device_type_name = reg.name if reg is not None else None
|
||||
|
||||
materialized: list[SubUnit] = []
|
||||
skipped: list[SkippedSubUnit] = []
|
||||
materialized: list[Subdevice] = []
|
||||
skipped: list[SkippedSubdevice] = []
|
||||
|
||||
for su in sub_units:
|
||||
view = canonical_view(su, resources, sub_units)
|
||||
for su in subdevices:
|
||||
view = canonical_view(su, resources, subdevices)
|
||||
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_bound = discover(view, su_caps, su_pats, subdevice=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,
|
||||
view, su_caps, su_pats, log=log, tier_log=tier_log, subdevice=su,
|
||||
)
|
||||
else:
|
||||
skipped.append(SkippedSubUnit(
|
||||
sub_unit=su,
|
||||
skipped.append(SkippedSubdevice(
|
||||
subdevice=su,
|
||||
hrefs=tuple(sorted({b.href for b in probe_bound})),
|
||||
))
|
||||
|
||||
@@ -104,7 +104,7 @@ 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.
|
||||
# This entity's own sub-unit's canonical view (issue #177), not
|
||||
# This entity's own subdevice'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:
|
||||
|
||||
Reference in new issue
Block a user