Files
MeshTerm/meshterm/ui/repeater_admin.py
T
Jean-Pierre MartineauandClaude Opus 5 63cd4e5f96 Performance: SQLite WAL, lighter startup, per-platform tick rates
PicoCalc plan phase P2, driven by measurements taken on the Lyra as a
paired A/B (git archive HEAD vs the working tree, run side by side
against the same venv).

The headline is the database. Every packet the monitor hears is one
durable commit, and on the SD card each was costing 33.78 ms of fsync.
db._tune() opens in WAL with temp_store=MEMORY: 0.59 ms per commit, a
54x cut. synchronous=NORMAL is requested only once WAL is confirmed
engaged - the pragma returns the mode it settled on rather than raising
when a filesystem refuses it - because under WAL it risks the last
transactions to a power cut, and under the rollback journal it risks the
file itself. This device runs on a battery pack.

Startup: bleak/dbus-fast were already lazy; the real finds were two
cheap *values* dragging heavy subtrees. core/connection.py imports one
constant from core/channels.py, which imported PyCryptodome at module
level (224 ms of CPU-feature probing per run) - now deferred into the
two functions that decrypt. And tools/map.py's register_cli, which runs
for every tool at boot, reached into ui/map_screen.py for a --fraction
default, dragging the basemap's urllib.request -> http.client -> ssl
behind it; DEFAULT_VIEW_FRACTION moves to core/geo.py (pure arithmetic,
already on the boot path) and is re-exported so existing importers are
untouched. 26 modules and 316 ms of import self-time gone.

Per-frame: the corner glow only ever recolours truecolor foregrounds, so
on a 16-slot console it scanned every line of every frame to hand back
the identical list - skipped outright where effects are off, along with
the battery blink and the braille spinner cycle. _has_wide_glyph answers
from the platform when no emoji can be drawn instead of scanning the
composed frame (6.5-9.1 ms per repaint on its own). The header built its
segments twice to measure the compact separator, on exactly the narrow
terminals that take that branch every frame; it now builds once and
measures arithmetically. Together: 101.2 -> 73.2 ms per frame at 53x26,
151.8 -> 110.4 at 53x40, and 72x24 unregressed at 91.7.

That finally gave numbers to set the clocks against. refresh_interval
becomes per-platform tick_s (2.0 s on picocalc: a 1 Hz idle tick alone
was 7-11% of a core doing nothing, and only the header's per-minute
pulse drifts, since screens push their own repaints via invalidate).
The 0.12 s spinner was untenable - 84% of a core at 53x26, and at 53x40
it did not fit in its own interval at all. Five duplicate
_SPINNER_INTERVAL = 0.12 constants across the UI collapse into one
platform-aware spinner_interval() beside the Spinner it paces.

The header refactor was verified equivalent over 540 renders, plain text
and style spans both. The span check earned its keep: the first cut
passed style= to the Text constructor, which makes it the object's base
style and blankets everything appended after, so the version string
would have rendered brand-under-muted. test_header.py only asserted on
.plain and never saw it; it now pins the styling too.

tests/test_performance.py pins each contract as behaviour rather than
timing, including a subprocess guard that fails if Crypto, the network
stack, or the map screen ever creep back onto the startup import graph.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 03:40:42 -04:00

628 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""The repeater-admin screens: configure a remote node over the mesh, editor-style.
The interactive face of the ``repeater-admin`` tool. Picking a node (the shared
credentialed-first picker) and logging in (remembered passwords, the one-time prompt
otherwise) opens an editor deliberately shaped like the local device-configuration
screen — the same setting / value / description lanes, staged ``current → new`` values,
Apply — but speaking the repeater's text CLI (see :mod:`meshterm.core.remote_config`)
instead of the companion's binary protocol, which brings the repeater-only knobs the
local editor never had: TX delay, Direct TX delay, airtime factor, advert intervals.
Because every read is one paced mesh round trip, the editor never bulk-reads on open:
values show what the last read (or the last applied set) said, stamped with age, from
the per-node cache (:class:`~meshterm.core.remote_store.RemoteStore`); the *Read
settings* action refreshes them all under an abortable progress dialog, one paced
``get`` at a time. Apply sends the staged ``set`` commands the same way and folds each
confirmed value straight back into the cache.
Beyond the settings, the action rows cover the box itself — advert, clock sync, change
admin password, reboot, each behind its own floating confirmation — and the **Command
line** opens the readline-style remote CLI (:mod:`meshterm.ui.remote_cli`) for anything
the catalog doesn't spell.
"""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any, Optional
from rich.cells import cell_len
from rich.text import Text
from ..core.models import Contact
from ..core.remote_config import (
RemoteSetting,
parse_reply_value,
reply_is_error,
settings_by_category,
)
from .menus import (
confirm_discard,
exit_rows,
lane_header,
lane_row,
menu_rows,
section_heading,
)
from .trace_screen import TracingDialog
from .tui import Choice, Separator
from .tui.spinner import Spinner, spinner_interval
from .widgets import _age_seconds, _format_age
if TYPE_CHECKING:
from ..context import AppContext
from ..core.connection import Device
#: Seconds to wait for one remote reply. Repeaters answer over the mesh — multi-hop
#: routes take seconds — so this is deliberately more patient than a local command.
_REPLY_TIMEOUT_S = 10.0
# Menu action sentinels (distinct from setting keys, which are CLI parameter names).
_CLI = "__cli__"
_READ = "__read__"
_ADVERT = "__advert__"
_CLOCK = "__clock__"
_PASSWORD = "__password__"
_REBOOT = "__reboot__"
_APPLY = "__apply__"
_CANCEL = "__cancel__"
async def open_repeater_admin(ctx: "AppContext") -> Optional[dict[str, Any]]:
"""Run the repeater-admin flow: pick a node, log in, and administer it.
Args:
ctx: The shared application context (must be running the interactive TUI surface).
Returns:
A summary of what happened (for the tool's log), or ``None`` on cancel.
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .admin_picker import admin_picker_rows, pick_admin_node
from .surface import TuiUi
from .tui import SelectScreen
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("repeater admin is only available in the menu")
session = ctx.ui.session
device = await ctx.device()
# Through the session cache — opening this screen shouldn't re-read the (slow) contacts
# table when another screen already has (see
# :class:`~meshterm.services.device_state.DeviceState`); the device handle below is still
# needed for the admin login and the CLI session that follow.
contacts = await ctx.devstate.contacts()
pick_title = "Repeater admin — node to manage"
pick_prompt = "The remote node to set up (you need its admin password):"
node = await pick_admin_node(ctx, contacts, title=pick_title, prompt=pick_prompt)
if node is None:
return None
# The picker was popped when it returned. Redraw it as a static backdrop and keep it
# pushed across the login, so the password prompt (and any rejection) floats over the
# node list the pick came from — the picked node still highlighted — rather than over a
# blank frame. The backdrop takes no input; the dialog above it owns the keyboard.
items, _candidates = admin_picker_rows(ctx, contacts)
backdrop = SelectScreen(pick_title, items, prompt=pick_prompt, default=node.name, wrap=False)
session.push(backdrop)
try:
if not await _login(ctx, device, node):
return None
finally:
session.pop(backdrop)
return await _admin_session(ctx, device, node)
async def _login(ctx: "AppContext", device: "Device", node: Contact) -> bool:
"""Log in to ``node``: remembered password silently, else one floating prompt.
A working password is remembered; a rejected one is forgotten so the next attempt
asks fresh (the app-wide remote-admin convention).
"""
session = ctx.ui.session
password = ctx.admin_store.get(node)
prompted = password is None
if password is None:
password = await session.text(
f"Admin password for {node.name}",
prompt="The node ignores admin commands without a login.",
password=True,
# Floats over the picker backdrop the caller keeps pushed; the flag is the
# belt-and-suspenders so it never fills the frame even if that backdrop is absent.
floating=True,
)
if not password:
return False
async with ctx.ui.busy_overlay():
accepted = await device.admin_login(node, password)
if not accepted:
ctx.admin_store.forget(node)
await session.message_dialog(
Text(
f"{node.name!r} rejected the admin login (wrong password?). "
+ ("" if prompted else "The saved password was cleared; ")
+ "try again to enter a new one.",
style="err",
),
title="Admin login",
)
return False
ctx.admin_store.remember(node, password)
return True
async def _admin_session(
ctx: "AppContext", device: "Device", node: Contact
) -> dict[str, Any]:
"""Run the editor loop for one logged-in node (the persistent-backdrop pattern)."""
from .tui import CANCEL, SelectScreen
session = ctx.ui.session
loop = asyncio.get_running_loop()
pending: dict[str, str] = {} # setting key -> staged new value
applied = 0
cursor: Any = None # the row to re-highlight, so the menu reopens where you left it
while True:
cache = ctx.remote_store.settings(node)
title, items = _menu_items(node, cache, pending)
menu = SelectScreen(
title, items, default=cursor, wrap=False,
footer_hint="↑↓ move · type to filter · Enter select · Esc back",
)
menu.future = loop.create_future()
session.push(menu)
try:
choice = await menu.future
if choice is CANCEL:
choice = _CANCEL
if choice not in (None, _CANCEL):
cursor = choice
if choice in (None, _CANCEL):
if pending and not await confirm_discard(ctx, len(pending), verb="sending"):
continue
return {"node": node.name, "applied": applied}
if choice == _APPLY:
applied += await _apply(ctx, device, node, pending)
elif choice == _READ:
await _read_all(ctx, device, node)
elif choice == _CLI:
await _command_line(ctx, device, node)
elif choice == _ADVERT:
await _simple_action(
ctx, device, node, "advert",
title="Send advert",
prompt=f"Ask {node.name} to announce itself (flood) now?",
commit="Send",
)
elif choice == _CLOCK:
await _simple_action(
ctx, device, node, "clock sync",
title="Sync clock",
prompt=f"Set {node.name}'s clock from this companion's time?",
commit="Sync",
)
elif choice == _PASSWORD:
await _change_password(ctx, device, node)
elif choice == _REBOOT:
await _simple_action(
ctx, device, node, "reboot",
title="Reboot node",
prompt=f"Reboot {node.name} now? It drops off the mesh while booting.",
commit="Reboot",
danger=True,
)
else: # a setting key
await _stage_setting(ctx, str(choice), cache, pending)
finally:
session.pop(menu)
# --- the menu ------------------------------------------------------------------------
def _menu_items(
node: Contact, cache: dict, pending: dict[str, str]
) -> tuple[str, list]:
"""Build the editor menu's title and rows for the cache + staged state.
The same lane layout as the local device-configuration editor — setting, value
(with any staged ``→ new``), description under one header line — so administering
a remote node reads exactly like configuring the local one.
"""
sections: list[tuple[str, list[tuple[str, Text, str, Any]]]] = []
for category, specs in settings_by_category():
rows: list[tuple[str, Text, str, Any]] = []
for spec in specs:
rows.append((spec.label, _value_text(spec, cache, pending), spec.help, spec.key))
sections.append((category, rows))
label_w = max(cell_len(label) for _, rows in sections for label, _, _, _ in rows)
value_w = max(cell_len(value.plain) for _, rows in sections for _, value, _, _ in rows)
# The same pinned, self-fitting header the local editor heads its lanes with: it stays
# on screen under the category headings for the whole list, and abbreviates instead of
# wrapping when a long cached value leaves the last label no room (menus.lane_header).
items: list = [
Separator(lambda w: lane_header(label_w, value_w, w), pinned=True)
]
for category, rows in sections:
items.append(section_heading(category))
for label, value, help_text, key in rows:
items.append(
Choice(title=lane_row(label, value, help_text, label_w, value_w), value=key)
)
items.append(section_heading("Actions"))
items.extend(
menu_rows(
[
("↻ Read settings", "Fetch every value from the node, one paced get", _READ),
("⌨ Command line…", "Talk to the node's CLI directly", _CLI),
("📡 Send advert…", "Have the node announce itself now", _ADVERT),
("🕒 Sync clock…", "Set the node's clock over the mesh", _CLOCK),
("🔐 Admin password…", "Change the node's admin password", _PASSWORD),
("🔄 Reboot node…", "Restart it remotely", _REBOOT),
]
)
)
staged = len(pending)
items.extend(exit_rows(staged, apply_value=_APPLY, back_value=_CANCEL))
title = f"Repeater admin — {node.name}" + (f" · {staged} staged" if staged else "")
return title, items
def _value_text(spec: RemoteSetting, cache: dict, pending: dict[str, str]) -> Text:
"""One setting's VALUE lane: cached value (with its age), and any staged arrow."""
cached = cache.get(spec.key)
if not spec.readable:
value = Text("write-only", style="faint")
elif cached is None:
value = Text("?", style="muted")
else:
value = Text(cached.value)
age = _format_age(_age_seconds(cached.read_at))
if age != "now":
value.append(f" ·{age}", style="faint")
if spec.key in pending:
value.append(f" → {pending[spec.key]}", style="warn")
return value
# --- staging and applying ---------------------------------------------------------
async def _stage_setting(
ctx: "AppContext", key: str, cache: dict, pending: dict[str, str]
) -> None:
"""Prompt for one setting's new value and stage it (nothing is sent yet)."""
from ..core.remote_config import get_setting, validate_value
spec = get_setting(key)
if spec is None or not spec.writable: # pragma: no cover - menu offers only real keys
return
cached = cache.get(key)
current = pending.get(key, cached.value if cached is not None else "")
if spec.kind == "bool":
picked = await ctx.ui.dialog(
spec.help,
[("Off", "off"), ("On", "on")],
title=spec.label,
default=1 if current == "on" else 0,
keys={"0": "off", "1": "on", "n": "off", "y": "on"},
)
if picked is None:
return
value = str(picked)
else:
hint = ""
if spec.minimum is not None and spec.maximum is not None:
hint = f"Allowed: {spec.minimum:g} – {spec.maximum:g}"
elif spec.minimum is not None:
hint = f"Allowed: ≥ {spec.minimum:g}"
if spec.unit:
hint = f"{hint} ({spec.unit})" if hint else f"In {spec.unit}."
raw = await ctx.ui.text(
spec.label,
prompt=spec.help,
default=str(current),
validate=lambda t: validate_value(spec, t),
help_text=hint,
)
if raw is None:
return
value = raw.strip()
if cached is not None and value == cached.value:
pending.pop(key, None) # back to what the node last said — nothing to send
else:
pending[key] = value
async def _apply(
ctx: "AppContext", device: "Device", node: Contact, pending: dict[str, str]
) -> int:
"""Send every staged ``set`` command, paced, under an abortable progress dialog.
Each confirmed value folds straight into the per-node cache; a rejected or
unanswered set stays staged so it can be retried (or unstaged) rather than being
silently dropped. Records one ``runs`` row for the batch.
Returns:
How many settings the node accepted.
"""
from ..core.remote_config import get_setting
session = ctx.ui.session
run_id = ctx.repo.start_run(
"repeater-admin",
{"node": node.name, "mode": "apply", "settings": sorted(pending)},
ctx.profile_name,
)
outcomes: list[Text] = []
applied = 0
async def work(dialog: TracingDialog) -> None:
nonlocal applied
total = len(pending)
for i, (key, value) in enumerate(sorted(pending.items()), start=1):
spec = get_setting(key)
assert spec is not None
dialog.status = f"set {key} · {i}/{total}"
session.invalidate()
reply = await device.send_remote_command(
node, spec.set_command(value), timeout=_REPLY_TIMEOUT_S
)
if reply is not None and not reply_is_error(reply):
ctx.remote_store.remember_setting(node, key, value)
pending.pop(key, None)
applied += 1
outcomes.append(Text.assemble(("✓ ", "ok"), f"{spec.label} = {value}"))
elif reply is None:
outcomes.append(Text.assemble(
("? ", "warn"), f"{spec.label} — no reply (still staged)"
))
else:
outcomes.append(Text.assemble(
("✗ ", "err"), f"{spec.label} — {reply.strip()} (still staged)"
))
if i < total:
await asyncio.sleep(ctx.settings.trace_cooldown_s)
aborted = await _run_under_dialog(ctx, f"Applying — {node.name}", work)
ctx.repo.finish_run(
run_id, "error" if aborted else "ok",
{"applied": applied, "staged_left": len(pending)},
)
summary = Text.assemble(
(f"{applied}", "brand"), f" of {applied + len(pending)} settings applied"
)
if aborted:
summary.append(" (aborted — the rest stay staged)", style="warn")
await ctx.ui.session.message_dialog(
Text("\n").join([summary, Text(), *outcomes]) if outcomes else summary,
title=f"Apply — {node.name}",
)
return applied
async def _read_all(ctx: "AppContext", device: "Device", node: Contact) -> None:
"""Refresh every readable setting from the node, one paced ``get`` at a time.
Values that parse land in the cache (and on screen); firmware that doesn't know a
key just leaves that row unknown. Abort keeps everything already read.
"""
session = ctx.ui.session
specs = [s for cat, ss in settings_by_category() for s in ss if s.readable]
run_id = ctx.repo.start_run(
"repeater-admin", {"node": node.name, "mode": "read"}, ctx.profile_name
)
read = 0
async def work(dialog: TracingDialog) -> None:
nonlocal read
for i, spec in enumerate(specs, start=1):
dialog.status = f"get {spec.key} · {i}/{len(specs)}"
session.invalidate()
reply = await device.send_remote_command(
node, spec.get_command, timeout=_REPLY_TIMEOUT_S
)
value = parse_reply_value(spec, reply)
if value is not None:
ctx.remote_store.remember_setting(node, spec.key, value)
read += 1
if i < len(specs):
await asyncio.sleep(ctx.settings.trace_cooldown_s)
aborted = await _run_under_dialog(ctx, f"Reading — {node.name}", work)
ctx.repo.finish_run(
run_id, "error" if aborted else "ok", {"read": read, "asked": len(specs)}
)
async def _run_under_dialog(ctx: "AppContext", title: str, work) -> bool:
"""Run an async remote batch under a floating spinner dialog with Abort.
Args:
ctx: The shared application context.
title: The dialog's heading.
work: ``async work(dialog)`` performing the batch, updating ``dialog.status``.
Returns:
``True`` if the user aborted, ``False`` if the batch ran to completion.
"""
session = ctx.ui.session
spinner = Spinner()
dialog = TracingDialog(title, spinner=spinner, on_abort=lambda: None)
dialog.show_last = False
task = asyncio.ensure_future(work(dialog))
dialog.on_abort = task.cancel
async def animate() -> None:
while True:
await asyncio.sleep(spinner_interval())
spinner.tick()
session.invalidate()
ticker = asyncio.ensure_future(animate())
session.push(dialog)
aborted = False
try:
await task
except asyncio.CancelledError:
aborted = True
except Exception as exc: # noqa: BLE001 - surface in a dialog, keep the screen
await session.message_dialog(Text(str(exc), style="err"), title=title)
finally:
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - a spinner hiccup must never break the batch
pass
session.pop(dialog)
return aborted
# --- one-shot actions ------------------------------------------------------------
async def _simple_action(
ctx: "AppContext",
device: "Device",
node: Contact,
command: str,
*,
title: str,
prompt: str,
commit: str,
danger: bool = False,
) -> None:
"""Confirm and send one fixed CLI command, showing the node's reply."""
choice = await ctx.ui.dialog(
prompt,
[("Cancel", None), (commit, "go")],
title=title,
default=1,
danger=danger,
)
if choice != "go":
return
async with ctx.ui.busy_overlay():
reply = await device.send_remote_command(node, command, timeout=_REPLY_TIMEOUT_S)
if reply is None:
body = Text("no reply — the command may still have landed", style="warn")
else:
body = Text(reply.strip(), style="err" if reply_is_error(reply) else "ok")
await ctx.ui.session.message_dialog(body, title=title)
async def _change_password(ctx: "AppContext", device: "Device", node: Contact) -> None:
"""Change the node's admin password (and re-remember it on success)."""
new = await ctx.ui.text(
f"New admin password for {node.name}",
prompt="Sent over the mesh; the node applies it immediately.",
password=True,
)
if not new:
return
confirmed = await ctx.ui.dialog(
f"Change {node.name}'s admin password now? You will need the new one everywhere.",
[("Cancel", None), ("Change", "go")],
title="Admin password",
default=1,
danger=True,
)
if confirmed != "go":
return
async with ctx.ui.busy_overlay():
reply = await device.send_remote_command(
node, f"password {new}", timeout=_REPLY_TIMEOUT_S
)
if reply is not None and not reply_is_error(reply):
ctx.admin_store.remember(node, new) # the working password just changed
body = Text("✓ password changed and remembered", style="ok")
elif reply is None:
body = Text(
"no reply — the change may or may not have landed; the old password "
"stays remembered until a login proves otherwise",
style="warn",
)
else:
body = Text(reply.strip(), style="err")
await ctx.ui.session.message_dialog(body, title="Admin password")
# --- the command line ---------------------------------------------------------------
async def _command_line(ctx: "AppContext", device: "Device", node: Contact) -> None:
"""Open the readline-style remote CLI for ``node`` (history persists per node)."""
from .remote_cli import RemoteCliScreen
session = ctx.ui.session
worker: Optional[asyncio.Task] = None
ticker: Optional[asyncio.Task] = None
def send(command: str) -> None:
nonlocal worker
if worker is not None and not worker.done():
return # one command in flight at a time — every send is a transmission
screen.sent(command)
ctx.remote_store.append_history(node, command)
worker = asyncio.ensure_future(roundtrip(command))
async def roundtrip(command: str) -> None:
try:
reply = await device.send_remote_command(
node, command, timeout=_REPLY_TIMEOUT_S
)
except asyncio.CancelledError:
screen.failed("cancelled", error=False)
raise
except Exception as exc: # noqa: BLE001 - shown inline, the screen stays up
screen.failed(str(exc), error=True)
return
if reply is None:
screen.failed(f"no reply within {_REPLY_TIMEOUT_S:.0f} s")
else:
screen.reply(reply)
screen = RemoteCliScreen(
node_label=node.name,
history=ctx.remote_store.history(node),
send=send,
session=session,
)
async def animate() -> None:
while True:
await asyncio.sleep(spinner_interval())
if screen.busy:
screen.tick()
session.invalidate()
ticker = asyncio.ensure_future(animate())
try:
await session.run_screen(screen)
finally:
for task in (worker, ticker):
if task is not None and not task.done():
task.cancel()
for task in (worker, ticker):
if task is not None:
try:
await task
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - the screen is closed; nothing to surface
pass