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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user