Repeaters penalize (and can blacklist) nodes that burst traffic, so every trace-transmitting surface now sends exactly one transmission per human action: Enter on the live screen runs a single trace (the screen still aggregates the session's medians), the scenario probe measures each candidate path once, and the CLI drops --samples to become a true one-shot. trace_runner.measure and the s-key sample cycle go with it. Also de-pollute the target picker's "Recently traced" section: the repository now returns a short recency-ordered list instead of the ever-growing all-time tally, and stored hex prefixes fold back to their contact names so a node shows once. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
125 lines
4.8 KiB
Python
125 lines
4.8 KiB
Python
"""Multi-path probing: measure candidate routes to a target and rank what actually works.
|
|
|
|
The topology graph (:mod:`~meshterm.services.topology`) proposes routes from *received*
|
|
evidence; this module puts them to the test. Each candidate outbound path is traced once
|
|
(by default — repeaters penalize, and can blacklist, nodes that burst traffic), every
|
|
trace is persisted exactly like a normal trace run, the per-candidate aggregate is
|
|
recorded as a ``path_candidates`` row, and the outcomes come back ranked the way the TX
|
|
optimizer ranks its levels: reliability first, bottleneck SNR as the tie-breaker, then
|
|
round-trip time. The caller (the live trace screen) shows the ranking and offers to adopt
|
|
the winner — measurement proposes, the user disposes.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Awaitable, Callable, Optional
|
|
|
|
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: Short description of where the route came from (shown in the results).
|
|
spec: The forced-path spec to trace — comma-separated hex hashes, outbound
|
|
only, ending at the target's own hash (the reply retraces it in reverse).
|
|
"""
|
|
|
|
label: str
|
|
spec: str
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ProbeOutcome:
|
|
"""One candidate's measured result.
|
|
|
|
Attributes:
|
|
candidate: The route that was measured.
|
|
stats: The aggregated trace statistics over its traces.
|
|
"""
|
|
|
|
candidate: ProbeCandidate
|
|
stats: TraceStats
|
|
|
|
@property
|
|
def sort_key(self) -> tuple:
|
|
"""Ranking key, best-first under ascending sort: reliability, then bottleneck
|
|
SNR, then RTT — the same priority order the TX optimizer applies."""
|
|
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: Optional[ProbeProgress] = None,
|
|
persist_trace: Optional[Callable[[TraceResult], Awaitable[None] | None]] = None,
|
|
persist_candidate: Optional[PersistCandidate] = None,
|
|
) -> list[ProbeOutcome]:
|
|
"""Trace every candidate path and return the outcomes ranked best-first.
|
|
|
|
Runs the candidates sequentially (one radio, duty cycle applies), a single trace
|
|
each by default. Persistence is delegated through callbacks so this stays pure
|
|
measurement: the caller owns the run row and decides where traces and candidate
|
|
aggregates go. Cancelling the surrounding task stops mid-candidate; everything
|
|
persisted so far stays persisted.
|
|
|
|
Args:
|
|
device: The connected device to trace through.
|
|
target: The destination (for :class:`TraceStats` labelling).
|
|
candidates: The routes to measure, in the order to try them.
|
|
samples: Traces per candidate. The default of one is deliberate — repeaters
|
|
can blacklist nodes that burst traffic; raise it only when the airtime
|
|
budget clearly allows.
|
|
cooldown_s: Pause between traces (and between candidates).
|
|
on_result: Optional callback ``(candidate_index, done_in_candidate, result)``
|
|
invoked as each trace lands, e.g. to advance the probe dialog.
|
|
persist_trace: Optional per-trace persistence callback (sync or async).
|
|
persist_candidate: Optional callback invoked with each candidate's finished
|
|
:class:`ProbeOutcome` (e.g. to record a ``path_candidates`` row).
|
|
|
|
Returns:
|
|
One :class:`ProbeOutcome` per candidate, ranked by :attr:`ProbeOutcome.sort_key`.
|
|
"""
|
|
from . import trace_runner
|
|
|
|
outcomes: list[ProbeOutcome] = []
|
|
for index, candidate in enumerate(candidates):
|
|
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)
|