#146's zero-as-"not measuring" carve-out was meant for ARTIK051 boards' plain x.com.samsung.da.humidity field, which only populates while Air monitoring is briefly on and zeroes out afterward. It was accidentally applied to fivepercentHumidity too, which every other AC board relies on and which has never been documented getting stuck at zero -- so a real 0% reading on those boards silently became "unknown". Only the humidity fallback field now collapses 0; fivepercentHumidity passes 0 through as a real reading.
429 lines
19 KiB
Python
429 lines
19 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.rep_fn(rep) == 42.0
|
|
|
|
|
|
def test_humidity_falls_back_to_the_plain_field_where_five_percent_is_absent():
|
|
"""ARTIK051 boards (issue #136) have no fivepercentHumidity field at all.
|
|
Their plain field is not stuck -- it carries a reading while Air monitoring
|
|
is on -- so 0 means "not measuring" on both generations, not 0% humidity."""
|
|
desc = airconditioner.HUMIDITY.entities[0]
|
|
assert desc.rep_fn({'x.com.samsung.da.humidity': '51'}) == 51.0
|
|
assert desc.rep_fn({'x.com.samsung.da.humidity': '0'}) is None
|
|
assert desc.rep_fn({}) is None
|
|
|
|
|
|
def test_humidity_five_percent_field_passes_a_genuine_zero_through():
|
|
"""issue #160: fivepercentHumidity's zero-as-"not measuring" carve-out
|
|
(added in #146 to cover ARTIK051's plain humidity field) was
|
|
over-applied to fivepercentHumidity too, silently turning a real 0%
|
|
reading on every other AC board into unknown. Only the humidity
|
|
fallback field collapses 0 -- fivepercentHumidity's 0 is a real
|
|
reading."""
|
|
desc = airconditioner.HUMIDITY.entities[0]
|
|
assert desc.rep_fn({'x.com.samsung.da.fivepercentHumidity': '0'}) == 0.0
|