Every copy on a machine reads and writes the same ~/.meshterm — a checkout, a downloaded build, a second one for a second radio — and nothing about how it was installed changes that. Two at once will not corrupt anything, but each holds the JSON stores in memory and rewrites them whole, so the second to save discards the first's changes. A lost contact is much harder to notice than a crash. The interactive session now takes an exclusive lock on the directory it is using, and a second one is refused with the fix in the message: set MESHTERM_HOME. The lock belongs to the operating system rather than to a process id written in a file, because a pid file has to answer "is that still alive?", the answer differs per platform, and it is wrong once a reboot recycles the number — so a crash leaves a lock nobody can explain and everyone learns to delete it. One-shot subcommands do not take it; blocking `meshterm contacts` because a menu is open in another window would be an obstacle rather than a guard. The scratch-file race is fixed at the same time, and it was ten places rather than the three I first counted: every store under core/ carried the same three lines, writing to a fixed `.tmp` name beside its file. Two processes saving together took turns inside that one scratch file and then each renamed whatever was in it over the real one — the rename is atomic, the thing being renamed was not. They now go through one `write_atomically`, whose temporary carries the writing process's id, and which cleans up after a failed write instead of leaving a partial file behind. And the Windows console error is answered rather than raised. prompt_toolkit needs a Win32 console; Git Bash, MSYS and Cygwin are not one, and its complaint arrives as a traceback that reads like a broken application when the answer is "open a different terminal". It now says that, and points out that every screen has a subcommand which pipes perfectly well. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
4.4 KiB
Python
114 lines
4.4 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 .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 display text.
|
|
read_at: When it was read from (or written to) the node.
|
|
"""
|
|
|
|
value: str
|
|
read_at: datetime | None
|
|
|
|
|
|
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: datetime | None = 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)."""
|
|
write_atomically(self._path, json.dumps(records, indent=2))
|