Files
localthings/tests/test_airconditioner_artik051_krac.py
perseus177 eed04faaed fix(climate): derive presets only when the board publishes both capability maps
One map is not enough to judge by: with only OptionCode present, every
eoc-gated rule reads None, and None means the board does not publish the map
rather than that the feature is absent. artik051_dongle_fac_18k is exactly that
board and lost WindFree in every mode. Requiring both also keeps these bit
positions inside the family they were documented for -- the FAC and CAC dumps
carry only the older map, with values small enough that RAC positions read as
zeros.

Also from review: an unknown HVAC mode falls back the same way, Comfort is
spelled like the identical Speed rule, the unreachable AIComfort branch is
gone, the Cool code comes from the unit's own supportedModes, DlightCool gains
its catalog entry, and the Single User claim is dropped -- the app's own Single
User command sends Comode_Smart, so there is no distinct token to write.
2026-08-06 12:04:46 +02:00

722 lines
30 KiB
Python

"""ARTIK051_KRAC_18K room air conditioner (issue #136).
This board generation predates every AC dump the registry was built from and
differs from all of them in three ways, each covered below:
* No ``/wind/*`` resources at all -- fan speed and vane direction share one
``/airflow/vs/0`` resource (``speedLevel`` on the same 0-4 scale as
``_DEVICE_TO_FAN``, ``direction`` with the same codes as ``_DEVICE_TO_SWING``).
* No ``/mode/convenient/vs/0`` -- the convenient-mode preset is a ``Comode_*``
token in ``/mode/vs/0``'s ``options`` array.
* Several settings (SPI, auto clean, air monitoring, beep volume, Good Sleep,
outdoor temperature, filter time and its alarm interval) are ``options``
tokens too, where newer boards have dedicated resources.
Fan, swing, preset, SPI and beep-volume writes were all confirmed on hardware
by read-back on the unit this fixture is dumped from. The issue #136 unit is
the same model with a slightly different token set (no ``Spi``,
``FilterTime_5460``, ``OutdoorTemp_81``), which the token entities' presence
gating handles the same way it handles newer boards.
"""
from typing import ClassVar, cast
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.adapter import flatten
from custom_components.localthings.registry.by_type import (
airconditioner,
for_device_by_model,
)
from custom_components.localthings.registry.capabilities.airconditioner import (
HREF_AIRFLOW,
HREF_WIND_STRENGTH,
_option_number_write,
_option_switch_write,
is_legacy_board,
)
from custom_components.localthings.registry.discovery import discover
from custom_components.localthings.registry.entities import ClimateDesc
from tests.conftest import _load_device
FIXTURE = "airconditioner_artik051_krac_18k"
MODEL = "ARTIK051_KRAC_18K|10193441|60010119001111010100000000000000"
class _FakeCoordinator:
device_serial = "TEST-KRAC-SERIAL"
device_info: ClassVar[dict] = {}
data: ClassVar[dict] = {}
def __init__(self, resources):
self.last_resources = resources
self.commands = []
def resource(self, href):
return self.last_resources.get(href, {})
def canonical_resources(self, subdevice):
# Every bound entity in this test uses the default MAIN
# subdevice, 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 _discover(resources, registry=airconditioner.REGISTRY):
unbound = []
bound = discover(
resources, registry.capabilities, registry.pattern_capabilities, log=unbound.append
)
return bound, unbound
def _state(fixture=FIXTURE):
resources = _load_device(fixture)
bound, _ = _discover(resources)
return flatten(bound, resources)
def _climate(resources, coordinator=None):
bound, _ = _discover(resources)
climate_bound = next(item for item in bound if isinstance(item.desc, ClimateDesc))
return LocalThingsClimate(
cast(LocalThingsCoordinator, coordinator or _FakeCoordinator(resources)),
climate_bound,
)
# -- device type --------------------------------------------------------------
def test_krac_model_resolves_to_the_airconditioner_registry():
"""The '_RAC_' token check can't see '_KRAC_' -- the 'K' sits between the
underscore and 'RAC' -- and the consumer-prefix fallback only covers
washers/dryers/dishwashers, so this model resolved to 'unknown' and
exposed nothing but power."""
registry = for_device_by_model(MODEL, "ARTIK051_KRAC_18K")
assert registry is not None
assert registry.name == "airconditioner"
def test_no_unbound_hrefs():
_, unbound = _discover(_load_device(FIXTURE))
assert unbound == []
# -- option-token entities ----------------------------------------------------
def test_token_entities_present_with_calibrated_values():
state = _state()
# token/10 hours: 1715 displayed as "171 hours 0 minutes"... at 1710 in the
# official app on this unit, which pins the scale (the .5 here is a later
# reading). It counts up -- see the descriptor comment and
# test_filter_alarm_tracks_the_counter_against_its_threshold below.
assert state["filter_time"] == 171.5
# token - 55 == 19 C, against a 19.4 C forecast at the time of the dump.
assert state["outdoor_temperature"] == 19.0
assert state["beep"] is True
assert state["good_sleep"] == 0.0
assert state["spi"] is False
assert state["auto_clean_legacy"] is False
assert state["air_monitoring"] is False
def test_token_entities_stay_off_newer_boards():
"""Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too,
while also exposing those settings as dedicated resources -- ungated, the
token entities would duplicate them (auto clean) or apply a scale
calibrated on another board generation (outdoor temperature)."""
state = _state("airconditioner_tp1x_rac")
for key in (
"spi",
"auto_clean_legacy",
"air_monitoring",
"good_sleep",
"outdoor_temperature",
"filter_time",
):
assert key not in state, key
def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board():
"""issue #161: climate.py's _legacy_airflow() delegates to
capabilities/airconditioner.py's is_legacy_board() instead of
re-implementing the same presence/absence check, so the token entities
and the climate card's legacy read/write paths can't drift apart on
which board generation is in play."""
legacy_resources = _load_device(FIXTURE)
assert is_legacy_board(legacy_resources) is True
assert _climate(legacy_resources)._legacy_airflow() == legacy_resources[HREF_AIRFLOW]
newer_resources = _load_device("airconditioner_tp1x_rac")
assert is_legacy_board(newer_resources) is False
assert _climate(newer_resources)._legacy_airflow() == {}
assert HREF_WIND_STRENGTH in newer_resources
def test_absent_token_yields_no_entity():
"""The issue #136 unit of this same model reports no Spi token."""
resources = _load_device(FIXTURE)
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
option for option in options if not option.startswith("Spi_")
]
bound, _ = _discover(resources)
assert "spi" not in flatten(bound, resources)
def test_humidity_reads_the_vendor_field_and_treats_zero_as_unknown():
"""This board has no fivepercentHumidity field; the plain humidity field
only carries a reading while Air monitoring is on, and the unit switches
that back off by itself after about a minute."""
resources = _load_device(FIXTURE)
assert resources["/humidity/vs/0"]["x.com.samsung.da.humidity"] == "0"
bound, _ = _discover(resources)
assert flatten(bound, resources)["humidity"] is None
resources["/humidity/vs/0"]["x.com.samsung.da.humidity"] = "51"
bound, _ = _discover(resources)
assert flatten(bound, resources)["humidity"] == 51.0
def test_option_writes_carry_one_token():
"""Both go through option_write's single-token merge, the same mechanism
the display light already uses on this href. Confirmed on hardware by
read-back: Spi_On/Spi_Off, and the volume token surviving a write."""
assert _option_switch_write("Spi")("On", {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["Spi_On"]},
)
# The number platform hands over a float; the device wants an integer token.
assert _option_number_write("Volume")(70.0, {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["Volume_70"]},
)
# -- filter counter and its reset ---------------------------------------------
def _desc(resources, key):
"""The bound descriptor for `key`, or None when its exists_fn declines it.
flatten() gives values, not descriptors, so this is how a test reaches a
descriptor's own write_fn and options without standing up an HA entity.
"""
bound, _ = _discover(resources)
for item in bound:
if item.desc.key == key and (
item.desc.exists_fn is None
or item.desc.exists_fn(resources.get(item.href) or {}, resources)
):
return item.desc
return None
def test_good_sleep_is_hours_while_the_token_counts_half_hours():
"""The appliance's own app pairs its duration picker with the values it sends
one to one -- 0:30 -> 1, 1:00 -> 2, 3:00 -> 6, 12:00 -> 24 -- so the token is
half hours and the entity, which is in hours, has to halve and double. The
fixture's own Sleep_0 is the one value that reads the same either way, hence
the injected token here."""
resources = _load_device(FIXTURE)
mode = resources["/mode/vs/0"]
mode["x.com.samsung.da.options"] = [
option for option in mode["x.com.samsung.da.options"] if option != "Sleep_0"
] + ["Sleep_5"]
bound, _ = _discover(resources)
assert flatten(bound, resources)["good_sleep"] == 2.5
desc = _desc(resources, "good_sleep")
assert (desc.native_max, desc.step) == (12, 0.5)
assert desc.write_fn(2.5, {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["Comode_Sleep", "Sleep_5"]},
)
# 12 hours is the app's maximum and has to be reachable -- it was not while
# the token was published as hours.
assert desc.write_fn(12, {})[1]["x.com.samsung.da.options"] == ["Comode_Sleep", "Sleep_24"]
def _options(rep_options):
return {"x.com.samsung.da.options": list(rep_options)}
def test_good_sleep_write_carries_the_mode_token_the_duration_belongs_to():
"""`Sleep_<n>` on its own does nothing. Measured on an ARTIK051_KRAC_18K:
writing `["Sleep_4"]` was answered 2.04 Changed and the token still read
`Sleep_0` at +8s and +45s, while `["Comode_Sleep", "Sleep_4"]` held. The
duration is a parameter of the mode, so both go in one write -- which is
also the only form the appliance's own app sends."""
desc = _desc(_load_device(FIXTURE), "good_sleep")
assert desc.write_fn(2, _options(["Comode_Off", "Sleep_0"])) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["Comode_Sleep", "Sleep_4"]},
)
# Off means leaving the mode as well as zeroing the duration.
assert desc.write_fn(0, _options(["Comode_Sleep", "Sleep_4"]))[1] == _options(
["Comode_Off", "Sleep_0"]
)
def test_good_sleep_and_nano_wind_share_one_token():
"""Nano wind and Good Sleep are one Comode_ slot, so running both is
Comode_NanoSleep -- the board produces that code by itself when nano is
asked for while the timer runs. Turning the timer off then has to leave nano
running rather than switching the mode off entirely, which is how the app
reads it back."""
desc = _desc(_load_device(FIXTURE), "good_sleep")
for comode in ("Comode_Nano", "Comode_NanoSleep"):
assert desc.write_fn(2, _options([comode, "Sleep_0"]))[1] == _options(
["Comode_NanoSleep", "Sleep_4"]
)
assert desc.write_fn(0, _options(["Comode_NanoSleep", "Sleep_4"]))[1] == _options(
["Comode_Nano", "Sleep_0"]
)
def test_filter_alarm_time_reads_the_threshold_and_writes_one_token():
"""The interval FilterTime_ is measured against, offered by the app as a
180/300/500/700 hour radio. All four were walked on hardware while watching
all 19 resources: the token carries the hour count verbatim and each step
moved that one field and nothing else, which is also what makes a static
options tuple defensible here (the board advertises no supported-values
list for options[] tokens)."""
state = _state()
assert state["filter_alarm_time"] == "500" # the fixture's own value
desc = _desc(_load_device(FIXTURE), "filter_alarm_time")
assert desc.options == ("180", "300", "500", "700")
assert desc.write_fn("180", {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["FilterAlarmTime_180"]},
)
def test_auto_clean_progress_and_stop_come_off_their_own_tokens():
"""Three tokens describe the drying cycle and the switch only covered the
first. AutocleanProgress_ is a percentage -- the app renders it into a
`<progress max="100">` beside a "{{value}}%" label -- and StopAutoClean_ is
the channel for ending a cycle early, whose presence is what says the
appliance accepts that at all (the fixture reports Idle, this unit's
resting value)."""
assert _state()["auto_clean_progress_legacy"] == 1.0 # the fixture's own value
desc = _desc(_load_device(FIXTURE), "auto_clean_stop")
assert desc is not None
assert desc.write_fn(desc.payload, {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["StopAutoClean_Set"]},
)
def test_auto_clean_stop_stays_off_boards_without_the_token():
"""Newer boards run the same cycle off /option/autoclean/vs/0 and say
nothing about stopping it, so writing a legacy token there would be a
guess."""
newer = _load_device("airconditioner_tp1x_rac")
assert _desc(newer, "auto_clean_stop") is None
assert "auto_clean_progress_legacy" not in _state("airconditioner_tp1x_rac")
def test_filter_time_reset_writes_the_appliance_s_own_trigger_token():
"""FilterCleanAlarm_Clear, measured on hardware: 2.04 Changed and
FilterTime_95 -> FilterTime_0, still zero on a fresh session and every poll
after. The token is a trigger the board acts on rather than a value it
stores -- it never appears in options[] -- so the button is gated on the
counter's own token being present, which is what says this board has a
filter timer at all."""
desc = _desc(_load_device(FIXTURE), "filter_time_reset")
assert desc is not None
assert desc.write_fn(desc.payload, {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["FilterCleanAlarm_Clear"]},
)
def test_filter_time_reset_stays_off_boards_without_the_counter():
"""No FilterTime_ token, no counter to reset. Newer boards report filter
usage through their own resource and would need a different mechanism, so
offering a button that writes a legacy token there would be a guess."""
assert _desc(_load_device("airconditioner_tp1x_rac"), "filter_time_reset") is None
def test_filter_alarm_time_stays_off_boards_with_a_real_threshold_resource():
"""Newer boards carry air_filter_threshold off supportedFilterDesiredUsage;
two thresholds on one device would be a coin flip for the user.
No non-legacy fixture carries a FilterAlarmTime_ token today (only the two
KRAC dumps have one at all), so asserting on an unmodified newer board
would pass for the wrong reason -- absent token rather than the
board-generation gate. The token is injected to exercise the gate itself,
test_absent_token_yields_no_entity's technique in reverse."""
newer = _load_device("airconditioner_tp1x_rac")
assert _desc(newer, "filter_alarm_time") is None
mode = newer["/mode/vs/0"]
mode["x.com.samsung.da.options"] = [
*(mode.get("x.com.samsung.da.options") or []),
"FilterAlarmTime_500",
]
assert is_legacy_board(newer) is False
assert _desc(newer, "filter_alarm_time") is None
def test_filter_alarm_tracks_the_counter_against_its_threshold():
"""Why filter_time is read as elapsed rather than remaining: the same
options blob carries the threshold, and /alarms/vs/0's filter entry is a
'FilterAlarm_OFF'/'Deleted' placeholder below it. The sibling unit on the
same site, at FilterTime_5595 against the same FilterAlarmTime_500, instead
reported an unsuffixed 'FilterAlarm' in state 'Created'."""
resources = _load_device(FIXTURE)
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
assert "FilterTime_1715" in options
assert "FilterAlarmTime_500" in options
alarms = resources["/alarms/vs/0"]["x.com.samsung.da.items"]
filter_alarm = next(
item for item in alarms if item["x.com.samsung.da.code"].startswith("FilterAlarm")
)
assert filter_alarm["x.com.samsung.da.code"] == "FilterAlarm_OFF"
assert filter_alarm["x.com.samsung.da.state"] == "Deleted"
# -- climate entity: fan, swing and preset off /airflow/vs/0 ------------------
def test_fan_mode_reads_the_airflow_speed_level():
entity = _climate(_load_device(FIXTURE))
assert entity.fan_mode == "high" # speedLevel 3 in the fixture
# No supportedModes on this resource, so the full 0-4 scale is offered.
assert entity.fan_modes == ["auto", "low", "medium", "high", "turbo"]
def test_swing_mode_reads_the_airflow_direction():
resources = _load_device(FIXTURE)
entity = _climate(resources)
assert entity.swing_mode == "off" # 'Fix' in the fixture
resources[HREF_AIRFLOW]["x.com.samsung.da.direction"] = "All"
assert _climate(resources).swing_mode == "both"
assert "both" in _climate(resources).swing_modes
async def test_fan_and_swing_writes_target_the_airflow_resource():
resources = _load_device(FIXTURE)
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_fan_mode("turbo")
await entity.async_set_swing_mode("both")
assert [payload for _, payload in coordinator.commands] == [
("fan_legacy", "4"),
("swing_legacy", "All"),
]
def test_preset_comes_from_the_comode_token():
resources = _load_device(FIXTURE)
entity = _climate(resources)
assert entity.preset_mode == "none" # Comode_Off in the fixture
# The codes go through the same dynamic resolver as a real convenient
# resource's supportedModes, so 'Nano' resolves to the existing 'nano' preset
# -- already labelled WindFree in the catalog. Which of them are offered
# depends on the HVAC mode and on the unit's own capability bits; the fixture
# is in Cool, and test_presets_follow_the_hvac_mode_and_the_capability_bits
# covers every mode.
assert entity.preset_modes == [
"none",
"nano",
"speed",
"2step",
"quiet",
"comfort",
"smart",
"sleep",
"nanosleep",
]
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
"Comode_Nano" if option.startswith("Comode_") else option for option in options
]
assert _climate(resources).preset_mode == "nano"
async def test_preset_write_uses_the_token_path():
resources = _load_device(FIXTURE)
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_preset_mode("nano")
assert coordinator.commands[-1][1] == ("preset_legacy", "Nano")
def test_the_sleep_modes_are_presets_too():
"""Good Sleep lives in the same Comode_ slot as the presets, so a unit
running it reports a code that was not in the list -- and a preset_mode
outside preset_modes is not a state HA allows. Verified against a live unit:
with the board on Comode_Sleep, the entity reported preset_mode 'sleep'
while preset_modes offered only none/nano/quiet/comfort/2step/speed."""
resources = _load_device(FIXTURE)
assert _climate(resources).preset_modes[-2:] == ["sleep", "nanosleep"]
for token, preset in (("Comode_Sleep", "sleep"), ("Comode_NanoSleep", "nanosleep")):
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
token if option.startswith("Comode_") else option for option in options
]
entity = _climate(resources)
assert entity.preset_mode == preset
assert preset in entity.preset_modes
def test_boards_without_the_sleep_token_do_not_get_the_sleep_presets():
"""The codes come with the Sleep_ token; a unit that has no such token has
nothing to report them from."""
resources = _load_device(FIXTURE)
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
option for option in options if not option.startswith("Sleep_")
]
presets = _climate(resources).preset_modes
assert "sleep" not in presets and "nanosleep" not in presets
def _in_mode(mode, fixture=FIXTURE):
resources = _load_device(fixture)
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
return _climate(resources).preset_modes
def test_presets_follow_the_hvac_mode_and_the_capability_bits():
"""The fixture's own OptionCode_35882 / ExtendOptionCode_7 say: WindFree yes
(eoc[31]), 18K yes (eoc[30]), Quiet yes (oc[10]), Turbo/Comfort in Heat yes
(oc[12]), d'light no (oc[2]), Single User no (oc[3], oc[11]).
Its owner read the same lists off the remote and the appliance's app --
2-Step/Fast Turbo/Comfort/Quiet/WindFree in Cool, Fast Turbo/Comfort/Quiet in
Heat with WindFree impossible, WindFree alone in Dry and Fan, nothing in Auto
beyond WindFree (which the app reaches by switching to Cool) -- and the
appliance itself refused Comode_Nano in Auto and accepted it in Cool.
"""
assert _in_mode("Cool") == [
"none",
"nano",
"speed",
"2step",
"quiet",
"comfort",
"smart",
"sleep",
"nanosleep",
]
# Heat: no WindFree at all, and no Smart Saver (Cool-only).
heat = _in_mode("Heat")
assert "nano" not in heat
assert "smart" not in heat
assert [c for c in ("speed", "comfort", "quiet") if c in heat] == ["speed", "comfort", "quiet"]
# Dry and Fan: WindFree is the only one, and it keeps the mode.
for mode in ("Dry", "Wind"):
assert _in_mode(mode) == ["none", "nano"]
# Auto: WindFree only because this is an 18K model.
assert _in_mode("Auto") == ["none", "nano"]
# d'light Cool is a live rule and this unit's oc[2] denies it everywhere.
for mode in ("Cool", "Heat", "Dry", "Wind", "Auto"):
assert "dlightcool" not in _in_mode(mode), mode
def test_a_board_with_only_the_old_map_keeps_the_unconditional_list():
"""One map is not enough to judge by. `airconditioner_artik051_dongle_fac_18k`
is a legacy board that publishes OptionCode and no ExtendOptionCode, so every
eoc-gated rule would read None -- and None means "this board does not publish
the map", not "the feature is absent". Deriving from it would have cost that
unit WindFree in every mode, and left it with ['none'] alone in its own
fixture mode.
Its OptionCode is also 521, three orders of magnitude below the RAC-class
values these bit positions were read from, which is the second reason not to
interpret it: the FAC and CAC families use the field differently.
"""
resources = _load_device("airconditioner_artik051_dongle_fac_18k")
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
assert any(o.startswith("OptionCode_") for o in options)
assert not any(o.startswith("ExtendOptionCode_") for o in options)
baseline = ["none", "nano", "quiet", "comfort", "2step", "speed"]
for mode in ("Auto", "Cool", "Heat", "Dry", "Wind"):
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
assert _climate(resources).preset_modes == baseline, mode
def test_the_other_board_with_both_maps_still_derives():
"""`airconditioner_artik051_krac_energy` is the same model as the fixture
above with a different OptionCode (56378), and carries both maps -- so it
stays on the derived path rather than the fallback."""
presets = _in_mode("Cool", fixture="airconditioner_artik051_krac_energy")
assert presets[:1] == ["none"]
assert "nano" in presets and "smart" in presets
assert presets != ["none", "nano", "quiet", "comfort", "2step", "speed"]
def test_an_unknown_hvac_mode_falls_back_instead_of_deriving():
"""An HVAC mode these rules have never seen is the same "cannot judge" case
as an absent map, so it gets the same answer rather than a derived-but-wrong
one. Reachable with a partial or malformed rep, where `modes` is missing."""
resources = _load_device(FIXTURE)
for modes in ([], ["CoolClean"]):
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = modes
assert _climate(resources).preset_modes == [
"none",
"nano",
"quiet",
"comfort",
"2step",
"speed",
], modes
async def test_aicomfort_neither_offers_nano_nor_switches_the_mode():
"""The app disables WindFree in AIComfort, so it is not offered -- and the
Cool-first write is therefore Auto-only, with no unreachable branch for a
mode that can never ask for it."""
resources = _load_device(FIXTURE)
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["AIComfort"]
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
assert "nano" not in entity.preset_modes
await entity.async_set_preset_mode("quiet")
assert [payload for _, payload in coordinator.commands] == [("preset_legacy", "Quiet")]
def test_a_bit_that_is_zero_removes_its_preset():
"""oc[12] is what puts Fast Turbo and Comfort in Heat; without it the app
hides both, and so does this."""
resources = _load_device(FIXTURE)
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
# 35882 with oc[12] cleared, everything else untouched.
assert format(35882, "016b")[12] == "1"
cleared = int(format(35882, "016b")[:12] + "0" + format(35882, "016b")[13:], 2)
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
f"OptionCode_{cleared}" if option.startswith("OptionCode_") else option
for option in options
]
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Heat"]
presets = _climate(resources).preset_modes
assert "speed" not in presets and "comfort" not in presets
assert "quiet" in presets # oc[10], untouched
def test_a_board_without_the_bit_maps_keeps_the_unconditional_list():
"""An absent map is not a claim that nothing is supported -- issue #136's unit
of this same model publishes a different token set, and a board that omits
OptionCode entirely must not lose every preset."""
resources = _load_device(FIXTURE)
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
option for option in options if not option.startswith(("OptionCode_", "ExtendOptionCode_"))
]
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
assert _climate(resources).preset_modes == [
"none",
"nano",
"quiet",
"comfort",
"2step",
"speed",
]
def test_the_active_code_is_always_listed_even_where_the_rules_deny_it():
"""A remote can put the unit in a mode these rules would not offer -- and a
preset_mode outside preset_modes is not a state HA allows, so the appliance
has the last word. Measured: Comode_2Step was accepted and held while the
unit was in Auto, where neither the remote nor the app offers it."""
resources = _load_device(FIXTURE)
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
resources["/mode/vs/0"]["x.com.samsung.da.options"] = [
"Comode_2Step" if option.startswith("Comode_") else option for option in options
]
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
entity = _climate(resources)
assert entity.preset_mode == "2step"
assert "2step" in entity.preset_modes
async def test_nano_in_auto_switches_the_board_to_cool_first():
"""Comode_Nano written while the unit is in Auto is answered 2.04 Changed and
dropped; the same token after a separate mode write holds. Putting modes and
options in one POST does not work either, so the mode goes first, on its own.
"""
resources = _load_device(FIXTURE)
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["Auto"]
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_preset_mode("nano")
assert [payload for _, payload in coordinator.commands] == [
("mode", "Cool"),
("preset_legacy", "Nano"),
]
async def test_nano_outside_auto_writes_only_the_preset():
"""In Cool the token holds on its own, so nothing else is sent -- and in Dry
the mode must be left alone, since WindFree keeps it."""
for mode in ("Cool", "Dry"):
resources = _load_device(FIXTURE)
resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode]
coordinator = _FakeCoordinator(resources)
await _climate(resources, coordinator).async_set_preset_mode("nano")
assert [payload for _, payload in coordinator.commands] == [("preset_legacy", "Nano")], mode
def test_nano_preset_keeps_a_running_good_sleep_at_its_own_duration():
"""Writing a bare Comode_Nano over a live Comode_Sleep/Sleep_4 came back as
Comode_NanoSleep/Sleep_16 -- the board upgrades the code by itself and then
supplies a duration of its own, turning two hours into eight without anyone
asking. Sending the pair keeps the user's value."""
from custom_components.localthings.registry.capabilities.airconditioner import _climate_write
assert _climate_write(("preset_legacy", "Nano"), _options(["Comode_Sleep", "Sleep_4"]))[
1
] == _options(["Comode_NanoSleep", "Sleep_4"])
# Idle timer: nano is just nano, exactly as before.
assert _climate_write(("preset_legacy", "Nano"), _options(["Comode_Off", "Sleep_0"]))[
1
] == _options(["Comode_Nano"])
# A sleep preset selected outright has no duration to reuse, so it takes the
# one the appliance itself falls back to (Sleep_16, eight hours).
assert _climate_write(("preset_legacy", "Sleep"), _options(["Comode_Off", "Sleep_0"]))[
1
] == _options(["Comode_Sleep", "Sleep_16"])
# Any other preset is untouched by all of this.
assert _climate_write(("preset_legacy", "Quiet"), _options(["Comode_Sleep", "Sleep_4"]))[
1
] == _options(["Comode_Quiet"])
async def test_newer_boards_keep_the_resource_paths():
"""The legacy fallbacks are gated on this board's resource shape, so a
board with /wind/* and /mode/convenient/vs/0 must be untouched by them."""
resources = _load_device("airconditioner_tp1x_rac")
coordinator = _FakeCoordinator(resources)
entity = _climate(resources, coordinator)
await entity.async_set_fan_mode("high")
await entity.async_set_swing_mode("off")
await entity.async_set_preset_mode("quiet") # from its own supportedModes
kinds = [payload[0] for _, payload in coordinator.commands]
assert kinds == ["fan", "swing", "preset"]