fix(airconditioner): Good Sleep needs the mode token its duration belongs to

Sleep_<n> written on its own is answered 2.04 Changed and then discarded, so
the Number wrote nothing at all. Nano wind shares the same Comode_ slot, which
is why writing the nano preset over a running timer silently changed its
duration, and why the two sleep codes the board reports had to become presets:
a preset_mode outside preset_modes is not a state HA allows.
This commit is contained in:
perseus177
2026-08-05 15:46:53 +02:00
parent 4e47a1c3d9
commit 30bd0fd2af
9 changed files with 174 additions and 6 deletions
+12 -1
View File
@@ -267,15 +267,26 @@ 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")
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: list(self._LEGACY_PRESET_CODES),
_SUPPORTED_FIELD: codes,
}
return {}
@@ -324,6 +324,59 @@ 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.
"""
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 +457,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 +608,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,6 +152,7 @@
"smart": "Chytrý",
"speed": "Rychlý",
"nano": "WindFree",
"sleep": "Spánek",
"nanosleep": "WindFree spánek",
"longwind": "Dlouhý vánek",
"motionindirect": "Nepřímý vzduch při pohybu",
@@ -152,6 +152,7 @@
"smart": "Smart",
"speed": "Speed",
"nano": "WindFree",
"sleep": "Sleep",
"nanosleep": "WindFree sleep",
"longwind": "Long wind",
"motionindirect": "Motion indirect",
@@ -274,6 +274,7 @@
"smart": "Inteligente",
"speed": "Rápido",
"nano": "WindFree",
"sleep": "Sueño",
"nanosleep": "WindFree sueño",
"longwind": "Viento prolongado",
"motionindirect": "Indirecto al movimiento",
@@ -152,6 +152,7 @@
"smart": "Smart",
"speed": "Veloce",
"nano": "WindFree",
"sleep": "Sonno",
"nanosleep": "WindFree sonno",
"longwind": "Vento prolungato",
"motionindirect": "Indiretto al movimento",
@@ -152,6 +152,7 @@
"smart": "스마트",
"speed": "스피드",
"nano": "무풍",
"sleep": "숙면",
"nanosleep": "무풍 수면",
"longwind": "롱바람",
"motionindirect": "간접풍",
@@ -152,6 +152,7 @@
"smart": "Slim",
"speed": "Snel",
"nano": "WindFree",
"sleep": "Slaap",
"nanosleep": "WindFree-slaap",
"longwind": "Lange wind",
"motionindirect": "Beweging indirect",
+101 -3
View File
@@ -235,12 +235,50 @@ def test_good_sleep_is_hours_while_the_token_counts_half_hours():
assert (desc.native_max, desc.step) == (12, 0.5)
assert desc.write_fn(2.5, {}) == (
["mode", "vs", "0"],
{"x.com.samsung.da.options": ["Sleep_5"]},
{"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"] == ["Sleep_24"]
assert desc.write_fn(0, {})[1]["x.com.samsung.da.options"] == ["Sleep_0"]
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():
@@ -398,6 +436,10 @@ def test_preset_comes_from_the_comode_token():
"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.
"sleep",
"nanosleep",
]
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
@@ -417,6 +459,62 @@ async def test_preset_write_uses_the_token_path():
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 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."""