Samsung 2-in-1 air conditioners put more than one logical indoor unit behind a single IP and a single DTLS session. Only the unit the config entry was set up against was ever discovered; the second one -- a whole physical appliance the user can see in SmartThings -- had no entities at all. Two reporters turned out to have two different mechanisms: ARTIK051_DONGLE_FAC_18K -- indexed siblings. /oic/res registers the whole tree discoverable and lists three complete parallel resource sets whose trailing path segment is the index (/mode/vs/0, /mode/vs/1, ...), on OCF-standard and vendor hrefs alike. /device/0's batch carries only the index-0 hrefs, so a sibling is reachable only through its own /device/<n> collection. TP2X_FAC_BORA_21K -- UUID-prefixed tree. /oic/res hides the appliance tree entirely (which is why a direct /device/1 probe returns nothing on this board). /subdevices/vs/0 carries subdeviceIdList instead, and that UUID appears as a literal href prefix; /<uuid>/information/vs/0 was confirmed live to return the wall unit's own model and serial (TP2X_FAC_BORA_RAC_21K) against the master's TP2X_FAC_BORA_21K. The detection signals don't overlap on either board, so no disambiguation is needed -- enumeration checks both and takes what answers. Both patterns are the same thing underneath: a logical unit is a seed collection path to poll plus an href transform between the canonical href the registry knows and the actual on-the-wire href. That is the whole abstraction (SubUnit), applied at four boundaries -- discovery, the coordinator, the adapter, and the platforms. Capabilities, the registry and the climate composite stay written against canonical hrefs and are untouched. Uniqueness comes from a key_prefix inside the flattened state key, so the master unit's keys are byte-identical to every release before this and every existing golden file is an unchanged regression guard. Each sub-unit gets its own device-registry entry linked by via_device and named from its own /information/vs/<n>, so it lands in its own room rather than crowding the master's device page. A sub-unit materializes only when it yields at least one primary (non-diagnostic) entity with a populated value. That gate is not decoration: the reporter's /device/2 is an unused slot that SmartThings shows disabled, yet it answers with a full 14-href batch, and it flattens to exactly one non-None value -- a diagnostic alarm_code derived from an empty /alarms/vs/2. Without the entity-category filter it becomes a phantom third climate card. The rule is deliberately domain-agnostic rather than a list of HVAC hrefs, so a multi-drum washer (#19) gets the same treatment with no new curation. Units that answer but fail the gate are logged and reported in diagnostics, so a genuinely missing unit stays diagnosable from a dump. Enumeration fetches things that must not then be treated as appliance state. A rejected candidate's seed has to be read to evaluate the gate, but only units that pass are polled again, and StateCache has no eviction -- so discovery runs before the first cache apply and those reps are held aside for diagnostics rather than frozen into the cache forever. /multidevice/vs/0 is probed on every device regardless of family, so merging it into the resources dict would have reached discovery on any board whose registry doesn't ignore that href -- only the air conditioner one does -- raising a spurious coverage-gap repair for a washer or fridge whose firmware answers it. It is corroborating metadata (numofsubdevice, confirmed read-only) and now lives beside the resources rather than in them. Diagnostics reports each unit separately: top-level `resources` is this unit's own and only its own, which is what the module docstring and the adding-device-support skill have always claimed it was, and each sibling or rejected candidate carries its own reps canonicalized so a block reads exactly like the master's instead of needing to be de-indexed by hand. Fixtures are real captures. The ARTIK051_DONGLE_FAC_18K one is entirely verbatim, both sibling seeds and the hand-read /multidevice/vs/0 included. The TP2X_FAC_BORA one has a real device0, oic_res and sub-unit /information/vs/0, with the remainder of that unit's tree constructed and documented as such in seeds_note; /<uuid>/device/0 is the one part of that pattern still inferred rather than observed, and can't be tested through the debug panel because a Collection returns a list.
142 lines
5.4 KiB
Python
142 lines
5.4 KiB
Python
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
FIXTURES = Path(__file__).resolve().parent / 'fixtures'
|
|
|
|
|
|
def _resources_from_dump(dump: dict) -> dict[str, dict]:
|
|
from custom_components.localthings.registry.batch import parse_device0_batch
|
|
return parse_device0_batch(dump['device0'])
|
|
|
|
|
|
def _load_device(name: str) -> dict[str, dict]:
|
|
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
|
|
return _resources_from_dump(data)
|
|
|
|
|
|
def _load_device_full(name: str):
|
|
"""Like _load_device, but also returns the optional `oic_res`/`seeds`
|
|
keys a sub-unit-capable fixture (issue #177) may carry alongside
|
|
`device0` -- see the two `airconditioner_*` fixtures with a
|
|
`seeds_note` field. `oic_res`/`seeds` default to `[]`/`{}` for every
|
|
other fixture, so this is safe to call on any fixture in the corpus.
|
|
|
|
Returns `(resources, oic_res, seeds)` where `seeds` is
|
|
`{seed_href: raw_batch_list}` -- the same [devcol-rep, {href, rep}, ...]
|
|
shape a real /device/<n> or /<id>/device/0 RETRIEVE returns, ready to
|
|
hand to a FakeCoapSession.
|
|
|
|
A fixture's optional `probes` map (plain Property-map resources that
|
|
belong to no batch, e.g. the hand-read /multidevice/vs/0 in the
|
|
ARTIK051_DONGLE_FAC_18K fixture) is folded into `seeds` here, since
|
|
FakeCoapSession answers both shapes off the same href key.
|
|
"""
|
|
data = json.loads((FIXTURES / f'{name}_device.json').read_text())
|
|
resources = _resources_from_dump(data)
|
|
seeds = {**data.get('seeds', {}), **data.get('probes', {})}
|
|
return resources, data.get('oic_res', []), seeds
|
|
|
|
|
|
class FakeCoapSession:
|
|
"""Minimal stand-in for smartthings_local's DtlsCoapSession, backed by a
|
|
fixture's `seeds` map (raw device0-batch-shaped lists keyed by seed
|
|
href -- plus any `probes` entries, which are plain Property maps rather
|
|
than batch lists; both are just CBOR bodies at this layer, and the two
|
|
readers in registry.subunits already type-check what they get back).
|
|
Enough surface for registry.subunits.enumerate_sub_units and
|
|
LocalThingsCoordinator's blocking sub-unit polls to run against fixture
|
|
data without a live device -- same idea as test_identity.py's
|
|
FakeSession, but keyed by href string (post path-join) rather than a
|
|
path tuple, since callers here pass a `seed_path` tuple straight
|
|
through.
|
|
"""
|
|
|
|
def __init__(self, seeds: dict[str, list] | None = None):
|
|
self.seeds = seeds or {}
|
|
|
|
def get(self, path, timeout=None):
|
|
href = '/' + '/'.join(path)
|
|
body = self.seeds.get(href)
|
|
if body is None:
|
|
return 0x84, b'' # 4.04 not found -- tolerated absence
|
|
import cbor2
|
|
return 0x45, cbor2.dumps(body)
|
|
|
|
def pace(self):
|
|
pass
|
|
|
|
|
|
def _discover_full(resources: dict[str, dict], oic_res, seeds: dict[str, list]):
|
|
"""Run the *whole* sub-unit-aware discovery pipeline against fixture
|
|
data, HA-free -- mirrors exactly what LocalThingsCoordinator does across
|
|
_enumerate_sub_units_blocking + _run_discovery (issue #177), so a test
|
|
exercising this exercises the real code path, not a re-implementation of
|
|
it. See the adding-device-support skill's section 2 for the plain
|
|
(non-sub-unit) equivalent this extends.
|
|
|
|
Returns `(bound, materialized, skipped, full_resources, device_type_name)`:
|
|
- `bound`: every BoundEntity, main + every materialized sub-unit.
|
|
- `materialized`/`skipped`: SubUnit / SkippedSubUnit lists straight from
|
|
discover_partitioned.
|
|
- `full_resources`: `resources` merged with every candidate's seed data
|
|
(actual hrefs) -- what a coordinator's cache would hold.
|
|
- `device_type_name`: the master's resolved registry name.
|
|
"""
|
|
from custom_components.localthings.registry.by_type import resolve
|
|
from custom_components.localthings.registry.registry import CAPABILITIES
|
|
from custom_components.localthings.registry.subunits import (
|
|
discover_partitioned, enumerate_sub_units,
|
|
)
|
|
|
|
sess = FakeCoapSession(seeds)
|
|
candidates, extra = enumerate_sub_units(sess, resources, oic_res)
|
|
full_resources = {**resources, **extra}
|
|
bound, device_type_name, materialized, skipped = discover_partitioned(
|
|
full_resources, candidates, resolve, CAPABILITIES,
|
|
)
|
|
return bound, materialized, skipped, full_resources, device_type_name
|
|
|
|
|
|
def _load_resources(ip: str) -> dict[str, dict]:
|
|
"""Legacy IP-based loader — maps known IPs to named fixtures."""
|
|
_ip_to_name = {
|
|
'10.0.0.129': 'dishwasher',
|
|
'10.0.0.254': 'refrigerator',
|
|
}
|
|
name = _ip_to_name.get(ip)
|
|
if name is None:
|
|
raise ValueError(f"No fixture for IP {ip!r} — add a scrubbed fixture to tests/fixtures/")
|
|
return _load_device(name)
|
|
|
|
|
|
@pytest.fixture
|
|
def dishwasher_resources() -> dict[str, dict]:
|
|
return _load_device('dishwasher')
|
|
|
|
|
|
@pytest.fixture
|
|
def fridge_resources() -> dict[str, dict]:
|
|
return _load_device('refrigerator')
|
|
|
|
|
|
@pytest.fixture
|
|
def washer_resources() -> dict[str, dict]:
|
|
return _load_device('washer')
|
|
|
|
|
|
@pytest.fixture
|
|
def all_device_fixtures() -> dict[str, dict[str, dict]]:
|
|
"""Every scrubbed device dump, keyed by fixture name.
|
|
|
|
For invariants that must hold across the whole corpus rather than for one
|
|
device -- so a newly added dump exercises them automatically.
|
|
"""
|
|
return {
|
|
path.name[:-len('_device.json')]: _resources_from_dump(
|
|
json.loads(path.read_text())
|
|
)
|
|
for path in sorted(FIXTURES.glob('*_device.json'))
|
|
}
|