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:
@@ -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.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -448,7 +448,16 @@ class LocalThingsClimate(LocalThingsEntity, ClimateEntity):
|
|||||||
return bool(self._rep(POWER_HREF).get("value"))
|
return bool(self._rep(POWER_HREF).get("value"))
|
||||||
|
|
||||||
def _supported(self, href: str) -> list[str]:
|
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:
|
def _warn_unmapped(self, href: str, code: str) -> None:
|
||||||
"""Log once per (href, code) when a device-reported mode has no
|
"""Log once per (href, code) when a device-reported mode has no
|
||||||
|
|||||||
@@ -45,11 +45,14 @@ from .const import (
|
|||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_LEAF_CERT_PEM,
|
CONF_LEAF_CERT_PEM,
|
||||||
CONF_LEAF_KEY_PEM,
|
CONF_LEAF_KEY_PEM,
|
||||||
|
CONF_LEARN_MODES,
|
||||||
|
CONF_LEARNED_MODES,
|
||||||
CONF_MANUFACTURER,
|
CONF_MANUFACTURER,
|
||||||
CONF_MODEL,
|
CONF_MODEL,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
CONF_SERIAL,
|
CONF_SERIAL,
|
||||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||||
|
DEFAULT_LEARN_MODES,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
LIVENESS_PROBE_TIMEOUT_S,
|
LIVENESS_PROBE_TIMEOUT_S,
|
||||||
PREFERRED_PROBE_PORTS,
|
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:
|
async def async_step_init(self, user_input: dict[str, Any] | None = None) -> ConfigFlowResult:
|
||||||
return self.async_show_menu(
|
return self.async_show_menu(
|
||||||
step_id="init",
|
step_id="init",
|
||||||
menu_options=["settings", "debug_write"],
|
menu_options=["settings", "forget_learned_modes", "debug_write"],
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_step_settings(
|
async def async_step_settings(
|
||||||
@@ -854,10 +857,55 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
|||||||
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
|
||||||
),
|
),
|
||||||
): _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(
|
async def async_step_debug_write(
|
||||||
self, user_input: dict[str, Any] | None = None
|
self, user_input: dict[str, Any] | None = None
|
||||||
) -> ConfigFlowResult:
|
) -> ConfigFlowResult:
|
||||||
|
|||||||
@@ -34,6 +34,20 @@ CONF_MODEL = "model"
|
|||||||
CONF_MANUFACTURER = "manufacturer"
|
CONF_MANUFACTURER = "manufacturer"
|
||||||
CONF_DEVICE_TYPE = "device_type"
|
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
|
# Options-flow key (entry.options, not entry.data): lets a user override
|
||||||
# the device-wide remote-control-off write block for a specific device
|
# the device-wide remote-control-off write block for a specific device
|
||||||
# (issue #54). Some devices accept certain writes even while reporting
|
# (issue #54). Some devices accept certain writes even while reporting
|
||||||
|
|||||||
@@ -28,15 +28,19 @@ from .const import (
|
|||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_LEAF_CERT_PEM,
|
CONF_LEAF_CERT_PEM,
|
||||||
CONF_LEAF_KEY_PEM,
|
CONF_LEAF_KEY_PEM,
|
||||||
|
CONF_LEARN_MODES,
|
||||||
|
CONF_LEARNED_MODES,
|
||||||
CONF_MANUFACTURER,
|
CONF_MANUFACTURER,
|
||||||
CONF_MODEL,
|
CONF_MODEL,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
CONF_SERIAL,
|
CONF_SERIAL,
|
||||||
|
DEFAULT_LEARN_MODES,
|
||||||
DEVICE_SUPPORT_ISSUE_URL,
|
DEVICE_SUPPORT_ISSUE_URL,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
DTLS_LOCAL_PORT_BASE,
|
DTLS_LOCAL_PORT_BASE,
|
||||||
SUMMARY_INTERVAL_S,
|
SUMMARY_INTERVAL_S,
|
||||||
)
|
)
|
||||||
|
from .learned import SUPPORTED_FIELD, LearnedModes
|
||||||
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
|
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
|
||||||
from .registry import CAPABILITIES
|
from .registry import CAPABILITIES
|
||||||
from .registry.adapter import flatten
|
from .registry.adapter import flatten
|
||||||
@@ -239,6 +243,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
|||||||
self._cache = StateCache(_NoOpDescriptor())
|
self._cache = StateCache(_NoOpDescriptor())
|
||||||
self._cache.set_on_change(self._on_cache_changed)
|
self._cache.set_on_change(self._on_cache_changed)
|
||||||
self._observe = ObserveManager(self._cache, logger=self._log)
|
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 = False
|
||||||
self._push_pending_lock = threading.Lock()
|
self._push_pending_lock = threading.Lock()
|
||||||
# Identity is resolved once by the config flow's probe (issue #236).
|
# 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
|
self._canonical_cache[view_key] = view
|
||||||
return 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:
|
def device_info_for(self, subdevice: Subdevice) -> DeviceInfo:
|
||||||
"""DeviceInfo for one logical subdevice on this connection (issue
|
"""DeviceInfo for one logical subdevice on this connection (issue
|
||||||
#177): the master's own device_info for MAIN, or a linked child
|
#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
|
# about the connection rather than one subdevice's state, and
|
||||||
# nothing polls it after discovery so it would go stale in there.
|
# nothing polls it after discovery so it would go stale in there.
|
||||||
"multidevice": redact_resources(coordinator._multidevice),
|
"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,
|
"integration_version": integration.version,
|
||||||
"smartthings_local_version": stl_version,
|
"smartthings_local_version": stl_version,
|
||||||
"observe_mode": coordinator.observe_mode,
|
"observe_mode": coordinator.observe_mode,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
import cbor2
|
import cbor2
|
||||||
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
from smartthings_local.ocf.observe_refresh import ObserveRefreshTask
|
||||||
@@ -77,10 +78,25 @@ class ObserveManager:
|
|||||||
# have notified. Guards only `_notified` mutations + the `wait_for`.
|
# have notified. Guards only `_notified` mutations + the `wait_for`.
|
||||||
self._notify_cond = threading.Condition()
|
self._notify_cond = threading.Condition()
|
||||||
self.fallback_hrefs: set[str] = set()
|
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_task: ObserveRefreshTask | None = None
|
||||||
self._refresh_stop: threading.Event | None = None
|
self._refresh_stop: threading.Event | None = None
|
||||||
self._refresh_thread: threading.Thread | 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:
|
def mark_write_pending(self, href: str, settle_s: float = DEFAULT_SETTLE_S) -> None:
|
||||||
with self._settle_lock:
|
with self._settle_lock:
|
||||||
self._settle_until[href] = time.monotonic() + settle_s
|
self._settle_until[href] = time.monotonic() + settle_s
|
||||||
@@ -138,7 +154,14 @@ class ObserveManager:
|
|||||||
return False
|
return False
|
||||||
with self._cache_lock:
|
with self._cache_lock:
|
||||||
merged = {**(self.cache.get(href) or {}), **rep}
|
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:
|
def on_notification(self, href: str, payload: bytes) -> None:
|
||||||
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
"""Wired as DtlsCoapSession.on_notification. Runs on the DTLS
|
||||||
|
|||||||
@@ -1348,17 +1348,23 @@
|
|||||||
"title": "Možnosti LocalThings",
|
"title": "Možnosti LocalThings",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"settings": "Nastavení zápisu pro dálkové ovládání",
|
"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"
|
"debug_write": "Ladění: zápis do prostředku"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Nastavení zápisu pro dálkové ovládání",
|
"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": {
|
"data": {
|
||||||
"bypass_remote_control_lock": "Povolit zápis i když je dálkové ovládání hlášeno jako vypnuté",
|
"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": {
|
"debug_write": {
|
||||||
"title": "Ladění: zápis do prostředku",
|
"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ě.",
|
"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",
|
"title": "LocalThings options",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"settings": "Device settings",
|
"settings": "Device settings",
|
||||||
|
"forget_learned_modes": "Forget remembered modes",
|
||||||
"debug_write": "Debug: write to a resource"
|
"debug_write": "Debug: write to a resource"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Device 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": {
|
"data": {
|
||||||
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
|
"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": {
|
"debug_write": {
|
||||||
"title": "Debug: write to a resource",
|
"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.",
|
"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",
|
"title": "Opciones de LocalThings",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"settings": "Ajustes del dispositivo",
|
"settings": "Ajustes del dispositivo",
|
||||||
|
"forget_learned_modes": "Olvidar los modos recordados",
|
||||||
"debug_write": "Depuración: escribir en un recurso"
|
"debug_write": "Depuración: escribir en un recurso"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Ajustes del dispositivo",
|
"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": {
|
"data": {
|
||||||
"bypass_remote_control_lock": "Permitir escrituras incluso cuando el control remoto se notifica como desactivado",
|
"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": {
|
"debug_write": {
|
||||||
"title": "Depuración: escribir en un recurso",
|
"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.",
|
"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",
|
"title": "Opzioni LocalThings",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"settings": "Impostazioni dispositivo",
|
"settings": "Impostazioni dispositivo",
|
||||||
|
"forget_learned_modes": "Dimentica le modalità memorizzate",
|
||||||
"debug_write": "Debug: scrivi su una risorsa"
|
"debug_write": "Debug: scrivi su una risorsa"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Impostazioni dispositivo",
|
"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": {
|
"data": {
|
||||||
"bypass_remote_control_lock": "Consenti la scrittura anche quando il controllo remoto risulta disattivato.",
|
"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": {
|
"debug_write": {
|
||||||
"title": "Debug: scrivi su una risorsa",
|
"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.",
|
"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 옵션",
|
"title": "LocalThings 옵션",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"settings": "기기 설정",
|
"settings": "기기 설정",
|
||||||
|
"forget_learned_modes": "기억된 모드 지우기",
|
||||||
"debug_write": "디버그: 리소스에 쓰기"
|
"debug_write": "디버그: 리소스에 쓰기"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "기기 설정",
|
"title": "기기 설정",
|
||||||
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.",
|
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.\n\n일부 모델은 지원 목록에 없는 모드를 현재 모드로 보고합니다. 예를 들어 Off/Sleep/Speed만 제공하면서 Quiet 모드로 동작 중이라고 보고하는 에어컨이 그렇습니다. LocalThings는 이렇게 발견한 모드를 기억해 두고 계속 제공하므로, 기기가 한 번이라도 그 모드였다면 이후에도 선택할 수 있습니다. 이 옵션을 끄면 기기가 지원한다고 알린 모드만 제공합니다. 이미 기억된 항목을 지우려면 이전 화면의 '기억된 모드 지우기'를 사용하세요.",
|
||||||
"data": {
|
"data": {
|
||||||
"bypass_remote_control_lock": "스마트 컨트롤이 꺼진 것으로 보고되어도 쓰기 허용",
|
"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": {
|
"debug_write": {
|
||||||
"title": "디버그: 리소스에 쓰기",
|
"title": "디버그: 리소스에 쓰기",
|
||||||
"description": "기기별 쓰기 동작을 파악하기 위한 고급 사용자용 도구입니다. 쓰려는 리소스(href)를 선택하거나 목록에 없는 값을 직접 입력하세요. 이 기능은 스마트 컨트롤 꺼짐 차단을 우회하고 입력한 필드를 그대로 전송합니다. 가전제품이 잘못 설정될 수 있으므로 신중하게 사용하세요.",
|
"description": "기기별 쓰기 동작을 파악하기 위한 고급 사용자용 도구입니다. 쓰려는 리소스(href)를 선택하거나 목록에 없는 값을 직접 입력하세요. 이 기능은 스마트 컨트롤 꺼짐 차단을 우회하고 입력한 필드를 그대로 전송합니다. 가전제품이 잘못 설정될 수 있으므로 신중하게 사용하세요.",
|
||||||
|
|||||||
@@ -1348,17 +1348,23 @@
|
|||||||
"title": "LocalThings-opties",
|
"title": "LocalThings-opties",
|
||||||
"menu_options": {
|
"menu_options": {
|
||||||
"settings": "Apparaatinstellingen",
|
"settings": "Apparaatinstellingen",
|
||||||
|
"forget_learned_modes": "Onthouden modi vergeten",
|
||||||
"debug_write": "Foutopsporing: naar een resource schrijven"
|
"debug_write": "Foutopsporing: naar een resource schrijven"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Apparaatinstellingen",
|
"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": {
|
"data": {
|
||||||
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
|
"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": {
|
"debug_write": {
|
||||||
"title": "Foutopsporing: naar een resource schrijven",
|
"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.",
|
"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.",
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from custom_components.localthings.const import (
|
|||||||
CONF_CA_KEY_PEM,
|
CONF_CA_KEY_PEM,
|
||||||
CONF_HOST,
|
CONF_HOST,
|
||||||
CONF_LEAF_CERT_PEM,
|
CONF_LEAF_CERT_PEM,
|
||||||
|
CONF_LEARN_MODES,
|
||||||
|
CONF_LEARNED_MODES,
|
||||||
CONF_PORT,
|
CONF_PORT,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
)
|
)
|
||||||
@@ -935,7 +937,11 @@ async def test_options_flow_init_shows_menu(hass: HomeAssistant) -> None:
|
|||||||
|
|
||||||
assert result["type"] == FlowResultType.MENU
|
assert result["type"] == FlowResultType.MENU
|
||||||
assert result["step_id"] == "init"
|
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:
|
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
|
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:
|
async def test_options_flow_reflects_previously_saved_value(hass: HomeAssistant) -> None:
|
||||||
"""Reopening the form shows the currently-saved choice as the default,
|
"""Reopening the form shows the currently-saved choice as the default,
|
||||||
not always False."""
|
not always False."""
|
||||||
|
|||||||
@@ -64,6 +64,11 @@ class _FakeCoordinator:
|
|||||||
async def async_send_command(self, bound, payload):
|
async def async_send_command(self, bound, payload):
|
||||||
self.commands.append((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):
|
def _discover(resources, registry=airconditioner.REGISTRY):
|
||||||
unbound = []
|
unbound = []
|
||||||
|
|||||||
@@ -48,6 +48,11 @@ class _FakeCoordinator:
|
|||||||
async def async_send_command(self, bound, payload):
|
async def async_send_command(self, bound, payload):
|
||||||
self.commands.append((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):
|
def _climate(resources, coordinator=None):
|
||||||
info = resources["/information/vs/0"]
|
info = resources["/information/vs/0"]
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ def test_fac_bora_wind_strength_codes_fit_the_standard_scale():
|
|||||||
# canonical view is just the raw snapshot (issue #177).
|
# canonical view is just the raw snapshot (issue #177).
|
||||||
return self.last_resources
|
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")
|
resources = _load_device("airconditioner_fac_bora")
|
||||||
info = resources["/information/vs/0"]
|
info = resources["/information/vs/0"]
|
||||||
reg = by_type.for_device_by_model(
|
reg = by_type.for_device_by_model(
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user