diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index 511c712..032909e 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -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) diff --git a/custom_components/localthings/translations/en.json b/custom_components/localthings/translations/en.json index f990cc6..3145d4f 100644 --- a/custom_components/localthings/translations/en.json +++ b/custom_components/localthings/translations/en.json @@ -95,6 +95,7 @@ }, "preset_mode": { "state": { + "ai_comfort": "AI Comfort", "quiet": "Quiet", "smart": "Smart", "speed": "Speed" diff --git a/custom_components/localthings/translations/nl.json b/custom_components/localthings/translations/nl.json index c922d82..9e0f20b 100644 --- a/custom_components/localthings/translations/nl.json +++ b/custom_components/localthings/translations/nl.json @@ -95,6 +95,7 @@ }, "preset_mode": { "state": { + "ai_comfort": "AI-comfort", "quiet": "Stil", "smart": "Slim", "speed": "Snel" diff --git a/tests/test_climate_ac_modes.py b/tests/test_climate_ac_modes.py index 62c3489..41c5bfe 100644 --- a/tests/test_climate_ac_modes.py +++ b/tests/test_climate_ac_modes.py @@ -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'