From 30bd0fd2af23d71d586bd9d2a79fb98b178a1f4e Mon Sep 17 00:00:00 2001 From: perseus177 Date: Wed, 5 Aug 2026 15:46:53 +0200 Subject: [PATCH] fix(airconditioner): Good Sleep needs the mode token its duration belongs to Sleep_ 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. --- custom_components/localthings/climate.py | 13 ++- .../registry/capabilities/airconditioner.py | 57 +++++++++- .../localthings/translations/cs.json | 1 + .../localthings/translations/en.json | 1 + .../localthings/translations/es.json | 1 + .../localthings/translations/it.json | 1 + .../localthings/translations/ko.json | 1 + .../localthings/translations/nl.json | 1 + tests/test_airconditioner_artik051_krac.py | 104 +++++++++++++++++- 9 files changed, 174 insertions(+), 6 deletions(-) diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 1482807..e56d3ab 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -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 {} diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index f4689dc..72f7a8e 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -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_` 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_` 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, diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 83e5b72..8250824 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -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", diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index d403e24..6e1d1f2 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -152,6 +152,7 @@ "smart": "Smart", "speed": "Speed", "nano": "WindFree", + "sleep": "Sleep", "nanosleep": "WindFree sleep", "longwind": "Long wind", "motionindirect": "Motion indirect", diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index ec8e6a0..d7de837 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -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", diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index be69d02..208b287 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -152,6 +152,7 @@ "smart": "Smart", "speed": "Veloce", "nano": "WindFree", + "sleep": "Sonno", "nanosleep": "WindFree sonno", "longwind": "Vento prolungato", "motionindirect": "Indiretto al movimento", diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 7a86ba4..ed65fb1 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -152,6 +152,7 @@ "smart": "스마트", "speed": "스피드", "nano": "무풍", + "sleep": "숙면", "nanosleep": "무풍 수면", "longwind": "롱바람", "motionindirect": "간접풍", diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 09e15db..649bfd2 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -152,6 +152,7 @@ "smart": "Slim", "speed": "Snel", "nano": "WindFree", + "sleep": "Slaap", "nanosleep": "WindFree-slaap", "longwind": "Lange wind", "motionindirect": "Beweging indirect", diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 36723aa..d71e081 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -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_` 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."""