Merge pull request #206 from mbillow/fix/subdevice-flat-href-fallback-205

fix(subdevices): fall back to per-href probing when a prefixed subdevice has no /device/0 Collection
This commit is contained in:
Marc Billow
2026-07-29 22:07:47 -05:00
committed by GitHub
15 changed files with 1427 additions and 141 deletions
+54 -18
View File
@@ -218,10 +218,14 @@ This is a rule about **writes and entities**, not about reading. A speculative
`GET` of an href a dump doesn't contain is fine and the codebase already relies
on it: `read_identity` reads `/oic/p`, `/oic/d` and `/oic/res`, and
`subdevices.enumerate_subdevices` probes `/device/<n>`, `/<uuid>/device/0` and
`/multidevice/vs/0` on every device. A RETRIEVE is non-mutating and a 4.04 is
tolerated everywhere in that path, so the cost of a wrong guess is one wasted
round trip. Guessing a *write* against live hardware is the thing this rule
forbids — as is materializing an entity from a field you can't explain.
`/multidevice/vs/0` on every device — and, when a prefixed candidate's own
`/<uuid>/device/0` doesn't answer (issue #205: not guaranteed even on the
board this pattern was built against), every href the master itself
answered this cycle, individually under that UUID's prefix (see §11). A
RETRIEVE is non-mutating and a 4.04 is tolerated everywhere in that path, so
the cost of a wrong guess is one wasted round trip. Guessing a *write*
against live hardware is the thing this rule forbids — as is materializing
an entity from a field you can't explain.
## 6. Select options: read them from the device, don't hardcode
@@ -364,6 +368,19 @@ The new fixture is picked up automatically by the corpus-wide checks (the
board token fails the build rather than silently mistyping someone's
appliance.
**Don't put a reporter's name or GitHub username in code.** Fixture data
gets serials/MACs/other device PII scrubbed per point 1 above — the same
rule applies to the *prose* you write while fixing the issue: comments,
docstrings, `seeds_note`, and test/function names should say "the
reporter," "issue #NNN's reporter," or (when a module already distinguishes
multiple reporters, like `subdevices.py`'s Pattern A/Pattern B) "the
Pattern A reporter," never a real name or handle. That prose ships in the
package and lives in git history indefinitely — unlike an issue thread or a
release-notes thank-you (both fine places to credit someone by name), it's
not somewhere a person would expect to stay named forever. If you're fixing
an issue and about to write `<username>'s board`/`<username>'s dump` in a
comment, stop and swap in a generic reference instead.
## 11. Triage: "one of my subdevices is missing"
For an appliance that exposes several logical indoor subdevices over one IP —
@@ -372,22 +389,41 @@ the dump in this order; each step rules out a different cause.
1. **`subdevice_probes`** — did we even look? Every seed attempted appears
here with what it returned. An absent seed means enumeration never tried
that path; a `false` means it tried and got nothing.
2. **`subdevices_skipped`** — did we find it and reject it? A candidate lands
here when its seed answered but it produced no *primary* (non-diagnostic)
entity with a populated value. Its `resources` block holds the exact reps
the gate judged, so you can check the call yourself. If every
power/mode/temperature rep is `{}`, the subdevice is an unused slot and the
skip is correct. If they're populated, the gate is wrong — that's a bug
worth a fixture.
3. **`multidevice.numofsubdevice`** — the board's own count, where it
reports one. Disagreement with `len(subdevices) + 1` is a strong hint,
not proof; only one board family is known to expose it.
4. **Which pattern is this board?** `identity.resources['/oic/res']` listing
that path; a `false` means it tried and got nothing. On a UUID-prefixed
board whose `/<uuid>/device/0` reads `false` (issue #205 — this isn't
rare, not even on the board the pattern was built against), the report
also carries one probe per href the master itself answered that cycle,
individually under that prefix (`subdevices.enumerate_subdevices`'s flat
fallback) — a `true` there is real, confirmed-live evidence for that one
href, not a guess.
2. **`subdevices`**/**`flat_hrefs`** — for a *materialized* subdevice found
this way, `flat_hrefs` lists exactly which hrefs it's actually being
polled on (individually, no Collection endpoint to batch through) —
compare against the master's own hrefs to see what's still unconfirmed
for that sibling.
3. **`subdevices_skipped`** — did we find it and reject it? A candidate lands
here when its seed(s) answered but it produced no *primary*
(non-diagnostic) entity with a populated value. Its `resources` block
holds the exact reps the gate judged, so you can check the call
yourself. If every power/mode/temperature rep is `{}`, the subdevice is
an unused slot and the skip is correct. If they're populated, the gate
is wrong — that's a bug worth a fixture. A flat-fallback candidate whose
only confirmed href is `/information/vs/0` (never bound to any entity —
only ever read for device-type resolution) will *always* land here until
more of its hrefs are confirmed live; that's the gate working as
intended, not a bug to chase.
4. **`multidevice.numofsubdevice`** — the board's own count, where it
reports one. `coordinator._run_discovery` compares it against
`len(materialized) + 1` (materialized subdevices plus the master) and
only warns on disagreement — `subdevices_skipped` entries don't count
toward either side, since they never materialized. A strong hint, not
proof; only one board family is known to expose it.
5. **Which pattern is this board?** `identity.resources['/oic/res']` listing
`/device/1`, `/device/2` means indexed siblings. `resources['/subdevices/
vs/0']` carrying a `subdeviceIdList` means a UUID-prefixed tree, and that
same UUID usually shows up as an href prefix in `/oic/res` too. Neither
present, on a device the owner insists has two subdevices, is the
same UUID usually shows up as an href prefix in `/oic/res` too — enumerate
whether or not `/<uuid>/device/0` itself answers, per §5's fallback.
Neither present, on a device the owner insists has two subdevices, is the
interesting case — that's a third mechanism and needs a new dump, not a
code guess.
+70 -17
View File
@@ -174,9 +174,10 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# 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.
# slot that still answers its seed, e.g. the issue #177 reporter'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_subdevices: list = []
# Those rejected candidates' raw reps, kept aside for diagnostics
# only (see _live_subdevice_resources). They are deliberately not in the
@@ -285,8 +286,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# 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.
# ordinal); UUID-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'
@@ -408,12 +409,15 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""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 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."""
because a sibling timed out or dropped off (e.g. the issue #177
reporter'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 {}
if subdevice.flat_hrefs:
return self._poll_subdevice_flat_hrefs(subdevice, sess)
try:
code, payload = sess.get(list(subdevice.seed_path), timeout=10.0)
if code == 0x45 and payload:
@@ -424,6 +428,53 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._log.debug("subdevice %s seed poll failed: %s", subdevice.key, e)
return {}
def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice, sess) -> dict[str, dict]:
"""Re-poll a flat-mode prefixed subdevice's hrefs individually
(issue #205) -- it has no Collection endpoint to batch-refresh
through (see enumerate_subdevices' fallback), so each canonical
href confirmed at enumeration time gets its own GET under the
subdevice's prefix. A href failing to answer this cycle just drops
out of the result, same "never let a sibling's flakiness fail the
master's poll" posture as the Collection path above.
Takes `sess` from the caller (already None-checked there) rather
than re-reading self._session -- async_close() can null that
without holding _session_lock, and pace()/get() both need a live
session on every iteration, not just the first.
Skips any href already covered by the hot/warm sub-poll tiers
(self._hot_hrefs/_warm_hrefs, in the same actual/on-the-wire form
this method builds) -- those are already refreshed every 3s/6s by
_run_subpolls, strictly more current than this once-per-summary-poll
pass could offer, so re-fetching them here would only add GETs
without adding freshness. A subdevice with many confirmed hrefs
(unlike a Collection batch, which is always one GET regardless of
count) is otherwise a summary-poll cost that scales with its href
count."""
skip = set(self._hot_hrefs) | set(self._warm_hrefs)
result: dict[str, dict] = {}
first = True
for href in subdevice.flat_hrefs:
actual = subdevice.to_actual(href)
if actual in skip:
continue
try:
if not first:
sess.pace()
first = False
path = [s for s in actual.strip('/').split('/')]
code, payload = sess.get(path, timeout=10.0)
if code == 0x45 and payload:
rep = cbor2.loads(payload)
if isinstance(rep, dict):
result[actual] = rep
except Exception as e:
self._log.debug(
"subdevice %s flat href %s poll failed: %s",
subdevice.key, href, e,
)
return result
def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]:
"""GET individual hrefs sequentially. Does not reconnect on failure. Blocking."""
if self._session is None:
@@ -494,8 +545,9 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
_run_discovery sees every candidate's state without a second poll
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.
method doesn't know how to tell an unused SmartThings slot (the
issue #177 reporter's /device/2) from a real sibling, only that
something answered.
"""
if self._session is None:
self._connect_session()
@@ -573,8 +625,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# its own /information/vs/0 when it reports one and falling back to
# 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
# produced live primary state (the issue #177 reporter's /device/2,
# an unused SmartThings slot, answers its seed but never does), so
# 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
@@ -592,11 +644,12 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
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
# 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.
# /multidevice/vs/0's numofsubdevice is a plain count the issue
# #177 reporter'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:
+18 -7
View File
@@ -41,6 +41,17 @@ async def async_get_config_entry_diagnostics(
# registry/identity.py.
identity = coordinator._identity
def _seed_diag(su) -> dict:
# A flat-mode subdevice (issue #205 -- no working /<uuid>/device/0
# Collection, so its state comes from individually-polled hrefs
# instead) has no meaningful seed_path; report the flat_hrefs list
# in its place rather than the misleading bare "/" a joined empty
# tuple would otherwise produce.
return {
"seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None,
"flat_hrefs": list(su.flat_hrefs),
}
def _subdevice_diag(su) -> dict:
# One pass over coordinator.bound for both fields below (count and
# the distinct hrefs), and one redaction of this subdevice's canonical
@@ -53,7 +64,7 @@ async def async_get_config_entry_diagnostics(
return {
"kind": su.kind,
"key": su.key,
"seed_path": "/" + "/".join(su.seed_path),
**_seed_diag(su),
"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', ''),
@@ -101,16 +112,16 @@ async def async_get_config_entry_diagnostics(
"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 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.
# (the issue #177 reporter's /device/2) that still answers a
# same-shaped batch, not a 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.
"subdevices_skipped": [
{
"kind": skip.subdevice.kind,
"key": skip.subdevice.key,
"seed_path": "/" + "/".join(skip.subdevice.seed_path),
**_seed_diag(skip.subdevice),
"hrefs": list(skip.hrefs),
# The reps the liveness gate actually judged, canonicalized
# like the materialized subdevices above. These are the one
@@ -958,7 +958,7 @@ _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-subdevice systems (issue #177, HJcom's
# 2-in-1/multi-indoor-subdevice systems (issue #177, the reporter's
# ARTIK051_DONGLE_FAC_18K): x.com.samsung.da.numofsubdevice, a plain
# corroborating count of indoor subdevices on this connection. Confirmed
# read-only (a write attempt returned CoAP 4.00). Absent from
@@ -73,7 +73,7 @@ def read_identity(sess, serial: Optional[str]) -> DeviceIdentity:
# 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
# to find a board's `/device/<n>` siblings (Pattern A -- the reporter'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
@@ -4,50 +4,64 @@ more than one logical indoor subdevice -- issue #177.
Two reporters, two different board families, two genuinely different
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):
diagnostics dumps this was built against come from the Pattern A and
Pattern B reporters, respectively -- 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 subdevices are reachable only via their own
`/device/<n>` collection.
Pattern A -- indexed siblings (`ARTIK051_DONGLE_FAC_18K`, that reporter'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 subdevices are
reachable only via their own `/device/<n>` collection.
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, jhkwon19's board).
Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, that reporter'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
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 subdevice, vs. the master's `TP2X_FAC_BORA_21K`, the floor
subdevice).
(`/<uuid>/file/list/vs/0`, ...). What's actually been confirmed live on
that reporter's unit is narrower than early issue #177 writeups suggested: a
single individual `GET /<uuid>/information/vs/0` was read by hand through
the debug panel and came back carrying a different model/serial than the
master (`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the
master's `TP2X_FAC_BORA_21K`, the floor subdevice) -- real evidence a
second subdevice exists at that prefix, but not evidence that `GET
/<uuid>/device/0` (the Collection batch PR #199 built this pattern's seed
around) itself returns anything. Issue #205, the same unit on a later
version, is that assumption failing: `/<uuid>/device/0` comes back empty.
So `enumerate_subdevices` tries it first (a future board might genuinely
expose it) and falls back, when it's empty, to probing every href the
master itself answered this cycle individually under the UUID prefix --
the only thing ever actually confirmed to work for this pattern -- on the
assumption that a composite device's siblings share the master's resource
surface. See `Subdevice.flat_hrefs`.
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_subdevices` checks both and
materializes any candidate whose seed answers with a non-empty batch.
detection signals don't overlap on either captured board (the Pattern A
reporter's has no `/subdevices/vs/0` at all; the Pattern B reporter's has
no `/device/1`), so no 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 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 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
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
to actually be a live second subdevice, though: the Pattern A reporter'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 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 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.
the Pattern A reporter'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
@@ -90,10 +104,19 @@ class Subdevice:
('1', '2', ...) or the full subdevice UUID, and `seed_path` is the
Collection href (as path segments) whose batch response
enumerates/refreshes that subdevice.
`flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't
expose its own Collection at `seed_path` (issue #205 -- not even
TP2X_FAC_BORA_21K, the board this pattern was built against, always
does). When set, `seed_path` is meaningless (left as `()`) and this
subdevice's state comes from GETting each of these canonical hrefs
individually under its prefix instead of one Collection batch -- see
enumerate_subdevices' fallback and coordinator._poll_subdevice_seed.
"""
kind: str # 'main' | 'indexed' | 'prefixed'
key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001'
seed_path: tuple[str, ...]
flat_hrefs: tuple[str, ...] = ()
def to_actual(self, canonical: str) -> str:
"""Canonical registry href (e.g. '/mode/vs/0') -> the real,
@@ -196,10 +219,11 @@ def normalize_seed_batch(subdevice: Subdevice, batch: dict[str, dict]) -> dict[s
normalized so every href actually carries this subdevice's prefix/index.
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 subdevice's batch entries may or may not already carry the
`/<id>` prefix (unconfirmed which -- jhkwon19's board was never probed
live before the subdevice id was known), so it's added when missing.
href in its own `/device/<n>` batch (confirmed against the Pattern A
reporter's dump). A prefixed subdevice's batch entries may or may not
already carry the `/<id>` prefix (unconfirmed which -- the Pattern B
reporter's board was never probed live before the subdevice id was
known), so it's added when missing.
"""
if subdevice.kind != 'prefixed':
return batch
@@ -262,9 +286,9 @@ def _get_batch(sess, path_segs: tuple[str, ...]) -> dict[str, dict]:
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."""
`/oic/res` on the Pattern A reporter'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 {}
@@ -293,9 +317,10 @@ def enumerate_subdevices(
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.
SmartThings slot that merely answers the same shape (the Pattern A
reporter'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.
"""
subdevices: list[Subdevice] = []
@@ -319,11 +344,52 @@ def enumerate_subdevices(
seed = (sub_id, 'device', '0')
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
if not batch:
if batch:
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
continue
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this
# pattern was built against -- turns out not to always expose its own
# `/<uuid>/device/0` Collection either, so "every prefixed subdevice
# has one" doesn't hold even on the reference hardware. With no
# Collection to seed from and no per-UUID entry in `/oic/res` to
# enumerate hrefs from, the only signal left is that a composite
# device's siblings are the same physical board family as the
# subdevice this config entry already talks to -- so probe every
# href the master itself answered this cycle, individually, under
# this UUID's prefix, and keep whichever ones answer. Each is a
# plain tolerated-404 RETRIEVE, same posture as every other probe in
# this function.
#
# Known gap, not yet guarded against: a firmware that answers *any*
# request under an unrecognized prefix (echoing the master's own
# state back rather than 4.04ing) would pass every one of these
# probes and, if the echoed state also clears discover_partitioned's
# liveness gate, materialize a phantom duplicate of the master
# rather than a real sibling. Every board seen so far genuinely
# 4.04s on paths it doesn't own (issue #205's own unit answered only
# 1 of 31 probes), so this hasn't been built -- the one place it
# could hook in later is comparing a candidate's confirmed reps
# against the master's own values for those same canonical hrefs.
flat_hrefs = []
first = True
for href in sorted(resources):
if not first:
sess.pace()
first = False
actual = f'/{sub_id}{href}'
rep = _get_property(sess, tuple(actual.strip('/').split('/')))
_probed(actual, bool(rep))
if rep:
flat_hrefs.append(href)
fetched[actual] = rep
if not flat_hrefs:
continue
subdevices.append(Subdevice(
kind='prefixed', key=sub_id, seed_path=(),
flat_hrefs=tuple(flat_hrefs),
))
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted({
@@ -334,7 +400,7 @@ def enumerate_subdevices(
})
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
# reporter 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)
@@ -348,9 +414,9 @@ def enumerate_subdevices(
fetched.update(batch) # already real /x/<n> hrefs, no normalization needed
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
# own RETRIEVE. It's a plain corroborating count
# /multidevice/vs/0 (issue #177 follow-up): the Pattern A reporter'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
@@ -374,9 +440,9 @@ def enumerate_subdevices(
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 subdevice.
Kept around (rather than silently dropped) so a caller can log/report
what was skipped and why."""
unused SmartThings slot (the Pattern A reporter'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."""
subdevice: Subdevice
hrefs: tuple[str, ...]
@@ -388,7 +454,7 @@ def _has_live_primary_entity(bound, state: dict) -> bool:
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
the Pattern A reporter'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 subdevice is actually
@@ -418,8 +484,8 @@ def discover_partitioned(
device's registry claims that literal href) and raise a spurious
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 ->
`/information/vs/0` when it reports one (e.g. the Pattern B reporter'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
@@ -2029,5 +2029,5 @@
"x.com.samsung.da.numofsubdevice": "2"
}
},
"seeds_note": "ALL REAL CAPTURED DATA -- issue #177, HJcom's ARTIK051_DONGLE_FAC_18K (Samsung 2-in-1: floor-standing master + wall-mounted second indoor subdevice). device0, oic_res and both /device/<n> seeds come verbatim from their v0.16.0 diagnostics download, which is the first release that probes /device/1 and /device/2. Nothing here is reconstructed. The reporter masked a handful of values in their own dump before posting (serialNum, and the airflow `direction` / vendor `humidity` readings); those are normalized onto this repo's usual **REDACTED** marker. /device/1 is the real second subdevice -- populated power/mode/temperature and its own /information/vs/1 reporting ARTIK051_DONGLE_FAC_RAC_18K (RAC = the wall-mounted subdevice) against the master's ARTIK051_DONGLE_FAC_18K. /device/2 answers with the same 14-href shape but every state rep is empty {} -- it is the unused third slot SmartThings shows disabled, and it is why subdevice materialization gates on populated climate state rather than on a seed merely answering. `probes` is not part of any batch response: /multidevice/vs/0 is listed in oic_res but absent from /device/0's batch, and the reporter read it by hand through the debug panel (a write attempt returned CoAP 4.00, i.e. read-only). Its numofsubdevice=2 independently corroborates 2 real subdevices, not 3."
"seeds_note": "ALL REAL CAPTURED DATA -- issue #177, the reporter's ARTIK051_DONGLE_FAC_18K (Samsung 2-in-1: floor-standing master + wall-mounted second indoor subdevice). device0, oic_res and both /device/<n> seeds come verbatim from their v0.16.0 diagnostics download, which is the first release that probes /device/1 and /device/2. Nothing here is reconstructed. The reporter masked a handful of values in their own dump before posting (serialNum, and the airflow `direction` / vendor `humidity` readings); those are normalized onto this repo's usual **REDACTED** marker. /device/1 is the real second subdevice -- populated power/mode/temperature and its own /information/vs/1 reporting ARTIK051_DONGLE_FAC_RAC_18K (RAC = the wall-mounted subdevice) against the master's ARTIK051_DONGLE_FAC_18K. /device/2 answers with the same 14-href shape but every state rep is empty {} -- it is the unused third slot SmartThings shows disabled, and it is why subdevice materialization gates on populated climate state rather than on a seed merely answering. `probes` is not part of any batch response: /multidevice/vs/0 is listed in oic_res but absent from /device/0's batch, and the reporter read it by hand through the debug panel (a write attempt returned CoAP 4.00, i.e. read-only). Its numofsubdevice=2 independently corroborates 2 real subdevices, not 3."
}
@@ -0,0 +1,720 @@
{
"device0": [
{
"rt": [
"x.com.samsung.devcol",
"oic.wk.col"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
]
},
{
"href": "/alarms/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "ErrorCode_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-30T01:48:51"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Alarm",
"x.com.samsung.da.alarmType": "Device",
"x.com.samsung.da.code": "AC_V_0002_OFF",
"x.com.samsung.da.triggeredTime": "2026-07-30T01:48:51"
}
]
}
},
{
"href": "/availablecontrolsets/vs/0",
"rep": {
"x.com.samsung.da.sets": "000000B4012C0000404B04000000",
"x.com.samsung.da.id": "FAC",
"x.com.samsung.da.version": "1.0"
}
},
{
"href": "/configuration/vs/0",
"rep": {
"x.com.samsung.da.region": "3017000000",
"x.com.samsung.da.airconOptionList": [
"HOMECARE_WIZARD_V2",
"ENERGY_2.0",
"AI_2.0",
"DeviceTypeMaster",
"SingleCommand_1"
]
}
},
{
"href": "/diagnosis/vs/0",
"rep": {}
},
{
"href": "/drlc/0",
"rep": {
"DRLevel": 0,
"start": "1970-01-01T00:00:00Z",
"duration": 0,
"override": false
}
},
{
"href": "/drlc/vs/0",
"rep": {
"x.com.samsung.da.drlcLevel": "0",
"x.com.samsung.da.duration": "00:00:00",
"x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z",
"x.com.samsung.da.override": "Off"
}
},
{
"href": "/energy/consumption/0",
"rep": {
"energy": 800.0,
"power": 65278.0
}
},
{
"href": "/energy/consumption/vs/0",
"rep": {
"x.com.samsung.da.cumulativeConsumption": "800.000000",
"x.com.samsung.da.instantaneousPower": "65278.000000",
"x.com.samsung.da.usageThreshold": "0.000000",
"x.com.samsung.da.cumulativePower": "544088",
"x.com.samsung.da.cumulativeUnit": "Wh",
"x.com.samsung.da.instantaneousPowerUnit": "W",
"x.com.samsung.da.cumulativePowerType": "individual"
}
},
{
"href": "/file/information/vs/0",
"rep": {
"x.com.samsung.timeoffset": "+09:00",
"x.com.samsung.supprtedtype": 1
}
},
{
"href": "/filter/airdustfilter/vs/0",
"rep": {
"x.com.samsung.da.filterUsage": "0",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "112",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "112",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": [
"washable"
],
"x.com.samsung.da.supportedFilterDesiredUsage": [
"112",
"224",
"336",
"448"
]
}
},
{
"href": "/humidity/0",
"rep": {
"humidity": 0
}
},
{
"href": "/humidity/vs/0",
"rep": {
"x.com.samsung.da.humidity": "0.000000",
"x.com.samsung.da.fivepercentHumidity": "58"
}
},
{
"href": "/information/vs/0",
"rep": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_21K",
"x.com.samsung.da.serialNum": "**REDACTED**",
"x.com.samsung.da.otnDUID": "**REDACTED**",
"x.com.samsung.da.diagProtocolType": "WIFI_HTTPS",
"x.com.samsung.da.diagLogType": [
"errCode",
"dump"
],
"x.com.samsung.da.diagDumpType": "file",
"x.com.samsung.da.diagEndPoint": "SSM",
"x.com.samsung.da.diagMnid": "0AJT",
"x.com.samsung.da.diagSetupid": "000",
"x.com.samsung.da.diagMinVersion": "1.0",
"x.com.samsung.da.serialNumOption": "**REDACTED**",
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Software",
"x.com.samsung.da.number": "02337A260424",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "1",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Firmware",
"x.com.samsung.da.number": "2102240021022200",
"x.com.samsung.da.newVersionAvailable": "0"
},
{
"x.com.samsung.da.id": "2",
"x.com.samsung.da.description": "Version",
"x.com.samsung.da.type": "Outdoor",
"x.com.samsung.da.number": "2103300110000300"
}
]
}
},
{
"href": "/keepnormalstate/vs/0",
"rep": {
"x.com.samsung.da.keepnormal": 5
}
},
{
"href": "/mode/convenient/vs/0",
"rep": {
"x.com.samsung.da.modes": "Off",
"x.com.samsung.da.supportedModes": [
"Off",
"Sleep",
"Quiet",
"Speed"
]
}
},
{
"href": "/mode/vs/0",
"rep": {
"x.com.samsung.da.supportedModes": [
"AIComfort",
"Cool",
"Dry",
"Wind"
],
"x.com.samsung.da.modes": [
"Wind"
],
"x.com.samsung.da.options": [
"Operation_Family",
"Blooming_0",
"OnTimer_0",
"OffTimer_0",
"Sleep_16",
"ArtificialWorking_Off",
"ComfortAICooling_Off",
"AiTempChanged_Off",
"AiTemp_270",
"welcomecare_Off",
"Panel_Close",
"Weather_Off",
"Volume_100",
"StopAutoClean_Idle",
"DiagnosisAI_Off",
"Display_Off",
"ProgressDiagnosisAI_1",
"ResultDiagnosisAI_Normal",
"Service_Off",
"SmartCoolClean_Off",
"ProgressSmartClean_0",
"OutDoorVentil_Off",
"FreezeAlarmSetting_Off",
"DesiredFreezeAlarm_240",
"OptionCode_529",
"ExtendOptionCode_16975",
"RacInfo_First",
"RacInfo_None_Second",
"ModelInfo_16K_BORA_VENT2",
"UpdateAllow_NotAllowed",
"EnergySaveIcon_Off",
"DurationOn_0",
"welcomecareElapsedTime_0",
"welcomecareThresholdTemp_0",
"welcomecareStartDate_0000",
"welcomecareEndDate_0000",
"welcomecareSeason_None"
]
}
},
{
"href": "/option/autoclean/vs/0",
"rep": {
"x.com.samsung.da.status": "Stop",
"x.com.samsung.da.settingStatus": "On",
"x.com.samsung.da.progress": "0",
"x.com.samsung.da.supportedStatus": [
"Start",
"SpeedClean",
"QuietClean",
"Stop"
],
"x.com.samsung.da.supportedSettingStatus": [
"On",
"SpeedClean",
"QuietClean",
"Off"
]
}
},
{
"href": "/otninformation/vs/0",
"rep": {
"x.com.samsung.da.target": "",
"x.com.samsung.da.newVersionAvailable": "false",
"x.com.samsung.da.newVersionNo": "00000000",
"x.com.samsung.da.currentVersionInfo": "00000000",
"otnStatus": "None",
"flashingProgress": "",
"otnTarget": "main",
"otnCompleteDate": "noHistory",
"otnList": [
{
"type": "WIFI",
"modelId": "AFA-KR-TP2-21-AF9X00",
"versions": [
"10260424"
],
"visVersion": "260424"
},
{
"type": "Micom",
"modelId": "04511023304110232941",
"versions": [
"21022400",
"21022200"
],
"visVersion": "210224"
},
{
"type": "Micom",
"modelId": "04511022974110229941",
"versions": [
"21033001",
"10000300"
],
"visVersion": "210330"
},
{
"type": "Micom",
"modelId": "045110230741FFFFFFFF",
"versions": [
"22050300",
"FFFFFFFF"
],
"visVersion": "220503"
}
]
}
},
{
"href": "/personality/presence/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "",
"x.com.samsung.da.deviceId": "**REDACTED**",
"x.com.samsung.da.value": ""
}
]
}
},
{
"href": "/power/0",
"rep": {
"value": false
}
},
{
"href": "/power/vs/0",
"rep": {
"x.com.samsung.da.power": "Off"
}
},
{
"href": "/realtimenotiforclient/vs/0",
"rep": {
"x.com.samsung.da.timeforshortnoti": "0",
"x.com.samsung.da.longnotisubscription": "true",
"x.com.samsung.da.periodicnotisubscription": "true"
}
},
{
"href": "/runn/vs/0",
"rep": {
"x.com.samsung.da.runningMode": 0
}
},
{
"href": "/subdevices/vs/0",
"rep": {
"x.com.samsung.da.subdeviceIdList": [
"6c2dff6d-ee5c-dad1-6a5e-000000000001"
]
}
},
{
"href": "/temperature/control/vs/0",
"rep": {
"x.com.samsung.da.increment": "1"
}
},
{
"href": "/temperature/current/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 32.0
}
},
{
"href": "/temperature/desired/0",
"rep": {
"range": [
18.0,
30.0
],
"units": "C",
"temperature": 24.0
}
},
{
"href": "/temperatures/vs/0",
"rep": {
"x.com.samsung.da.items": [
{
"x.com.samsung.da.id": "0",
"x.com.samsung.da.description": "Temperature",
"x.com.samsung.da.desired": "24.0",
"x.com.samsung.da.current": "32.0",
"x.com.samsung.da.maximum": "30",
"x.com.samsung.da.minimum": "18",
"x.com.samsung.da.increment": "1.0",
"x.com.samsung.da.unit": "Celsius"
}
]
}
},
{
"href": "/timezone/vs/0",
"rep": {
"timezoneid": "Asia/Seoul",
"offset": "+09:00",
"DST": "OFF"
}
},
{
"href": "/wind/direction/vs/0",
"rep": {
"x.com.samsung.da.modes": "NotSupported",
"x.com.samsung.da.supportedModes": [
"NotSupported"
]
}
},
{
"href": "/wind/strength/vs/0",
"rep": {
"x.com.samsung.da.modes": "2",
"x.com.samsung.da.supportedModes": [
"0",
"2",
"3",
"4"
],
"x.com.samsung.da.modesName": [
"Auto",
"Mid",
"High",
"Turbo"
]
}
}
],
"oic_res": [
{
"di": "**REDACTED**",
"links": [
{
"href": "/oic/sec/doxm",
"rt": [
"oic.r.doxm"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/oic/sec/pstat",
"rt": [
"oic.r.pstat"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/oic/d",
"rt": [
"oic.wk.d",
"oic.d.airconditioner"
],
"if": [
"oic.if.baseline",
"oic.if.r"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/oic/p",
"rt": [
"oic.wk.p"
],
"if": [
"oic.if.baseline",
"oic.if.r"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/hass/state/vs/0",
"rt": [
"x.com.samsung.da.hass.state"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/hass/command/vs/0",
"rt": [
"x.com.samsung.da.hass.command"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/transfer/chunk/vs/0",
"rt": [
"x.com.samsung.file.chunk"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/list/vs/0",
"rt": [
"x.com.samsung.file.list"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/file/transfer/vs/0",
"rt": [
"x.com.samsung.file.transfer"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0",
"rt": [
"x.com.samsung.file.list"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0",
"rt": [
"x.com.samsung.file.transfer"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 3,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/EasySetupResURI",
"rt": [
"oic.r.easysetup"
],
"if": [
"oic.if.baseline",
"oic.if.ll",
"oic.if.b"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/WiFiConfResURI",
"rt": [
"oic.wk.wifi"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/CoapCloudConfResURI",
"rt": [
"oic.wk.cloudserver"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/DevConfResURI",
"rt": [
"oic.wk.devconf"
],
"if": [
"oic.if.baseline"
],
"p": {
"bm": 1,
"sec": true,
"port": 49154,
"x.org.iotivity.tls": 0
}
},
{
"href": "/sec/provisioninginfo",
"rt": [
"x.com.samsung.provisioninginfo"
],
"if": [
"oic.if.baseline",
"oic.if.a"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
},
{
"href": "/sec/accesspointlist",
"rt": [
"x.com.samsung.accesspointlist"
],
"if": [
"oic.if.baseline",
"oic.if.s"
],
"p": {
"bm": 1,
"sec": false,
"x.org.iotivity.tcp": 0
}
}
]
}
],
"probes": {
"/6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0": {
"x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|60010610001500014600081200810000",
"x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K",
"x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000"
}
},
"seeds_note": "device0 and oic_res are real, captured from the reporter's issue #205 report. subdeviceIdList in device0's /subdevices/vs/0 is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] (HA's diagnostics download redacts it, matching redact.py's 'deviceid' substring rule, not because it's account data -- same restoration airconditioner_fac_bora_2in1_device.json documents for the same field), and every other value is otherwise exactly what HA's download redacts (serials/otnDUID etc.) against the same physical TP2X_FAC_BORA_21K unit as airconditioner_fac_bora_2in1_device.json -- same subdeviceIdList UUID, same wall subdevice. Filed specifically because, contrary to the assumption that fixture's seed batch was built on, this unit's /6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0 does NOT answer (subdevice_probes in the report shows it False), so the Collection-batch fallback this fixture exercises (registry.subdevices.enumerate_subdevices' per-href probe, issue #205) is what has to find the subdevice instead. The one probes entry, /6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0, is the same real capture already used in airconditioner_fac_bora_2in1_device.json's seed batch (issue #177 comment 5113518087) -- the only href ever actually confirmed to answer under this UUID prefix. No other /6c2dff6d-ee5c-dad1-6a5e-000000000001/* href has been confirmed live yet, so none are seeded here; this fixture's expected outcome is the candidate being found by the flat-href probe but then correctly held back by discover_partitioned's liveness gate (information alone binds no entity), matching where the real issue stands -- not a materialized climate entity, which would require guessing at unconfirmed hrefs. The probe's serialNum (\"TEST-SUBDEVICE-SERIAL-0000\") is a hand-placed placeholder for the real value, not HA's own redaction output -- same placeholder airconditioner_fac_bora_2in1_device.json uses for the identical field."
}
+1 -1
View File
@@ -805,5 +805,5 @@
}
]
},
"seeds_note": "device0 and oic_res are real, captured from jhkwon19's diagnostics dump for the TP2X_FAC_BORA_21K board (issue #177, the same physical device as tests/fixtures/airconditioner_fac_bora_device.json -- that fixture's redacted x.com.samsung.da.subdeviceIdList string is a deliberate, separate regression case and is NOT changed here). subdeviceIdList above is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] reported in the issue thread (redacted in the raw capture the same way serialNum/otnDUID/deviceId are -- it matches redact.py's 'deviceid' substring rule, not because it's actually account data). The seed's /information/vs/0 rep is REAL, verbatim from the reporter's live debug-panel read of /6c2dff6d-.../information/vs/0 (serial replaced with a placeholder) -- modelNum TP2X_FAC_BORA_RAC_21K confirms this is the wall-mounted room subdevice, distinct from the master's floor subdevice TP2X_FAC_BORA_21K. Every other href in this seed batch is CONSTRUCTED (never read from this subdevice) so the subdevice's climate entity has a resource surface to bind -- per DESIGN-177.md section 4, retrieval for this pattern is batch-only, and confirming '/6c2dff6d-.../device/0' actually returns a full batch (as opposed to just answering the information probe the reporter tried by hand) is listed as a follow-up, not something this fixture can attest to."
"seeds_note": "device0 and oic_res are real, captured from the reporter's diagnostics dump for the TP2X_FAC_BORA_21K board (issue #177, the same physical device as tests/fixtures/airconditioner_fac_bora_device.json -- that fixture's redacted x.com.samsung.da.subdeviceIdList string is a deliberate, separate regression case and is NOT changed here). subdeviceIdList above is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] reported in the issue thread (redacted in the raw capture the same way serialNum/otnDUID/deviceId are -- it matches redact.py's 'deviceid' substring rule, not because it's actually account data). The seed's /information/vs/0 rep is REAL, verbatim from the reporter's live debug-panel read of /6c2dff6d-.../information/vs/0 (serial replaced with a placeholder) -- modelNum TP2X_FAC_BORA_RAC_21K confirms this is the wall-mounted room subdevice, distinct from the master's floor subdevice TP2X_FAC_BORA_21K. Every other href in this seed batch is CONSTRUCTED (never read from this subdevice) so the subdevice's climate entity has a resource surface to bind -- per DESIGN-177.md section 4, retrieval for this pattern is batch-only, and confirming '/6c2dff6d-.../device/0' actually returns a full batch (as opposed to just answering the information probe the reporter tried by hand) is listed as a follow-up, not something this fixture can attest to."
}
@@ -0,0 +1,20 @@
{
"state_keys": [
"air_filter_status",
"air_filter_threshold",
"air_filter_usage",
"air_filter_usage_hours",
"alarm_code",
"auto_clean",
"beep",
"climate",
"current_temperature_c",
"diagnosis_status",
"energy_kwh",
"firmware_update",
"humidity",
"power_energy_kwh",
"power_watts",
"tropical_night_mode"
]
}
+15 -13
View File
@@ -3,10 +3,11 @@
two -- and that the legacy-board test (is_legacy_board/_legacy_airflow) is
evaluated per subdevice rather than once globally.
Uses HJcom's ARTIK051_DONGLE_FAC_18K fixture deliberately: it's a legacy
`/airflow/vs/<n>` board (no `/wind/*` at all) on *both* the master and its
materialized sibling, which is exactly the shape climate.py's own comments
warn is easy to get wrong if the canonical view leaks between subdevices.
Uses the issue #177 reporter's ARTIK051_DONGLE_FAC_18K fixture deliberately:
it's a legacy `/airflow/vs/<n>` board (no `/wind/*` at all) on *both* the
master and its materialized sibling, which is exactly the shape climate.py's
own comments warn is easy to get wrong if the canonical view leaks between
subdevices.
"""
from __future__ import annotations
@@ -71,15 +72,16 @@ async def test_subdevice_climate_reads_its_own_power_state(climates):
async def test_legacy_board_test_is_evaluated_per_subdevice(climates):
"""HJcom's board has no /wind/* resources at all on *either* subdevice --
is_legacy_board(self._resources) must independently evaluate True for
the master's own canonical view and for the subdevice's own canonical
view. If is_legacy_board were fed the raw, unpartitioned snapshot (or
if canonical_view leaked one subdevice's hrefs into the other's), this
wouldn't distinguish "this subdevice is legacy" from "some subdevice on
this connection is legacy" -- and a future board with one legacy + one
modern subdevice sharing a connection would silently read the wrong
fan/swing channel on one side."""
"""The reporter's board has no /wind/* resources at all on *either*
subdevice -- is_legacy_board(self._resources) must independently
evaluate True for the master's own canonical view and for the
subdevice's own canonical view. If is_legacy_board were fed the raw,
unpartitioned snapshot (or if canonical_view leaked one subdevice's
hrefs into the other's), this wouldn't distinguish "this subdevice is
legacy" from "some subdevice on this connection is legacy" -- and a
future board with one legacy + one modern subdevice sharing a
connection would silently read the wrong fan/swing channel on one
side."""
main, sub1 = climates[None], climates['1']
assert main._legacy_airflow() != {}
assert sub1._legacy_airflow() != {}
+22
View File
@@ -105,3 +105,25 @@ async def test_diagnostics_reports_prefixed_subdevice(
# not the master's TP2X_FAC_BORA_21K.
assert diag['subdevices'][0]['model'].startswith('TP2X_FAC_BORA_RAC_21K')
assert diag['subdevices_skipped'] == []
async def test_diagnostics_reports_flat_hrefs_for_skipped_prefixed_candidate(
hass: HomeAssistant, enable_custom_integrations,
) -> None:
"""issue #205: a prefixed candidate found through the per-href flat
fallback (no working /<uuid>/device/0 Collection) has no meaningful
seed_path -- diagnostics reports None there instead of the misleading
bare "/" an empty tuple would otherwise join to, and lists the actual
hrefs the fallback confirmed instead."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_205_flat')
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
assert diag['subdevices'] == []
assert len(diag['subdevices_skipped']) == 1
skipped = diag['subdevices_skipped'][0]
assert skipped['kind'] == 'prefixed'
assert skipped['seed_path'] is None
assert skipped['flat_hrefs'] == ['/information/vs/0']
+26 -2
View File
@@ -903,7 +903,7 @@ def _new_subdevice_aware_state_keys(name):
def test_registry_reproduces_golden_state_keys_for_airconditioner_artik051_dongle_fac_18k():
"""HJcom's ARTIK051_DONGLE_FAC_18K (issue #177, Pattern A -- indexed
"""The reporter's ARTIK051_DONGLE_FAC_18K (issue #177, Pattern A -- indexed
siblings): a real v0.16.0 dump with a genuine second indoor subdevice at
`/device/1` (subdevice1_-prefixed keys below) and an unused SmartThings
slot at `/device/2` that answers its seed but never produces a
@@ -942,7 +942,7 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_cac():
def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_2in1():
"""jhkwon19's TP2X_FAC_BORA_21K (issue #177, Pattern B -- UUID-prefixed
"""The reporter's TP2X_FAC_BORA_21K (issue #177, Pattern B -- UUID-prefixed
tree): device0/oic_res are real; the wall-mounted subdevice's own
/information/vs/0 is real (confirmed live by the reporter), the rest of
its seed tree is constructed (see the fixture's own seeds_note) -- just
@@ -961,6 +961,30 @@ def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_2in1(
)
def test_registry_reproduces_golden_state_keys_for_airconditioner_fac_bora_205_flat():
"""The same reporter's same physical TP2X_FAC_BORA_21K unit as the _2in1
fixture above, but a later capture (issue #205) where /<uuid>/device/0 doesn't
answer -- contrary to what that fixture's own seed batch assumed the
Collection endpoint would do. device0/oic_res are real; the only
UUID-prefixed data is the one href ever actually confirmed live
(/information/vs/0, same real capture the _2in1 fixture uses), fed
through registry.subdevices.enumerate_subdevices' per-href flat
fallback instead of a Collection batch. /information/vs/0 alone binds
no entity, so the candidate is found but never materializes -- this
golden has no `subdevice_...`-prefixed keys at all, same shape as
tests/fixtures/golden/airconditioner_fac_bora.json, which is the point:
a device whose sibling can't yet be confirmed live must regress to
exactly the master-only state, never a phantom or partial subdevice."""
name = 'airconditioner_fac_bora_205_flat'
golden = json.loads((GOLDEN / f'{name}.json').read_text())
state_keys = _new_subdevice_aware_state_keys(name)
assert set(state_keys) == set(golden['state_keys']), (
f"state_keys mismatch:\n"
f" extra: {sorted(set(state_keys) - set(golden['state_keys']))}\n"
f" missing: {sorted(set(golden['state_keys']) - set(state_keys))}"
)
def test_registry_reproduces_golden_state_keys_for_air_purifier_avt_ww():
"""AVT-WW-TP1-23-AXX500 (issue #190) -- next-gen BESPOKE Cube Air board;
reports device_type 'unknown' with empty oneUiVersion because 'VTWW' as a
+245 -13
View File
@@ -36,12 +36,14 @@ def _coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
return LocalThingsCoordinator(hass, entry)
async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
async def _discover_with(
coordinator: LocalThingsCoordinator, resources: dict, oic_res, seeds: dict,
) -> None:
"""Run the same two-step sequence _async_update_data's first cycle does
(enumerate, then discover) against fixture data, without the polling/
reconnect machinery around it -- see coordinator.py's
_enumerate_subdevices_blocking/_run_discovery."""
resources, oic_res, seeds = _load_device_full(name)
(enumerate, then discover) against arbitrary resources/oic_res/seeds,
without the polling/reconnect machinery around it -- see coordinator.py's
_enumerate_subdevices_blocking/_run_discovery. `_discover` below is the
fixture-file-backed convenience wrapper most tests want."""
coordinator._session = FakeCoapSession(seeds)
# _connect_session (skipped here -- the session is pre-set) is what
# normally populates _identity via read_identity; set it directly with
@@ -67,6 +69,12 @@ async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
coordinator._observe.apply(href, rep, source='poll')
async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
"""Fixture-file-backed convenience wrapper around _discover_with."""
resources, oic_res, seeds = _load_device_full(name)
await _discover_with(coordinator, resources, oic_res, seeds)
def _climate_bound(coordinator, subdevice_key: str):
from custom_components.localthings.registry.subdevices import MAIN
for b in coordinator.bound:
@@ -79,10 +87,10 @@ def _climate_bound(coordinator, subdevice_key: str):
# ---------------------------------------------------------------------------
# HJcom -- ARTIK051_DONGLE_FAC_18K, Pattern A (indexed siblings)
# Pattern A reporter -- ARTIK051_DONGLE_FAC_18K, indexed siblings
# ---------------------------------------------------------------------------
async def test_hjcom_materializes_master_and_bedroom_subdevice(hass: HomeAssistant):
async def test_pattern_a_materializes_master_and_bedroom_subdevice(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
@@ -96,11 +104,11 @@ async def test_hjcom_materializes_master_and_bedroom_subdevice(hass: HomeAssista
assert sub1_climate.href == '/mode/vs/1'
async def test_hjcom_device_2_produces_no_entities_at_all(hass: HomeAssistant):
"""HJcom's /device/2 is the unused SmartThings slot (DESIGN-177.md
section 4): it answers its seed with a full-shaped batch, but every
climate-state rep on it is empty. It must be recorded as skipped, not
materialized, and must contribute zero bound entities."""
async def test_pattern_a_device_2_produces_no_entities_at_all(hass: HomeAssistant):
"""The reporter's /device/2 is the unused SmartThings slot
(DESIGN-177.md section 4): it answers its seed with a full-shaped
batch, but every climate-state rep on it is empty. It must be recorded
as skipped, not materialized, and must contribute zero bound entities."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
@@ -130,7 +138,7 @@ async def test_hjcom_sub1_device_info_links_via_device_to_master(hass: HomeAssis
# ---------------------------------------------------------------------------
# jhkwon19 -- TP2X_FAC_BORA_21K, Pattern B (UUID-prefixed tree)
# Issue #177's Pattern B reporter -- TP2X_FAC_BORA_21K, UUID-prefixed tree
# ---------------------------------------------------------------------------
_SUB_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
@@ -182,6 +190,230 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis
)
# ---------------------------------------------------------------------------
# Same reporter and physical unit/UUID again -- issue #205, but this
# time /<uuid>/device/0 doesn't answer. Exercises enumerate_subdevices'
# per-href flat-probe fallback (registry/subdevices.py) against a real
# capture instead of the synthetic sessions test_subdevices.py uses.
# ---------------------------------------------------------------------------
async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back(
hass: HomeAssistant,
):
"""The fixture's only seeded UUID-prefixed href is /information/vs/0 --
the one href ever actually confirmed live under this prefix (issue #177
comment 5113518087) -- since nothing else has been confirmed yet for
this unit. That's enough for the flat-probe fallback to find a
candidate, but /information/vs/0 binds no entity on its own (it's only
ever read for device-type resolution, never bound as a capability), so
discover_partitioned's liveness gate correctly holds it back rather than
materializing a phantom climate card from unconfirmed hrefs. This is the
honest current state of issue #205, not a guess at its resolution."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_205_flat')
assert coordinator.subdevices == []
assert [s.subdevice.key for s in coordinator._skipped_subdevices] == [_SUB_UUID]
skipped = coordinator._skipped_subdevices[0].subdevice
assert skipped.kind == 'prefixed'
assert skipped.seed_path == ()
assert skipped.flat_hrefs == ('/information/vs/0',)
assert coordinator._subdevice_probes[f'/{_SUB_UUID}/device/0'] is False
assert coordinator._subdevice_probes[f'/{_SUB_UUID}/information/vs/0'] is True
# Confirms the master itself is completely unaffected by its sibling's
# Collection endpoint not answering -- same guarantee every other
# subdevice test in this file relies on.
assert _climate_bound(coordinator, None) is not None
async def test_flat_subdevice_materializes_and_repolls_end_to_end(hass: HomeAssistant):
"""Synthetic (not a real capture, unlike the fixture-driven test above) --
exercises the one path nothing else covers: a flat-mode prefixed
subdevice with *enough* confirmed hrefs to actually pass
discover_partitioned's liveness gate and materialize a real climate
entity, then a subsequent _poll_subdevice_seed re-poll refreshing its
state all the way through to canonical_resources -- the path a real
resolution of issue #205 (once more hrefs are confirmed live for some
unit) would actually need.
Also pins _poll_subdevice_flat_hrefs' hot/warm skip: climate-critical
hrefs (power/mode/temperature) land on the warm tier by discovery's own
rules, so they're already kept fresh every few seconds by _run_subpolls
-- re-fetching them again on this once-per-summary-poll pass would only
add GETs, not freshness, so they're the ones this method must skip.
/option/autoclean/vs/0 is cold-tier and is what actually needs this
path."""
resources, oic_res, _real_seeds = _load_device_full('airconditioner_fac_bora_2in1')
seeds = {
# No (_SUB_UUID, 'device', '0') entry -- forces the flat fallback,
# same as the real issue #205 capture above, but this time with
# enough hrefs answering to actually materialize. power/mode/
# temperature values copied verbatim from that fixture's own (real)
# Collection-batch seed, just served individually instead of
# batched, to isolate "does flat mode produce the same result as
# Collection mode" as the only variable.
f'/{_SUB_UUID}/power/vs/0': {'x.com.samsung.da.power': 'On'},
f'/{_SUB_UUID}/mode/vs/0': {
'x.com.samsung.da.supportedModes': ['Cool', 'Dry', 'Wind', 'Auto'],
'x.com.samsung.da.modes': ['Cool'],
'x.com.samsung.da.options': [],
},
f'/{_SUB_UUID}/temperature/current/0': {
'range': [18.0, 30.0], 'units': 'C', 'temperature': 26.0,
},
f'/{_SUB_UUID}/temperature/desired/0': {
'range': [18.0, 30.0], 'units': 'C', 'temperature': 24.0,
},
# Cold-tier -- not covered by _run_subpolls, so this is the href
# that actually depends on _poll_subdevice_flat_hrefs to ever
# refresh at all.
f'/{_SUB_UUID}/option/autoclean/vs/0': {
'x.com.samsung.da.settingStatus': 'Off',
},
}
coordinator = _coordinator(hass)
await _discover_with(coordinator, resources, oic_res, seeds)
assert [su.key for su in coordinator.subdevices] == [_SUB_UUID]
subdevice = coordinator.subdevices[0]
assert subdevice.seed_path == ()
assert subdevice.flat_hrefs != ()
sub_climate = _climate_bound(coordinator, _SUB_UUID)
assert sub_climate is not None
# Re-poll: a fresh reading under the prefix should reach
# canonical_resources through _poll_subdevice_seed's flat-mode branch,
# not just sit frozen at the one-time enumeration snapshot.
coordinator._session.seeds[f'/{_SUB_UUID}/temperature/current/0'] = {
'range': [18.0, 30.0], 'units': 'C', 'temperature': 27.5,
}
coordinator._session.seeds[f'/{_SUB_UUID}/option/autoclean/vs/0'] = {
'x.com.samsung.da.settingStatus': 'On',
}
refreshed = coordinator._poll_subdevice_seed(subdevice)
# The warm-tier temperature href is skipped here -- already covered by
# _run_subpolls at a faster cadence -- so it does NOT show up refreshed
# through this path.
assert f'/{_SUB_UUID}/temperature/current/0' not in refreshed
assert refreshed == {
f'/{_SUB_UUID}/option/autoclean/vs/0': {'x.com.samsung.da.settingStatus': 'On'},
}
for href, rep in refreshed.items():
coordinator._observe.apply(href, rep, source='poll')
res = coordinator.canonical_resources(subdevice)
assert res['/option/autoclean/vs/0']['x.com.samsung.da.settingStatus'] == 'On'
# Confirms the skip is about redundant re-fetching, not stale data --
# the warm-tier value from initial discovery is still there, untouched.
assert res['/temperature/current/0']['temperature'] == 26.0
class _FakeCollectionSession:
"""Minimal session that only ever answers a Collection GET -- used to
prove the flat-mode re-poll path (issue #205) is only taken when
flat_hrefs is actually set, not whenever seed_path happens to be
unusual."""
def __init__(self, table):
self.table = table
self.calls: list[tuple[str, ...]] = []
def get(self, path, timeout=10.0):
self.calls.append(tuple(path))
body = self.table.get(tuple(path))
if body is None:
return 0x84, b''
import cbor2
return 0x45, cbor2.dumps(body)
def pace(self):
pass
def test_poll_subdevice_seed_collection_mode_unaffected_by_flat_fallback(
hass: HomeAssistant,
):
"""A subdevice with a working Collection endpoint (flat_hrefs empty)
keeps re-polling it with a single Collection GET, unchanged by issue
#205's fallback."""
from custom_components.localthings.registry.subdevices import Subdevice
coordinator = _coordinator(hass)
devcol_rep = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']}
sess = _FakeCollectionSession({
(_SUB_UUID, 'device', '0'): [
devcol_rep, {'href': '/mode/vs/0', 'rep': {'mode': 'cool'}},
],
})
coordinator._session = sess
subdevice = Subdevice(kind='prefixed', key=_SUB_UUID, seed_path=(_SUB_UUID, 'device', '0'))
result = coordinator._poll_subdevice_seed(subdevice)
assert result == {f'/{_SUB_UUID}/mode/vs/0': {'mode': 'cool'}}
assert sess.calls == [(_SUB_UUID, 'device', '0')]
def test_poll_subdevice_seed_flat_mode_polls_each_href_individually(
hass: HomeAssistant,
):
"""A flat-mode subdevice (issue #205) has no Collection to batch-refresh
through, so each confirmed href is GET individually under the prefix on
every re-poll -- a href that stops answering just drops out, same
"never fail the master's poll over a sibling" posture as the Collection
path."""
from custom_components.localthings.registry.subdevices import Subdevice
coordinator = _coordinator(hass)
sess = _FakeCollectionSession({
(_SUB_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
# (_SUB_UUID, 'power', 'vs', '0') deliberately absent -> drops out.
})
coordinator._session = sess
subdevice = Subdevice(
kind='prefixed', key=_SUB_UUID, seed_path=(),
flat_hrefs=('/mode/vs/0', '/power/vs/0'),
)
result = coordinator._poll_subdevice_seed(subdevice)
assert result == {f'/{_SUB_UUID}/mode/vs/0': {'mode': 'cool'}}
assert sess.calls == [
(_SUB_UUID, 'mode', 'vs', '0'), (_SUB_UUID, 'power', 'vs', '0'),
]
def test_poll_subdevice_seed_flat_mode_skips_hrefs_covered_by_hot_warm_subpolls(
hass: HomeAssistant,
):
"""A flat href already in the hot/warm sub-poll tiers is refreshed every
few seconds by _run_subpolls -- re-fetching it again on this
once-per-summary-poll pass would only add GETs, not freshness, so
_poll_subdevice_flat_hrefs must not even attempt it."""
from custom_components.localthings.registry.subdevices import Subdevice
coordinator = _coordinator(hass)
sess = _FakeCollectionSession({
(_SUB_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
(_SUB_UUID, 'power', 'vs', '0'): {'power': 'On'},
})
coordinator._session = sess
coordinator._warm_hrefs = [f'/{_SUB_UUID}/mode/vs/0']
subdevice = Subdevice(
kind='prefixed', key=_SUB_UUID, seed_path=(),
flat_hrefs=('/mode/vs/0', '/power/vs/0'),
)
result = coordinator._poll_subdevice_seed(subdevice)
assert result == {f'/{_SUB_UUID}/power/vs/0': {'power': 'On'}}
assert sess.calls == [(_SUB_UUID, 'power', 'vs', '0')]
async def test_multidevice_probe_never_reaches_discovery_or_the_cache(
hass: HomeAssistant,
):
+110 -10
View File
@@ -206,6 +206,9 @@ class _FakeSession:
return 0x84, b''
return 0x45, cbor2.dumps(body)
def pace(self):
pass
_DEVCOL_REP = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']}
@@ -264,6 +267,102 @@ def test_enumerate_prefixed_from_subdevice_id_list():
assert extra == {f'/{_UUID}/mode/vs/0': {'m': 'cool'}}
def test_enumerate_prefixed_falls_back_to_flat_hrefs_when_device0_collection_is_empty():
"""issue #205: not every prefixed subdevice exposes its own
/<uuid>/device/0 Collection -- not even TP2X_FAC_BORA_21K, the board
this pattern was built against, always does. When it doesn't,
enumeration probes every href the master itself answered this cycle,
individually, under the UUID prefix, and keeps whichever ones answer."""
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
'/mode/vs/0': {'m': 'cool'},
'/power/vs/0': {'p': 'On'},
}
sess = _FakeSession({
# (_UUID, 'device', '0') deliberately absent -> Collection probe fails.
(_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
# (_UUID, 'power', 'vs', '0') deliberately absent -> drops out.
})
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
assert len(subdevices) == 1
subdevice = subdevices[0]
assert (subdevice.kind, subdevice.key) == ('prefixed', _UUID)
assert subdevice.seed_path == ()
assert subdevice.flat_hrefs == ('/mode/vs/0',)
assert extra == {f'/{_UUID}/mode/vs/0': {'mode': 'cool'}}
def test_enumerate_prefixed_flat_fallback_materializes_nothing_when_no_href_answers():
"""Same posture as every other candidate check in this module: nothing
answering means no candidate, not a crash."""
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
'/mode/vs/0': {'m': 'cool'},
}
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried():
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
'/mode/vs/0': {'m': 'cool'},
}
sess = _FakeSession({
(_UUID, 'mode', 'vs', '0'): {'mode': 'cool'},
})
probes: dict[str, bool] = {}
enumerate_subdevices(sess, resources, oic_res_links=[], probe_log=probes.__setitem__)
assert probes[f'/{_UUID}/device/0'] is False
assert probes[f'/{_UUID}/mode/vs/0'] is True
def test_enumerate_prefixed_flat_fallback_with_no_master_hrefs_to_probe_is_a_no_op():
"""The master itself having nothing but /subdevices/vs/0 in its own
resources this cycle (e.g. a very first, mostly-empty poll) must not
crash the fallback loop -- the only href in `resources` is
/subdevices/vs/0 itself, which the fake session doesn't answer under
the prefix either, so nothing materializes."""
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]},
}
subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[])
assert subdevices == []
assert extra == {}
def test_enumerate_prefixed_flat_fallback_does_not_cross_contaminate_a_second_uuid():
"""Two prefixed candidates in the same subdeviceIdList, one whose
Collection endpoint works and one that needs the flat fallback -- each
must end up with only its own hrefs, no bleed between them."""
uuid_a, uuid_b = _UUID, '11111111-1111-1111-1111-111111111111'
resources = {
'/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [uuid_a, uuid_b]},
'/mode/vs/0': {'m': 'cool'},
}
sess = _FakeSession({
(uuid_a, 'device', '0'): [
_DEVCOL_REP, {'href': '/mode/vs/0', 'rep': {'m': 'a-collection'}},
],
# uuid_b's Collection deliberately absent -> falls back to the flat
# per-href probe.
(uuid_b, 'mode', 'vs', '0'): {'m': 'b-flat'},
})
subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[])
by_key = {u.key: u for u in subdevices}
assert set(by_key) == {uuid_a, uuid_b}
assert by_key[uuid_a].seed_path == (uuid_a, 'device', '0')
assert by_key[uuid_a].flat_hrefs == ()
assert by_key[uuid_b].seed_path == ()
assert by_key[uuid_b].flat_hrefs == ('/mode/vs/0',)
assert extra == {
f'/{uuid_a}/mode/vs/0': {'m': 'a-collection'},
f'/{uuid_b}/mode/vs/0': {'m': 'b-flat'},
}
def test_enumerate_prefixed_tolerates_redacted_string_id_list():
"""subdeviceIdList matches redact.py's 'deviceid' substring rule, and the
real airconditioner_fac_bora_device.json fixture carries the literal
@@ -284,10 +383,10 @@ def test_enumerate_no_subdevices_resource_at_all():
def test_enumerate_indexed_materializes_candidate_regardless_of_content():
"""enumerate_subdevices itself has no way to tell a real sibling from an
unused slot that merely answers the same shape (HJcom's /device/2) --
that's discover_partitioned's job (see its own tests below). A
same-shaped batch of otherwise-empty reps is still returned as a
candidate here."""
unused slot that merely answers the same shape (the reporter's
/device/2) -- that's discover_partitioned's job (see its own tests
below). A same-shaped batch of otherwise-empty reps is still returned
as a candidate here."""
oic_res = [{'di': 'a', 'links': [{'href': '/device/2'}]}]
sess = _FakeSession({
('device', '2'): [_DEVCOL_REP, {'href': '/power/vs/2', 'rep': {}},
@@ -425,8 +524,8 @@ def test_discover_partitioned_main_pass_excludes_subdevice_hrefs_from_unbound():
def test_discover_partitioned_subdevice_resolves_its_own_registry():
"""A subdevice reporting its own /information/vs/0 resolves its own
device type (jhkwon19's wall subdevice: TP2X_FAC_BORA_RAC_21K -> RAC ->
airconditioner) independent of the master's."""
device type (issue #177's real wall subdevice: TP2X_FAC_BORA_RAC_21K ->
RAC -> airconditioner) independent of the master's."""
main_cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m', field='x'),))
sub_cap = Capability(href='/mode/vs/0', entities=(BinarySensorDesc(key='m2', field='x'),))
main_reg = _FakeRegistry('main_type', {'/mode/vs/0': [main_cap]})
@@ -499,13 +598,14 @@ def test_discover_partitioned_no_subdevices_matches_plain_discover():
# ---------------------------------------------------------------------------
# discover_partitioned's materialization gate: a candidate is only kept if
# it produced at least one *primary* (no entity_category) bound entity whose
# flattened value isn't None. This is what tells HJcom's real /device/1
# sibling apart from the unused /device/2 slot that answers the same shape.
# flattened value isn't None. This is what tells the reporter's real
# /device/1 sibling apart from the unused /device/2 slot that answers the
# same shape.
# ---------------------------------------------------------------------------
def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
"""A candidate whose only populated entity is diagnostic-category
doesn't count -- exactly HJcom's /device/2 shape (an alarm_code
doesn't count -- exactly the reporter's /device/2 shape (an alarm_code
sensor reading something even though the subdevice itself is empty)."""
diag_cap = Capability(
href='/alarms/vs/0',
@@ -533,7 +633,7 @@ def test_discover_partitioned_skips_candidate_with_no_live_primary_entity():
def test_discover_partitioned_materializes_candidate_with_live_primary_entity():
"""A candidate with a populated *primary* (no entity_category) entity
is materialized, even alongside an all-empty diagnostic sibling href --
exactly HJcom's /device/1 shape."""
exactly the reporter's /device/1 shape."""
climate_cap = Capability(
href='/mode/vs/0',
entities=(BinarySensorDesc(key='mode', field='m'),), # no entity_category -> primary