Files
localthings/tests/test_identity.py
T
Marc Billow c181a8b447 feat(subdevices): support multi-indoor-unit systems (#177)
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.
2026-07-29 19:14:51 +00:00

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'] == []