`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>
1002 lines
42 KiB
Python
1002 lines
42 KiB
Python
"""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()
|