feat: discover UUID-prefixed subdevices advertised only via /oic/res (Pattern C, #241)

AWM-WW-AID-26-ONEBODY (washer+dryer combo) reports numofsubdevice='2' on
/multidevice/vs/0 but carries no /subdevices/vs/0 (no subdeviceIdList --
Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's). The
washer subdevice's UUID appears only as the path prefix of the
x.com.samsung.da.multidevice link in /oic/res; GET /<uuid>/device/0
answers the washer's own full Collection batch (model ..._WF80H vs the
master's ..._DV80H27H).

Treat every UUID path prefix seen in /oic/res as a prefixed-subdevice
candidate (minus ones subdeviceIdList already named), probed with the
same tolerated-404 seed RETRIEVE as Pattern B -- the shared body is
factored into _probe_prefixed. discover_partitioned's entity-level
liveness gate still decides materialization, so a UUID link with no live
sibling behind it contributes nothing.

Fixture is a live capture from the reporting board (serials/MACs/di
scrubbed); tests cover discovery, probe hygiene, washer-side entity
binding, and that the master's own entity set is unchanged.
This commit is contained in:
Hyunook
2026-08-02 21:40:34 +00:00
committed by Marc Billow
parent e309d3e7b8
commit 6bcf8f3bec
3 changed files with 1519 additions and 14 deletions
@@ -36,7 +36,18 @@ 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
Pattern C -- UUID prefix advertised only via `/oic/res`
(`AWM-WW-AID-26-ONEBODY` washer+dryer combo, issue #241). The board answers
`numofsubdevice='2'` on `/multidevice/vs/0` but has no `/subdevices/vs/0`
(no `subdeviceIdList`) and 4.04s `/device/1`/`/device/2`; the washer
subdevice's UUID appears nowhere except as the path prefix of the
`x.com.samsung.da.multidevice` link in `/oic/res`, and
`GET /<uuid>/device/0` answers the washer's own full Collection batch
(model `..._WF80H` vs. the master's `..._DV80H27H`) -- Pattern B's
transform with the UUID sourced from the link prefix instead of
`subdeviceIdList`.
All of these 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 (the Pattern A
@@ -88,6 +99,12 @@ from .batch import parse_device0_batch
_INDEXED_HREF_RE = re.compile(r'^/device/(\d+)$')
# A UUID as the first path segment of an /oic/res link href -- Pattern C's
# discovery signal (issue #241): a subdevice tree whose UUID is advertised
# nowhere except as this prefix (no subdeviceIdList, no /device/<n>).
_UUID_PREFIX_RE = re.compile(
r'^/([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})/')
# Speculative /device/<n> siblings probed when /oic/res doesn't reveal a
# second logical subdevice's Collection on this board (moved here from
# identity.py, issue #177 -- see enumerate_subdevices' docstring for why: the
@@ -338,17 +355,11 @@ def enumerate_subdevices(
if probe_log is not None:
probe_log(seed_href, bool(batch))
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
'x.com.samsung.da.subdeviceIdList')
# Tolerate anything but a list of strings -- this field is redaction-prone
# (it matches the 'deviceid' substring rule in redact.py) and the existing
# airconditioner_fac_bora fixture carries the literal string
# '**REDACTED**'/'REDACTED' there. That must yield zero 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 []
for sub_id in sorted(i for i in ids if isinstance(i, str) and i):
def _probe_prefixed(sub_id: str) -> None:
"""Materialize one UUID-prefixed subdevice candidate -- shared by
Pattern B (ids from subdeviceIdList) and Pattern C (ids from
/oic/res link prefixes) below, which differ only in where the UUID
came from."""
seed = (sub_id, 'device', '0')
batch = _get_batch(sess, seed)
_probed(_seed_href(seed), batch)
@@ -356,7 +367,7 @@ def enumerate_subdevices(
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
continue
return
# 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
@@ -393,12 +404,50 @@ def enumerate_subdevices(
flat_hrefs.append(href)
fetched[actual] = rep
if not flat_hrefs:
continue
return
subdevices.append(Subdevice(
kind='prefixed', key=sub_id, seed_path=(),
flat_hrefs=tuple(flat_hrefs),
))
# --- Pattern B: UUID-prefixed tree (TP2X_FAC_BORA_21K) ------------------
raw_ids = (resources.get('/subdevices/vs/0') or {}).get(
'x.com.samsung.da.subdeviceIdList')
# Tolerate anything but a list of strings -- this field is redaction-prone
# (it matches the 'deviceid' substring rule in redact.py) and the existing
# airconditioner_fac_bora fixture carries the literal string
# '**REDACTED**'/'REDACTED' there. That must yield zero 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 []
listed = sorted(i for i in ids if isinstance(i, str) and i)
for sub_id in listed:
_probe_prefixed(sub_id)
# --- Pattern C: UUID prefix advertised only via /oic/res ----------------
# (AWM-WW-AID-26-ONEBODY washer+dryer combo, issue #241.) A third
# multidevice shape: the board answers numofsubdevice='2' on
# /multidevice/vs/0, but carries no /subdevices/vs/0 (no subdeviceIdList
# -- Pattern B's signal) and 4.04s /device/1 and /device/2 (Pattern A's).
# The only trace of the sibling is a UUID-prefixed link in /oic/res
# itself: the x.com.samsung.da.multidevice link,
# '/<uuid>/multidevice/vs/0' on the reporting board. Its washer tree
# answers a full Collection at /<uuid>/device/0, exactly Pattern B's
# transform -- so treat every UUID path prefix seen in /oic/res as a
# prefixed-subdevice candidate (minus ones subdeviceIdList already
# named). Probing is the same tolerated-404 RETRIEVE as everything else
# here, and discover_partitioned's entity-level liveness gate still
# decides materialization, so a board that advertises a UUID link
# without a live sibling behind it contributes nothing.
linked = sorted({
m.group(1)
for link in _iter_oic_res_hrefs(oic_res_links)
for m in [_UUID_PREFIX_RE.match(link.get('href', ''))]
if m
} - set(listed))
for sub_id in linked:
_probe_prefixed(sub_id)
# --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) --------------
indices = sorted({
int(m.group(1))
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
"""Pattern C subdevice discovery: AWM-WW-AID-26-ONEBODY washer+dryer combo
(issue #241).
The fixture is a live capture from the reporting board (see its
`seeds_note`): the master tree is the dryer (`oic.d.dryer`,
`..._DV80H27H`), and a complete washer tree (`..._WF80H`) answers at
`/<uuid>/device/0`, where the UUID appears *only* as the path prefix of the
`x.com.samsung.da.multidevice` link in `/oic/res` -- no `subdeviceIdList`
(Pattern B's signal), no `/device/<n>` sibling (Pattern A's).
"""
from tests.conftest import FakeCoapSession, _discover_full, _load_device_full
FIXTURE = 'washer_dryer_onebody_awm'
WASHER_UUID = '58b7d338-15c5-97d3-b562-000000000001'
def _discover():
resources, oic_res, seeds = _load_device_full(FIXTURE)
return _discover_full(resources, oic_res, seeds)
def test_washer_subdevice_materializes_from_oic_res_uuid_link():
bound, materialized, skipped, full_resources, device_type_name = _discover()
assert [s.key for s in materialized] == [WASHER_UUID]
sub = materialized[0]
assert sub.kind == 'prefixed'
assert sub.seed_path == (WASHER_UUID, 'device', '0')
# Collection mode, not the issue-#205 flat fallback.
assert sub.flat_hrefs == ()
def test_washer_subdevice_probe_only_fires_for_the_advertised_uuid():
"""Pattern C must not invent candidates: exactly one prefixed seed is
probed (the UUID from the /oic/res multidevice link), alongside the
bounded speculative Pattern A probes this board 4.04s."""
from custom_components.localthings.registry.subdevices import (
enumerate_subdevices,
)
resources, oic_res, seeds = _load_device_full(FIXTURE)
probes: dict[str, bool] = {}
sess = FakeCoapSession(seeds)
candidates, _extra = enumerate_subdevices(
sess, resources, oic_res, probe_log=probes.__setitem__,
)
assert probes[f'/{WASHER_UUID}/device/0'] is True
assert probes['/device/1'] is False
assert probes['/device/2'] is False
# No flat-fallback flood: the seed Collection answered, so no per-href
# probes under the prefix beyond the seed itself.
prefixed_probes = [h for h in probes if h.startswith(f'/{WASHER_UUID}/')]
assert prefixed_probes == [f'/{WASHER_UUID}/device/0']
assert [c.key for c in candidates] == [WASHER_UUID]
def test_washer_subdevice_binds_its_own_primary_entities():
"""The washer tree carries its own /operational/state and /power -- the
liveness gate must see live primary entities, and the bound set must
carry washer-side keys under the subdevice key prefix."""
bound, materialized, _skipped, _full, _name = _discover()
sub = materialized[0]
sub_keys = {b.desc.key for b in bound if b.subdevice.key == sub.key}
assert sub_keys, 'washer subdevice bound no entities at all'
# Its own operational state and its own power switch/sensor -- the two
# things SmartThings cloud splits this unit over.
assert any('machine_state' in k for k in sub_keys), sub_keys
assert any('power' in k for k in sub_keys), sub_keys
def test_master_dryer_entities_unchanged_by_washer_discovery():
"""Additive only: the master (dryer) side must keep binding the same
entity keys with and without the washer candidate materializing."""
resources, _oic_res, _seeds = _load_device_full(FIXTURE)
# Without any subdevices (oic_res withheld, seeds empty -> washer never
# discovered).
bound_solo, materialized_solo, _s, _f, _n = _discover_full(
resources, [], {},
)
assert materialized_solo == []
solo_keys = {(b.href, b.desc.key) for b in bound_solo}
bound, materialized, _s2, _f2, _n2 = _discover()
sub = materialized[0]
main_keys = {
(b.href, b.desc.key) for b in bound if b.subdevice.key != sub.key
}
assert main_keys == solo_keys