Files
MeshTerm/meshterm/core/models.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
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>
2026-09-13 13:09:18 -04:00

888 lines
37 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""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 collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
#: Maps a hop's raw key-prefix hash to a display label (a contact name when known, or the
#: hash itself when not). ``None`` passes through, being our own device. THE alias for the
#: name side of a hop, shared by everything that builds one
#: (:func:`~meshterm.services.trace_runner.make_node_resolver`) and everything that takes
#: one (every screen that draws a route).
NodeResolver = Callable[[str | None], str | None]
#: Maps a display name back to the node's key — as full as we hold one: a public key, a key
#: prefix, or a stored node id — or ``None`` for a name no known node carries. The colour
#: side of :data:`NodeResolver`: where that turns hex into names, this turns a bare name back
#: into the key its palette hue derives from
#: (:func:`~meshterm.services.trace_runner.make_name_key_resolver`).
NameKeyResolver = Callable[[str], str | None]
#: Label used for our own (local) device when framing a trace path's endpoints.
LOCAL_DEVICE_LABEL = "us"
#: Sentinel ``target`` recorded on traces run by the *Trace path* feature, which walks a
#: hand-composed route with no destination node at all (a trace is one walked path; the
#: notion of a target is pure UX). The parentheses keep it from colliding with contact
#: names. History queries key on it: ``Repository.latest_trace`` seeds the path screen
#: from it, while ``Repository.traced_targets`` excludes it so composed walks never
#: surface in the target picker's "Recently traced" list.
PATH_TRACE_TARGET = "(path)"
# MeshCore advert types — the low nibble of an advert's flag byte — classify the kind of
# node an advert announces. Repeaters are fixed infrastructure and carry the mesh, so
# location-aware features (e.g. the map) prioritise them over ordinary leaf nodes.
NODE_TYPE_CHAT = 1
NODE_TYPE_REPEATER = 2
NODE_TYPE_ROOM = 3
NODE_TYPE_SENSOR = 4
#: Human-readable label for each advert type, for legends and summaries.
NODE_TYPE_LABELS = {
NODE_TYPE_CHAT: "node",
NODE_TYPE_REPEATER: "repeater",
NODE_TYPE_ROOM: "room",
NODE_TYPE_SENSOR: "sensor",
}
class LoginResult(Enum):
"""How an admin login ended — and, crucially, whether the node was there to end it.
"Rejected" and "never answered" used to be the same ``False``, and every caller read
that ``False`` as *wrong password* and dropped the remembered credential. Administering
a repeater while it happened to be down therefore erased its password: the node had said
nothing at all, and we took the silence as a denial.
They are not the same claim. A refusal is the node telling us the password is wrong, and
only that is grounds for forgetting it. Silence says nothing about the password —
the node may be asleep, out of range, or the reply lost on the way home — so the
credential must survive it. Truthy exactly when the session opened, so the long-standing
``if not await device.admin_login(...)`` still reads "we are not logged in"; the two
failures are told apart by identity, which is what makes each call site say which one it
is handling.
"""
#: The node accepted the password; an admin session is open.
ACCEPTED = "accepted"
#: The node answered and said no. The stored password is wrong — forget it.
REFUSED = "refused"
#: Nothing came back. Out of reach, asleep, or the reply was lost. Keep the password.
NO_REPLY = "no_reply"
def __bool__(self) -> bool:
"""Truthy only when logged in, so the plain ``if not …`` idiom keeps its meaning."""
return self is LoginResult.ACCEPTED
def is_direct_messageable(node_type: int | None) -> bool:
"""Whether a node of this advert type is a direct-message recipient — THE DM rule.
We only send direct messages to *companion* (chat) nodes: a repeater, room server, or
sensor is infrastructure, not someone to message. A contact whose type was never
advertised (``None``) gets the benefit of the doubt, so a real companion is never
hidden by a missing type. Every DM recipient picker (the chat conversation list, the
courier outbox) filters through this one predicate, so "DMs go to companions only" is
enforced in a single place.
Args:
node_type: The node's advert type (see the ``NODE_TYPE_*`` constants), or ``None``
when it was never advertised.
Returns:
``True`` for a companion node or an untyped contact; ``False`` for a repeater,
room, or sensor.
"""
return node_type in (NODE_TYPE_CHAT, None)
def utcnow() -> datetime:
"""Return a timezone-aware UTC timestamp.
Returns:
The current time in UTC.
"""
return datetime.now(timezone.utc)
#: How far into the future an advert timestamp may sit (seconds) before it is refused as a
#: lie. An advert's timestamp is stamped by the *sender's* clock, and a mesh node with no
#: time source can be minutes — or months — off; a couple of minutes of skew is ordinary and
#: harmless (the age clamps to "now" until real time catches up), but anything beyond it
#: would pin the contact at the top of every heard-sorted list, reading "now" for as long as
#: the bogus timestamp stays ahead of the wall clock.
ADVERT_FUTURE_SKEW_S = 300
def advert_time(last_advert: object) -> datetime | None:
"""Convert an advert's Unix timestamp into a *plausible* UTC datetime, or ``None``.
THE converter for every ``last_advert`` epoch entering the app — the device's live
contact table and the cross-session contact store both pass through here — so the
plausibility rule lives in one place. The value is written by the advertising node's
own clock, which makes it hearsay: a zero/absent/garbage value means "never heard",
and a timestamp more than :data:`ADVERT_FUTURE_SKEW_S` ahead of our clock is a mis-set
sender clock and is refused the same way. Callers with their own reception evidence
(recorded observations) fill the resulting ``None`` from that instead — an honest
"when *we* heard it" beats a fictional "heard in the future".
Args:
last_advert: The raw ``last_advert`` field from a contact payload or stored entry.
Returns:
A timezone-aware UTC :class:`datetime`, or ``None`` when unknown or implausible.
"""
try:
seconds = int(last_advert) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
if seconds <= 0:
return None
when = datetime.fromtimestamp(seconds, tz=timezone.utc)
if when > utcnow() + timedelta(seconds=ADVERT_FUTURE_SKEW_S):
return None
return when
@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.
node_type: Advert type of the node (see the ``NODE_TYPE_*`` constants), if known.
lat: Latitude the node last advertised (decimal degrees), if it shared one.
lon: Longitude the node last advertised (decimal degrees), if it shared one.
route_hops: The device-learned outbound route to this contact, one hex path-hash
per repeater in order from us outward — knowledge the firmware distilled from
the paths of *received* flood packets. An empty tuple means the firmware
considers the contact a direct neighbour (a zero-hop route); ``None`` means
no route has been learned (or the transport didn't report one).
"""
name: str
public_key: str = ""
key_prefix: str = ""
last_seen: datetime | None = None
node_type: int | None = None
lat: float | None = None
lon: float | None = None
route_hops: tuple[str, ...] | None = None
@property
def has_location(self) -> bool:
"""Whether this contact advertised a usable latitude/longitude."""
return self.lat is not None and self.lon is not None
@property
def is_repeater(self) -> bool:
"""Whether this contact advertises as a repeater (fixed infrastructure)."""
return self.node_type == NODE_TYPE_REPEATER
@dataclass(slots=True)
class MapMarker:
"""One mesh node to overlay on the map.
Data, not drawing: a located node with the little the map needs to place and label it.
It lives here rather than beside the renderer because it is assembled well below the UI
— :func:`~meshterm.services.markers.gather_markers` builds the list from contacts and
observations, and three separate surfaces draw it.
Attributes:
label: Node name shown beside the marker.
lat: Latitude in decimal degrees.
lon: Longitude in decimal degrees.
is_repeater: Whether the node is a repeater (prioritised marker).
is_self: Whether this is our own node (highlighted).
detail: Extra text for the CLI legend (e.g. ``"18 pkts · +6.0 dB"``).
key: The node's key hex (as full as the caller holds), seeding the label's
key-derived hue; ``None`` leaves the label the muted no-key grey.
"""
label: str
lat: float
lon: float
is_repeater: bool = False
is_self: bool = False
detail: str = ""
key: str | None = None
def _rank(self) -> int:
"""Draw order: self on top of repeaters on top of leaf nodes."""
return 2 if self.is_self else (1 if self.is_repeater else 0)
@dataclass(slots=True)
class NeighbourInfo:
"""One entry of a remote repeater's neighbour table, fetched over the mesh.
Repeater firmware keeps a table of the nodes it has *heard directly* (zero-hop),
each with the SNR measured at the repeater — a vantage point ours can't reproduce.
:meth:`~meshterm.core.connection.Device.fetch_neighbours` returns these; the values
describe the link ``repeater ↔ node`` as observed *at the repeater*.
Attributes:
node: The neighbour's hex public-key prefix, lowercased (the width the firmware
replied at — typically 8 hex).
snr: SNR (dB) the repeater measured receiving this neighbour, if reported.
heard_at: When the repeater last heard the neighbour (derived from the reply's
seconds-ago field), or ``None`` when it didn't say.
"""
node: str
snr: float | None = None
heard_at: datetime | None = None
@dataclass(slots=True)
class Observation:
"""A single over-the-air reception heard while passively monitoring the mesh.
Adverts, telemetry frames, and other packets the companion overhears are captured as
observations and logged longitudinally, so the mesh's behavior can be reviewed over
time rather than only at the instant a command runs.
Attributes:
node: Key prefix / hash identifying the transmitting node, if known — the stored
12-hex canonical id everything groups and joins on. Kept short on purpose so it
matches the key prefixes traces, messages, and the topology graph carry.
public_key: The node's *full* public key, when the advert carried one (it arrives
in the payload and is truncated to :attr:`node` for the id). Display-only —
lets a hash lane show more than the twelve stored digits — and ``None`` for a
packet class or firmware that named only a short hash.
name: Friendly name advertised by the node, if carried.
kind: Packet class, e.g. ``advert`` or ``telemetry``.
node_type: Advert type of the transmitting node (see the ``NODE_TYPE_*`` constants),
e.g. :data:`NODE_TYPE_REPEATER`; ``None`` when the packet did not carry it.
snr: Signal-to-noise ratio (dB) the companion measured, if reported.
rssi: Received signal strength (dBm), if reported.
lat: Advertised latitude (decimal degrees), when the node shares location.
lon: Advertised longitude (decimal degrees), when the node shares location.
path: For ``packet`` observations (the companion's RX packet log), the relay
path the packet traversed before reaching us: comma-separated per-hop hex
hashes in propagation order, nearest the *originator* first and the repeater
we actually heard last. An empty string means the packet arrived direct
(zero hops); ``None`` means the packet class carries no path. For packets,
:attr:`snr`/:attr:`rssi` describe our reception from the *last relay* in
this path — not the originating :attr:`node` — which is why packet rows are
kept out of per-node reception statistics and feed topology instead.
observed_at: When the packet was heard.
raw: Optional raw event payload for debugging/replay.
"""
node: str | None
public_key: str | None = None
name: str | None = None
kind: str = "advert"
node_type: int | None = None
snr: float | None = None
rssi: float | None = None
lat: float | None = None
lon: float | None = None
path: str | None = None
observed_at: datetime = field(default_factory=utcnow)
raw: dict | None = None
@dataclass(slots=True)
class Message:
"""An inbound text message the companion received (direct or on a channel).
Direct messages carry a :attr:`sender` key prefix; channel messages carry a
:attr:`channel` index instead. Both are surfaced by the always-on event hub as
:attr:`~meshterm.core.events.EventKind.MESSAGE` events, so client features (an inbox,
notifications) can react to them without polling.
Attributes:
text: The decoded message body.
sender: Key prefix of the sending contact (direct messages), if known.
channel: Channel index the message arrived on (channel messages), if applicable.
is_channel: Whether this is a channel message rather than a direct one.
sender_timestamp: The sender's own timestamp for the message, if carried.
snr: Signal-to-noise ratio (dB) of the reception, if reported.
received_at: When the companion delivered the message to us.
raw: Optional raw event payload for debugging/replay.
"""
text: str
sender: str | None = None
channel: int | None = None
is_channel: bool = False
sender_timestamp: datetime | None = None
snr: float | None = None
received_at: datetime = field(default_factory=utcnow)
raw: dict | None = None
@dataclass(slots=True)
class Ack:
"""A delivery acknowledgement for a message the companion sent.
Attributes:
code: The ACK correlation code (hex), matching it to the sent message, if present.
received_at: When the acknowledgement arrived.
raw: Optional raw event payload for debugging/replay.
"""
code: str | None = None
received_at: datetime = field(default_factory=utcnow)
raw: dict | None = None
def conversation_key(is_channel: bool, channel_id: str | None, peer: str | None) -> str:
"""Return a stable key identifying a chat conversation.
A conversation is either a channel or a direct exchange with one contact; this key
is how history, unread counts, and the live screen all agree on which thread a message
belongs to. It is deliberately keyed on something *intrinsic* to the conversation — a
channel's identity (derived from its secret; see
:func:`~meshterm.core.channels.channel_identity`) and a contact's key prefix — never on
a slot index or list position, so reordering channels never re-points history.
Args:
is_channel: Whether the conversation is a channel.
channel_id: The channel's slot-independent identity (for channel conversations).
peer: The contact's key prefix (for direct conversations).
Returns:
``"chan:<identity>"`` for a channel or ``"dm:<peer>"`` (lowercased) for a direct
exchange.
"""
if is_channel:
return f"chan:{channel_id}"
return f"dm:{(peer or '').lower()}"
@dataclass(slots=True)
class ChatMessage:
"""A chat message, sent or received, on a channel or with a contact.
This is the persisted, display-oriented view of a message: it unifies the outbound
messages we send with the inbound :class:`Message` events the hub delivers, so a
conversation transcript is a single ordered list of these. Each belongs to exactly one
conversation — a channel (:attr:`is_channel` with :attr:`channel_idx`) or a direct
exchange with a contact (:attr:`peer` holding the contact's key prefix).
Attributes:
text: The message body.
outbound: ``True`` if we sent it, ``False`` if we received it.
is_channel: Whether it belongs to a channel rather than a direct exchange.
channel_id: The channel's slot-independent identity, for channel messages. This is
what the message is keyed to, so its history follows the channel across slot
reorders (see :func:`~meshterm.core.channels.channel_identity`).
channel_idx: The channel slot the message went out on / arrived on. Retained only
for reference and legacy backfill — the conversation key never uses it.
peer: The other party's key prefix, for direct messages.
peer_name: A friendly name for the peer/channel, snapshotted for display.
snr: Signal-to-noise ratio (dB) of an inbound reception, if reported.
acked: Delivery state of an outbound direct message: ``True`` acknowledged, ``False``
sent but not acknowledged (retryable), ``None`` either still awaiting the ack
or not applicable (a channel broadcast or an inbound message).
created_at: When the message was sent or received.
row_id: The ``messages`` table primary key once persisted, used to update an
outbound message's delivery state in place on retry; ``None`` until stored.
"""
text: str
outbound: bool = False
is_channel: bool = False
channel_id: str | None = None
channel_idx: int | None = None
peer: str | None = None
peer_name: str | None = None
snr: float | None = None
acked: bool | None = None
created_at: datetime = field(default_factory=utcnow)
row_id: int | None = None
@property
def key(self) -> str:
"""The key of the conversation this message belongs to."""
return conversation_key(self.is_channel, self.channel_id, self.peer)
@classmethod
def from_message(
cls,
message: Message,
*,
peer_name: str | None = None,
channel_id: str | None = None,
) -> ChatMessage:
"""Build an inbound :class:`ChatMessage` from a received :class:`Message`.
Args:
message: The inbound message delivered by the event hub.
peer_name: A friendly name for the sender, resolved from contacts if known.
channel_id: The channel's resolved identity (channel messages only). The wire
carries only a slot index, so the caller resolves it to the channel's
intrinsic identity before recording.
Returns:
The equivalent inbound :class:`ChatMessage`.
"""
return cls(
text=message.text,
outbound=False,
is_channel=message.is_channel,
channel_id=channel_id if message.is_channel else None,
channel_idx=message.channel,
peer=None if message.is_channel else (message.sender or None),
peer_name=peer_name,
snr=message.snr,
# Prefer the sender's own timestamp (the actual moment the message was
# composed) over ``received_at`` (when we happened to pull it off the radio),
# so the transcript reflects message time rather than retrieval time. Falls
# back to the receive time when the sender carried no timestamp.
created_at=message.sender_timestamp or message.received_at,
)
@dataclass(slots=True)
class Conversation:
"""A selectable chat thread: a channel or a direct exchange with a contact.
Attributes:
label: Display name (e.g. ``#general`` or ``Alice``).
is_channel: Whether this is a channel rather than a direct conversation.
channel_idx: The channel slot to address for sending / live matching, for channels.
channel_id: The channel's slot-independent identity, used to key its history (see
:func:`~meshterm.core.channels.channel_identity`).
secret: The channel's 16-byte secret, for channels — used only to show its
public/private openness marker; ``None`` when unknown.
contact: The contact, for direct conversations.
"""
label: str
is_channel: bool
channel_idx: int | None = None
channel_id: str | None = None
secret: bytes | None = None
contact: Contact | None = None
@property
def peer(self) -> str | None:
"""The peer key prefix for a direct conversation, else ``None``."""
if self.is_channel or self.contact is None:
return None
return self.contact.key_prefix or self.contact.public_key[:12] or None
@property
def key(self) -> str:
"""The conversation's stable key (see :func:`conversation_key`)."""
return conversation_key(self.is_channel, self.channel_id, self.peer)
@dataclass(slots=True)
class HeardNode:
"""Aggregated reception statistics for one node across many observations.
Attributes:
node: Key prefix / hash of the node (``None`` only if never identified).
name: Most recent friendly name seen for the node, if any.
count: Number of observations aggregated.
median_snr: Median SNR (dB) across observations that reported one.
best_snr: Strongest SNR (dB) seen, if any.
last_rssi: Most recent RSSI (dBm), if any.
last_seen: Timestamp of the most recent observation.
lat: Most recent advertised latitude, if the node shared one.
lon: Most recent advertised longitude, if the node shared one.
node_type: Most recent advert type seen for the node (see the ``NODE_TYPE_*``
constants), if any observation carried it.
public_key: The node's full public key, when any observation captured one (see
:attr:`Observation.public_key`); ``None`` leaves only the short :attr:`node` id.
"""
node: str | None
name: str | None
count: int
median_snr: float | None
best_snr: float | None
last_rssi: float | None
last_seen: datetime
lat: float | None = None
lon: float | None = None
node_type: int | None = None
public_key: str | None = None
@property
def has_location(self) -> bool:
"""Whether this node reported a usable latitude/longitude."""
return self.lat is not None and self.lon is not None
@property
def is_repeater(self) -> bool:
"""Whether this node advertised itself as a repeater."""
return self.node_type == NODE_TYPE_REPEATER
@classmethod
def from_observations(cls, node: str | None, observations: list[Observation]) -> HeardNode:
"""Aggregate one node's observations into reception statistics.
Args:
node: The node identifier these observations belong to.
observations: The observations for ``node`` (must be non-empty).
Returns:
A :class:`HeardNode` summarizing them. The most recent observation supplies
the name, RSSI, and location; SNR is summarized robustly (median + best).
"""
ordered = sorted(observations, key=lambda o: o.observed_at)
latest = ordered[-1]
snrs = [o.snr for o in ordered if o.snr is not None]
located = next(
(o for o in reversed(ordered) if o.lat is not None and o.lon is not None), None
)
name = next((o.name for o in reversed(ordered) if o.name), None)
node_type = next((o.node_type for o in reversed(ordered) if o.node_type is not None), None)
public_key = next((o.public_key for o in reversed(ordered) if o.public_key), None)
return cls(
node=node,
name=name,
count=len(ordered),
median_snr=statistics.median(snrs) if snrs else None,
best_snr=max(snrs) if snrs else None,
last_rssi=latest.rssi,
last_seen=latest.observed_at,
lat=located.lat if located else None,
lon=located.lon if located else None,
node_type=node_type,
public_key=public_key,
)
@dataclass(slots=True)
class SelfActivity:
"""The Time Machine's own-node ledger: what this station *did*, not what it heard.
Our own node is the one subject the reception history can't describe — we never
overhear ourselves — so its Time Machine page is built from the outbound record
instead: the traces we launched and the messages we sent. This carries the roll-up
tallies that page prints, over one history window (see
:meth:`~meshterm.persistence.repository.Repository.self_activity_ledger`).
Attributes:
trace_total: Traces launched in the window (timed-out attempts included).
trace_ok: How many of those came home (``success = 1``).
trace_targets: Distinct destinations aimed at, hand-composed path walks
(filed under :data:`PATH_TRACE_TARGET`) excluded — they name no target.
msg_channel: Channel messages sent (``outbound = 1``, ``is_channel = 1``).
msg_dm: Direct messages sent (``outbound = 1``, ``is_channel = 0``).
dm_acked: Direct messages sent that were acknowledged (``acked = 1``).
dm_ackable: Direct messages sent whose ack was tracked at all (``acked``
non-null) — the denominator the ack rate is honest over, since a channel
broadcast is never acked and an in-flight DM has no verdict yet.
dm_peers: Distinct contacts we sent a direct message to.
tx_samples: TX-power optimization samples recorded (each a robust reach probe).
"""
trace_total: int = 0
trace_ok: int = 0
trace_targets: int = 0
msg_channel: int = 0
msg_dm: int = 0
dm_acked: int = 0
dm_ackable: int = 0
dm_peers: int = 0
tx_samples: int = 0
@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: str | None
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: float | None = None
tx_power: int | None = None
path_hash_bytes: int | None = None
timestamp: datetime = field(default_factory=utcnow)
raw: dict | None = None
@property
def hop_count(self) -> int:
"""Number of hops recorded in the trace."""
return len(self.hops)
@property
def min_snr(self) -> float | None:
"""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)
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: str | None
destination: str | None
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: float | None
median_rtt_ms: float | None
tx_power: int | None = 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[str | None]] = {}
dests_by_index: dict[int, list[str | None]] = {}
for trace in successes:
origin: str | None = 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.
The headline metric is ``target_snr`` — the SNR the *target* node reports receiving
from the admin-tuned node just ahead of it — since that single link is what the
remote-admin optimizer is tuning. ``success_rate`` is the primary objective
(reliability first), with ``target_snr`` the tie-breaker.
Attributes:
tx_power: The transmit power level tested on the admin node.
samples: Number of traces run at this level.
successes: Number of traces that returned a reply.
target_snr: Median SNR (dB) received at the target across successful traces,
or ``None`` if every trace at this level failed.
score: Scalar objective for display/plotting (the median target SNR, or
negative infinity when nothing got through).
stats: Aggregated trace statistics at this level (full path, for display).
"""
tx_power: int
samples: int
successes: int
target_snr: float | None
score: float
stats: TraceStats
@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
@dataclass(slots=True)
class TxOptResult:
"""The outcome of a remote-admin TX-power optimization run.
Attributes:
target: Node the SNR was measured at.
admin_node: Label of the node whose TX power was tuned (the hop before target).
path: The forced path the traces walked (comma-separated hashes).
original_tx: The admin node's TX power before the sweep, if it could be read.
best_tx: The chosen optimal TX power.
best_snr: Median target SNR (dB) at ``best_tx``.
best_success_rate: Trace success rate at ``best_tx``.
applied: Whether ``best_tx`` was written to the admin node.
levels: Every level measured, in the order tested.
"""
target: str
admin_node: str
path: str
original_tx: int | None
best_tx: int
best_snr: float | None
best_success_rate: float
applied: bool
levels: list[TxLevelResult] = field(default_factory=list)
@property
def best_level(self) -> TxLevelResult | None:
"""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)