The same rewrite as for the core package, for the screens, the dialogs, the map, the charts, and the CLI renderers. The glossary words are used for the screen: the user (not the reader), dialog (not popup), viewport, list window, highlight, pointer, graph lane, marker, raster, result screen, and key prefix (never hash for a truncated key). The rewrite also corrects approximately 70 docstrings and comments that no longer agreed with the code: rows that no longer exist (Back rows), counts that changed, renamed constants, wrong cross-references, and behaviour that changed. Each correction follows the code; no code changed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2446 lines
116 KiB
Python
2446 lines
116 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
||
"""The live trace screens: compose a route from the observed topology, then see its replies.
|
||
|
||
This module is the interactive form 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 only a UX concept. The two menu features divide on that line:
|
||
|
||
* **Trace target** (:func:`open_trace`) answers the question *can I reach this node?*
|
||
The route is symmetric: the user composes (or explores) the outbound leg, and the
|
||
return leg is those hops in mirror order around the pinned target.
|
||
* **Trace path** (:func:`open_trace_path`) answers the question *how far can a route
|
||
that I build go?* It has no target. The user composes the full walk by hand, out and
|
||
back by any way. The only condition is that the walk ends at a node that our node can
|
||
hear.
|
||
|
||
Both features open the same screen, *armed but idle*. The screen shows the last known
|
||
route, the path is what the user makes it, and nothing transmits until the user tells
|
||
it to. An action list controls the screen: ↑/↓ select a row, and Enter runs it:
|
||
|
||
* **Compose path** opens the path composer (:mod:`~meshterm.ui.path_composer`). The user
|
||
builds the route one hop at a time. For each step, the composer suggests hops from the
|
||
links observed in *received* traffic: traces, contact routes that the firmware
|
||
learned, and the packet paths in the RX log. The strongest link comes first. The user
|
||
can also type a raw hex hash for a node that is not in the data. When the insertion
|
||
slot is after a repeater for which the user has admin credentials, the composer can
|
||
also *get the neighbour table of that repeater* over the mesh. A login is necessary,
|
||
because the firmware ignores guests. The table is evidence from a second point of
|
||
view. MeshTerm stores it and adds it immediately to the suggestions. When the user
|
||
confirms the composer, the highlight of the action list moves to Trace: the user
|
||
composes, then Enter walks the path. When the user leaves the composer with Esc, the
|
||
highlight stays where it was.
|
||
* **Reverse path** (Trace path only) reverses the hop order of the walk:
|
||
``us → a → b → c`` becomes ``us → c → b → a``. A route in target mode is a symmetric
|
||
boomerang, so a reversal changes nothing there. Radio links are rarely the same in the
|
||
two directions, so this action measures the route that the user built in the opposite
|
||
direction. It adopts the reversed spec the same as any change of path (the aggregates
|
||
of the forward run clear). Trace stays idle, because nothing starts automatically.
|
||
Thus the user reverses, then traces. Reverse again to go back. The stored history
|
||
keeps the two directions.
|
||
* **Explore paths** (Trace target only, because candidates must have a destination)
|
||
explores scenarios: ranked candidate routes to the target, directly from the topology
|
||
evidence. These are the route that the device learned, the direct attempt, and the
|
||
strongest observed alternatives. The user can adopt one directly. Or the user can
|
||
probe all of them: one measured trace for each candidate, stored as
|
||
``path_candidates`` rows and ranked by reliability first. Then MeshTerm offers the
|
||
winner for adoption. The evidence proposes, the measurement decides, and the user
|
||
makes the choice.
|
||
* **Path width** opens a small dialog, where the user selects the path-hash width for
|
||
each hop: 1, 2, or 4 bytes. A forced hop is addressed by this number of bytes from
|
||
the start of its key. The first value is the routing width of the device. When the
|
||
user changes the width, MeshTerm renders the current forced path again at the new
|
||
width. The new width also applies to each spec that the composer and the explorer
|
||
make after the change.
|
||
* **Sample count** opens a dialog, where the user selects how many traces one Trace
|
||
action runs (1, 2, 3, 5, or 8). Runs of more than one trace are *paced*: a cooldown
|
||
sleeps between the transmissions. This is because repeaters penalize (and can block)
|
||
nodes that send traffic in bursts. The tracing dialog counts the traces as the replies
|
||
arrive.
|
||
* **Trace** runs the selected number of traces. The highlight starts on this row, thus
|
||
Enter alone traces. While the traces are in progress, a floating *tracing* dialog
|
||
(spinner, progress, Abort) is over the screen. The replies go into the log behind
|
||
the dialog. Esc in the dialog cancels the run, but the screen stays open, and MeshTerm
|
||
keeps the traces that it already stored. The screen adds each trace of the session to
|
||
its medians. A walk can cross a link two times in the same direction. Before such a
|
||
walk, an amber Cancel/Trace dialog asks for confirmation, because such a walk is not
|
||
a *trail*, and the trophy case ignores it (the no-cheat rule, refer to
|
||
:mod:`~meshterm.services.records`). When a run places on the boards, a *New record*
|
||
dialog opens after the run ends. Close (the default) stays on the screen. Trophy case
|
||
opens the boards over the trace screen, so Esc from the boards goes back to the trace.
|
||
|
||
Esc leaves the screen. The screen has no Back row.
|
||
|
||
The layout, from top to bottom:
|
||
|
||
1. The walked route. This is the live route when a reply arrived. If not, it is the
|
||
route that the next Trace will walk: composed by hand, or resolved automatically
|
||
from the route that the device learned or from the stored history, with a label
|
||
that shows that source. If not, it is the most recent stored trace.
|
||
2. The robust aggregates of the run.
|
||
3. The wire spec of that route.
|
||
4. The action list.
|
||
5. The results: the median SNR for each hop, with quality bars, and the individual
|
||
traces, newest first.
|
||
|
||
All these parts are on the same page. ↑/↓ move the highlight of the action list. After
|
||
the last action, they edge-scroll into the results. PgUp/PgDn/Home/End scroll the full
|
||
screen by pages, and ←/→ slide the path of a hop where it goes past the edge.
|
||
|
||
The two path lanes draw through the only path widget (:mod:`~meshterm.ui.pathline`).
|
||
They break at hop boundaries, under their own value column. A route folds between nodes.
|
||
It never separates a name from the hash that addresses it, and it prefers the turn where
|
||
the mirrored return of a boomerang starts. The spec below the route folds after a comma,
|
||
never in the middle of a hash. The source of a route (the source of an automatic plan,
|
||
or the timestamp of a previous walk) is on the line below the route, not at its right
|
||
edge, because there it takes space from the route.
|
||
|
||
MeshTerm stores each trace the same as a scripted run: one ``runs`` row for each trace,
|
||
with the trace stored under it. Thus the stored history is the same, whichever front end
|
||
made it. Path walks are stored under :data:`~meshterm.core.models.PATH_TRACE_TARGET`.
|
||
Thus they are not in the history of the target picker, but they still give the
|
||
previous-route line of the path screen.
|
||
"""
|
||
|
||
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.text import Text
|
||
|
||
from ..core.models import PATH_TRACE_TARGET, HopAggregate, 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 (
|
||
ELIDE_HEAD,
|
||
ELIDE_TAIL,
|
||
PathHop,
|
||
PathLine,
|
||
cut_mark,
|
||
cut_to,
|
||
hops_atom,
|
||
path_line,
|
||
)
|
||
from .theme import snr_style
|
||
from .tui.render import crop_cells, render_lines, render_to_ansi
|
||
from .tui.screen import 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
|
||
|
||
#: The width of the SNR quality bar for each hop, in characters. Each character holds
|
||
#: two fill steps (refer to :func:`snr_bar`). Thus the bar has a resolution of 16 steps
|
||
#: in half the cells of a block bar with one step for each cell.
|
||
_BAR_WIDTH = 8
|
||
|
||
#: The SNR range of the bars, in dB: -15 (almost not readable) to +10 (excellent). A
|
||
#: value outside the range clamps to an end, so the bar always shows *something* for a
|
||
#: heard hop.
|
||
_BAR_SNR_MIN = -15.0
|
||
_BAR_SNR_MAX = 10.0
|
||
|
||
#: The number of cells that one ←/→ press slides the paths of the hops. All the other
|
||
#: sliding path rows in the app use the same step (the ``hscroll`` of the select list,
|
||
#: the routes of the node page).
|
||
_HSCROLL_STEP = 8
|
||
|
||
#: The sample counts that the Sample count dialog offers: how many traces one Trace
|
||
#: action runs, with pacing between the transmissions.
|
||
SAMPLE_CHOICES = (1, 2, 3, 5, 8)
|
||
|
||
#: The value of the *Trophy case* button in the new-record dialog. This button opens the
|
||
#: trophy case over the trace screen, which stays pushed below it. Thus Esc from the
|
||
#: trophy case is one pop back to the trace that got the record.
|
||
OPEN_TROPHY_CASE = "records"
|
||
|
||
|
||
def _trophy_case_opener(ctx: AppContext) -> Callable[[], Awaitable[None]]:
|
||
"""A trophy case opener with no arguments, for the left button of the new-record dialog.
|
||
|
||
The import is inside the closure, because the trophy case also imports this module
|
||
(its records offer their own *Trace this path*). The two screens open each other. A
|
||
strict stack permits this cycle, and ^W is the way out of it.
|
||
"""
|
||
|
||
async def open_it() -> None:
|
||
from .records_screen import open_records
|
||
|
||
await open_records(ctx)
|
||
|
||
return open_it
|
||
|
||
|
||
#: A runner for one trace: ``(path_spec, on_trace)`` → runs exactly one trace. When the
|
||
#: trace arrives, the runner gives the result to ``on_trace``, together with the
|
||
#: trophy case disciplines that the walk placed in, as short ``"Title — score"`` labels.
|
||
#: The session openers supply it. They close over the device, the repository, and the
|
||
#: preferences, so that the screen has no persistence or scoring code.
|
||
TraceOnce = Callable[[str, Callable[[TraceResult, Sequence[str]], None]], Awaitable[None]]
|
||
|
||
#: A path picker flow. It takes the current spec, runs its own dialogs over the screen,
|
||
#: and resolves to the new spec (``""`` = device-routed) or to ``None`` to keep the
|
||
#: current spec. The sample-count flow has the same shape, and it always resolves
|
||
#: ``None``.
|
||
PathFlow = Callable[[str], Awaitable[str | None]]
|
||
|
||
#: The label column of the route lane: the word and its padding. Its width is the
|
||
#: column under which the wrapped continuations of the route (and its source note) hang.
|
||
_ROUTE_LANE = "route "
|
||
|
||
#: The label column of the path lane. It has the same label width as the aggregate
|
||
#: block, so the wire spec starts, and hangs, in the same column as the numbers above it.
|
||
_PATH_LANE = "path "
|
||
|
||
#: All the marks at the start of the action rows, in row order. This list is necessary
|
||
#: to *measure* the icon column. ``⚡`` is an emoji of two cells, and the other marks
|
||
#: are one cell. Thus with a fixed ``"icon "`` prefix, the label of Explore starts one
|
||
#: column to the right of the labels of all the other actions. Refer to
|
||
#: :func:`_icon_lane`.
|
||
_ACTION_ICONS = ("✎", "⇄", "⚡", "⚙", "#", "▶")
|
||
|
||
|
||
def _icon_lane() -> int:
|
||
"""The icon column of the action rows, in cells: the marks of this screen, measured once."""
|
||
return icon_lane(_ACTION_ICONS)
|
||
|
||
|
||
def _icon(text: Text, icon: str, style: str) -> None:
|
||
"""Append the mark of an action row in the icon column, with its trailing space."""
|
||
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.
|
||
|
||
This function is the counterpart of :func:`~meshterm.ui.tui.render.render_hanging`
|
||
for values that know their own line breaks.
|
||
:meth:`~meshterm.ui.pathline.PathLine.wrapped` returns lines that wrap at hops and
|
||
have their indent. A second wrap here only undoes that work. The label goes on the
|
||
first line. The other lines are drawn as they are.
|
||
|
||
Args:
|
||
label: The label column of the lane, with its padding (the value hangs under it).
|
||
value: The lines of the value. The first is bare, and the continuations have
|
||
their own indent.
|
||
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 _slid(line: Text, shift: int, width: int) -> Text:
|
||
"""The part of ``line`` that a viewport of ``width`` cells shows, slid ``shift`` cells in.
|
||
|
||
Each edge where the line continues has a :func:`~meshterm.ui.pathline.cut_mark`: a
|
||
chip broken off in its own fill, or the ``…`` of an arrow line. The marks are chrome
|
||
inside the viewport, so each mark uses one of its cells. The ``hscroll`` rows of the
|
||
select list and the routes of the node page slide through the same viewport.
|
||
"""
|
||
left = 1 if shift else 0
|
||
inner = max(1, width - left)
|
||
right = 1 if shift + inner < line.cell_len else 0
|
||
window = max(1, inner - right)
|
||
out = Text(no_wrap=True)
|
||
if left:
|
||
out.append_text(cut_mark(line, shift, ELIDE_HEAD))
|
||
out.append_text(crop_cells(line, shift, window))
|
||
if right:
|
||
out.append_text(cut_mark(line, shift + window - 1, ELIDE_TAIL))
|
||
return out
|
||
|
||
|
||
def _note(text: str, indent: int, *, style: str = "faint") -> Text:
|
||
"""The trailing note of a lane (a source, 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: joined with commas, each hop in its own hue.
|
||
|
||
The line has exactly the characters that the radio gets (``3d63,7f21,3d63``). The
|
||
purpose of the lane is to show the exact spec, so it stays plain-mode text and does
|
||
not become chips. The only path widget adds two things. The first is the key colouring
|
||
of the app: each addressed part of a key is lit in its own hue, which comes from the
|
||
key, instead of one flat brand colour. The second is wrapping at hop boundaries, so
|
||
a spec that is too long for its column breaks after a comma.
|
||
|
||
Args:
|
||
spec: The wire spec (hex hop hashes, separated by commas. Blanks are accepted).
|
||
|
||
Returns:
|
||
The :class:`~meshterm.ui.pathline.PathLine` of the spec, separated by commas.
|
||
"""
|
||
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.
|
||
|
||
This is the slim form, on a track, of the braille
|
||
:func:`~meshterm.ui.braillechart.meter` of the app. The reading maps onto the range
|
||
:data:`_BAR_SNR_MIN` → :data:`_BAR_SNR_MAX`. It fills a dark track of the same glyph
|
||
at two steps for each cell.
|
||
|
||
Args:
|
||
snr: The reading in dB, or ``None`` (renders as a track with no fill).
|
||
width: The width of the bar track in characters (each one has two fill steps).
|
||
|
||
Returns:
|
||
A :class:`Text` of filled braille cells over a dark track of the same glyph,
|
||
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 dialog for work in progress: a spinner chip, live progress, and Abort.
|
||
|
||
The dialog is pushed over the trace screen while traces (or a scenario probe)
|
||
transmit. Thus the user cannot miss the activity, or the way out, while the replies
|
||
continue to arrive in the screen behind it. Enter, Esc, or Space aborts through the
|
||
injected callback. The owner pops the dialog when the work ends, so the dialog never
|
||
resolves a value of its own.
|
||
"""
|
||
|
||
footer_hint = "Enter/Esc abort"
|
||
|
||
@property
|
||
def picocalc_lyra_lane(self):
|
||
"""No lane: the only verb of the dialog is abort, and Enter and Esc already do it."""
|
||
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: The heading for the dialog border (for example ``Tracing — Lakeside``).
|
||
spinner: The spinner to animate (shared with the ticker of the owner).
|
||
on_abort: Called when the user asks to abort (it must be idempotent).
|
||
"""
|
||
super().__init__()
|
||
self.title = title
|
||
self._spinner = spinner
|
||
#: Called when the user aborts. It is public, so that an owner whose task starts
|
||
#: only after the dialog (the probe sweep) can connect it again.
|
||
self.on_abort = on_abort
|
||
#: The progress on one line. The owner changes it as the replies arrive.
|
||
self.status = "transmitting…"
|
||
#: The most recent trace result, shown below the status line.
|
||
self.last: TraceResult | None = None
|
||
#: Whether to render the last-reply line. Trace and probe owners show their
|
||
#: replies on it. Owners that send a request (a neighbour fetch) have no replies
|
||
#: to show.
|
||
self.show_last = True
|
||
|
||
@property
|
||
def dialog_width(self) -> int:
|
||
"""The natural outer width, around the widest line (the compositor can still cap 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 reply arrives."""
|
||
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 has the only button look of the app: a centred reverse-video chip in
|
||
the shared ``selected`` fill. The ButtonDialog and the reconnect dialog draw
|
||
their buttons the same way. Thus the committing control of each dialog looks
|
||
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:
|
||
"""A commit or dismiss key aborts the work in progress. All other keys do nothing."""
|
||
if action in ("enter", "escape", "space"):
|
||
self.on_abort()
|
||
|
||
|
||
class TraceScreen(Screen):
|
||
"""A full-screen live trace session: armed, but idle until the user starts a trace.
|
||
|
||
One screen serves the two trace features, and ``mode`` selects the feature:
|
||
|
||
* ``"target"`` traces a pinned destination over a symmetric (mirrored) route, and
|
||
offers *Explore paths*.
|
||
* ``"path"`` walks a route that the user composed by hand, with no target. It has no
|
||
Explore (ranked candidates must have a destination) and no device routing. But it
|
||
has a *Reverse path*, which turns the asymmetric walk end for end. Trace does
|
||
nothing until a path is available to walk (composed here, or resolved
|
||
automatically from the last stored walk).
|
||
|
||
When the screen adopts a different path (composed, explored, reversed, or rendered
|
||
again at a new width), the measurement starts again. The aggregates, the medians for
|
||
each hop, and the trace log all described the old route. Thus they clear, as if the
|
||
screen was new.
|
||
|
||
↑/↓ move the highlight over the action rows, and Enter runs the selected row. The
|
||
highlight starts on Trace, so Enter alone traces. The results (the medians for each
|
||
hop and the trace log) come after the actions on the page. ↓ on the last action
|
||
edge-scrolls into the results, and PgUp/PgDn/Home/End move the full screen. Esc
|
||
leaves the screen and cancels a trace in progress. MeshTerm keeps the traces that it
|
||
already stored.
|
||
"""
|
||
|
||
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 runs Trace).
|
||
|
||
Args:
|
||
target: The label under which the traces are stored: the name of the
|
||
destination in target mode, :data:`~meshterm.core.models.PATH_TRACE_TARGET`
|
||
in path mode.
|
||
mode: ``"target"`` (symmetric, can be explored) or ``"path"`` (a walk
|
||
composed by hand, with no destination).
|
||
device_label: The name of our node, which labels the endpoints of the route.
|
||
device_hash: The public key of our node, so that the endpoints have a hash
|
||
the same as each hop.
|
||
resolve: Changes the raw hash of a hop to a friendly contact name, when it
|
||
is known.
|
||
session: The running TUI session (for paints and the floating dialog).
|
||
trace: Runs exactly one trace and returns the result (refer to
|
||
:data:`TraceOnce`). The screen runs it in a loop for runs with more
|
||
than one sample.
|
||
compose_path: Opens the path composer (one hop at a time) over this screen,
|
||
with the current spec as its start. Resolves to the new spec, or to
|
||
``None`` if the user cancels.
|
||
pick_width: Opens the path-hash width dialog. Resolves to the current spec,
|
||
rendered again at the selected width, or to ``None`` when nothing
|
||
changes.
|
||
pick_samples: Opens the sample-count dialog. Always resolves ``None``
|
||
(the owner keeps the count, and the screen reads it through
|
||
``sample_count``).
|
||
width_bytes: Reads the width for each hop that is selected now, for the
|
||
label of the action row. (The owner keeps the width, because the flows
|
||
make specs at that width.)
|
||
sample_count: Reads the sample count that is selected now, for the action
|
||
rows and for the number of traces that one Trace action runs.
|
||
explore: Opens the scenario browser and probe flow over this screen (target
|
||
mode only). Resolves to an adopted spec, or to ``None`` to keep the
|
||
current spec.
|
||
pace_s: The cooldown that sleeps between the traces of a run with more than
|
||
one sample. Thus a sample session is never a burst for the repeaters.
|
||
previous: The most recent stored trace, if one exists. Its route is the
|
||
first value of the route line, so that the screen opens with the last
|
||
path in the history.
|
||
auto_spec: Renders the spec that an *auto* trace (with no composed path)
|
||
walks now. The session resolves it from the route that the device
|
||
learned, or from the stored history, at the current width. (In path
|
||
mode, it is the last successful stored walk, unchanged.) ``""`` means a
|
||
trace with no path (a target that cannot be addressed, or a path walk
|
||
with no history).
|
||
auto_source: The short source of the automatic route (for example
|
||
``device route`` or ``last trace · Jul 09 14:32``), for the route line
|
||
and the summary. Thus the screen never shows a route that the radio
|
||
did not get.
|
||
initial_spec: A forced path on which the screen opens armed, instead of
|
||
idle on the automatic route. *Trace this path* in the trophy case uses
|
||
it to open the exact route of a record again, ready to walk. Empty (the
|
||
default) opens on the automatic route.
|
||
open_trophy_case: Opens the trophy case over this screen. The left button
|
||
of the new-record dialog does this. ``None`` leaves only *Close* in the
|
||
dialog, which is what a screen that runs 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 that the current run placed in. The key is the
|
||
#: title of the discipline, so that a run with more than one sample that
|
||
#: improves keeps only its latest ``"Title — score"`` label for each board.
|
||
#: Cleared when a run starts. Moved into the floating new-record dialog when the
|
||
#: run ends.
|
||
self._run_placed: dict[str, str] = {}
|
||
#: The traces aggregated on the screen: the run of the current route. When the
|
||
#: screen adopts a different path, this list clears (the old numbers describe
|
||
#: the old route).
|
||
self._traces: list[TraceResult] = []
|
||
#: Increased at each change of ``_traces``. It is the key on which the caches
|
||
#: for each frame below expire. (A bare ``len`` does not see a clear followed
|
||
#: by a refill.)
|
||
self._traces_rev = 0
|
||
# The aggregate stats and the windowed results block. Each one is valid for one
|
||
# traces revision (refer to render_body and _tail_lines). Thus a paint that
|
||
# changed nothing reads them again, and does not aggregate each stored trace
|
||
# again.
|
||
self._stats_memo: tuple[int, TraceStats] | None = None
|
||
self._tail_memo: tuple[tuple, list[str]] | None = None
|
||
#: All the traces that this screen ran, across changes of path. It is the
|
||
#: session count that the owner reports, and the clears for each route above
|
||
#: do not change it.
|
||
self._total_traces = 0
|
||
self._running = False
|
||
self._dialog_open = False
|
||
self._status = ""
|
||
self._progress: tuple[int, int] | None = None # (current, total) during a run
|
||
self._spinner = Spinner()
|
||
#: The loop clock at the last transmission of this screen. :meth:`_pace_remaining`
|
||
#: measures the cooldown from it, so the pacing stays after an abort and a 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 is first: Compose,
|
||
# then Explore (target mode) or Reverse (path mode). Explore must have a
|
||
# destination for which it ranks candidates, so path mode does not have it.
|
||
# Only a path walk is asymmetric enough to turn end for end, so only path mode
|
||
# offers Reverse. The highlight starts on Trace in the two modes.
|
||
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 # pin the viewport only while ↑/↓ are in use
|
||
#: How far ←→ slid the paths of the hops (in cells), and how far they can slide:
|
||
#: the tail of the widest path, as the last paint measured it. It is 0 while the
|
||
#: full path of each row is visible, and this also keeps ``←→ scroll`` out of
|
||
#: the footer.
|
||
self._hshift = 0
|
||
self._hop_pan = 0
|
||
|
||
# --- state -----------------------------------------------------------------
|
||
|
||
@property
|
||
def footer_hint(self) -> str: # type: ignore[override]
|
||
"""The footer keys, which change while traces are in progress."""
|
||
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"
|
||
slide = " · ←→ scroll" if self._hop_pan else ""
|
||
return f"↑↓ actions{slide} · Enter run · PgUp/PgDn scroll · Esc back"
|
||
|
||
def start_trace(self) -> None:
|
||
"""Start a trace run in the background (does nothing while a run is in progress)."""
|
||
if self._running:
|
||
return
|
||
self._running = True
|
||
self._status = ""
|
||
self._run_placed.clear() # this run gets its own records
|
||
self._spinner.reset()
|
||
self._worker = asyncio.ensure_future(self._run_trace())
|
||
self._session.invalidate()
|
||
|
||
async def _run_trace(self) -> None:
|
||
"""Run one Trace action (the selected number of traces) under the dialog.
|
||
|
||
Each transmission is paced, not only the transmissions in a run with more than
|
||
one sample. :attr:`_pace_s` is measured from the *last* transmission of this
|
||
screen. Thus if the user aborts a walk and runs it again immediately, the run
|
||
waits for the same gap as a second sample. When the pacing was only between
|
||
samples, one important gap stayed open. A user who sees a failure and pushes
|
||
Enter again immediately makes exactly the burst that repeaters penalize. After
|
||
a penalty, each later trace of that node comes back empty.
|
||
|
||
The dialog counts the traces of the run as the replies arrive. It is pushed for
|
||
the duration of the run, and it is popped however the run ends: completion,
|
||
failure, or abort. Its Abort connects directly to :meth:`cancel`, so the
|
||
cancellation path is the same when Esc goes to the dialog or to the screen. An
|
||
aborted run keeps each trace that it already stored.
|
||
"""
|
||
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 - show it inline, and keep the screen open
|
||
self._status = f"trace failed: {exc}"
|
||
finally:
|
||
ticker.cancel()
|
||
try:
|
||
await ticker
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception: # noqa: BLE001 - a spinner problem must never stop 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: open the new-record dialog after the
|
||
# tracing dialog closes. This is a separate task, because an aborted run
|
||
# comes here already cancelled, and it cannot await a dialog itself. A
|
||
# run whose screen already resolves (Esc during the run) does not open
|
||
# it, because a dialog must never follow the user out of the screen.
|
||
asyncio.ensure_future(self._announce_records())
|
||
|
||
def _pace_remaining(self) -> float:
|
||
"""The seconds to wait before the next transmission can go out (0 when none)."""
|
||
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 spinner and paint again at a steady interval, 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 trace that arrived, keep its records, show it, and paint again.
|
||
|
||
Args:
|
||
result: The trace that arrived.
|
||
placed: The trophy case disciplines that this walk placed in, as short
|
||
``"Title — score"`` labels (empty when it set no record). They go into
|
||
the tally of the run, which one dialog announces when the run ends
|
||
(refer to :meth:`_announce_records`). A later improvement replaces 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:
|
||
"""Open the new-record dialog for all the records that the ended run placed.
|
||
|
||
There is one dialog for each run, however many samples scored. Each placed
|
||
discipline has its own ``★ Title — score`` line. The dialog has the platform
|
||
shape: *Trophy case* on the left, *Close* on the right and the default. Thus
|
||
Enter dismisses the dialog, and Esc closes it. Trophy case opens the trophy case
|
||
*over* this screen, the same as any other sub-view. Esc from the trophy case is
|
||
one pop back to the trace that got the record, and ^W leaves the full excursion
|
||
at once. (Before, this button resolved the full screen instead: it unwound the
|
||
trace first and took the user to the main menu. That was a hand-built way out
|
||
of a stack that had no other way out.) The re-entrancy guard also stays held
|
||
while the trophy case is open. Thus a record that arrives while it is open
|
||
cannot put 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 a trace run in progress (MeshTerm keeps the traces that it stored)."""
|
||
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 highlight of the action list, run the selected action, scroll, or leave.
|
||
|
||
↑/↓ move the highlight of the action list. After the last action, edge scroll
|
||
continues them down the results. PgUp/PgDn/Home/End move the full screen, and
|
||
the highlight stays where it was until an arrow brings it back. ←→ slide the
|
||
paths of the hops that go past the edge (refer to :meth:`_hop_lines`).
|
||
"""
|
||
if action in ("left", "right"):
|
||
# The paths are not the highlight. A slide of the paths must not give the
|
||
# page back to the highlight of the action list. If it does, the page jumps
|
||
# up to the actions when the user reads a hop that edge scroll made
|
||
# visible. The shift is clamped to the tail of the paths at render.
|
||
self._pin_cursor = False
|
||
step = _HSCROLL_STEP if action == "right" else -_HSCROLL_STEP
|
||
self._hshift = max(0, min(self._hop_pan, self._hshift + step))
|
||
elif action == "enter":
|
||
self._commit_action()
|
||
elif action == "up":
|
||
# The two ends clamp instead of wrap. This is the rule of the app for a
|
||
# highlight: a highlight that jumps from one end to the other moves the page
|
||
# with it.
|
||
self._index = max(0, self._index - 1)
|
||
self._pin_cursor = True
|
||
elif action == "down":
|
||
self._index = min(len(self._actions) - 1, self._index + 1)
|
||
self._pin_cursor = True
|
||
elif action == "pageup":
|
||
self._pin_cursor = False
|
||
self.scroll_pages(-1)
|
||
elif action in ("pagedown", "space"):
|
||
self._pin_cursor = False
|
||
self.scroll_pages(1)
|
||
elif action in ("home", "ctrl_home"):
|
||
self._pin_cursor = False
|
||
self.scroll_to_top()
|
||
elif action in ("end", "ctrl_end"):
|
||
self._pin_cursor = False
|
||
self.scroll_to_bottom()
|
||
elif action == "escape":
|
||
self.cancel()
|
||
self.resolve(None)
|
||
|
||
def _commit_action(self) -> None:
|
||
"""Run the action row at the highlight (the flows guard against re-entry)."""
|
||
key = self._actions[self._index]
|
||
if key == "trace":
|
||
# A path walk has nothing to transmit until a path is available (composed,
|
||
# or the last stored walk). It has no target, so an empty spec cannot use
|
||
# device routing instead.
|
||
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":
|
||
# Start the composer with the route that the screen shows: the composed
|
||
# spec if one is set, or else the plan that was resolved automatically. Do
|
||
# not use the bare stored spec (empty at the first open). Thus Compose
|
||
# always continues from the visible route. When the user confirms the
|
||
# composer, the highlight moves to Trace (refer to _open_flow): the user
|
||
# composes, then Enter walks the path.
|
||
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:
|
||
"""Open a flow that selects a path over the screen (one at a time, not during a trace).
|
||
|
||
The composer, the scenario explorer, and the width picker all resolve the same
|
||
way. They resolve to a new spec to adopt (``""`` gives the routing back to the
|
||
device), or to ``None`` to keep the current path unchanged. (The sample-count
|
||
flow always resolves ``None``.)
|
||
|
||
Args:
|
||
flow: The dialog flow to run with the current spec.
|
||
seed: The spec given to the flow, when it must be different from the stored
|
||
spec. The composer starts from the *visible* route (the automatic plan
|
||
when no spec is set), so it never opens empty over a route that the
|
||
screen shows. The adopt test still compares with the stored spec. Thus
|
||
when the user confirms an automatic plan without a change, the plan
|
||
becomes pinned, exactly as if the user adopted it by hand.
|
||
focus_trace: Whether a confirmed flow (any resolution that is not
|
||
``None``, also the unchanged spec) moves the highlight of the action
|
||
list to Trace. The composer uses this when it returns, so that Enter
|
||
walks the path that the user built. When the user leaves with Esc,
|
||
the highlight stays 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, the
|
||
# medians for each hop, and the log all belong to the old route.
|
||
# Thus the session starts again, as clean as a new 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:
|
||
"""Turn the walked path end for end, and arm the screen again in the reverse direction.
|
||
|
||
A path walk is the only asymmetric trace. ``us → a → b → c → us`` and
|
||
``us → c → b → a → us`` cross the same links in opposite directions, and radio
|
||
links are rarely the same in the two directions. Thus a reversal of the hop
|
||
order lets the user measure one route out and back. This method adopts the
|
||
reversed spec exactly as any other change of path. The aggregates and the log
|
||
described the forward run, so they clear. Trace stays idle until the user runs
|
||
it (nothing starts automatically). Reverse again to walk the path in the
|
||
original direction. The stored history keeps the two directions, so that the
|
||
user can compare them side by side.
|
||
|
||
This method does nothing when there is nothing to reverse: no path, a single
|
||
hop (which is its own mirror), or a trace or a dialog that is in progress.
|
||
"""
|
||
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, the aggregates, the actions, the medians for each hop, and the log.
|
||
|
||
The action rows are rendered one line at a time (not through one Rich group), so
|
||
that the body line of the highlighted row is known exactly. :meth:`cursor_line`
|
||
pins that line while the user moves through the actions.
|
||
"""
|
||
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)
|
||
# The two path lanes break at hop boundaries under their own value column (the
|
||
# rule of the app for labelled rows). A long walk never folds back to column
|
||
# zero. It never separates a name from its hash, and never breaks a wire spec
|
||
# in the middle of a 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))
|
||
# Put a gap before the next group: after the build-path group (Explore in
|
||
# target mode, Reverse in path mode, whichever is last) and after the
|
||
# trace-settings group.
|
||
if key in ("explore", "reverse", "samples"):
|
||
lines.append("")
|
||
# The results are page, not a list window of their own. Nothing highlights them.
|
||
# Thus a private list window hides its rows from the arrows, and gives the frame
|
||
# a page exactly as tall as the screen. Then edge scroll has nothing to scroll
|
||
# (refer to ListWindow). The frame scrolls the results, and ↓ on the last action
|
||
# continues into them.
|
||
lines.extend(self._tail_lines(stats, current, width))
|
||
self._scroll_total = max(1, len(lines))
|
||
return lines
|
||
|
||
def _tail_lines(self, stats: TraceStats, current: TraceResult | None, width: int) -> list[str]:
|
||
"""The results block below the actions: the medians for each hop, then the trace log.
|
||
|
||
While the screen is idle, the block is cached for each traces revision. The
|
||
block renders each stored trace again, and between two completions nothing in
|
||
it changes. A *running* trace does not use the cache at all, because its log row
|
||
has the live spinner glyph.
|
||
"""
|
||
key = (self._traces_rev, self._status, width, self._hshift)
|
||
if not self._running and self._tail_memo is not None and self._tail_memo[0] == key:
|
||
return self._tail_memo[1]
|
||
lines: list[str] = []
|
||
if stats.hop_snrs:
|
||
hash_bytes = current.path_hash_bytes if current is not None else None
|
||
lines += render_lines(Group(Text(), Text("Per-hop medians", style="accent")), width)
|
||
lines += self._hop_lines(stats, hash_bytes, width)
|
||
else:
|
||
self._hop_pan = self._hshift = 0
|
||
if self._running or self._status or self._traces:
|
||
lines += render_lines(
|
||
Group(Text(), Text("Traces", style="accent"), self._trace_log()), width
|
||
)
|
||
if not self._running:
|
||
# The key is the shift at which the rows were drawn, which _hop_lines can
|
||
# clamp. Thus the next paint finds it.
|
||
self._tail_memo = ((self._traces_rev, self._status, width, self._hshift), lines)
|
||
return lines
|
||
|
||
def cursor_line(self) -> int | None:
|
||
"""The highlighted action row while ↑/↓ are in use, or else free scroll.
|
||
|
||
It is important to return ``None`` between uses of the arrows. The frame always
|
||
keeps the cursor line visible. If this method did not return ``None``, PgDn could
|
||
never scroll the action list off the screen, and the user could not 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: the pointer, the glyph, and the label (with the current value)."""
|
||
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 on which the highlight starts
|
||
_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 value lines of the route lane: the path, wrapped at hops, then its source.
|
||
|
||
The route is the live route after a reply arrived. If not, it is the plan that
|
||
the next Trace walks. If not, it is the last stored walk. If not, it is a muted
|
||
note. In all cases, it breaks at hop boundaries under the column of the lane: a
|
||
name never separates from its hash, and a boomerang folds at its turn. The
|
||
source (where an automatic plan came from, or when a previous walk ran) goes on
|
||
the line below. It is not at the right edge of the route, because there it takes
|
||
space from the route.
|
||
|
||
The two ends are bare (``bare_self``). Each walk on this screen leaves our node
|
||
and comes back to our node. The name of our node at the two ends only pushes the
|
||
hops that *are* new information out of the lane. Named hops also do not show
|
||
their hash. This lane answers the question *which nodes*. The hex after each
|
||
name is already in the ``path`` lane below, exactly, at the width that goes on
|
||
the air.
|
||
|
||
Args:
|
||
current: The newest successful trace of this session, if one exists.
|
||
width: The full body width. The lines fit it, with the indent.
|
||
|
||
Returns:
|
||
The lines of the value. The first is bare (the label goes on it), and the
|
||
continuations already have the indent of the lane.
|
||
"""
|
||
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 that the next Trace walks, and whether automatic resolution gave it.
|
||
|
||
A composed or adopted spec has priority, unchanged. When there is none, the
|
||
automatic resolver of the session gives the path that it forces now: the
|
||
route that the device learned or the stored history in target mode, or the last
|
||
successful stored walk in path mode. ``trace_once`` in :func:`_open_session`
|
||
makes the same call. Thus the route on the 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 hop hashes of the effective spec, in walk order (empty = a trace with no path)."""
|
||
spec, _ = self._effective_spec()
|
||
return [t.strip() for t in spec.split(",") if t.strip()]
|
||
|
||
def _confirm_ineligible(self, edge: tuple[str, str]) -> None:
|
||
"""Open the amber not-a-trail confirmation before a walk that cannot set records.
|
||
|
||
The walk that is about to start crosses ``edge`` two times in the same
|
||
direction. Thus it is not a trail, and the trophy case will ignore its scores
|
||
(the no-cheat rule, refer to
|
||
:func:`~meshterm.services.records.first_repeated_edge`). It is still correct to
|
||
walk the path, so the dialog only makes the disqualification clear. Cancel
|
||
leaves. Trace (on the right and the default, the same as each 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 show at the width with which the screen addresses
|
||
# hops, the same as each other node that 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:
|
||
"""A preview of the route that the next Trace walks, or ``None`` if there is none.
|
||
|
||
Renders the literal wire spec through the only path widget. It is the full walk,
|
||
because the trace protocol has no separate field for the return path. In target
|
||
mode, the spec is the symmetric boomerang (the outbound hops, the target, and
|
||
those hops in mirror order). Its second half is dim, through the ``dim_from`` of
|
||
the widget, and this means "you do not compose this part". When the line wraps,
|
||
the dim half also gives the fold its natural seam (the turn). The spec of a path
|
||
walk is the full route (composed by hand, or the last stored walk). Thus each
|
||
hop renders in full colour, and only the automatic return to our node stays
|
||
faint. The endpoints of our node are bare, as in :meth:`_route_value` (only the
|
||
arrow). The hash of each hop is also not shown: the lane names nodes, and the
|
||
spec below it is the exact hex.
|
||
"""
|
||
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 # the outbound hops and the target are the first half
|
||
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 parts: light all of them
|
||
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:
|
||
"""The number of nodes that the shown route goes through, without the endpoints.
|
||
|
||
This method uses the same order of priority as the route line: the live route,
|
||
or else the planned spec, or else the stored previous trace. It counts exactly
|
||
the items that are drawn between the two ``us`` endpoints. (The last hop of a
|
||
walked trace has no hash and *is* our node, so it does not 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 aggregate lanes of the session, with aligned labels (the path lane is separate)."""
|
||
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 value lines of the ``path`` lane: the wire spec (or its note) and the hop count.
|
||
|
||
A pinned spec is the exact string that the radio gets. It is drawn through the
|
||
only path widget (:func:`_spec_line`), so it breaks after a comma, and does not
|
||
divide a hash in two. The automatic and the "none" states stay prose. The hop
|
||
count follows the last line. It goes to a line of its own only when it does not
|
||
fit.
|
||
|
||
Args:
|
||
current: The newest successful trace of this session, if one exists. When
|
||
a trace arrived, the count describes its walked route.
|
||
width: The full body width. The lines fit it, with the indent.
|
||
|
||
Returns:
|
||
The lines of the value, with the same indent as the lines of
|
||
:meth:`_route_value`.
|
||
"""
|
||
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 _hop_lines(self, stats: TraceStats, hash_bytes: int | None, width: int) -> list[str]:
|
||
"""The median SNR of each hop, with quality bars, in path order.
|
||
|
||
Each hop shows ``n origin → destination +4.5 dB meter``. The link is drawn
|
||
through the only path widget, the same as the route lane above draws it. It
|
||
shows names with no hash after them (a node with no name shows its hash, which
|
||
is its only identity). Our end is the bare ``★``, and only the two ends of the
|
||
route are square (refer to :meth:`_hop_path`).
|
||
|
||
When each row fits fully, the hops are a table of four columns, with one line
|
||
for each hop. When a row does not fit, each row uses two lines. All the rows are
|
||
the same, so that the column of readings never jumps between lines while the
|
||
user reads down it. The first line has the hop number and its path. The second
|
||
line has the reading and its meter, flush right. A path that is still wider than
|
||
its lane is cut on its own crack, and it slides under ←→ (:attr:`_hshift`, which
|
||
all the rows share, and each row stops at its own tail). The hop number stays
|
||
pinned.
|
||
|
||
This method measures how far ←→ can slide (:attr:`_hop_pan`) and clamps the
|
||
shift to that value. Thus the rows are drawn at the shift that the footer and the
|
||
next key press agree on.
|
||
"""
|
||
numbers = [Text(str(agg.index), style="muted") for agg in stats.hop_snrs]
|
||
last = len(stats.hop_snrs) - 1
|
||
paths = [
|
||
self._hop_path(agg, hash_bytes, from_origin=i == 0, to_destination=i == last)
|
||
for i, agg in enumerate(stats.hop_snrs)
|
||
]
|
||
readings = [
|
||
Text(f"{agg.median_snr:+.1f} dB", style=snr_style(agg.median_snr))
|
||
for agg in stats.hop_snrs
|
||
]
|
||
number_w = max(n.cell_len for n in numbers)
|
||
path_w = max(p.cell_len for p in paths)
|
||
reading_w = max(r.cell_len for r in readings)
|
||
lines: list[str] = []
|
||
if number_w + 1 + path_w + 1 + reading_w + 1 + _BAR_WIDTH <= width:
|
||
self._hop_pan = self._hshift = 0
|
||
for number, path, reading, agg in zip(
|
||
numbers, paths, readings, stats.hop_snrs, strict=True
|
||
):
|
||
row = Text(" " * (number_w - number.cell_len))
|
||
row.append_text(number)
|
||
row.append(" ")
|
||
row.append_text(path)
|
||
row.append(" " * (path_w - path.cell_len + 1 + reading_w - reading.cell_len))
|
||
row.append_text(reading)
|
||
row.append(" ")
|
||
row.append_text(snr_bar(agg.median_snr))
|
||
lines.append(render_to_ansi(row, width, no_wrap=True))
|
||
return lines
|
||
lane = max(1, width - number_w - 1)
|
||
# A slid line gives one cell to its left mark. Thus the tail of a path is
|
||
# visible at ``cells - (lane - 1)``. This is the exact stop, the same as a
|
||
# panning table uses.
|
||
tails = [p.cell_len - (lane - 1) if p.cell_len > lane else 0 for p in paths]
|
||
self._hop_pan = max(tails)
|
||
self._hshift = min(self._hshift, self._hop_pan)
|
||
for number, path, reading, tail, agg in zip(
|
||
numbers, paths, readings, tails, stats.hop_snrs, strict=True
|
||
):
|
||
first = Text(" " * (number_w - number.cell_len))
|
||
first.append_text(number)
|
||
first.append(" ")
|
||
first.append_text(_slid(path, min(self._hshift, tail), lane))
|
||
lines.append(render_to_ansi(first, width, no_wrap=True))
|
||
second = Text(" " * max(0, width - reading.cell_len - 1 - _BAR_WIDTH))
|
||
second.append_text(reading)
|
||
second.append(" ")
|
||
second.append_text(snr_bar(agg.median_snr))
|
||
lines.append(render_to_ansi(second, width, no_wrap=True))
|
||
return lines
|
||
|
||
def _hop_path(
|
||
self,
|
||
agg: HopAggregate,
|
||
hash_bytes: int | None,
|
||
*,
|
||
from_origin: bool,
|
||
to_destination: bool,
|
||
) -> Text:
|
||
"""The link of one hop, ``origin → destination``, as the route lane draws its nodes.
|
||
|
||
A hop is one link of the walk. Thus only the two ends of the route are drawn
|
||
square: the start of the first hop and the end of the last hop. Each other end
|
||
is a node through which the route continues. It has the chevron (in the arrow
|
||
form, a bare ``→``) that shows this.
|
||
"""
|
||
ends = [
|
||
None if not node or node == self._device_label else node
|
||
for node in (agg.origin, agg.destination)
|
||
]
|
||
return path_line(
|
||
ends,
|
||
self._resolve,
|
||
prefix_bytes=hash_bytes or 8,
|
||
self_name=self._device_label,
|
||
hash_bytes=hash_bytes,
|
||
bare_self=True,
|
||
from_origin=from_origin,
|
||
to_destination=to_destination,
|
||
).text()
|
||
|
||
def _trace_log(self) -> RenderableType:
|
||
"""The individual traces, newest first, with the spinner of a run in progress 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 of a trace.
|
||
|
||
The line has the number, the local time, the outcome, the hop count, the
|
||
bottleneck SNR, and the 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 hop width that a trace can encode.
|
||
|
||
Routing widths are ``mode + 1`` bytes, but the flags of a trace can encode only 1, 2,
|
||
4, or 8 (refer to :func:`~meshterm.services.trace_runner.path_hash_flags`). The
|
||
firmware matches by prefix, so a smaller width addresses the same nodes.
|
||
|
||
Args:
|
||
mode: The path-hash mode (``size - 1``). A negative value means unknown.
|
||
|
||
Returns:
|
||
The width for each hop 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 trace in target mode walks the symmetric boomerang. Thus its stored hop hashes
|
||
(the last hop, with no hash, is our node) are ``[out…, target, out reversed…]``:
|
||
a palindrome of odd length whose middle entry is the target. When the stored walk
|
||
has that shape, its first half is a route that the mesh already proved, ready to
|
||
force again.
|
||
|
||
Args:
|
||
previous: The most recent successful stored trace, if one exists.
|
||
target_hash: The full hex hash of the target, to make sure that the walk
|
||
turned at this target (the hop hashes are prefixes of it).
|
||
|
||
Returns:
|
||
The outbound repeater hashes in order, from our node outward (empty = the
|
||
target answered directly). ``None`` when there is no stored walk, or when it
|
||
is not a boomerang that this function can recognize.
|
||
"""
|
||
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 full 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 that
|
||
the mesh already carried from end to end. Thus the previous walk, which the screen
|
||
shows when it opens, is also a path that Trace can walk again immediately. The hop
|
||
hashes are returned unchanged (the last hop, with no hash, is our node, and this
|
||
function removes it). They were proved at the width at which they were
|
||
transmitted, so they are not rendered again.
|
||
|
||
Args:
|
||
previous: The most recent stored path walk, if one exists.
|
||
|
||
Returns:
|
||
The walked hop hashes in transmit order. ``None`` when there is no stored
|
||
walk, when it failed, or when it stored no hops that can be addressed.
|
||
"""
|
||
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 outbound route to ``target_hash`` in the evidence, if the data has one.
|
||
|
||
This is the observed-topology counterpart of the route that the firmware learned.
|
||
The device often knows no route to a contact, because repeater contacts flood. In
|
||
that case, the evidence of the recorder (traces, relay chains heard from other
|
||
traffic, neighbour tables that MeshTerm got) may still give a route for which there
|
||
is proof that it carried traffic. The ranking is done by
|
||
:meth:`~meshterm.services.topology.MeshTopology.suggested`. This function returns
|
||
the canonical outbound hops of the winner, with a short source line
|
||
(``best observed · 3× · −7 dB``) for the route line and the summary. It returns
|
||
``None`` when no route is better than a direct attempt.
|
||
|
||
Args:
|
||
topo: The newly built :class:`~meshterm.services.topology.MeshTopology`.
|
||
target_hash: The hex hash of the target (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:
|
||
"""The pathline of a scenario on one line: our node, the candidate hops, and the target.
|
||
|
||
The line includes the full boomerang endpoints (not only the intermediate hops that
|
||
a :class:`~meshterm.services.topology.PathScenario` stores). Thus the row shows the
|
||
full route, and not a part that the user must complete in the mind. The line
|
||
renders through the shared path widget, so each hop has its own key hue (a hop with
|
||
no name has its hash, lit by prefix) instead of the old flat single colour. As in
|
||
the route lane above it, our end is bare. Each candidate starts from our node, so
|
||
its name is the same on each row, and the cells are for the candidates.
|
||
|
||
A ``width`` budget *cuts* the line, and does not elide its middle
|
||
(:func:`~meshterm.ui.pathline.cut_to`). The ``⋯`` elision exists to keep the two
|
||
endpoints of a route. Here, the two endpoints are the same on each row by design:
|
||
our ``★`` and the one target of the full screen. Thus an elision uses cells for what
|
||
the user already knows, and takes them from the *front* of the candidates, which is
|
||
the only part that is different. A cut also makes each row look the same as the
|
||
highlighted row at shift zero. Thus when the highlight moves down the list, the row
|
||
that it goes onto does not change (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 that hangs below the pathline of a scenario: its length, source, and evidence.
|
||
|
||
The hop count is first (:func:`~meshterm.ui.pathline.hops_atom`). The line above
|
||
encodes this number but never states it, and it is the first thing on which the
|
||
user compares one candidate route with another. The hop count also replaces the
|
||
source tag of the *direct* attempt, which was the same word for the same reason (no
|
||
repeaters). The device route keeps its own tag, because a route that the firmware
|
||
learned is a claim about where the hops came from, not about how many there are.
|
||
Observed candidates *are* their hop sequence, and have no tag. Then come the
|
||
bottleneck SNR and the sample count that a trace can expect to measure, or
|
||
``unobserved`` when the evidence graph has no information about the route 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 the user leaves.
|
||
|
||
This is the symmetric feature: can I reach this node? Routes turn at the target and
|
||
come back over the mirrored hops. *Explore paths* ranks candidate outbound legs from
|
||
the observed evidence.
|
||
|
||
Args:
|
||
ctx: The shared application context (it must run the interactive TUI surface).
|
||
target: The trace destination (a contact name or a key prefix).
|
||
initial_spec: A forced path on which the screen opens armed (the full symmetric
|
||
boomerang, hex hops separated by commas), instead of idle on the route that
|
||
was resolved automatically. The Node detail screen uses it to give its
|
||
suggested best path, so that the trace opens ready to walk that path. Empty
|
||
(the default) opens on the automatic route (learned by the device, best
|
||
observed, last trace, or direct, refer to :func:`_open_session`).
|
||
|
||
Returns:
|
||
The number of traces that ran 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 the user leaves.
|
||
|
||
This is the feature with a route by hand: how far can a route that I build go? It
|
||
has no target. The user composes the full walk one hop at a time, and the walk must
|
||
only end at a node that our node can hear. Thus there is no target picker, no device
|
||
routing, and no scenario explorer. The traces are stored under
|
||
:data:`~meshterm.core.models.PATH_TRACE_TARGET`.
|
||
|
||
Args:
|
||
ctx: The shared application context (it must run the interactive TUI surface).
|
||
spec: A forced path on which the screen opens armed (hex hops separated by
|
||
commas), instead of idle on the last stored walk. *Trace this path* in the
|
||
trophy case uses it to open the route of a record again. Empty (the
|
||
default) opens on the history.
|
||
|
||
Returns:
|
||
The number of traces that ran 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:
|
||
"""Connect and run one live trace session (the two features share this code).
|
||
|
||
This function connects the screen to the radio, the database, and the
|
||
observed-topology services:
|
||
|
||
* Each trace opens its own ``runs`` row and is stored under it (the same shape that
|
||
a scripted ``meshterm trace`` writes).
|
||
* The composer and scenario flows build a new
|
||
:class:`~meshterm.services.topology.MeshTopology` from the stored evidence each
|
||
time they open. Thus the suggestions always show the latest received traffic.
|
||
|
||
Nothing transmits until the user asks. The screen opens idle, and the most recent
|
||
stored trace is the first value of its route line.
|
||
|
||
Args:
|
||
ctx: The shared application context (it must run the interactive TUI surface).
|
||
target: The trace destination (a contact name or a key prefix), or ``None`` for
|
||
a path walk with no target.
|
||
initial_spec: A forced path (hex hops separated by commas) on which the path
|
||
screen opens armed, instead of idle on the history. *Trace this path* in the
|
||
trophy case gives it. Ignored in target mode (only path walks arm on a bare
|
||
spec).
|
||
|
||
Returns:
|
||
The number of traces that ran 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 - the caller is only in the menu
|
||
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()
|
||
# The contacts and the self-info come from the session cache (refer to DeviceState).
|
||
# The contacts table is a slow round trip on a busy node, and the user opens this
|
||
# screen often. The screen still keeps the live ``device`` for the traces that it
|
||
# 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 width for each hop at which composed and scenario specs are made: the routing
|
||
# width of the region, collapsed to what a trace can encode. If it is unknown (old
|
||
# firmware), it is 1 byte. That is the protocol default, and all stored evidence
|
||
# uses it. The user can change it for the session with the *Path width* action of
|
||
# the screen (refer to pick_width).
|
||
try:
|
||
width_bytes = collapse_trace_width(int(await ctx.devstate.path_hash_mode()))
|
||
except Exception: # noqa: BLE001 - an optional read. The 1-byte default always works.
|
||
width_bytes = 1
|
||
device_width = width_bytes # kept, so that the width dialog can mark the default
|
||
|
||
# How many traces one Trace action runs. It is session state, the same as the
|
||
# width. The *Sample count* action of the screen changes it (refer to pick_samples).
|
||
sample_count = 1
|
||
|
||
# The target as a hash that can be addressed: the full key of a known contact, or
|
||
# the typed hex prefix. An unknown target that is not hex can still be traced with
|
||
# device routing. But to compose or explore, a destination hash is necessary on
|
||
# which to pin the path. A path walk has no target: each target_* stays None, and
|
||
# the composer runs with a route by hand.
|
||
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 all the data that is stored now and 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 (with no composed path) forces on the air, and where that
|
||
# route came from. A trace replies only when its destination is the last outbound
|
||
# hop of the path and nothing reflects it back home. Thus auto must always give a
|
||
# full boomerang. But the device rarely has one to give. The firmware learns the
|
||
# ``out_path`` of a contact only from addressed traffic in the two directions
|
||
# (checked on hardware: each repeater contact reported ``out_path_len`` -1,
|
||
# flood). Thus a trace with device routing to a node farther than a direct
|
||
# neighbour goes out with no repeaters, and fails. The order of priority: the route
|
||
# that the device learned, when it really has one. If not, the outbound leg of the
|
||
# last successful stored walk (the route that the screen shows). If not, the bare
|
||
# destination: a direct attempt, which says clearly that it is one.
|
||
device_route: tuple[str, ...] | None = None
|
||
if target_contact is not None and target_contact.route_hops is not None:
|
||
# To make hop hashes canonical, only the contact index is necessary. Thus an
|
||
# *empty* graph does it. The full evidence build of fresh_topology() (stored
|
||
# traces, packet paths, neighbour tables) is not necessary, because this
|
||
# branch does not read it.
|
||
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:
|
||
# The firmware has no route, but the evidence of the recorder gives one: the
|
||
# strongest observed path (the ranking uses sample counts and SNR). Because
|
||
# of this, Trace target *suggests* a route from the data. It does not use a
|
||
# flood with no path by default, which fails after 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 that auto forces at the current width of the session (``""`` = no path).
|
||
|
||
The auto spec of a path walk is the stored hops, unchanged. They were proved at
|
||
the width at which they were transmitted, so they are not rendered again.
|
||
"""
|
||
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 the path features of target mode must have a target that resolves."""
|
||
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, get its neighbour table, and store the snapshot.
|
||
|
||
The fetch action of the composer comes here. The password comes from the admin
|
||
store, or from a prompt one time, which MeshTerm keeps when the login succeeds
|
||
(the tx-optimize convention). Then the login and the fetch run under a floating
|
||
spinner dialog with Abort. Each outcome closes its own ``runs`` row. A refused
|
||
login also forgets the stored password, so the next attempt asks again.
|
||
|
||
Args:
|
||
repeater: The repeater contact to query (it has the public key).
|
||
repeater_id: Its canonical id, the key under which the snapshot is stored.
|
||
|
||
Returns:
|
||
``True`` when new neighbour links were stored (the caller must build the
|
||
topology again). ``False`` on a cancel, a 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 stream of replies to show
|
||
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 - show it in a dialog, and continue to compose
|
||
error = exc
|
||
finally:
|
||
ticker.cancel()
|
||
try:
|
||
await ticker
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception: # noqa: BLE001 - a spinner problem must never stop 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 handled where the outcome was known (refer to
|
||
# ``work`` and AdminStore.record). Thus an exception here is only 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:
|
||
# Checked on real firmware: an empty table is a normal answer (repeaters
|
||
# forget neighbours when they reboot, and learn them again 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 composer, which starts with the hops of the current spec.
|
||
|
||
This function runs the composer in a loop. A :class:`FetchNeighbours` resolution
|
||
does the fetch, builds the topology again with the new evidence, and opens the
|
||
composer again exactly where the user was (the same hops and insertion slot,
|
||
with 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
|
||
)
|
||
# Start again from the current spec. A spec in target mode is the symmetric
|
||
# boomerang (hops, target, mirror): start with only the outbound hops, and let
|
||
# the composer make the rest again. A path walk is its spec, unchanged. Tokens
|
||
# that match no contact are kept as they are, and not removed. An adopted route
|
||
# must stay when the composer opens again, also where the evidence graph has no
|
||
# data.
|
||
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)] # an old walk by hand: keep the outbound
|
||
else:
|
||
seed = ids
|
||
seed_cursor: int | None = None # the first open puts the insertion slot at the end
|
||
while True:
|
||
# The nodes that can give their neighbour table: repeater contacts with a
|
||
# public key for the login (our node has no new information for 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 that 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 # continue from the same point 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() # add the new reports to the suggestions
|
||
continue
|
||
return result
|
||
|
||
async def pick_width(current: str) -> str | None:
|
||
"""Open the path-hash width picker, and render the current spec again to match.
|
||
|
||
Each row shows a key with the addressed part lit at that width. Thus the choice
|
||
means "this much of each key goes on the air". The selected width applies to
|
||
each spec that the composer and the explorer make after it. A current forced
|
||
path is rendered again immediately. The hops get their full width back through
|
||
their canonical hashes where they are known, then they are collapsed to one
|
||
width. (If only a narrower form of a hop is known, the full spec stays at the
|
||
width that this hop can supply.)
|
||
|
||
Returns:
|
||
The spec, rendered again. ``None`` when the user cancels, when nothing
|
||
changes, or when there is no forced path to render again (the width itself
|
||
still stays).
|
||
"""
|
||
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:
|
||
"""Open the sample-count picker: how many traces one Trace action runs.
|
||
|
||
Runs of more than one trace are paced by the cooldown preference between the
|
||
transmissions. Thus a choice of 8 is a deliberate sample session, never a burst.
|
||
Always resolves ``None``, because the count is session state that the screen
|
||
reads, 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:
|
||
"""The fixed head of a probed candidate: rank, verdict, bottleneck SNR, round trip.
|
||
|
||
This function is separate from :func:`outcome_title`, so that the row can
|
||
*measure* the block that it pins out of the ←→ scroll
|
||
(:attr:`~meshterm.ui.tui.select.Choice.hscroll_from`), and not guess it. These
|
||
lanes are the only reason why the list is ranked. If they slide off to show the
|
||
tail of a spec of ten hops, the row loses its identity and gets nothing, because
|
||
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 each candidate (one trace for each) under a dialog that can abort.
|
||
|
||
One ``runs`` row covers the full sweep. Each trace and each candidate aggregate
|
||
is stored under it. Thus an aborted probe still keeps all that it measured. Each
|
||
candidate gets exactly one transmission (a probe compares, it does not take
|
||
samples). Returns ``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. Connect the button again.
|
||
|
||
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 - show it in a dialog, and keep the screen
|
||
error = exc
|
||
finally:
|
||
ticker.cancel()
|
||
try:
|
||
await ticker
|
||
except asyncio.CancelledError:
|
||
pass
|
||
except Exception: # noqa: BLE001 - a spinner problem must never stop a probe
|
||
pass
|
||
session.pop(dialog)
|
||
if aborted:
|
||
# All that was measured before the abort is already stored. The run row
|
||
# only stores the fact that the sweep did not 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: examine the 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(
|
||
# The title knows the width (refer to Choice.title). A long
|
||
# candidate is cut to the terminal, with a crack on its own chip,
|
||
# exactly as the highlighted row is at shift zero. Then the
|
||
# highlighted row keeps its natural length and slides under ←→. The
|
||
# row is not drawn again in a different form when the highlight
|
||
# goes onto it.
|
||
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 ←→, and is not truncated
|
||
)
|
||
)
|
||
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 different spec. (Scenarios can collapse to the same
|
||
# spec after they are truncated to the trace width, and the radio must not walk
|
||
# one spec two times.)
|
||
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",
|
||
# ←→ show the tail of a long spec. The ranking lanes before it stay
|
||
# pinned (each row declares their width, refer to outcome_lanes).
|
||
hscroll=True,
|
||
)
|
||
)
|
||
if adopted is CANCEL or adopted is None:
|
||
return None
|
||
return adopted.candidate.spec
|
||
|
||
# -- trophy case: score each walk that comes back home ------------------------------
|
||
# A trace is a walk: it starts and ends at our node (a Trace path route directly,
|
||
# and a Trace target boomerang the same). Thus each successful reply gets a score
|
||
# in each trophy case discipline (``services.records.CATEGORIES``, seven now), and
|
||
# MeshTerm offers it to their boards.
|
||
|
||
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]]:
|
||
"""The node positions by canonical id.
|
||
|
||
The advert history comes first, and the contacts are put 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 that came home, offer it to each board, and return the placed boards.
|
||
|
||
Returns the placed disciplines as short ``"Title — score"`` labels for the inline
|
||
note of the screen. This function is defensive by design, because a scoring
|
||
problem must never stop a trace. Thus a failure gives no records, and the error
|
||
does not propagate.
|
||
"""
|
||
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 - the scoring must never stop a trace
|
||
return []
|
||
|
||
async def trace_once(
|
||
path_spec: str, on_trace: Callable[[TraceResult, Sequence[str]], None]
|
||
) -> None:
|
||
"""Run one stored trace: one transmission, with its own ``runs`` row.
|
||
|
||
The screen calls this function one time for each sample. The screen paces runs
|
||
of more than one trace, so the repeaters never get a burst, whatever count the
|
||
user selected. A walk that comes home gets a score in the trophy case. The
|
||
records that it set go 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 that no
|
||
# ``running`` orphan stays.
|
||
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 spec from the caller arms the two modes: the stored route of a path walk, or
|
||
# the suggested best path that the Node detail screen gives to a target trace. In
|
||
# target mode, it arms only when the target can be addressed, because 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
|