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.
This commit is contained in:
Marc Billow
2026-08-08 19:10:53 +00:00
parent 867f4b0ae8
commit d65735ac47
19 changed files with 754 additions and 16 deletions
+3
View File
@@ -95,6 +95,9 @@ Each device has its own **Configure** option in Settings > Devices & Services, u
- **Allow writes even when remote control is reported off** — by default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Some devices accept certain writes anyway (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. Only enable this if you've confirmed writes actually work on your device with remote control off — otherwise you trade a clear error for a silent failure.
- **Estimated finish -- minimum change (minutes)** — a washer/dryer/dishwasher's `finish_time` sensor is recomputed from the device's own remaining-time estimate on every poll, which commonly drifts or gets revised by a minute or two between updates. This setting holds `finish_time` at its last reported value until a new estimate differs by at least this many minutes, cutting down on Home Assistant history/logbook noise from a value that hasn't meaningfully changed. Defaults to `3`; set it to `0` to report every computed change.
- **Remember modes the device reports but doesn't advertise** — some firmware reports a current mode it never lists as supported. Issue #327's air conditioner sits in `Quiet` while offering only `Off/Sleep/Speed/Nano/NanoSleep`, so Home Assistant showed the preset as active but refused to select it. LocalThings remembers any such mode it sees and keeps offering it afterwards, stored on the config entry so it survives a restart — the device only names the mode while it is in it, and you shouldn't have to reach for the physical remote after every reboot. Defaults to on. Turning it off offers only what the device advertises, without discarding what was already learned.
The same **Configure** menu has a **Forget remembered modes** step, which clears what has been learned for that device. Use it if a mode was learned that turns out not to be selectable — otherwise, by design, it stays forever.
---
+10 -1
View File
@@ -448,7 +448,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
return bool(self._rep(POWER_HREF).get("value"))
def _supported(self, href: str) -> list[str]:
return list(self._rep(href).get(_SUPPORTED_FIELD) or [])
"""The resource's own supportedModes, plus any code this unit has
been seen in but never advertised (issue #327, learned.py).
Both the option lists (preset_modes, fan_modes, ...) and the write
paths resolve codes through here, so a learned code is selectable
and writable by virtue of appearing in one list.
"""
supported = list(self._rep(href).get(_SUPPORTED_FIELD) or [])
learned = self.coordinator.learned_modes(self._bound.subdevice.to_actual(href))
return supported + [code for code in learned if code not in supported]
def _warn_unmapped(self, href: str, code: str) -> None:
"""Log once per (href, code) when a device-reported mode has no
+49 -1
View File
@@ -45,11 +45,14 @@ from .const import (
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM,
CONF_LEARN_MODES,
CONF_LEARNED_MODES,
CONF_MANUFACTURER,
CONF_MODEL,
CONF_PORT,
CONF_SERIAL,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_LEARN_MODES,
DOMAIN,
LIVENESS_PROBE_TIMEOUT_S,
PREFERRED_PROBE_PORTS,
@@ -830,7 +833,7 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
return self.async_show_menu(
step_id="init",
menu_options=["settings", "debug_write"],
menu_options=["settings", "forget_learned_modes", "debug_write"],
)
async def async_step_settings(
@@ -854,10 +857,55 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
),
): _HYSTERESIS_MINUTES,
vol.Required(
CONF_LEARN_MODES,
default=self.config_entry.options.get(
CONF_LEARN_MODES, DEFAULT_LEARN_MODES
),
): bool,
}
),
)
async def async_step_forget_learned_modes(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm-and-clear for the learned-mode store (issue #327).
The point of learning is that it's permanent, so a code learned
from a one-off firmware hiccup would otherwise sit in an option
list forever. An empty schema renders as a plain confirmation
form; the description lists what's about to be forgotten.
"""
coord = self._coordinator()
learned = (
coord.learned_snapshot()
if coord is not None
else self.config_entry.data.get(CONF_LEARNED_MODES) or {}
)
codes = sorted(
{code for fields in learned.values() for value in fields.values() for code in value}
)
if user_input is not None:
if coord is not None:
coord.forget_learned_modes()
else:
# Not loaded, so there's no store to clear -- drop the
# persisted copy directly, which is all a reload would
# restore from anyway.
self.hass.config_entries.async_update_entry(
self.config_entry,
data={**self.config_entry.data, CONF_LEARNED_MODES: {}},
)
return self.async_create_entry(data=dict(self.config_entry.options))
return self.async_show_form(
step_id="forget_learned_modes",
data_schema=vol.Schema({}),
description_placeholders={"codes": ", ".join(codes) if codes else "(none)"},
)
async def async_step_debug_write(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
+14
View File
@@ -34,6 +34,20 @@ CONF_MODEL = "model"
CONF_MANUFACTURER = "manufacturer"
CONF_DEVICE_TYPE = "device_type"
# entry.data key: modes this device reported itself in but never advertised
# in the same resource's supportedModes (issue #327). Stored on the entry
# rather than kept in memory so a mode the device only names while it is
# active survives a restart -- see learned.py. Shape:
# {actual_href: {supported_field: [code, ...]}}.
CONF_LEARNED_MODES = "learned_modes"
# Options-flow key: whether learned modes are remembered and offered.
# Defaults to on; turning it off stops both halves at once (nothing new is
# learned, nothing already learned is offered) without discarding what was
# already remembered -- the options flow's reset step does that.
CONF_LEARN_MODES = "learn_device_modes"
DEFAULT_LEARN_MODES = True
# Options-flow key (entry.options, not entry.data): lets a user override
# the device-wide remote-control-off write block for a specific device
# (issue #54). Some devices accept certain writes even while reporting
@@ -28,15 +28,19 @@ from .const import (
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEAF_KEY_PEM,
CONF_LEARN_MODES,
CONF_LEARNED_MODES,
CONF_MANUFACTURER,
CONF_MODEL,
CONF_PORT,
CONF_SERIAL,
DEFAULT_LEARN_MODES,
DEVICE_SUPPORT_ISSUE_URL,
DOMAIN,
DTLS_LOCAL_PORT_BASE,
SUMMARY_INTERVAL_S,
)
from .learned import SUPPORTED_FIELD, LearnedModes
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
from .registry import CAPABILITIES
from .registry.adapter import flatten
@@ -239,6 +243,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._cache = StateCache(_NoOpDescriptor())
self._cache.set_on_change(self._on_cache_changed)
self._observe = ObserveManager(self._cache, logger=self._log)
# Modes this device reported itself in but never advertised as
# supported (issue #327), restored from the entry so one learned
# last week is still offered today. See learned.py.
self._learned = LearnedModes(entry.data.get(CONF_LEARNED_MODES))
self._observe.set_on_applied(self._on_rep_applied)
self._push_pending = False
self._push_pending_lock = threading.Lock()
# Identity is resolved once by the config flow's probe (issue #236).
@@ -305,6 +314,69 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._canonical_cache[view_key] = view
return view
# ------------------------------------------------------------------
# Learned modes (issue #327)
# ------------------------------------------------------------------
@property
def learning_enabled(self) -> bool:
return bool(self._entry.options.get(CONF_LEARN_MODES, DEFAULT_LEARN_MODES))
def learned_modes(self, actual_href: str, field: str = SUPPORTED_FIELD) -> list[str]:
"""Codes learned for `actual_href`, or [] while the option is off.
Gating the read here rather than only the write is what makes the
option a single switch: turning it off restores stock behavior
immediately, without also throwing away what was already learned
(the options flow's reset step is for that)."""
if not self.learning_enabled:
return []
return self._learned.codes(actual_href, field)
def learned_snapshot(self) -> dict[str, dict[str, list[str]]]:
"""Everything learned, option state ignored -- for diagnostics and
the options flow, both of which need to show what is remembered
even when it isn't currently being offered."""
return self._learned.snapshot()
def forget_learned_modes(self) -> None:
"""Drop every learned mode, here and on the entry."""
if self._learned.clear():
self._persist_learned()
def _canonical_href(self, actual: str) -> str:
"""`actual` in the namespace the registry -- and so learned.LEARNABLE
-- is written against; identity for MAIN's own hrefs (issue #177)."""
for subdevice in self.subdevices:
if subdevice.owns(actual):
return subdevice.to_canonical(actual) or actual
return actual
def _on_rep_applied(self, href: str, rep: dict, source: str) -> None:
"""ObserveManager.set_on_applied hook. Runs on whichever thread
applied the update, so the persist goes through hass.add_job the
same way _on_cache_changed marshals its state push.
An 'optimistic' rep is the value this integration just wrote, not
one the device reported, so there is nothing to learn from it."""
if source == "optimistic" or not self.learning_enabled:
return
if self._learned.observe(self._canonical_href(href), href, rep):
self._log.info(
"%s reported mode(s) it does not advertise as supported; "
"remembering %s so they stay selectable (issue #327)",
href,
self._learned.codes(href),
)
self.hass.add_job(self._persist_learned)
@callback
def _persist_learned(self) -> None:
self.hass.config_entries.async_update_entry(
self._entry,
data={**self._entry.data, CONF_LEARNED_MODES: self._learned.snapshot()},
)
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
"""DeviceInfo for one logical subdevice on this connection (issue
#177): the master's own device_info for MAIN, or a linked child
@@ -128,6 +128,15 @@ async def async_get_config_entry_diagnostics(
# about the connection rather than one subdevice's state, and
# nothing polls it after discovery so it would go stale in there.
"multidevice": redact_resources(coordinator._multidevice),
# Modes this unit reported itself in but never advertised (issue
# #327). Reported separately from `resources` on purpose: the dump
# above stays exactly what the device said, so a triager can still
# see the gap these codes were inferred from. Keyed by actual href,
# like the store itself.
"learned_modes": {
"enabled": coordinator.learning_enabled,
"codes": coordinator.learned_snapshot(),
},
"integration_version": integration.version,
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
+142
View File
@@ -0,0 +1,142 @@
"""Modes a device reports itself in but never advertises as supported.
Some firmwares report a current mode that is missing from the same
resource's own supported list (issue #327: an ARTIK051 air conditioner
sitting in 'Quiet' with supportedModes [Off, Sleep, Speed, Nano,
NanoSleep]). The mode is real -- the remote and the SmartThings app select
it, and the unit accepts it written back -- so once the device has been
seen in it, it is remembered and offered alongside the advertised ones.
Learning is deliberately not global. A current value that isn't a
selectable option is common across this corpus -- an oven idling in
'NoOperation', a fridge's /mode/vs/0 carrying capability tokens like
'WATERFILTER_DISABLE' -- and remembering one of those permanently would
put an option in the UI that the device can only reject. LEARNABLE is the
allowlist of canonical hrefs where a device-reported current mode is known
to be a genuine selectable option; every consumer of it (climate's
_supported today) must read its supported list through the coordinator.
"""
from __future__ import annotations
import threading
from dataclasses import dataclass
from .registry.capabilities.airconditioner import HREF_CONVENIENT
MODES_FIELD = "x.com.samsung.da.modes"
SUPPORTED_FIELD = "x.com.samsung.da.supportedModes"
@dataclass(frozen=True)
class LearnRule:
"""Which field of a rep names the current mode, and which lists the
supported ones. Both are per-href because Samsung spells them
differently across resources (`supportedModes` on the vendor `/vs/`
ones, bare `modes`/`supportedModes` on a few OCF-shaped ones)."""
current_field: str
supported_field: str
LEARNABLE: dict[str, LearnRule] = {
# Convenient (preset) mode. Reported by two independent reporters on
# ARTIK051_PRAC_20K, one of whom has three identical units where only
# the two sharing an outdoor unit hide Quiet -- so this is a firmware
# reporting gap, not a real capability difference.
HREF_CONVENIENT: LearnRule(MODES_FIELD, SUPPORTED_FIELD),
}
def _codes(value) -> list[str]:
"""The mode codes in a `modes`-style field, which is an array on every
board seen but a bare string on none -- tolerated anyway, since this
runs against whatever the device sends."""
if isinstance(value, str):
return [value]
if isinstance(value, (list, tuple)):
return [v for v in value if isinstance(v, str)]
return []
def _coerce(stored) -> dict[str, dict[str, list[str]]]:
"""Restore the persisted map, dropping anything that isn't the shape
this module writes. It round-trips through the config entry as plain
JSON, and a hand-edited .storage file shouldn't be able to crash
setup."""
restored: dict[str, dict[str, list[str]]] = {}
if not isinstance(stored, dict):
return restored
for href, fields in stored.items():
if not isinstance(href, str) or not isinstance(fields, dict):
continue
for field, codes in fields.items():
if not isinstance(field, str):
continue
if valid := [c for c in _codes(codes) if c]:
restored.setdefault(href, {})[field] = valid
return restored
class LearnedModes:
"""Per-device store of learned codes, keyed by actual (on-the-wire)
href so two subdevices of one composite appliance learn separately.
Mutated from whichever thread applied the update (the DTLS reader for
an OBSERVE notify, an executor thread for a poll -- see
ObserveManager.apply), so every access takes the lock; persistence is
the caller's job, on the event loop.
"""
def __init__(self, stored=None) -> None:
self._lock = threading.Lock()
self._learned = _coerce(stored)
def observe(self, canonical_href: str, actual_href: str, rep: dict) -> bool:
"""Learn from one applied rep; True when something new was learned
(i.e. the caller should persist).
A rep that carries no supported list teaches nothing: "missing from
the list" is only meaningful against a list that exists, and
inventing one for a device that publishes none would offer options
nothing ever said were selectable. `rep` is the merged rep
ObserveManager.apply stores, so a partial notify carrying `modes`
alone (issue #27) still sees the supported list from the last full
poll.
"""
rule = LEARNABLE.get(canonical_href)
if rule is None:
return False
supported = _codes(rep.get(rule.supported_field))
if not supported:
return False
with self._lock:
known = self._learned.get(actual_href, {}).get(rule.supported_field, [])
new = [
code
for code in _codes(rep.get(rule.current_field))
if code and code not in supported and code not in known
]
if not new:
return False
self._learned.setdefault(actual_href, {})[rule.supported_field] = [*known, *new]
return True
def codes(self, actual_href: str, field: str = SUPPORTED_FIELD) -> list[str]:
with self._lock:
return list(self._learned.get(actual_href, {}).get(field, ()))
def snapshot(self) -> dict[str, dict[str, list[str]]]:
with self._lock:
return {
href: {f: list(c) for f, c in fields.items()}
for href, fields in self._learned.items()
}
def clear(self) -> bool:
"""Forget everything; True when there was something to forget."""
with self._lock:
if not self._learned:
return False
self._learned = {}
return True
+24 -1
View File
@@ -14,6 +14,7 @@ from __future__ import annotations
import logging
import threading
import time
from collections.abc import Callable
import cbor2
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
@@ -77,10 +78,25 @@ class ObserveManager:
# have notified. Guards only `_notified` mutations + the `wait_for`.
self._notify_cond = threading.Condition()
self.fallback_hrefs: set[str] = set()
# Called with (href, merged_rep, source) after every accepted
# device update, on the applying thread -- see set_on_applied.
self._on_applied: Callable[[str, dict, str], None] | None = None
self._refresh_task: ObserveRefreshTask | None = None
self._refresh_stop: threading.Event | None = None
self._refresh_thread: threading.Thread | None = None
def set_on_applied(self, callback: Callable[[str, dict, str], None]) -> None:
"""Register a hook run after every rep this manager accepts, with
the merged rep that reached the cache.
Unlike StateCache.set_on_change, which reports only that
*something* changed, this hands over the href and rep -- and fires
even when the rep matched what was already cached, which the
learned-modes store (learned.py) depends on: a device sitting in
an unadvertised mode sends an unchanged rep every poll.
"""
self._on_applied = callback
def mark_write_pending(self, href: str, settle_s: float = DEFAULT_SETTLE_S) -> None:
with self._settle_lock:
self._settle_until[href] = time.monotonic() + settle_s
@@ -138,7 +154,14 @@ class ObserveManager:
return False
with self._cache_lock:
merged = {**(self.cache.get(href) or {}), **rep}
return self.cache.apply_rep(href, merged, source=source)
changed = self.cache.apply_rep(href, merged, source=source)
# Outside the cache lock -- the hook takes locks of its own and
# never reads the cache back. `source` is passed along rather than
# filtered here: which sources are worth acting on is the hook's
# policy, not this manager's.
if self._on_applied is not None:
self._on_applied(href, merged, source)
return changed
def on_notification(self, href: str, payload: bytes) -> None:
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
@@ -1348,17 +1348,23 @@
"title": "Možnosti LocalThings",
"menu_options": {
"settings": "Nastavení zápisu pro dálkové ovládání",
"forget_learned_modes": "Zapomenout zapamatované režimy",
"debug_write": "Ladění: zápis do prostředku"
}
},
"settings": {
"title": "Nastavení zápisu pro dálkové ovládání",
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.",
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.\n\nNěkterá zařízení hlásí režim, který nikdy neuvedou mezi podporovanými – například klimatizace běžící v režimu Quiet, která nabízí pouze Off/Sleep/Speed. LocalThings si každý takový režim zapamatuje a dál jej nabízí, takže zůstane volitelný, jakmile v něm zařízení alespoň jednou bylo. Vypnutím této volby se budou nabízet pouze režimy, které zařízení samo uvádí; k vymazání již zapamatovaných použijte „Zapomenout zapamatované režimy“ v předchozí nabídce.",
"data": {
"bypass_remote_control_lock": "Povolit zápis i když je dálkové ovládání hlášeno jako vypnuté",
"finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)"
"finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)",
"learn_device_modes": "Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované"
}
},
"forget_learned_modes": {
"title": "Zapomenout zapamatované režimy",
"description": "Aktuálně zapamatováno: {codes}\n\nJde o režimy, ve kterých se toto zařízení samo hlásilo, aniž by je uvádělo mezi podporovanými; jsou uchovány, aby zůstaly volitelné. Zapomenutí je řešením, pokud se některý ukázal jako chybný – cokoli, co zařízení skutečně znovu nahlásí, si systém opět zapamatuje, pokud zároveň nevypnete volbu „Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované“ v nastavení."
},
"debug_write": {
"title": "Ladění: zápis do prostředku",
"description": "Nástroj pro pokročilé uživatele k odhalení chování zápisu specifického pro dané zařízení. Vyberte prostředek (href), do kterého chcete zapisovat, nebo zadejte vlastní, který není uveden v seznamu. Toto obchází blokování při vypnutém dálkovém ovládání a odesílá přesně ta pole, která zadáte -- může to špatně nakonfigurovat váš spotřebič, používejte tedy záměrně.",
@@ -1348,17 +1348,23 @@
"title": "LocalThings options",
"menu_options": {
"settings": "Device settings",
"forget_learned_modes": "Forget remembered modes",
"debug_write": "Debug: write to a resource"
}
},
"settings": {
"title": "Device settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)"
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)",
"learn_device_modes": "Remember modes the device reports but doesn't advertise"
}
},
"forget_learned_modes": {
"title": "Forget remembered modes",
"description": "Currently remembered: {codes}\n\nThese are modes this device reported itself in without listing them as supported, kept so they stay selectable. Forgetting them is the fix if one turned out to be bogus -- anything the device genuinely reports again will simply be remembered again, unless you also turn off \"Remember modes the device reports but doesn't advertise\" in Device settings."
},
"debug_write": {
"title": "Debug: write to a resource",
"description": "Power-user tool for pinning down device-specific write behavior. Pick the resource (href) you want to write to, or type a custom one that isn't listed. This bypasses the remote-control-off block and sends exactly the fields you provide -- it can misconfigure your appliance, so use it deliberately.",
@@ -53,17 +53,23 @@
"title": "Opciones de LocalThings",
"menu_options": {
"settings": "Ajustes del dispositivo",
"forget_learned_modes": "Olvidar los modos recordados",
"debug_write": "Depuración: escribir en un recurso"
}
},
"settings": {
"title": "Ajustes del dispositivo",
"description": "Algunos dispositivos aceptan ciertas escrituras (p. ej. la dosificación por defecto de detergente/suavizante en una lavadora) incluso cuando informan de control remoto desactivado. Por defecto, LocalThings bloquea cada escritura con un error claro cuando un dispositivo informa de control remoto desactivado, en lugar de dejar que el dispositivo la rechace en silencio. Solo actívalo si has confirmado que las escrituras funcionan de verdad en este dispositivo con el control remoto desactivado; si no, cambiarás ese error claro por un fallo silencioso.\n\nLa hora estimada de finalización se recalcula a partir de la estimación de tiempo restante del dispositivo en cada sondeo, que puede variar o revisarse uno o dos minutos entre actualizaciones. Sube el valor de cambio mínimo siguiente para mantener el sensor en su último valor notificado hasta que la estimación varíe al menos esa cantidad de minutos, reduciendo el ruido en el historial/registro de actividad. Ponlo a 0 para notificar cada cambio calculado.",
"description": "Algunos dispositivos aceptan ciertas escrituras (p. ej. la dosificación por defecto de detergente/suavizante en una lavadora) incluso cuando informan de control remoto desactivado. Por defecto, LocalThings bloquea cada escritura con un error claro cuando un dispositivo informa de control remoto desactivado, en lugar de dejar que el dispositivo la rechace en silencio. Solo actívalo si has confirmado que las escrituras funcionan de verdad en este dispositivo con el control remoto desactivado; si no, cambiarás ese error claro por un fallo silencioso.\n\nLa hora estimada de finalización se recalcula a partir de la estimación de tiempo restante del dispositivo en cada sondeo, que puede variar o revisarse uno o dos minutos entre actualizaciones. Sube el valor de cambio mínimo siguiente para mantener el sensor en su último valor notificado hasta que la estimación varíe al menos esa cantidad de minutos, reduciendo el ruido en el historial/registro de actividad. Ponlo a 0 para notificar cada cambio calculado.\n\nAlgunos modelos notifican un modo que nunca incluyen entre los admitidos: por ejemplo, un aire acondicionado en modo Quiet que solo ofrece Off/Sleep/Speed. LocalThings recuerda cualquier modo así que detecte y lo sigue ofreciendo, de modo que quede seleccionable en cuanto el dispositivo haya estado en él al menos una vez. Desactívalo para ofrecer solo lo que el dispositivo anuncia; usa «Olvidar los modos recordados» en la pantalla anterior para borrar lo ya recordado.",
"data": {
"bypass_remote_control_lock": "Permitir escrituras incluso cuando el control remoto se notifica como desactivado",
"finish_time_hysteresis_minutes": "Finalización estimada -- cambio mínimo (minutos)"
"finish_time_hysteresis_minutes": "Finalización estimada -- cambio mínimo (minutos)",
"learn_device_modes": "Recordar los modos que el dispositivo notifica pero no anuncia"
}
},
"forget_learned_modes": {
"title": "Olvidar los modos recordados",
"description": "Recordados actualmente: {codes}\n\nSon modos en los que este dispositivo se notificó a sí mismo sin incluirlos entre los admitidos; se conservan para que sigan siendo seleccionables. Olvidarlos es la solución si alguno resultó ser erróneo: cualquier modo que el dispositivo vuelva a notificar de verdad se recordará otra vez, salvo que además desactives «Recordar los modos que el dispositivo notifica pero no anuncia» en los ajustes del dispositivo."
},
"debug_write": {
"title": "Depuración: escribir en un recurso",
"description": "Herramienta para usuarios avanzados para precisar el comportamiento de escritura específico del dispositivo. Elige el recurso (href) sobre el que quieres escribir, o escribe uno personalizado que no esté en la lista. Esto omite el bloqueo de control remoto desactivado y envía exactamente los campos que proporciones; puede desconfigurar tu electrodoméstico, así que úsalo deliberadamente.",
@@ -1348,17 +1348,23 @@
"title": "Opzioni LocalThings",
"menu_options": {
"settings": "Impostazioni dispositivo",
"forget_learned_modes": "Dimentica le modalità memorizzate",
"debug_write": "Debug: scrivi su una risorsa"
}
},
"settings": {
"title": "Impostazioni dispositivo",
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.",
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.\n\nAlcuni modelli segnalano una modalità che non elencano mai tra quelle supportate: ad esempio un condizionatore in modalità Quiet che offre solo Off/Sleep/Speed. LocalThings memorizza ogni modalità di questo tipo che rileva e continua a proporla, così resta selezionabile una volta che il dispositivo vi è stato almeno una volta. Disattivare questa opzione per proporre solo ciò che il dispositivo dichiara; usare «Dimentica le modalità memorizzate» nella schermata precedente per cancellare quanto già memorizzato.",
"data": {
"bypass_remote_control_lock": "Consenti la scrittura anche quando il controllo remoto risulta disattivato.",
"finish_time_hysteresis_minutes": "Tempo finale stimato - variazione minima (minuti)"
"finish_time_hysteresis_minutes": "Tempo finale stimato - variazione minima (minuti)",
"learn_device_modes": "Memorizza le modalità che il dispositivo segnala ma non dichiara supportate"
}
},
"forget_learned_modes": {
"title": "Dimentica le modalità memorizzate",
"description": "Attualmente memorizzate: {codes}\n\nSono modalità in cui questo dispositivo si è dichiarato senza elencarle tra quelle supportate; vengono conservate perché restino selezionabili. Dimenticarle è la soluzione se una si è rivelata errata: qualsiasi modalità che il dispositivo segnali davvero di nuovo verrà memorizzata un'altra volta, a meno che non si disattivi anche «Memorizza le modalità che il dispositivo segnala ma non dichiara supportate» nelle impostazioni del dispositivo."
},
"debug_write": {
"title": "Debug: scrivi su una risorsa",
"description": "Strumento avanzato per definire con precisione il comportamento di scrittura specifico del dispositivo. Seleziona la risorsa (href) su cui desideri scrivere oppure digitane una personalizzata non presente nell'elenco. Questo bypassa il blocco di disattivazione del controllo remoto e invia esattamente i campi specificati; tuttavia, potrebbe causare una configurazione errata del dispositivo, quindi utilizzalo con cautela.",
@@ -1348,17 +1348,23 @@
"title": "LocalThings 옵션",
"menu_options": {
"settings": "기기 설정",
"forget_learned_modes": "기억된 모드 지우기",
"debug_write": "디버그: 리소스에 쓰기"
}
},
"settings": {
"title": "기기 설정",
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.",
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.\n\n일부 모델은 지원 목록에 없는 모드를 현재 모드로 보고합니다. 예를 들어 Off/Sleep/Speed만 제공하면서 Quiet 모드로 동작 중이라고 보고하는 에어컨이 그렇습니다. LocalThings는 이렇게 발견한 모드를 기억해 두고 계속 제공하므로, 기기가 한 번이라도 그 모드였다면 이후에도 선택할 수 있습니다. 이 옵션을 끄면 기기가 지원한다고 알린 모드만 제공합니다. 이미 기억된 항목을 지우려면 이전 화면의 '기억된 모드 지우기'를 사용하세요.",
"data": {
"bypass_remote_control_lock": "스마트 컨트롤이 꺼진 것으로 보고되어도 쓰기 허용",
"finish_time_hysteresis_minutes": "예상 완료 시각 - 최소 변경량(분)"
"finish_time_hysteresis_minutes": "예상 완료 시각 - 최소 변경량(분)",
"learn_device_modes": "기기가 보고하지만 지원 목록에 없는 모드 기억하기"
}
},
"forget_learned_modes": {
"title": "기억된 모드 지우기",
"description": "현재 기억된 항목: {codes}\n\n이 기기가 지원 목록에 넣지 않은 채 자신의 현재 모드로 보고했던 모드들이며, 계속 선택할 수 있도록 보관되어 있습니다. 잘못된 항목이 기억되었다면 지우면 됩니다. 다만 기기가 실제로 다시 보고하면 다시 기억되므로, 그러길 원하지 않는다면 기기 설정에서 '기기가 보고하지만 지원 목록에 없는 모드 기억하기'도 함께 꺼 주세요."
},
"debug_write": {
"title": "디버그: 리소스에 쓰기",
"description": "기기별 쓰기 동작을 파악하기 위한 고급 사용자용 도구입니다. 쓰려는 리소스(href)를 선택하거나 목록에 없는 값을 직접 입력하세요. 이 기능은 스마트 컨트롤 꺼짐 차단을 우회하고 입력한 필드를 그대로 전송합니다. 가전제품이 잘못 설정될 수 있으므로 신중하게 사용하세요.",
@@ -1348,17 +1348,23 @@
"title": "LocalThings-opties",
"menu_options": {
"settings": "Apparaatinstellingen",
"forget_learned_modes": "Onthouden modi vergeten",
"debug_write": "Foutopsporing: naar een resource schrijven"
}
},
"settings": {
"title": "Apparaatinstellingen",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden.",
"data": {
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)"
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)",
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft"
}
},
"forget_learned_modes": {
"title": "Onthouden modi vergeten",
"description": "Nu onthouden: {codes}\n\nDit zijn modi waarin dit apparaat zichzelf meldde zonder ze als ondersteund op te geven; ze worden bewaard zodat ze selecteerbaar blijven. Vergeten is de oplossing als er een onterecht tussen staat: alles wat het apparaat echt opnieuw meldt, wordt gewoon opnieuw onthouden, tenzij je ook \"Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft\" bij Apparaatinstellingen uitzet."
},
"debug_write": {
"title": "Foutopsporing: naar een resource schrijven",
"description": "Geavanceerd hulpmiddel om apparaatspecifiek schrijfgedrag te onderzoeken. Kies de resource (href) waarnaar je wilt schrijven, of voer een aangepaste resource in die niet in de lijst staat. Hiermee wordt de blokkering bij een uitgeschakelde afstandsbediening omzeild en worden precies de opgegeven velden verzonden. Dit kan de configuratie van je apparaat verstoren, dus gebruik het bewust.",
+63 -1
View File
@@ -17,6 +17,8 @@ from custom_components.localthings.const import (
CONF_CA_KEY_PEM,
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEARN_MODES,
CONF_LEARNED_MODES,
CONF_PORT,
DOMAIN,
)
@@ -935,7 +937,11 @@ async def test_options_flow_init_shows_menu(hass: HomeAssistant) -> None:
assert result["type"] == FlowResultType.MENU
assert result["step_id"] == "init"
assert set(cast(Iterable[str], result["menu_options"])) == {"settings", "debug_write"}
assert set(cast(Iterable[str], result["menu_options"])) == {
"settings",
"forget_learned_modes",
"debug_write",
}
async def test_options_flow_default_is_off(hass: HomeAssistant) -> None:
@@ -974,6 +980,62 @@ async def test_options_flow_can_enable_bypass(hass: HomeAssistant) -> None:
assert entry.options[CONF_BYPASS_REMOTE_CONTROL] is True
async def test_learned_modes_option_defaults_to_on(hass: HomeAssistant) -> None:
"""Issue #327's remembering is on by default -- a device that hides a
mode it's in should just work, not need the option found first."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "settings"}
)
data_schema = result["data_schema"]
assert data_schema is not None
assert data_schema({})[CONF_LEARN_MODES] is True
async def test_learned_modes_option_can_be_turned_off(hass: HomeAssistant) -> None:
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "settings"}
)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={CONF_BYPASS_REMOTE_CONTROL: False, CONF_LEARN_MODES: False},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert entry.options[CONF_LEARN_MODES] is False
async def test_forget_learned_modes_clears_the_entry(hass: HomeAssistant) -> None:
"""The reset step works on an unloaded entry too, by dropping the
persisted copy directly -- that's all a reload would restore from."""
entry = MockConfigEntry(
domain=DOMAIN,
data={**ENTRY_DATA, CONF_LEARNED_MODES: {"/mode/convenient/vs/0": {"f": ["Quiet"]}}},
unique_id=f"localthings_{MOCK_SERIAL}",
)
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "forget_learned_modes"}
)
assert result["type"] == FlowResultType.FORM
assert result["description_placeholders"] == {"codes": "Quiet"}
result = await hass.config_entries.options.async_configure(result["flow_id"], user_input={})
assert result["type"] == FlowResultType.CREATE_ENTRY
assert entry.data[CONF_LEARNED_MODES] == {}
async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant) -> None:
"""Reopening the form shows the currently-saved choice as the default,
not always False."""
@@ -64,6 +64,11 @@ class _FakeCoordinator:
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
def learned_modes(self, href, field=None):
# Nothing learned in this stub -- issue #327's store lives on the
# real coordinator; climate._supported unions it in.
return []
def _discover(resources, registry=airconditioner.REGISTRY):
unbound = []
@@ -48,6 +48,11 @@ class _FakeCoordinator:
async def async_send_command(self, bound, payload):
self.commands.append((bound, payload))
def learned_modes(self, href, field=None):
# Nothing learned in this stub -- issue #327's store lives on the
# real coordinator; climate._supported unions it in.
return []
def _climate(resources, coordinator=None):
info = resources["/information/vs/0"]
+5
View File
@@ -119,6 +119,11 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
# canonical view is just the raw snapshot (issue #177).
return self.last_resources
def learned_modes(self, href, field=None):
# Nothing learned in this stub -- issue #327's store lives on
# the real coordinator; climate._supported unions it in.
return []
resources = _load_device("airconditioner_fac_bora")
info = resources["/information/vs/0"]
reg = by_type.for_device_by_model(
+305
View File
@@ -0,0 +1,305 @@
"""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