Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
135 lines
5.3 KiB
Python
135 lines
5.3 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""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
|
|
|
|
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: 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:
|
|
"""A ranking key that sorts the best path first.
|
|
|
|
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: ProbeProgress | None = None,
|
|
persist_trace: Callable[[TraceResult], Awaitable[None] | None] | None = None,
|
|
persist_candidate: PersistCandidate | None = 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):
|
|
if index and cooldown_s > 0:
|
|
# ``run_traces`` paces *between* its own samples and never after the last
|
|
# one, so at the default of one trace per candidate it paces nothing at
|
|
# all: without this the sweep walks every candidate back to back and the
|
|
# repeaters see a burst — the traffic pattern that gets a node ignored,
|
|
# after which every later trace comes home 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)
|