Finishes PR #91's contribution (pedroperosin) with the requested review changes applied, on our own branch: - Preset resolution is now fully dynamic, read from each unit's own /mode/convenient/vs/0 supportedModes instead of a static per-model table -- any board's convenient modes (including WindFree's Nano/ NanoSleep) surface without code changes. Unlabelled codes across existing fixtures (longwind, motionindirect, motiondirect, drycomfort) plus the two new WindFree ones are added to en.json/nl.json. - 'Auto' now maps to HVACMode.AUTO instead of HEAT_COOL: these are single-setpoint "device decides" units, not two-setpoint heat+cool ones. 'Fan' is added alongside 'Wind' as a second FAN_ONLY spelling; a new _device_code_for_hvac() picks the code from the unit's own supportedModes since the flat map can't disambiguate two device codes mapping to one HA value. - Detection gains a hyphenated '-RAC-' modelNum fallback (alongside the existing '_RAC_') for cool-only global RAC variants whose /otninformation/vs/0 ships no swVersionInfo block. - Adds a display_light switch sourced from /mode/vs/0's opaque options blob (inverted Light_On/Light_Off token) for boards with no dedicated /light/vs/0 switch. Write-path behavioural change (touches the path iterated on across #9/#17/#27/#38/#54): power now targets the vendor /power/vs/0 instead of the OCF /power/0, and _is_on() reads vendor-first. /power/0 is absent on several known AC boards, so the previous OCF-first read/write pair could report and act on stale state -- consistent with #53's "can turn on but not off". Target temperature now picks its channel (OCF pair vs. vendor items[]) based on which the unit actually reports, reading and writing the same one. The vendor temperature write and the new display-light write both carry only the changed field(s), not the whole resource -- confirmed sufficient on the wire, the device merges the rest itself. That requires the coordinator's optimistic cache to do the same merge on the read side so a setpoint change doesn't blank out current/min/max/unit for the settle window: common.py gains merge_items_field() next to the existing merge_options_field(), and async_send_command wires it in for any write touching x.com.samsung.da.items. New fixture/golden for the cool-only global RAC variant (TP1X_DA-AC-RAC- 01001, AI_RAC_GLOBAL_COOLONLY_3.0); display_light added to the goldens for boards that gain the new options-based switch. Regenerated against current main rather than copied from the original PR, since those goldens had already shifted (current_temperature_c/humidity from issue #75).
408 lines
18 KiB
Python
408 lines
18 KiB
Python
"""Tests for Samsung air-conditioner support (issue #17).
|
|
|
|
These stay HA-free like the rest of the suite: they exercise the registry,
|
|
discovery/flatten, and the CLIMATE capability's write contract. The composite
|
|
climate entity itself lives in climate.py (imports homeassistant) and is not
|
|
importable here -- consistent with how the other HA platform files are untested.
|
|
"""
|
|
from custom_components.localthings.registry.adapter import flatten
|
|
from custom_components.localthings.registry.by_type import for_device, for_device_by_model
|
|
from custom_components.localthings.registry.capabilities import airconditioner
|
|
from custom_components.localthings.registry.discovery import discover
|
|
from custom_components.localthings.registry.entities import ClimateDesc
|
|
|
|
from tests.conftest import _load_device
|
|
|
|
|
|
def _ac():
|
|
resources = _load_device('airconditioner')
|
|
info = resources['/information/vs/0']
|
|
reg = for_device_by_model(
|
|
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
|
)
|
|
return reg, resources
|
|
|
|
|
|
def _resolve(name):
|
|
"""Mirror the coordinator's detection order: oneUiVersion first, modelNum
|
|
fallback second (needed for issue #37's board, which reports neither
|
|
oneUiVersion nor a '_PRAC_' modelNum token)."""
|
|
resources = _load_device(name)
|
|
otn = resources.get('/otninformation/vs/0', {})
|
|
one_ui = otn.get('swVersionInfo', {}).get('oneUiVersion', '')
|
|
info = resources['/information/vs/0']
|
|
reg = for_device(one_ui) if one_ui else None
|
|
if reg is None:
|
|
reg = for_device_by_model(
|
|
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
|
)
|
|
return reg, resources
|
|
|
|
|
|
def _bound():
|
|
reg, resources = _ac()
|
|
return discover(resources, reg.capabilities, reg.pattern_capabilities), resources
|
|
|
|
|
|
def _state():
|
|
bound, resources = _bound()
|
|
return flatten(bound, resources)
|
|
|
|
|
|
def test_ac_model_resolves_to_airconditioner_registry():
|
|
reg, _ = _ac()
|
|
assert reg is not None and reg.name == 'airconditioner'
|
|
|
|
|
|
def test_no_unbound_hrefs():
|
|
"""Every resource in the issue #17 dump binds or is covered -- clears the
|
|
coverage-gap repair."""
|
|
reg, resources = _ac()
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_climate_entity_is_bound():
|
|
"""The composite climate entity binds the primary /mode/vs/0 resource."""
|
|
bound, _ = _bound()
|
|
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
|
|
assert len(climate) == 1
|
|
assert climate[0].href == '/mode/vs/0'
|
|
|
|
|
|
def test_expected_state_keys_present():
|
|
state = _state()
|
|
for key in ('climate', 'air_purify', 'auto_clean', 'air_filter_status',
|
|
'air_filter_usage', 'diagnosis_status', 'alarm_code', 'energy_kwh'):
|
|
assert key in state, key
|
|
|
|
|
|
def test_power_and_convenient_folded_into_climate():
|
|
"""On/off is the climate entity's HVACMode.OFF and convenient mode is its
|
|
preset_mode -- neither surfaces as a standalone switch/select."""
|
|
state = _state()
|
|
assert 'power_switch' not in state
|
|
assert 'convenient_mode' not in state
|
|
|
|
|
|
def test_air_filter_usage_is_percentage_of_capacity():
|
|
"""filterUsage is a raw count in the capacity unit (100 of 500), surfaced as
|
|
a percentage rather than the misleading raw value."""
|
|
assert _state()['air_filter_usage'] == 20
|
|
|
|
|
|
def test_climate_write_targets():
|
|
"""The CLIMATE write_fn maps each (kind, value) command to the right vendor
|
|
POST target and body. `value` is already the raw device code. Power and
|
|
temperature target the vendor /power/vs/0 and /temperatures/vs/0 (the OCF
|
|
/power/0 is absent on most boards and a non-authoritative mirror where
|
|
present; /temperature/desired/0 is only written via the temperature_ocf
|
|
kind, on boards that have the OCF pair)."""
|
|
write = airconditioner.CLIMATE.entities[0].write_fn
|
|
assert write(('power', True), {}) == (
|
|
['power', 'vs', '0'], {'x.com.samsung.da.power': 'On'})
|
|
assert write(('power', False), {}) == (
|
|
['power', 'vs', '0'], {'x.com.samsung.da.power': 'Off'})
|
|
assert write(('mode', 'Heat'), {}) == (
|
|
['mode', 'vs', '0'], {'x.com.samsung.da.modes': ['Heat']})
|
|
# OCF-pair boards: temperature_ocf -> /temperature/desired/0.
|
|
assert write(('temperature_ocf', 23.6), {}) == (
|
|
['temperature', 'desired', '0'], {'temperature': 24})
|
|
# Vendor boards: temperature -> /temperatures/vs/0, carrying only the id
|
|
# and the changed field -- the device merges current/min/max/unit itself
|
|
# (see common.merge_items_field, wired into async_send_command, for the
|
|
# read-side half that keeps the optimistic cache complete).
|
|
assert write(('temperature', 22), {}) == (
|
|
['temperatures', 'vs', '0'],
|
|
{'x.com.samsung.da.items': [
|
|
{'x.com.samsung.da.id': '0', 'x.com.samsung.da.desired': '22'}]})
|
|
assert write(('fan', '2'), {}) == (
|
|
['wind', 'strength', 'vs', '0'], {'x.com.samsung.da.modes': '2'})
|
|
assert write(('swing', 'All'), {}) == (
|
|
['wind', 'direction', 'vs', '0'], {'x.com.samsung.da.modes': 'All'})
|
|
assert write(('preset', 'Sleep'), {}) == (
|
|
['mode', 'convenient', 'vs', '0'], {'x.com.samsung.da.modes': 'Sleep'})
|
|
assert write(('bogus', 1), {}) is None
|
|
|
|
|
|
def test_climate_consumed_hrefs_declared_as_coverage():
|
|
"""The climate-consumed and ambiguous hrefs are declared in the AC registry
|
|
(as no-entity coverage caps) so they don't leak as gaps -- but produce no
|
|
standalone entities. /temperature/current/0 and /temperatures/vs/0 are
|
|
NOT in this list -- CURRENT_TEMPERATURE / CURRENT_TEMPERATURE_VS give
|
|
those two real sensor entities (issue #75)."""
|
|
reg, _ = _ac()
|
|
for href in ('/power/0', '/power/vs/0', '/temperature/desired/0',
|
|
'/wind/strength/vs/0', '/mode/convenient/vs/0',
|
|
'/sensors/vs/0', '/humidity/0'):
|
|
caps = reg.capabilities.get(href)
|
|
assert caps, href
|
|
assert all(c.entities == () for c in caps), href
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TP1X_DA-AC-RAC-01011 (oneUiVersion "7.0 Air conditioner", Tizen Lite) -- a
|
|
# newer model class than the ARTIK051_PRAC dump above. It has no OCF-standard
|
|
# /temperature/current+desired pair (temperature lives on the vendor
|
|
# /temperatures/vs/0 items[] resource), exposes a /light/vs/0 display light, and
|
|
# carries extra vendor housekeeping hrefs. Issue #17 for this class (PR #36).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _ac_tp1x():
|
|
resources = _load_device('airconditioner_tp1x_da_ac_rac_01011')
|
|
one_ui = resources['/otninformation/vs/0']['swVersionInfo']['oneUiVersion']
|
|
return for_device(one_ui), resources
|
|
|
|
|
|
def test_tp1x_resolves_to_airconditioner_registry():
|
|
reg, _ = _ac_tp1x()
|
|
assert reg is not None and reg.name == 'airconditioner'
|
|
|
|
|
|
def test_tp1x_no_unbound_hrefs():
|
|
"""Every resource in the TP1X dump binds or is covered -- including
|
|
/temperatures/vs/0, /light/vs/0 and the housekeeping hrefs absent from
|
|
the ARTIK051 dump. Clears the coverage-gap repair."""
|
|
reg, resources = _ac_tp1x()
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_tp1x_display_light_switch_present():
|
|
"""/light/vs/0 (mode On/Off) surfaces as the display-light switch."""
|
|
reg, resources = _ac_tp1x()
|
|
state = flatten(
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
|
assert state.get('display_light') is True # device reports mode == 'On'
|
|
|
|
|
|
def test_tp1x_vendor_temperature_and_light_covered():
|
|
"""The vendor temperature resource (read by the climate entity) and the
|
|
display-light resource both resolve in the registry -- no gap."""
|
|
reg, _ = _ac_tp1x()
|
|
assert reg.capabilities.get('/temperatures/vs/0'), '/temperatures/vs/0'
|
|
assert reg.capabilities.get('/light/vs/0'), '/light/vs/0'
|
|
|
|
|
|
def test_tp1x_climate_entity_is_bound():
|
|
"""The composite climate entity still binds the primary /mode/vs/0."""
|
|
reg, resources = _ac_tp1x()
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
|
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
|
|
assert len(climate) == 1 and climate[0].href == '/mode/vs/0'
|
|
|
|
|
|
def test_tp2x_rac_20k_model_resolves_via_model_fallback():
|
|
"""TP2X_RAC_20K (issue #37) reports no oneUiVersion and no '_PRAC_' token
|
|
-- resolved via the '_RAC_' modelNum fallback added for this device."""
|
|
reg, _ = _resolve('airconditioner_tp2x_rac_20k')
|
|
assert reg is not None and reg.name == 'airconditioner'
|
|
|
|
|
|
def test_tp2x_rac_20k_no_unbound_hrefs():
|
|
reg, resources = _resolve('airconditioner_tp2x_rac_20k')
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_tp1x_rac_model_resolves_via_one_ui_version():
|
|
"""TP1X_DA-AC-RAC-01001_0000 (issue #38) self-reports oneUiVersion
|
|
'7.0 Air conditioner' -- resolved via for_device(), not the modelNum
|
|
fallback."""
|
|
reg, _ = _resolve('airconditioner_tp1x_rac')
|
|
assert reg is not None and reg.name == 'airconditioner'
|
|
|
|
|
|
def test_tp1x_rac_no_unbound_hrefs():
|
|
reg, resources = _resolve('airconditioner_tp1x_rac')
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_tp1x_rac_expected_state_keys_present():
|
|
reg, resources = _resolve('airconditioner_tp1x_rac')
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
|
state = flatten(bound, resources)
|
|
for key in ('climate', 'display_light', 'mute_once', 'selfcheck_status',
|
|
'selfcheck_result', 'current_limit_enabled', 'current_limit_level'):
|
|
assert key in state, key
|
|
|
|
|
|
def test_caww_tp2_model_resolves_via_model_fallback():
|
|
"""A-CAWW-TP2-20-COMMON (issue #52, System AC) reports no oneUiVersion
|
|
and no '_RAC_'/'_PRAC_' token -- resolved via the '-CAWW-' modelNum
|
|
fallback added for this device."""
|
|
reg, _ = _resolve('airconditioner_caww_tp2')
|
|
assert reg is not None and reg.name == 'airconditioner'
|
|
|
|
|
|
def test_caww_tp2_no_unbound_hrefs():
|
|
"""Every resource in the issue #52 dump binds or is ignored -- clears
|
|
the coverage-gap repair. Only new href beyond the existing RAC/PRAC
|
|
surface is /sac/installationinfo/vs/0 (opaque SAC installation topology,
|
|
ignored)."""
|
|
reg, resources = _resolve('airconditioner_caww_tp2')
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_caww_tp2_sac_installationinfo_is_ignored():
|
|
ignored_hrefs = {cap.href for cap in airconditioner.COVERAGE}
|
|
assert '/sac/installationinfo/vs/0' in ignored_hrefs
|
|
|
|
|
|
def test_mute_once_write_target():
|
|
write = airconditioner.MUTE_ONCE.entities[0].write_fn
|
|
assert write('On', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'On'})
|
|
assert write('Off', {}) == (['option', 'muteonce', 'vs', '0'], {'muteonce': 'Off'})
|
|
|
|
|
|
def test_display_light_write_target():
|
|
write = airconditioner.DISPLAY_LIGHT.entities[0].write_fn
|
|
assert write('On', {}) == (['light', 'vs', '0'], {'mode': 'On'})
|
|
assert write('Off', {}) == (['light', 'vs', '0'], {'mode': 'Off'})
|
|
|
|
|
|
def test_current_limit_is_read_only():
|
|
"""Meaning/write contract for the current-limit levels isn't confirmed
|
|
from the dump alone -- exposed as read-only diagnostic sensors rather
|
|
than a guessed writable control."""
|
|
for desc in airconditioner.CURRENT_LIMIT.entities:
|
|
assert getattr(desc, 'write_fn', None) is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# TP1X_DA-AC-RAC-01001 cool-only global variant (issue #91). Same modelNum as
|
|
# the issue #38 board above, but its /otninformation/vs/0 ships no
|
|
# swVersionInfo block, so oneUiVersion is empty and detection must fall back
|
|
# to the hyphenated '-RAC-' modelNum token (the older '_RAC_' underscore match
|
|
# doesn't fire on this DA-AC-RAC spelling). Adds /stepcontrol/vs/0 and
|
|
# /remotedeviceinfo/vs/0 (both ignored) and exposes the WindFree preset via
|
|
# the Nano/NanoSleep convenient-mode codes. Its panel light is carried inside
|
|
# /mode/vs/0's options blob instead of a dedicated /light/vs/0 switch.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_tp1x_rac_coolonly_resolves_via_hyphenated_model_fallback():
|
|
"""Empty oneUiVersion -> resolved by the '-RAC-' modelNum token, not
|
|
for_device(). Guards the regression where this unit loaded as 'unknown'."""
|
|
resources = _load_device('airconditioner_tp1x_rac_coolonly')
|
|
otn = resources.get('/otninformation/vs/0', {})
|
|
assert otn.get('swVersionInfo', {}).get('oneUiVersion', '') == ''
|
|
reg, _ = _resolve('airconditioner_tp1x_rac_coolonly')
|
|
assert reg is not None and reg.name == 'airconditioner'
|
|
|
|
|
|
def test_tp1x_rac_coolonly_no_unbound_hrefs():
|
|
"""Every resource binds or is ignored -- including the two hrefs unique
|
|
to this dump (/stepcontrol/vs/0, /remotedeviceinfo/vs/0). Clears the gap
|
|
repair."""
|
|
reg, resources = _resolve('airconditioner_tp1x_rac_coolonly')
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_tp1x_rac_coolonly_stray_hrefs_ignored():
|
|
ignored_hrefs = {cap.href for cap in airconditioner.COVERAGE}
|
|
assert '/stepcontrol/vs/0' in ignored_hrefs
|
|
assert '/remotedeviceinfo/vs/0' in ignored_hrefs
|
|
|
|
|
|
def test_tp1x_rac_coolonly_climate_bound():
|
|
reg, resources = _resolve('airconditioner_tp1x_rac_coolonly')
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
|
climate = [b for b in bound if isinstance(b.desc, ClimateDesc)]
|
|
assert len(climate) == 1 and climate[0].href == '/mode/vs/0'
|
|
|
|
|
|
def test_tp1x_rac_coolonly_display_light_from_mode_options():
|
|
"""This board has no /light/vs/0 switch; the panel light lives in
|
|
/mode/vs/0's options and surfaces as a display_light switch. The token is
|
|
inverted vs its name (confirmed by a live toggle test): with the panel
|
|
lit the option reads `Light_Off`, and with it dark it reads `Light_On`."""
|
|
reg, resources = _resolve('airconditioner_tp1x_rac_coolonly')
|
|
state = flatten(discover(resources, reg.capabilities, reg.pattern_capabilities), resources)
|
|
assert state.get('display_light') is True
|
|
|
|
|
|
def test_display_light_option_parsing_and_gating():
|
|
# Inverted token: Light_Off -> panel lit (on), Light_On -> panel dark (off).
|
|
lit = {'x.com.samsung.da.options': ['CoolCapa_35', 'Light_Off', 'Volume_Mute']}
|
|
dark = {'x.com.samsung.da.options': ['Light_On']}
|
|
absent = {'x.com.samsung.da.options': ['Volume_Mute']}
|
|
assert airconditioner._display_light_on(lit) is True
|
|
assert airconditioner._display_light_on(dark) is False
|
|
assert airconditioner._display_light_on(absent) is None
|
|
assert airconditioner._has_display_light_option(lit, {}) is True
|
|
assert airconditioner._has_display_light_option(absent, {}) is False
|
|
|
|
|
|
def test_mode_options_display_light_write_is_inverted_single_token():
|
|
"""Turning the lamp ON writes the inverted 'Light_Off' token as a
|
|
single-element options list (single-token merge); OFF writes 'Light_On'."""
|
|
sw = next(e for e in airconditioner.CLIMATE.entities if e.key == 'display_light')
|
|
assert sw.write_fn('On', {}) == (
|
|
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Light_Off']})
|
|
assert sw.write_fn('Off', {}) == (
|
|
['mode', 'vs', '0'], {'x.com.samsung.da.options': ['Light_On']})
|
|
|
|
|
|
def test_light_switch_board_gates_out_mode_options_light():
|
|
"""Boards with a real /light/vs/0 switch carry no Light_* option, so the
|
|
mode-options display-light entity doesn't double up (mutually exclusive
|
|
encodings)."""
|
|
reg, resources = _resolve('airconditioner_tp1x_rac')
|
|
assert airconditioner._has_display_light_option(resources['/mode/vs/0'], resources) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WindFree unit (issue #75): same ARTIK051_PRAC_20K modelNum family as the
|
|
# original issue #17 fixture, but its /mode/convenient/vs/0 additionally
|
|
# supports Nano/NanoSleep/MotionDirect/MotionIndirect, /wind/direction/vs/0
|
|
# additionally supports Left_And_Right, and /humidity/vs/0's
|
|
# fivepercentHumidity is populated (unlike the all-zero original dump).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _ac_windfree():
|
|
resources = _load_device('airconditioner_windfree')
|
|
info = resources['/information/vs/0']
|
|
reg = for_device_by_model(
|
|
info['x.com.samsung.da.modelNum'], info['x.com.samsung.da.description'],
|
|
)
|
|
return reg, resources
|
|
|
|
|
|
def test_windfree_no_unbound_hrefs():
|
|
reg, resources = _ac_windfree()
|
|
unbound = []
|
|
discover(resources, reg.capabilities, reg.pattern_capabilities, log=unbound.append)
|
|
assert unbound == []
|
|
|
|
|
|
def test_windfree_humidity_and_temperature_sensors_present():
|
|
reg, resources = _ac_windfree()
|
|
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
|
|
state = flatten(bound, resources)
|
|
assert state['humidity'] == 42.0 # fivepercentHumidity, not the stuck humidity=0 field
|
|
assert state['current_temperature_c'] == 27.0
|
|
|
|
|
|
def test_current_temperature_vs_only_binds_when_ocf_href_absent():
|
|
"""CURRENT_TEMPERATURE_VS's match_fn must not double-bind alongside
|
|
CURRENT_TEMPERATURE when a device (like this one) reports both
|
|
/temperature/current/0 and /temperatures/vs/0."""
|
|
match = airconditioner.CURRENT_TEMPERATURE_VS.match_fn
|
|
assert match({}, {'/temperature/current/0': {}}) is False
|
|
assert match({}, {}) is True
|
|
|
|
|
|
def test_humidity_reads_five_percent_field_not_stuck_humidity_field():
|
|
desc = airconditioner.HUMIDITY.entities[0]
|
|
rep = {'x.com.samsung.da.humidity': '0', 'x.com.samsung.da.fivepercentHumidity': '42'}
|
|
assert desc.value_fn(rep.get(desc.field)) == 42.0
|