Files
MeshTerm/meshterm/core/remote_store.py
T
Jean-Pierre MartineauandClaude Opus 5.5 6b13755c2b Every store writes only the fields its records know
The admin and remote stores round-tripped the raw JSON they read, so any
field a past version wrote survived forever. Both now read into typed
records (AdminCredential; _RemoteNode holding CachedValue settings) and
write out of them, keeping the file's existing spelling. The settings store
keeps only keys device_config still registers: it offers remembered values
back to the radio, so an unregistered key must not reach a restore.

Every persisted record now declares a RETIRED set (all empty: the history
holds no removed fields), device_config gains its own RETIRED registry, and
test_retired pins each store's records so the sweep cannot go vacuous.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 22:07:48 -04:00

230 lines
9.6 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Persistence for remote-node admin state: last-read settings and CLI history.
Reading a repeater's configuration costs one paced mesh round trip per value, so the
repeater-admin screen never bulk-reads on open — it shows what the *last* read (or the
last applied ``set``) said, stamped with when, and refreshes on demand. That cache lives
here, per node (keyed like the admin passwords, by public key), in a small JSON file in
the config directory (``<config_dir>/remote.json``) alongside each node's remote
command-line history — global machine state, like the other JSON stores, deliberately
outside the per-invocation SQLite database.
The file is read into typed records (:class:`_RemoteNode`, each holding its
:class:`CachedValue` settings) and written back out of them, never round-tripped as the
raw JSON it was read as, so a field no record knows is gone after the next write.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field, replace
from datetime import datetime
from pathlib import Path
from typing import ClassVar
from .admin_store import admin_key
from .atomicwrite import write_atomically
from .models import Contact, utcnow
#: How many command-line entries are kept per node (newest last).
HISTORY_CAP = 100
@dataclass(frozen=True, slots=True)
class CachedValue:
"""One remembered remote setting: what the node last said, and when.
Attributes:
value: The value as stored text (empty when unsupported).
read_at: When it was read from (or written to) the node.
supported: ``False`` when the node answered the read with an error — its firmware
has no such setting (a board without a front-end module, a build without a
bridge). Kept apart from *never read*, which is no entry at all.
discovered: ``True`` for a setting the catalog hasn't got, learned from a command
the reader ran on *this node's* command line. Such a row reads ``n/a`` like any
other when the node stops answering it, and is removed only by hand: its key is
recorded nowhere else, so a misread reply must not be able to delete it.
"""
value: str
read_at: datetime | None
supported: bool = True
discovered: bool = False
#: Fields this record once held and must never hold again under another meaning (see
#: :data:`meshterm.core.preferences.RETIRED` for why a name is never reused).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@classmethod
def read(cls, raw: object) -> CachedValue | None:
"""Build a value from one file entry, or ``None`` when it is malformed.
A supported entry must carry its value; an unsupported one carries none.
"""
if not isinstance(raw, dict):
return None
supported = not raw.get("unsupported", False)
if supported and "value" not in raw:
return None
return cls(
value=str(raw.get("value", "")),
read_at=_as_time(raw.get("read_at")),
supported=supported,
discovered=bool(raw.get("discovered", False)),
)
def to_json(self) -> dict:
"""The entry as written, in the file's long-standing spelling.
A supported value carries ``value``; an unsupported one says ``unsupported`` and
carries no value; ``discovered`` appears only when true.
"""
entry: dict = {"value": self.value} if self.supported else {"unsupported": True}
entry["read_at"] = self.read_at.isoformat() if self.read_at is not None else None
if self.discovered:
entry["discovered"] = True
return entry
@dataclass(slots=True)
class _RemoteNode:
"""Everything remembered about one remote node.
Attributes:
settings: Its cached settings, keyed by CLI parameter name.
history: Its remote command-line history, oldest first.
"""
settings: dict[str, CachedValue] = field(default_factory=dict)
history: list[str] = field(default_factory=list)
#: Fields this record once held; none yet (see :attr:`CachedValue.RETIRED`).
RETIRED: ClassVar[frozenset[str]] = frozenset()
@classmethod
def read(cls, raw: object) -> _RemoteNode:
"""Build a node's record from its file entry, taking only the fields it knows."""
record = raw if isinstance(raw, dict) else {}
settings: dict[str, CachedValue] = {}
raw_settings = record.get("settings")
if isinstance(raw_settings, dict):
for key, entry in raw_settings.items():
value = CachedValue.read(entry)
if value is not None:
settings[str(key)] = value
raw_history = record.get("history")
history = [str(c) for c in raw_history] if isinstance(raw_history, list) else []
return cls(settings=settings, history=history)
@property
def empty(self) -> bool:
"""Whether there is nothing left to remember about this node."""
return not self.settings and not self.history
def to_json(self) -> dict:
"""The record as written: its own fields and nothing it happened to be read with."""
return {
"settings": {key: value.to_json() for key, value in self.settings.items()},
"history": list(self.history),
}
class RemoteStore:
"""Reads and writes per-node remote-admin state (setting cache + CLI history)."""
def __init__(self, path: Path) -> None:
"""Open the store against a JSON file location.
Args:
path: Path to the JSON state file (created lazily on first write).
"""
self._path = path
def _load(self) -> dict[str, _RemoteNode]:
"""Read every node's record, keyed by :func:`admin_key`; empty on trouble."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
if not isinstance(data, dict):
return {}
return {str(key): _RemoteNode.read(raw) for key, raw in data.items()}
def _node(self, node: Contact) -> _RemoteNode:
"""One node's record, or an empty one when nothing is remembered."""
return self._load().get(admin_key(node)) or _RemoteNode()
# -- the settings cache -------------------------------------------------------
def settings(self, node: Contact) -> dict[str, CachedValue]:
"""Every remembered setting for ``node``, keyed by CLI parameter name."""
return dict(self._node(node).settings)
def remember_setting(self, node: Contact, key: str, value: str) -> None:
"""Cache one setting's value for ``node``, stamped now."""
self._put(node, key, CachedValue(value=value, read_at=utcnow()))
def remember_discovered(self, node: Contact, key: str, value: str) -> None:
"""Cache a setting the catalog hasn't got, which ``node`` has just proved it has."""
self._put(node, key, CachedValue(value=value, read_at=utcnow(), discovered=True))
def remember_unsupported(self, node: Contact, key: str) -> None:
"""Record that ``node`` answered a read of ``key`` with an error, stamped now."""
self._put(node, key, CachedValue(value="", read_at=utcnow(), supported=False))
def forget_setting(self, node: Contact, key: str) -> None:
"""Drop one cached setting, so the row reads as never read."""
records = self._load()
record = records.get(admin_key(node))
if record is not None and record.settings.pop(key, None) is not None:
self._write(records)
def _put(self, node: Contact, key: str, value: CachedValue) -> None:
"""Store one setting's cache entry for ``node``, keeping it discovered if it was.
A discovered row is refreshed by the same reads and writes as any other — through
:meth:`remember_setting` — and that must not quietly demote it to a catalog row it
has no entry for, which would leave a row nothing draws.
"""
records = self._load()
record = records.setdefault(admin_key(node), _RemoteNode())
previous = record.settings.get(key)
if previous is not None and previous.discovered:
value = replace(value, discovered=True)
record.settings[key] = value
self._write(records)
# -- the command-line history ---------------------------------------------------
def history(self, node: Contact) -> list[str]:
"""The node's remote CLI history, oldest first."""
return list(self._node(node).history)
def append_history(self, node: Contact, command: str) -> None:
"""Append one sent command to the node's history (dropping an adjacent dupe)."""
command = command.strip()
if not command:
return
records = self._load()
record = records.setdefault(admin_key(node), _RemoteNode())
if record.history and record.history[-1] == command:
return # re-running the last command shouldn't stutter the recall
record.history.append(command)
del record.history[:-HISTORY_CAP]
self._write(records)
def _write(self, records: dict[str, _RemoteNode]) -> None:
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
data = {key: record.to_json() for key, record in records.items() if not record.empty}
write_atomically(self._path, json.dumps(data, indent=2))
def _as_time(value: object) -> datetime | None:
"""Parse a stored ISO-8601 timestamp, or ``None`` if absent or corrupt."""
if not isinstance(value, str):
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None