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>
141 lines
5.7 KiB
Python
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)
|