Files
MeshTerm/meshtools/core/models.py
T
Jean-Pierre MartineauandClaude Opus 4.8 d652a4e012 Add an interactive braille OpenStreetMap map of mesh nodes
New `map` tool (menu + `meshtools map` CLI) that plots the mesh's
location-sharing nodes over a real OSM street basemap rendered as Unicode
braille — streets, rivers, water, and place names — the mapscii idea, but
able to plot our own nodes and live inside the TUI.

- core/mvt.py: dependency-free MVT/protobuf vector-tile decoder.
- core/geo.py: Web Mercator + a pannable/zoomable Viewport and tile math.
- services/basemap.py: OpenFreeMap vector tiles (free, no API key), resolved
  from the versioned TileJSON, disk-cached, offline-safe (stdlib urllib only).
- ui/mapcanvas.py: colored braille canvas (per-cell priority, line raster,
  polygon fill, collision-avoided labels), emits truecolor ANSI.
- ui/map_render.py: compositor — basemap styling + node markers with names.
- ui/map_screen.py: full-screen screen; wasd/arrows pan, =/- zoom, r reset,
  Esc/q exit; background tile loading. Offline falls back to nodes on a grid.
- tools/map.py: interactive in the menu, one-shot static render on the CLI.

Repeaters are prioritised over leaf nodes via advert type: threaded `node_type`
through Observation/HeardNode and the persistence layer (schema v4->v5, with an
idempotent migration adding observations.node_type). Added
TuiSession.base_body_size() so a full-screen screen can size itself to the frame.

Tests: tests/test_map.py (24) cover MVT decode against a real cached tile
fixture, projection, the canvas, the compositor, offline behavior, and the
interactive pan/zoom/reset logic. Full suite: 261 passing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 17:38:39 -04:00

651 lines
25 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"
# 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",
}
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 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.
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.
observed_at: When the packet was heard.
raw: Optional raw event payload for debugging/replay.
"""
node: Optional[str]
name: Optional[str] = None
kind: str = "advert"
node_type: Optional[int] = None
snr: Optional[float] = None
rssi: Optional[float] = None
lat: Optional[float] = None
lon: Optional[float] = None
observed_at: datetime = field(default_factory=utcnow)
raw: Optional[dict] = 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:`~meshtools.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: Optional[str] = None
channel: Optional[int] = None
is_channel: bool = False
sender_timestamp: Optional[datetime] = None
snr: Optional[float] = None
received_at: datetime = field(default_factory=utcnow)
raw: Optional[dict] = 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: Optional[str] = None
received_at: datetime = field(default_factory=utcnow)
raw: Optional[dict] = None
def conversation_key(
is_channel: bool, channel_id: Optional[str], peer: Optional[str]
) -> 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:`~meshtools.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:`~meshtools.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: Optional[str] = None
channel_idx: Optional[int] = None
peer: Optional[str] = None
peer_name: Optional[str] = None
snr: Optional[float] = None
acked: Optional[bool] = None
created_at: datetime = field(default_factory=utcnow)
row_id: Optional[int] = 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: Optional[str] = None,
channel_id: Optional[str] = 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:`~meshtools.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: Optional[int] = None
channel_id: Optional[str] = None
secret: Optional[bytes] = None
contact: Optional[Contact] = None
@property
def peer(self) -> Optional[str]:
"""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.
"""
node: Optional[str]
name: Optional[str]
count: int
median_snr: Optional[float]
best_snr: Optional[float]
last_rssi: Optional[float]
last_seen: datetime
lat: Optional[float] = None
lon: Optional[float] = None
node_type: Optional[int] = 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: Optional[str], 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)
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,
)
@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.
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: Optional[float]
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: Optional[int]
best_tx: int
best_snr: Optional[float]
best_success_rate: 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)