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():