No, it isn't the ack — ACK is its own payload class and was never in the match. Both symptoms come from the same place: a packet header's route type, which we never recorded, decides what its `path` field *is*. A FLOOD packet accumulates: every relay appends its hash, so the path is the route it travelled to reach us. A DIRECT packet is the opposite — the path is a routing instruction the sender wrote and the relays consume — so it says where the packet is going, and an empty one means the route was used up, not that the packet arrived in a single leap. Reading every path the first way is what drew a message from five hops out as having appeared from nowhere. The evidence is in your own log. The four-hop route on those sends is identical across four consecutive messages, where a flood-accumulated path varies every time; it ends on 40ef0f, which is the first hop of everything hairu sends *us*; and a PATH frame carrying exactly those four hops crosses the log seconds before the first message that uses it. That is a learned route to hairu being announced and then used — not a route anything took to us. So route_typename is stored (schema v15, a `route` column beside the crypto and addressing ones added for the same reason), and an Arrival carries whether it was routed. A routed frame with no path has route_known False: it is kept as a copy that reached us — the count and the time are real — but it is drawn as "routed · route not carried" instead of a confident zero hops, and it puts no lane in the fan, because an empty lane there is a claim of adjacency the frame never made. Frames from before the column read None and fall back to the old behaviour rather than to a guess. The round trip is a second bug and needed no new data. The path line closed on our own ★ unconditionally, and for an outgoing message its head is our ★ too — so every send drew ★ ▶ … ▶ ★. The graph did the same, DST_NODE having been hardcoded to us. Both now take a destination: the far end for a message we sent, ourselves for one we received. It is the rule the line's head already followed — a path runs between the nodes it went between — applied at last to its tail. A channel broadcast keeps us at both ends, because there the copies we log really are our own message coming back. collapse now keys on the route type as well as the hops, so a route travelled and a route intended never fold into one row even when the hashes match. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
394 lines
18 KiB
Python
394 lines
18 KiB
Python
"""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 dataclasses import dataclass, replace
|
|
from datetime import datetime, timedelta
|
|
from typing import TYPE_CHECKING, Optional, Sequence
|
|
|
|
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: Optional[float]
|
|
resend: int = 0
|
|
routed: Optional[bool] = 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) -> Optional[bool]:
|
|
"""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: Optional[str]) -> 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: Optional[str] = None,
|
|
peer_key: Optional[str] = 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})
|