From 1becd85f6ef49abbffd5e6f05169cf1ead61f90d Mon Sep 17 00:00:00 2001 From: Marc Billow Date: Mon, 3 Aug 2026 19:22:06 +0000 Subject: [PATCH] Fix HOMECARE_WIZARD_V2 false-positive warning and None entity_id log (#235) HOMECARE_WIZARD_V2 appears in /mode/vs/0's supportedModes on TP2X_RAC_20K units but is a capability/option flag echoed from /configuration/vs/0's airconOptionList, not a selectable HVAC mode -- the unit's current mode never reports it. Added to a new _NON_HVAC_OPTION_CODES set that's dropped silently, so hvac_mode/hvac_modes stop tripping the issue #93 unmapped-mode warning for it on every start. Also fixes _warn_unmapped logging "None: device mode ..." during setup's first discovery pass, before the entity is added to hass and entity_id is assigned -- falls back to unique_id (set eagerly in __init__), so multiple same-type devices are distinguishable in the log. --- custom_components/localthings/climate.py | 37 +++++++++-- tests/test_climate_homecare_wizard.py | 80 ++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 tests/test_climate_homecare_wizard.py diff --git a/custom_components/localthings/climate.py b/custom_components/localthings/climate.py index d81a8e3..f095f4c 100644 --- a/custom_components/localthings/climate.py +++ b/custom_components/localthings/climate.py @@ -128,6 +128,23 @@ _HVAC_TO_DEVICE = {v: k for k, v in _DEVICE_TO_HVAC.items()} _AI_COMFORT_MODE = "AIComfort" PRESET_AI_COMFORT = "ai_comfort" +# Codes that appear in /mode/vs/0's supportedModes but are option/capability +# flags rather than selectable thermodynamic operations -- dropped silently +# (no _warn_unmapped call) rather than every owner of an affected unit +# tripping the issue #93 warning on every start. +# +# HOMECARE_WIZARD_V2 (issue #235, TP2X_RAC_20K): also appears in +# /configuration/vs/0's x.com.samsung.da.airconOptionList alongside +# PRODUCT_GLOBAL/AI_3.0/SingleCommand_1 -- clearly a capability flag, not a +# mode, on that resource. The unit's own `modes` (current mode) never +# reported it as active across the reporter's logs, only ever a real +# thermodynamic mode -- consistent with it being echoed into supportedModes +# rather than genuinely selectable. Unlike _AI_COMFORT_MODE above, it isn't +# modeled as a preset: there's no confirmation it's user-selectable at all, +# so silently dropping it (rather than guessing a write contract) is the +# 'don't guess' rule applied to a mode code instead of a resource field. +_NON_HVAC_OPTION_CODES = frozenset({"HOMECARE_WIZARD_V2"}) + # 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] = { @@ -363,7 +380,15 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): 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).""" + in the log instead of silently vanishing (issue #93). + + Falls back to `unique_id` when `entity_id` is unset (issue #235): + this fires during setup's first discovery pass, before the entity is + added to hass, so `entity_id` is always None at that point -- + indistinguishable across multiple same-type devices in the log. + `unique_id` is set eagerly in `__init__` (see entity.py), so it's + always available here even though `entity_id` isn't. + """ key = (href, code) if key in self._warned_unmapped: return @@ -371,7 +396,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): _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, + self.entity_id or self.unique_id, code, href, ) @@ -472,7 +497,11 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): 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: + if ( + device is not None + and device not in _DEVICE_TO_HVAC + and device not in _NON_HVAC_OPTION_CODES + ): self._warn_unmapped(MODE_HREF, device) return _DEVICE_TO_HVAC.get(device, HVACMode.AUTO) @@ -480,7 +509,7 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity): def hvac_modes(self) -> list[HVACMode]: modes = [HVACMode.OFF] for m in self._supported(MODE_HREF): - if m == _AI_COMFORT_MODE: + if m == _AI_COMFORT_MODE or m in _NON_HVAC_OPTION_CODES: continue mapped = _DEVICE_TO_HVAC.get(m) if mapped is None: diff --git a/tests/test_climate_homecare_wizard.py b/tests/test_climate_homecare_wizard.py new file mode 100644 index 0000000..cd4e58d --- /dev/null +++ b/tests/test_climate_homecare_wizard.py @@ -0,0 +1,80 @@ +"""Tests for issue #235: HOMECARE_WIZARD_V2 false-positive unmapped-mode +warning, and the entity_id-is-None logging gap it surfaced. + +Uses the pre-existing airconditioner_tp2x_rac_20k fixture (issue #37) -- +its /mode/vs/0 already carries the exact reporter-confirmed shape: +supportedModes includes 'HOMECARE_WIZARD_V2' alongside the real modes, the +current mode ('Cool') never is it, and /configuration/vs/0's +airconOptionList lists it alongside other plain capability flags. +""" + +from __future__ import annotations + +import logging + +import pytest +from homeassistant.components.climate import HVACMode +from homeassistant.core import HomeAssistant + +from custom_components.localthings.climate import _NON_HVAC_OPTION_CODES, LocalThingsClimate +from custom_components.localthings.registry.entities import ClimateDesc +from tests.test_subdevice_discovery import _coordinator, _discover + + +@pytest.fixture +async def climate(hass: HomeAssistant): + coordinator = _coordinator(hass) + await _discover(coordinator, "airconditioner_tp2x_rac_20k") + bound = next(b for b in coordinator.bound if isinstance(b.desc, ClimateDesc)) + return LocalThingsClimate(coordinator, bound) + + +def test_homecare_wizard_v2_excluded_from_hvac_modes(climate): + """supportedModes is ['Auto', 'Cool', 'Dry', 'Wind', 'Heat', + 'HOMECARE_WIZARD_V2'] -- five real modes plus the option flag. Every + real mode maps through; the flag contributes no extra (or bogus) + HVACMode entry.""" + modes = climate.hvac_modes + assert set(modes) == { + HVACMode.OFF, + HVACMode.AUTO, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.HEAT, + HVACMode.FAN_ONLY, + } + + +def test_homecare_wizard_v2_reports_no_warning(climate, caplog): + """Reading hvac_modes must not emit the issue #93 unmapped-mode warning + for HOMECARE_WIZARD_V2 -- that's the actual bug report: every owner of + an affected unit saw this fire on every start.""" + with caplog.at_level(logging.WARNING): + _ = climate.hvac_modes + assert "HOMECARE_WIZARD_V2" not in caplog.text + + +def test_hvac_mode_is_off_when_powered_off(climate): + """The fixture's power is off -- hvac_mode short-circuits to OFF before + ever reading /mode/vs/0's 'Cool', matching real behavior (mode is only + meaningful while the unit is on).""" + assert climate.hvac_mode == HVACMode.OFF + + +def test_homecare_wizard_v2_is_the_known_non_hvac_set(): + assert "HOMECARE_WIZARD_V2" in _NON_HVAC_OPTION_CODES + + +def test_warn_unmapped_falls_back_to_unique_id_when_entity_id_unset(climate, caplog): + """Issue #235's secondary bug: _warn_unmapped logged 'None: device mode + ...' because entity_id is unset until the entity is added to hass -- + exactly the state a fixture-backed entity is in here, without ever + calling async_add_entities. unique_id is set eagerly in __init__ + (entity.py), so it must appear in the log line instead of a bare + 'None'.""" + assert climate.entity_id is None + assert climate.unique_id is not None + with caplog.at_level(logging.WARNING): + climate._warn_unmapped("/mode/vs/0", "TotallyUnknownCode") + assert "None:" not in caplog.text + assert climate.unique_id in caplog.text