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 committed 2026-08-05 15:46:53 +02:00
1 parent 4e47a1c3d9
commit 30bd0fd2af
9 files changed
+174 -6

No files matched your search

+12 -1
View File
@@ -267,15 +267,26 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
# Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none. # Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none.
_LEGACY_PRESET_CODES = ("Off", "Nano", "Quiet", "Comfort", "2Step", "Speed") _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: def _legacy_convenient(self) -> dict:
"""A /mode/convenient/vs/0-shaped rep built from the Comode_* token in """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.""" /mode/vs/0's options, for boards that have no convenient resource."""
options = self._rep(MODE_HREF).get("x.com.samsung.da.options") or [] 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: for option in options:
if isinstance(option, str) and option.startswith("Comode_"): if isinstance(option, str) and option.startswith("Comode_"):
return { return {
_MODES_FIELD: [option.split("_", 1)[1]], _MODES_FIELD: [option.split("_", 1)[1]],
_SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES), _SUPPORTED_FIELD: codes,
} }
return {} return {}
@@ -324,6 +324,59 @@ def _option_number_write(prefix, factor=1):
return write 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): def _odor_controller_active(rep):
"""Odor-controller self-clean on/off, from the `SmartCoolClean_<On/Off>` """Odor-controller self-clean on/off, from the `SmartCoolClean_<On/Off>`
option token (matches the SmartThings cloud's airConditionerOdorController option token (matches the SmartThings cloud's airConditionerOdorController
@@ -404,7 +457,7 @@ def _climate_write(payload, rep, href=None):
if kind == "swing_legacy": if kind == "swing_legacy":
return (["airflow", "vs", "0"], {"x.com.samsung.da.direction": value}) return (["airflow", "vs", "0"], {"x.com.samsung.da.direction": value})
if kind == "preset_legacy": 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": if kind == "preset":
return (["mode", "convenient", "vs", "0"], {"x.com.samsung.da.modes": value}) return (["mode", "convenient", "vs", "0"], {"x.com.samsung.da.modes": value})
return None return None
@@ -555,7 +608,7 @@ CLIMATE = Capability(
key="good_sleep", key="good_sleep",
rep_fn=_option_token_num("Sleep", divisor=2), rep_fn=_option_token_num("Sleep", divisor=2),
exists_fn=_has_option_token("Sleep"), exists_fn=_has_option_token("Sleep"),
write_fn=_option_number_write("Sleep", factor=2), write_fn=_good_sleep_write,
native_min=0, native_min=0,
native_max=12, native_max=12,
step=0.5, step=0.5,
@@ -152,6 +152,7 @@
"smart": "Chytrý", "smart": "Chytrý",
"speed": "Rychlý", "speed": "Rychlý",
"nano": "WindFree", "nano": "WindFree",
"sleep": "Spánek",
"nanosleep": "WindFree spánek", "nanosleep": "WindFree spánek",
"longwind": "Dlouhý vánek", "longwind": "Dlouhý vánek",
"motionindirect": "Nepřímý vzduch při pohybu", "motionindirect": "Nepřímý vzduch při pohybu",
@@ -152,6 +152,7 @@
"smart": "Smart", "smart": "Smart",
"speed": "Speed", "speed": "Speed",
"nano": "WindFree", "nano": "WindFree",
"sleep": "Sleep",
"nanosleep": "WindFree sleep", "nanosleep": "WindFree sleep",
"longwind": "Long wind", "longwind": "Long wind",
"motionindirect": "Motion indirect", "motionindirect": "Motion indirect",
@@ -274,6 +274,7 @@
"smart": "Inteligente", "smart": "Inteligente",
"speed": "Rápido", "speed": "Rápido",
"nano": "WindFree", "nano": "WindFree",
"sleep": "Sueño",
"nanosleep": "WindFree sueño", "nanosleep": "WindFree sueño",
"longwind": "Viento prolongado", "longwind": "Viento prolongado",
"motionindirect": "Indirecto al movimiento", "motionindirect": "Indirecto al movimiento",
@@ -152,6 +152,7 @@
"smart": "Smart", "smart": "Smart",
"speed": "Veloce", "speed": "Veloce",
"nano": "WindFree", "nano": "WindFree",
"sleep": "Sonno",
"nanosleep": "WindFree sonno", "nanosleep": "WindFree sonno",
"longwind": "Vento prolungato", "longwind": "Vento prolungato",
"motionindirect": "Indiretto al movimento", "motionindirect": "Indiretto al movimento",
@@ -152,6 +152,7 @@
"smart": "스마트", "smart": "스마트",
"speed": "스피드", "speed": "스피드",
"nano": "무풍", "nano": "무풍",
"sleep": "숙면",
"nanosleep": "무풍 수면", "nanosleep": "무풍 수면",
"longwind": "롱바람", "longwind": "롱바람",
"motionindirect": "간접풍", "motionindirect": "간접풍",
@@ -152,6 +152,7 @@
"smart": "Slim", "smart": "Slim",
"speed": "Snel", "speed": "Snel",
"nano": "WindFree", "nano": "WindFree",
"sleep": "Slaap",
"nanosleep": "WindFree-slaap", "nanosleep": "WindFree-slaap",
"longwind": "Lange wind", "longwind": "Lange wind",
"motionindirect": "Beweging indirect", "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.native_max, desc.step) == (12, 0.5)
assert desc.write_fn(2.5, {}) == ( assert desc.write_fn(2.5, {}) == (
["mode", "vs", "0"], ["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 # 12 hours is the app's maximum and has to be reachable -- it was not while
# the token was published as hours. # the token was published as hours.
assert desc.write_fn(12, {})[1]["x.com.samsung.da.options"] == ["Sleep_24"] assert desc.write_fn(12, {})[1]["x.com.samsung.da.options"] == ["Comode_Sleep", "Sleep_24"]
assert desc.write_fn(0, {})[1]["x.com.samsung.da.options"] == ["Sleep_0"]
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(): 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", "comfort",
"2step", "2step",
"speed", "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"] 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") 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(): async def test_newer_boards_keep_the_resource_paths():
"""The legacy fallbacks are gated on this board's resource shape, so a """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.""" board with /wind/* and /mode/convenient/vs/0 must be untouched by them."""