Address review findings on the learned-modes store
Flatten the store to {href: [codes]}. One href carries one LEARNABLE
rule, so keying the codes by the rule's supported field too let the
write side (rule.supported_field) and both read sides (the module-level
SUPPORTED_FIELD) disagree the moment a rule used a different field --
codes learned and persisted, then never offered.
The options flow's reset step read the persisted value raw in the
entry-not-loaded branch, so malformed data aborted the one screen that
can clear it; route it through LearnedModes like every other reader.
For the same reason forget_learned_modes() now persists whenever the
entry carries a record, not only when the in-memory store had one: a
record _coerce rejected at startup exists only on the entry.
This commit is contained in:
9 files changed
+78
-60
No files matched your search
@@ -61,6 +61,7 @@ from .const import (
|
||||
PROBE_PORT_RANGE,
|
||||
SERVICE_WRITE_RESOURCE,
|
||||
)
|
||||
from .learned import LearnedModes
|
||||
|
||||
_TEXT = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT))
|
||||
_MULTILINE = TextSelector(TextSelectorConfig(type=TextSelectorType.TEXT, multiline=True))
|
||||
@@ -881,11 +882,12 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
|
||||
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}
|
||||
# Through LearnedModes, not the raw entry value: this step is
|
||||
# the one screen that can clear a malformed persisted record,
|
||||
# so it must not be the one screen that trips over it.
|
||||
else LearnedModes(self.config_entry.data.get(CONF_LEARNED_MODES)).snapshot()
|
||||
)
|
||||
codes = sorted({code for codes in learned.values() for code in codes})
|
||||
|
||||
if user_input is not None:
|
||||
if coord is not None:
|
||||
|
||||
@@ -38,7 +38,7 @@ CONF_DEVICE_TYPE = "device_type"
|
||||
# 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, ...]}}.
|
||||
# {actual_href: [code, ...]}.
|
||||
CONF_LEARNED_MODES = "learned_modes"
|
||||
|
||||
# Options-flow key: whether learned modes are remembered and offered.
|
||||
|
||||
@@ -40,7 +40,7 @@ from .const import (
|
||||
DTLS_LOCAL_PORT_BASE,
|
||||
SUMMARY_INTERVAL_S,
|
||||
)
|
||||
from .learned import SUPPORTED_FIELD, LearnedModes
|
||||
from .learned import LearnedModes
|
||||
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
|
||||
from .registry import CAPABILITIES
|
||||
from .registry.adapter import flatten
|
||||
@@ -322,7 +322,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
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]:
|
||||
def learned_modes(self, actual_href: str) -> 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
|
||||
@@ -331,17 +331,22 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
(the options flow's reset step is for that)."""
|
||||
if not self.learning_enabled:
|
||||
return []
|
||||
return self._learned.codes(actual_href, field)
|
||||
return self._learned.codes(actual_href)
|
||||
|
||||
def learned_snapshot(self) -> dict[str, dict[str, list[str]]]:
|
||||
def learned_snapshot(self) -> 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():
|
||||
"""Drop every learned mode, here and on the entry.
|
||||
|
||||
Persists even when the in-memory store was already empty: a record
|
||||
_coerce rejected at startup exists only on the entry, and this is
|
||||
the one control that can clear it."""
|
||||
self._learned.clear()
|
||||
if self._entry.data.get(CONF_LEARNED_MODES):
|
||||
self._persist_learned()
|
||||
|
||||
def _canonical_href(self, actual: str) -> str:
|
||||
|
||||
@@ -59,28 +59,25 @@ def _codes(value) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _coerce(stored) -> dict[str, dict[str, list[str]]]:
|
||||
def _coerce(stored) -> 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 = {}
|
||||
for href, codes in stored.items():
|
||||
if isinstance(href, str) and (valid := [c for c in _codes(codes) if c]):
|
||||
restored[href] = 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.
|
||||
One href carries one LEARNABLE rule, so the rule's fields are how a rep
|
||||
is read, never part of the key.
|
||||
|
||||
Mutated from whichever thread applied the update (the DTLS reader for
|
||||
an OBSERVE notify, an executor thread for a poll -- see
|
||||
@@ -111,7 +108,7 @@ class LearnedModes:
|
||||
if not supported:
|
||||
return False
|
||||
with self._lock:
|
||||
known = self._learned.get(actual_href, {}).get(rule.supported_field, [])
|
||||
known = self._learned.get(actual_href, [])
|
||||
new = [
|
||||
code
|
||||
for code in _codes(rep.get(rule.current_field))
|
||||
@@ -119,24 +116,17 @@ class LearnedModes:
|
||||
]
|
||||
if not new:
|
||||
return False
|
||||
self._learned.setdefault(actual_href, {})[rule.supported_field] = [*known, *new]
|
||||
self._learned[actual_href] = [*known, *new]
|
||||
return True
|
||||
|
||||
def codes(self, actual_href: str, field: str = SUPPORTED_FIELD) -> list[str]:
|
||||
def codes(self, actual_href: str) -> list[str]:
|
||||
with self._lock:
|
||||
return list(self._learned.get(actual_href, {}).get(field, ()))
|
||||
return list(self._learned.get(actual_href, ()))
|
||||
|
||||
def snapshot(self) -> dict[str, dict[str, list[str]]]:
|
||||
def snapshot(self) -> 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()
|
||||
}
|
||||
return {href: list(codes) for href, codes in self._learned.items()}
|
||||
|
||||
def clear(self) -> bool:
|
||||
"""Forget everything; True when there was something to forget."""
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
if not self._learned:
|
||||
return False
|
||||
self._learned = {}
|
||||
return True
|
||||
Reference in new issue
Block a user