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.
221 lines
9.8 KiB
Python
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'}
|