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.
111 lines
4.1 KiB
Python
111 lines
4.1 KiB
Python
import cbor2
|
|
|
|
from custom_components.localthings.registry.identity import DeviceIdentity, read_identity
|
|
|
|
|
|
class FakeSession:
|
|
def __init__(self, table):
|
|
self.table = table # tuple(path) -> rep dict
|
|
|
|
def get(self, path, timeout=10.0):
|
|
rep = self.table.get(tuple(path))
|
|
if rep is None:
|
|
return 0x84, b'' # 4.04 not found
|
|
return 0x45, cbor2.dumps(rep)
|
|
|
|
|
|
def test_read_identity_from_oic_p_and_d():
|
|
sess = FakeSession({
|
|
('oic', 'p'): {'mnmn': 'Samsung Electronics', 'mnmo': 'RF9000B'},
|
|
('oic', 'd'): {'n': 'Family Hub'},
|
|
})
|
|
ident = read_identity(sess, serial='ABC123')
|
|
assert ident.manufacturer == 'Samsung Electronics'
|
|
assert ident.model == 'RF9000B'
|
|
assert ident.name == 'Family Hub'
|
|
assert ident.serial == 'ABC123'
|
|
|
|
|
|
def test_read_identity_tolerates_missing_resources():
|
|
ident = read_identity(FakeSession({}), serial=None)
|
|
assert ident.manufacturer == 'Samsung'
|
|
assert ident.model == ''
|
|
assert ident.serial is None
|
|
assert ident.device_types == ()
|
|
assert ident.raw == {'/oic/p': {}, '/oic/d': {}, '/oic/res': []}
|
|
|
|
|
|
def test_read_identity_captures_oic_d_device_types():
|
|
"""/oic/d's `rt` is OCF's own device-type declaration -- captured so
|
|
diagnostics can show whether real hardware populates it usefully."""
|
|
sess = FakeSession({
|
|
('oic', 'd'): {
|
|
'n': 'Living Room AC',
|
|
'rt': ['oic.wk.d', 'oic.d.airconditioner'],
|
|
},
|
|
})
|
|
ident = read_identity(sess, serial=None)
|
|
assert ident.device_types == ('oic.wk.d', 'oic.d.airconditioner')
|
|
|
|
|
|
def test_read_identity_normalizes_scalar_and_malformed_rt():
|
|
"""Firmware that reports a bare string, or a non-list, must not explode."""
|
|
assert read_identity(
|
|
FakeSession({('oic', 'd'): {'rt': 'oic.d.refrigerator'}}), None
|
|
).device_types == ('oic.d.refrigerator',)
|
|
assert read_identity(
|
|
FakeSession({('oic', 'd'): {'rt': 42}}), None
|
|
).device_types == ()
|
|
assert read_identity(
|
|
FakeSession({('oic', 'd'): {'rt': ['oic.wk.d', 7, None]}}), None
|
|
).device_types == ('oic.wk.d',)
|
|
|
|
|
|
def test_read_identity_keeps_raw_payloads_for_diagnostics():
|
|
sess = FakeSession({
|
|
('oic', 'p'): {'mnmn': 'Samsung Electronics', 'mnmo': 'RF9000B'},
|
|
('oic', 'd'): {'n': 'Family Hub', 'di': 'abc-123'},
|
|
})
|
|
ident = read_identity(sess, serial=None)
|
|
assert ident.raw['/oic/p']['mnmo'] == 'RF9000B'
|
|
assert ident.raw['/oic/d']['di'] == 'abc-123'
|
|
|
|
|
|
def test_read_identity_captures_oic_res_links():
|
|
"""/oic/res is OCF's discovery endpoint. Real hardware (issue #177
|
|
follow-up, a TP1X_REF_21K fridge dump) groups the response by `di`: one
|
|
entry per logical Device, each carrying its own `links` array -- not a
|
|
flat array of individually-`di`-tagged links. Every entry that dump
|
|
returned had its discoverable policy bit set (`bm`'s bit 0); the whole
|
|
x.com.samsung.da.* tree (including /device/0 itself) did not appear at
|
|
all, meaning it's registered non-discoverable and simply invisible to
|
|
this endpoint -- see _SPECULATIVE_DEVICE_INDICES' docstring for why that
|
|
motivated probing /device/1 and /device/2 directly instead."""
|
|
sess = FakeSession({
|
|
('oic', 'res'): [
|
|
{'di': 'aaaa', 'links': [
|
|
{'href': '/oic/d', 'rt': ['oic.wk.d', 'oic.d.refrigerator'],
|
|
'p': {'bm': 1}},
|
|
{'href': '/oic/sec/doxm', 'rt': ['oic.r.doxm'], 'p': {'bm': 1}},
|
|
]},
|
|
],
|
|
})
|
|
ident = read_identity(sess, serial=None)
|
|
assert ident.raw['/oic/res'] == [
|
|
{'di': 'aaaa', 'links': [
|
|
{'href': '/oic/d', 'rt': ['oic.wk.d', 'oic.d.refrigerator'],
|
|
'p': {'bm': 1}},
|
|
{'href': '/oic/sec/doxm', 'rt': ['oic.r.doxm'], 'p': {'bm': 1}},
|
|
]},
|
|
]
|
|
|
|
|
|
def test_read_identity_tolerates_malformed_oic_res():
|
|
"""A single Property map instead of an array (or anything else
|
|
non-list-shaped) must not explode -- same defensive posture as
|
|
_device_types' handling of a malformed /oic/d rt."""
|
|
ident = read_identity(
|
|
FakeSession({('oic', 'res'): {'not': 'a list'}}), serial=None
|
|
)
|
|
assert ident.raw['/oic/res'] == []
|