The href alone was not a sufficient key. /mode/convenient/vs/0 is declared by three family registries with three meanings: a real preset resource on the AC, explicitly unmodeled on the dehumidifier (no live current-value field), empty on the air purifier. Matching on the href globally meant a dehumidifier reporting a mode there would learn it, persist it, and show it in diagnostics for a resource nothing offers. The coordinator now narrows LEARNABLE to the hrefs a climate entity is actually bound to, at discovery -- which also retires the per-rep subdevice walk, since those hrefs are already actual. With that, LEARNABLE is a plain frozenset of hrefs and the per-href LearnRule goes away: its two fields were the same module constants for its only entry. observe() now returns the codes it learned rather than a bool the caller re-reads the store to interpret, so the log names what was new instead of everything ever learned. learned.py also takes ownership of the entry key and persisted shape -- the options flow was the second module that knew both, and the shape has already changed once. Comment trims throughout, per CONTRIBUTING: the LEARNABLE entry no longer recounts how many reporters there were, and three copies of the same test-stub comment are gone.
332 lines
12 KiB
Python
332 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, _climate_bound, _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, QUIET_REP) == ["Quiet"]
|
|
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, QUIET_REP) == ["Quiet"]
|
|
assert learned.observe(CONVENIENT, QUIET_REP) == []
|
|
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, rep) == []
|
|
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, {MODES_FIELD: "Quiet"}) == []
|
|
assert learned.codes(CONVENIENT) == []
|
|
|
|
|
|
def test_the_allowlist_is_only_the_convenient_href():
|
|
"""The guard that keeps this feature off resources whose current value
|
|
isn't a selectable option -- an oven idling in 'NoOperation' on
|
|
/mode/vs/0, whose codes would become permanent junk options in that
|
|
unit's cook-mode select."""
|
|
assert set(LEARNABLE) == {CONVENIENT}
|
|
|
|
|
|
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("/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": None},
|
|
{"/mode/convenient/vs/0": [""]},
|
|
{"/mode/convenient/vs/0": [1, 2]},
|
|
{"/mode/convenient/vs/0": {"x.com.samsung.da.supportedModes": ["Quiet"]}},
|
|
],
|
|
)
|
|
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: ["Quiet"]})
|
|
assert learned.codes(CONVENIENT) == ["Quiet"]
|
|
|
|
|
|
def test_clear_forgets_everything():
|
|
learned = LearnedModes({CONVENIENT: ["Quiet"]})
|
|
learned.clear()
|
|
assert learned.snapshot() == {}
|
|
|
|
|
|
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. The coordinator
|
|
enforces the device half of this (only hrefs a climate entity binds
|
|
are learnable); this is the static half."""
|
|
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)
|
|
return coordinator, LocalThingsClimate(coordinator, _climate_bound(coordinator, None))
|
|
|
|
|
|
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: ["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: ["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_a_device_with_no_climate_entity_learns_nothing(hass: HomeAssistant):
|
|
"""The href alone isn't a sufficient key: the dehumidifier registry
|
|
declares this same /mode/convenient/vs/0 explicitly unmodeled (no live
|
|
current-value field), so a code learned there would be persisted for a
|
|
resource nothing will ever offer."""
|
|
entry = _entry(hass)
|
|
coordinator = LocalThingsCoordinator(hass, entry)
|
|
await _discover(coordinator, "dehumidifier")
|
|
assert not any(isinstance(b.desc, ClimateDesc) for b in coordinator.bound)
|
|
|
|
coordinator._observe.apply(
|
|
CONVENIENT, {MODES_FIELD: "Quiet", SUPPORTED_FIELD: ADVERTISED}, source="poll"
|
|
)
|
|
|
|
assert coordinator.learned_snapshot() == {}
|
|
|
|
|
|
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: ["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: ["Smart"]}
|
|
assert entry.data[CONF_LEARNED_MODES] == {CONVENIENT: ["Smart"]}
|
|
|
|
|
|
async def test_forgetting_clears_the_store_and_the_entry(hass: HomeAssistant):
|
|
entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: ["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_forgetting_clears_a_record_the_store_rejected(hass: HomeAssistant):
|
|
"""A malformed persisted record is dropped on restore, so the store is
|
|
empty while the entry still holds it. Forget has to reach it anyway --
|
|
it's the only control that can."""
|
|
entry = _entry(hass, data={CONF_LEARNED_MODES: {CONVENIENT: None}})
|
|
coordinator, _ = await _climate(hass, entry)
|
|
assert coordinator.learned_snapshot() == {}
|
|
|
|
coordinator.forget_learned_modes()
|
|
await _flush(hass)
|
|
|
|
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: ["Quiet"]}}
|
|
assert diag["resources"][CONVENIENT][SUPPORTED_FIELD] == ADVERTISED
|