Merge pull request #304 from perseus177/ac-presets-per-hvac-mode
feat(climate): derive legacy AC presets from the unit's own capability bits, per HVAC mode
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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_<n>` / `ExtendOptionCode_<n>` 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_<n>` 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_<On/Off>`
|
||||
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,
|
||||
|
||||
@@ -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ý"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,11 +152,13 @@
|
||||
"smart": "스마트",
|
||||
"speed": "스피드",
|
||||
"nano": "무풍",
|
||||
"sleep": "숙면",
|
||||
"nanosleep": "무풍 수면",
|
||||
"longwind": "롱바람",
|
||||
"motionindirect": "간접풍",
|
||||
"motiondirect": "직접풍",
|
||||
"drycomfort": "쾌적 제습",
|
||||
"dlightcool": "d'light Cool",
|
||||
"2step": "2단계"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_<n>` 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."""
|
||||
|
||||
Reference in New Issue
Block a user