Files
localthings/tests/test_subdevice_discovery.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

221 lines
9.8 KiB
Python

"""End-to-end discovery tests for issue #177's two composite-device
fixtures, against the real LocalThingsCoordinator (not the HA-free
registry-level helpers test_subunits.py/test_unique_ids.py use) -- this is
what actually exercises _enumerate_sub_units_blocking + _run_discovery
together, including device_info_for/via_device and the "no phantom
/device/2 entities" guarantee.
"""
from __future__ import annotations
import pytest
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.const import (
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.entities import ClimateDesc
from custom_components.localthings.registry.identity import DeviceIdentity
from tests.conftest import FakeCoapSession, _load_device_full
ENTRY_DATA = {
CONF_HOST: '10.0.0.177',
CONF_PORT: 49154,
CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----',
CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----',
}
def _coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
entry = MockConfigEntry(
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SUBDEVICE-TEST',
)
entry.add_to_hass(hass)
return LocalThingsCoordinator(hass, entry)
async def _discover(coordinator: LocalThingsCoordinator, name: str) -> None:
"""Run the same two-step sequence _async_update_data's first cycle does
(enumerate, then discover) against fixture data, without the polling/
reconnect machinery around it -- see coordinator.py's
_enumerate_sub_units_blocking/_run_discovery."""
resources, oic_res, seeds = _load_device_full(name)
coordinator._session = FakeCoapSession(seeds)
# _connect_session (skipped here -- the session is pre-set) is what
# normally populates _identity via read_identity; set it directly with
# the fixture's real /oic/res so enumeration sees the same links a live
# read_identity call would have captured.
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics', model='', name='', serial=None,
device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': oic_res},
)
merged = await coordinator.hass.async_add_executor_job(
coordinator._enumerate_sub_units_blocking, resources,
)
# Mirror _async_update_data's first-cycle order exactly: discover, then
# drop the candidates the liveness gate rejected, then apply what's left
# to the observe/cache layer. The apply has to happen (canonical_resources
# -- device_info_for, is_legacy_board, ... -- reads the cache, not the
# dict passed to _run_discovery), but it has to happen *after* the gate,
# or a rejected slot's reps get frozen into the cache forever. Applying
# first here would leave this helper testing an ordering production no
# longer uses.
coordinator._run_discovery(merged)
for href, rep in coordinator._live_unit_resources(merged).items():
coordinator._observe.apply(href, rep, source='poll')
def _climate_bound(coordinator, sub_unit_key: str):
from custom_components.localthings.registry.subunits import MAIN
for b in coordinator.bound:
if isinstance(b.desc, ClimateDesc):
if sub_unit_key is None and b.sub_unit == MAIN:
return b
if sub_unit_key is not None and b.sub_unit.key == sub_unit_key:
return b
return None
# ---------------------------------------------------------------------------
# HJcom -- ARTIK051_DONGLE_FAC_18K, Pattern A (indexed siblings)
# ---------------------------------------------------------------------------
async def test_hjcom_materializes_master_and_bedroom_unit(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
assert [su.key for su in coordinator.sub_units] == ['1']
main_climate = _climate_bound(coordinator, None)
unit1_climate = _climate_bound(coordinator, '1')
assert main_climate is not None
assert unit1_climate is not None
assert main_climate.href == '/mode/vs/0'
assert unit1_climate.href == '/mode/vs/1'
async def test_hjcom_device_2_produces_no_entities_at_all(hass: HomeAssistant):
"""HJcom's /device/2 is the unused SmartThings slot (DESIGN-177.md
section 4): it answers its seed with a full-shaped batch, but every
climate-state rep on it is empty. It must be recorded as skipped, not
materialized, and must contribute zero bound entities."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
assert '2' not in [su.key for su in coordinator.sub_units]
assert any(
skip.sub_unit.kind == 'indexed' and skip.sub_unit.key == '2'
for skip in coordinator._skipped_sub_units
)
assert not any(b.sub_unit.key == '2' for b in coordinator.bound)
assert not any(href.endswith('/2') for href in coordinator._hot_hrefs)
assert not any(href.endswith('/2') for href in coordinator._warm_hrefs)
async def test_hjcom_unit1_device_info_links_via_device_to_master(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
unit1 = next(su for su in coordinator.sub_units if su.key == '1')
info = coordinator.device_info_for(unit1)
master_serial = coordinator.device_serial
assert info['identifiers'] == {(DOMAIN, f'{master_serial}_1')}
assert info['via_device'] == (DOMAIN, master_serial)
# The sub-unit's own /information/vs/1 (real, ARTIK051_DONGLE_FAC_RAC_18K)
# is what names/models this device, not the master's.
assert info['model'] == 'ARTIK051_DONGLE_FAC_RAC_18K'
# ---------------------------------------------------------------------------
# jhkwon19 -- TP2X_FAC_BORA_21K, Pattern B (UUID-prefixed tree)
# ---------------------------------------------------------------------------
_SUB_UUID = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
async def test_fac_bora_2in1_materializes_prefixed_wall_unit(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
assert [su.key for su in coordinator.sub_units] == [_SUB_UUID]
assert coordinator.sub_units[0].kind == 'prefixed'
main_climate = _climate_bound(coordinator, None)
sub_climate = _climate_bound(coordinator, _SUB_UUID)
assert main_climate is not None
assert sub_climate is not None
assert main_climate.href == '/mode/vs/0'
assert sub_climate.href == f'/{_SUB_UUID}/mode/vs/0'
async def test_fac_bora_2in1_sub_unit_device_info(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
unit = coordinator.sub_units[0]
info = coordinator.device_info_for(unit)
master_serial = coordinator.device_serial
assert info['identifiers'] == {(DOMAIN, f'{master_serial}_{_SUB_UUID}')}
assert info['via_device'] == (DOMAIN, master_serial)
# Confirmed live by the reporter (DESIGN-177.md section 1): the wall
# unit's own identity, distinct from the master's TP2X_FAC_BORA_21K.
assert info['model'] == 'TP2X_FAC_BORA_RAC_21K'
async def test_fac_bora_2in1_unique_ids_include_sub_prefix(hass: HomeAssistant):
"""The prefixed unit's unique_id carries the full subdevice UUID
(non-alphanumerics stripped), not a truncation or an ordinal -- see
SubUnit.key_prefix."""
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
from custom_components.localthings.entity import LocalThingsEntity
sub_climate = _climate_bound(coordinator, _SUB_UUID)
entity = LocalThingsEntity(coordinator, sub_climate)
expected_slug = _SUB_UUID.replace('-', '')
assert entity._attr_unique_id == (
f"{DOMAIN}_{coordinator.device_serial}_sub_{expected_slug}_climate"
)
async def test_multidevice_probe_never_reaches_discovery_or_the_cache(
hass: HomeAssistant,
):
"""/multidevice/vs/0 is probed on every device but is metadata, not state.
It has to stay out of the resources dict on both counts. Discovery would
otherwise report it as an unbound href on every family whose registry
doesn't ignore that path -- only the AC one does -- raising a spurious
"incomplete capability coverage" repair for, say, a washer whose
firmware happens to answer it. And nothing polls it after discovery, so
anything applied to the state cache would sit frozen there forever.
Driven with a washer fixture precisely because the AC registry's own
ignore entry would mask the coverage half of this on an AC.
"""
resources, _oic, _seeds = _load_device_full('washer_flexwash')
coordinator = _coordinator(hass)
coordinator._session = FakeCoapSession({
'/multidevice/vs/0': {'x.com.samsung.da.numofsubdevice': '2'},
})
coordinator._identity = DeviceIdentity(
manufacturer='Samsung Electronics', model='', name='', serial=None,
device_types=(), raw={'/oic/p': {}, '/oic/d': {}, '/oic/res': []},
)
merged = await hass.async_add_executor_job(
coordinator._enumerate_sub_units_blocking, resources,
)
coordinator._run_discovery(merged)
for href, rep in coordinator._live_unit_resources(merged).items():
coordinator._observe.apply(href, rep, source='poll')
assert '/multidevice/vs/0' not in merged
assert '/multidevice/vs/0' not in coordinator._unbound_hrefs
assert '/multidevice/vs/0' not in coordinator.last_resources
# Still captured, just not as device state.
assert coordinator._multidevice == {'x.com.samsung.da.numofsubdevice': '2'}