Files
MeshTerm/meshterm/tools/repeater_admin.py
T
Jean-Pierre MartineauandClaude Opus 5 b76a93808f Three more arguments that were reported as the radio's fault
`4` promises a caller that the node answered and a retry is worth
trying. An unknown admin node, an unknown courier contact and an `--at`
that is not a clock time are all decided before a word goes over the
air, so a caller retrying on `4` would retransmit nothing, forever.
`tx-optimize` had already answered the identical question the identical
way for a missing password; these had not caught up.

A refused admin login stays `4`. The node was reached and answered no,
the password may simply have changed, and that is exactly the case a
retry exists for — the line is whether anything was transmitted, not
whether the command worked.

The manual documented the old behaviour and called the inconsistency
out; it now documents the rule instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 05:03:17 -04:00

168 lines
6.7 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 TYPE_CHECKING, Any
import typer
from ..context import AppContext
from ..core import exitcodes
from ..core.connection import DeviceCommandError
from ..core.models import NODE_TYPE_LABELS, Contact, LoginResult
from .base import Tool, ToolResult, register
if TYPE_CHECKING: # pragma: no cover - typing only
from ..ui.report import Facts
@register
class RepeaterAdminTool(Tool):
"""Configure a remote repeater/room server: settings, actions, and its raw CLI."""
name = "repeater-admin"
title = "Repeater admin"
icon = "🗼"
help = "Run a remote repeater — settings and actions"
category = "Other nodes"
order = 10 # the remote sibling of the local config tools
async def prompt_params(self, ctx: AppContext) -> dict[str, Any] | None:
"""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)
# Both of these are the *argument* being wrong, and nothing has gone out over
# the air when either fires — so they are usage errors (2), not device failures
# (4), whose promise to a caller is that a retry is worth trying. `tx-optimize`
# already answered the identical question this way for the identical condition.
if node is None:
raise typer.BadParameter(f"unknown contact: {name!r}")
password = params.get("password") or ctx.admin_store.get(node)
if not password:
raise typer.BadParameter(
f"no admin password for {name!r}; pass --password or run the "
"interactive flow once to store it."
)
outcome = await device.admin_login(node, str(password))
ctx.admin_store.record(node, str(password), outcome)
if outcome is LoginResult.REFUSED:
raise DeviceCommandError(
f"admin login to {name!r} failed (wrong password?). The saved password was cleared."
)
if not outcome:
raise DeviceCommandError(
f"{name!r} did not answer the admin login — it may be out of reach, "
"asleep, or busy. Any saved password was kept; try again when it answers."
)
command = str(params["command"])
ctx.remote_store.append_history(node, command)
reply = await device.send_remote_command(node, command, timeout=10.0)
return ToolResult(
report=(_answered(node, command, reply),),
summary={"node": name, "command": command, "replied": reply is not None},
# Silence is not failure — the command may well have landed — but there is
# nothing to report, and a script waiting on output should know which it got.
exit_code=exitcodes.OK if reply is not None else exitcodes.NO_RESULT,
)
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: str | None = 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)
def _answered(node: Contact, command: str, reply: str | None) -> Facts:
"""What a remote node said back.
``reply`` is the node's own text, whole and verbatim — the plain face prints it bare
(as text and never as markup, so a reply holding a square bracket is a reply holding a
square bracket), and the document carries it raw, multi-line where the node sent
several lines.
It is deliberately **not parsed**. MeshTerm does not know the remote node's CLI
grammar, and a document that pretended to would be inventing structure a consumer
would then depend on.
"""
from ..ui import fields
from ..ui.fields import NodeRef
from ..ui.report import BARE, Facts
return Facts(
key="remote",
fields=(
fields.node("node", lanes=()),
fields.hidden("command"),
fields.word("reply", "reply"),
),
values={
"node": NodeRef(
name=node.name,
key=(node.public_key or "").lower() or None,
hash=(node.key_prefix or "").lower() or None,
type=NODE_TYPE_LABELS.get(node.node_type),
),
"command": command,
"reply": reply,
},
shape=BARE,
bare="reply",
)