Trace: - Auto-path now ends every route at the target's own hash (verified on hardware: a trace only replies when its destination is the final hop), so direct neighbors and learned multi-hop routes both resolve correctly. - Retry the transient "no event received during contacts retrieval" blip and surface a clean DeviceCommandError instead of a raw traceback. - Route summary annotates each node with its hash at the command's path-hash width, wrapping after an arrow when the line is too long. - Default trace count changed from 5 to 3. Also includes the in-progress device-config feature (config_io, device_config, config tool, config editor UI, tests) and related supporting edits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""Domain models shared across services, tools, persistence, and visualizations.
|
|
|
|
These are plain dataclasses with no I/O dependencies so they can be constructed by the
|
|
real device, the simulator, or rehydrated from the database identically.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import statistics
|
|
from collections import Counter
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
#: Label used for our own (local) device when framing a trace path's endpoints.
|
|
LOCAL_DEVICE_LABEL = "us"
|
|
|
|
|
|
def utcnow() -> datetime:
|
|
"""Return a timezone-aware UTC timestamp.
|
|
|
|
Returns:
|
|
The current time in UTC.
|
|
"""
|
|
return datetime.now(timezone.utc)
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Contact:
|
|
"""A node known to the connected companion device.
|
|
|
|
Attributes:
|
|
name: Human-friendly name advertised by the node.
|
|
public_key: Full public key hex string, if known.
|
|
key_prefix: Short key prefix used to address the node.
|
|
last_seen: When the node was last heard, if known.
|
|
"""
|
|
|
|
name: str
|
|
public_key: str = ""
|
|
key_prefix: str = ""
|
|
last_seen: Optional[datetime] = None
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class Hop:
|
|
"""A single hop in a path trace.
|
|
|
|
Attributes:
|
|
index: Zero-based position of the hop along the path.
|
|
node: Identifier of the relaying node (name or key prefix), if resolved.
|
|
snr: Signal-to-noise ratio in dB recorded at this hop.
|
|
"""
|
|
|
|
index: int
|
|
node: Optional[str]
|
|
snr: float
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class TraceResult:
|
|
"""The aggregated outcome of a single path trace to a target.
|
|
|
|
Attributes:
|
|
target: Name or key prefix of the trace destination.
|
|
success: Whether a trace reply was received before timeout.
|
|
hops: Per-hop SNR readings, ordered from source to destination.
|
|
round_trip_ms: Round-trip time of the trace in milliseconds, if measured.
|
|
tx_power: TX power level in effect when the trace ran, if known.
|
|
path_hash_bytes: Per-hop path-hash width (bytes) used by the trace command, so
|
|
node hashes can be displayed at the same width the command addressed them.
|
|
timestamp: When the trace completed.
|
|
raw: Optional raw event payload for debugging/replay.
|
|
"""
|
|
|
|
target: str
|
|
success: bool
|
|
hops: list[Hop] = field(default_factory=list)
|
|
round_trip_ms: Optional[float] = None
|
|
tx_power: Optional[int] = None
|
|
path_hash_bytes: Optional[int] = None
|
|
timestamp: datetime = field(default_factory=utcnow)
|
|
raw: Optional[dict] = None
|
|
|
|
@property
|
|
def hop_count(self) -> int:
|
|
"""Number of hops recorded in the trace."""
|
|
return len(self.hops)
|
|
|
|
@property
|
|
def min_snr(self) -> Optional[float]:
|
|
"""The bottleneck (weakest) SNR along the path, or ``None`` if no hops."""
|
|
if not self.hops:
|
|
return None
|
|
return min(h.snr for h in self.hops)
|
|
|
|
@property
|
|
def path_str(self) -> str:
|
|
"""A compact ``a -> b -> c`` rendering of the resolved path."""
|
|
if not self.hops:
|
|
return self.target
|
|
nodes = [h.node or f"hop{h.index}" for h in self.hops]
|
|
return " -> ".join(nodes)
|
|
|
|
def edges(self, device_label: str = LOCAL_DEVICE_LABEL) -> list["HopEdge"]:
|
|
"""Frame the per-hop SNR as directed ``origin -> destination`` edges.
|
|
|
|
Each hop's SNR is the signal measured arriving at that node, so an edge runs
|
|
from the previous node to this one. The first edge therefore originates at our
|
|
own device, and (because firmware records the reply returning to us as a final
|
|
hash-less hop) the last edge's destination is our device too.
|
|
|
|
Args:
|
|
device_label: Name to show for our own device at the path's endpoints.
|
|
|
|
Returns:
|
|
One :class:`HopEdge` per hop, in path order.
|
|
"""
|
|
edges: list[HopEdge] = []
|
|
origin = device_label
|
|
for hop in self.hops:
|
|
destination = hop.node or device_label
|
|
edges.append(HopEdge(index=hop.index, origin=origin, destination=destination, snr=hop.snr))
|
|
origin = destination
|
|
return edges
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class HopEdge:
|
|
"""A directed link in a trace path: a hop framed as ``origin -> destination``.
|
|
|
|
Attributes:
|
|
index: Zero-based position of the hop along the path.
|
|
origin: Identifier of the transmitting node (our device for the first edge).
|
|
destination: Identifier of the receiving node (our device for the last edge).
|
|
snr: Signal-to-noise ratio in dB measured at ``destination``.
|
|
"""
|
|
|
|
index: int
|
|
origin: str
|
|
destination: str
|
|
snr: float
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class HopAggregate:
|
|
"""Median SNR for one hop position aggregated across several traces.
|
|
|
|
``origin`` and ``destination`` hold raw node identifiers; ``None`` means our own
|
|
device, so the display label can be applied at render time.
|
|
|
|
Attributes:
|
|
index: Zero-based hop position along the path.
|
|
origin: Representative transmitting node at this position (``None`` = us).
|
|
destination: Representative receiving node at this position (``None`` = us).
|
|
median_snr: Median SNR in dB measured at ``destination`` across the samples.
|
|
samples: Number of traces that reported this hop.
|
|
"""
|
|
|
|
index: int
|
|
origin: Optional[str]
|
|
destination: Optional[str]
|
|
median_snr: float
|
|
samples: int
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class TraceStats:
|
|
"""Robust statistics aggregated over several traces to the same target.
|
|
|
|
Robust (median-based) metrics are used throughout because mesh SNR readings are
|
|
noisy and prone to outliers.
|
|
|
|
Attributes:
|
|
target: The trace destination these statistics describe.
|
|
samples: Number of traces attempted.
|
|
successes: Number of traces that returned a reply.
|
|
median_min_snr: Median of each trace's bottleneck SNR, the headline metric.
|
|
median_rtt_ms: Median round-trip time, if measured.
|
|
tx_power: TX power level in effect for these samples, if fixed.
|
|
hop_snrs: Per-hop median SNR across the successful traces, in path order.
|
|
"""
|
|
|
|
target: str
|
|
samples: int
|
|
successes: int
|
|
median_min_snr: Optional[float]
|
|
median_rtt_ms: Optional[float]
|
|
tx_power: Optional[int] = None
|
|
hop_snrs: list[HopAggregate] = field(default_factory=list)
|
|
|
|
@property
|
|
def success_rate(self) -> float:
|
|
"""Fraction of traces that returned a reply, in the range ``[0, 1]``."""
|
|
return self.successes / self.samples if self.samples else 0.0
|
|
|
|
@classmethod
|
|
def from_traces(cls, target: str, traces: list[TraceResult]) -> "TraceStats":
|
|
"""Aggregate a list of traces into robust statistics.
|
|
|
|
Args:
|
|
target: The trace destination.
|
|
traces: The individual trace results to aggregate.
|
|
|
|
Returns:
|
|
A :class:`TraceStats` summarizing the supplied traces.
|
|
"""
|
|
successes = [t for t in traces if t.success]
|
|
min_snrs = [t.min_snr for t in successes if t.min_snr is not None]
|
|
rtts = [t.round_trip_ms for t in successes if t.round_trip_ms is not None]
|
|
tx_powers = {t.tx_power for t in traces if t.tx_power is not None}
|
|
return cls(
|
|
target=target,
|
|
samples=len(traces),
|
|
successes=len(successes),
|
|
median_min_snr=statistics.median(min_snrs) if min_snrs else None,
|
|
median_rtt_ms=statistics.median(rtts) if rtts else None,
|
|
tx_power=next(iter(tx_powers)) if len(tx_powers) == 1 else None,
|
|
hop_snrs=cls._aggregate_hops(successes),
|
|
)
|
|
|
|
@staticmethod
|
|
def _aggregate_hops(successes: list["TraceResult"]) -> list[HopAggregate]:
|
|
"""Compute the median SNR per hop position across successful traces.
|
|
|
|
Hops are grouped by their path position; for each position the SNR median is
|
|
taken and the most common origin/destination nodes are used so the aggregate
|
|
path reads like a single representative trace. Node identities are kept raw
|
|
(``None`` = our device) so a display label can be applied later.
|
|
|
|
Args:
|
|
successes: The successful traces to aggregate.
|
|
|
|
Returns:
|
|
One :class:`HopAggregate` per hop position, ordered along the path.
|
|
"""
|
|
snrs_by_index: dict[int, list[float]] = {}
|
|
origins_by_index: dict[int, list[Optional[str]]] = {}
|
|
dests_by_index: dict[int, list[Optional[str]]] = {}
|
|
for trace in successes:
|
|
origin: Optional[str] = None # the first hop originates at our device
|
|
for hop in trace.hops:
|
|
snrs_by_index.setdefault(hop.index, []).append(hop.snr)
|
|
origins_by_index.setdefault(hop.index, []).append(origin)
|
|
dests_by_index.setdefault(hop.index, []).append(hop.node)
|
|
origin = hop.node
|
|
|
|
aggregates: list[HopAggregate] = []
|
|
for index in sorted(snrs_by_index):
|
|
snrs = snrs_by_index[index]
|
|
origin = Counter(origins_by_index[index]).most_common(1)[0][0]
|
|
destination = Counter(dests_by_index[index]).most_common(1)[0][0]
|
|
aggregates.append(
|
|
HopAggregate(
|
|
index=index,
|
|
origin=origin,
|
|
destination=destination,
|
|
median_snr=statistics.median(snrs),
|
|
samples=len(snrs),
|
|
)
|
|
)
|
|
return aggregates
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class TxLevelResult:
|
|
"""Robust measurement of one TX power level during an optimization sweep.
|
|
|
|
Attributes:
|
|
tx_power: The transmit power level tested.
|
|
stats: Aggregated trace statistics at this level.
|
|
score: Scalar objective value (higher is better) combining SNR and reliability.
|
|
"""
|
|
|
|
tx_power: int
|
|
stats: TraceStats
|
|
score: float
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class TxOptResult:
|
|
"""The outcome of a TX-power optimization run.
|
|
|
|
Attributes:
|
|
target: Node the optimization was tuned against.
|
|
original_tx: TX power in effect before the sweep (restored afterward).
|
|
best_tx: TX power with the highest score.
|
|
best_score: The winning score.
|
|
applied: Whether ``best_tx`` was written back to the device.
|
|
levels: Every level measured, in the order tested.
|
|
"""
|
|
|
|
target: str
|
|
original_tx: Optional[int]
|
|
best_tx: int
|
|
best_score: float
|
|
applied: bool
|
|
levels: list[TxLevelResult] = field(default_factory=list)
|
|
|
|
@property
|
|
def best_level(self) -> Optional[TxLevelResult]:
|
|
"""The :class:`TxLevelResult` for ``best_tx``, if present."""
|
|
return next((lv for lv in self.levels if lv.tx_power == self.best_tx), None)
|
|
|
|
def sorted_by_tx(self) -> list[TxLevelResult]:
|
|
"""Return measured levels sorted ascending by TX power.
|
|
|
|
Returns:
|
|
The levels ordered by TX power, suitable for plotting.
|
|
"""
|
|
return sorted(self.levels, key=lambda lv: lv.tx_power)
|