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.
227 lines
8.1 KiB
Python
227 lines
8.1 KiB
Python
"""HA fan-entity mapping tests for the range hood."""
|
|
|
|
from custom_components.localthings.fan import LocalThingsRangeHoodFan
|
|
from custom_components.localthings.registry.by_type import microwave, range_hood
|
|
from custom_components.localthings.registry.discovery import discover
|
|
from custom_components.localthings.registry.entities import FanDesc
|
|
from tests.conftest import _load_device
|
|
|
|
|
|
class _FakeCoordinator:
|
|
device_serial = 'TEST-HOOD-SERIAL'
|
|
device_info = {}
|
|
data = {}
|
|
|
|
def __init__(self, resources):
|
|
self.last_resources = resources
|
|
self.commands = []
|
|
|
|
def resource(self, href):
|
|
return self.last_resources.get(href, {})
|
|
|
|
def canonical_resources(self, sub_unit):
|
|
# Every bound entity in this test uses the default MAIN
|
|
# sub-unit, so the canonical view is just the raw snapshot
|
|
# (issue #177 -- see LocalThingsEntity._resources).
|
|
return self.last_resources
|
|
|
|
async def async_send_command(self, bound, payload):
|
|
self.commands.append((bound, payload))
|
|
|
|
|
|
def _entity(resources, coordinator=None, registry=range_hood.REGISTRY):
|
|
bound = discover(
|
|
resources,
|
|
registry.capabilities,
|
|
registry.pattern_capabilities,
|
|
)
|
|
fan_bound = next(item for item in bound if isinstance(item.desc, FanDesc))
|
|
return LocalThingsRangeHoodFan(
|
|
coordinator or _FakeCoordinator(resources), fan_bound,
|
|
)
|
|
|
|
|
|
def _microwave_entity(resources, coordinator=None):
|
|
return _entity(resources, coordinator, registry=microwave.REGISTRY)
|
|
|
|
|
|
def test_power_off_maps_to_zero_percent_and_four_retained_speeds():
|
|
entity = _entity(_load_device('range_hood'))
|
|
assert entity.is_on is False
|
|
assert entity.speed_count == 4
|
|
assert entity.percentage == 0
|
|
|
|
|
|
def test_active_codes_map_to_ordered_percentages():
|
|
resources = _load_device('range_hood')
|
|
resources['/power/0']['value'] = True
|
|
|
|
resources['/hood/fanspeed/vs/0'][
|
|
'x.com.samsung.da.hood.fanSpeed'
|
|
] = '14'
|
|
assert _entity(resources).percentage == 25
|
|
|
|
resources['/hood/fanspeed/vs/0'][
|
|
'x.com.samsung.da.hood.fanSpeed'
|
|
] = '15'
|
|
assert _entity(resources).percentage == 50
|
|
|
|
resources['/hood/fanspeed/vs/0'][
|
|
'x.com.samsung.da.hood.fanSpeed'
|
|
] = '16'
|
|
assert _entity(resources).percentage == 75
|
|
|
|
resources['/hood/fanspeed/vs/0'][
|
|
'x.com.samsung.da.hood.fanSpeed'
|
|
] = '17'
|
|
assert _entity(resources).percentage == 100
|
|
|
|
|
|
async def test_power_write_prefers_standard_resource_when_both_exist():
|
|
resources = _load_device('range_hood')
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_turn_on()
|
|
|
|
assert coordinator.commands[-1][1] == ('power', True, '/power/0')
|
|
|
|
|
|
async def test_power_write_falls_back_to_vendor_resource():
|
|
resources = _load_device('range_hood')
|
|
resources.pop('/power/0')
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _entity(resources, coordinator)
|
|
|
|
await entity.async_turn_off()
|
|
|
|
assert coordinator.commands[-1][1] == ('power', False, '/power/vs/0')
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Microwave built-in vent fan (issues #137/#142): reuses HOOD_FAN, but this
|
|
# board has no sibling /power/0 or /power/vs/0 resource -- fan speed 0 is
|
|
# itself the off state.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_standalone_hood_has_separate_power():
|
|
"""Explicit converse of the microwave case below: guards the
|
|
discriminator itself, not just its downstream effects, so a future
|
|
change to it fails loudly here instead of only via behavioral drift."""
|
|
entity = _entity(_load_device('range_hood'))
|
|
assert entity._has_separate_power() is True
|
|
assert entity._speed_zero_is_off() is False
|
|
|
|
|
|
def test_microwave_vent_fan_has_no_separate_power_resource():
|
|
resources = _load_device('microwave_me7500d')
|
|
assert '/power/0' not in resources
|
|
assert '/power/vs/0' not in resources
|
|
entity = _microwave_entity(resources)
|
|
assert entity._has_separate_power() is False
|
|
assert entity._speed_zero_is_off() is True
|
|
|
|
|
|
async def test_combi_microwave_with_cavity_power_still_treats_zero_speed_as_fan_off():
|
|
"""A combi over-the-range microwave can report a /power/0 resource for
|
|
the cavity while the vent fan still has no power resource of its own
|
|
(settableMinFanSpeed '0' -- same board shape as microwave_me7500d).
|
|
_speed_zero_is_off must key off the hood resource itself, not merely
|
|
"some power resource exists on this device", so turning the fan off
|
|
writes fan speed rather than the shared cavity power resource."""
|
|
resources = _load_device('microwave_me7500d')
|
|
resources['/power/0'] = {'value': True}
|
|
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _microwave_entity(resources, coordinator)
|
|
|
|
assert entity._has_separate_power() is True
|
|
assert entity._speed_zero_is_off() is True
|
|
|
|
await entity.async_turn_off()
|
|
|
|
assert coordinator.commands[-1][1] == ('speed', '0')
|
|
|
|
|
|
def test_microwave_vent_fan_off_state_excludes_zero_from_speed_codes():
|
|
"""supportedFanSpeed reports ['0'..'4'] with 0 meaning off -- unlike the
|
|
standalone hood's codes, which never include an off entry."""
|
|
resources = _load_device('microwave_me7500d')
|
|
entity = _microwave_entity(resources)
|
|
assert entity.is_on is False
|
|
assert entity.percentage == 0
|
|
assert entity.speed_count == 4 # ['1', '2', '3', '4'], '0' excluded
|
|
|
|
|
|
def test_microwave_vent_fan_nonzero_speed_reads_as_on():
|
|
resources = _load_device('microwave_me7500d')
|
|
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
|
entity = _microwave_entity(resources)
|
|
assert entity.is_on is True
|
|
assert entity.percentage == 50 # 2nd of ['1', '2', '3', '4']
|
|
|
|
|
|
async def test_microwave_vent_fan_turn_off_writes_zero_speed_not_power():
|
|
resources = _load_device('microwave_me7500d')
|
|
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _microwave_entity(resources, coordinator)
|
|
|
|
await entity.async_turn_off()
|
|
|
|
assert coordinator.commands[-1][1] == ('speed', '0')
|
|
|
|
|
|
async def test_microwave_vent_fan_turn_on_without_percentage_picks_lowest_speed():
|
|
resources = _load_device('microwave_me7500d')
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _microwave_entity(resources, coordinator)
|
|
|
|
await entity.async_turn_on()
|
|
|
|
assert coordinator.commands[-1][1] == ('speed', '1')
|
|
|
|
|
|
async def test_microwave_vent_fan_set_percentage_writes_speed_only():
|
|
resources = _load_device('microwave_me7500d')
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _microwave_entity(resources, coordinator)
|
|
|
|
await entity.async_set_percentage(100)
|
|
|
|
assert coordinator.commands == [(entity._bound, ('speed', '4'))]
|
|
|
|
|
|
async def test_microwave_vent_fan_turn_on_with_percentage_writes_speed_directly():
|
|
resources = _load_device('microwave_me7500d')
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _microwave_entity(resources, coordinator)
|
|
|
|
await entity.async_turn_on(percentage=75)
|
|
|
|
assert coordinator.commands == [(entity._bound, ('speed', '3'))]
|
|
|
|
|
|
async def test_microwave_vent_fan_turn_on_without_percentage_when_already_on_is_a_noop():
|
|
"""A scene or automation calling fan.turn_on on an already-running vent
|
|
fan must not reset it to the lowest speed."""
|
|
resources = _load_device('microwave_me7500d')
|
|
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '3'
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _microwave_entity(resources, coordinator)
|
|
|
|
await entity.async_turn_on()
|
|
|
|
assert coordinator.commands == []
|
|
|
|
|
|
async def test_microwave_vent_fan_set_percentage_zero_turns_off():
|
|
resources = _load_device('microwave_me7500d')
|
|
resources['/hood/fanspeed/vs/0']['x.com.samsung.da.hood.fanSpeed'] = '2'
|
|
coordinator = _FakeCoordinator(resources)
|
|
entity = _microwave_entity(resources, coordinator)
|
|
|
|
await entity.async_set_percentage(0)
|
|
|
|
assert coordinator.commands[-1][1] == ('speed', '0')
|