Files
MeshTerm/meshterm/core/remote_store.py
T
Jean-Pierre MartineauandClaude Fable 5 aef80d18bb Repeater admin: set up remote nodes over the mesh
A new Device management entry for administering repeaters and room
servers. The shared credentialed-first picker (extracted from TX
optimize) chooses the node, login runs on remembered passwords with a
one-time prompt otherwise (forgotten on rejection), and the editor
lands in the local device-configuration screen's exact lanes — but
speaking the node's text CLI through a new public
Device.send_remote_command, which brings the repeater-only knobs the
local editor never had: TX delay, Direct TX delay, RX delay, airtime
factor, advert intervals, flood max, repeat, guest password.

Reads cost one paced mesh round trip each, so the editor never
bulk-reads on open: values show what the last read said, age-stamped,
from a new per-node remote.json cache; Read settings refreshes them
under an abortable progress dialog and Apply sends the staged sets
the same way, folding confirmed values back into the cache and
keeping refused ones staged. Advert, clock sync, password change, and
reboot run as confirmed popups.

The Command line is a readline-style screen onto the node's raw CLI:
per-node history persisted across sessions (↑↓ recall, adjacent-dupe
suppression, capped), Tab completion over the known verbs plus every
catalog get/set spelling with ghost-text preview, one command in
flight at a time. The simulator grows a full repeater CLI so all of
it drives against --mock, and a scripted one-shot
(repeater-admin <node> <command…>) covers automation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 04:14:16 -04:00

117 lines
4.6 KiB
Python

"""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.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Optional
from .admin_store import admin_key
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 display text.
read_at: When it was read from (or written to) the node.
"""
value: str
read_at: Optional[datetime]
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_all(self) -> dict[str, dict]:
"""Return the raw key -> record mapping, or empty on missing/corrupt file."""
try:
data = json.loads(self._path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
# -- the settings cache -------------------------------------------------------
def settings(self, node: Contact) -> dict[str, CachedValue]:
"""Every remembered setting for ``node``, keyed by CLI parameter name."""
record = self._load_all().get(admin_key(node))
raw = record.get("settings") if isinstance(record, dict) else None
out: dict[str, CachedValue] = {}
if not isinstance(raw, dict):
return out
for key, entry in raw.items():
if not isinstance(entry, dict) or "value" not in entry:
continue
read_at: Optional[datetime] = None
stamp = entry.get("read_at")
if isinstance(stamp, str):
try:
read_at = datetime.fromisoformat(stamp)
except ValueError:
read_at = None
out[key] = CachedValue(value=str(entry["value"]), read_at=read_at)
return out
def remember_setting(self, node: Contact, key: str, value: str) -> None:
"""Cache one setting's value for ``node``, stamped now."""
records = self._load_all()
record = records.setdefault(admin_key(node), {})
settings = record.setdefault("settings", {})
settings[key] = {"value": value, "read_at": utcnow().isoformat()}
self._write(records)
# -- the command-line history ---------------------------------------------------
def history(self, node: Contact) -> list[str]:
"""The node's remote CLI history, oldest first."""
record = self._load_all().get(admin_key(node))
raw = record.get("history") if isinstance(record, dict) else None
return [str(c) for c in raw] if isinstance(raw, list) else []
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_all()
record = records.setdefault(admin_key(node), {})
history = record.setdefault("history", [])
if history and history[-1] == command:
return # re-running the last command shouldn't stutter the recall
history.append(command)
del history[:-HISTORY_CAP]
self._write(records)
def _write(self, records: dict[str, dict]) -> None:
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
tmp.replace(self._path)