Files
localthings/tests/test_learned_modes.py
T
Marc Billow d65735ac47 Remember modes a device reports but never advertises (issue #327)
Some firmware reports a current mode that is missing from the same
resource's supportedModes. An ARTIK051 air conditioner sits in Quiet
while advertising only [Off, Sleep, Speed, Nano, NanoSleep], so HA
showed preset_mode: quiet and then refused to select it. A second
reporter has three identical units where only the two sharing an
outdoor unit hide it, which rules out a real capability difference.

learned.py remembers any such code and the coordinator persists it on
the config entry, so a mode the device only names while it is active
survives a restart. climate._supported unions it into the resource's
own list, which fixes the read and the write together --
async_set_preset_mode reverse-resolves the device code from that same
list.

Learning is allowlisted per canonical href rather than global. Across
the fixture corpus 17 dumps already report a current mode that is not
in supportedModes: an oven idling in NoOperation, a fridge's
/mode/vs/0 carrying capability tokens like WATERFILTER_DISABLE. Those
are not selectable options, and remembering one permanently would put
an option in the UI that the device can only reject. Only
/mode/convenient/vs/0 is learnable today.

On by default, with a per-device option that stops offering and
learning at once, and a reset step in the options flow for a code that
turns out to be bogus. Diagnostics report what was learned separately
from `resources`, which stays exactly what the device said.
2026-08-08 19:10:53 +00:00

306 lines
12 KiB
Python

"""Tests for issue #327: remembering a mode the device reports itself in
but never advertises in supportedModes.
The reporters' unit (ARTIK051_PRAC_20K) isn't in the corpus, so the
scenario runs on an existing fixture whose /mode/convenient/vs/0 genuinely
lacks Quiet -- airconditioner_tp1x_fac_time_23k, supportedModes [Off,
Sleep, Nano, NanoSleep] -- by applying the rep their firmware sends: a
current mode of 'Quiet' with that same supportedModes list unchanged.
"""
from __future__ import annotations
import asyncio
from typing import Any, cast
import pytest
from homeassistant.core import HomeAssistant
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings.climate import LocalThingsClimate
from custom_components.localthings.const import (
CONF_LEARN_MODES,
CONF_LEARNED_MODES,
DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.learned import (
LEARNABLE,
MODES_FIELD,
SUPPORTED_FIELD,
LearnedModes,
)
from custom_components.localthings.registry.entities import ClimateDesc
from tests.test_subdevice_discovery import ENTRY_DATA, _discover
FIXTURE = "airconditioner_tp1x_fac_time_23k"
CONVENIENT = "/mode/convenient/vs/0"
ADVERTISED = ["Off", "Sleep", "Nano", "NanoSleep"]
# What the reporters' firmware sends while the unit is in Quiet: the mode
# named as current, and a supported list that still omits it. `modes` is a
# bare string here, matching the dump in the issue.
QUIET_REP = {MODES_FIELD: "Quiet", SUPPORTED_FIELD: ADVERTISED}
# ---------------------------------------------------------------------------
# The store itself
# ---------------------------------------------------------------------------
def test_learns_a_current_mode_missing_from_the_supported_list():
learned = LearnedModes()
assert learned.observe(CONVENIENT, CONVENIENT, QUIET_REP) is True
assert learned.codes(CONVENIENT) == ["Quiet"]
def test_relearning_the_same_mode_is_not_a_change():
"""The device reports the same rep on every poll while it sits in the
mode, so only the first one may report back as something to persist."""
learned = LearnedModes()
assert learned.observe(CONVENIENT, CONVENIENT, QUIET_REP) is True
assert learned.observe(CONVENIENT, CONVENIENT, QUIET_REP) is False
assert learned.codes(CONVENIENT) == ["Quiet"]
def test_an_advertised_mode_is_never_learned():
learned = LearnedModes()
rep = {MODES_FIELD: "Sleep", SUPPORTED_FIELD: ADVERTISED}
assert learned.observe(CONVENIENT, CONVENIENT, rep) is False
assert learned.codes(CONVENIENT) == []
def test_a_rep_with_no_supported_list_teaches_nothing():
""" "Missing from the list" is only meaningful against a list that
exists -- a board publishing none would otherwise get an option list
invented out of whatever it happened to be doing."""
learned = LearnedModes()
assert learned.observe(CONVENIENT, CONVENIENT, {MODES_FIELD: "Quiet"}) is False
assert learned.codes(CONVENIENT) == []
def test_an_href_outside_the_allowlist_learns_nothing():
"""The guard that keeps this feature off resources whose current value
isn't a selectable option: an oven idling in 'NoOperation' reports
exactly this shape on /mode/vs/0, and remembering it would put a
permanent junk option in that unit's cook-mode select."""
learned = LearnedModes()
rep = {MODES_FIELD: "NoOperation", SUPPORTED_FIELD: ["Bake", "Broil"]}
assert learned.observe("/mode/vs/0", "/mode/vs/0", rep) is False
assert learned.snapshot() == {}
def test_two_subdevices_learn_separately():
"""Keyed by the actual on-the-wire href (issue #177), so a composite
appliance's second indoor unit doesn't inherit the first's gap."""
learned = LearnedModes()
learned.observe(CONVENIENT, "/mode/convenient/vs/1", QUIET_REP)
assert learned.codes("/mode/convenient/vs/1") == ["Quiet"]
assert learned.codes(CONVENIENT) == []
@pytest.mark.parametrize(
"stored",
[
None,
"not-a-dict",
{"/mode/convenient/vs/0": "not-a-dict"},
{"/mode/convenient/vs/0": {SUPPORTED_FIELD: [""]}},
{"/mode/convenient/vs/0": {SUPPORTED_FIELD: [1, 2]}},
],
)
def test_malformed_stored_data_restores_as_empty(stored):
"""It round-trips through the config entry as plain JSON, so a
hand-edited .storage file must not be able to break setup."""
assert LearnedModes(stored).snapshot() == {}
def test_well_formed_stored_data_restores():
learned = LearnedModes({CONVENIENT: {SUPPORTED_FIELD: ["Quiet"]}})
assert learned.codes(CONVENIENT) == ["Quiet"]
def test_clear_reports_whether_there_was_anything_to_forget():
learned = LearnedModes({CONVENIENT: {SUPPORTED_FIELD: ["Quiet"]}})
assert learned.clear() is True
assert learned.snapshot() == {}
assert learned.clear() is False
def test_every_learnable_href_is_one_climate_resolves():
"""climate._supported is the only consumer that unions learned codes
in today, so an href added to LEARNABLE that climate never reads would
be learned, persisted, and never offered anywhere."""
from custom_components.localthings.climate import (
CONVENIENT_HREF,
MODE_HREF,
WIND_DIRECTION_HREF,
WIND_STRENGTH_HREF,
)
assert set(LEARNABLE) <= {MODE_HREF, WIND_STRENGTH_HREF, WIND_DIRECTION_HREF, CONVENIENT_HREF}
# ---------------------------------------------------------------------------
# End to end: device reports it -> HA offers it -> the entry remembers it
# ---------------------------------------------------------------------------
def _entry(hass: HomeAssistant, data: dict | None = None, options: dict | None = None):
entry = MockConfigEntry(
domain=DOMAIN,
data={**ENTRY_DATA, **(data or {})},
options=options or {},
unique_id="localthings_LEARNED-TEST",
)
entry.add_to_hass(hass)
return entry
async def _flush(hass: HomeAssistant) -> None:
"""Let a persist scheduled from the applying thread land.
The coordinator marshals it with hass.add_job (the same way it
marshals its state push), which goes through call_soon_threadsafe --
async_block_till_done alone doesn't pick that up in this harness.
"""
await asyncio.sleep(0)
await hass.async_block_till_done()
async def _climate(hass: HomeAssistant, entry) -> tuple[LocalThingsCoordinator, Any]:
coordinator = LocalThingsCoordinator(hass, entry)
await _discover(coordinator, FIXTURE)
bound = next(b for b in coordinator.bound if isinstance(b.desc, ClimateDesc))
return coordinator, LocalThingsClimate(coordinator, bound)
async def test_the_fixture_really_does_not_advertise_quiet(hass: HomeAssistant):
"""Guard for every assertion below: they'd all pass vacuously against a
unit that advertised Quiet in the first place."""
_, entity = await _climate(hass, _entry(hass))
assert "quiet" not in entity.preset_modes
async def test_a_reported_mode_becomes_a_preset(hass: HomeAssistant):
coordinator, entity = await _climate(hass, _entry(hass))
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
assert entity.preset_mode == "quiet"
assert "quiet" in entity.preset_modes
# Alongside, not instead of, what the device does advertise.
assert "sleep" in entity.preset_modes
async def test_a_learned_preset_is_writable(hass: HomeAssistant, monkeypatch):
"""The reason the union belongs in _supported: async_set_preset_mode
reverse-resolves the device code from the same list, so read and write
are fixed by one change."""
coordinator, entity = await _climate(hass, _entry(hass))
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
sent: list = []
async def _record(bound, payload):
sent.append(payload)
monkeypatch.setattr(coordinator, "async_send_command", _record)
await entity.async_set_preset_mode("quiet")
assert sent == [("preset", "Quiet")]
async def test_learning_persists_onto_the_config_entry(hass: HomeAssistant):
entry = _entry(hass)
coordinator, _ = await _climate(hass, entry)
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
await _flush(hass)
assert entry.data[CONF_LEARNED_MODES] == {CONVENIENT: {SUPPORTED_FIELD: ["Quiet"]}}
async def test_a_learned_preset_survives_a_restart(hass: HomeAssistant):
"""The point of persisting: the unit only names Quiet while it is in
Quiet, so a restart in any other mode would otherwise lose it until
someone reached for the physical remote again."""
entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: {SUPPORTED_FIELD: ["Quiet"]}}})
_, entity = await _climate(hass, entry)
# Nothing applied this run -- the fixture's own rep says Off, and its
# supportedModes still omits Quiet.
assert entity.preset_mode == "none"
assert "quiet" in entity.preset_modes
async def test_an_optimistic_write_teaches_nothing(hass: HomeAssistant):
"""An optimistic cache entry is the value this integration just wrote,
not something the device reported."""
coordinator, _ = await _climate(hass, _entry(hass))
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="optimistic")
assert coordinator.learned_snapshot() == {}
async def test_a_partial_notify_still_learns(hass: HomeAssistant):
"""An OBSERVE notify can carry `modes` alone (issue #27). Learning sees
the merged rep, so the supported list from the last full poll is still
there to judge it against."""
coordinator, entity = await _climate(hass, _entry(hass))
coordinator._observe.apply(CONVENIENT, {MODES_FIELD: "Quiet"}, source="observe")
assert "quiet" in entity.preset_modes
async def test_the_option_turns_off_both_halves(hass: HomeAssistant):
"""Off means the stock list, immediately -- nothing new is learned and
nothing already learned is offered."""
entry = _entry(
hass,
data={CONF_LEARNED_MODES: {CONVENIENT: {SUPPORTED_FIELD: ["Smart"]}}},
options={CONF_LEARN_MODES: False},
)
coordinator, entity = await _climate(hass, entry)
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
await _flush(hass)
assert "smart" not in entity.preset_modes
assert "quiet" not in entity.preset_modes
# Kept, not discarded -- turning the option back on restores it, and
# nothing new was written while it was off.
assert coordinator.learned_snapshot() == {CONVENIENT: {SUPPORTED_FIELD: ["Smart"]}}
assert entry.data[CONF_LEARNED_MODES] == {CONVENIENT: {SUPPORTED_FIELD: ["Smart"]}}
async def test_forgetting_clears_the_store_and_the_entry(hass: HomeAssistant):
entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: {SUPPORTED_FIELD: ["Quiet"]}}})
coordinator, entity = await _climate(hass, entry)
coordinator.forget_learned_modes()
await _flush(hass)
assert "quiet" not in entity.preset_modes
assert entry.data[CONF_LEARNED_MODES] == {}
async def test_diagnostics_report_what_was_learned(
hass: HomeAssistant,
enable_custom_integrations,
):
"""Kept out of the `resources` dump on purpose -- that stays exactly
what the device said, so a triager can still see the gap."""
from custom_components.localthings.diagnostics import (
async_get_config_entry_diagnostics,
)
entry = _entry(hass)
coordinator, _ = await _climate(hass, entry)
coordinator._observe.apply(CONVENIENT, QUIET_REP, source="poll")
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
diag = await async_get_config_entry_diagnostics(hass, cast(Any, entry))
assert diag["learned_modes"] == {
"enabled": True,
"codes": {CONVENIENT: {SUPPORTED_FIELD: ["Quiet"]}},
}
assert diag["resources"][CONVENIENT][SUPPORTED_FIELD] == ADVERTISED