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