Administering a repeater that happened to be down made MeshTerm forget its admin
password. The node had said nothing at all, and we took the silence as a denial.
`admin_login` returned a bool, and all five callers read `False` as *wrong password* and
dropped the credential. Underneath, they could not have done better: meshcore's
`send_login_sync` waits for LOGIN_SUCCESS and only LOGIN_SUCCESS, so a refusal — which
the firmware does send, as a LOGIN_FAILED frame the library parses and dispatches like
any other event — times out exactly like an unreachable node and comes back as the same
`None`. Our own `etype in (ERROR, LOGIN_FAILED)` branch could never fire.
So the device now watches for that frame itself, subscribed before the request goes out
because the refusal can land the moment the request does, and answers with a three-way
`LoginResult`: ACCEPTED, REFUSED (the node said no), NO_REPLY (nothing came back — which
includes a companion-side error, where the request never even left the radio). It is
truthy only when logged in, so `if not await device.admin_login(...)` still reads "we are
not in"; a caller that acts on the failure has to name which failure.
What that means for the stored password now lives in one place, `AdminStore.record`:
remember on accepted, forget on refused, leave it exactly as it is on silence. It was
five copies of the policy, and every copy was wrong the same way. Each surface still
phrases its own message — the sweep says "sweep again", the CLI says "re-run" — and a
no-reply now says so plainly ("No reply from X — it may be out of reach, asleep, or busy.
The saved password was kept") instead of accusing the operator of a typo. The trace
screen's forget-on-DeviceAuthenticationError goes with it: the credential is settled
where the outcome is known, and an exception there is only a message to show.
A source-level test walks the package's AST and holds the line: anything that calls
`admin_login` records the outcome through the store, and nothing reaches for `forget()`
to decide this on its own.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2212 lines
104 KiB
Python
2212 lines
104 KiB
Python
"""The live trace screens: compose a route from observed topology, then watch it answer.
|
||
|
||
This is the interactive face of the two trace tools (the scripted CLI keeps its one-shot
|
||
table output). A trace is one walked path — the protocol has no destination field, so
|
||
"target" is purely a UX notion — and the two menu features split along exactly that line:
|
||
|
||
* **Trace target** (:func:`open_trace`) answers *can I reach this node?* The route is
|
||
symmetric: compose (or explore) the outbound leg, and the return is those hops
|
||
mirrored around the pinned target.
|
||
* **Trace path** (:func:`open_trace_path`) answers *how far can a route I build carry?*
|
||
There is no target at all: the whole walk is composed by hand, out and back by
|
||
whatever way you choose, and it only has to end within our own earshot.
|
||
|
||
Both open the same screen, *armed but idle*: the last-known route shows, the path is
|
||
whatever you make it, and nothing transmits until you say so. An action list drives it —
|
||
↑/↓ select a row, Enter commits it:
|
||
|
||
* **Compose path** opens the path composer (:mod:`~meshterm.ui.path_composer`): build
|
||
the route hop by hop, each step suggested from the links observed in *received*
|
||
traffic — traces, firmware-learned contact routes, and RX-logged packet paths —
|
||
strongest first, with raw hex entry for nodes the data has never seen. Standing on a
|
||
repeater you hold admin credentials for, the composer can also *fetch that repeater's
|
||
neighbour table* over the mesh (login required; firmware ignores guests):
|
||
second-vantage evidence, persisted and folded straight back into the suggestions.
|
||
Committing the composer parks the action cursor on Trace — compose, then plain
|
||
Enter walks it — while backing out with Esc leaves the cursor where it was.
|
||
* **Reverse path** (Trace path only — a target-mode route is a symmetric boomerang,
|
||
so flipping it changes nothing) reverses the walk's hop order: ``us → a → b → c``
|
||
becomes ``us → c → b → a``. Radio links rarely read the same both ways, so this
|
||
measures the route you built in the opposite direction. It adopts the reversed spec
|
||
like any path change (the forward run's aggregates clear) and leaves Trace idle —
|
||
nothing auto-fires — so you flip, then trace; reverse again to go back. The stored
|
||
history keeps both directions.
|
||
* **Explore paths** (Trace target only — candidates need a destination) explores
|
||
scenarios: ranked candidate routes to the target straight from the topology evidence
|
||
(the device's own learned route, the direct shot, and the strongest observed
|
||
alternatives). Adopt one directly — or probe them all, one measured trace per
|
||
candidate, persisted as ``path_candidates`` rows and ranked reliability-first, with
|
||
the winner offered for adoption. Evidence proposes, measurement decides, you dispose.
|
||
* **Path width** floats a small dialog picking the per-hop path-hash width (1, 2, or
|
||
4 bytes — the leading key slice forced hops are addressed by). It seeds from the
|
||
device's routing width; changing it re-renders a standing forced path at the new
|
||
width and shapes every spec the composer and explorer emit after it.
|
||
* **Sample count** floats a dialog picking how many traces one Trace action runs
|
||
(1, 2, 3, 5, or 8). Multi-trace runs are *paced* — a cooldown sleeps between
|
||
transmissions, because repeaters penalize (and can blacklist) nodes that burst
|
||
traffic — and the in-flight dialog counts them off as replies stream in.
|
||
* **Trace** (the cursor opens here, so plain Enter still just traces) runs the chosen
|
||
number of traces. While they fly, a floating *tracing* dialog (spinner, progress,
|
||
Abort) sits over the screen — replies stream into the log behind it, and Esc in the
|
||
dialog cancels without leaving the screen; traces already recorded are kept. The
|
||
screen keeps aggregating every trace of the session into its medians. A walk that
|
||
crosses some link twice in the same direction first confirms on an amber
|
||
Cancel/Trace dialog — such a walk isn't a *trail*, so the trophy case ignores it
|
||
(the no-cheat rule; see :mod:`~meshterm.services.records`) — and a run that places
|
||
on the boards floats a *New record* dialog once it settles: Close (the default)
|
||
stays on the screen, Trophy case unwinds the whole session and opens the boards,
|
||
so leaving them lands on the main menu.
|
||
* **Back** leaves the screen, exactly like Esc.
|
||
|
||
Layout, top to bottom: the walked route (live when a reply has landed, else the route
|
||
the next Trace will walk — composed by hand, or auto-resolved from the device's learned
|
||
route or the stored history and labelled with that provenance — else the most recent
|
||
stored trace), the run's robust aggregates, the wire spec that route amounts to, the
|
||
action list, then the results — per-hop median SNR with quality bars and the
|
||
individual traces newest-first — scrolling in a window beneath the pinned controls:
|
||
PgUp/PgDn/Home/End slide it (↑/↓ belong to the action cursor), and faint ``↑/↓ n
|
||
more`` markers count what the window hides.
|
||
|
||
Both path lanes draw through THE path widget (:mod:`~meshterm.ui.pathline`) and break
|
||
at hop boundaries under their own value column: a route folds between nodes — never
|
||
parting a name from the hash it is addressed by, and preferring the turn where a
|
||
boomerang's mirrored return begins — while the spec below it folds after a comma,
|
||
never mid-hash. Whatever a route's provenance is (an auto plan's source, a previous
|
||
walk's timestamp) hangs on the line under it rather than crowding its right edge.
|
||
|
||
Every trace is persisted exactly like a scripted run: one ``runs`` row per trace,
|
||
recorded under it, so the stored history reads the same no matter which front end
|
||
produced it. Path walks record under :data:`~meshterm.core.models.PATH_TRACE_TARGET`,
|
||
keeping them out of the target picker's history while still seeding the path screen's
|
||
previous-route line.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Sequence
|
||
|
||
from rich.cells import cell_len
|
||
from rich.console import Group, RenderableType
|
||
from rich.table import Table
|
||
from rich.text import Text
|
||
|
||
from ..core.models import PATH_TRACE_TARGET, TraceResult, TraceStats
|
||
from ..services import trace_runner
|
||
from ..services.records import first_repeated_edge
|
||
from ..services.topology import render_forced_spec
|
||
from .braillechart import meter
|
||
from .menus import back_rows, command_icon, marked_label, section_heading
|
||
from .theme import snr_style
|
||
from .tui.render import render_lines, render_to_ansi
|
||
from .tui.screen import ListWindow, Screen
|
||
from .tui.spinner import Spinner, spinner_interval
|
||
from .pathline import PathHop, PathLine, cut_to, hops_atom, path_line
|
||
from .widgets import NodeResolver, _link_text, _route_path, highlighted_hash
|
||
|
||
if TYPE_CHECKING:
|
||
from ..context import AppContext
|
||
|
||
#: How wide the per-hop SNR quality bars draw, in characters. Each character packs
|
||
#: two fill steps (see :func:`snr_bar`), so the bar reads at 16-step resolution in
|
||
#: half the columns a one-step-per-cell block bar would need.
|
||
_BAR_WIDTH = 8
|
||
|
||
#: The SNR range the bars span, in dB: -15 (barely readable) to +10 (excellent). Values
|
||
#: outside clamp to the ends, so the bar always shows *something* for a heard hop.
|
||
_BAR_SNR_MIN = -15.0
|
||
_BAR_SNR_MAX = 10.0
|
||
|
||
#: The sample counts the Sample count dialog offers: how many traces one Trace action
|
||
#: runs, paced between transmissions.
|
||
SAMPLE_CHOICES = (1, 2, 3, 5, 8)
|
||
|
||
#: What the trace screen resolves with when the new-record dialog's *Trophy case*
|
||
#: button is chosen. The session opener catches it and opens the trophy case only
|
||
#: after the screen has fully unwound, so leaving the trophy case afterwards lands
|
||
#: on the main menu instead of re-entering a stack of trace screens.
|
||
OPEN_TROPHY_CASE = "records"
|
||
|
||
#: A single-trace runner: ``(path_spec, on_trace)`` → runs exactly one trace, handing
|
||
#: the result — plus any trophy-case disciplines the walk just placed in, as short
|
||
#: ``"Title — score"`` labels — to ``on_trace`` when it lands. Provided by the session
|
||
#: openers, which close over the device, repository, and settings so the screen stays
|
||
#: free of persistence and scoring concerns.
|
||
TraceOnce = Callable[
|
||
[str, Callable[[TraceResult, Sequence[str]], None]], Awaitable[None]
|
||
]
|
||
|
||
#: A path picker flow: takes the current spec, runs its own dialogs over the screen, and
|
||
#: resolves to the new spec (``""`` = device-routed) or ``None`` to keep the current one.
|
||
#: The sample-count flow shares the shape and simply always resolves ``None``.
|
||
PathFlow = Callable[[str], Awaitable[Optional[str]]]
|
||
|
||
#: The route lane's label column: the word plus its padding. Its width is the column
|
||
#: the route's wrapped continuations (and its provenance note) hang under.
|
||
_ROUTE_LANE = "route "
|
||
|
||
#: The path lane's label column — the aggregate block's own label width, so the wire
|
||
#: spec starts, and hangs, in the same column as the numbers above it.
|
||
_PATH_LANE = "path "
|
||
|
||
#: Every mark the action rows lead with, in row order. The list exists to *measure*
|
||
#: the icon column: ``⚡`` is an emoji, two cells where the rest are one, so a hard
|
||
#: ``"icon "`` prefix would start Explore's label a column right of every other
|
||
#: action's. See :func:`_icon_lane`.
|
||
_ACTION_ICONS = ("✎", "⇄", "⚡", "⚙", "#", "▶")
|
||
|
||
|
||
def _icon_lane() -> int:
|
||
"""The action rows' icon column, in cells: the widest mark on this platform.
|
||
|
||
Measured rather than hard-coded because the marks themselves change with the
|
||
platform: PicoCalc draws no icon lane on a command row at all
|
||
(:func:`~meshterm.ui.menus.command_icon`), so the column measures zero and the labels
|
||
take the cells — the same alignment rule wherever the icons land.
|
||
"""
|
||
return max(cell_len(command_icon(icon)) for icon in _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.
|
||
|
||
A one-cell mark pads out to :func:`_icon_lane` so every label starts in the same
|
||
column; an emptied lane (see there) appends nothing, separator included.
|
||
"""
|
||
lane = _icon_lane()
|
||
if not lane:
|
||
return
|
||
mark = command_icon(icon)
|
||
text.append(mark, style=style)
|
||
text.append(" " * (lane - cell_len(mark) + 1))
|
||
|
||
|
||
def _lane_lines(label: str, value: list[Text], width: int) -> list[str]:
|
||
"""Render a labelled lane whose value hangs, already broken, under its own column.
|
||
|
||
The counterpart to :func:`~meshterm.ui.tui.render.render_hanging` for values that
|
||
know their own line breaks: :meth:`~meshterm.ui.pathline.PathLine.wrapped` hands
|
||
back lines that hop-wrap and carry their indent, so re-wrapping them here would
|
||
only undo that. The label rides the first line; the rest are drawn as given.
|
||
|
||
Args:
|
||
label: The lane's label column, padding included (the value hangs under it).
|
||
value: The value's lines — first bare, continuations self-indented.
|
||
width: The render width; lines are cropped, never folded.
|
||
|
||
Returns:
|
||
The rendered ANSI lines.
|
||
"""
|
||
lines: list[str] = []
|
||
for i, line in enumerate(value):
|
||
row = Text(label, style="muted") if i == 0 else Text()
|
||
row.append_text(line)
|
||
lines.append(render_to_ansi(row, width, no_wrap=True))
|
||
return lines
|
||
|
||
|
||
def _note(text: str, indent: int, *, style: str = "faint") -> Text:
|
||
"""A lane's trailing note — provenance, a stamp, a count — on its own hanging line."""
|
||
return Text(" " * indent + text, style=style)
|
||
|
||
|
||
def _spec_line(spec: str) -> PathLine:
|
||
"""The literal wire spec as a path line: comma-joined, each hop in its own hue.
|
||
|
||
Exactly the characters the radio is handed (``3d63,7f21,3d63``) — the lane's whole
|
||
point is being the verbatim spec, so it stays plain-mode text rather than becoming
|
||
chips. What THE path widget adds is the app-wide key colouring (each addressed
|
||
slice lit in its own key-derived hue instead of one flat brand smear) and hop-
|
||
boundary wrapping, so a spec too long for its column breaks after a comma.
|
||
|
||
Args:
|
||
spec: The wire spec (comma-separated hex hop hashes; blanks tolerated).
|
||
|
||
Returns:
|
||
The spec's :class:`~meshterm.ui.pathline.PathLine`, comma-separated.
|
||
"""
|
||
tokens = [t.strip() for t in spec.split(",") if t.strip()]
|
||
hops = [PathHop(t, key=t, lit_bytes=len(t) // 2) for t in tokens]
|
||
return PathLine(hops, mode="plain", separator=",")
|
||
|
||
|
||
def snr_bar(snr: Optional[float], width: int = _BAR_WIDTH) -> Text:
|
||
"""Render an SNR reading as a horizontal quality bar in the shared SNR colours.
|
||
|
||
The slim-on-a-track flavour of the app's braille :func:`~meshterm.ui.braillechart.meter`:
|
||
the reading maps onto :data:`_BAR_SNR_MIN` → :data:`_BAR_SNR_MAX` and fills a dark
|
||
same-glyph track at two steps per cell.
|
||
|
||
Args:
|
||
snr: The reading in dB, or ``None`` (renders as an entirely unlit track).
|
||
width: Bar track width in characters (each worth two fill steps).
|
||
|
||
Returns:
|
||
A :class:`Text` of filled braille cells over a dark, same-glyph track,
|
||
coloured by :func:`~meshterm.ui.theme.snr_style`.
|
||
"""
|
||
if snr is None:
|
||
return meter(None, width, style="track", slim=True, track="track")
|
||
span = _BAR_SNR_MAX - _BAR_SNR_MIN
|
||
frac = min(1.0, max(0.0, (snr - _BAR_SNR_MIN) / span))
|
||
return meter(frac, width, style=snr_style(snr), slim=True, track="track")
|
||
|
||
|
||
class TracingDialog(Screen):
|
||
"""The floating in-flight dialog: a spinner chip, live progress, and Abort.
|
||
|
||
Pushed over the trace screen while traces (or a scenario probe) transmit, so the
|
||
activity — and the way out — is unmissable while replies keep streaming into the
|
||
screen behind it. Enter, Esc, or Space aborts via the injected callback; the owner
|
||
pops the dialog when the work finishes, so it never resolves a value of its own.
|
||
"""
|
||
|
||
footer_hint = "Enter/Esc abort"
|
||
|
||
@property
|
||
def fkey_lane(self):
|
||
"""No lane: the dialog's only verb is abort, and it already answers to Enter and Esc."""
|
||
from .tui.fkeys import EMPTY_LANE
|
||
|
||
return EMPTY_LANE
|
||
|
||
def __init__(self, title: str, *, spinner: Spinner, on_abort: Callable[[], None]) -> None:
|
||
"""Build the dialog.
|
||
|
||
Args:
|
||
title: Heading for the dialog border (e.g. ``Tracing — YUL-Poly``).
|
||
spinner: The spinner to animate (shared with the owner's ticker).
|
||
on_abort: Invoked when the user asks to abort (idempotent expected).
|
||
"""
|
||
super().__init__()
|
||
self.title = title
|
||
self._spinner = spinner
|
||
#: Invoked when the user aborts. Public so an owner whose task only exists
|
||
#: after the dialog does (the probe sweep) can rewire it.
|
||
self.on_abort = on_abort
|
||
#: One-line progress, updated by the owner as replies land.
|
||
self.status = "transmitting…"
|
||
#: The most recent trace result, echoed beneath the status line.
|
||
self.last: Optional[TraceResult] = None
|
||
#: Whether to render the last-reply line. Trace/probe owners stream replies
|
||
#: through it; request-shaped owners (a neighbour fetch) have none to show.
|
||
self.show_last = True
|
||
|
||
@property
|
||
def dialog_width(self) -> int:
|
||
"""Natural outer width hugging the widest line (compositor still caps it)."""
|
||
widths = [cell_len(self.title), cell_len(self.footer_hint),
|
||
cell_len(self.status) + 4, len("last ✗ no reply — will retry"),
|
||
len(" Abort ")]
|
||
return max(widths) + 8
|
||
|
||
def _last_line(self) -> Text:
|
||
"""The most recent reply, or a waiting note before the first one lands."""
|
||
line = Text("last ", style="muted")
|
||
if self.last is None:
|
||
line.append("waiting for the first reply…", style="muted")
|
||
elif not self.last.success:
|
||
line.append("✗ no reply", style="err")
|
||
else:
|
||
line.append("✓ ", style="ok")
|
||
line.append(f"{self.last.hop_count} hop{'s' if self.last.hop_count != 1 else ''}")
|
||
if self.last.min_snr is not None:
|
||
line.append(" min ", style="muted")
|
||
line.append(f"{self.last.min_snr:+.1f} dB", style=snr_style(self.last.min_snr))
|
||
if self.last.round_trip_ms is not None:
|
||
line.append(f" {self.last.round_trip_ms:.0f} ms", style="muted")
|
||
return line
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Render the spinner chip, the last reply, and the Abort button.
|
||
|
||
The button is the app's one button look — a centered reverse-video chip in the
|
||
shared ``selected`` fill, exactly as the ButtonDialog and reconnect dialog draw
|
||
theirs — so every popup's committing control reads the same.
|
||
"""
|
||
from .tui.prompt import _center
|
||
|
||
chip = self._spinner.text()
|
||
chip.append(f" {self.status}", style="")
|
||
lines = render_lines(chip, width)
|
||
if self.show_last:
|
||
lines.extend(render_lines(self._last_line(), width))
|
||
lines.append("")
|
||
button = Text(" Abort ", style="selected")
|
||
lines.append(render_to_ansi(_center(button, width), width))
|
||
return lines
|
||
|
||
def handle(self, action: str, data: str = "") -> None:
|
||
"""Any commit/dismiss key aborts the in-flight work; everything else is inert."""
|
||
if action in ("enter", "escape", "space"):
|
||
self.on_abort()
|
||
|
||
|
||
class TraceScreen(Screen):
|
||
"""A full-screen live trace session — armed, but idle until told.
|
||
|
||
One screen serves both trace features; ``mode`` decides which. ``"target"`` traces
|
||
a pinned destination over a symmetric (mirrored) route and offers *Explore paths*;
|
||
``"path"`` walks a hand-composed route with no target at all — no Explore (ranked
|
||
candidates need a destination), no device routing, but a *Reverse path* that flips
|
||
the asymmetric walk end-for-end, and Trace stays inert until a path exists to walk
|
||
(composed here, or auto-resolved from the last stored walk).
|
||
|
||
Adopting a different path — composed, explored, reversed, or re-rendered at a new
|
||
width — restarts the measurement: the aggregates, per-hop medians, and trace log
|
||
all described the old route, so they clear as if the screen had just opened.
|
||
|
||
↑/↓ move the cursor over the action rows and Enter commits the selected one — the
|
||
cursor opens on Trace, so plain Enter still just traces. The results (per-hop
|
||
medians and the trace log) scroll in a window beneath the pinned controls with
|
||
PgUp/PgDn/Home/End, and Esc (or the Back row) backs out, cancelling any
|
||
in-flight trace; already-recorded traces are kept.
|
||
"""
|
||
|
||
floating = False
|
||
|
||
def __init__(
|
||
self,
|
||
target: str,
|
||
*,
|
||
mode: str = "target",
|
||
device_label: str,
|
||
device_hash: Optional[str],
|
||
resolve: NodeResolver,
|
||
session: Any,
|
||
trace: TraceOnce,
|
||
compose_path: PathFlow,
|
||
pick_width: PathFlow,
|
||
pick_samples: PathFlow,
|
||
width_bytes: Callable[[], int],
|
||
sample_count: Callable[[], int],
|
||
explore: Optional[PathFlow] = None,
|
||
pace_s: float = 1.0,
|
||
previous: Optional[TraceResult] = None,
|
||
auto_spec: Callable[[], str] = lambda: "",
|
||
auto_source: str = "",
|
||
initial_spec: str = "",
|
||
) -> None:
|
||
"""Create the screen (nothing transmits until the user commits Trace).
|
||
|
||
Args:
|
||
target: The label traces persist under — the destination's name in target
|
||
mode, :data:`~meshterm.core.models.PATH_TRACE_TARGET` in path mode.
|
||
mode: ``"target"`` (symmetric, explorable) or ``"path"`` (hand-composed
|
||
walk, no destination).
|
||
device_label: Our own node's name, labelling the route's endpoints.
|
||
device_hash: Our own public key, so the endpoints carry a hash like every hop.
|
||
resolve: Maps a hop's raw hash to a friendly contact name when known.
|
||
session: The running TUI session (for repaints and the floating dialog).
|
||
trace: Runs exactly one trace, handing back the result (see
|
||
:data:`TraceOnce`); the screen loops it for multi-sample runs.
|
||
compose_path: Opens the hop-by-hop path composer over this screen, seeded
|
||
with the current spec; resolves to the new spec or ``None`` if cancelled.
|
||
pick_width: Floats the path-hash width dialog; resolves to the standing
|
||
spec re-rendered at the chosen width, or ``None`` when nothing changes.
|
||
pick_samples: Floats the sample-count dialog; always resolves ``None``
|
||
(the count lives with the owner, read back via ``sample_count``).
|
||
width_bytes: Reads the currently chosen per-hop width, for the action row's
|
||
label (the owner holds the width, since the flows emit specs at it).
|
||
sample_count: Reads the currently chosen sample count, for the action rows
|
||
and for how many traces one Trace commit runs.
|
||
explore: Opens the scenario browser/probe flow over this screen (target
|
||
mode only); resolves to an adopted spec or ``None`` to keep the current.
|
||
pace_s: Cooldown slept between the traces of a multi-sample run, so a
|
||
sampling session never reads as a burst to the repeaters.
|
||
previous: The most recent stored trace, if any — its route seeds the route
|
||
line so the screen opens knowing the path history last saw.
|
||
auto_spec: Renders the spec an *auto* trace (no composed path) actually
|
||
walks right now — the session resolves it from the device's learned
|
||
route or the stored history at the current width (in path mode, the
|
||
last successful stored walk verbatim). ``""`` means a path-less
|
||
trace (unaddressable target, or a path walk with no history).
|
||
auto_source: Short provenance of the auto route (e.g. ``device route``,
|
||
``last trace · Jul 09 14:32``) for the route line and summary, so
|
||
the screen never claims a route the radio wasn't given.
|
||
initial_spec: A forced path to open armed on, instead of idle on the auto
|
||
route — how *Trace this path* from the trophy case reopens a record's
|
||
exact route, ready to walk again. Empty (the default) opens on auto.
|
||
"""
|
||
super().__init__()
|
||
self.title = f"Trace — {target}" if mode == "target" else "Trace path"
|
||
self._target = target
|
||
self._mode = mode
|
||
self._flight_label = target if mode == "target" else "path"
|
||
self._device_label = device_label
|
||
self._device_hash = device_hash
|
||
self._resolve = resolve
|
||
self._session = session
|
||
self._trace_once = trace
|
||
self._compose_path = compose_path
|
||
self._explore = explore
|
||
self._pick_width = pick_width
|
||
self._pick_samples = pick_samples
|
||
self._width_bytes = width_bytes
|
||
self._sample_count = sample_count
|
||
self._pace_s = pace_s
|
||
self._previous = previous
|
||
self._auto_spec = auto_spec
|
||
self._auto_source = auto_source
|
||
self._path_spec = initial_spec.strip()
|
||
#: The trophy-case disciplines the current run has placed in, keyed by
|
||
#: discipline title so an improving multi-sample run keeps only its latest
|
||
#: ``"Title — score"`` label per board. Cleared when a run starts; drained
|
||
#: into the floating new-record dialog once the run settles.
|
||
self._run_placed: dict[str, str] = {}
|
||
#: Traces aggregated on screen — the current route's run. Adopting a
|
||
#: different path clears it (old numbers describe the old route).
|
||
self._traces: list[TraceResult] = []
|
||
#: Bumped on every mutation of ``_traces`` — the key the per-frame memos
|
||
#: below expire on (a bare ``len`` would miss a clear-then-refill).
|
||
self._traces_rev = 0
|
||
# The aggregate stats and the windowed results block, each valid for one
|
||
# traces revision (see render_body / _tail_lines): a repaint that changed
|
||
# nothing re-reads them instead of re-aggregating every stored trace.
|
||
self._stats_memo: Optional[tuple[int, TraceStats]] = None
|
||
self._tail_memo: Optional[tuple[tuple, list[str]]] = None
|
||
#: Every trace this screen ever ran, across path changes — the session
|
||
#: count the owner reports, immune to the per-route clears above.
|
||
self._total_traces = 0
|
||
self._running = False
|
||
self._dialog_open = False
|
||
self._status = ""
|
||
self._progress: Optional[tuple[int, int]] = None # (current, total) mid-run
|
||
self._spinner = Spinner()
|
||
#: Loop clock at this screen's last transmission — what :meth:`_pace_remaining`
|
||
#: measures the cooldown from, so pacing survives an abort-and-retry.
|
||
self._last_tx: Optional[float] = None
|
||
self._worker: Optional[asyncio.Task] = None
|
||
self._flight: Optional[TracingDialog] = None
|
||
# The action rows, in display order. The build-path group leads: Compose,
|
||
# then Explore (target mode) or Reverse (path mode). Explore needs a
|
||
# destination to rank candidates for, so path mode drops it; and only a path
|
||
# walk is asymmetric enough to flip end-for-end, so only it offers Reverse.
|
||
# The cursor opens on Trace either way.
|
||
actions = ["compose", "explore"] if mode == "target" else ["compose", "reverse"]
|
||
actions += ["width", "samples", "trace", "back"]
|
||
self._actions: tuple[str, ...] = tuple(actions)
|
||
self._index = self._actions.index("trace")
|
||
self._pin_cursor = False # only pin the view while ↑/↓ are actually in use
|
||
#: The results window under the pinned actions (per-hop medians + trace
|
||
#: log); PgUp/PgDn slide it while the route and controls hold still.
|
||
self._tail_window = ListWindow()
|
||
|
||
# --- state -----------------------------------------------------------------
|
||
|
||
@property
|
||
def footer_hint(self) -> str: # type: ignore[override]
|
||
"""The footer keys, tracking whether traces are in flight."""
|
||
if self._running:
|
||
if self._progress is not None and self._progress[1] > 1:
|
||
done, total = self._progress
|
||
return f"tracing {done}/{total}… · PgUp/PgDn scroll · Esc back"
|
||
return "tracing… · PgUp/PgDn scroll · Esc back"
|
||
return "↑↓ actions · Enter run · PgUp/PgDn scroll · Esc back"
|
||
|
||
def start_trace(self) -> None:
|
||
"""Kick off a trace run in the background (no-op while one is already flying)."""
|
||
if self._running:
|
||
return
|
||
self._running = True
|
||
self._status = ""
|
||
self._run_placed.clear() # this run earns its own records
|
||
self._spinner.reset()
|
||
self._worker = asyncio.ensure_future(self._run_trace())
|
||
self._session.invalidate()
|
||
|
||
async def _run_trace(self) -> None:
|
||
"""Drive one Trace commit — the chosen number of traces — under the dialog.
|
||
|
||
Every transmission is paced, not just the ones inside a multi-sample run:
|
||
:attr:`_pace_s` is measured from the *last* transmission this screen made, so
|
||
aborting a walk and immediately re-running it waits out the same gap a second
|
||
sample would. Pacing only between samples left the one hole that matters — a
|
||
user watching a failure and hitting Enter again straight away is exactly the
|
||
burst repeaters penalize, and a penalized node's every later trace comes home
|
||
empty. The dialog counts the run off as replies land; it is pushed for the
|
||
duration and popped however the run ends — completion, failure, or abort — and
|
||
its Abort wires straight to :meth:`cancel`, so the cancellation path is the
|
||
same whether Esc lands on the dialog or the screen. An aborted run keeps every
|
||
trace already recorded.
|
||
"""
|
||
total = max(1, int(self._sample_count()))
|
||
dialog = TracingDialog(
|
||
f"Tracing — {self._flight_label}", spinner=self._spinner, on_abort=self.cancel
|
||
)
|
||
self._flight = dialog
|
||
self._session.push(dialog)
|
||
ticker = asyncio.ensure_future(self._animate())
|
||
try:
|
||
for done in range(total):
|
||
self._progress = (done + 1, total)
|
||
wait = self._pace_remaining()
|
||
if wait > 0:
|
||
dialog.status = (
|
||
f"trace {done + 1}/{total} · pacing…" if total > 1 else "pacing…"
|
||
)
|
||
self._session.invalidate()
|
||
await asyncio.sleep(wait)
|
||
if total > 1:
|
||
dialog.status = f"trace {done + 1}/{total} · transmitting…"
|
||
self._session.invalidate()
|
||
self._last_tx = asyncio.get_event_loop().time()
|
||
await self._trace_once(self._path_spec, self._on_trace)
|
||
except asyncio.CancelledError:
|
||
raise
|
||
except Exception as exc: # noqa: BLE001 - report inline, keep the screen alive
|
||
self._status = f"trace failed: {exc}"
|
||
finally:
|
||
ticker.cancel()
|
||
try:
|
||
await ticker
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception: # noqa: BLE001 - a spinner hiccup must never break a trace
|
||
pass
|
||
self._session.pop(dialog)
|
||
self._flight = None
|
||
self._running = False
|
||
self._progress = None
|
||
self._session.invalidate()
|
||
if self._run_placed and not (self.future is not None and self.future.done()):
|
||
# The run placed on the boards: float the new-record dialog once the
|
||
# tracing dialog is down. A separate task, because an aborted run
|
||
# reaches here already cancelled and couldn't await a dialog itself;
|
||
# a run whose screen is already resolving (Esc mid-run) skips it —
|
||
# a popup must never chase the user out of the screen.
|
||
asyncio.ensure_future(self._announce_records())
|
||
|
||
def _pace_remaining(self) -> float:
|
||
"""Seconds still owed before the next transmission may go out (0 when clear)."""
|
||
if self._last_tx is None or self._pace_s <= 0:
|
||
return 0.0
|
||
elapsed = asyncio.get_event_loop().time() - self._last_tx
|
||
return max(0.0, self._pace_s - elapsed)
|
||
|
||
async def _animate(self) -> None:
|
||
"""Advance the in-flight spinner and repaint on a steady cadence, until cancelled."""
|
||
while True:
|
||
await asyncio.sleep(spinner_interval())
|
||
self._spinner.tick()
|
||
self._session.invalidate()
|
||
|
||
def _on_trace(self, result: TraceResult, placed: Sequence[str] = ()) -> None:
|
||
"""Append the landed trace, bank any records it set, echo it, and repaint.
|
||
|
||
Args:
|
||
result: The trace that just landed.
|
||
placed: The trophy-case disciplines this walk placed in, as short
|
||
``"Title — score"`` labels (empty when it set nothing). They bank
|
||
into the run's tally — announced in one dialog when the run settles
|
||
(see :meth:`_announce_records`), a later improvement replacing the
|
||
earlier label on the same board.
|
||
"""
|
||
self._traces.append(result)
|
||
self._traces_rev += 1
|
||
self._total_traces += 1
|
||
for label in placed:
|
||
self._run_placed[label.split(" — ")[0]] = label
|
||
if self._flight is not None:
|
||
self._flight.last = result
|
||
self._session.invalidate()
|
||
|
||
async def _announce_records(self) -> None:
|
||
"""Float the new-record dialog for everything the settled run placed.
|
||
|
||
One dialog per run, however many samples scored: each placed discipline reads
|
||
as its own ``★ Title — score`` line. Platform-dialog shape — *Trophy case* on
|
||
the left, *Close* on the right and default — so Enter simply dismisses and Esc
|
||
closes. Choosing Trophy case resolves the whole screen with
|
||
:data:`OPEN_TROPHY_CASE`: the session opener then opens the trophy case only
|
||
*after* this screen (and everything stacked over it) has unwound, so backing
|
||
out of the trophy case lands on the main menu, never a pile of trace screens.
|
||
"""
|
||
if self._dialog_open:
|
||
return
|
||
self._dialog_open = True
|
||
try:
|
||
labels = list(self._run_placed.values())
|
||
self._run_placed.clear()
|
||
prompt = Text()
|
||
for i, label in enumerate(labels):
|
||
if i:
|
||
prompt.append("\n")
|
||
prompt.append("★ ", style="accent")
|
||
prompt.append(label, style="accent")
|
||
choice = await self._session.button_dialog(
|
||
prompt,
|
||
[("Trophy case", OPEN_TROPHY_CASE), ("Close", None)],
|
||
title="New record" if len(labels) == 1 else f"{len(labels)} new records",
|
||
default=1,
|
||
footer_hint="←→ choose · Enter select · Esc close",
|
||
)
|
||
finally:
|
||
self._dialog_open = False
|
||
self._session.invalidate()
|
||
if choice == OPEN_TROPHY_CASE:
|
||
self.cancel()
|
||
self.resolve(OPEN_TROPHY_CASE)
|
||
|
||
def cancel(self) -> None:
|
||
"""Cancel any in-flight trace run (already-recorded traces are kept)."""
|
||
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.
|
||
|
||
↑/↓ belong to the action cursor; PgUp/PgDn/Home/End slide the results
|
||
window beneath the pinned controls, so a long trace log can be read while
|
||
the route and actions stay on screen.
|
||
"""
|
||
if action == "enter":
|
||
self._commit_action()
|
||
elif action == "up":
|
||
self._index = (self._index - 1) % len(self._actions)
|
||
self._pin_cursor = True
|
||
elif action == "down":
|
||
self._index = (self._index + 1) % len(self._actions)
|
||
self._pin_cursor = True
|
||
elif action == "pageup":
|
||
self._pin_cursor = False
|
||
self._tail_window.top -= self._tail_window.page
|
||
elif action in ("pagedown", "space"):
|
||
self._pin_cursor = False
|
||
self._tail_window.top += self._tail_window.page
|
||
elif action in ("home", "ctrl_home"):
|
||
self._pin_cursor = False
|
||
self._tail_window.top = 0
|
||
elif action in ("end", "ctrl_end"):
|
||
self._pin_cursor = False
|
||
self._tail_window.to_end()
|
||
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)."""
|
||
key = self._actions[self._index]
|
||
if key == "trace":
|
||
# A path walk has nothing to transmit until a path exists (composed,
|
||
# or the last stored walk) — with no target, an empty spec can't fall
|
||
# back to device routing.
|
||
if self._mode == "path" and not self._effective_spec()[0]:
|
||
return
|
||
edge = first_repeated_edge(self._spec_tokens())
|
||
if edge is not None:
|
||
self._confirm_ineligible(edge)
|
||
else:
|
||
self.start_trace()
|
||
elif key == "width":
|
||
self._open_flow(self._pick_width)
|
||
elif key == "samples":
|
||
self._open_flow(self._pick_samples)
|
||
elif key == "compose":
|
||
# Seed the composer with the route the screen is showing — the composed
|
||
# spec if one stands, else the auto-resolved plan — not the bare
|
||
# (empty on first open) stored spec, so opening Compose always resumes
|
||
# from the visible route. Committing the composer parks the cursor on
|
||
# Trace (see _open_flow): compose, then plain Enter walks it.
|
||
self._open_flow(
|
||
self._compose_path, seed=self._effective_spec()[0], focus_trace=True
|
||
)
|
||
elif key == "reverse":
|
||
self._reverse_path()
|
||
elif key == "explore" and self._explore is not None:
|
||
self._open_flow(self._explore)
|
||
elif key == "back":
|
||
self.cancel()
|
||
self.resolve(None)
|
||
|
||
def _open_flow(
|
||
self, flow: PathFlow, seed: Optional[str] = None, focus_trace: bool = False
|
||
) -> None:
|
||
"""Float a path-picking flow over the screen (one at a time, not mid-trace).
|
||
|
||
The composer, the scenario explorer, and the width picker all resolve the
|
||
same way: a new spec to adopt (``""`` returns routing to the device), or
|
||
``None`` to leave the current path untouched (which is all the sample-count
|
||
flow ever resolves).
|
||
|
||
Args:
|
||
flow: The dialog flow to run with the current spec.
|
||
seed: The spec handed to the flow, when it should differ from the stored
|
||
one — the composer seeds from the *visible* route (the auto plan when
|
||
no spec stands), so it never opens blank over a shown route. The
|
||
adopt test still compares against the stored spec, so re-confirming an
|
||
auto plan verbatim simply pins it, exactly like adopting it by hand.
|
||
focus_trace: Whether committing the flow (any non-``None`` resolution,
|
||
even the unchanged spec) should park the action cursor on Trace —
|
||
the composer's hand-back, so plain Enter walks what was just built.
|
||
Backing out with Esc leaves the cursor where it was.
|
||
"""
|
||
if self._dialog_open or self._running:
|
||
return
|
||
self._dialog_open = True
|
||
|
||
async def run() -> None:
|
||
try:
|
||
spec = await flow(self._path_spec if seed is None else seed)
|
||
if spec is not None and focus_trace:
|
||
self._index = self._actions.index("trace")
|
||
if spec is not None and spec.strip() != self._path_spec:
|
||
# A different spec is a different measurement: the aggregates,
|
||
# per-hop medians, and log all belong to the old route, so the
|
||
# session restarts as clean as a fresh screen.
|
||
self._path_spec = spec.strip()
|
||
self._traces.clear()
|
||
self._traces_rev += 1
|
||
self._status = ""
|
||
finally:
|
||
self._dialog_open = False
|
||
self._session.invalidate()
|
||
|
||
asyncio.ensure_future(run())
|
||
|
||
def _reverse_path(self) -> None:
|
||
"""Flip the walked path end-for-end and re-arm on the reversed direction.
|
||
|
||
A path walk is the one asymmetric trace: ``us → a → b → c → us`` and
|
||
``us → c → b → a → us`` cross the same links the opposite way, and radio
|
||
links rarely read the same in both — so reversing the hop order lets one
|
||
route be measured out and back. It adopts the reversed spec exactly like any
|
||
other path change: the aggregates and log described the forward run, so they
|
||
clear, and Trace stays idle until the user commits it (nothing auto-fires).
|
||
Reverse again to walk it the original way; the stored history keeps both
|
||
directions for side-by-side comparison.
|
||
|
||
A no-op when there is nothing to flip: no path, or a single hop (its own
|
||
mirror), or already mid-trace/mid-dialog.
|
||
"""
|
||
if self._dialog_open or self._running:
|
||
return
|
||
spec, _ = self._effective_spec()
|
||
tokens = [h.strip() for h in spec.split(",") if h.strip()]
|
||
if len(tokens) < 2:
|
||
return
|
||
reversed_spec = ",".join(reversed(tokens))
|
||
if reversed_spec == self._path_spec:
|
||
return
|
||
self._path_spec = reversed_spec
|
||
self._traces.clear()
|
||
self._traces_rev += 1
|
||
self._status = ""
|
||
self._session.invalidate()
|
||
|
||
# --- rendering -----------------------------------------------------------------
|
||
|
||
def render_body(self, width: int) -> list[str]:
|
||
"""Render the route, aggregates, action list, per-hop medians, and the log.
|
||
|
||
The action rows are rendered line by line (not through one Rich group) so the
|
||
highlighted row's body line is known exactly — that is what :meth:`cursor_line`
|
||
pins while the user is navigating.
|
||
"""
|
||
if self._stats_memo is not None and self._stats_memo[0] == self._traces_rev:
|
||
stats = self._stats_memo[1]
|
||
else:
|
||
stats = TraceStats.from_traces(self._target, self._traces)
|
||
self._stats_memo = (self._traces_rev, stats)
|
||
current = next((t for t in reversed(self._traces) if t.success), None)
|
||
# Both path lanes break at hop boundaries under their own value column (the
|
||
# app-wide labelled-row rule): a long walk never folds back to column zero,
|
||
# and never splits a name from its hash or a wire spec mid-hash.
|
||
lines = _lane_lines(_ROUTE_LANE, self._route_value(current, width), width)
|
||
lines.extend(render_lines(Group(Text(), self._summary(stats)), width))
|
||
lines.extend(_lane_lines(_PATH_LANE, self._path_value(current, width), width))
|
||
lines.append("")
|
||
self._cursor: Optional[int] = None
|
||
for i, key in enumerate(self._actions):
|
||
if key == "back":
|
||
lines.append("") # Back is its own group, set apart like the menus do
|
||
selected = i == self._index
|
||
text = self._action_text(key, selected)
|
||
text.no_wrap = True
|
||
text.truncate(width, overflow="ellipsis")
|
||
if selected:
|
||
self._cursor = len(lines)
|
||
lines.append(render_to_ansi(text, width))
|
||
# Set the next group apart: after the build-path group (Explore in target
|
||
# mode, Reverse in path mode — whichever closes it) and after the
|
||
# trace-settings group.
|
||
if key in ("explore", "reverse", "samples"):
|
||
lines.append("")
|
||
tail = self._tail_lines(stats, current, width)
|
||
if tail:
|
||
# The results scroll in a window beneath the pinned controls (see
|
||
# ListWindow): the route and actions never leave the screen, however
|
||
# long a sampling session's log grows.
|
||
win = max(3, self._scroll_viewport - len(lines))
|
||
top, count = self._tail_window.fit(len(tail), win)
|
||
if top > 0:
|
||
lines.append(render_to_ansi(ListWindow.marker(top, "above"), width))
|
||
lines.extend(tail[top : top + count])
|
||
below = len(tail) - top - count
|
||
if below > 0:
|
||
lines.append(render_to_ansi(ListWindow.marker(below, "below"), width))
|
||
self._scroll_total = max(1, len(lines))
|
||
return lines
|
||
|
||
def _tail_lines(
|
||
self, stats: TraceStats, current: Optional[TraceResult], width: int
|
||
) -> list[str]:
|
||
"""The windowed results block: per-hop medians, then the trace log.
|
||
|
||
Memoized per traces revision while idle — the block re-renders every stored
|
||
trace, and between completions nothing in it moves. A *running* trace skips
|
||
the memo outright: its log row carries the live spinner glyph.
|
||
"""
|
||
key = (self._traces_rev, self._status, width)
|
||
if (
|
||
not self._running
|
||
and self._tail_memo is not None
|
||
and self._tail_memo[0] == key
|
||
):
|
||
return self._tail_memo[1]
|
||
tail: list[RenderableType] = []
|
||
if stats.hop_snrs:
|
||
hash_bytes = current.path_hash_bytes if current is not None else None
|
||
tail += [Text(), Text("Per-hop medians", style="accent")]
|
||
tail.append(self._hops_table(stats, hash_bytes))
|
||
if self._running or self._status or self._traces:
|
||
tail += [Text(), Text("Traces", style="accent")]
|
||
tail.append(self._trace_log())
|
||
lines = render_lines(Group(*tail), width) if tail else []
|
||
if not self._running:
|
||
self._tail_memo = (key, lines)
|
||
return lines
|
||
|
||
def cursor_line(self) -> Optional[int]:
|
||
"""The highlighted action row while ↑/↓ are in use; free scrolling otherwise.
|
||
|
||
Returning ``None`` between navigations matters: the frame force-keeps a
|
||
cursor line visible, which would otherwise stop PgDn ever scrolling the
|
||
action list off screen to read a long trace log.
|
||
"""
|
||
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 == "compose":
|
||
_icon(text, "✎", "brand")
|
||
text.append("Compose path")
|
||
elif key == "reverse":
|
||
_icon(text, "⇄", "accent")
|
||
if self._effective_spec()[0]:
|
||
text.append("Reverse path — trace it the other way")
|
||
else:
|
||
text.append("Reverse path — compose a path first", style="muted")
|
||
elif key == "explore":
|
||
_icon(text, "⚡", "warn")
|
||
text.append("Explore paths")
|
||
elif key == "width":
|
||
w = self._width_bytes()
|
||
_icon(text, "⚙", "accent")
|
||
text.append(f"Path width — {w} byte{'s' if w != 1 else ''} per hop")
|
||
elif key == "samples":
|
||
n = self._sample_count()
|
||
_icon(text, "#", "accent")
|
||
text.append(f"Sample count — {n} trace{'s' if n != 1 else ''}")
|
||
elif key == "trace":
|
||
_icon(text, "▶", "ok")
|
||
if self._mode == "path" and not self._effective_spec()[0]:
|
||
text.append("Trace — compose a path first", style="muted")
|
||
else:
|
||
n = self._sample_count()
|
||
if n > 1:
|
||
text.append(f"Trace — {n} paced transmissions")
|
||
else:
|
||
text.append("Trace — one transmission")
|
||
else:
|
||
text.append("Back")
|
||
if selected:
|
||
text.style = "cursor"
|
||
return text
|
||
|
||
def _route_value(self, current: Optional[TraceResult], width: int) -> list[Text]:
|
||
"""The route lane's value lines: the path, hop-wrapped, then its provenance.
|
||
|
||
The route itself is live once a reply has landed, else the plan the next Trace
|
||
walks, else the last stored walk, else a muted note. Whichever it is, it breaks
|
||
at hop boundaries under the lane's own column — a name never parts from its
|
||
hash, and a boomerang folds at its turn — while the provenance (where an auto
|
||
plan came from, when a previous walk ran) takes the line below rather than
|
||
crowding the route's right edge.
|
||
|
||
Both ends go bare (``bare_self``): every walk on this screen leaves us and comes
|
||
back to us, so naming ourselves twice would only push the hops that *are* news
|
||
out of the lane. Named hops go bare of their hash too: this lane answers *which
|
||
nodes*, and the hex it would repeat after every name is already spelled out
|
||
verbatim, at the width it goes on the air, in the ``path`` lane below.
|
||
|
||
Args:
|
||
current: The newest successful trace of this session, if any.
|
||
width: The full body width; the lines fit it, indent included.
|
||
|
||
Returns:
|
||
The value's lines — the first bare (the label rides it), continuations
|
||
already carrying the lane's indent.
|
||
"""
|
||
indent = len(_ROUTE_LANE)
|
||
if current is not None:
|
||
route = _route_path(
|
||
current, self._device_label, self._resolve, self._device_hash,
|
||
bare_self=True, show_hash=False,
|
||
)
|
||
return route.wrapped(width, indent=indent)
|
||
planned = self._planned_route()
|
||
if planned is not None:
|
||
lines = planned.wrapped(width, indent=indent)
|
||
if self._effective_spec()[1] and self._auto_source:
|
||
lines.append(_note(f"(auto · {self._auto_source})", indent))
|
||
return lines
|
||
if self._previous is not None:
|
||
route = _route_path(
|
||
self._previous, self._device_label, self._resolve, self._device_hash,
|
||
bare_self=True, show_hash=False,
|
||
)
|
||
lines = route.wrapped(width, indent=indent)
|
||
stamp = self._previous.timestamp.astimezone().strftime("%b %d %H:%M")
|
||
lines.append(_note(f"(previous · {stamp})", indent))
|
||
return lines
|
||
if self._mode == "path":
|
||
return [Text("none — compose a path to walk", style="muted")]
|
||
return [Text("unknown — press Enter to trace", style="muted")]
|
||
|
||
def _effective_spec(self) -> tuple[str, bool]:
|
||
"""The wire spec the next Trace walks, and whether auto resolution supplied it.
|
||
|
||
A composed/adopted spec wins verbatim; with none, the session's auto
|
||
resolver says what it would force right now (the device's learned route or
|
||
the stored history in target mode, the last successful stored walk in path
|
||
mode) — the same call :func:`_open_session`'s ``trace_once`` makes, so the
|
||
route on screen is the route on the air.
|
||
"""
|
||
if self._path_spec:
|
||
return self._path_spec, False
|
||
spec = self._auto_spec()
|
||
return spec, bool(spec)
|
||
|
||
def _spec_tokens(self) -> list[str]:
|
||
"""The effective spec's hop hashes in walk order (empty = path-less trace)."""
|
||
spec, _ = self._effective_spec()
|
||
return [t.strip() for t in spec.split(",") if t.strip()]
|
||
|
||
def _confirm_ineligible(self, edge: tuple[str, str]) -> None:
|
||
"""Float the amber not-a-trail confirm before walking a record-ineligible path.
|
||
|
||
The walk about to fly crosses ``edge`` twice in the same direction, so it
|
||
isn't a trail and the trophy case will ignore whatever it scores (the
|
||
no-cheat rule — see :func:`~meshterm.services.records.first_repeated_edge`).
|
||
Walking it is still perfectly fine, so the dialog only makes the
|
||
disqualification explicit: Cancel backs out, Trace — on the right and the
|
||
default, like every committing verb — transmits anyway.
|
||
"""
|
||
if self._dialog_open or self._running:
|
||
return
|
||
self._dialog_open = True
|
||
|
||
async def run() -> None:
|
||
try:
|
||
prompt = Text("This walk crosses ", style="warn")
|
||
prompt.append_text(
|
||
# The hashes read at the width the screen addresses hops by, like
|
||
# every other node this session shows.
|
||
_link_text(
|
||
edge[0], edge[1], self._device_label, self._resolve,
|
||
self._width_bytes(), self._device_hash,
|
||
)
|
||
)
|
||
prompt.append(" twice in the same direction, ", style="warn")
|
||
prompt.append(
|
||
"so it isn't a trail — records will ignore it. Trace anyway?",
|
||
style="warn",
|
||
)
|
||
choice = await self._session.button_dialog(
|
||
prompt,
|
||
[("Cancel", None), ("Trace", "trace")],
|
||
title="Not eligible for records",
|
||
default=1,
|
||
footer_hint="←→ choose · Enter select · Esc cancel",
|
||
border_style="warn",
|
||
)
|
||
finally:
|
||
self._dialog_open = False
|
||
self._session.invalidate()
|
||
if choice == "trace":
|
||
self.start_trace()
|
||
|
||
asyncio.ensure_future(run())
|
||
|
||
def _planned_route(self) -> Optional[PathLine]:
|
||
"""The route the next Trace walks as a preview, or ``None`` without one.
|
||
|
||
Renders the literal wire spec through THE path widget — the whole walk, since
|
||
the trace protocol has no separate return-path field. In target mode the spec
|
||
is the symmetric boomerang (outbound hops, the target, those hops mirrored):
|
||
its second half is dimmed via the widget's ``dim_from``, reading as "this part
|
||
isn't yours to compose" — and, when the line wraps, giving the fold its natural
|
||
seam (the turn). A path walk's spec is the whole route (hand-composed, or the
|
||
last stored walk), so every hop renders in full colour and only the automatic
|
||
landing back on us stays faint. Our own endpoints go bare, as in
|
||
:meth:`_route_value` — the arrow alone — and so does every hop's hash: the lane
|
||
names nodes, and the spec below it is the hex, verbatim.
|
||
"""
|
||
spec, _ = self._effective_spec()
|
||
tokens = [h.strip() for h in spec.split(",") if h.strip()]
|
||
if not tokens:
|
||
return None
|
||
if self._mode == "target" and len(tokens) % 2 and tokens == tokens[::-1]:
|
||
mid = len(tokens) // 2 # outbound hops + target = first half, inclusive
|
||
outbound, return_leg = tokens[: mid + 1], tokens[mid + 1 :]
|
||
else:
|
||
outbound, return_leg = tokens, []
|
||
return path_line(
|
||
[None, *outbound, *return_leg, None],
|
||
self._resolve,
|
||
prefix_bytes=8, # spec tokens are the addressed slices: light them whole
|
||
self_name=self._device_label,
|
||
show_hash=False,
|
||
dim_from=1 + len(outbound),
|
||
bare_self=True,
|
||
)
|
||
|
||
def _displayed_hop_count(self, current: Optional[TraceResult]) -> Optional[int]:
|
||
"""How many nodes the displayed route passes through, endpoints excluded.
|
||
|
||
Follows the route line's precedence — the live route, else the planned spec,
|
||
else the stored previous trace — counting exactly the items drawn between the
|
||
two ``us`` endpoints (a walked trace's final hash-less hop *is* us, so it
|
||
doesn't count).
|
||
"""
|
||
if current is not None:
|
||
return sum(1 for h in current.hops if h.node)
|
||
spec, _ = self._effective_spec()
|
||
tokens = [h for h in spec.split(",") if h.strip()]
|
||
if tokens:
|
||
return len(tokens)
|
||
if self._previous is not None and self._previous.hops:
|
||
return sum(1 for h in self._previous.hops if h.node)
|
||
return None
|
||
|
||
def _summary(self, stats: TraceStats) -> Text:
|
||
"""The session's aggregate lanes, label-aligned (the path lane hangs apart)."""
|
||
snr = stats.median_min_snr
|
||
snr_text = (
|
||
Text(f"{snr:+.1f} dB", style=snr_style(snr)) if snr is not None else Text("—")
|
||
)
|
||
rtt = f"{stats.median_rtt_ms:.0f} ms" if stats.median_rtt_ms is not None else "—"
|
||
rate = f"{stats.success_rate:.0%} ({stats.successes}/{stats.samples})"
|
||
return Text.assemble(
|
||
("success rate ", "muted"), (rate if stats.samples else "—", ""), ("\n", ""),
|
||
("median min SNR ", "muted"), snr_text, ("\n", ""),
|
||
("median RTT ", "muted"), (rtt, ""),
|
||
)
|
||
|
||
def _path_value(self, current: Optional[TraceResult], width: int) -> list[Text]:
|
||
"""The ``path`` lane's value lines: the wire spec (or its note) + hop count.
|
||
|
||
A pinned spec is the verbatim string the radio is handed, drawn through THE
|
||
path widget (:func:`_spec_line`) so it breaks after a comma instead of
|
||
splitting a hash in half; the auto/none states stay prose. The hop count
|
||
trails the last line, dropping to a line of its own only when it doesn't fit.
|
||
|
||
Args:
|
||
current: The newest successful trace of this session, if any — its walked
|
||
route is what the count describes once one has landed.
|
||
width: The full body width; the lines fit it, indent included.
|
||
|
||
Returns:
|
||
The value's lines, indented like :meth:`_route_value`'s.
|
||
"""
|
||
indent = len(_PATH_LANE)
|
||
if self._path_spec:
|
||
lines = _spec_line(self._path_spec).wrapped(width, indent=indent)
|
||
elif self._effective_spec()[1] and self._auto_source:
|
||
lines = [Text(f"auto · {self._auto_source}", style="muted")]
|
||
elif self._mode == "path":
|
||
lines = [Text("none — compose a path first", style="muted")]
|
||
else:
|
||
lines = [Text("auto — path-less (unknown target)", style="muted")]
|
||
hops = self._displayed_hop_count(current)
|
||
if hops is not None:
|
||
count = f"{hops} hop{'s' if hops != 1 else ''}"
|
||
used = lines[-1].cell_len + (indent if len(lines) == 1 else 0)
|
||
if used + len(count) + 4 <= width:
|
||
lines[-1].append(f" · {count}", style="muted")
|
||
else:
|
||
lines.append(_note(count, indent, style="muted"))
|
||
return lines
|
||
|
||
def _hops_table(self, stats: TraceStats, hash_bytes: Optional[int]) -> Table:
|
||
"""The per-hop median SNRs with quality bars, in path order."""
|
||
table = Table(box=None, padding=(0, 1, 0, 0), expand=False, show_header=False)
|
||
table.add_column(justify="right", style="muted") # hop index
|
||
table.add_column() # link
|
||
table.add_column(justify="right") # median snr
|
||
table.add_column() # bar
|
||
for agg in stats.hop_snrs:
|
||
table.add_row(
|
||
str(agg.index),
|
||
_link_text(
|
||
agg.origin, agg.destination, self._device_label, self._resolve,
|
||
hash_bytes, self._device_hash,
|
||
),
|
||
Text(f"{agg.median_snr:+.1f} dB", style=snr_style(agg.median_snr)),
|
||
snr_bar(agg.median_snr),
|
||
)
|
||
return table
|
||
|
||
def _trace_log(self) -> RenderableType:
|
||
"""The individual traces, newest first, with the in-flight spinner on top."""
|
||
rows: list[RenderableType] = []
|
||
if self._running:
|
||
spin = self._spinner.text()
|
||
if self._progress is not None and self._progress[1] > 1:
|
||
spin.append(f" tracing {self._progress[0]}/{self._progress[1]}…",
|
||
style="muted")
|
||
else:
|
||
spin.append(" tracing…", style="muted")
|
||
rows.append(spin)
|
||
elif self._status:
|
||
rows.append(Text(self._status, style="err"))
|
||
for number, trace in zip(range(len(self._traces), 0, -1), reversed(self._traces)):
|
||
rows.append(self._trace_row(number, trace))
|
||
return Group(*rows)
|
||
|
||
def _trace_row(self, number: int, trace: TraceResult) -> Text:
|
||
"""One log line: number, local time, outcome, hop count, bottleneck SNR, RTT."""
|
||
stamp = trace.timestamp.astimezone().strftime("%H:%M:%S")
|
||
row = Text.assemble((f"#{number:<3}", "muted"), (f"{stamp} ", "muted"))
|
||
if not trace.success:
|
||
row.append("✗ no reply", style="err")
|
||
return row
|
||
row.append("✓ ", style="ok")
|
||
row.append(f"{trace.hop_count} hop{'s' if trace.hop_count != 1 else ''}", style="")
|
||
if trace.min_snr is not None:
|
||
row.append(" min ", style="muted")
|
||
row.append(f"{trace.min_snr:+.1f} dB", style=snr_style(trace.min_snr))
|
||
if trace.round_trip_ms is not None:
|
||
row.append(f" {trace.round_trip_ms:.0f} ms", style="muted")
|
||
return row
|
||
|
||
|
||
def _collapse_trace_width(mode: int) -> int:
|
||
"""Collapse a routing hash mode to the widest trace-representable hop width.
|
||
|
||
Routing widths are ``mode + 1`` bytes, but a trace's flags can only encode 1, 2, 4,
|
||
or 8 (see :func:`~meshterm.services.trace_runner.path_hash_flags`); the firmware
|
||
matches by prefix, so collapsing down addresses the same nodes.
|
||
|
||
Args:
|
||
mode: The path-hash mode (``size - 1``); negative means unknown.
|
||
|
||
Returns:
|
||
The per-hop width in bytes (1, 2, 4, or 8).
|
||
"""
|
||
size = max(mode + 1, 1)
|
||
return max(s for s in (1, 2, 4, 8) if s <= size)
|
||
|
||
|
||
def _previous_outbound(
|
||
previous: Optional[TraceResult], target_hash: str
|
||
) -> Optional[tuple[str, ...]]:
|
||
"""Extract the outbound repeaters from the last successful walk to a target.
|
||
|
||
A target-mode trace walks the symmetric boomerang, so its stored hop hashes
|
||
(the final hash-less hop is us) read ``[out…, target, out reversed…]`` — an
|
||
odd-length palindrome whose middle entry is the target. When the stored walk
|
||
has that shape, its first half is a route the mesh has already proven, ready
|
||
to force again.
|
||
|
||
Args:
|
||
previous: The most recent successful stored trace, if any.
|
||
target_hash: The target's full hex hash, to confirm the walk really
|
||
turned at this target (hop hashes are prefixes of it).
|
||
|
||
Returns:
|
||
The outbound repeater hashes in order from us outward (empty = the
|
||
target answered directly), or ``None`` when there is no stored walk or
|
||
it isn't a recognizable boomerang.
|
||
"""
|
||
if previous is None or not previous.success:
|
||
return None
|
||
tokens = [h.node.lower() for h in previous.hops if h.node]
|
||
if not tokens or len(tokens) % 2 == 0 or tokens != tokens[::-1]:
|
||
return None
|
||
mid = len(tokens) // 2
|
||
if not target_hash.lower().startswith(tokens[mid]):
|
||
return None
|
||
return tuple(tokens[:mid])
|
||
|
||
|
||
def _previous_walk(previous: Optional[TraceResult]) -> Optional[tuple[str, ...]]:
|
||
"""Extract the whole walked route from the last successful stored path walk.
|
||
|
||
A path walk has no destination to route to, but its stored spec is a route the
|
||
mesh has already carried end to end — so the previous walk the screen opens
|
||
showing is also a path Trace can immediately walk again. The hop hashes come
|
||
back verbatim (the final hash-less hop is us and drops out): they were proven
|
||
at the width they were transmitted, so no re-rendering is applied.
|
||
|
||
Args:
|
||
previous: The most recent stored path walk, if any.
|
||
|
||
Returns:
|
||
The walked hop hashes in transmit order, or ``None`` when there is no
|
||
stored walk, it failed, or it recorded no addressable hops.
|
||
"""
|
||
if previous is None or not previous.success:
|
||
return None
|
||
tokens = tuple(h.node.lower() for h in previous.hops if h.node)
|
||
return tokens or None
|
||
|
||
|
||
def _best_observed(topo: Any, target_hash: str) -> Optional[tuple[tuple[str, ...], str]]:
|
||
"""The strongest evidence-backed outbound route to ``target_hash``, if the data has one.
|
||
|
||
The observed-topology counterpart to the firmware's learned route: when the device
|
||
knows no route to a contact — the common case, since repeater contacts flood — the
|
||
recorder's own evidence (traces, overheard relay chains, fetched neighbour tables)
|
||
may still spell out a route that has demonstrably carried traffic. Delegates the
|
||
ranking to :meth:`~meshterm.services.topology.MeshTopology.suggested` and returns the
|
||
winner's canonical outbound hops with a short provenance line
|
||
(``best observed · 3× · −7 dB``) for the route line and summary, or ``None`` when
|
||
nothing beats a direct shot.
|
||
|
||
Args:
|
||
topo: The freshly built :class:`~meshterm.services.topology.MeshTopology`.
|
||
target_hash: The target's hex hash (any width ≥ 1 byte).
|
||
|
||
Returns:
|
||
``(outbound_hops, provenance)`` for the suggested route, or ``None``.
|
||
"""
|
||
scenario = topo.suggested(topo.canonical(target_hash) or target_hash[:12])
|
||
if scenario is None:
|
||
return None
|
||
source = f"best observed · {scenario.samples}×"
|
||
if scenario.weakest_snr is not None:
|
||
source += f" · {scenario.weakest_snr:+.0f} dB"
|
||
return scenario.hops, source
|
||
|
||
|
||
def _scenario_path(
|
||
scenario: Any, topo: Any, target_id: str, *, device_label: str, width_bytes: int,
|
||
width: Optional[int] = None,
|
||
) -> Text:
|
||
"""A scenario's pathline: us, the candidate hops, and the target — one line.
|
||
|
||
Full boomerang endpoints included (not just the intermediate hops a
|
||
:class:`~meshterm.services.topology.PathScenario` stores), so the row reads as
|
||
the whole route rather than a fragment the reader has to mentally close.
|
||
Renders through the shared path widget, so every hop wears its own key hue (an
|
||
unnamed hop its prefix-lit hash) instead of the old single-colour smear — and, as
|
||
in the route lane above it, our own end goes bare: every candidate starts from us,
|
||
so the word would be the same on every row and the cells are the candidates'.
|
||
|
||
A ``width`` budget *cuts* the line rather than middle-eliding it
|
||
(:func:`~meshterm.ui.pathline.cut_to`): the ``⋯`` rescue exists to save a route's two
|
||
endpoints, and here both endpoints are the same two on every row by construction — our
|
||
own ``★`` and the one target the whole screen is about — so it would spend cells on
|
||
what the reader already knows and take them from the candidates' *front*, the only part
|
||
that differs. Cutting also makes every row read the way the highlighted one does at
|
||
shift zero, so walking the cursor down the list no longer rewrites the row under it
|
||
(JP, 2026-08-10). ``None`` returns the full line, which is what the highlighted row
|
||
slides under ``←→``.
|
||
"""
|
||
route = path_line(
|
||
[None, *scenario.hops, target_id],
|
||
topo.display_name,
|
||
prefix_bytes=width_bytes,
|
||
self_name=device_label,
|
||
bare_self=True,
|
||
)
|
||
return route.text() if width is None else cut_to(route.text(), width)
|
||
|
||
|
||
def _scenario_detail(scenario: Any) -> Text:
|
||
"""The line hanging under a scenario's pathline: its length, provenance and evidence.
|
||
|
||
The hop count leads (:func:`~meshterm.ui.pathline.hops_atom`) — the figure the line
|
||
above encodes but never states, and the first thing one candidate route is weighed
|
||
against another on. It also absorbs the *direct* shot's provenance tag, which was
|
||
that same word for the same reason (no repeaters at all); the device route keeps its
|
||
own, since a firmware-learned route is a claim about where the hops came from rather
|
||
than about how many there are. Observed candidates *are* their hop sequence and carry
|
||
no tag. Then the bottleneck SNR and sample count a trace would expect to measure, or
|
||
``unobserved`` when the evidence graph has nothing to say about it yet.
|
||
"""
|
||
atoms: list[Text] = [hops_atom(len(scenario.hops))]
|
||
if scenario.source == "device":
|
||
atoms.append(Text(scenario.label, style="accent"))
|
||
if scenario.weakest_snr is not None:
|
||
snr = Text("weakest ", style="muted")
|
||
snr.append(f"{scenario.weakest_snr:+.1f} dB", style=snr_style(scenario.weakest_snr))
|
||
atoms.append(snr)
|
||
if scenario.samples:
|
||
atoms.append(Text(f"{scenario.samples}×", style="muted"))
|
||
elif scenario.score == 0:
|
||
atoms.append(Text("unobserved", style="faint"))
|
||
detail = Text()
|
||
for i, atom in enumerate(atoms):
|
||
if i:
|
||
detail.append(" · ", style="muted")
|
||
detail.append_text(atom)
|
||
return detail
|
||
|
||
|
||
async def open_trace(ctx: "AppContext", target: str, *, initial_spec: str = "") -> int:
|
||
"""Open the live *Trace target* screen for ``target`` and run it until dismissed.
|
||
|
||
The symmetric feature: can I reach this node? Routes turn at the target and come
|
||
home over the mirrored hops; *Explore paths* ranks candidate outbound legs from
|
||
the observed evidence.
|
||
|
||
Args:
|
||
ctx: The shared application context (must be running the interactive TUI surface).
|
||
target: The trace destination (contact name or key prefix).
|
||
initial_spec: A forced path to open armed on (the full symmetric boomerang,
|
||
comma-separated hex hops), instead of idle on the auto-resolved route — how
|
||
the Node detail screen hands over its suggested best path so the trace opens
|
||
ready to walk it. Empty (the default) opens on the auto route (device-learned,
|
||
best-observed, last trace, or direct — see :func:`_open_session`).
|
||
|
||
Returns:
|
||
The number of traces run while the screen was open.
|
||
|
||
Raises:
|
||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||
"""
|
||
return await _open_session(ctx, target, initial_spec=initial_spec)
|
||
|
||
|
||
async def open_trace_path(ctx: "AppContext", spec: str = "") -> int:
|
||
"""Open the live *Trace path* screen and run it until dismissed.
|
||
|
||
The hand-routed feature: how far can a route I build carry? There is no target —
|
||
the whole walk is composed hop by hop and only has to end within our earshot — so
|
||
there is no target picker, no device routing, and no scenario explorer. Traces
|
||
record under :data:`~meshterm.core.models.PATH_TRACE_TARGET`.
|
||
|
||
Args:
|
||
ctx: The shared application context (must be running the interactive TUI surface).
|
||
spec: A forced path to open armed on (comma-separated hex hops), instead of idle
|
||
on the last stored walk — how the trophy case's *Trace this path* reopens a
|
||
record's route. Empty (the default) opens on history.
|
||
|
||
Returns:
|
||
The number of traces run while the screen was open.
|
||
|
||
Raises:
|
||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||
"""
|
||
return await _open_session(ctx, None, initial_spec=spec)
|
||
|
||
|
||
async def _open_session(
|
||
ctx: "AppContext", target: Optional[str], *, initial_spec: str = ""
|
||
) -> int:
|
||
"""Wire and run one live trace session (both features share this plumbing).
|
||
|
||
Wires the screen to the radio, the database, and the observed-topology services:
|
||
each trace opens its own ``runs`` row and is recorded under it (same shape a
|
||
scripted ``meshterm trace`` writes); the composer and scenario flows build a fresh
|
||
:class:`~meshterm.services.topology.MeshTopology` from stored evidence on each open,
|
||
so suggestions always reflect the latest received traffic. Nothing transmits until
|
||
the user asks — the screen opens idle, its route line seeded from the most recent
|
||
stored trace.
|
||
|
||
Args:
|
||
ctx: The shared application context (must be running the interactive TUI surface).
|
||
target: The trace destination (contact name or key prefix), or ``None`` for a
|
||
target-less path walk.
|
||
initial_spec: A forced path (comma-separated hex hops) to open the path screen
|
||
armed on, instead of idle on history — the trophy case's *Trace this path*
|
||
hand-off. Ignored in target mode (only path walks arm on a bare spec).
|
||
|
||
Returns:
|
||
The number of traces run while the screen was open.
|
||
|
||
Raises:
|
||
RuntimeError: If called outside the interactive menu (no full-screen session).
|
||
"""
|
||
from ..core.connection import DeviceAuthenticationError, DeviceCommandError
|
||
from ..core.models import (
|
||
LOCAL_DEVICE_LABEL,
|
||
Contact,
|
||
LoginResult,
|
||
NeighbourInfo,
|
||
)
|
||
from ..services.path_probe import ProbeCandidate, ProbeOutcome, probe_paths
|
||
from ..services.topology import MeshTopology, _is_hex, build_topology, collapse_width
|
||
from .path_composer import FetchNeighbours, PathComposerScreen
|
||
from .surface import TuiUi
|
||
from .tui import CANCEL, Choice, SelectScreen, Separator
|
||
|
||
if not isinstance(ctx.ui, TuiUi): # pragma: no cover - guarded by the menu-only caller
|
||
raise RuntimeError("the live trace screen is only available in the menu")
|
||
session = ctx.ui.session
|
||
|
||
mode = "target" if target is not None else "path"
|
||
record_target = target if target is not None else PATH_TRACE_TARGET
|
||
|
||
device = await ctx.device()
|
||
# Contacts and self-info come from the session cache (see DeviceState): the contacts
|
||
# table is a slow round-trip on a busy node, and this screen is reached often. The live
|
||
# ``device`` is still held for the traces this screen actually runs.
|
||
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 LOCAL_DEVICE_LABEL)
|
||
device_hash = str(self_info.get("public_key") or "") or None
|
||
|
||
# The per-hop width composed/scenario specs are emitted at: the region's routing
|
||
# width, collapsed to what a trace can encode. Unknown (old firmware) → 1 byte, the
|
||
# protocol default and what all stored evidence uses anyway. The user can override
|
||
# it for the session through the screen's *Path width* action (see pick_width).
|
||
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
|
||
device_width = width_bytes # remembered so the width dialog can mark the default
|
||
|
||
# How many traces one Trace commit runs. Session state like the width; the
|
||
# screen's *Sample count* action changes it (see pick_samples).
|
||
sample_count = 1
|
||
|
||
# The target as an addressable hash: a known contact's full key, or the typed hex
|
||
# prefix itself. A non-hex unknown target can still be traced device-routed, but
|
||
# composing/exploring needs a destination hash to pin the path on. A path walk has
|
||
# no target at all — every target_* stays None and the composer runs hand-routed.
|
||
target_contact: Optional[Contact] = None
|
||
target_hash: Optional[str] = None
|
||
target_label: Optional[str] = None
|
||
if target is not None:
|
||
needle = target.casefold()
|
||
target_contact = next(
|
||
(
|
||
c
|
||
for c in contacts
|
||
if c.name.casefold() == needle
|
||
or ((c.public_key or "").lower().startswith(target.lower()))
|
||
),
|
||
None,
|
||
)
|
||
raw_hash = (
|
||
(target_contact.public_key or target_contact.key_prefix)
|
||
if target_contact
|
||
else target
|
||
).lower().removeprefix("0x")
|
||
target_hash = raw_hash if _is_hex(raw_hash) else None
|
||
target_label = target_contact.name if target_contact else target
|
||
|
||
def fresh_topology() -> MeshTopology:
|
||
"""Build the evidence graph from everything currently stored + on the device."""
|
||
return 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(),
|
||
)
|
||
|
||
previous = ctx.repo.latest_trace(record_target)
|
||
|
||
# What an *auto* trace (no composed path) forces on the air, and where that
|
||
# route came from. A trace only replies when its destination is the path's
|
||
# final outbound hop and nothing reflects it home, so auto must always spell
|
||
# out a full boomerang — but the device rarely has one to offer: firmware
|
||
# only learns a contact's ``out_path`` from two-way addressed traffic
|
||
# (verified on hardware: every repeater contact reported ``out_path_len``
|
||
# -1, flood), so a device-routed trace to anything further than a direct
|
||
# neighbour would go out with no repeaters and die. Precedence: the device's
|
||
# learned route when it genuinely has one, else the outbound leg of the last
|
||
# successful stored walk (the route the screen shows), else the bare
|
||
# destination — a direct attempt, honest about being one.
|
||
device_route: Optional[tuple[str, ...]] = None
|
||
if target_contact is not None and target_contact.route_hops is not None:
|
||
# Canonicalizing hop hashes needs only the contact index, so an *empty*
|
||
# graph does it — not the full evidence build (stored traces, packet paths,
|
||
# neighbour tables) fresh_topology() runs, which this branch would then
|
||
# discard unread.
|
||
ident = MeshTopology(device_hash or "local", contacts)
|
||
device_route = tuple(ident.canonical(h) or h for h in target_contact.route_hops)
|
||
auto_hops: Optional[tuple[str, ...]] = None
|
||
auto_source = ""
|
||
if target_hash is not None:
|
||
if device_route is not None:
|
||
auto_hops, auto_source = device_route, "device route"
|
||
elif (observed := _best_observed(fresh_topology(), target_hash)) is not None:
|
||
# No firmware route, but the recorder's own evidence spells one out — the
|
||
# strongest observed path (sample counts, SNR behind the ranking). This is
|
||
# what makes Trace target *suggest* a route from the data rather than
|
||
# defaulting to a doomed path-less flood past the first neighbour.
|
||
auto_hops, auto_source = observed
|
||
else:
|
||
auto_hops = _previous_outbound(previous, target_hash)
|
||
if auto_hops is not None and previous is not None:
|
||
stamp = previous.timestamp.astimezone().strftime("%b %d %H:%M")
|
||
auto_source = f"last trace · {stamp}"
|
||
else:
|
||
auto_hops, auto_source = (), "direct — no known route"
|
||
elif mode == "path":
|
||
auto_hops = _previous_walk(previous)
|
||
if auto_hops is not None and previous is not None:
|
||
stamp = previous.timestamp.astimezone().strftime("%b %d %H:%M")
|
||
auto_source = f"last walk · {stamp}"
|
||
|
||
def auto_spec() -> str:
|
||
"""The spec auto forces at the session's current width (``""`` = path-less).
|
||
|
||
A path walk's auto spec is the stored hops verbatim — they were proven at
|
||
the width they were transmitted, so no re-rendering is applied to them.
|
||
"""
|
||
if auto_hops is None:
|
||
return ""
|
||
if target_hash is None:
|
||
return ",".join(auto_hops)
|
||
return render_forced_spec(auto_hops, target_hash, width_bytes)
|
||
|
||
async def unaddressable() -> None:
|
||
"""Explain why target-mode path features need a resolvable target."""
|
||
await session.message_dialog(
|
||
Text(
|
||
f"{target!r} isn't a known contact or a hex key prefix, so a forced "
|
||
"path can't end at it. Trace it device-routed, or pick a contact.",
|
||
),
|
||
title="No destination hash",
|
||
)
|
||
|
||
async def fetch_neighbours_via(repeater: Contact, repeater_id: str) -> bool:
|
||
"""Log in to a repeater, fetch its neighbour table, and persist the snapshot.
|
||
|
||
The composer's fetch action lands here: password from the admin store (or a
|
||
one-time prompt, remembered on success — the tx-optimize convention), then the
|
||
login + fetch run under a floating spinner dialog with Abort. Every outcome
|
||
closes its own ``runs`` row; a rejected login also forgets the stored password
|
||
so the next attempt asks fresh.
|
||
|
||
Args:
|
||
repeater: The repeater contact to query (carries the public key).
|
||
repeater_id: Its canonical id, the key the snapshot is stored under.
|
||
|
||
Returns:
|
||
``True`` when new neighbour links were recorded (the caller should rebuild
|
||
the topology); ``False`` on cancel, failure, or an empty table.
|
||
"""
|
||
password = ctx.admin_store.get(repeater)
|
||
if password is None:
|
||
password = await session.text(
|
||
f"Admin password for {repeater.name}",
|
||
prompt="The repeater ignores neighbour requests without an admin login.",
|
||
password=True,
|
||
)
|
||
if not password:
|
||
return False
|
||
run_id = ctx.repo.start_run(
|
||
"trace",
|
||
{"mode": "neighbours", "repeater": repeater.name},
|
||
ctx.profile_name,
|
||
)
|
||
spinner = Spinner()
|
||
dialog = TracingDialog(
|
||
f"Fetch neighbours — {repeater.name}", spinner=spinner, on_abort=lambda: None
|
||
)
|
||
dialog.show_last = False # a fetch has no streaming replies to echo
|
||
dialog.status = f"logging in to {repeater.name}…"
|
||
|
||
async def work() -> list[NeighbourInfo]:
|
||
outcome = await device.admin_login(repeater, password)
|
||
ctx.admin_store.record(repeater, password, outcome)
|
||
if outcome is LoginResult.REFUSED:
|
||
raise DeviceAuthenticationError(
|
||
f"{repeater.name!r} rejected the admin login (wrong password?). "
|
||
"The saved password was cleared; retry to enter a new one."
|
||
)
|
||
if not outcome:
|
||
raise DeviceCommandError(
|
||
f"No reply from {repeater.name} — it may be out of reach, asleep, or "
|
||
"busy. The saved password was kept; retry when it answers."
|
||
)
|
||
dialog.status = "fetching the neighbour table…"
|
||
session.invalidate()
|
||
return await device.fetch_neighbours(repeater)
|
||
|
||
task = asyncio.ensure_future(work())
|
||
dialog.on_abort = task.cancel
|
||
|
||
async def animate() -> None:
|
||
while True:
|
||
await asyncio.sleep(spinner_interval())
|
||
spinner.tick()
|
||
session.invalidate()
|
||
|
||
ticker = asyncio.ensure_future(animate())
|
||
session.push(dialog)
|
||
error: Optional[BaseException] = None
|
||
aborted = False
|
||
entries: list[NeighbourInfo] = []
|
||
try:
|
||
entries = await task
|
||
except asyncio.CancelledError:
|
||
aborted = True
|
||
except Exception as exc: # noqa: BLE001 - surface in a dialog, keep composing
|
||
error = exc
|
||
finally:
|
||
ticker.cancel()
|
||
try:
|
||
await ticker
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception: # noqa: BLE001 - a spinner hiccup must never break a fetch
|
||
pass
|
||
session.pop(dialog)
|
||
if aborted:
|
||
ctx.repo.finish_run(run_id, "error", {"error": "aborted"})
|
||
return False
|
||
if error is not None:
|
||
# The credential was already settled where the outcome was known (see ``work``
|
||
# and AdminStore.record); an exception here is only ever a message to show.
|
||
ctx.repo.finish_run(
|
||
run_id, "error", {"error": str(error) or type(error).__name__}
|
||
)
|
||
await session.message_dialog(
|
||
Text(str(error), style="err"), title="Fetch neighbours"
|
||
)
|
||
return False
|
||
ctx.repo.record_neighbours(run_id, repeater_id, entries)
|
||
ctx.repo.finish_run(
|
||
run_id, "ok", {"repeater": repeater.name, "neighbours": len(entries)}
|
||
)
|
||
if not entries:
|
||
# Verified on real firmware: an empty table is a normal answer (repeaters
|
||
# forget neighbours across reboots and relearn them from adverts).
|
||
await session.message_dialog(
|
||
Text(
|
||
f"{repeater.name} answered, but its neighbour table is empty — "
|
||
"it relearns neighbours from received adverts, so ask again later.",
|
||
style="muted",
|
||
),
|
||
title="Fetch neighbours",
|
||
)
|
||
return False
|
||
return True
|
||
|
||
async def compose(current: str) -> Optional[str]:
|
||
"""Open the hop-by-hop composer seeded with the current spec's hops.
|
||
|
||
Runs the composer in a loop: a :class:`FetchNeighbours` resolution performs the
|
||
fetch, rebuilds the topology with the new evidence, and reopens the composer
|
||
exactly where the user stood (same hops and insertion cursor, refreshed
|
||
suggestions).
|
||
"""
|
||
if mode == "target" and target_hash is None:
|
||
await unaddressable()
|
||
return None
|
||
topo = fresh_topology()
|
||
target_id = (
|
||
(topo.canonical(target_hash) or target_hash[:12])
|
||
if target_hash is not None
|
||
else None
|
||
)
|
||
# Re-seed from the current spec. A target-mode spec is the symmetric boomerang
|
||
# (hops, target, mirror): seed just the outbound hops and let the composer
|
||
# regenerate the rest. A path walk is its spec verbatim. Tokens no contact
|
||
# matches are kept as they are rather than dropped — an adopted route must
|
||
# survive a reopen even where the evidence graph is blind.
|
||
tokens = [p.strip() for p in current.split(",") if p.strip()]
|
||
ids = [topo.canonical(h) or h for h in tokens]
|
||
if mode == "path":
|
||
seed = ids
|
||
elif ids and len(ids) % 2 == 1 and ids == ids[::-1]:
|
||
seed = ids[: len(ids) // 2]
|
||
elif target_id in ids:
|
||
seed = ids[: ids.index(target_id)] # a stale hand walk: keep the outbound
|
||
else:
|
||
seed = ids
|
||
seed_cursor: Optional[int] = None # first open parks the cursor at the end
|
||
while True:
|
||
# Nodes whose neighbour table can be asked for: repeater contacts with a
|
||
# public key to log in against (our own node has nothing new to tell us).
|
||
fetchable: dict[str, Contact] = {}
|
||
for c in contacts:
|
||
if not c.is_repeater or not (c.public_key or "").strip():
|
||
continue
|
||
cid = topo.canonical(c.public_key)
|
||
if cid is not None and cid != topo.self_id:
|
||
fetchable[cid] = c
|
||
screen = PathComposerScreen(
|
||
device_label=device_label,
|
||
device_hash=device_hash,
|
||
topology=topo,
|
||
width_bytes=width_bytes,
|
||
target_id=target_id,
|
||
target_hash=target_hash,
|
||
target_label=target_label,
|
||
hops=seed,
|
||
cursor=seed_cursor,
|
||
fetch_nodes=frozenset(fetchable),
|
||
resolve=resolve, # name a hop the topology left ambiguous, as we do
|
||
)
|
||
result = await session.run_screen(screen)
|
||
if result is CANCEL:
|
||
return None
|
||
if isinstance(result, FetchNeighbours):
|
||
seed = screen.hops # resume mid-thought after the fetch
|
||
seed_cursor = screen.cursor
|
||
repeater = fetchable.get(result.node)
|
||
if repeater is not None and await fetch_neighbours_via(
|
||
repeater, result.node
|
||
):
|
||
topo = fresh_topology() # fold the new reports into suggestions
|
||
continue
|
||
return result
|
||
|
||
async def pick_width(current: str) -> Optional[str]:
|
||
"""Float the path-hash width picker and re-render the standing spec to match.
|
||
|
||
Each row previews a key with the addressed slice lit at that width, so the
|
||
choice reads as "this much of every key goes on the air". The chosen width
|
||
shapes every spec the composer/explorer emit afterwards; a standing forced
|
||
path is re-rendered immediately — hops are widened back through their
|
||
canonical hashes where known, then collapsed uniformly (a hop only ever known
|
||
narrower keeps the whole spec at what it can honour).
|
||
|
||
Returns:
|
||
The re-rendered spec, or ``None`` when cancelled, unchanged, or there is
|
||
no forced path to re-render (the width itself still sticks).
|
||
"""
|
||
nonlocal width_bytes
|
||
sample = (target_hash or device_hash or "").lower().removeprefix("0x")
|
||
items: list = []
|
||
for w in (1, 2, 4):
|
||
title = Text(f"{w} byte{'s' if w > 1 else ' '}")
|
||
if sample:
|
||
title.append(" ")
|
||
title.append_text(highlighted_hash(sample, w, width=16))
|
||
if w == device_width:
|
||
title.append(" · device default", style="muted")
|
||
items.append(Choice(title=title, value=w))
|
||
picked = await session.run_screen(
|
||
SelectScreen(
|
||
"Path width",
|
||
items,
|
||
prompt="Forced hops are addressed by this many leading key bytes.",
|
||
default=width_bytes,
|
||
footer_hint="↑↓ move · Enter set · Esc keep",
|
||
filterable=False,
|
||
wrap=False,
|
||
)
|
||
)
|
||
if picked is CANCEL or picked is None or int(picked) == width_bytes:
|
||
return None
|
||
width_bytes = int(picked)
|
||
tokens = [p.strip() for p in current.split(",") if p.strip()]
|
||
if not tokens:
|
||
return None
|
||
topo = fresh_topology()
|
||
full = [topo.canonical(t) or t for t in tokens]
|
||
width = collapse_width(*full, ceiling=width_bytes)
|
||
return ",".join(f[: width * 2] for f in full)
|
||
|
||
async def pick_samples(current: str) -> Optional[str]:
|
||
"""Float the sample-count picker: how many traces one Trace action runs.
|
||
|
||
Multi-trace runs are paced by the configured cooldown between transmissions,
|
||
so choosing 8 is a deliberate sampling session, never a burst. Always resolves
|
||
``None`` — the count is session state the screen reads back, not a spec.
|
||
"""
|
||
nonlocal sample_count
|
||
pace = ctx.settings.trace_cooldown_s
|
||
items: list = []
|
||
for n in SAMPLE_CHOICES:
|
||
title = Text(f"{n} trace{'s' if n > 1 else ' '}")
|
||
if n == 1:
|
||
title.append(" · a single transmission", style="muted")
|
||
items.append(Choice(title=title, value=n))
|
||
picked = await session.run_screen(
|
||
SelectScreen(
|
||
"Sample count",
|
||
items,
|
||
prompt=f"One Trace action runs this many traces, {pace:g} s apart.",
|
||
default=sample_count,
|
||
footer_hint="↑↓ move · Enter set · Esc keep",
|
||
filterable=False,
|
||
wrap=False,
|
||
)
|
||
)
|
||
if picked is not CANCEL and picked is not None:
|
||
sample_count = int(picked)
|
||
return None
|
||
|
||
def outcome_lanes(rank: int, outcome: ProbeOutcome) -> Text:
|
||
"""A probed candidate's fixed head: rank, verdict, bottleneck SNR, round trip.
|
||
|
||
Split out from :func:`outcome_title` so the row can *measure* the block it pins
|
||
out of the ←→ scroll (:attr:`~meshterm.ui.tui.select.Choice.hscroll_from`) rather
|
||
than guess at it. These lanes are the whole reason the list is ranked — sliding
|
||
them off to read the tail of a ten-hop spec would cost the row its identity and
|
||
gain nothing, since the columns are the part that already fits.
|
||
"""
|
||
stats = outcome.stats
|
||
text = Text(f"#{rank} ", style="muted")
|
||
if stats.successes:
|
||
text.append("✓ replied", style="ok")
|
||
else:
|
||
text.append("✗ no reply", style="err")
|
||
snr = stats.median_min_snr
|
||
if snr is not None:
|
||
text.append(" min ", style="muted")
|
||
text.append(f"{snr:+.1f} dB", style=snr_style(snr))
|
||
if stats.median_rtt_ms is not None:
|
||
text.append(f" {stats.median_rtt_ms:.0f} ms", style="muted")
|
||
text.append(" via ", style="muted")
|
||
return text
|
||
|
||
def outcome_title(lanes: Text, outcome: ProbeOutcome) -> Text:
|
||
"""One probed candidate as a ranked select row: one measured trace, not a guess."""
|
||
text = lanes.copy()
|
||
text.append(outcome.candidate.spec, style="brand")
|
||
text.append(f" ({outcome.candidate.label})", style="faint")
|
||
return text
|
||
|
||
async def run_probe(
|
||
candidates: list[ProbeCandidate],
|
||
) -> Optional[list[ProbeOutcome]]:
|
||
"""Measure every candidate — one trace each — under an abortable dialog.
|
||
|
||
One ``runs`` row spans the sweep; each trace and each candidate aggregate is
|
||
recorded under it, so an aborted probe still keeps everything it measured.
|
||
Each candidate gets exactly one transmission (probing is comparison, not
|
||
sampling); ``None`` when aborted.
|
||
"""
|
||
run_id = ctx.repo.start_run(
|
||
"trace",
|
||
{
|
||
"target": target,
|
||
"mode": "probe",
|
||
"paths": [c.spec for c in candidates],
|
||
},
|
||
ctx.profile_name,
|
||
)
|
||
spinner = Spinner()
|
||
dialog = TracingDialog(
|
||
f"Probing — {target_label}", spinner=spinner, on_abort=lambda: None
|
||
)
|
||
dialog.status = f"path 1/{len(candidates)} · one trace each"
|
||
|
||
def on_result(index: int, done: int, result: TraceResult) -> None:
|
||
dialog.status = f"path {index + 1}/{len(candidates)} · one trace each"
|
||
dialog.last = result
|
||
session.invalidate()
|
||
|
||
sweep = asyncio.ensure_future(
|
||
probe_paths(
|
||
device,
|
||
target,
|
||
candidates,
|
||
cooldown_s=ctx.settings.trace_cooldown_s,
|
||
on_result=on_result,
|
||
persist_trace=lambda t: ctx.repo.record_trace(run_id, t),
|
||
persist_candidate=lambda o: ctx.repo.record_path_candidate(
|
||
run_id,
|
||
target,
|
||
o.candidate.spec,
|
||
bottleneck_snr=o.stats.median_min_snr,
|
||
success_rate=o.stats.success_rate,
|
||
median_rtt_ms=o.stats.median_rtt_ms,
|
||
),
|
||
)
|
||
)
|
||
dialog.on_abort = sweep.cancel # the task exists only now; rewire the button
|
||
|
||
async def animate() -> None:
|
||
while True:
|
||
await asyncio.sleep(spinner_interval())
|
||
spinner.tick()
|
||
session.invalidate()
|
||
|
||
ticker = asyncio.ensure_future(animate())
|
||
session.push(dialog)
|
||
error: Optional[BaseException] = None
|
||
aborted = False
|
||
outcomes: list[ProbeOutcome] = []
|
||
try:
|
||
outcomes = await sweep
|
||
except asyncio.CancelledError:
|
||
aborted = True
|
||
except Exception as exc: # noqa: BLE001 - surface in a dialog, keep the screen
|
||
error = exc
|
||
finally:
|
||
ticker.cancel()
|
||
try:
|
||
await ticker
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception: # noqa: BLE001 - a spinner hiccup must never break a probe
|
||
pass
|
||
session.pop(dialog)
|
||
if aborted:
|
||
# Everything measured before the abort is already persisted; the run row
|
||
# just records that the sweep didn't finish.
|
||
ctx.repo.finish_run(run_id, "error", {"error": "aborted"})
|
||
return None
|
||
if error is not None:
|
||
ctx.repo.finish_run(
|
||
run_id, "error", {"error": str(error) or type(error).__name__}
|
||
)
|
||
await session.message_dialog(
|
||
Text(f"probe failed: {error}", style="err"), title="Path probe"
|
||
)
|
||
return None
|
||
best = outcomes[0] if outcomes else None
|
||
ctx.repo.finish_run(
|
||
run_id,
|
||
"ok",
|
||
{
|
||
"target": target,
|
||
"paths": len(candidates),
|
||
"best_path": best.candidate.spec if best else None,
|
||
"best_success_rate": round(best.stats.success_rate, 3) if best else None,
|
||
"best_median_min_snr": best.stats.median_min_snr if best else None,
|
||
},
|
||
)
|
||
return outcomes
|
||
|
||
async def explore(current: str) -> Optional[str]:
|
||
"""The scenario flow: browse ranked candidate routes, adopt one, or probe all."""
|
||
if target_hash is None:
|
||
await unaddressable()
|
||
return None
|
||
topo = fresh_topology()
|
||
target_id = topo.canonical(target_hash) or target_hash[:12]
|
||
scenarios = topo.scenarios(target_id, device_route=device_route)
|
||
if not scenarios:
|
||
await session.message_dialog(
|
||
Text("no observed evidence involving this target yet — run a trace or "
|
||
"let monitoring accumulate paths first.", style="muted"),
|
||
title="Explore paths",
|
||
)
|
||
return None
|
||
|
||
items: list = [section_heading("Candidate paths · from received evidence")]
|
||
for scenario in scenarios:
|
||
items.append(
|
||
Choice(
|
||
# Width-aware (see Choice.title): a long candidate is cut to the
|
||
# terminal, cracking on its own chip exactly as the highlighted row
|
||
# does at shift zero — the highlight then keeps its natural length
|
||
# and slides under ←→ instead of the row being redrawn as it lands.
|
||
title=(
|
||
lambda width, scenario=scenario: _scenario_path(
|
||
scenario, topo, target_id,
|
||
device_label=device_label, width_bytes=width_bytes,
|
||
width=width,
|
||
)
|
||
),
|
||
detail=_scenario_detail(scenario),
|
||
value=("use", scenario),
|
||
)
|
||
)
|
||
items.append(Separator(" "))
|
||
items.append(
|
||
Choice(
|
||
title=marked_label("⚡", "Probe all — trace each path once and rank", "warn"),
|
||
value=("probe", None),
|
||
)
|
||
)
|
||
items.extend(back_rows(("back", None)))
|
||
picked = await session.run_screen(
|
||
SelectScreen(
|
||
f"Explore paths — {target_label}",
|
||
items,
|
||
prompt="Choose the outbound leg — the return mirrors it.",
|
||
footer_hint="↑↓ move · Enter adopt/probe · Esc back",
|
||
wrap=False,
|
||
hscroll=True, # a long candidate row slides under ←→ instead of truncating
|
||
)
|
||
)
|
||
if picked is CANCEL or picked is None or picked[0] == "back":
|
||
return None
|
||
if picked[0] == "use":
|
||
return picked[1].spec(target_hash, width_bytes)
|
||
|
||
# Probe all: measure each distinct spec (scenarios can collapse to the same
|
||
# spec once truncated to the trace width — the radio must not walk one twice).
|
||
candidates: list[ProbeCandidate] = []
|
||
for scenario in scenarios:
|
||
spec = scenario.spec(target_hash, width_bytes)
|
||
if all(c.spec != spec for c in candidates):
|
||
candidates.append(ProbeCandidate(label=scenario.label, spec=spec))
|
||
outcomes = await run_probe(candidates)
|
||
if not outcomes:
|
||
return None
|
||
result_items: list = [
|
||
section_heading("Ranked · reliability, then bottleneck SNR")
|
||
]
|
||
for rank, outcome in enumerate(outcomes, start=1):
|
||
lanes = outcome_lanes(rank, outcome)
|
||
result_items.append(
|
||
Choice(
|
||
title=outcome_title(lanes, outcome),
|
||
value=outcome,
|
||
hscroll_from=lanes.cell_len,
|
||
)
|
||
)
|
||
result_items.append(Separator(" "))
|
||
result_items.append(Choice(title="Keep current path", value=None)) # the exit: no adoption
|
||
adopted = await session.run_screen(
|
||
SelectScreen(
|
||
f"Probe results — {target_label}",
|
||
result_items,
|
||
footer_hint="↑↓ move · Enter adopt path · Esc keep",
|
||
wrap=False,
|
||
# ←→ read the tail of a long spec; the ranking lanes in front of it stay
|
||
# pinned (each row declares their width — see outcome_lanes).
|
||
hscroll=True,
|
||
)
|
||
)
|
||
if adopted is CANCEL or adopted is None:
|
||
return None
|
||
return adopted.candidate.spec
|
||
|
||
# -- trophy case: score every walk that comes home ---------------------------------
|
||
# A trace is a walk (it starts and ends at us — a Trace path route directly, a
|
||
# Trace target boomerang just the same), so every successful reply is scored against
|
||
# the six trophy-case disciplines and offered to their boards. See services/records.
|
||
|
||
def _as_float(value) -> Optional[float]: # noqa: ANN001
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
_lat, _lon = _as_float(self_info.get("adv_lat")), _as_float(self_info.get("adv_lon"))
|
||
self_pos = (_lat, _lon) if _lat is not None and _lon is not None and (_lat or _lon) else None
|
||
|
||
def build_positions(topo: MeshTopology) -> dict[str, tuple[float, float]]:
|
||
"""Node positions by canonical id: advert history first, contacts over it."""
|
||
positions: dict[str, tuple[float, float]] = {}
|
||
for heard in ctx.repo.heard_nodes():
|
||
if heard.node and heard.lat is not None and heard.lon is not None:
|
||
positions[heard.node] = (heard.lat, heard.lon)
|
||
for contact in contacts:
|
||
cid = topo.canonical(contact.public_key or contact.key_prefix)
|
||
if cid is not None and contact.lat is not None and contact.lon is not None and (
|
||
contact.lat or contact.lon
|
||
):
|
||
positions[cid] = (contact.lat, contact.lon)
|
||
return positions
|
||
|
||
def offer_records(result: TraceResult) -> list[str]:
|
||
"""Score a walk home and offer it to every board; return the ones it placed.
|
||
|
||
Returns the placed disciplines as short ``"Title — score"`` labels for the
|
||
screen's inline note. Defensive by design — a scoring hiccup must never break
|
||
a trace — so any failure yields no records rather than propagating.
|
||
"""
|
||
from .. import __version__
|
||
from ..services.records import CATEGORY_BY_ID, walk_from_trace, walk_scores
|
||
|
||
try:
|
||
topo = fresh_topology()
|
||
derived = walk_from_trace(
|
||
result, canonical=topo.canonical,
|
||
positions=build_positions(topo), self_pos=self_pos,
|
||
)
|
||
if derived is None:
|
||
return []
|
||
spec, route, stats = derived
|
||
placed: list[str] = []
|
||
for cid, score in walk_scores(stats).items():
|
||
cat = CATEGORY_BY_ID[cid]
|
||
row_id = ctx.repo.record_discovery(
|
||
cid, width_bytes, spec, route,
|
||
score=score, stats=stats.as_dict(),
|
||
app_version=__version__, ascending=cat.ascending,
|
||
)
|
||
if row_id is not None:
|
||
placed.append(f"{cat.title} — {cat.format_score(score)}")
|
||
return placed
|
||
except Exception: # noqa: BLE001 - scoring must never break a trace
|
||
return []
|
||
|
||
async def trace_once(
|
||
path_spec: str, on_trace: Callable[[TraceResult, Sequence[str]], None]
|
||
) -> None:
|
||
"""Run one persisted trace: one transmission, its own ``runs`` row.
|
||
|
||
The screen calls this once per sample; multi-trace runs are paced there, so
|
||
the repeaters never see a burst regardless of the chosen count. A walk that
|
||
comes home is scored into the trophy case, and any records it set are handed
|
||
to the screen with the result.
|
||
"""
|
||
spec = path_spec.strip() or auto_spec()
|
||
path = trace_runner.parse_trace_path(spec, contacts) if spec else None
|
||
params: dict[str, Any] = {"target": record_target}
|
||
if path:
|
||
params["path"] = path
|
||
run_id = ctx.repo.start_run("trace", params, ctx.profile_name)
|
||
try:
|
||
result = await device.run_trace(record_target, path=path)
|
||
except BaseException as exc:
|
||
# A cancelled or failed trace still closes its run row, so no ``running``
|
||
# orphan is left behind.
|
||
ctx.repo.finish_run(run_id, "error", {"error": str(exc) or type(exc).__name__})
|
||
raise
|
||
ctx.repo.record_trace(run_id, result)
|
||
placed = offer_records(result) if result.success else []
|
||
on_trace(result, placed)
|
||
ctx.repo.finish_run(
|
||
run_id,
|
||
"ok",
|
||
{
|
||
"target": record_target,
|
||
"success": result.success,
|
||
"min_snr": result.min_snr,
|
||
"rtt_ms": result.round_trip_ms,
|
||
"records": placed or None,
|
||
},
|
||
)
|
||
|
||
screen = TraceScreen(
|
||
record_target,
|
||
mode=mode,
|
||
device_label=device_label,
|
||
device_hash=device_hash,
|
||
resolve=resolve,
|
||
session=session,
|
||
trace=trace_once,
|
||
compose_path=compose,
|
||
explore=explore if mode == "target" else None,
|
||
pick_width=pick_width,
|
||
pick_samples=pick_samples,
|
||
width_bytes=lambda: width_bytes,
|
||
sample_count=lambda: sample_count,
|
||
pace_s=ctx.settings.trace_cooldown_s,
|
||
previous=previous,
|
||
auto_spec=auto_spec,
|
||
auto_source=auto_source,
|
||
# A caller-supplied spec arms either mode: a path walk's stored route, or the Node
|
||
# detail screen's suggested best path handed to a target trace. In target mode it
|
||
# only arms when the target is addressable — a forced path must end at a real hash.
|
||
initial_spec=initial_spec if (mode == "path" or target_hash is not None) else "",
|
||
)
|
||
try:
|
||
result = await session.run_screen(screen)
|
||
finally:
|
||
screen.cancel()
|
||
if result == OPEN_TROPHY_CASE:
|
||
# The new-record dialog's hand-off: the trace screen (and every prompt over
|
||
# it) is already down, so the trophy case opens over a clean stack and Esc
|
||
# from it unwinds straight to the main menu — never back into this session.
|
||
from .records_screen import open_records
|
||
|
||
await open_records(ctx)
|
||
return screen._total_traces
|