Files
MeshTerm/meshtools/core/models.py
T
Jean-Pierre MartineauandClaude Opus 4.8 421781f13d Fix auto-path traces, harden contacts retrieval, add device config tooling
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>
2026-06-21 11:19:21 -04:00

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)