Files
Jean-Pierre MartineauandClaude Opus 5.5 5fc224476c Docstrings and comments of meshterm/services are in ASD-STE100
The same rewrite as for the core package. Three comments now agree with
the code: the activity bucket of the header is one minute
(ACTIVITY_BUCKET_S), the RX log holds packets, and a reference to a
module note that does not exist is gone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 03:48:44 -04:00

141 lines
5.7 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Multi-path probing: measure the candidate routes to a target, and rank the routes that work.
The topology graph (:mod:`~meshterm.services.topology`) proposes routes from the evidence
that MeshTerm heard. This module tests them. By default, MeshTerm traces each candidate
outbound path one time, because repeaters penalize nodes that send traffic in bursts, and
can blacklist them. Each trace is stored exactly as a normal trace run. The total for each
candidate is stored as a ``path_candidates`` row. The results come back in the order that
the TX optimizer uses for its levels: reliability first, then the bottleneck SNR to break
a tie, then the round-trip time. The caller (the live trace screen) shows the ranking and
offers to adopt the winner. The measurement only proposes a route, and the user decides.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from ..core.connection import Device
from ..core.models import TraceResult, TraceStats
ProbeProgress = Callable[[int, int, TraceResult], None]
PersistTrace = Callable[[TraceResult], None]
PersistCandidate = Callable[["ProbeOutcome"], None]
@dataclass(slots=True)
class ProbeCandidate:
"""One route to measure.
Attributes:
label: A short description of the source of the route (shown in the results).
spec: The forced-path spec to trace: hex hashes with commas between them,
outbound only, which end at the hash of the target itself. The reply goes
back along it in reverse.
"""
label: str
spec: str
@dataclass(slots=True)
class ProbeOutcome:
"""The measured result of one candidate.
Attributes:
candidate: The route that was measured.
stats: The trace statistics, aggregated over its traces.
"""
candidate: ProbeCandidate
stats: TraceStats
@property
def sort_key(self) -> tuple:
"""A ranking key that sorts the best path first.
Reliability, then bottleneck SNR, then RTT: the same order of priority that the
TX optimizer uses.
"""
snr = self.stats.median_min_snr
rtt = self.stats.median_rtt_ms
return (
-self.stats.success_rate,
-(snr if snr is not None else float("-inf")),
rtt if rtt is not None else float("inf"),
)
async def probe_paths(
device: Device,
target: str,
candidates: list[ProbeCandidate],
*,
samples: int = 1,
cooldown_s: float = 1.0,
on_result: ProbeProgress | None = None,
persist_trace: Callable[[TraceResult], Awaitable[None] | None] | None = None,
persist_candidate: PersistCandidate | None = None,
) -> list[ProbeOutcome]:
"""Trace each candidate path, and return the results with the best result first.
Runs the candidates in sequence (there is one radio, and the duty cycle applies), with
one trace for each candidate by default. Callbacks do the storage, so that this
function only measures. The caller owns the run row, and it decides where the traces
and the candidate totals go. When the task around this function is cancelled, the
function stops in the middle of a candidate. All the data stored before then stays
stored.
Args:
device: The connected device to trace through.
target: The destination (for the label of :class:`TraceStats`).
candidates: The routes to measure, in the order to try them.
samples: The number of traces for each candidate. The default of one is on
purpose, because repeaters can blacklist nodes that send traffic in bursts.
Increase it only when the airtime budget clearly permits it.
cooldown_s: The pause between traces (and between candidates).
on_result: An optional callback ``(candidate_index, done_in_candidate, result)``,
called when each trace completes. For example, it moves the probe dialog
forward.
persist_trace: An optional callback that stores each trace (sync or async).
persist_candidate: An optional callback, called with the finished
:class:`ProbeOutcome` of each candidate (for example, to store a
``path_candidates`` row).
Returns:
One :class:`ProbeOutcome` for each candidate, in the order of
:attr:`ProbeOutcome.sort_key`.
"""
from . import trace_runner
outcomes: list[ProbeOutcome] = []
for index, candidate in enumerate(candidates):
if index and cooldown_s > 0:
# ``run_traces`` pauses between its own samples, and never after the last
# one. Thus, at the default of one trace for each candidate, it does not
# pause at all. Without this pause, the sweep traces all the candidates one
# after the other, and the repeaters hear a burst. Because of that traffic
# pattern, the repeaters ignore the node, and then each later trace comes
# back empty.
await asyncio.sleep(cooldown_s)
results = await trace_runner.run_traces(
device,
target,
samples=samples,
path=candidate.spec,
cooldown_s=cooldown_s,
on_result=(
(lambda done, _total, result, i=index: on_result(i, done, result))
if on_result is not None
else None
),
persist=persist_trace,
)
outcome = ProbeOutcome(candidate=candidate, stats=TraceStats.from_traces(target, results))
outcomes.append(outcome)
if persist_candidate is not None:
persist_candidate(outcome)
return sorted(outcomes, key=lambda o: o.sort_key)