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.
108 lines
5.0 KiB
Python
108 lines
5.0 KiB
Python
"""Sanity check for diagnostics.py's issue #177 additions (sub_units,
|
|
sub_units_skipped, sub_unit_probes) against a real composite-device
|
|
discovery run -- makes sure the new blocks are actually reachable/shaped
|
|
right, not just that the coordinator's own attributes look correct in
|
|
isolation (test_subdevice_discovery.py covers that)."""
|
|
from __future__ import annotations
|
|
|
|
from homeassistant.core import HomeAssistant
|
|
|
|
from custom_components.localthings.const import DOMAIN
|
|
from custom_components.localthings.diagnostics import (
|
|
async_get_config_entry_diagnostics,
|
|
)
|
|
|
|
from tests.test_subdevice_discovery import _coordinator, _discover
|
|
|
|
|
|
async def test_diagnostics_reports_materialized_and_skipped_sub_units(
|
|
hass: HomeAssistant, enable_custom_integrations,
|
|
) -> None:
|
|
coordinator = _coordinator(hass)
|
|
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
|
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
|
|
|
|
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
|
|
|
sub_unit_keys = {su['key'] for su in diag['sub_units']}
|
|
skipped_keys = {su['key'] for su in diag['sub_units_skipped']}
|
|
assert sub_unit_keys == {'1'}
|
|
assert skipped_keys == {'2'}
|
|
assert diag['sub_units'][0]['bound_entity_count'] > 0
|
|
assert diag['sub_units_skipped'][0]['hrefs']
|
|
assert '/multidevice/vs/0' in diag['sub_unit_probes']
|
|
# The reporter's hand-read value, carried in the fixture's `probes` map
|
|
# (it belongs to no batch -- see that fixture's seeds_note). Two real
|
|
# units, master + bedroom, independently corroborating the gate's
|
|
# decision to skip /device/2. Reported on its own, never as a resource.
|
|
assert diag['multidevice'] == {'x.com.samsung.da.numofsubdevice': '2'}
|
|
assert '/multidevice/vs/0' not in diag['resources']
|
|
|
|
|
|
async def test_top_level_resources_is_the_master_unit_only(
|
|
hass: HomeAssistant, enable_custom_integrations,
|
|
) -> None:
|
|
"""`resources` reports this unit's own hrefs and nothing else.
|
|
|
|
It used to be `last_resources` raw -- the union across every live unit,
|
|
keyed by real hrefs -- so a sibling's /mode/vs/1 sat in it alongside the
|
|
master's /mode/vs/0 with no attribution, contradicting both the module
|
|
docstring and the adding-device-support skill ("the parsed /device/0
|
|
snapshot"). Each sibling carries its own canonicalized block instead.
|
|
"""
|
|
coordinator = _coordinator(hass)
|
|
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
|
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
|
|
|
|
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
|
|
|
assert not [h for h in diag['resources'] if h.endswith('/1')]
|
|
assert '/mode/vs/0' in diag['resources']
|
|
# The sibling's own block carries its state, canonicalized -- '/mode/vs/0',
|
|
# not the '/mode/vs/1' it actually answers on.
|
|
unit1 = diag['sub_units'][0]
|
|
assert '/mode/vs/0' in unit1['resources']
|
|
assert not [h for h in unit1['resources'] if h.endswith('/1')]
|
|
assert unit1['resources']['/power/vs/0']['x.com.samsung.da.power'] == 'On'
|
|
|
|
|
|
async def test_rejected_candidate_reps_reach_diagnostics_but_not_the_cache(
|
|
hass: HomeAssistant, enable_custom_integrations,
|
|
) -> None:
|
|
"""A gate-rejected slot is never polled again, so anything applied to the
|
|
state cache for it would sit frozen at its first-discovery value while
|
|
looking as live as every other href. It's kept out of the cache entirely
|
|
and reported only under its own sub_units_skipped entry -- which is also
|
|
the only place a reader can go to second-guess the gate."""
|
|
coordinator = _coordinator(hass)
|
|
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
|
|
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
|
|
|
|
assert not [h for h in coordinator.last_resources if h.endswith('/2')]
|
|
|
|
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
|
skipped = diag['sub_units_skipped'][0]
|
|
# Present, canonicalized, and visibly the empty state the gate rejected.
|
|
assert skipped['resources']['/power/vs/0'] == {}
|
|
assert skipped['resources']['/mode/vs/0'] == {}
|
|
assert skipped['resources']['/information/vs/0']
|
|
|
|
|
|
async def test_diagnostics_reports_prefixed_sub_unit(
|
|
hass: HomeAssistant, enable_custom_integrations,
|
|
) -> None:
|
|
coordinator = _coordinator(hass)
|
|
await _discover(coordinator, 'airconditioner_fac_bora_2in1')
|
|
hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator
|
|
|
|
diag = await async_get_config_entry_diagnostics(hass, coordinator._entry)
|
|
|
|
assert len(diag['sub_units']) == 1
|
|
assert diag['sub_units'][0]['kind'] == 'prefixed'
|
|
# diagnostics reports the raw modelNum field verbatim (board revision/
|
|
# capability-bitmap suffix included), unlike device_info_for's model
|
|
# (split at '|') -- confirms it's still the wall unit's own identity,
|
|
# not the master's TP2X_FAC_BORA_21K.
|
|
assert diag['sub_units'][0]['model'].startswith('TP2X_FAC_BORA_RAC_21K')
|
|
assert diag['sub_units_skipped'] == []
|