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

104 lines
4.7 KiB
Python

"""Tests that a sub-unit's LocalThingsClimate entity (issue #177) reads its
*own* power/mode/temperature -- not the master's, and not some mix of the
two -- and that the legacy-board test (is_legacy_board/_legacy_airflow) is
evaluated per unit rather than once globally.
Uses HJcom's ARTIK051_DONGLE_FAC_18K fixture deliberately: it's a legacy
`/airflow/vs/<n>` board (no `/wind/*` at all) on *both* the master and its
materialized sibling, which is exactly the shape climate.py's own comments
warn is easy to get wrong if the canonical view leaks between units.
"""
from __future__ import annotations
import pytest
from homeassistant.components.climate import HVACMode
from homeassistant.core import HomeAssistant
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.registry.entities import ClimateDesc
from tests.test_subdevice_discovery import _coordinator, _discover
def _climate_entities(coordinator):
"""{sub_unit_key_or_None: LocalThingsClimate}, None standing for MAIN."""
from custom_components.localthings.registry.subunits import MAIN
out = {}
for b in coordinator.bound:
if isinstance(b.desc, ClimateDesc):
key = None if b.sub_unit == MAIN else b.sub_unit.key
out[key] = LocalThingsClimate(coordinator, b)
return out
@pytest.fixture
async def climates(hass: HomeAssistant):
coordinator = _coordinator(hass)
await _discover(coordinator, 'airconditioner_artik051_dongle_fac_18k')
return _climate_entities(coordinator)
async def test_sub_unit_climate_reads_its_own_mode_and_power(climates):
"""Master reports 'Auto'; the bedroom unit (/device/1) reports 'Cool' --
confirmed distinct in the real captured fixture. If the sub-unit
entity's _rep() weren't translating through its own sub_unit, it would
read the master's /mode/vs/0 instead and report the master's mode."""
main, unit1 = climates[None], climates['1']
assert main.hvac_mode == HVACMode.AUTO
assert unit1.hvac_mode == HVACMode.COOL
async def test_sub_unit_climate_reads_its_own_temperature(climates):
"""Master: current 25.0 / desired 26.0. Unit 1: current 27.0 / desired
28.0 -- distinct values in the real fixture, so a href mix-up here
would show up as a wrong number, not just a wrong mode string."""
main, unit1 = climates[None], climates['1']
assert main.current_temperature == 25.0
assert main.target_temperature == 26.0
assert unit1.current_temperature == 27.0
assert unit1.target_temperature == 28.0
async def test_sub_unit_climate_reads_its_own_power_state(climates):
"""Both units happen to report power On in this fixture -- this at
least confirms _is_on() reads the *unit's own* /power/vs/<n>, not a
hardcoded /power/vs/0, by checking the entity resolves without falling
back to OFF (which _is_on() would do if it silently read an absent
href instead of the sub-unit's actual one)."""
main, unit1 = climates[None], climates['1']
assert main.hvac_mode != HVACMode.OFF
assert unit1.hvac_mode != HVACMode.OFF
async def test_legacy_board_test_is_evaluated_per_unit(climates):
"""HJcom's board has no /wind/* resources at all on *either* unit --
is_legacy_board(self._resources) must independently evaluate True for
the master's own canonical view and for the sub-unit's own canonical
view. If is_legacy_board were fed the raw, unpartitioned snapshot (or
if canonical_view leaked one unit's hrefs into the other's), this
wouldn't distinguish "this unit is legacy" from "some unit on this
connection is legacy" -- and a future board with one legacy + one
modern unit sharing a connection would silently read the wrong fan/
swing channel on one side."""
main, unit1 = climates[None], climates['1']
assert main._legacy_airflow() != {}
assert unit1._legacy_airflow() != {}
# Both resolve to *some* fan mode via the legacy path rather than the
# /wind/strength/vs/0 channel (absent on this board) -- confirms
# is_legacy_board(self._resources) actually gated fan_mode's branch,
# not just that _legacy_airflow() itself returned something.
assert main.fan_mode is not None
assert unit1.fan_mode is not None
async def test_sub_unit_climate_writes_are_scoped_to_its_own_bound_entity(climates):
"""A sanity check that the two entities are backed by genuinely
different BoundEntity objects (different hrefs), which is what makes
per-unit reads/writes possible at all -- see async_send_command's
translation test in test_coordinator_send_command.py for the write
side of this."""
main, unit1 = climates[None], climates['1']
assert main._bound.href == '/mode/vs/0'
assert unit1._bound.href == '/mode/vs/1'
assert main._bound is not unit1._bound