Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2220 lines
104 KiB
Python
2220 lines
104 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""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 collections.abc import Awaitable, Callable, Sequence
|
||
from typing import TYPE_CHECKING, Any
|
||
|
||
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 icon_lane, icon_mark, marked_label, section_heading
|
||
from .pathline import PathHop, PathLine, cut_to, hops_atom, path_line
|
||
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 .widgets import NodeResolver, highlighted_hash, link_text, route_path
|
||
|
||
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)
|
||
|
||
#: The new-record dialog's *Trophy case* button value. Choosing it opens the trophy case
|
||
#: over the trace screen, which stays pushed underneath — Esc from the trophy case is one
|
||
#: pop back onto the trace that earned the record.
|
||
OPEN_TROPHY_CASE = "records"
|
||
|
||
|
||
def _trophy_case_opener(ctx: AppContext) -> Callable[[], Awaitable[None]]:
|
||
"""A zero-argument opener for the trophy case, for the new-record dialog's left button.
|
||
|
||
Imported inside the closure because the trophy case imports *this* module back (its
|
||
records offer a *Trace this path* of their own) — the two screens open each other, which
|
||
is exactly the cycle a strict stack allows and ^W exists to climb out of.
|
||
"""
|
||
|
||
async def open_it() -> None:
|
||
from .records_screen import open_records
|
||
|
||
await open_records(ctx)
|
||
|
||
return open_it
|
||
|
||
|
||
#: 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[str | None]]
|
||
|
||
#: 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 — this screen's own marks, measured once."""
|
||
return icon_lane(_ACTION_ICONS)
|
||
|
||
|
||
def _icon(text: Text, icon: str, style: str) -> None:
|
||
"""Append an action row's mark in the icon column, its trailing space included."""
|
||
text.append_text(icon_mark(icon, style, _icon_lane()))
|
||
|
||
|
||
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: float | None, 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 — Lakeside``).
|
||
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: TraceResult | None = 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: str | None,
|
||
resolve: NodeResolver,
|
||
session: Any,
|
||
trace: TraceOnce,
|
||
compose_path: PathFlow,
|
||
pick_width: PathFlow,
|
||
pick_samples: PathFlow,
|
||
width_bytes: Callable[[], int],
|
||
sample_count: Callable[[], int],
|
||
explore: PathFlow | None = None,
|
||
pace_s: float = 1.0,
|
||
previous: TraceResult | None = None,
|
||
auto_spec: Callable[[], str] = lambda: "",
|
||
auto_source: str = "",
|
||
initial_spec: str = "",
|
||
open_trophy_case: Callable[[], Awaitable[None]] | None = None,
|
||
) -> 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.
|
||
open_trophy_case: Opens the trophy case over this screen — what the new-record
|
||
dialog's left button does. ``None`` leaves the dialog with nothing but
|
||
*Close* to do, which is what a screen run outside the menu gets.
|
||
"""
|
||
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()
|
||
self._open_trophy_case = open_trophy_case
|
||
#: 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: tuple[int, TraceStats] | None = None
|
||
self._tail_memo: tuple[tuple, list[str]] | None = 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: tuple[int, int] | None = 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: float | None = None
|
||
self._worker: asyncio.Task | None = None
|
||
self._flight: TracingDialog | None = 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"]
|
||
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_running_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_running_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 opens it *over* this screen, like any other
|
||
sub-view: Esc from the trophy case is one pop back onto the trace that earned
|
||
the record, and ^W is what leaves the whole excursion at once. (It used to
|
||
resolve the whole screen instead, unwinding the trace away first and landing the
|
||
reader on the main menu — a hand-built escape from a stack that had no other way
|
||
out.) The re-entrancy guard is held across the trophy case too, so a record
|
||
landing while it is open cannot stack a second dialog behind it.
|
||
"""
|
||
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",
|
||
)
|
||
if choice == OPEN_TROPHY_CASE and self._open_trophy_case is not None:
|
||
await self._open_trophy_case()
|
||
finally:
|
||
self._dialog_open = False
|
||
self._session.invalidate()
|
||
|
||
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":
|
||
# Both ends clamp rather than wrap — the app-wide rule for a row cursor:
|
||
# a highlight that leaps end to end takes the results window with it.
|
||
self._index = max(0, self._index - 1)
|
||
self._pin_cursor = True
|
||
elif action == "down":
|
||
self._index = min(len(self._actions) - 1, self._index + 1)
|
||
self._pin_cursor = True
|
||
elif action == "pageup":
|
||
self._pin_cursor = False
|
||
self._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)
|
||
|
||
def _open_flow(
|
||
self, flow: PathFlow, seed: str | None = 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()
|
||
|
||
self._session.run_detached(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: int | None = None
|
||
for i, key in enumerate(self._actions):
|
||
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: TraceResult | None, 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) -> int | None:
|
||
"""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 ''}")
|
||
else: # trace — the row the cursor opens on
|
||
_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")
|
||
if selected:
|
||
text.style = "cursor"
|
||
return text
|
||
|
||
def _route_value(self, current: TraceResult | None, 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) -> PathLine | None:
|
||
"""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: TraceResult | None) -> int | None:
|
||
"""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: TraceResult | None, 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: int | None) -> 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"))
|
||
numbering = range(len(self._traces), 0, -1)
|
||
for number, trace in zip(numbering, reversed(self._traces), strict=True):
|
||
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: TraceResult | None, target_hash: str) -> tuple[str, ...] | None:
|
||
"""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: TraceResult | None) -> tuple[str, ...] | None:
|
||
"""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) -> tuple[tuple[str, ...], str] | None:
|
||
"""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: int | None = 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: str | None, *, 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, build_topology, collapse_width, is_path_hash
|
||
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: Contact | None = None
|
||
target_hash: str | None = None
|
||
target_label: str | None = 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_path_hash(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: tuple[str, ...] | None = 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: tuple[str, ...] | None = 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: BaseException | None = 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) -> str | None:
|
||
"""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: int | None = 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) -> str | None:
|
||
"""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,
|
||
)
|
||
)
|
||
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) -> str | None:
|
||
"""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.preferences.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,
|
||
)
|
||
)
|
||
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],
|
||
) -> list[ProbeOutcome] | None:
|
||
"""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.preferences.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: BaseException | None = 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) -> str | None:
|
||
"""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),
|
||
)
|
||
)
|
||
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",
|
||
hscroll=True, # a long candidate row slides under ←→ instead of truncating
|
||
)
|
||
)
|
||
if picked is CANCEL or picked is None: # Esc
|
||
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",
|
||
# ←→ 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) -> float | None: # 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.preferences.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 "",
|
||
open_trophy_case=_trophy_case_opener(ctx),
|
||
)
|
||
try:
|
||
await session.run_screen(screen)
|
||
finally:
|
||
screen.cancel()
|
||
return screen._total_traces
|