Files
MeshTerm/meshterm/tools/repeater_admin.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.5 KiB
Python

"""The ``repeater-admin`` tool: set up remote repeaters and room servers over the mesh.
Interactively it opens the repeater-admin flow (see :mod:`meshterm.ui.repeater_admin`):
pick a node (credentialed ones lead the list), log in with a remembered or prompted
password, and land in a device-configuration-style editor speaking the node's text CLI —
including the repeater-only knobs the local editor never had (TX delay, Direct TX delay,
airtime factor, advert intervals) — plus one-shot actions (advert, clock sync, password,
reboot) and the readline-style remote command line.
On the CLI it stays a scriptable one-shot: ``repeater-admin <node> <command…>`` logs in
(remembered password, or ``--password``) and prints the node's reply — one transmission
per invocation, the trace tool's rule.
"""
from __future__ import annotations
from typing import Any, Optional
import typer
from ..context import AppContext
from ..core.connection import DeviceCommandError
from .base import Tool, ToolResult, register
@register
class RepeaterAdminTool(Tool):
"""Configure a remote repeater/room server: settings, actions, and its raw CLI."""
name = "repeater-admin"
title = "Repeater admin"
icon = "🗼"
help = "Set up a remote repeater — settings, actions, command line"
category = "Device management"
order = 12 # after Device info: the remote sibling of the local config tools
async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]:
"""Run the interactive flow; there are never parameters to collect.
The flow presents everything itself (the same pattern as ``device-actions``),
so returning ``None`` tells the menu the invocation is complete.
Args:
ctx: Shared application context.
Returns:
Always ``None``.
"""
from ..ui.repeater_admin import open_repeater_admin
await open_repeater_admin(ctx)
return None
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Send one CLI command to a remote node and print its reply (scripted path).
Args:
ctx: Shared application context.
params: ``node`` (contact name), ``command`` (the CLI text), and an
optional ``password`` overriding the remembered one.
Returns:
A :class:`ToolResult` carrying the node's reply (or its absence).
"""
device = await ctx.device()
contacts = await device.get_contacts()
name = str(params["node"])
node = next((c for c in contacts if c.name == name), None)
if node is None:
raise DeviceCommandError(f"unknown contact: {name!r}")
password = params.get("password") or ctx.admin_store.get(node)
if not password:
raise DeviceCommandError(
f"no admin password for {name!r}; pass --password or run the "
"interactive flow once to store it."
)
if not await device.admin_login(node, str(password)):
ctx.admin_store.forget(node)
raise DeviceCommandError(
f"admin login to {name!r} failed (wrong password?). "
"The saved password was cleared."
)
ctx.admin_store.remember(node, str(password))
command = str(params["command"])
ctx.remote_store.append_history(node, command)
reply = await device.send_remote_command(node, command, timeout=10.0)
if reply is None:
ctx.ui.note("[warn]no reply — the command may still have landed[/warn]")
else:
ctx.ui.note(reply)
return ToolResult(
summary={"node": name, "command": command, "replied": reply is not None}
)
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``repeater-admin`` subcommand.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
@app.command(name=self.name, help=self.help)
def _repeater_admin(
node: str = typer.Argument(..., help="The remote contact's name"),
command: list[str] = typer.Argument(..., help="The CLI command to send"),
password: Optional[str] = typer.Option(
None, "--password", help="Admin password (else remembered)"
),
) -> None:
tool_params: dict[str, Any] = {"node": node, "command": " ".join(command)}
if password is not None:
tool_params["password"] = password
run_tool_command(self, tool_params)