Rework AIComfort as an HVACMode.AUTO + preset overlay, add unmapped-mode warning (issue #93)
AIComfort isn't a distinct thermodynamic operation like Cool/Dry/Heat -- it's an AI-driven overlay on top of the device's own 'Auto' behavior, confirmed by A-CAWW-TP2-20-COMMON reporting both 'Auto' and 'AIComfort' as separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled the idiomatic HA way instead of a flat _DEVICE_TO_HVAC entry: hvac_mode reports AUTO and a new 'ai_comfort' preset carries the distinction. Entered/left only via the preset (writes the primary mode resource, not the convenient one) -- there's no dedicated HVACMode value for it, so it's not offered in the hvac_mode dropdown directly. Also adds a once-per-(href, code) warning log when a device-reported mode has no entry in the relevant map, so a future gap like this one surfaces in the log instead of silently vanishing -- the exact failure mode issue #93 called out ("this class of gap is invisible without diffing against supportedModes").
This commit is contained in:
@@ -20,6 +20,8 @@ back for, power, mode, temperature and wind resources alike.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ClimateEntity,
|
||||
ClimateEntityFeature,
|
||||
@@ -53,6 +55,8 @@ from .const import DOMAIN
|
||||
from .coordinator import LocalThingsCoordinator
|
||||
from .entity import LocalThingsEntity, _is_included
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_MODES_FIELD = 'x.com.samsung.da.modes'
|
||||
_SUPPORTED_FIELD = 'x.com.samsung.da.supportedModes'
|
||||
|
||||
@@ -65,14 +69,22 @@ _DEVICE_TO_HVAC: dict[str, HVACMode] = {
|
||||
'Wind': HVACMode.FAN_ONLY,
|
||||
'Auto': HVACMode.HEAT_COOL,
|
||||
'Heat': HVACMode.HEAT,
|
||||
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON) -- a
|
||||
# separate device code from 'Auto' above (both can appear in the same
|
||||
# unit's supportedModes), so it needs its own entry rather than reusing
|
||||
# 'Auto'. HVACMode.AUTO is otherwise unused by this map.
|
||||
'AIComfort': HVACMode.AUTO,
|
||||
}
|
||||
_HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()}
|
||||
|
||||
# AI-driven auto-comfort mode (issue #93, A-CAWW-TP2-20-COMMON). Not a flat
|
||||
# _DEVICE_TO_HVAC entry: 'AIComfort' isn't a distinct thermodynamic operation
|
||||
# like Cool/Dry/Heat, it's an AI overlay on top of the device's own 'Auto'
|
||||
# behavior -- confirmed by this unit reporting both 'Auto' and 'AIComfort' as
|
||||
# separate, mutually-exclusive entries in /mode/vs/0's supportedModes. Modeled
|
||||
# the idiomatic HA way instead: hvac_mode reports AUTO (same as plain 'Auto'
|
||||
# would if it were ever mapped there) and a dedicated 'ai_comfort' preset
|
||||
# carries the distinction a bare hvac_mode can't. Not reachable via
|
||||
# async_set_hvac_mode -- entered/left only through the preset, since there's
|
||||
# no dedicated HVACMode value for it to write back to.
|
||||
_AI_COMFORT_MODE = 'AIComfort'
|
||||
PRESET_AI_COMFORT = 'ai_comfort'
|
||||
|
||||
# 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] = {
|
||||
@@ -169,6 +181,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
| ClimateEntityFeature.TURN_ON
|
||||
| ClimateEntityFeature.TURN_OFF
|
||||
)
|
||||
# (href, raw device code) pairs already logged by _warn_unmapped --
|
||||
# these properties are read on every coordinator refresh, so an
|
||||
# un-deduped warning would spam the log for any device with a
|
||||
# genuinely unrecognized code.
|
||||
self._warned_unmapped: set[tuple[str, str]] = set()
|
||||
|
||||
# -- resource helpers ---------------------------------------------------
|
||||
|
||||
@@ -185,13 +202,34 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
def _supported(self, href: str) -> list[str]:
|
||||
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
|
||||
|
||||
def _warn_unmapped(self, href: str, code: str) -> None:
|
||||
"""Log once per (href, code) when a device-reported mode has no
|
||||
entry in the relevant device<->HA map, so a real device gap surfaces
|
||||
in the log instead of silently vanishing (issue #93)."""
|
||||
key = (href, code)
|
||||
if key in self._warned_unmapped:
|
||||
return
|
||||
self._warned_unmapped.add(key)
|
||||
_LOGGER.warning(
|
||||
"%s: device mode %r on %s has no HA mapping and was dropped; "
|
||||
"please file an issue with your diagnostics dump",
|
||||
self.entity_id, code, href,
|
||||
)
|
||||
|
||||
def _read_mode(self, href: str, mapping: dict):
|
||||
"""Current mode of a wind/convenient resource, mapped to its HA value."""
|
||||
return mapping.get(_first(self._rep(href).get(_MODES_FIELD)))
|
||||
raw = _first(self._rep(href).get(_MODES_FIELD))
|
||||
if raw is not None and raw not in mapping:
|
||||
self._warn_unmapped(href, raw)
|
||||
return mapping.get(raw)
|
||||
|
||||
def _read_modes(self, href: str, mapping: dict) -> list[str]:
|
||||
"""Supported modes of a resource, mapped to HA values (unknowns dropped)."""
|
||||
return [mapping[c] for c in self._supported(href) if c in mapping]
|
||||
supported = self._supported(href)
|
||||
for c in supported:
|
||||
if c not in mapping:
|
||||
self._warn_unmapped(href, c)
|
||||
return [mapping[c] for c in supported if c in mapping]
|
||||
|
||||
# -- temperature --------------------------------------------------------
|
||||
|
||||
@@ -255,14 +293,23 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
if not self._is_on():
|
||||
return HVACMode.OFF
|
||||
device = _first(self._rep(MODE_HREF).get(_MODES_FIELD))
|
||||
if device == _AI_COMFORT_MODE:
|
||||
return HVACMode.AUTO
|
||||
if device is not None and device not in _DEVICE_TO_HVAC:
|
||||
self._warn_unmapped(MODE_HREF, device)
|
||||
return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO)
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
modes = [HVACMode.OFF]
|
||||
for m in self._supported(MODE_HREF):
|
||||
if m == _AI_COMFORT_MODE:
|
||||
continue
|
||||
mapped = _DEVICE_TO_HVAC.get(m)
|
||||
if mapped is not None and mapped not in modes:
|
||||
if mapped is None:
|
||||
self._warn_unmapped(MODE_HREF, m)
|
||||
continue
|
||||
if mapped not in modes:
|
||||
modes.append(mapped)
|
||||
return modes
|
||||
|
||||
@@ -286,11 +333,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
|
||||
@property
|
||||
def preset_mode(self):
|
||||
if _first(self._rep(MODE_HREF).get(_MODES_FIELD)) == _AI_COMFORT_MODE:
|
||||
return PRESET_AI_COMFORT
|
||||
return self._read_mode(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
|
||||
@property
|
||||
def preset_modes(self) -> list[str]:
|
||||
return self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
modes = self._read_modes(CONVENIENT_HREF, _DEVICE_TO_PRESET)
|
||||
if _AI_COMFORT_MODE in self._supported(MODE_HREF):
|
||||
modes.append(PRESET_AI_COMFORT)
|
||||
return modes
|
||||
|
||||
# -- writes -------------------------------------------------------------
|
||||
|
||||
@@ -329,4 +381,10 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
||||
await self._set_mapped('swing', _SWING_TO_DEVICE, swing_mode)
|
||||
|
||||
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 --
|
||||
# 'AIComfort' lives in /mode/vs/0 alongside Cool/Dry/Auto, not in
|
||||
# /mode/convenient/vs/0 with Quiet/Smart/Speed/Sleep.
|
||||
await self.coordinator.async_send_command(self._bound, ('mode', _AI_COMFORT_MODE))
|
||||
return
|
||||
await self._set_mapped('preset', _PRESET_TO_DEVICE, preset_mode)
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"ai_comfort": "AI Comfort",
|
||||
"quiet": "Quiet",
|
||||
"smart": "Smart",
|
||||
"speed": "Speed"
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
},
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"ai_comfort": "AI-comfort",
|
||||
"quiet": "Stil",
|
||||
"smart": "Slim",
|
||||
"speed": "Snel"
|
||||
|
||||
@@ -1,34 +1,40 @@
|
||||
"""Tests for the AC HVAC-mode device<->HA map in climate.py (issue #93).
|
||||
"""Tests for the AC HVAC-mode/preset device<->HA maps in climate.py (issue #93).
|
||||
|
||||
`_DEVICE_TO_HVAC`/`_HVAC_TO_DEVICE` are plain module-level dicts with no
|
||||
coordinator/entity dependency, so -- like `_temps_vs_item` in
|
||||
test_climate_temperature_fallback.py -- they're testable directly.
|
||||
Module-level dicts/constants with no coordinator/entity dependency, so --
|
||||
like `_temps_vs_item` in test_climate_temperature_fallback.py -- they're
|
||||
testable directly.
|
||||
"""
|
||||
from homeassistant.components.climate import HVACMode
|
||||
|
||||
from custom_components.localthings.climate import _DEVICE_TO_HVAC, _HVAC_TO_DEVICE
|
||||
|
||||
|
||||
def test_aicomfort_maps_to_auto():
|
||||
"""A-CAWW-TP2-20-COMMON (issue #93) reports 'AIComfort' in its
|
||||
supportedModes alongside 'Auto' -- a separate AI-driven auto-comfort
|
||||
mode, distinct from the existing 'Auto' -> HEAT_COOL entry."""
|
||||
assert _DEVICE_TO_HVAC['AIComfort'] == HVACMode.AUTO
|
||||
from custom_components.localthings.climate import (
|
||||
_AI_COMFORT_MODE, _DEVICE_TO_HVAC, _HVAC_TO_DEVICE, PRESET_AI_COMFORT,
|
||||
)
|
||||
|
||||
|
||||
def test_auto_still_maps_to_heat_cool():
|
||||
"""'AIComfort' is additive -- the existing 'Auto' -> HEAT_COOL mapping
|
||||
(a different device code) is unchanged."""
|
||||
"""'Auto' is unchanged -- AIComfort is handled separately, not folded
|
||||
into this map."""
|
||||
assert _DEVICE_TO_HVAC['Auto'] == HVACMode.HEAT_COOL
|
||||
|
||||
|
||||
def test_hvac_auto_writes_back_aicomfort():
|
||||
"""Reverse map: selecting HA's Auto hvac_mode writes the 'AIComfort'
|
||||
device code, not 'Auto' (which is reserved for HEAT_COOL)."""
|
||||
assert _HVAC_TO_DEVICE[HVACMode.AUTO] == 'AIComfort'
|
||||
def test_aicomfort_not_in_flat_hvac_map():
|
||||
"""AIComfort isn't a flat _DEVICE_TO_HVAC entry -- it's an AI overlay on
|
||||
top of 'Auto', modeled as hvac_mode=AUTO + a dedicated preset instead of
|
||||
a distinct HVACMode value (see the climate.py module comment)."""
|
||||
assert _AI_COMFORT_MODE not in _DEVICE_TO_HVAC
|
||||
|
||||
|
||||
def test_hvac_auto_not_writable_via_hvac_mode():
|
||||
"""HVACMode.AUTO has no _DEVICE_TO_HVAC entry, so it's unreachable via
|
||||
async_set_hvac_mode -- entered/left only through the ai_comfort preset."""
|
||||
assert HVACMode.AUTO not in _HVAC_TO_DEVICE
|
||||
|
||||
|
||||
def test_fan_only_still_reachable_via_wind():
|
||||
"""Guard against regressing the existing 'Wind' -> FAN_ONLY entry while
|
||||
editing this map."""
|
||||
assert _DEVICE_TO_HVAC['Wind'] == HVACMode.FAN_ONLY
|
||||
|
||||
|
||||
def test_preset_ai_comfort_constant():
|
||||
assert PRESET_AI_COMFORT == 'ai_comfort'
|
||||
|
||||
Reference in New Issue
Block a user