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:
@@ -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))
|
||||
|
||||
+1364
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
Reference in New Issue
Block a user