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.
124 lines
5.2 KiB
Python
124 lines
5.2 KiB
Python
"""Tests for the AC HVAC-mode/preset device<->HA maps in climate.py (issue #93).
|
|
|
|
Module-level dicts/constants with no coordinator/entity dependency, so --
|
|
like `_temps_vs_item` in test_climate_temperature_fallback.py -- they're
|
|
testable directly.
|
|
"""
|
|
from homeassistant.components.climate import HVACMode
|
|
|
|
from custom_components.localthings.climate import (
|
|
_AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE,
|
|
PRESET_AI_COMFORT, _preset_to_ha,
|
|
)
|
|
|
|
|
|
def test_auto_maps_to_hvac_auto():
|
|
"""The device's 'Auto' is a single-setpoint "device decides" mode -> HA
|
|
HVACMode.AUTO, not HEAT_COOL (issue #91 review): HEAT_COOL implies a
|
|
two-setpoint heat+cool range these single-setpoint units (including
|
|
cool-only models) don't have. AIComfort is handled separately, not
|
|
folded into this map."""
|
|
assert _DEVICE_TO_HVAC['Auto'] == HVACMode.AUTO
|
|
|
|
|
|
def test_aicomfort_not_in_flat_hvac_map():
|
|
"""AIComfort isn't a flat _DEVICE_TO_HVAC entry -- it's an AI overlay on
|
|
top of 'Auto', modeled as hvac_mode=AUTO + a dedicated preset instead of
|
|
a distinct HVACMode value (see the climate.py module comment)."""
|
|
assert _AI_COMFORT_MODE not in _DEVICE_TO_HVAC
|
|
|
|
|
|
def test_hvac_auto_writes_back_to_plain_auto_not_aicomfort():
|
|
"""HVACMode.AUTO is reachable via async_set_hvac_mode -- it writes the
|
|
device's plain 'Auto' code. AIComfort stays reachable only through the
|
|
ai_comfort preset, since it isn't a flat _DEVICE_TO_HVAC entry (see
|
|
test_aicomfort_not_in_flat_hvac_map) and so can never win the reverse
|
|
{v: k} dict even though both map to HVACMode.AUTO conceptually."""
|
|
assert _HVAC_TO_DEVICE[HVACMode.AUTO] == 'Auto'
|
|
|
|
|
|
def test_fan_only_still_reachable_via_wind():
|
|
"""Guard against regressing the existing 'Wind' -> FAN_ONLY entry while
|
|
editing this map."""
|
|
assert _DEVICE_TO_HVAC['Wind'] == HVACMode.FAN_ONLY
|
|
|
|
|
|
def test_fan_only_still_reachable_via_fan():
|
|
"""'Fan' (e.g. TP1X_DA-AC-RAC-01011) is a second FAN_ONLY spelling
|
|
alongside 'Wind' -- issue #91."""
|
|
assert _DEVICE_TO_HVAC['Fan'] == HVACMode.FAN_ONLY
|
|
|
|
|
|
def test_fan_only_reverse_fallback_prefers_wind():
|
|
"""_device_code_for_hvac() resolves FAN_ONLY from a unit's own
|
|
supportedModes first, so _HVAC_TO_DEVICE is only a fallback for a unit
|
|
reporting no supportedModes at all. That fallback must stay 'Wind' (the
|
|
original single spelling, predating 'Fan') rather than silently
|
|
flipping to whichever of the two duplicate-value entries happens to
|
|
come last in _DEVICE_TO_HVAC."""
|
|
assert _HVAC_TO_DEVICE[HVACMode.FAN_ONLY] == 'Wind'
|
|
|
|
|
|
def test_preset_ai_comfort_constant():
|
|
assert PRESET_AI_COMFORT == 'ai_comfort'
|
|
|
|
|
|
def test_preset_to_ha_off_maps_to_preset_none():
|
|
from homeassistant.components.climate import PRESET_NONE
|
|
assert _preset_to_ha('Off') == PRESET_NONE
|
|
|
|
|
|
def test_preset_to_ha_lowercases_other_codes():
|
|
"""Every other device code is exposed as its lowercased self -- resolved
|
|
dynamically, not via a per-model table (issue #91)."""
|
|
assert _preset_to_ha('Sleep') == 'sleep'
|
|
assert _preset_to_ha('NanoSleep') == 'nanosleep'
|
|
assert _preset_to_ha('MotionIndirect') == 'motionindirect'
|
|
|
|
|
|
def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
|
|
"""TP2X_FAC_BORA_21K's (issues #150/#153) /wind/strength/vs/0 codes
|
|
(0/2/3/4, skipping 1/'low') and modesName (Auto/Mid/High/Turbo) already
|
|
match _DEVICE_TO_FAN's own mapping exactly -- no dynamic modesName
|
|
fallback needed for this particular board, unlike issue #155's
|
|
TP1X_DA-AC-RAC-01001_0000.
|
|
|
|
Asserts the live climate entity's actual fan_modes/fan_mode output
|
|
(not just that the module constant _DEVICE_TO_FAN happens to have
|
|
these keys) -- a bare `code in _DEVICE_TO_FAN` check would still pass
|
|
even if fan_modes/fan_mode were completely broken, since it never
|
|
touches the entity at all.
|
|
"""
|
|
from custom_components.localthings.climate import LocalThingsClimate
|
|
from custom_components.localthings.registry import by_type
|
|
from custom_components.localthings.registry.discovery import discover
|
|
from custom_components.localthings.registry.entities import ClimateDesc
|
|
from tests.conftest import _load_device
|
|
|
|
class _FakeCoordinator:
|
|
device_serial = 'TEST-FAC-BORA-SERIAL'
|
|
device_info = {}
|
|
data = {}
|
|
|
|
def __init__(self, resources):
|
|
self.last_resources = resources
|
|
|
|
def resource(self, href):
|
|
return self.last_resources.get(href, {})
|
|
|
|
def canonical_resources(self, sub_unit):
|
|
# This test's entity uses the default MAIN sub-unit, so the
|
|
# canonical view is just the raw snapshot (issue #177).
|
|
return self.last_resources
|
|
|
|
resources = _load_device('airconditioner_fac_bora')
|
|
info = resources['/information/vs/0']
|
|
reg = by_type.for_device_by_model(
|
|
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'])
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
|
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
|
|
entity = LocalThingsClimate(_FakeCoordinator(resources), climate_bound)
|
|
|
|
assert entity.fan_modes == ['auto', 'medium', 'high', 'turbo']
|
|
assert entity.fan_mode == 'auto'
|