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:
9 files changed
+581
-18
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user