Files
MeshTerm/meshtools/tools/tx_optimize.py
T
Jean-Pierre MartineauandClaude Opus 4.8 2a9facc56e Replace questionary with a full-screen prompt_toolkit TUI
Introduce a UI surface abstraction (ctx.ui) that tools use for all input
and output instead of touching questionary or the console directly. Two
backends implement it: PlainUi for scripted CLI runs (prints immediately,
byte-for-byte unchanged) and TuiUi for the interactive menu (bounded,
scrollable result windows over a full-screen TuiSession).

The new meshtools/ui/tui package reimplements the slice of questionary the
app used — validated text input, confirm, autocomplete, and selectable
lists — plus framing, progress, and screen management. Swap the dependency
from questionary to prompt_toolkit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 14:09:18 -04:00

397 lines
15 KiB
Python

"""The ``tx-optimize`` tool: tune a remote node's TX power for the best signal at a target.
You force a path (just like ``trace``) ending at the **target** node where SNR is
measured. The node one hop *before* the target — one you hold admin rights on — is the
node whose transmit power gets swept and tuned. Admin passwords are remembered between
runs, progress streams live like a trace, and the winning power is written back to the
node when the sweep finishes.
"""
from __future__ import annotations
from typing import Any, Optional
import typer
from ..context import AppContext
from ..core.connection import DeviceCommandError
from ..core.models import Contact
from ..services import trace_runner, tx_optimizer
from ..ui.widgets import tx_opt_summary, tx_opt_table
from ..viz.tx_plot import render_tx_optimization
from .base import Tool, ToolResult, register
#: Trace count per TX level — kept to single digits so the radio's duty cycle stays sane
#: and the per-level sweep finishes in reasonable time.
MAX_SAMPLES = 9
@register
class TxOptimizeTool(Tool):
"""Tune a remote node's TX power for the strongest, most reliable signal at a target."""
name = "tx-optimize"
help = "Tune a remote node's TX power for the best signal at a target along a path."
category = "Optimization"
order = 10
async def prompt_params(self, ctx: AppContext) -> Optional[dict[str, Any]]:
"""Interactively gather the path, sampling, range, and apply choices.
Args:
ctx: Shared application context.
Returns:
A parameter dict, or ``None`` if the user cancelled.
"""
device = await ctx.device()
contacts = await device.get_contacts()
choices = [c.name for c in contacts]
path_prompt = (
"Path to the target (comma-separated contacts/hex, ending at the target;\n"
"the node just before the target is the one we'll tune):"
)
validate_path = lambda v: _validate_link_path(v, contacts) # noqa: E731
if choices:
path_spec = await ctx.ui.autocomplete(path_prompt, choices, validate=validate_path)
else:
path_spec = await ctx.ui.text(path_prompt, validate=validate_path)
if not path_spec:
return None
samples = await ctx.ui.text(
f"Traces per TX level? (1-{MAX_SAMPLES})", default="3", validate=_is_valid_sample_count
)
if samples is None:
return None
tx_min = await ctx.ui.text(
"Lowest TX power to try:", default=str(ctx.settings.tx_opt_min), validate=_is_int
)
if tx_min is None:
return None
tx_max = await ctx.ui.text(
"Highest TX power to try:", default=str(ctx.settings.tx_opt_max), validate=_is_int
)
if tx_max is None:
return None
step = await ctx.ui.text("Coarse step:", default="3", validate=_is_valid_sample_count)
if step is None:
return None
apply = await ctx.ui.confirm(
"Set the winning TX power on the node afterward?", default=True
)
if apply is None:
return None
return {
"path": path_spec.strip(),
"samples": int(samples),
"tx_min": int(tx_min),
"tx_max": int(tx_max),
"step": int(step),
"apply": bool(apply),
"viz": True,
}
async def run(self, ctx: AppContext, params: dict[str, Any]) -> ToolResult:
"""Resolve the link, log in, sweep TX power, render results, and apply the winner.
Args:
ctx: Shared application context.
params: ``path``, ``samples``, ``tx_min``, ``tx_max``, ``step``, ``apply``,
optional ``password``/``viz``, and the injected ``_run_id``.
Returns:
A :class:`ToolResult` with the optimum and any chart path.
"""
run_id = params["_run_id"]
device = await ctx.device()
contacts = await device.get_contacts()
# Resolve the forced path to hashes, then pull out the target (last hop) and the
# admin node we tune (the hop before it).
path = trace_runner.parse_trace_path(params["path"], contacts)
admin_node, target_label = _resolve_link(path, contacts)
samples = max(1, min(MAX_SAMPLES, int(params.get("samples", 3))))
if int(params.get("samples", 3)) > MAX_SAMPLES:
ctx.ui.note(f"[warn]capping at {MAX_SAMPLES} traces per level[/warn]")
password = await self._resolve_password(ctx, admin_node, params)
ctx.ui.note(f"[muted]logging in to[/muted] [brand]{admin_node.name}[/brand] [muted]…[/muted]")
if not await device.admin_login(admin_node, password):
ctx.admin_store.forget(admin_node) # bad password: don't keep reusing it
raise DeviceCommandError(
f"admin login to {admin_node.name!r} failed (wrong password?). "
"The saved password was cleared; re-run to enter a new one."
)
ctx.admin_store.remember(admin_node, password)
ctx.ui.note(
f"[muted]tuning[/muted] [brand]{admin_node.name}[/brand] "
f"[muted]→ target[/muted] [brand]{target_label}[/brand] "
f"[muted]via {path}[/muted]"
)
with ctx.ui.progress("tx-optimize") as progress:
task = progress.add_task(f"optimizing TX -> {target_label}", total=None)
def on_level(done: int, total: int, level) -> None: # noqa: ANN001
progress.update(
task, total=total, completed=done,
description=f"TX {level.tx_power:>2} "
f"SNR {_fmt_snr(level.target_snr)} {level.success_rate:.0%}",
)
result = await tx_optimizer.optimize_tx_power(
device,
target_label,
admin_node,
path,
tx_min=int(params.get("tx_min", ctx.settings.tx_opt_min)),
tx_max=int(params.get("tx_max", ctx.settings.tx_opt_max)),
coarse_step=int(params.get("step", 3)),
samples_per_level=samples,
apply=bool(params.get("apply", True)),
cooldown_s=ctx.settings.trace_cooldown_s,
on_level=on_level,
persist_level=lambda lv: ctx.repo.record_tx_sample(run_id, lv),
persist_trace=lambda t: ctx.repo.record_trace(run_id, t),
)
# No trace got through at any TX level: nothing was tuned, the node was left at
# its original power. Usually a wrong/unreachable path rather than a weak link.
no_result = result.best_snr is None
if result.applied:
ctx.log.info("set TX power %s on %s", result.best_tx, admin_node.name)
ctx.ui.show(tx_opt_table(result))
ctx.ui.show(tx_opt_summary(result))
artifacts: list[str] = []
if params.get("viz", True):
assert ctx.settings.output_dir is not None
path_out = render_tx_optimization(result, ctx.settings.output_dir)
artifacts.append(str(path_out))
if no_result:
restored = (
f" Restored TX to {result.original_tx}." if result.original_tx is not None else ""
)
message = (
f"[warn]![/warn] no traces reached [brand]{result.target}[/brand] at any TX "
f"level — check the path ends at the target and is reachable.{restored}"
)
else:
applied_note = f" [ok](set on {admin_node.name})[/ok]" if result.applied else ""
message = (
f"[ok]✓[/ok] optimal TX for [brand]{admin_node.name}[/brand] → "
f"[brand]{result.target}[/brand] is [brand]{result.best_tx}[/brand]"
+ applied_note
)
return ToolResult(
summary={
"target": result.target,
"admin_node": result.admin_node,
"path": result.path,
"best_tx": result.best_tx,
"best_snr": result.best_snr,
"best_success_rate": round(result.best_success_rate, 3),
"original_tx": result.original_tx,
"applied": result.applied,
"levels_measured": len(result.levels),
},
message=message,
artifacts=artifacts,
)
async def _resolve_password(
self, ctx: AppContext, admin_node: Contact, params: dict[str, Any]
) -> str:
"""Find the admin password: explicit flag, remembered, or an interactive prompt.
Args:
ctx: Shared application context.
admin_node: The node we're about to log in to.
params: Tool params (may carry an explicit ``password``).
Returns:
The password to log in with.
Raises:
DeviceCommandError: If no password is available and we can't prompt (JSON mode).
"""
password = params.get("password") or ctx.admin_store.get(admin_node)
if password:
return str(password)
if ctx.json_output:
raise DeviceCommandError(
f"no admin password for {admin_node.name!r}; pass --password or run once "
"interactively to store it."
)
entered = await ctx.ui.text(
f"Admin password for {admin_node.name}:", password=True
)
if not entered:
raise DeviceCommandError("an admin password is required to tune a remote node.")
return entered
def register_cli(self, app: typer.Typer) -> None:
"""Register the ``tx-optimize`` subcommand.
Args:
app: The Typer application.
"""
from ..cli import run_tool_command
@app.command(name=self.name, help=self.help)
def _tx_optimize(
path: str = typer.Option(
..., "--path", "-p",
help="Forced path ending at the target (e.g. 'Repeater,Target' or '3d,f2').",
),
samples: int = typer.Option(3, "--samples", "-n", help="Traces per TX level."),
step: int = typer.Option(3, "--step", help="Coarse sweep step."),
tx_min: Optional[int] = typer.Option(None, "--min", help="Lowest TX power."),
tx_max: Optional[int] = typer.Option(None, "--max", help="Highest TX power."),
password: Optional[str] = typer.Option(
None, "--password", help="Admin password (else remembered/prompted)."
),
apply: bool = typer.Option(True, "--apply/--no-apply", help="Set the winner."),
viz: bool = typer.Option(True, "--viz/--no-viz", help="Generate HTML chart."),
) -> None:
tool_params: dict[str, Any] = {
"path": path, "samples": samples, "step": step,
"apply": apply, "viz": viz,
}
if tx_min is not None:
tool_params["tx_min"] = tx_min
if tx_max is not None:
tool_params["tx_max"] = tx_max
if password is not None:
tool_params["password"] = password
run_tool_command(self, tool_params)
def _resolve_link(path: str, contacts: list[Contact]) -> tuple[Contact, str]:
"""Split a forced path into the admin node we tune and the target's display label.
Args:
path: The comma-separated hash path (output of ``parse_trace_path``).
contacts: Known contacts, used to map hashes back to names/keys.
Returns:
``(admin_node, target_label)`` — the second-to-last hop as a full
:class:`Contact` (needed for login), and a friendly name for the last hop.
Raises:
DeviceCommandError: If the path has fewer than two hops, or the admin hop can't
be matched to a known contact carrying a public key.
"""
hops = [h for h in path.split(",") if h]
if len(hops) < 2:
raise DeviceCommandError(
"the path needs at least two hops: the node to tune and the target after it "
"(e.g. 'AdminNode,Target')."
)
admin = _contact_for_hash(hops[-2], contacts)
if admin is None or not (admin.public_key or "").strip():
raise DeviceCommandError(
f"the node before the target ({hops[-2]}) isn't a known contact with a public "
"key, so we can't log in to tune it. Receive an advert from it first."
)
target = _contact_for_hash(hops[-1], contacts)
return admin, (target.name if target else hops[-1])
def _contact_for_hash(hash_hex: str, contacts: list[Contact]) -> Optional[Contact]:
"""Return the contact whose key matches a path-hop hash, if any.
Args:
hash_hex: A path hop hash (a leading slice of the node's public key).
contacts: Known contacts to match against.
Returns:
The matching :class:`Contact`, or ``None``.
"""
needle = hash_hex.lower().removeprefix("0x")
for c in contacts:
pub = (c.public_key or "").lower().removeprefix("0x")
prefix = (c.key_prefix or "").lower().removeprefix("0x")
if pub.startswith(needle):
return c
if prefix and (prefix.startswith(needle) or needle.startswith(prefix)):
return c
return None
def _validate_link_path(value: str, contacts: list[Contact]) -> bool | str:
"""Validate the interactive path entry: parseable and at least two hops.
Args:
value: The raw comma-separated path entry.
contacts: Known contacts used to resolve names.
Returns:
``True`` if valid, otherwise an error message string.
"""
if not value.strip():
return "Enter a path ending at the target node."
try:
path = trace_runner.parse_trace_path(value, contacts)
except ValueError as exc:
return str(exc)
if len([h for h in path.split(",") if h]) < 2:
return "Need at least two hops: the node to tune, then the target."
return True
def _is_valid_sample_count(value: str) -> bool | str:
"""Validate a positive count in ``1..MAX_SAMPLES`` for a questionary text answer.
Args:
value: Raw input.
Returns:
``True`` if valid, otherwise an error message string.
"""
try:
count = int(value)
except ValueError:
return "Enter a whole number."
if count < 1:
return "Enter a number greater than zero."
if count > MAX_SAMPLES:
return f"Maximum {MAX_SAMPLES}."
return True
def _is_int(value: str) -> bool | str:
"""Validate that a questionary answer is a whole number.
Args:
value: Raw input.
Returns:
``True`` if valid, otherwise an error message string.
"""
try:
int(value)
return True
except ValueError:
return "Enter a whole number."
def _fmt_snr(snr: Optional[float]) -> str:
"""Format an optional SNR for the progress description.
Args:
snr: SNR in dB, or ``None``.
Returns:
A short fixed-width string like ``+5.1`` or `` n/a``.
"""
return f"{snr:+.1f}" if snr is not None else " n/a"