Files
MeshTerm/meshterm/services/message_paths.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

392 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Recover the paths a chat message rode in on, from the stored packet log.
The radio's RX log records every frame it decodes — including the relay path each one
traversed — and the recorder has been persisting those as ``packet`` observations all
along. A message heard more than once (a channel broadcast rebroadcast by several
repeaters, a flood that reached us two ways) therefore left one logged frame *per
arrival*, each with its own path and SNR. This module matches those frames back to a
chat message so the chat screen can show, on demand, every way the message reached us:
* **Channel messages** match by content: a ``GRP_TXT`` frame names its channel only by
a one-byte hash, but we hold the channel's key, so
:func:`~meshterm.core.channels.decrypt_channel_text` can confirm the channel by MAC
and recover the plaintext. A frame whose text equals the message's (allowing for the
``Name: `` sender prefix convention on either side) is one arrival of that message —
our own broadcasts included, since a repeater's rebroadcast of us is overheard and
logged like anything else.
* **Direct messages** ride ECDH-encrypted ``TEXT_MSG`` frames that only the recipient can
decrypt, so no *content* match is possible from the log. What such a frame carries in
the clear is its **addressing and its MAC** (:mod:`~meshterm.core.frames`): the one-byte
key hash of each end, then a two-byte tag over the encrypted body. That MAC is a
fingerprint of the message itself — copies of one message share it and the next message's
do not — so direct frames group as exactly as channel frames do, without ever reading
them. Three filters, in order: the frame ran **between this conversation's two ends**,
in the **direction this message travelled** (our own sends are ``us → peer``; a received
message is ``peer → us``), and it carries **the same MAC** as the rest of its group.
A conversation's messages are then told apart by which MAC group sits nearest the
message's own time. That leaves one honest gap: history recorded before the MAC was kept
has none, and those messages fall back to matching by address, direction and time —
billed as such, because a frame between the right pair in the right window is only
*probably* this one.
Nothing here transmits; it is a read-model over the repository.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass, replace
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from ..core.channels import decrypt_channel_text, split_channel_sender
from ..core.frames import ENDPOINT_HASH_BYTES
from ..core.models import ChatMessage, Observation
if TYPE_CHECKING:
from ..persistence.repository import Repository
#: How far around a channel message the log is searched. Wide, because an inbound
#: message is stamped with the *sender's* clock, which may drift from ours by minutes.
_CHANNEL_WINDOW = timedelta(minutes=15)
#: How far around a direct message the log is searched, before the conversation's own
#: messages narrow it further (see :func:`direct_window`). Generous on its own: a send is
#: retried for a while after it is composed, and an inbound message is stamped with the
#: sender's clock.
_DIRECT_WINDOW = timedelta(seconds=90)
#: The least room left either side of a direct message once its neighbours clamp the
#: window. Two messages composed a second apart still each need somewhere to look, and the
#: midpoint between them would otherwise leave one of them nothing at all.
_MIN_HALF_WINDOW = timedelta(seconds=2)
#: Route types whose ``path`` field is a routing *instruction* rather than a record of
#: where the packet has been — the sender writes the route and the relays consume it. The
#: library names the header's two route bits (see its ``ROUTE_TYPENAMES``); the flooded
#: ones accumulate instead, which is the reading every path here used to get.
_ROUTED_TYPES = frozenset({"DIRECT", "TC_DIRECT"})
#: Frame classes that carry a direct (addressed) text message — the payload class the
#: meshcore library names, spelled exactly as it reports it (see
#: :data:`~meshterm.core.frames.ADDRESSED_CLASSES`, the packet viewer's lane, the
#: dashboard's traffic keys). A near-miss here matches nothing at all and reads as a
#: mesh that never carries direct messages.
_DIRECT_TYPENAMES = frozenset({"TEXT_MSG"})
#: Hex digits of a frame's endpoint hash — the leading byte of a public key, as both
#: ends of a direct frame are addressed by.
_HASH_CHARS = 2 * ENDPOINT_HASH_BYTES
@dataclass(slots=True)
class Arrival:
"""One logged copy of a message reaching this radio.
Attributes:
when: When the frame was heard.
hops: The relay path it rode, in propagation order (empty = arrived direct).
snr: Reception SNR in dB — of the *last relay*, as with every packet row.
resend: The sender's resend counter for this copy (0 = the original send),
recovered from a decrypted channel frame; always 0 for direct frames.
routed: Whether the frame was **direct-routed** rather than flooded, which decides
what :attr:`hops` even means. A flooded packet accumulates its path — every
relay appends itself — so the hops are the route it travelled to reach us. A
direct-routed packet carries a route its sender wrote and its relays consume,
so the hops are where it was *going*, and an empty one means the route was used
up rather than that the packet arrived in a single leap. ``None`` for history
recorded before the route type was kept.
copies: How many logged frames this row stands for — always ``1`` as the matchers
produce them, and more once :func:`collapse` folds a path heard several times
into one row. A direct send is retried, and each retry is overheard off every
repeater in earshot, so one message routinely leaves a dozen frames on two
paths; listing them individually said "twelve arrivals" where the truth was
"two paths, six times each".
"""
when: datetime
hops: tuple[str, ...]
snr: float | None
resend: int = 0
routed: bool | None = None
copies: int = 1
@property
def route_known(self) -> bool:
"""Whether :attr:`hops` says anything about how this copy actually travelled.
False for a direct-routed frame carrying no path: its route was consumed on the way
and the field is empty because there is nothing left of it, not because the packet
crossed no relays. Drawing that as a zero-hop arrival claimed a neighbour we do not
have — the "impossible direct path" (JP, 2026-09-02).
"""
return bool(self.hops) or not self.routed
def _frame_hops(observation: Observation) -> tuple[str, ...]:
"""An observation's path field as hop hashes (see :attr:`Arrival.routed` for what it means)."""
return tuple(h for h in (observation.path or "").split(",") if h)
def _frame_routed(raw: dict) -> bool | None:
"""Whether a frame was direct-routed, or ``None`` where its route type wasn't kept."""
name = str(raw.get("route_typename") or "").upper()
return name in _ROUTED_TYPES if name and name != "UNK" else None
def _texts_match(wire: str, stored: str) -> bool:
"""Whether an on-air text and a stored chat text are the same message body.
An inbound message is stored verbatim off the wire (sender prefix included), so an
exact match covers it. Our own outbound messages are stored as typed while the
radio prepends the ``Name: `` sender convention — so the wire text also matches
when its prefix-stripped body equals the stored text. Comparison is
whitespace-trimmed but otherwise exact: paths are evidence, and a fuzzy match
would fabricate some.
"""
wire, stored = wire.strip(), stored.strip()
if wire == stored:
return True
_sender, body = split_channel_sender(wire)
body = body.strip()
return bool(body) and body == stored
def channel_arrivals(
repo: Repository,
message: ChatMessage,
*,
channel_name: str,
secret: bytes,
) -> list[Arrival]:
"""Every logged arrival of a channel message, matched by decrypted content.
Args:
repo: The repository holding the packet log.
message: The chat message whose arrivals to find (in- or outbound).
channel_name: The conversation's channel name (for the decrypt attempt).
secret: The channel's 16-byte key.
Returns:
The matching arrivals, oldest first (resends of the same message included,
each carrying its ``resend`` counter).
"""
frames = repo.packet_frames_between(
message.created_at - _CHANNEL_WINDOW, message.created_at + _CHANNEL_WINDOW
)
arrivals: list[Arrival] = []
for frame in frames:
raw = frame.raw if isinstance(frame.raw, dict) else {}
if raw.get("payload_typename") != "GRP_TXT":
continue
chan_hash = raw.get("chan_hash")
cipher_mac = raw.get("cipher_mac")
crypted = raw.get("crypted")
if not (chan_hash and cipher_mac and crypted):
continue
decrypted = decrypt_channel_text(chan_hash, cipher_mac, crypted, [(channel_name, secret)])
if decrypted is None or not _texts_match(decrypted.text, message.text):
continue
arrivals.append(
Arrival(
when=frame.observed_at,
hops=_frame_hops(frame),
snr=frame.snr,
resend=decrypted.attempt,
routed=_frame_routed(raw),
)
)
return arrivals
def _endpoint_hash(key: str | None) -> str:
"""A node's endpoint hash — the leading byte of its key, as a frame addresses it."""
text = (key or "").strip().lower()
return text[:_HASH_CHARS] if len(text) >= _HASH_CHARS else ""
def direct_window(repo: Repository, message: ChatMessage) -> tuple[datetime, datetime]:
"""The span of log to search for one direct message's frames.
:data:`_DIRECT_WINDOW` either side, then clamped by the conversation's own messages —
only those travelling the **same way**, because the two directions are stamped by two
different clocks (see :meth:`~meshterm.persistence.repository.Repository
.direct_message_bounds`). Without any clamp the flat window is wildly too wide at
conversational pace: nine messages of one real exchange fell inside a single ±90 s
window, so each of them claimed all nine messages' frames as its own.
The two directions are clamped differently, because we know very different things about
them:
* **Our own sends** are stamped as they leave, by our own clock, so no frame of one can
predate it — the window opens *at* the message (less a moment for jitter) and runs to
the next send. Retries follow the send, so all the room goes forward.
* **A received message** carries the sender's clock, which may sit either side of when
we actually heard it. Its window stays centred, reaching halfway to the messages
before and after it.
Never narrower than :data:`_MIN_HALF_WINDOW` either side, so two messages a second apart
still each have somewhere to look.
Args:
repo: The repository holding the messages.
message: The message to bound a search around.
Returns:
``(start, end)`` for :meth:`~meshterm.persistence.repository.Repository
.packet_frames_between`.
"""
at = message.created_at
start, end = at - _DIRECT_WINDOW, at + _DIRECT_WINDOW
previous, following = repo.direct_message_bounds(message.peer, at, outbound=message.outbound)
if message.outbound:
# Our clock, so nothing of this send predates it; the whole window goes forward,
# up to the next send.
start = at - _MIN_HALF_WINDOW
if following is not None:
end = min(end, max(at + _MIN_HALF_WINDOW, following))
else:
if previous is not None:
start = max(start, min(at - _MIN_HALF_WINDOW, previous + (at - previous) / 2))
if following is not None:
end = min(end, max(at + _MIN_HALF_WINDOW, at + (following - at) / 2))
return start, end
def _addressed(raw: dict) -> tuple[str, str]:
"""A frame's ``(source, destination)`` endpoint hashes, lowercased (empty when absent)."""
return (
str(raw.get("src_hash") or "").lower(),
str(raw.get("dest_hash") or "").lower(),
)
def _pick_group(groups: dict[str, list[Arrival]], message: ChatMessage) -> list[Arrival]:
"""The MAC group that is this message, out of the ones in the window.
Each group is one message's frames — same MAC, so the same encrypted body. Which of
them is *this* message is the one thing the log cannot say, so the clock decides, and it
decides well: a send's frames start at the moment it was composed and run on through the
retries, so the group to take is the earliest one that did not start before the message
did. Falling back to the nearest group covers an inbound message, whose stamp is the
sender's clock and may sit slightly after the frame we heard.
"""
if not groups:
return []
at = message.created_at
started = {mac: min(a.when for a in arrivals) for mac, arrivals in groups.items()}
after = [mac for mac, first in started.items() if first >= at]
if after:
return groups[min(after, key=lambda mac: started[mac])]
return groups[min(started, key=lambda mac: abs(started[mac] - at))]
def direct_arrivals(
repo: Repository,
message: ChatMessage,
*,
self_key: str | None = None,
peer_key: str | None = None,
) -> tuple[list[Arrival], bool]:
"""Every logged frame of one direct message, and whether they were matched exactly.
Three filters (see the module docstring): the frame ran between this conversation's two
ends, in the direction this message travelled, and — where the log kept it — it shares
a MAC with the rest of its group, which is what makes one message's frames separable
from the next message's without decrypting either.
**Direction matters more than it looks.** Both ends' hashes appear on every frame of a
conversation, whichever way it went, so matching on the pair alone put our own outgoing
frames into the view of a message we *received* and vice versa. That is where the
implausible one-hop rows came from: they were our own sends, overheard coming back off
the two repeaters in earshot, correctly showing one hop because one hop is all they had
travelled — but shown under an inbound message as if they were its route to us.
Args:
repo: The repository holding the packet log.
message: The chat message whose frames to find.
self_key: Our own node's key (or prefix) — one end of every frame here.
peer_key: The conversation peer's key (or prefix) — the other end.
Returns:
``(arrivals, exact)`` — the frames oldest first, and whether a MAC identified them
as one message rather than merely correlating them by address and time.
"""
ours, theirs = _endpoint_hash(self_key), _endpoint_hash(peer_key)
# Which way this message travelled, so a send's own frames and a received message's
# never appear under each other.
if message.outbound:
want_src, want_dest = ours, theirs
else:
want_src, want_dest = theirs, ours
start, end = direct_window(repo, message)
groups: dict[str, list[Arrival]] = {}
loose: list[Arrival] = []
for frame in repo.packet_frames_between(start, end):
raw = frame.raw if isinstance(frame.raw, dict) else {}
if raw.get("payload_typename") not in _DIRECT_TYPENAMES:
continue
src, dest = _addressed(raw)
if (want_src and src != want_src) or (want_dest and dest != want_dest):
continue
arrival = Arrival(
when=frame.observed_at,
hops=_frame_hops(frame),
snr=frame.snr,
routed=_frame_routed(raw),
)
mac = str(raw.get("cipher_mac") or "").lower()
if mac:
groups.setdefault(mac, []).append(arrival)
else:
loose.append(arrival)
if groups:
return _pick_group(groups, message), True
# History recorded before the MAC was kept: address, direction and time are all there
# is, and the caller says so rather than claiming more.
return loose, False
def collapse(arrivals: Sequence[Arrival]) -> list[Arrival]:
"""Fold arrivals that rode the same path into one row apiece, counting the copies.
A direct send is retried and each retry is overheard off every repeater in earshot, so
one message leaves a dozen frames on two paths. The graph has always drawn the *distinct*
paths; the row list showed every frame, which read as twelve arrivals where the truth was
two paths heard six times each. Each surviving row keeps its **first** sighting's time
and its **best** SNR — the earliest is when the path first worked, and the strongest is
what the path is capable of.
Args:
arrivals: The matched frames, oldest first.
Returns:
One :class:`Arrival` per distinct path, in first-heard order, each carrying its
:attr:`~Arrival.copies` count.
"""
# Keyed on the route type as well as the hops: the same hashes mean two different
# things under the two types, so folding them together would merge a route travelled
# with a route intended.
folded: dict[tuple, Arrival] = {}
for arrival in arrivals:
key = (arrival.hops, arrival.routed)
seen = folded.get(key)
if seen is None:
folded[key] = replace(arrival, copies=1)
continue
best = (
seen.snr
if arrival.snr is None
else (arrival.snr if seen.snr is None else max(seen.snr, arrival.snr))
)
folded[key] = replace(seen, snr=best, copies=seen.copies + 1)
return list(folded.values())
def distinct_paths(arrivals: list[Arrival]) -> int:
"""How many distinct relay paths a set of arrivals covers."""
return len({a.hops for a in arrivals})