diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 1482807..a1d2529 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,11 @@ from .registry.capabilities.airconditioner import ( HREF_WIND_STRENGTH as WIND_STRENGTH_HREF, ) from .registry.capabilities.airconditioner import ( + extend_option_code_bit, + has_extend_option_code, + has_option_code, is_legacy_board, + option_code_bit, ) from .registry.capabilities.common import normalize_temp_unit from .registry.entities import ClimateDesc @@ -131,6 +136,10 @@ PRESET_AI_COMFORT = "ai_comfort" # not modeled as a preset either: nothing confirms it's user-selectable. _NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"}) +# Seconds to let a legacy board settle into Cool before the WindFree token is +# written after it -- see _legacy_preset_needs_cool for the measurement. +_NANO_AFTER_MODE_DELAY = 3 + # Fan (wind strength): device codes "0".."4" -> HA standard fan constants where # a clean match exists so they auto-localize; "turbo" is custom (translated). _DEVICE_TO_FAN: dict[str, str] = { @@ -267,17 +276,125 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # Nano=windFree, Quiet, Comfort, 2Step, Speed=Fast Turbo, Off=none. _LEGACY_PRESET_CODES = ("Off", "Nano", "Quiet", "Comfort", "2Step", "Speed") + # Good Sleep occupies the same Comode_ slot as the presets above, so a unit + # running it reports Comode_Sleep -- or Comode_NanoSleep, which the board + # will produce by itself when nano wind is asked for while the timer runs. + # Neither is in the list above, and a preset_mode outside preset_modes is + # not a state HA allows, so they are added for boards that have the Sleep_ + # token these codes come with. + _LEGACY_SLEEP_PRESET_CODES = ("Sleep", "NanoSleep") + + # HVAC modes _legacy_preset_codes() has a rule for. Anything else is a mode + # this transcription has never seen, which is the same "cannot judge" case as + # a board that publishes no capability map -- and gets the same fallback. + _LEGACY_KNOWN_HVAC = frozenset( + {"Cool", "Heat", "HeatClean", "Dry", "Fan", "Wind", "Auto", _AI_COMFORT_MODE} + ) + + # Which comfort modes a legacy board offers in which HVAC mode, and which of + # them it has at all. Both come from the appliance rather than from a table + # per model: the unit publishes its capabilities as two bit maps in + # /mode/vs/0's options (OptionCode, ExtendOptionCode), and its own app gates + # each item on a bit plus the current mode. _legacy_preset_codes() below is + # that logic, transcribed from the app's updateOptionsList() so the two can be + # compared line by line, with the bit each rule reads named in the comment. + # + # Confirmed against an ARTIK051_KRAC_18K whose owner read the same lists off + # the remote and the app: WindFree in Cool/Dry/Fan and (being an 18K model) + # Auto but never Heat, and Fast Turbo and Comfort in Heat because oc[12] is + # set. d'light Cool is gated the same way on oc[2], which is zero here -- and + # the appliance refuses the token locally too. + # + # Single User is deliberately not modelled, though the app does gate it on + # oc[3] / oc[11]: it has no token of its own. The app's own Single User + # command sends `Comode_Smart` -- the Smart Saver token -- with a hardcoded + # 24 desired alongside it, so there is nothing to write that would be + # distinguishable from the Smart preset below, and no name to give it that + # the appliance would recognise. + + def _legacy_preset_codes(self, hvac: str, options: list) -> list[str]: + """Comfort-mode codes this unit offers in this HVAC mode. + + Derived only for boards that publish *both* capability maps. One map on + its own is not enough: every eoc-gated rule would then read None, and + None means "this board does not publish the map", never "the feature is + absent". The FAC/CAC boards on record carry only the older map, with + values small enough that RAC bit positions all read as zeros, so + requiring both also keeps these rules inside the family they were + documented for. + + Within the derived path, a bit that cannot be read (a malformed or + over-wide token) is treated as permission rather than denial for the + codes the unconditional list already carried -- losing a working preset + to a parsing failure is worse than offering one too many. Codes that were + never in that list (d'light) still need their bit to be explicitly set. + """ + rep = self._rep(MODE_HREF) + if ( + not has_option_code(rep) + or not has_extend_option_code(rep) + or hvac not in self._LEGACY_KNOWN_HVAC + ): + return list(self._LEGACY_PRESET_CODES) + + cool = hvac in ("Cool", _AI_COMFORT_MODE) + heat = hvac in ("Heat", "HeatClean") + codes = ["Off"] + # WindFree: shown on eoc[31]; disabled in Heat, in AIComfort, and in Auto + # unless this is an 18K model (eoc[30]), where the app switches to Cool for + # it instead -- which is what _legacy_preset_needs_cool does. + nano_mode_ok = not heat and hvac != _AI_COMFORT_MODE + if hvac == "Auto": + nano_mode_ok = extend_option_code_bit(rep, 30) is not False + if extend_option_code_bit(rep, 31) is not False and nano_mode_ok: + codes.append("Nano") + if cool or (heat and option_code_bit(rep, 12) is not False): # Fast Turbo + codes.append("Speed") + if cool: + codes.append("2Step") + if cool and option_code_bit(rep, 2): # d'light Cool -- needs the bit set + codes.append("DlightCool") + # Quiet reads oc[10] with no mode condition in the app, but the owner of + # the unit above sees it in Cool and Heat only, on the remote as well as + # in the app -- the observation wins over the reading. + if option_code_bit(rep, 10) is not False and (cool or heat): + codes.append("Quiet") + if cool or (heat and option_code_bit(rep, 12) is not False): # Comfort + codes.append("Comfort") + # Smart Saver has no bit of its own and the app hides it from every single + # RAC outright (showSaverOption = false), yet the appliance accepts it and + # behaves as Samsung documents -- so absence from the app is not absence + # from the hardware. Cool-only, per that documentation. + if hvac == "Cool": + codes.append("Smart") + # Good Sleep, which shares this slot: the app enables it in Cool, Heat and + # AIComfort only. Both codes, because the board turns Sleep into NanoSleep + # by itself when WindFree is running. + if (cool or heat) and any( + isinstance(option, str) and option.startswith("Sleep_") for option in options + ): + codes += self._LEGACY_SLEEP_PRESET_CODES + return codes + def _legacy_convenient(self) -> dict: """A /mode/convenient/vs/0-shaped rep built from the Comode_* token in /mode/vs/0's options, for boards that have no convenient resource.""" options = self._rep(MODE_HREF).get("x.com.samsung.da.options") or [] - for option in options: - if isinstance(option, str) and option.startswith("Comode_"): - return { - _MODES_FIELD: [option.split("_", 1)[1]], - _SUPPORTED_FIELD: list(self._LEGACY_PRESET_CODES), - } - return {} + active = next( + (o.split("_", 1)[1] for o in options if isinstance(o, str) and o.startswith("Comode_")), + None, + ) + if active is None: + return {} + + codes = self._legacy_preset_codes(_first(self._rep(MODE_HREF).get(_MODES_FIELD)), options) + # Whatever the unit is actually running has to be listed whether the + # rules expect it there or not -- a preset_mode outside preset_modes is + # not a state HA allows, and the appliance has the last word on what it + # is doing (a remote can put it in a mode these rules would not offer). + if active not in codes: + codes.append(active) + return {_MODES_FIELD: [active], _SUPPORTED_FIELD: codes} def _legacy_airflow(self) -> dict: """The /airflow/vs/0 rep, but only when it is the fan/swing channel @@ -622,6 +739,38 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): if self._rep(WIND_OSCILLATION_HREF): await self.coordinator.async_send_command(self._bound, ("oscillation", swing_mode)) + async def _legacy_preset_needs_cool(self, code: str) -> None: + """Switch a legacy board to Cool first when the preset needs it. + + WindFree does not exist in Auto: `["Comode_Nano"]` written while the unit + is in Auto is answered 2.04 Changed and dropped (measured, still + Comode_Off at +8s and +45s), and putting `modes: Cool` in the *same* POST + does not help -- the mode moves and the token is still dropped, so the + board judges the option against the mode it was in. Sent as its own write + first, it holds. The appliance's own app pairs `modes: Cool` with its nano + command for the same reason. + + Auto only. The app's builder also covers AIComfort, but its + `updateOptionsList()` disables the WindFree button there outright, so that + pairing can never fire -- and `_legacy_preset_codes()` likewise does not + offer `Nano` in AIComfort, which would leave such a branch unreachable. + + The pause is measured, not padding: back to back (same session, no gap at + all) the token was dropped again, two seconds apart it held. Three is that + with a little margin, and it only ever runs for this one preset in this + one HVAC mode. + """ + if not self._legacy_preset() or code != "Nano": + return + if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) != "Auto": + return + # Same resolver the rest of the platform uses -- the device code for an HA + # mode is read off the unit's own supportedModes rather than assumed. + await self.coordinator.async_send_command( + self._bound, ("mode", self._device_code_for_hvac(HVACMode.COOL)) + ) + await asyncio.sleep(_NANO_AFTER_MODE_DELAY) + async def async_set_preset_mode(self, preset_mode: str) -> None: if preset_mode == PRESET_AI_COMFORT: # Writes the primary mode resource, not the convenient one -- @@ -633,6 +782,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): # a fixed transform of the HA value -- e.g. 'NanoSleep' -> 'nanosleep'). for code in self._supported(CONVENIENT_HREF): if _preset_to_ha(code) == preset_mode: + await self._legacy_preset_needs_cool(code) kind = "preset_legacy" if self._legacy_preset() else "preset" await self.coordinator.async_send_command(self._bound, (kind, code)) return diff --git a/custom_components/localthings/registry/capabilities/airconditioner.py b/custom_components/localthings/registry/capabilities/airconditioner.py index f4689dc..8faaa94 100644 --- a/custom_components/localthings/registry/capabilities/airconditioner.py +++ b/custom_components/localthings/registry/capabilities/airconditioner.py @@ -245,6 +245,59 @@ def _option_token(rep, prefix): return None +def option_bit(rep, prefix, index, width): + """One capability bit out of the `OptionCode_` / `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 has_option_code(rep): + """Whether this board publishes the 16-wide capability map at all.""" + return _option_token(rep, "OptionCode") is not None + + +def has_extend_option_code(rep): + """Whether this board publishes the 32-wide capability map at all. + + Its own name in the app is "Single RAC new option code, as old option code + is full", and every RAC-class dump on record carries it while the FAC/CAC + ones carry only the older map with values small enough that RAC bit + positions read as zeros. So its presence is the closest thing available to + "this is the family those bit positions were documented for" -- a proxy, + not a proof, and used only to decide whether to read the map at all. + """ + return _option_token(rep, "ExtendOptionCode") is not None + + def is_legacy_board(resources): """True for the board generation whose airflow lives in /airflow/vs/0 rather than /wind/strength/vs/0 -- every AC dump on record has one shape @@ -324,6 +377,65 @@ def _option_number_write(prefix, factor=1): return write +def _good_sleep_write(payload, rep, href=None): + """Good Sleep needs its mode token in the same write as its duration. + + `Sleep_` on its own is answered 2.04 Changed and then thrown away: + measured on an ARTIK051_KRAC_18K, writing `["Sleep_4"]` left the token at + `Sleep_0` at both +8s and +45s, while the same value written together with + `Comode_Sleep` held. So the number is a parameter of the mode, not a + setting of its own, and the appliance's app never sends one without the + other either. + + Which mode token goes with it depends on nano wind, the way the app decides + it: nano and Good Sleep share the single `Comode_` slot, so running both is + `Comode_NanoSleep`, and switching the timer off while nano is on leaves nano + running rather than turning everything off. + """ + half_hours = round(float(payload) * 2) + nano = _option_token(rep, "Comode") in ("Nano", "NanoSleep") + if half_hours: + comode = "Comode_NanoSleep" if nano else "Comode_Sleep" + else: + comode = "Comode_Nano" if nano else "Comode_Off" + return ( + ["mode", "vs", "0"], + {"x.com.samsung.da.options": [comode, f"Sleep_{half_hours}"]}, + ) + + +# What the appliance itself picks when a Good Sleep mode is asked for with no +# duration to go with it: writing a bare `Comode_Nano` over a live +# `Comode_Sleep`/`Sleep_4` came back as `Comode_NanoSleep`/`Sleep_16`. Used only +# when a sleep preset is selected while the timer reads 0. +_DEFAULT_SLEEP_HALF_HOURS = 16 + + +def _preset_options(code, rep): + """The options array for a legacy preset write. + + One `Comode_` token has to express both nano wind and Good Sleep, so + selecting nano while the timer is running means `Comode_NanoSleep` -- and it + has to carry the duration, because the board otherwise supplies its own. + Measured: `["Comode_Nano"]` written over `Comode_Sleep`/`Sleep_4` came back + as `Comode_NanoSleep`/`Sleep_16`, silently turning the user's two hours into + eight. Writing the pair keeps the two hours. + + Leaving a sleep mode needs no such care: the board zeroes the duration by + itself. Measured on the same unit -- a bare `["Comode_Off"]` written over + `Comode_Sleep`/`Sleep_4` read back as `Comode_Off`/`Sleep_0` at +8s and +38s + -- so the `none` preset cannot leave a stale token behind for the next nano + selection to pick up as a running timer. + """ + sleep = _option_token(rep, "Sleep") + running = sleep not in (None, "0") + if code == "Nano" and running: + code = "NanoSleep" + if code in ("Sleep", "NanoSleep"): + return [f"Comode_{code}", f"Sleep_{sleep if running else _DEFAULT_SLEEP_HALF_HOURS}"] + return option_write("Comode", code) + + def _odor_controller_active(rep): """Odor-controller self-clean on/off, from the `SmartCoolClean_` option token (matches the SmartThings cloud's airConditionerOdorController @@ -404,7 +516,7 @@ def _climate_write(payload, rep, href=None): if kind == "swing_legacy": return (["airflow", "vs", "0"], {"x.com.samsung.da.direction": value}) if kind == "preset_legacy": - return (["mode", "vs", "0"], {"x.com.samsung.da.options": option_write("Comode", value)}) + return (["mode", "vs", "0"], {"x.com.samsung.da.options": _preset_options(value, rep)}) if kind == "preset": return (["mode", "convenient", "vs", "0"], {"x.com.samsung.da.modes": value}) return None @@ -555,7 +667,7 @@ CLIMATE = Capability( key="good_sleep", rep_fn=_option_token_num("Sleep", divisor=2), exists_fn=_has_option_token("Sleep"), - write_fn=_option_number_write("Sleep", factor=2), + write_fn=_good_sleep_write, native_min=0, native_max=12, step=0.5, diff --git a/custom_components/localthings/translations/cs.json b/custom_components/localthings/translations/cs.json index 82ccf6f..d63d69f 100644 --- a/custom_components/localthings/translations/cs.json +++ b/custom_components/localthings/translations/cs.json @@ -152,11 +152,13 @@ "smart": "Chytrý", "speed": "Rychlý", "nano": "WindFree", + "sleep": "Spánek", "nanosleep": "WindFree spánek", "longwind": "Dlouhý vánek", "motionindirect": "Nepřímý vzduch při pohybu", "motiondirect": "Přímý vzduch při pohybu", "drycomfort": "Komfortní sušení", + "dlightcool": "d'light Cool", "2step": "2stupňový" } } diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index dbf61ee..82f1082 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -152,11 +152,13 @@ "smart": "Smart", "speed": "Speed", "nano": "WindFree", + "sleep": "Sleep", "nanosleep": "WindFree sleep", "longwind": "Long wind", "motionindirect": "Motion indirect", "motiondirect": "Motion direct", "drycomfort": "Dry comfort", + "dlightcool": "d'light Cool", "2step": "2-Step" } } diff --git a/custom_components/localthings/translations/es.json b/custom_components/localthings/translations/es.json index d24b5a8..76146d1 100644 --- a/custom_components/localthings/translations/es.json +++ b/custom_components/localthings/translations/es.json @@ -277,11 +277,13 @@ "smart": "Inteligente", "speed": "Rápido", "nano": "WindFree", + "sleep": "Sueño", "nanosleep": "WindFree sueño", "longwind": "Viento prolongado", "motionindirect": "Indirecto al movimiento", "motiondirect": "Directo al movimiento", "drycomfort": "Confort seco", + "dlightcool": "d'light Cool", "2step": "2 pasos" } } diff --git a/custom_components/localthings/translations/it.json b/custom_components/localthings/translations/it.json index 8e3e0e7..55ec69f 100644 --- a/custom_components/localthings/translations/it.json +++ b/custom_components/localthings/translations/it.json @@ -152,11 +152,13 @@ "smart": "Smart", "speed": "Veloce", "nano": "WindFree", + "sleep": "Sonno", "nanosleep": "WindFree sonno", "longwind": "Vento prolungato", "motionindirect": "Indiretto al movimento", "motiondirect": "Diretto al movimento", "drycomfort": "Comfort asciugatura", + "dlightcool": "d'light Cool", "2step": "2 fasi" } } diff --git a/custom_components/localthings/translations/ko.json b/custom_components/localthings/translations/ko.json index 736928f..bcdf078 100644 --- a/custom_components/localthings/translations/ko.json +++ b/custom_components/localthings/translations/ko.json @@ -152,11 +152,13 @@ "smart": "스마트", "speed": "스피드", "nano": "무풍", + "sleep": "숙면", "nanosleep": "무풍 수면", "longwind": "롱바람", "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 2623d3b..07a6b05 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -152,11 +152,13 @@ "smart": "Slim", "speed": "Snel", "nano": "WindFree", + "sleep": "Slaap", "nanosleep": "WindFree-slaap", "longwind": "Lange wind", "motionindirect": "Beweging indirect", "motiondirect": "Beweging direct", "drycomfort": "Droog comfort", + "dlightcool": "d'light Cool", "2step": "2-Step" } } diff --git a/tests/test_airconditioner_artik051_krac.py b/tests/test_airconditioner_artik051_krac.py index 36723aa..2e7d3a9 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(): @@ -387,17 +425,22 @@ 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", + "smart", + "sleep", + "nanosleep", ] options = resources["/mode/vs/0"]["x.com.samsung.da.options"] @@ -417,6 +460,252 @@ 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 _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"] + # d'light Cool is a live rule and this unit's oc[2] denies it everywhere. + for mode in ("Cool", "Heat", "Dry", "Wind", "Auto"): + 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(): + """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 + 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."""