feat(climate): offer legacy presets per HVAC mode, from the unit's own capability bits

The fixed list of six was offered in every mode on every legacy board. The
appliance publishes what it has as two bit maps in /mode/vs/0's options, and its
own app gates each comfort mode on a bit plus the current mode; this transcribes
that logic. WindFree also needs the mode written before it in Auto, which is
measured rather than assumed.
This commit is contained in:
perseus177
2026-08-05 17:24:40 +02:00
parent 30bd0fd2af
commit 2d772f16a7
3 changed files with 281 additions and 18 deletions
+109 -10
View File
@@ -21,6 +21,7 @@ temperature and wind resources alike.
from __future__ import annotations
import asyncio
import logging
from homeassistant.components.climate import (
@@ -79,7 +80,9 @@ from .registry.capabilities.airconditioner import (
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
)
from .registry.capabilities.airconditioner import (
extend_option_code_bit,
is_legacy_board,
option_code_bit,
)
from .registry.capabilities.common import normalize_temp_unit
from .registry.entities import ClimateDesc
@@ -131,6 +134,10 @@ PRESET_AI_COMFORT = "ai_comfort"
# not modeled as a preset either: nothing confirms it's user-selectable.
_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"})
# Seconds to let a legacy board settle into Cool before the WindFree token is
# written after it -- see _legacy_preset_needs_cool for the measurement.
_NANO_AFTER_MODE_DELAY = 3
# Fan (wind strength): device codes "0".."4" -> HA standard fan constants where
# a clean match exists so they auto-localize; "turbo" is custom (translated).
_DEVICE_TO_FAN: dict[str, str] = {
@@ -275,20 +282,88 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# token these codes come with.
_LEGACY_SLEEP_PRESET_CODES = ("Sleep", "NanoSleep")
# Which comfort modes a legacy board offers in which HVAC mode, and which of
# them it has at all. Both come from the appliance rather than from a table
# per model: the unit publishes its capabilities as two bit maps in
# /mode/vs/0's options (OptionCode, ExtendOptionCode), and its own app gates
# each item on a bit plus the current mode. _legacy_preset_codes() below is
# that logic, transcribed from the app's updateOptionsList() so the two can be
# compared line by line, with the bit each rule reads named in the comment.
#
# Confirmed against an ARTIK051_KRAC_18K whose owner read the same lists off
# the remote and the app: WindFree in Cool/Dry/Fan and (being an 18K model)
# Auto but never Heat, Fast Turbo and Comfort in Heat because oc[12] is set,
# and no d'light or Single User anywhere because oc[2], oc[3] and oc[11] are
# zero -- the appliance refuses both of those locally too.
def _legacy_preset_codes(self, hvac: str, options: list) -> list[str]:
"""Comfort-mode codes this unit offers in this HVAC mode."""
rep = self._rep(MODE_HREF)
cool = hvac in ("Cool", _AI_COMFORT_MODE)
heat = hvac in ("Heat", "HeatClean")
# An absent bit map is not a claim that nothing is supported, so a board
# that publishes no OptionCode keeps the older unconditional list.
if option_code_bit(rep, 1) is None and extend_option_code_bit(rep, 31) is None:
return list(self._LEGACY_PRESET_CODES)
codes = ["Off"]
# WindFree: shown on eoc[31], disabled in Heat, in AIComfort, and in Auto
# unless this is an 18K model (eoc[30]) -- the app switches such a unit to
# Cool instead, which is what _legacy_preset_needs_cool does.
nano_mode_ok = not heat and hvac != _AI_COMFORT_MODE
if hvac == "Auto":
nano_mode_ok = bool(extend_option_code_bit(rep, 30))
if extend_option_code_bit(rep, 31) and nano_mode_ok:
codes.append("Nano")
if cool or (heat and option_code_bit(rep, 12)): # Fast Turbo, oc[12] in Heat
codes.append("Speed")
if cool:
codes.append("2Step")
if cool and option_code_bit(rep, 2): # d'light Cool
codes.append("DlightCool")
# Quiet reads oc[10] with no mode condition in the app, but the owner of
# the unit above sees it in Cool and Heat only, on the remote as well as
# in the app -- the observation wins over the reading.
if option_code_bit(rep, 10) and (cool or heat):
codes.append("Quiet")
if (cool or heat) and not (heat and not option_code_bit(rep, 12)): # Comfort
codes.append("Comfort")
# Smart Saver has no bit of its own and the app hides it from every single
# RAC outright (showSaverOption = false), yet the appliance accepts it and
# behaves as Samsung documents -- so absence from the app is not absence
# from the hardware. Cool-only, per that documentation.
if hvac == "Cool":
codes.append("Smart")
# Good Sleep, which shares this slot: the app enables it in Cool, Heat and
# AIComfort only. Both codes, because the board turns Sleep into NanoSleep
# by itself when WindFree is running.
if (cool or heat) and any(
isinstance(option, str) and option.startswith("Sleep_") for option in options
):
codes += self._LEGACY_SLEEP_PRESET_CODES
return codes
def _legacy_convenient(self) -> dict:
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in
/mode/vs/0's options, for boards that have no convenient resource."""
options = self._rep(MODE_HREF).get("x.com.samsung.da.options") or []
codes = list(self._LEGACY_PRESET_CODES)
if any(isinstance(option, str) and option.startswith("Sleep_") for option in options):
codes += self._LEGACY_SLEEP_PRESET_CODES
for option in options:
if isinstance(option, str) and option.startswith("Comode_"):
return {
_MODES_FIELD: [option.split("_", 1)[1]],
_SUPPORTED_FIELD: codes,
}
return {}
active = next(
(o.split("_", 1)[1] for o in options if isinstance(o, str) and o.startswith("Comode_")),
None,
)
if active is None:
return {}
codes = self._legacy_preset_codes(
str(_first(self._rep(MODE_HREF).get(_MODES_FIELD))), options
)
# Whatever the unit is actually running has to be listed whether the
# table expects it there or not -- a preset_mode outside preset_modes is
# not a state HA allows, and the appliance has the last word on what it
# is doing (a remote can put it in a mode this table would not offer).
if active not in codes:
codes.append(active)
return {_MODES_FIELD: [active], _SUPPORTED_FIELD: codes}
def _legacy_airflow(self) -> dict:
"""The /airflow/vs/0 rep, but only when it is the fan/swing channel
@@ -633,6 +708,29 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
if self._rep(WIND_OSCILLATION_HREF):
await self.coordinator.async_send_command(self._bound, ("oscillation", swing_mode))
async def _legacy_preset_needs_cool(self, code: str) -> None:
"""Switch a legacy board to Cool first when the preset needs it.
WindFree does not exist in Auto: `["Comode_Nano"]` written while the unit
is in Auto is answered 2.04 Changed and dropped (measured, still
Comode_Off at +8s and +45s), and putting `modes: Cool` in the *same* POST
does not help -- the mode moves and the token is still dropped, so the
board judges the option against the mode it was in. Sent as its own write
first, it holds. The appliance's own app does the same thing for the same
reason (its nano command carries `modes: Cool` only in Auto/AIComfort).
The pause is measured, not padding: back to back (same session, no gap at
all) the token was dropped again, two seconds apart it held. Three is that
with a little margin, and it only ever runs for this one preset in this
one HVAC mode.
"""
if not self._legacy_preset() or code != "Nano":
return
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) not in ("Auto", _AI_COMFORT_MODE):
return
await self.coordinator.async_send_command(self._bound, ("mode", "Cool"))
await asyncio.sleep(_NANO_AFTER_MODE_DELAY)
async def async_set_preset_mode(self, preset_mode: str) -> None:
if preset_mode == PRESET_AI_COMFORT:
# Writes the primary mode resource, not the convenient one --
@@ -644,6 +742,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep').
for code in self._supported(CONVENIENT_HREF):
if _preset_to_ha(code) == preset_mode:
await self._legacy_preset_needs_cool(code)
kind = "preset_legacy" if self._legacy_preset() else "preset"
await self.coordinator.async_send_command(self._bound, (kind, code))
return
@@ -245,6 +245,41 @@ def _option_token(rep, prefix):
return None
def option_bit(rep, prefix, index, width):
"""One capability bit out of the `OptionCode_<n>` / `ExtendOptionCode_<n>` token.
The appliance packs which features it has into two integers. Its own app reads
them by turning the number into binary, left-padding to a fixed width, and
indexing that *string* -- so index 0 is the most significant bit, and the
indices below are the app's own (`racOptionCodeValue[12]`, and so on), kept
identical so the two can be compared line by line.
Returns None when the token is absent, which is not the same as a zero: a bit
that is present and 0 says the feature is missing, while no token at all says
only that this board does not publish the map.
"""
raw = _option_token(rep, prefix)
if raw is None:
return None
try:
bits = format(int(raw), f"0{width}b")
except (TypeError, ValueError):
return None
if len(bits) != width or not 0 <= index < width:
return None # a number too wide for the map is not one we can read
return bits[index] == "1"
def option_code_bit(rep, index):
"""A bit of the 16-wide `OptionCode` map."""
return option_bit(rep, "OptionCode", index, 16)
def extend_option_code_bit(rep, index):
"""A bit of the 32-wide `ExtendOptionCode` map."""
return option_bit(rep, "ExtendOptionCode", index, 32)
def is_legacy_board(resources):
"""True for the board generation whose airflow lives in /airflow/vs/0
rather than /wind/strength/vs/0 -- every AC dump on record has one shape
+137 -8
View File
@@ -425,19 +425,20 @@ 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
# Codes learned by driving this unit through its cloud integration and
# reading the token back. They 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.
# 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",
"2step",
"speed",
# Not learned from the cloud but from the unit itself, which reports
# them in the same slot -- see test_the_sleep_modes_are_presets_too.
"smart",
"sleep",
"nanosleep",
]
@@ -490,6 +491,134 @@ def test_boards_without_the_sleep_token_do_not_get_the_sleep_presets():
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"]
# Nothing offers what the bits deny, in any mode.
for mode in ("Cool", "Heat", "Dry", "Wind", "Auto"):
presets = _in_mode(mode)
assert "dlightcool" not in presets, mode
assert "singleuser" not in presets, mode
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