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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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ý"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
"motionindirect": "Motion indirect",
|
||||
"motiondirect": "Motion direct",
|
||||
"drycomfort": "Dry comfort",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2-Step"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"motionindirect": "Indirecto al movimiento",
|
||||
"motiondirect": "Directo al movimiento",
|
||||
"drycomfort": "Confort seco",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2 pasos"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
"motionindirect": "Indiretto al movimento",
|
||||
"motiondirect": "Diretto al movimento",
|
||||
"drycomfort": "Comfort asciugatura",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2 fasi"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
"motionindirect": "간접풍",
|
||||
"motiondirect": "직접풍",
|
||||
"drycomfort": "쾌적 제습",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2단계"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@
|
||||
"motionindirect": "Beweging indirect",
|
||||
"motiondirect": "Beweging direct",
|
||||
"drycomfort": "Droog comfort",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2-Step"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user