From 30bd0fd2af23d71d586bd9d2a79fb98b178a1f4e Mon Sep 17 00:00:00 2001 From: perseus177 Date: Wed, 5 Aug 2026 15:46:53 +0200 Subject: [PATCH 1/4] 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.""" From 2d772f16a7c1f85fda8609c24098f9b3ed8eabe3 Mon Sep 17 00:00:00 2001 From: perseus177 Date: Wed, 5 Aug 2026 17:24:40 +0200 Subject: [PATCH 2/4] 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. --- custom_components/localthings/climate.py | 119 ++++++++++++-- .../registry/capabilities/airconditioner.py | 35 +++++ tests/test_airconditioner_artik051_krac.py | 145 +++++++++++++++++- 3 files changed, 281 insertions(+), 18 deletions(-) diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index e56d3ab..7ff7286 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -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 diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 72f7a8e..f059c71 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -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_` / `ExtendOptionCode_` 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 diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index d71e081..2d18eb8 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -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 From eed04faaedd8f44b60daff94ba285d2de2a1de3f Mon Sep 17 00:00:00 2001 From: perseus177 Date: Thu, 6 Aug 2026 12:04:46 +0200 Subject: [PATCH 3/4] fix(climate): derive presets only when the board publishes both capability maps One map is not enough to judge by: with only OptionCode present, every eoc-gated rule reads None, and None means the board does not publish the map rather than that the feature is absent. artik051_dongle_fac_18k is exactly that board and lost WindFree in every mode. Requiring both also keeps these bit positions inside the family they were documented for -- the FAC and CAC dumps carry only the older map, with values small enough that RAC positions read as zeros. Also from review: an unknown HVAC mode falls back the same way, Comfort is spelled like the identical Speed rule, the unreachable AIComfort branch is gone, the Cool code comes from the unit's own supportedModes, DlightCool gains its catalog entry, and the Single User claim is dropped -- the app's own Single User command sends Comode_Smart, so there is no distinct token to write. --- custom_components/localthings/climate.py | 94 +++++++++++++------ .../registry/capabilities/airconditioner.py | 18 ++++ .../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 | 70 +++++++++++++- 9 files changed, 157 insertions(+), 31 deletions(-) diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 7ff7286..a1d2529 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -81,6 +81,8 @@ from .registry.capabilities.airconditioner import ( ) from .registry.capabilities.airconditioner import ( extend_option_code_bit, + has_extend_option_code, + has_option_code, is_legacy_board, option_code_bit, ) @@ -282,6 +284,13 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # 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 @@ -292,41 +301,65 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # # 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. + # 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.""" + """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) - 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: + 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]) -- the app switches such a unit to - # Cool instead, which is what _legacy_preset_needs_cool does. + # 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 = bool(extend_option_code_bit(rep, 30)) - if extend_option_code_bit(rep, 31) and nano_mode_ok: + 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)): # Fast Turbo, oc[12] in Heat + 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 + 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) and (cool or heat): + if option_code_bit(rep, 10) is not False and (cool or heat): codes.append("Quiet") - if (cool or heat) and not (heat and not option_code_bit(rep, 12)): # Comfort + 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 @@ -354,13 +387,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): if active is None: return {} - codes = self._legacy_preset_codes( - str(_first(self._rep(MODE_HREF).get(_MODES_FIELD))), options - ) + 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 - # table expects it there or not -- a preset_mode outside preset_modes is + # 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 this table would not offer). + # 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} @@ -716,8 +747,13 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): 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). + 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 @@ -726,9 +762,13 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): """ if not self._legacy_preset() or code != "Nano": return - if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) not in ("Auto", _AI_COMFORT_MODE): + if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) != "Auto": return - await self.coordinator.async_send_command(self._bound, ("mode", "Cool")) + # 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: diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index f059c71..2a4656e 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -280,6 +280,24 @@ def extend_option_code_bit(rep, index): 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 diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 8250824..85b633f 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -158,6 +158,7 @@ "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ý" } } diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index 6e1d1f2..809aa01 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -158,6 +158,7 @@ "motionindirect": "Motion indirect", "motiondirect": "Motion direct", "drycomfort": "Dry comfort", + "dlightcool": "d'light Cool", "2step": "2-Step" } } diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index d7de837..4697509 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -280,6 +280,7 @@ "motionindirect": "Indirecto al movimiento", "motiondirect": "Directo al movimiento", "drycomfort": "Confort seco", + "dlightcool": "d'light Cool", "2step": "2 pasos" } } diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 208b287..a848dbe 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -158,6 +158,7 @@ "motionindirect": "Indiretto al movimento", "motiondirect": "Diretto al movimento", "drycomfort": "Comfort asciugatura", + "dlightcool": "d'light Cool", "2step": "2 fasi" } } diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index ed65fb1..7c037eb 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -158,6 +158,7 @@ "motionindirect": "간접풍", "motiondirect": "직접풍", "drycomfort": "쾌적 제습", + "dlightcool": "d'light Cool", "2step": "2단계" } } diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index 649bfd2..f6de8fb 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -158,6 +158,7 @@ "motionindirect": "Beweging indirect", "motiondirect": "Beweging direct", "drycomfort": "Droog comfort", + "dlightcool": "d'light Cool", "2step": "2-Step" } } diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 2d18eb8..2e7d3a9 100644 --- a/tests/test_airconditioner_artik051_krac.py +++ b/tests/test_airconditioner_artik051_krac.py @@ -529,11 +529,73 @@ def test_presets_follow_the_hvac_mode_and_the_capability_bits(): 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. + # d'light Cool is a live rule and this unit's oc[2] denies it everywhere. for mode in ("Cool", "Heat", "Dry", "Wind", "Auto"): - presets = _in_mode(mode) - assert "dlightcool" not in presets, mode - assert "singleuser" not in presets, mode + assert "dlightcool" not in _in_mode(mode), mode + + +def test_a_board_with_only_the_old_map_keeps_the_unconditional_list(): + """One map is not enough to judge by. `airconditioner_artik051_dongle_fac_18k` + is a legacy board that publishes OptionCode and no ExtendOptionCode, so every + eoc-gated rule would read None -- and None means "this board does not publish + the map", not "the feature is absent". Deriving from it would have cost that + unit WindFree in every mode, and left it with ['none'] alone in its own + fixture mode. + + Its OptionCode is also 521, three orders of magnitude below the RAC-class + values these bit positions were read from, which is the second reason not to + interpret it: the FAC and CAC families use the field differently. + """ + resources = _load_device("airconditioner_artik051_dongle_fac_18k") + options = resources["/mode/vs/0"]["x.com.samsung.da.options"] + assert any(o.startswith("OptionCode_") for o in options) + assert not any(o.startswith("ExtendOptionCode_") for o in options) + + baseline = ["none", "nano", "quiet", "comfort", "2step", "speed"] + for mode in ("Auto", "Cool", "Heat", "Dry", "Wind"): + resources["/mode/vs/0"]["x.com.samsung.da.modes"] = [mode] + assert _climate(resources).preset_modes == baseline, mode + + +def test_the_other_board_with_both_maps_still_derives(): + """`airconditioner_artik051_krac_energy` is the same model as the fixture + above with a different OptionCode (56378), and carries both maps -- so it + stays on the derived path rather than the fallback.""" + presets = _in_mode("Cool", fixture="airconditioner_artik051_krac_energy") + assert presets[:1] == ["none"] + assert "nano" in presets and "smart" in presets + assert presets != ["none", "nano", "quiet", "comfort", "2step", "speed"] + + +def test_an_unknown_hvac_mode_falls_back_instead_of_deriving(): + """An HVAC mode these rules have never seen is the same "cannot judge" case + as an absent map, so it gets the same answer rather than a derived-but-wrong + one. Reachable with a partial or malformed rep, where `modes` is missing.""" + resources = _load_device(FIXTURE) + for modes in ([], ["CoolClean"]): + resources["/mode/vs/0"]["x.com.samsung.da.modes"] = modes + assert _climate(resources).preset_modes == [ + "none", + "nano", + "quiet", + "comfort", + "2step", + "speed", + ], modes + + +async def test_aicomfort_neither_offers_nano_nor_switches_the_mode(): + """The app disables WindFree in AIComfort, so it is not offered -- and the + Cool-first write is therefore Auto-only, with no unreachable branch for a + mode that can never ask for it.""" + resources = _load_device(FIXTURE) + resources["/mode/vs/0"]["x.com.samsung.da.modes"] = ["AIComfort"] + coordinator = _FakeCoordinator(resources) + entity = _climate(resources, coordinator) + + assert "nano" not in entity.preset_modes + await entity.async_set_preset_mode("quiet") + assert [payload for _, payload in coordinator.commands] == [("preset_legacy", "Quiet")] def test_a_bit_that_is_zero_removes_its_preset(): From b820a96277e0f9ba45cb0e211864a65a62ff9238 Mon Sep 17 00:00:00 2001 From: perseus177 Date: Thu, 6 Aug 2026 12:20:18 +0200 Subject: [PATCH 4/4] docs(airconditioner): record that the board zeroes Sleep_ on leaving a sleep mode Review question on #304: a bare Comode_Off written over Comode_Sleep/Sleep_4 read back as Comode_Off/Sleep_0 at +8s and +38s, so the preset path cannot leave a stale duration for the next nano selection to read as a running timer. --- .../localthings/registry/capabilities/airconditioner.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index 2a4656e..8faaa94 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -420,6 +420,12 @@ def _preset_options(code, rep): 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")