Files
MeshTerm/meshterm/ui/tx_screen.py
T
Jean-Pierre MartineauandClaude Opus 5 92d31a3191 Every file is spelled the way the formatter spells it
`ruff format .` across the repo: 153 files, no behaviour change. This is the
second gate CONTRIBUTING.md promises, and until now it reported 154 files needing
reformat — so a first contributor ran the command the guide told them to run and
could not tell their own diff from the background.

Landed on its own, with nothing else in it, because a whole-repo reformat is only
safe to skip past in blame if it is provably nothing but layout. It was checked
that way rather than assumed: every changed file was formatted in memory and
compared by full `ast.dump()`, and 152 of 153 come out structurally identical
(the one exception was a docstring opening on a quote character, fixed in the
commit before this one). The string and comment token streams were diffed
separately, so a quote flip or an edited comment could not hide inside a
re-layout — five quote-style normalisations and sixteen implicit-concatenation
joins, all value-identical, and one commented-out line gaining the space after
its `#`. `meshterm specimen` is byte-identical on both platforms, and the suite
is 1888 green, gallery gate and `test_theme16` included.

What actually changed, in order of how much of the diff it is: wrapped calls and
signatures rejoin onto one line where they fit inside the hundred columns; the
magic trailing comma expands what it is there to expand; complex slices gain
their spaces (`[len(noise) :]`); a few redundant parens go. The tables that are
laid out to be read were fenced in the previous commit and are untouched here.

The aligned trailing-comment column is gone from about ninety lanes that were not
fenced — including the `heat.*` ladder in `MESH_THEME`, which cannot be fenced
where it sits. Every comment stays on the entry it describes, so nothing there
reads wrong; it just no longer lines up.

Add this commit to `.git-blame-ignore-revs`, which follows immediately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 01:07:19 -04:00

1002 lines
42 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 live TX-optimization screen: pick the link, arm the sweep, watch levels land.
The interactive face of the ``tx-optimize`` tool (the scripted CLI keeps its one-shot
table and HTML chart), rebuilt on the trace screen's armed-but-idle pattern. Two pickers
in the tool choose the *link* — the admin node whose transmit power is tuned, then the
target whose reception is optimized — and the screen opens idle: the route, the sweep
parameters, and the login state read across the top, an action list drives everything,
and nothing transmits until Sweep is committed.
* **Route** opens the trace screen's hop-by-hop path composer, pinned at the tuned
node: compose how the measurement reaches it, each step suggested from observed
links; the target hop is appended automatically. The default is the direct shot.
* **Range / Step / Samples** float small dialogs adjusting the sweep: the TX window,
the coarse grid spacing, and the traces measured per level. The Sweep row always
shows the resulting worst-case transmission count, so the cost of a commit is on
screen before it happens (every run is paced, like the trace tool's sampling).
* **Sweep** logs in first when it must — the password prompt floats only when nothing
is remembered, and a working password is remembered for next time — then runs the
optimizer under a floating in-flight dialog with Abort: coarse → refine → verify,
every measured level landing in the bar chart behind it, the current best starred.
Aborting mid-sweep restores the node's original power.
* **Apply winner** joins the actions once a sweep found one (the cursor lands on it),
the same offer as the after-sweep dialog for whenever that was first declined.
Every sweep opens one ``runs`` row and persists its levels and traces exactly as a
scripted run records them. A finished sweep leaves the screen armed: adjust the range
and sweep again — a new sweep is a new measurement, so the chart clears as it starts.
"""
from __future__ import annotations
import asyncio
import re
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from rich.console import Group, RenderableType
from rich.table import Table
from rich.text import Text
from ..core.connection import REMOTE_TX_MAX, REMOTE_TX_MIN
from ..core.models import Contact, LoginResult, TraceResult, TxLevelResult, TxOptResult
from ..services import trace_runner, tx_optimizer
from ..services.topology import build_topology, render_custom_spec
from .menus import icon_lane, icon_mark
from .pathline import SELF_GLYPH, PathHop, PathLine, cut_to, path_line
from .theme import name_style, snr_style
from .trace_screen import TracingDialog, _collapse_trace_width, snr_bar
from .tui.render import render_lines, render_to_ansi
from .tui.screen import Screen
from .tui.spinner import Spinner, spinner_interval
from .widgets import NodeResolver
if TYPE_CHECKING:
from ..context import AppContext
#: Human phrasing for each optimizer phase reported through ``on_phase``.
_PHASE_LABELS = {
"coarse": "coarse sweep",
"refine": "refining around the leader",
"verify": "verifying the leader",
}
#: The coarse grid spacings the Step dialog offers. 1 measures every level (no refine
#: pass left to do); wider grids trade fewer transmissions for a refine pass later.
STEP_CHOICES = (1, 2, 3, 4, 5)
#: The per-level sample counts the Samples dialog offers (the trace tool's ladder).
SAMPLE_CHOICES = (1, 2, 3, 5, 8)
#: Every mark the sweep's action rows lead with. The list exists to *measure* the icon
#: column so each label starts in the same place, and so a platform that draws no icon
#: lane at all (see :func:`~meshterm.ui.menus.command_icon`) collapses it to nothing.
_ACTION_ICONS = ("✎", "⚙", "#", "▶", "★")
def _icon_lane() -> int:
"""The action rows' icon column, in cells — this screen's own marks, measured once."""
return icon_lane(_ACTION_ICONS)
def _icon(text: Text, icon: str, style: str) -> None:
"""Append an action row's mark in the icon column, its trailing space included."""
text.append_text(icon_mark(icon, style, _icon_lane()))
class TxSweepScreen(Screen):
"""A full-screen TX-power sweep session — armed, but idle until told.
↑/↓ move the cursor over the action rows and Enter commits the selected one — the
cursor opens on Sweep, so plain Enter still just sweeps. PgUp/PgDn/Home/End scroll
the body, and Esc (or the Back row) backs out, cancelling any in-flight sweep (the
optimizer's unwind restores the node's original power).
The screen renders state and routes keys; the owning session (see
:func:`open_tx_optimize`) injects the flows — the route composer, the parameter
dialogs, and the sweep runner — and feeds measurements back through
:meth:`on_phase` / :meth:`on_level` / :meth:`complete` / :meth:`fail`.
"""
floating = False
def __init__(
self,
*,
admin_label: str,
target_label: str,
device_label: str,
device_hash: str | None,
resolve: NodeResolver,
session: Any,
tx_min: int,
tx_max: int,
admin_key: str | None = None,
target_key: str | None = None,
step: int = 3,
samples: int = 3,
run_sweep: Callable[[], None] = lambda: None,
apply_winner: Callable[[], None] = lambda: None,
compose_route: Callable[[], None] = lambda: None,
pick_range: Callable[[], None] = lambda: None,
pick_step: Callable[[], None] = lambda: None,
pick_samples: Callable[[], None] = lambda: None,
) -> None:
"""Create the sweep screen (nothing transmits until the user commits Sweep).
Args:
admin_label: Display name of the node being tuned.
target_label: Display name of the node the SNR is measured at.
device_label: Our own node's name, opening the route line.
device_hash: Our own public key, so the route endpoints carry a hash.
resolve: Maps a hop's raw hash to a friendly contact name when known.
admin_key: The tuned node's key/hash, so its name wears its own hue.
target_key: The target's key/hash, likewise.
session: The running TUI session (for repaints).
tx_min: Initial low end of the sweep window.
tx_max: Initial high end of the sweep window.
step: Initial coarse grid spacing.
samples: Initial traces measured per level.
run_sweep: Starts one sweep (owner-guarded; no-op while one is flying).
apply_winner: Re-offers the apply dialog for a completed sweep's winner.
compose_route: Opens the route composer flow over this screen.
pick_range: Floats the TX-window dialog.
pick_step: Floats the grid-spacing dialog.
pick_samples: Floats the per-level sample-count dialog.
"""
super().__init__()
self.title = f"TX optimize — {admin_label} → {target_label}"
self._admin_label = admin_label
self._target_label = target_label
self._admin_key = admin_key
self._target_key = target_key
self._device_label = device_label
self._device_hash = device_hash
self._resolve = resolve
self._session = session
self._run_sweep = run_sweep
self._apply_winner = apply_winner
self._compose_route = compose_route
self._pick_range = pick_range
self._pick_step = pick_step
self._pick_samples = pick_samples
#: Sweep parameters, mutated by the owner's dialogs between sweeps.
self.tx_min = tx_min
self.tx_max = tx_max
self.step = step
self.samples = samples
#: Hops the measurement crosses *before* the tuned node (composer output; at
#: the session's spec width). Empty = the direct shot.
self.route_hops: list[str] = []
#: One line describing the login state, owner-maintained (remembered /
#: will-ask / logged in), shown in the header.
self.login_text = ""
self.running = False
self._dialog_open = False
self._phase: str | None = None
self._done = 0
self._total = 0
self._levels: dict[int, TxLevelResult] = {}
self._best_tx: int | None = None
self._result: TxOptResult | None = None
self._applied = False
self._error: str | None = None
self._worker: asyncio.Task | None = None
self._index = self._actions.index("sweep")
self._pin_cursor = False # only pin the view while ↑/↓ are actually in use
# --- state -------------------------------------------------------------------
@property
def _actions(self) -> tuple[str, ...]:
"""The action rows in display order; Apply appears once there is a winner."""
rows = ["route", "range", "step", "samples", "sweep"]
if self._can_apply():
rows.append("apply")
return tuple(rows)
@property
def footer_hint(self) -> str: # type: ignore[override]
"""The footer keys, tracking whether a sweep is in flight."""
if self.running:
return "sweeping… · PgUp/PgDn scroll · Esc back"
return "↑↓ actions · Enter run · PgUp/PgDn scroll · Esc back"
def _can_apply(self) -> bool:
"""Whether Apply should be offered (finished, got a winner, not yet set)."""
return self._result is not None and self._result.best_snr is not None and not self._applied
def estimated_traces(self) -> int:
"""The worst-case transmission count one Sweep commit can run.
Coarse grid levels, plus the refine pass's up to ``2 · (step − 1)`` integer
fill-ins around the winner, plus the verify re-measure — each measured with
the chosen per-level samples. The real count is usually lower (refine clamps
at the window's ends and skips levels already measured).
"""
coarse = len(tx_optimizer.coarse_levels(self.tx_min, self.tx_max, self.step))
refine = max(0, 2 * (self.step - 1))
return (coarse + refine + 1) * self.samples
# --- owner feed ----------------------------------------------------------------
def sweep_started(self, worker: asyncio.Task) -> None:
"""Adopt a just-started sweep: a new measurement, so the old evidence clears."""
self.running = True
self._worker = worker
self._phase = None
self._done = self._total = 0
self._levels.clear()
self._best_tx = None
self._result = None
self._applied = False
self._error = None
self._session.invalidate()
def sweep_finished(self) -> None:
"""Mark the sweep no longer in flight (however it ended)."""
self.running = False
self._worker = None
self._session.invalidate()
def on_phase(self, phase: str) -> None:
"""Record the optimizer entering a search phase (see ``tx_optimizer.PHASES``)."""
self._phase = phase
self._session.invalidate()
def on_level(self, done: int, total: int, level: TxLevelResult) -> None:
"""Record one measured level (replacing any earlier pass at the same power)."""
self._done, self._total = done, total
self._levels[level.tx_power] = level
self._best_tx = tx_optimizer.select_best(list(self._levels.values())).tx_power
self._session.invalidate()
def complete(self, result: TxOptResult) -> None:
"""Adopt the finished sweep's selection and park the cursor on Apply."""
self._result = result
self._best_tx = result.best_tx
if self._can_apply():
self._index = self._actions.index("apply")
self._session.invalidate()
def fail(self, error: str) -> None:
"""Mark the sweep failed, keeping whatever levels already landed on screen."""
self._error = error
self._session.invalidate()
def mark_applied(self) -> None:
"""Record that the winner was written to the admin node."""
self._applied = True
self._index = min(self._index, len(self._actions) - 1)
self._session.invalidate()
def phase_label(self) -> str:
"""The in-flight dialog's status line: phase and level progress."""
label = _PHASE_LABELS.get(self._phase or "", "starting…")
if self._total:
return f"{label} · level {self._done}/{self._total}"
return label
def cancel(self) -> None:
"""Cancel any in-flight sweep (the optimizer's unwind restores the power)."""
if self._worker is not None and not self._worker.done():
self._worker.cancel()
# --- input -------------------------------------------------------------------
def handle(self, action: str, data: str = "") -> None:
"""Move the action cursor, commit the selected action, scroll, or dismiss."""
if action == "enter":
self._commit_action()
elif action == "up":
# Both ends clamp rather than wrap — the app-wide rule for a row cursor:
# a highlight that leaps end to end takes the results window with it.
self._index = max(0, self._index - 1)
self._pin_cursor = True
elif action == "down":
self._index = min(len(self._actions) - 1, self._index + 1)
self._pin_cursor = True
elif action == "pageup":
self._pin_cursor = False
self.scroll_pages(-1)
elif action in ("pagedown", "space"):
self._pin_cursor = False
self.scroll_pages(1)
elif action in ("home", "ctrl_home"):
self._pin_cursor = False
self.scroll_to_top()
elif action in ("end", "ctrl_end"):
self._pin_cursor = False
self.scroll_to_bottom()
elif action == "escape":
self.cancel()
self.resolve(None)
def _commit_action(self) -> None:
"""Run the action row under the cursor (the flows guard against re-entry)."""
actions = self._actions
key = actions[min(self._index, len(actions) - 1)]
if key == "sweep":
if not self.running:
self._run_sweep()
elif key == "apply":
if self._can_apply():
self._apply_winner()
elif key == "route":
self._open_flow(self._compose_route)
elif key == "range":
self._open_flow(self._pick_range)
elif key == "step":
self._open_flow(self._pick_step)
elif key == "samples":
self._open_flow(self._pick_samples)
def _open_flow(self, flow: Callable[[], None]) -> None:
"""Float a parameter flow over the screen (one at a time, never mid-sweep)."""
if self._dialog_open or self.running:
return
self._dialog_open = True
async def run() -> None:
try:
await flow() # type: ignore[misc] # owner flows are async closures
finally:
self._dialog_open = False
self._session.invalidate()
asyncio.ensure_future(run())
# --- rendering -----------------------------------------------------------------
def render_body(self, width: int) -> list[str]:
"""Render the header, the action list, the level chart, and the outcome."""
lines = self._header_lines(width)
lines.append("")
self._cursor: int | None = None
actions = self._actions
self._index = min(self._index, len(actions) - 1)
for i, key in enumerate(actions):
selected = i == self._index
text = self._action_text(key, selected)
text.no_wrap = True
# The route row carries a path line, so it is cut rather than truncated: a
# chip that runs off the row cracks, every other row keeps the ellipsis.
text = cut_to(text, width)
text.no_wrap = True
if selected:
self._cursor = len(lines)
lines.append(render_to_ansi(text, width))
if key == "samples":
lines.append("") # set the sweep group apart from the parameters
tail: list[RenderableType] = []
if self.running or self._levels:
tail += [Text(), Text("Levels", style="accent"), self._levels_table()]
outcome = self._outcome()
if outcome is not None:
tail += [Text(), outcome]
lines.extend(render_lines(Group(*tail), width))
self._scroll_total = max(1, len(lines))
return lines
def cursor_line(self) -> int | None:
"""The highlighted action row while ↑/↓ are in use; free scrolling otherwise."""
return getattr(self, "_cursor", None) if self._pin_cursor else None
def _action_text(self, key: str, selected: bool) -> Text:
"""One action row: pointer, glyph, and label (current value inlined)."""
text = Text("❯ " if selected else " ", style="cursor" if selected else "")
if key == "route":
_icon(text, "✎", "brand")
if self.route_hops:
text.append("Route — via ")
text.append_text(path_line(list(self.route_hops), self._resolve).text())
else:
text.append(f"Route — direct to {self._admin_label}")
elif key == "range":
_icon(text, "⚙", "accent")
text.append(f"Range — TX {self.tx_min}–{self.tx_max}")
elif key == "step":
_icon(text, "⚙", "accent")
text.append(
f"Step — every {_nth(self.step)} level, then refine"
if self.step > 1
else "Step — every level (no refine needed)"
)
elif key == "samples":
_icon(text, "#", "accent")
text.append(
f"Samples — {self.samples} trace{'s' if self.samples != 1 else ''} per level"
)
elif key == "sweep":
_icon(text, "▶", "ok")
text.append(f"Sweep — up to {self.estimated_traces()} paced transmissions")
else: # apply — present only once there is a winner to set
_icon(text, "★", "ok")
best = self._result.best_tx if self._result is not None else "?"
text.append(f"Apply winner — set TX {best} on {self._admin_label}")
if selected:
text.style = "cursor"
return text
def _header_lines(self, width: int) -> list[str]:
"""The header lanes: tuned link, measured route, sweep window, login state.
The route renders through THE path widget and wraps at hop boundaries under
its own value column (the hanging-indent rule) instead of folding back to
column zero; the tuned link above it wears the same key hues as the route.
"""
tuning = Text.assemble(
("tuning ", "muted"),
(self._admin_label, name_style(self._admin_label, self._admin_key)),
(" → ", "muted"),
(self._target_label, name_style(self._target_label, self._target_key)),
)
lines = [render_to_ansi(tuning, width, no_wrap=True)]
route = self._route_line().wrapped(width, indent=9)
first = Text("route ", style="muted")
first.append_text(route[0])
lines.append(render_to_ansi(first, width, no_wrap=True))
lines.extend(render_to_ansi(cont, width, no_wrap=True) for cont in route[1:])
rest = Text("sweep ", style="muted")
rest.append(
f"TX {self.tx_min}–{self.tx_max} · step {self.step} · "
f"{self.samples} trace{'s' if self.samples != 1 else ''}/level"
)
if self.login_text:
rest.append("\n")
rest.append("login ", style="muted")
rest.append(self.login_text)
if self._result is not None and self._result.original_tx is not None:
rest.append("\n")
rest.append("was ", style="muted")
rest.append(f"TX {self._result.original_tx}")
lines.extend(render_lines(rest, width))
return lines
def _route_line(self) -> PathLine:
"""The walk one measurement makes: out through the tuned link, mirrored home.
The outbound leg — us, any composed hops, the tuned node, the target — draws
in full colour (each name in its own key hue); the return (the outbound
mirrored back, the trace boomerang the optimizer actually flies) is dimmed,
reading as "not yours to compose".
Both our ends stand on the app-wide ``★`` rather than our name: every
measurement this screen makes leaves us and comes home to us, so spelling
ourselves out twice per line would cost the lane the very hops the sweep is
tuning — the same trade the trace route lane and the trophy card make.
"""
hops: list[PathHop] = [PathHop(SELF_GLYPH, you=True)]
for hop in self.route_hops:
hops.append(PathHop(self._hop_name(hop), key=hop))
hops.append(PathHop(self._admin_label, key=self._admin_key))
hops.append(PathHop(self._target_label, key=self._target_key))
hops.append(PathHop(self._admin_label, key=self._admin_key, dim=True))
for hop in reversed(self.route_hops):
hops.append(PathHop(self._hop_name(hop), key=hop, dim=True))
hops.append(PathHop(SELF_GLYPH, you=True, dim=True))
return PathLine(hops)
def _hop_name(self, hop: str) -> str:
"""A route hop's friendly name when known, else its raw hash."""
named = self._resolve(hop)
return named if named else hop
def _levels_table(self) -> Table:
"""Every measured level as a bar chart, ascending by TX, the best starred."""
table = Table(box=None, padding=(0, 1, 0, 0), expand=False, header_style="muted")
table.add_column("TX", justify="right", min_width=4)
table.add_column("SNR AT TARGET")
table.add_column("", justify="right") # numeric SNR
table.add_column("RELIABILITY", justify="right")
table.add_column("TRACES", justify="right", style="muted")
for level in sorted(self._levels.values(), key=lambda lv: lv.tx_power):
is_best = level.tx_power == self._best_tx
tx_cell = Text()
tx_cell.append("★ " if is_best else " ", style="ok")
tx_cell.append(str(level.tx_power), style="brand" if is_best else "")
snr = level.target_snr
if snr is None:
bar_cell: Text = Text("✗ no reply", style="err")
snr_cell = Text("—", style="muted")
else:
bar_cell = snr_bar(snr)
snr_cell = Text(f"{snr:+.1f} dB", style=snr_style(snr))
rate = level.success_rate
rate_style = "ok" if rate >= 1.0 else ("warn" if rate > 0 else "err")
table.add_row(
tx_cell,
bar_cell,
snr_cell,
Text(f"{rate:.0%}", style=rate_style),
f"{level.successes}/{level.samples}",
)
return table
def _outcome(self) -> Text | None:
"""The completed sweep's verdict and apply status, or the in-flight error."""
if self._error is not None:
return Text(f"✗ sweep failed: {self._error}", style="err")
result = self._result
if result is None:
return None
if result.best_snr is None:
return Text.assemble(
("! ", "warn"),
("no traces reached ", ""),
(self._target_label, "brand"),
(" at any level — check the route, then sweep again.", ""),
)
outcome = Text.assemble(
("best ", "muted"),
(f"TX {result.best_tx}", "brand"),
(" · ", "muted"),
)
outcome.append(f"{result.best_snr:+.1f} dB", style=snr_style(result.best_snr))
outcome.append(f" at {self._target_label} · ", style="muted")
outcome.append(f"{result.best_success_rate:.0%} reliable")
outcome.append("\n")
outcome.append("status ", style="muted")
if self._applied:
outcome.append(f"✓ TX {result.best_tx} set on {self._admin_label}", style="ok")
else:
restored = (
f" — the node keeps TX {result.original_tx}"
if result.original_tx is not None
else ""
)
outcome.append(f"not applied{restored}", style="muted")
return outcome
def _nth(n: int) -> str:
"""``2 → "2nd"``, ``3 → "3rd"``, … for the Step action row."""
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n if n < 20 else n % 10, "th")
return f"{n}{suffix}"
def parse_tx_range(text: str) -> tuple[int, int] | None:
"""Parse a typed TX window like ``"12-28"`` / ``"12 28"`` into ``(low, high)``.
Clamped to the remote firmware's representable window; ``None`` when the text
doesn't contain exactly two numbers in order.
"""
numbers = re.findall(r"\d+", text)
if len(numbers) != 2:
return None
lo, hi = int(numbers[0]), int(numbers[1])
if lo > hi:
return None
return max(REMOTE_TX_MIN, lo), min(REMOTE_TX_MAX, hi)
async def open_tx_optimize(
ctx: AppContext,
*,
admin_node: Contact,
target_label: str,
target_hash: str,
) -> dict[str, Any]:
"""Run the live TX-optimization session for one tuned link.
The tool's pickers have chosen the link; this wires the armed-idle screen to the
radio, the topology evidence, and the database, and runs it until dismissed. Login
happens inside the first Sweep commit — a remembered password silently, otherwise
one floating prompt (remembered on success, forgotten on rejection: the trace
composer's convention). Each sweep opens its own ``runs`` row and records every
level and trace under it, exactly as a scripted run does.
Args:
ctx: The shared application context (must be running the interactive TUI surface).
admin_node: The contact being tuned (the hop before the target).
target_label: Display name of the node the SNR is measured at.
target_hash: The target's hex hash (full key, or the typed prefix).
Returns:
The last sweep's recorded summary (empty if dismissed before any sweep ran).
Raises:
RuntimeError: If called outside the interactive menu (no full-screen session).
"""
from .path_composer import AUTO_SPEC, PathComposerScreen
from .surface import TuiUi
from .tui import CANCEL, Choice, SelectScreen
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
raise RuntimeError("the live TX sweep is only available in the menu")
session = ctx.ui.session
device = await ctx.device()
# Contacts/self-info/routing width from the session cache (see DeviceState); the live
# ``device`` is still held for the sweep's own transmissions below.
contacts = await ctx.devstate.contacts()
resolve = trace_runner.make_node_resolver(contacts)
self_info = await ctx.devstate.self_info()
device_label = str(self_info.get("name") or "this node")
device_hash = str(self_info.get("public_key") or "") or None
try:
width_bytes = _collapse_trace_width(int(await ctx.devstate.path_hash_mode()))
except Exception: # noqa: BLE001 - optional read; the 1-byte default always works
width_bytes = 1
admin_hash = (admin_node.public_key or admin_node.key_prefix).lower().removeprefix("0x")
target_hex = target_hash.lower().removeprefix("0x")
logged_in = False
summary: dict[str, Any] = {}
def login_text() -> str:
if logged_in:
return f"✓ logged in to {admin_node.name}"
if ctx.admin_store.get(admin_node) is not None:
return "password remembered — logs in at the first sweep"
return "no saved password — asks at the first sweep"
screen = TxSweepScreen(
admin_label=admin_node.name,
target_label=target_label,
device_label=device_label,
device_hash=device_hash,
resolve=resolve,
session=session,
tx_min=ctx.preferences.tx_opt_min,
tx_max=ctx.preferences.tx_opt_max,
admin_key=admin_hash,
target_key=target_hex,
)
screen.login_text = login_text()
def spec() -> str:
"""The one-way wire spec of the next sweep: composed hops, tuned node, target."""
return render_custom_spec((*screen.route_hops, admin_hash, target_hex), width_bytes)
# --- parameter flows (floated over the screen) ---------------------------------
async def compose_route() -> None:
"""Compose how the measurement reaches the tuned node, pinned as the target."""
topo = build_topology(
self_id=device_hash or "local",
contacts=contacts,
trace_paths=ctx.repo.trace_paths(),
packet_paths=ctx.repo.packet_paths(),
neighbour_links=ctx.repo.neighbour_links(),
)
admin_id = topo.canonical(admin_hash) or admin_hash[:12]
composer = PathComposerScreen(
device_label=device_label,
device_hash=device_hash,
topology=topo,
width_bytes=width_bytes,
target_id=admin_id,
target_hash=admin_hash,
target_label=admin_node.name,
hops=[topo.canonical(h) or h for h in screen.route_hops],
resolve=resolve, # name a hop the topology left ambiguous, as we do
)
result = await session.run_screen(composer)
if result is CANCEL or not isinstance(result, str):
return
if result == AUTO_SPEC:
screen.route_hops = [] # device routing is meaningless here: direct shot
return
# The composer emits the symmetric boomerang around the tuned node; keep just
# the outbound hops before it (the target leg is ours to append).
tokens = [t for t in result.split(",") if t]
if tokens and len(tokens) % 2 == 1 and tokens == tokens[::-1]:
screen.route_hops = tokens[: len(tokens) // 2]
else:
screen.route_hops = tokens[:-1] if tokens else []
async def pick_range() -> None:
"""Pick the sweep's TX window: the configured default, the full range, or typed."""
preferred_lo, preferred_hi = ctx.preferences.tx_opt_min, ctx.preferences.tx_opt_max
items = [
Choice(
title=f"TX {preferred_lo}–{preferred_hi} — your configured default",
value=(preferred_lo, preferred_hi),
),
Choice(
title=f"TX {REMOTE_TX_MIN}–{REMOTE_TX_MAX} — the full remote range",
value=(REMOTE_TX_MIN, REMOTE_TX_MAX),
),
Choice(title="Custom… — type a window", value="custom"),
]
picked = await session.run_screen(
SelectScreen(
"Sweep range",
items,
prompt="The TX window the sweep explores:",
default=(screen.tx_min, screen.tx_max),
footer_hint="↑↓ move · Enter set · Esc keep",
filterable=False,
)
)
if picked is CANCEL or picked is None:
return
if picked == "custom":
typed = await session.text(
"Sweep range",
prompt=f"Low–high, within {REMOTE_TX_MIN}–{REMOTE_TX_MAX}:",
default=f"{screen.tx_min}-{screen.tx_max}",
validate=lambda t: (
parse_tx_range(t) is not None
or "Enter two numbers, low then high (e.g. 14-24)."
),
)
if not typed:
return
parsed = parse_tx_range(typed)
if parsed is None:
return
picked = parsed
screen.tx_min, screen.tx_max = picked
async def pick_step() -> None:
"""Pick the coarse grid spacing (1 = measure every level, nothing to refine)."""
items = [
Choice(
title=f"{n}"
+ (" — every level up front" if n == 1 else f" — every {_nth(n)} level"),
value=n,
)
for n in STEP_CHOICES
]
picked = await session.run_screen(
SelectScreen(
"Coarse step",
items,
prompt="The coarse sweep measures the window at this spacing, then refines.",
default=screen.step,
footer_hint="↑↓ move · Enter set · Esc keep",
filterable=False,
)
)
if picked is not CANCEL and picked is not None:
screen.step = int(picked)
async def pick_samples() -> None:
"""Pick how many traces each level measures (paced, like the trace tool's)."""
pace = ctx.preferences.trace_cooldown_s
items = [
Choice(
title=f"{n} trace{'s' if n > 1 else ' '}"
+ (" — fast but noisy" if n == 1 else ""),
value=n,
)
for n in SAMPLE_CHOICES
]
picked = await session.run_screen(
SelectScreen(
"Samples per level",
items,
prompt=f"Each TX level is measured with this many traces, {pace:g} s apart.",
default=screen.samples,
footer_hint="↑↓ move · Enter set · Esc keep",
filterable=False,
)
)
if picked is not CANCEL and picked is not None:
screen.samples = int(picked)
# --- login, sweep, apply --------------------------------------------------------
async def ensure_login() -> bool:
"""Log in to the tuned node once per session, prompting only when needed.
Runs *before* the in-flight dialog goes up, so the password prompt floats
over the idle screen and the login command itself hides behind the skeleton
card — the same shape as every other pre-transmission device call.
"""
nonlocal logged_in
if logged_in:
return True
password = ctx.admin_store.get(admin_node)
if password is None:
password = await session.text(
f"Admin password for {admin_node.name}",
prompt="The repeater ignores tuning commands without an admin login.",
password=True,
)
if not password:
return False
async with ctx.ui.busy_overlay():
outcome = await device.admin_login(admin_node, password)
ctx.admin_store.record(admin_node, password, outcome)
if outcome is LoginResult.REFUSED:
screen.login_text = login_text()
await session.message_dialog(
Text(
f"{admin_node.name!r} rejected the admin login (wrong password?). "
"The saved password was cleared; sweep again to enter a new one.",
style="err",
),
title="Admin login",
)
return False
if not outcome:
# Silence is not a denial: the password stays put for the next attempt.
screen.login_text = login_text()
await session.message_dialog(
Text(
f"No reply from {admin_node.name} — it may be out of reach, asleep, "
"or busy. The saved password was kept; sweep again when it answers.",
style="warn",
),
title="Admin login",
)
return False
logged_in = True
screen.login_text = login_text()
return True
async def apply_dialog() -> None:
"""Offer the winner in a floating dialog; write it to the node on Apply."""
result = screen._result
if result is None or result.best_snr is None or screen._applied:
return
was = f" (currently {result.original_tx})" if result.original_tx is not None else ""
prompt = Text.assemble(
("Set TX ", ""),
(str(result.best_tx), "brand"),
(f" on {admin_node.name}?{was}", ""),
)
# Platform-dialog convention: the safe way out left, the committing action right
# and default, so Enter applies and Esc backs out.
choice = await session.button_dialog(
prompt,
[("Cancel", "cancel"), ("Apply", "apply")],
title="Apply winner",
default=1,
# The dialog default hint: Enter commits whichever button is highlighted,
# and ←→ is what moves it (see the quit confirm in ui/menu.py).
)
if choice != "apply":
return
async with ctx.ui.busy_overlay():
await device.set_remote_tx_power(admin_node, result.best_tx)
ctx.log.info("set TX power %s on %s", result.best_tx, admin_node.name)
screen.mark_applied()
summary["applied"] = True
async def sweep() -> None:
"""One Sweep commit: log in if needed, then drive the optimizer under a dialog."""
run_id: int | None = None
try:
if not await ensure_login():
return
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - shown on the screen, not crashed through
screen.fail(str(exc))
return
finally:
if not logged_in:
screen.sweep_finished()
spinner = Spinner()
flight = TracingDialog(
f"Sweeping — {admin_node.name} → {target_label}",
spinner=spinner,
on_abort=screen.cancel,
)
session.push(flight)
ticker = asyncio.ensure_future(_animate(session, spinner, flight, screen))
try:
path = spec()
run_id = ctx.repo.start_run(
"tx-optimize",
{
"path": path,
"samples": screen.samples,
"step": screen.step,
"tx_min": screen.tx_min,
"tx_max": screen.tx_max,
},
ctx.profile_name,
)
def on_trace(trace: TraceResult) -> None:
flight.last = trace
assert run_id is not None
ctx.repo.record_trace(run_id, trace)
result = await tx_optimizer.optimize_tx_power(
device,
target_label,
admin_node,
path,
tx_min=screen.tx_min,
tx_max=screen.tx_max,
coarse_step=screen.step,
samples_per_level=screen.samples,
apply=False, # the decision moves to the dialog, after the evidence is in
cooldown_s=ctx.preferences.trace_cooldown_s,
snr_tolerance=ctx.preferences.tx_snr_tolerance_db,
on_level=screen.on_level,
on_phase=screen.on_phase,
persist_level=lambda lv: ctx.repo.record_tx_sample(run_id, lv),
persist_trace=on_trace,
)
except asyncio.CancelledError:
if run_id is not None:
ctx.repo.finish_run(run_id, "error", {"error": "cancelled"})
raise
except Exception as exc: # noqa: BLE001 - shown on the screen, not crashed through
screen.fail(str(exc))
if run_id is not None:
ctx.repo.finish_run(run_id, "error", {"error": str(exc)})
return
finally:
ticker.cancel()
try:
await ticker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - a spinner hiccup must never break the sweep
pass
session.pop(flight)
screen.sweep_finished()
summary.clear()
summary.update(
{
"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": False,
"levels_measured": len(result.levels),
}
)
ctx.repo.finish_run(run_id, "ok", summary)
screen.complete(result)
await apply_dialog()
def run_sweep() -> None:
if screen.running:
return
screen.sweep_started(asyncio.ensure_future(sweep()))
def apply_winner() -> None:
asyncio.ensure_future(apply_dialog())
screen._run_sweep = run_sweep
screen._apply_winner = apply_winner
screen._compose_route = compose_route
screen._pick_range = pick_range
screen._pick_step = pick_step
screen._pick_samples = pick_samples
try:
await session.run_screen(screen)
finally:
worker = screen._worker
if worker is not None and not worker.done():
# Esc mid-sweep: stop the search and let the optimizer's unwind restore the
# node's original power. The skeleton card covers that last device command.
worker.cancel()
async with ctx.ui.busy_overlay():
try:
await worker
except asyncio.CancelledError:
pass
except Exception: # noqa: BLE001 - the sweep is over; nothing to surface
pass
return summary
async def _animate(
session: Any, spinner: Spinner, flight: TracingDialog, screen: TxSweepScreen
) -> None:
"""Advance the in-flight dialog's spinner and status on a steady cadence."""
while True:
await asyncio.sleep(spinner_interval())
spinner.tick()
flight.status = screen.phase_label()
session.invalidate()