Merge pull request #304 from perseus177/ac-presets-per-hvac-mode

feat(climate): derive legacy AC presets from the unit's own capability bits, per HVAC mode
This commit is contained in:
Marc Billow authored and GitHub committed 2026-08-06 08:46:55 -04:00
commit dd953b8150
9 files changed
+581 -18

No files matched your search

+157 -7
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,11 @@ from .registry.capabilities.airconditioner import (
HREF_WIND_STRENGTH as WIND_STRENGTH_HREF,
)
from .registry.capabilities.airconditioner import (
extend_option_code_bit,
has_extend_option_code,
has_option_code,
is_legacy_board,
option_code_bit,
)
from .registry.capabilities.common import normalize_temp_unit
from .registry.entities import ClimateDesc
@@ -131,6 +136,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] = {
@@ -267,17 +276,125 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
_LEGACY_PRESET_CODES = ("Off", "Nano", "Quiet", "Comfort", "2Step", "Speed")
# Good Sleep occupies the same Comode_ slot as the presets above, so a unit
# running it reports Comode_Sleep -- or Comode_NanoSleep, which the board
# will produce by itself when nano wind is asked for while the timer runs.
# Neither is in the list above, and a preset_mode outside preset_modes is
# not a state HA allows, so they are added for boards that have the Sleep_
# token these codes come with.
_LEGACY_SLEEP_PRESET_CODES = ("Sleep", "NanoSleep")
# HVAC modes _legacy_preset_codes() has a rule for. Anything else is a mode
# this transcription has never seen, which is the same "cannot judge" case as
# a board that publishes no capability map -- and gets the same fallback.
_LEGACY_KNOWN_HVAC = frozenset(
{"Cool", "Heat", "HeatClean", "Dry", "Fan", "Wind", "Auto", _AI_COMFORT_MODE}
)
# 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, and Fast Turbo and Comfort in Heat because oc[12] is
# set. d'light Cool is gated the same way on oc[2], which is zero here -- and
# the appliance refuses the token locally too.
#
# Single User is deliberately not modelled, though the app does gate it on
# oc[3] / oc[11]: it has no token of its own. The app's own Single User
# command sends `Comode_Smart` -- the Smart Saver token -- with a hardcoded
# 24 desired alongside it, so there is nothing to write that would be
# distinguishable from the Smart preset below, and no name to give it that
# the appliance would recognise.
def _legacy_preset_codes(self, hvac: str, options: list) -> list[str]:
"""Comfort-mode codes this unit offers in this HVAC mode.
Derived only for boards that publish *both* capability maps. One map on
its own is not enough: every eoc-gated rule would then read None, and
None means "this board does not publish the map", never "the feature is
absent". The FAC/CAC boards on record carry only the older map, with
values small enough that RAC bit positions all read as zeros, so
requiring both also keeps these rules inside the family they were
documented for.
Within the derived path, a bit that cannot be read (a malformed or
over-wide token) is treated as permission rather than denial for the
codes the unconditional list already carried -- losing a working preset
to a parsing failure is worse than offering one too many. Codes that were
never in that list (d'light) still need their bit to be explicitly set.
"""
rep = self._rep(MODE_HREF)
if (
not has_option_code(rep)
or not has_extend_option_code(rep)
or hvac not in self._LEGACY_KNOWN_HVAC
):
return list(self._LEGACY_PRESET_CODES)
cool = hvac in ("Cool", _AI_COMFORT_MODE)
heat = hvac in ("Heat", "HeatClean")
codes = ["Off"]
# WindFree: shown on eoc[31]; disabled in Heat, in AIComfort, and in Auto
# unless this is an 18K model (eoc[30]), where the app switches to Cool for
# it 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 = extend_option_code_bit(rep, 30) is not False
if extend_option_code_bit(rep, 31) is not False and nano_mode_ok:
codes.append("Nano")
if cool or (heat and option_code_bit(rep, 12) is not False): # Fast Turbo
codes.append("Speed")
if cool:
codes.append("2Step")
if cool and option_code_bit(rep, 2): # d'light Cool -- needs the bit set
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) is not False and (cool or heat):
codes.append("Quiet")
if cool or (heat and option_code_bit(rep, 12) is not False): # 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 []
for option in options:
if isinstance(option, str) and option.startswith("Comode_"):
return {
_MODES_FIELD: [option.split("_", 1)[1]],
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_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(_first(self._rep(MODE_HREF).get(_MODES_FIELD)), options)
# Whatever the unit is actually running has to be listed whether the
# rules expect 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 these rules 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
@@ -622,6 +739,38 @@ 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 pairs `modes: Cool` with its nano
command for the same reason.
Auto only. The app's builder also covers AIComfort, but its
`updateOptionsList()` disables the WindFree button there outright, so that
pairing can never fire -- and `_legacy_preset_codes()` likewise does not
offer `Nano` in AIComfort, which would leave such a branch unreachable.
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)) != "Auto":
return
# Same resolver the rest of the platform uses -- the device code for an HA
# mode is read off the unit's own supportedModes rather than assumed.
await self.coordinator.async_send_command(
self._bound, ("mode", self._device_code_for_hvac(HVACMode.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 --
@@ -633,6 +782,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,59 @@ 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 has_option_code(rep):
"""Whether this board publishes the 16-wide capability map at all."""
return _option_token(rep, "OptionCode") is not None
def has_extend_option_code(rep):
"""Whether this board publishes the 32-wide capability map at all.
Its own name in the app is "Single RAC new option code, as old option code
is full", and every RAC-class dump on record carries it while the FAC/CAC
ones carry only the older map with values small enough that RAC bit
positions read as zeros. So its presence is the closest thing available to
"this is the family those bit positions were documented for" -- a proxy,
not a proof, and used only to decide whether to read the map at all.
"""
return _option_token(rep, "ExtendOptionCode") is not None
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
@@ -324,6 +377,65 @@ def _option_number_write(prefix, factor=1):
return write
def _good_sleep_write(payload, rep, href=None):
"""Good Sleep needs its mode token in the same write as its duration.
`Sleep_<n>` on its own is answered 2.04 Changed and then thrown away:
measured on an ARTIK051_KRAC_18K, writing `["Sleep_4"]` left the token at
`Sleep_0` at both +8s and +45s, while the same value written together with
`Comode_Sleep` held. So the number is a parameter of the mode, not a
setting of its own, and the appliance's app never sends one without the
other either.
Which mode token goes with it depends on nano wind, the way the app decides
it: nano and Good Sleep share the single `Comode_` slot, so running both is
`Comode_NanoSleep`, and switching the timer off while nano is on leaves nano
running rather than turning everything off.
"""
half_hours = round(float(payload) * 2)
nano = _option_token(rep, "Comode") in ("Nano", "NanoSleep")
if half_hours:
comode = "Comode_NanoSleep" if nano else "Comode_Sleep"
else:
comode = "Comode_Nano" if nano else "Comode_Off"
return (
["mode", "vs", "0"],
{"x.com.samsung.da.options": [comode, f"Sleep_{half_hours}"]},
)
# What the appliance itself picks when a Good Sleep mode is asked for with no
# duration to go with it: writing a bare `Comode_Nano` over a live
# `Comode_Sleep`/`Sleep_4` came back as `Comode_NanoSleep`/`Sleep_16`. Used only
# when a sleep preset is selected while the timer reads 0.
_DEFAULT_SLEEP_HALF_HOURS = 16
def _preset_options(code, rep):
"""The options array for a legacy preset write.
One `Comode_` token has to express both nano wind and Good Sleep, so
selecting nano while the timer is running means `Comode_NanoSleep` -- and it
has to carry the duration, because the board otherwise supplies its own.
Measured: `["Comode_Nano"]` written over `Comode_Sleep`/`Sleep_4` came back
as `Comode_NanoSleep`/`Sleep_16`, silently turning the user's two hours into
eight. Writing the pair keeps the two hours.
Leaving a sleep mode needs no such care: the board zeroes the duration by
itself. Measured on the same unit -- a bare `["Comode_Off"]` written over
`Comode_Sleep`/`Sleep_4` read back as `Comode_Off`/`Sleep_0` at +8s and +38s
-- so the `none` preset cannot leave a stale token behind for the next nano
selection to pick up as a running timer.
"""
sleep = _option_token(rep, "Sleep")
running = sleep not in (None, "0")
if code == "Nano" and running:
code = "NanoSleep"
if code in ("Sleep", "NanoSleep"):
return [f"Comode_{code}", f"Sleep_{sleep if running else _DEFAULT_SLEEP_HALF_HOURS}"]
return option_write("Comode", code)
def _odor_controller_active(rep):
"""Odor-controller self-clean on/off, from the `SmartCoolClean_<On/Off>`
option token (matches the SmartThings cloud's airConditionerOdorController
@@ -404,7 +516,7 @@ def _climate_write(payload, rep, href=None):
if kind == "swing_legacy":
return (["airflow", "vs", "0"], {"x.com.samsung.da.direction": value})
if kind == "preset_legacy":
return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write("Comode", value)})
return (["mode", "vs", "0"], {"x.com.samsung.da.options": _preset_options(value, rep)})
if kind == "preset":
return (["mode", "convenient", "vs", "0"], {"x.com.samsung.da.modes": value})
return None
@@ -555,7 +667,7 @@ CLIMATE = Capability(
key="good_sleep",
rep_fn=_option_token_num("Sleep", divisor=2),
exists_fn=_has_option_token("Sleep"),
write_fn=_option_number_write("Sleep", factor=2),
write_fn=_good_sleep_write,
native_min=0,
native_max=12,
step=0.5,
@@ -152,11 +152,13 @@
"smart": "Chytrý",
"speed": "Rychlý",
"nano": "WindFree",
"sleep": "Spánek",
"nanosleep": "WindFree spánek",
"longwind": "Dlouhý vánek",
"motionindirect": "Nepřímý vzduch při pohybu",
"motiondirect": "Přímý vzduch při pohybu",
"drycomfort": "Komfortní sušení",
"dlightcool": "d'light Cool",
"2step": "2stupňový"
}
}
@@ -152,11 +152,13 @@
"smart": "Smart",
"speed": "Speed",
"nano": "WindFree",
"sleep": "Sleep",
"nanosleep": "WindFree sleep",
"longwind": "Long wind",
"motionindirect": "Motion indirect",
"motiondirect": "Motion direct",
"drycomfort": "Dry comfort",
"dlightcool": "d'light Cool",
"2step": "2-Step"
}
}
@@ -277,11 +277,13 @@
"smart": "Inteligente",
"speed": "Rápido",
"nano": "WindFree",
"sleep": "Sueño",
"nanosleep": "WindFree sueño",
"longwind": "Viento prolongado",
"motionindirect": "Indirecto al movimiento",
"motiondirect": "Directo al movimiento",
"drycomfort": "Confort seco",
"dlightcool": "d'light Cool",
"2step": "2 pasos"
}
}
@@ -152,11 +152,13 @@
"smart": "Smart",
"speed": "Veloce",
"nano": "WindFree",
"sleep": "Sonno",
"nanosleep": "WindFree sonno",
"longwind": "Vento prolungato",
"motionindirect": "Indiretto al movimento",
"motiondirect": "Diretto al movimento",
"drycomfort": "Comfort asciugatura",
"dlightcool": "d'light Cool",
"2step": "2 fasi"
}
}
@@ -152,11 +152,13 @@
"smart": "스마트",
"speed": "스피드",
"nano": "무풍",
"sleep": "숙면",
"nanosleep": "무풍 수면",
"longwind": "롱바람",
"motionindirect": "간접풍",
"motiondirect": "직접풍",
"drycomfort": "쾌적 제습",
"dlightcool": "d'light Cool",
"2step": "2단계"
}
}
@@ -152,11 +152,13 @@
"smart": "Slim",
"speed": "Snel",
"nano": "WindFree",
"sleep": "Slaap",
"nanosleep": "WindFree-slaap",
"longwind": "Lange wind",
"motionindirect": "Beweging indirect",
"motiondirect": "Beweging direct",
"drycomfort": "Droog comfort",
"dlightcool": "d'light Cool",
"2step": "2-Step"
}
}