Live feed: the lane is about the packet, whatever the packet is about

The NODE lane suited the two classes that name a node and left every
other one reading a dash. On this mesh that is half the traffic: the
direct messages, requests, responses, returned paths, acks and traces
that relay past us naming no origin. So the lane asks each class its own
question instead, and the header says SUBJECT — the word that stays true
down the whole column.

An advert or a telemetry frame is still about its node. A channel text is
about its channel, named by the key whose MAC confirms the frame — never
by the fingerprint alone, which 256 buckets make no proof at all; a
channel we hold no key for reads as that fingerprint and says so. A
direct message, request, response, returned path or anonymous request is
about the two nodes it travels between, drawn sender → recipient on the
hop-sequence widget, so endpoints read exactly as route hops do: resolved
names, key-derived hues, our own node white the moment a frame is
addressed to us. Too wide, and the sender drops to its hash — the frame
is addressed, so the addressee is the half worth the cells. An ack is
about the message it answers, a trace about its tag. And an unsigned
channel post is filed under its channel rather than the bare word
"channel", which named nothing.

Half of that was unreadable before because nobody had opened the frame.
The companion hands every overheard packet to the library, which decodes
the body for exactly two classes — advert and channel text — and leaves
the rest as bytes. Those bytes are not opaque: MeshCore addresses a frame
in the first few of them, and core/frames.py now reads them, class by
class, against the layouts the protocol's own docs/payloads.md publishes.
The trace tag is the little-endian uint32 the firmware writes, so it
reads as the same number a trace reply reports; an ack's checksum keeps
wire order, the form the companion reports its own delivery acks in. Two
checks guard every decode, because a wrong slice yields not an error but
a plausible-looking hash: the frame's payload version must be v1 (the
header reserves two bits for a v2 that widens both hash and MAC), and the
body must be long enough for the layout its class promises. Either
failing yields nothing at all.

The feed opens seeded from history, so any of this that lived only in a
live event's raw payload would leave the opening screen blank — the same
problem the channel-text crypto trio had. Same answer: three columns
(dest/src/tag), written on capture and restored to the very keys the
event used, so a replayed frame and a fresh one read identically all the
way up. Older rows carry NULL until re-heard.

The viewer gained the matching rows: to, from, tag, acks, and a channel
for a datagram — whose body is not text, but whose MAC still names it.

Verified against the MeshCore protocol documentation and firmware source,
then corroborated on the air: every frame captured off the radio has a
body length that decomposes exactly into its documented layout, an
anonymous request's 51 bytes (1 + 32 + 2 + one AES block) included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jean-Pierre MartineauandClaude Opus 5 committed 2026-07-28 19:11:07 -04:00
1 parent 64ac7dc2ed
commit 01ca242c98
11 files changed
+897 -110

No files matched your search

+2 -1
View File
@@ -86,7 +86,8 @@ with its scripted equivalent, where one exists.
| Feature | What it does | Scripted |
| --- | --- | --- |
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, window RF health beside the radio's own live numbers, and a streaming packet feed. Repaints every second. | — |
| **📊 Dashboard** | The live mesh overview: a two-hour all-packet activity chart with a pulse line, session traffic tallies by packet class, and window RF health beside the radio's own live numbers. Repaints every second. | — |
| **📡 Live feed** | Every packet as it arrives, newest first: time, what the frame *is* (its parsed payload class), and what it is *about* — the node for an advert, the channel for a channel text, sender → recipient for a direct message or a request, the tag for a trace — with reception quality beside it. Enter opens any row in the packet viewer, route graph and all. | — |
| **🌍 Map** | Located nodes plotted over a real OpenStreetMap street basemap rendered as Unicode braille (streets, rivers, place names). Pannable and zoomable; repeaters highlighted and drawn on top. Falls back to a blank grid offline. | `meshterm map` (one-shot render) |
| **🌐 Mesh atlas** | The mesh's *observed shape*, walked one node at a time: an evidence graph built from trace walks, firmware routes, overheard relay chains, and repeater neighbour tables. SNR-coloured braille edges, quality bars, Enter to walk, ⌫ to backtrack, type to find any node. The map answers *where*; the atlas answers *how it hangs together*. | — |
| **🚨 Watchtower** | A passive sentinel over the nodes you star: silence alarms when a watched node goes quiet, SNR-sag warnings when reception degrades, recovery notes when it returns, and a heads-up when a never-before-seen node appears. Runs in the background all session; unacked alerts show as a header badge. | — |
+66 -25
View File
@@ -319,6 +319,54 @@ class DecryptedText:
attempt: int
def identify_channel(
chan_hash: str,
cipher_mac: str,
crypted: str,
channels: Iterable[tuple[str, bytes]],
) -> Optional[tuple[str, bytes]]:
"""Name the channel an overheard frame belongs to, confirming the key by its MAC.
The frame names its channel only by :func:`channel_hash`'s one-byte fingerprint, and
a fingerprint is not proof: 256 buckets hold every channel on the air, so several
can — and on a busy mesh do — collide on one. The MAC is the proof. Every candidate
with a matching fingerprint is HMAC-checked against the ciphertext, exactly as the
firmware does before decoding anything, and only a channel whose key reproduces the
frame's own MAC is named.
Naming is all this does, which is what makes it usable on a frame we have no business
decrypting: a channel *datagram* carries the same envelope as a channel text but a
body that is not text at all, and a feed lane only ever wanted the channel's name.
:func:`decrypt_channel_text` is this same confirmation followed by the decode.
Args:
chan_hash: The frame's channel-hash fingerprint (2 hex chars).
cipher_mac: The frame's MAC, hex-encoded (2 bytes).
crypted: The frame's ciphertext, hex-encoded.
channels: Candidate channels to try, as ``(name, secret)`` pairs.
Returns:
The channel's ``(name, effective key)``, or ``None`` when no known channel's MAC
matches — a channel we don't hold the key for, or a bare fingerprint collision.
"""
try:
mac = bytes.fromhex(cipher_mac)
msg = bytes.fromhex(crypted)
except ValueError:
return None
if not msg:
return None
for name, secret in channels:
key = effective_secret(name, secret)
if channel_hash(key) != chan_hash:
continue
mac_check = HMAC.new(key, digestmod=_SHA256)
mac_check.update(msg)
if mac_check.digest()[:2] == mac:
return name, key
return None # unknown channel, or the fingerprint collided and the key doesn't match
def decrypt_channel_text(
chan_hash: str,
cipher_mac: str,
@@ -327,10 +375,9 @@ def decrypt_channel_text(
) -> Optional[DecryptedText]:
"""Recover a GRP_TXT frame's plaintext against a set of known channels.
Mirrors the firmware's own decode of an overheard channel-text packet: the frame
names its channel only by :func:`channel_hash`'s one-byte fingerprint — several
channels can collide on it — so every same-fingerprint candidate is tried and its
2-byte MAC checked before its key is trusted to decrypt anything. A frame from a
Mirrors the firmware's own decode of an overheard channel-text packet: the channel is
first *confirmed* by MAC (:func:`identify_channel` — a fingerprint alone can collide),
and only the key that proved itself is trusted to decrypt anything. A frame from a
channel not in ``channels`` (or whose fingerprint matches but MAC doesn't — a
genuine collision) simply yields ``None``, same as firmware that doesn't know the
channel either.
@@ -346,29 +393,23 @@ def decrypt_channel_text(
matches or the ciphertext is malformed.
"""
try:
mac = bytes.fromhex(cipher_mac)
msg = bytes.fromhex(crypted)
except ValueError:
return None
if not msg or len(msg) % AES.block_size:
return None # not a whole number of blocks: not a decryptable GRP_TXT body
for name, secret in channels:
key = effective_secret(name, secret)
if channel_hash(key) != chan_hash:
continue
mac_check = HMAC.new(key, digestmod=_SHA256)
mac_check.update(msg)
if mac_check.digest()[:2] != mac:
continue # fingerprint collided but the key doesn't actually match
plain = AES.new(key, AES.MODE_ECB).decrypt(msg)
timestamp = int.from_bytes(plain[0:4], "little")
attempt = plain[4] & 0x03
text = plain[5:].strip(b"\x00").decode("utf-8", "ignore")
sent_at = None
if timestamp:
try:
sent_at = datetime.fromtimestamp(timestamp, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
sent_at = None
return DecryptedText(channel_name=name, text=text, sent_at=sent_at, attempt=attempt)
return None
identified = identify_channel(chan_hash, cipher_mac, crypted, channels)
if identified is None:
return None
name, key = identified
plain = AES.new(key, AES.MODE_ECB).decrypt(msg)
timestamp = int.from_bytes(plain[0:4], "little")
attempt = plain[4] & 0x03
text = plain[5:].strip(b"\x00").decode("utf-8", "ignore")
sent_at = None
if timestamp:
try:
sent_at = datetime.fromtimestamp(timestamp, tz=timezone.utc)
except (OverflowError, OSError, ValueError):
sent_at = None
return DecryptedText(channel_name=name, text=text, sent_at=sent_at, attempt=attempt)
+12
View File
@@ -23,6 +23,7 @@ from typing import TYPE_CHECKING, Callable, Optional
from .channels import CHANNEL_SLOT_PROBE_CAP
from .events import MeshEvent
from .frames import frame_addressing
from .models import (
NODE_TYPE_CHAT,
NODE_TYPE_REPEATER,
@@ -2785,6 +2786,12 @@ def packet_observation_from_event(event) -> Optional[Observation]: # noqa: ANN0
relay) is still adjacency evidence. Frames that carry neither an origin nor any path
teach us nothing about topology and map to ``None``.
Origin-less does not mean featureless, though: what the frame *addresses* is decoded
out of its undecoded body (:func:`~meshterm.core.frames.frame_addressing`) and merged
into the raw payload — the recipient and sender hashes of a direct message or request,
an anonymous request's whole sender key, a channel datagram's envelope, an ack's
checksum, a trace's tag — so every class has something to say about itself downstream.
Args:
event: A meshcore ``RX_LOG_DATA`` event (anything exposing a ``payload`` mapping).
@@ -2806,6 +2813,11 @@ def packet_observation_from_event(event) -> Optional[Observation]: # noqa: ANN0
origin = payload.get("adv_key")
if not origin and not hops:
return None # neither endpoint nor relays: no topology content
# What the frame addresses — the recipient, the sender, the channel, the token it
# carries — read out of the body the library leaves undecoded for every class but
# advert and channel text (see :mod:`~meshterm.core.frames`). Merged in under its own
# keys so a class that names no origin node still says what it is *about*.
payload.update(frame_addressing(payload))
ident = str(origin).lower().removeprefix("0x") if origin else None
node = ident[:12] if ident else None
public_key = ident if ident and len(ident) > 12 else None # keep the whole adv_key
+162
View File
@@ -0,0 +1,162 @@
"""What an overheard frame is *addressed to* — the packet body nobody else parses.
The companion's RX packet log hands every overheard frame to the meshcore library, which
splits off the header it always understands — route type, payload class, relay path — and
leaves the rest as an undecoded ``pkt_payload`` blob. The library then decodes that blob
for exactly two classes: an advert (the identity and location it carries) and a
channel-text frame (its channel fingerprint, MAC and ciphertext). Every other class — the
direct messages, requests, responses, path returns, acks and traces that make up half of
what a busy mesh puts on the air — arrives as bytes, which is why an origin-less flood
used to have nothing to say about itself beyond its class.
Those bytes are not opaque. MeshCore addresses a frame in the first few of them, and the
layout is fixed per payload class — as published in the protocol's own ``docs/payloads.md``
and ``docs/packet_format.md``:
=========================== ==========================================================
class body layout
=========================== ==========================================================
``REQ``/``RESPONSE``/ ``[dest hash:1][src hash:1][MAC:2][ciphertext]``
``TEXT_MSG``/``PATH``
``ANON_REQ`` ``[dest hash:1][sender key:32][MAC:2][ciphertext]``
``ACK`` ``[checksum:4]``
``TRACE`` ``[tag:4][auth code:4][flags:1][hops…]``
``GRP_TXT``/``GRP_DATA`` ``[channel hash:1][MAC:2][ciphertext]``
=========================== ==========================================================
Endpoints are named by a *hash* — the leading byte of the node's public key, the same
one-byte identity the relay path's hops use — so they resolve through the app's ordinary
node resolver and land on the same names, hues and collisions as every hop does. An
anonymous request is the exception: having no shared secret to be recognised by yet, it
carries its sender's whole public key (the protocol docs' "sender's Ed25519 public key" —
the ``Packet.h`` header comment still calls it "ephemeral", but the login payloads it
carries are addressed to a node that has to know who logged in, and the docs' field table
is the later word). What is recovered here is only ever addressing: who a frame is for,
who it says it is from, which channel it belongs to, the token it carries. The ciphertext
is left alone (a channel we hold the key for is decrypted in
:mod:`~meshterm.core.channels`, by MAC-confirmed key, and nothing else on the mesh is ours
to read).
Two things are checked before a single byte is trusted, because a wrong slice would
produce not an error but a plausible-looking hash. The frame's **payload version** must be
the one these layouts describe — v1, the only version deployed, and the only one whose
hashes are one byte and whose MAC is two (the header reserves two bits for a v2 that
widens both). And the body must be **long enough** to hold the layout its class promises.
Either check failing yields nothing at all.
The recovered fields are merged straight into the frame's raw payload (see
:func:`~meshterm.core.connection.packet_observation_from_event`) under their own keys, so
every reader — the live feed's lane, the packet viewer's card, the repository's stored
columns — reads them exactly as it reads the two classes the library decoded itself.
"""
from __future__ import annotations
from typing import Any, Mapping
#: Payload classes MeshCore addresses with a pair of one-byte key hashes: the recipient
#: first, the sender second, then the MAC and the ciphertext. A direct message, a request
#: to a repeater, its response, and a returned path all share this envelope.
ADDRESSED_CLASSES = frozenset({"REQ", "RESPONSE", "TEXT_MSG", "PATH"})
#: Payload classes carrying the ``[channel hash][MAC][ciphertext]`` channel envelope: a
#: channel's text messages and its datagrams. Only ``GRP_DATA`` is decoded here — the
#: library already breaks ``GRP_TXT`` out itself, into these very same field names — but
#: both are stored, decrypted and named by the same code from here on.
CHANNEL_CLASSES = frozenset({"GRP_TXT", "GRP_DATA"})
#: Bytes of a public key a frame names an endpoint by — one, the same one-byte identity a
#: relay-path hop carries, which is why an endpoint resolves through the ordinary node
#: resolver (and collides exactly as a hop does). Fixed by :data:`_PAYLOAD_V1`.
ENDPOINT_HASH_BYTES = 1
#: The payload version these layouts describe: 1-byte endpoint hashes and a 2-byte MAC.
#: The header's two version bits reserve a v2 that widens both, so a frame announcing any
#: other version is left undecoded rather than sliced by v1's offsets.
_PAYLOAD_V1 = 0
#: Bytes of the MAC that follows the addressing.
_MAC = 2
#: Bytes of a full public key, carried whole by an anonymous request's sender.
_KEY = 32
#: Bytes of a frame's own token — an ack's checksum, a trace's tag.
_TOKEN = 4
def frame_addressing(payload: Mapping[str, Any]) -> dict[str, str]:
"""Recover what a raw RX-logged frame addresses, from its undecoded body.
Reads the leading bytes of ``pkt_payload`` according to the frame's payload class
(see the module docstring's table), and returns them as hex under the raw-payload keys
the rest of the app reads:
* ``dest_hash`` — the recipient's one-byte key hash (every addressed class).
* ``src_hash`` — the sender's one-byte key hash (the two-hash classes).
* ``src_key`` — the sender's *whole* public key, which an anonymous request carries
instead of a hash (it has no shared secret to be recognised by yet).
* ``ack_crc`` — an ack's four-byte checksum of the message it acknowledges, hex in
wire order: the same form the companion reports its *own* delivery acks in, so the
two can be compared.
* ``trace_tag`` — a trace's tag, the token its reply is matched by. Read as the
little-endian ``uint32`` the firmware writes and shown as eight hex digits, so it
reads as the same number a trace reply's ``tag`` does rather than byte-reversed.
* ``chan_hash``/``cipher_mac``/``crypted`` — a channel datagram's envelope, the same
three fields the library breaks a channel *text* frame into.
Hashes and keys are the bytes as they sit on the wire, hex-encoded — a hash reads
exactly as the path hops beside it do.
Args:
payload: The frame's raw payload, as the RX-log event carried it (needs
``payload_typename`` and the undecoded ``pkt_payload`` bytes; ``payload_ver``
is honoured when present).
Returns:
The recovered fields, or an empty mapping when the class carries no addressing,
the body is missing, the frame announces a payload version these layouts don't
describe, or the body is too short to hold the layout its class promises.
"""
typename = payload.get("payload_typename")
body = payload.get("pkt_payload")
if not typename or not isinstance(body, (bytes, bytearray)):
return {}
version = payload.get("payload_ver")
if version is not None and version != _PAYLOAD_V1:
return {} # a wider hash/MAC: v1's offsets would slice the wrong bytes
body = bytes(body)
hash_w = ENDPOINT_HASH_BYTES
if typename in ADDRESSED_CLASSES:
if len(body) < hash_w * 2 + _MAC:
return {}
return {
"dest_hash": body[:hash_w].hex(),
"src_hash": body[hash_w : hash_w * 2].hex(),
}
if typename == "ANON_REQ":
if len(body) < hash_w + _KEY + _MAC:
return {}
return {
"dest_hash": body[:hash_w].hex(),
"src_key": body[hash_w : hash_w + _KEY].hex(),
}
if typename == "ACK":
if len(body) < _TOKEN:
return {}
return {"ack_crc": body[:_TOKEN].hex()}
if typename == "TRACE":
# tag, auth code, flags — the shortest trace is one that collected no hops yet.
if len(body) < _TOKEN * 2 + 1:
return {}
# The firmware memcpy's the tag straight out of a uint32, and the library reads a
# trace reply's back the same way: little-endian, so the two agree on the number.
return {"trace_tag": f"{int.from_bytes(body[:_TOKEN], 'little'):08x}"}
if typename == "GRP_DATA": # GRP_TXT's twin, which the library leaves undecoded
if len(body) < hash_w + _MAC:
return {}
return {
"chan_hash": body[:hash_w].hex(),
"cipher_mac": body[hash_w : hash_w + _MAC].hex(),
"crypted": body[hash_w + _MAC :].hex(),
}
return {}
+17 -3
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
import sqlite3
from pathlib import Path
SCHEMA_VERSION = 12
SCHEMA_VERSION = 13
_SCHEMA = """
CREATE TABLE IF NOT EXISTS schema_meta (
@@ -130,10 +130,13 @@ CREATE TABLE IF NOT EXISTS observations (
lon REAL,
path TEXT, -- 'packet' rows: comma-separated relay-hop hex hashes
observed_at TEXT NOT NULL,
chan_hash TEXT, -- overheard GRP_TXT frames: channel-hash fingerprint (hex)
chan_hash TEXT, -- overheard channel frames: channel-hash fingerprint (hex)
cipher_mac TEXT, -- …its 2-byte MAC (hex)
crypted TEXT, -- …its ciphertext (hex), so a stored channel text still decrypts
payload_typename TEXT -- 'packet' rows: the frame's payload class (GRP_TXT/TRACE/…)
payload_typename TEXT, -- 'packet' rows: the frame's payload class (GRP_TXT/TRACE/…)
dest TEXT, -- …the recipient's key hash, for an addressed class (hex)
src TEXT, -- …the sender's key hash, or its whole key (anon request)
tag TEXT -- …the frame's own token: an ack's checksum, a trace's tag
);
-- One chat message, sent or received, on a channel or with a contact. Unlike the other
@@ -231,6 +234,17 @@ def _migrate(conn: sqlite3.Connection) -> None:
# origin node — so the dashboard feed can read "channel text" / "trace" instead of
# a bare "?" even when seeded from history. Older rows carry NULL (class unknown).
conn.execute("ALTER TABLE observations ADD COLUMN payload_typename TEXT")
if "dest" not in observation_cols:
# v12 -> v13: 'packet' rows keep what the frame addressed — the recipient's key
# hash, the sender's (a hash, or the whole key an anonymous request carries), and
# the token a tokened class stands on: an ack's checksum, a trace's tag. Decoded
# from the frame body (see meshterm.core.frames), which only a live event carries,
# so — exactly like the channel-text crypto trio above — a replayed row would
# otherwise have nowhere to keep it and the feed's subject lane would go blank the
# moment it was seeded from history. Older rows carry NULL until re-heard.
conn.execute("ALTER TABLE observations ADD COLUMN dest TEXT")
conn.execute("ALTER TABLE observations ADD COLUMN src TEXT")
conn.execute("ALTER TABLE observations ADD COLUMN tag TEXT")
if "public_key" not in observation_cols:
# v11 -> v12: adverts carry the transmitting node's full public key, but only its
# 12-hex prefix was ever stored as the node id. Keep that prefix as the canonical id
+50 -18
View File
@@ -13,6 +13,7 @@ from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional
from ..core.frames import CHANNEL_CLASSES, ENDPOINT_HASH_BYTES
from ..core.models import (
PATH_TRACE_TARGET,
ChatMessage,
@@ -56,25 +57,39 @@ ACTIVITY_DRAWN_BUCKETS = 24
def _packet_raw(row: sqlite3.Row) -> Optional[dict]:
"""Rebuild the minimal raw payload a stored ``packet`` observation is read back with.
Two things a live RX-log event carried in its raw payload are kept per row: the
frame's payload class (``payload_typename``), so a list can name what the packet is,
and — for an overheard ``GRP_TXT`` frame — the three crypto fields it decrypts from
(fingerprint, MAC, ciphertext), so a channel we hold the key for stays readable
straight from history via the same code path a fresh frame takes. A row that stored
neither carries no raw (``None``), exactly as before.
What a live RX-log event carried in its raw payload is kept per row, under the very
keys the event used, so a replayed frame and a fresh one read identically all the way
up: the frame's payload class (``payload_typename``), so a list can name what the
packet is; an overheard channel frame's three crypto fields (fingerprint, MAC,
ciphertext), so a channel we hold the key for stays readable straight from history;
and what the frame addressed (:mod:`~meshterm.core.frames`) — the recipient, the
sender, the token — restored to the key each was decoded under. The stored sender is
a hash or a whole public key, and the stored token an ack's checksum or a trace's tag;
the value's own width and the frame's class say which, so neither needed a second
column to be told apart. A row that stored none of it carries no raw (``None``).
"""
typename = _row_value(row, "payload_typename")
chan_hash = row["chan_hash"]
if not typename and not chan_hash:
dest = _row_value(row, "dest")
src = _row_value(row, "src")
tag = _row_value(row, "tag")
if not any((typename, chan_hash, dest, src, tag)):
return None
raw: dict = {}
if typename:
raw["payload_typename"] = typename
if chan_hash:
raw.setdefault("payload_typename", "GRP_TXT")
raw.setdefault("payload_typename", "GRP_TXT") # pre-v10 rows: only channel text kept one
raw["chan_hash"] = chan_hash
raw["cipher_mac"] = row["cipher_mac"]
raw["crypted"] = row["crypted"]
if dest:
raw["dest_hash"] = dest
if src:
# A hash is one byte; anything longer is the whole key an anonymous request carries.
raw["src_key" if len(src) > 2 * ENDPOINT_HASH_BYTES else "src_hash"] = src
if tag:
raw["trace_tag" if typename == "TRACE" else "ack_crc"] = tag
return raw
@@ -930,10 +945,18 @@ class Repository:
path the packet traversed — the raw material of the topology graph (see
:meth:`packet_paths`).
An overheard channel-text (``GRP_TXT``) frame also keeps the three fields the
packet viewer decrypts from — the channel-hash fingerprint, the 2-byte MAC, and
the ciphertext — lifted out of the raw payload so a channel we hold the key for
is still readable when the feed is later seeded from stored history.
An overheard channel frame also keeps the three fields the packet viewer decrypts
from — the channel-hash fingerprint, the 2-byte MAC, and the ciphertext — lifted
out of the raw payload so a channel we hold the key for is still readable when the
feed is later seeded from stored history, and so a channel *datagram*, which the
library never breaks out at all, can still be named by the key its MAC confirms.
What the frame addressed is kept the same way, and for the same reason: the
recipient's key hash, the sender's (a hash, or the whole key an anonymous request
carries), and a tokened class's own token — an ack's checksum, a trace's tag. All
three are decoded from the frame body (:mod:`~meshterm.core.frames`) that only a
live event carries, so a replayed row would otherwise lose everything it had to
say about itself.
Args:
run_id: The owning run.
@@ -942,17 +965,23 @@ class Repository:
chan_hash = cipher_mac = crypted = None
raw = obs.raw if isinstance(obs.raw, dict) else {}
# The payload class identifies a relayed 'packet' that names no origin node — kept
# for every packet, not only channel text; the crypto trio is kept for GRP_TXT alone.
# for every packet, not only the classed ones the fields below apply to.
typename = raw.get("payload_typename") if obs.kind == "packet" else None
if raw.get("payload_typename") == "GRP_TXT":
if typename in CHANNEL_CLASSES:
chan_hash = raw.get("chan_hash")
cipher_mac = raw.get("cipher_mac")
crypted = raw.get("crypted")
# One column each for the three shapes of addressing: the sender is a hash or a
# whole key (its length says which), the token an ack's checksum or a trace's tag
# (the payload class says which) — so neither needs a column of its own.
dest = raw.get("dest_hash") if typename else None
src = (raw.get("src_key") or raw.get("src_hash")) if typename else None
tag = (raw.get("ack_crc") or raw.get("trace_tag")) if typename else None
self._conn.execute(
"INSERT INTO observations "
"(run_id, node, public_key, name, kind, node_type, snr, rssi, lat, lon, path, "
"observed_at, chan_hash, cipher_mac, crypted, payload_typename) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
"observed_at, chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
run_id,
obs.node,
@@ -970,6 +999,9 @@ class Repository:
cipher_mac,
crypted,
typename,
dest,
src,
tag,
),
)
self._conn.commit()
@@ -1007,7 +1039,7 @@ class Repository:
"""
rows = self._conn.execute(
"SELECT node, name, kind, node_type, snr, rssi, lat, lon, path, observed_at, "
"chan_hash, cipher_mac, crypted, payload_typename "
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag "
"FROM observations WHERE observed_at >= ? ORDER BY observed_at DESC LIMIT ?",
(since.isoformat(), limit),
).fetchall()
@@ -1033,7 +1065,7 @@ class Repository:
"""
rows = self._conn.execute(
"SELECT node, name, kind, node_type, snr, rssi, lat, lon, path, observed_at, "
"chan_hash, cipher_mac, crypted, payload_typename "
"chan_hash, cipher_mac, crypted, payload_typename, dest, src, tag "
"FROM observations WHERE kind = 'packet' AND observed_at >= ? "
"AND observed_at <= ? ORDER BY observed_at ASC LIMIT ?",
(start.isoformat(), end.isoformat(), limit),
+204 -43
View File
@@ -3,13 +3,19 @@
The interactive face of the ``livefeed`` tool — the dashboard's old feed panel,
promoted to a first-class screen. One always-repainting list streams the latest
packets, newest first: time, class (icon + label, icon alone on a narrow terminal),
node, SNR/RSSI, and a message's conversation. What a row deliberately does *not* carry
subject, SNR/RSSI, and a message's conversation. What a row deliberately does *not* carry
is the relay path a frame rode in on: a route is a shape, not a lane, and squeezing one
into the cells left at the right edge only ever produced a stub — so the route belongs
to the packet viewer, which has the room to draw it as a wrapped path line over its
graph. The feed's job is to say what arrived and how well it was heard; Enter says how
it got here.
The subject lane is contextual: it holds whatever the *class* of packet is about (see
:meth:`LiveFeedScreen._feed_subject`). An advert or a telemetry frame is about the node
that sent it, so the lane names the node — but a channel text is about its channel, a
direct message or a request about the two nodes it travels between, a trace about its
tag. Only a class that genuinely says nothing about itself leaves the lane empty.
Seeded from stored history so the screen opens full, then streamed
live off the event hub. ``↑``/``↓`` walk the rows, PgUp/PgDn/Home/End page and jump
them, ``←``/``→`` scroll the highlighted row sideways on a terminal too narrow to hold
@@ -28,12 +34,13 @@ from __future__ import annotations
import asyncio
from collections import deque
from typing import TYPE_CHECKING, Any, Optional, Sequence
from typing import TYPE_CHECKING, Any, Mapping, Optional, Sequence
from rich.text import Text
from ..core.channels import split_channel_sender
from ..core.channels import identify_channel, split_channel_sender
from ..core.events import EventKind, MeshEvent
from ..core.frames import CHANNEL_CLASSES, ENDPOINT_HASH_BYTES
from ..core.models import Observation, utcnow
from ..persistence.repository import OBSERVATION_WINDOW
from .menus import fit_cells
@@ -44,6 +51,7 @@ from .packet_viewer import (
class_marks,
node_label,
)
from .pathline import PathHop, PathLine
from .theme import name_style, snr_style
from .tui.render import crop_cells, render_to_ansi
from .tui.screen import ListWindow, Screen
@@ -60,8 +68,12 @@ _REFRESH_S = 1.0
#: history depth, not layout).
_FEED_CAP = 100
#: The feed's fixed node-name lane width; longer names ellipsize so the columns hold.
_FEED_NAME_WIDTH = 18
#: The feed's fixed subject-lane width; anything longer ellipsizes so the columns hold.
#: Sized for a node name, which is what most classes put here — and wide enough that the
#: two endpoints of an addressed frame still read as ``a1 → YUL-Cartierv…`` rather than
#: collapsing to bare hashes. Widening it would push the row past the 72-column screen
#: the lanes are budgeted for, so the cells the lane can't hold are the viewer's to spend.
_FEED_SUBJECT_WIDTH = 18
#: The class lane's fixed width — wide enough for the longest class the app files a
#: packet under (``direct message``), so every label reads whole and the lanes hold.
@@ -90,7 +102,7 @@ _RSSI_LANE = _LANE_GAP + _READING_W + len(" dBm")
#: field now that it names the real class) is only ever dropped when it truly can't fit.
_FEED_LABEL_MIN_WIDTH = (
2 + _TIME_LANE + _ICON_LANE + _FEED_CLASS_WIDTH + _LANE_GAP
+ _FEED_NAME_WIDTH + _LANE_GAP + _SNR_LANE + _RSSI_LANE
+ _FEED_SUBJECT_WIDTH + _LANE_GAP + _SNR_LANE + _RSSI_LANE
)
#: Cells one ←/→ press shifts the highlighted row by — the app-wide select list's own
@@ -132,6 +144,7 @@ class LiveFeedScreen(Screen):
prefix_bytes: int = 0,
self_name: Optional[str] = None,
channels: Sequence[tuple[str, bytes]] = (),
channel_names: Optional[Mapping[int, str]] = None,
type_of: Optional[TypeOf] = None,
key_of: Optional[NameKeyResolver] = None,
) -> None:
@@ -145,9 +158,13 @@ class LiveFeedScreen(Screen):
hub_active: Zero-arg callable: whether the event hub is pumping.
prefix_bytes: The hash width to light in the packet viewer's keys.
self_name: Our own node's name, drawn white wherever it appears.
channels: The device's configured channels, as ``(name, secret)`` pairs,
handed to each opened :class:`~meshterm.ui.packet_viewer.PacketViewer`
so it can attempt to decrypt an overheard channel-text packet.
channels: The device's configured channels, as ``(name, secret)`` pairs. The
subject lane names an overheard channel frame's channel by whichever of
these keys reproduces its MAC, and each opened
:class:`~meshterm.ui.packet_viewer.PacketViewer` decrypts with them.
channel_names: The device's channel names by slot index, so a channel message
the radio decoded for us can be filed under the same channel name an
overheard frame on it is — rather than under its slot number.
type_of: Maps a relay hash to its node type, handed to the packet viewer so a
relayed packet's route graph marks a repeater ``▲`` (etc.) over a dot.
key_of: Maps a sender's display name back to its node's key (see
@@ -163,6 +180,7 @@ class LiveFeedScreen(Screen):
self._prefix_bytes = prefix_bytes
self._self_name = self_name
self._channels = channels
self._channel_names: Mapping[int, str] = channel_names or {}
self._type_of = type_of
self._key_of: NameKeyResolver = key_of or (lambda name: None)
#: The feed: latest events of every class as data, newest first — rendered
@@ -194,6 +212,7 @@ class LiveFeedScreen(Screen):
entry = PacketEntry(
when=utcnow(), kind="message", node=msg.sender, snr=msg.snr,
where=f"ch {msg.channel}" if msg.is_channel else "direct",
channel=msg.channel if msg.is_channel else None,
text=msg.text, raw=msg.raw,
)
elif event.kind == EventKind.ACK and event.ack is not None:
@@ -360,6 +379,11 @@ class LiveFeedScreen(Screen):
these are signposts, not controls. On a terminal too narrow for the class label
the header drops its ``CLASS`` too, leaving the icon lane unlabelled rather than
clipping a word into three cells.
The subject lane's label names the lane's *job*, not one class's answer to it:
what a packet is about is a node for an advert, a channel for a channel text, a
pair of endpoints for a direct message. ``SUBJECT`` is the word that stays true
down the whole column.
"""
header = Text(" ", style="muted") # the pointer lane
header.append(fit_cells("TIME", _TIME_LANE))
@@ -368,7 +392,7 @@ class LiveFeedScreen(Screen):
if show_label
else " " * _ICON_LANE
)
header.append(fit_cells("NODE", _FEED_NAME_WIDTH + _LANE_GAP))
header.append(fit_cells("SUBJECT", _FEED_SUBJECT_WIDTH + _LANE_GAP))
# The two readings right-align their number, so their labels do too — each sits
# over the digits it names rather than over the sign column ahead of them.
header.append(fit_cells("SNR", _READING_W, align="right"))
@@ -398,19 +422,18 @@ class LiveFeedScreen(Screen):
def _feed_row(
self, entry: PacketEntry, selected: bool, show_label: bool, width: int
) -> Text:
"""Lay one feed row out in fixed lanes: time, class, node, reception, detail.
"""Lay one feed row out in fixed lanes: time, class, subject, reception, detail.
The class lane says what the packet *is*, straight from
:func:`~meshterm.ui.packet_viewer.class_marks` — so a raw frame reads
``📻 channel text``, the class the viewer's card headlines, rather than the
``📦 packet`` event family it merely arrived in. Its icon always shows; the
textual label beside it is dropped wholesale on a narrow terminal
(``show_label``), keeping the lanes aligned either way. The node lane is then
free to be about the node: its name in the app-wide palette hue (our own node
white, a bare hash muted), and a dash where nobody identified themselves. No
relay path rides here — the whole row fits a 72-column screen precisely because
it doesn't try to, and the route is drawn properly one keypress away, in the
packet viewer.
(``show_label``), keeping the lanes aligned either way. The subject lane beside it
then answers what the packet is *about*, in whatever terms its class deals in
(:meth:`_feed_subject`). No relay path rides here — the whole row fits a
72-column screen precisely because it doesn't try to, and the route is drawn
properly one keypress away, in the packet viewer.
The highlight is the app's own: the ``❯`` pointer and a brand base style under the
whole row, exactly as every select list draws its cursor. The lanes keep their own
@@ -441,10 +464,11 @@ class LiveFeedScreen(Screen):
style=KIND_STYLES.get(entry.kind, "brand"),
)
body.append(" " * _LANE_GAP) # the lane's gutter — the longest class fills it
label, style = node_label(entry, self._resolve, self._self_name)
if label == "?":
label, style = self._feed_subject(entry) # no node identity: name what we can
body.append(fit_cells(label, _FEED_NAME_WIDTH), style=style)
subject = self._feed_subject(entry)
# The lane fits like every other: cells, not characters, ellipsis on overflow —
# but as a Text, so a subject built of several styled pieces keeps them.
subject.truncate(_FEED_SUBJECT_WIDTH, overflow="ellipsis", pad=True)
body.append_text(subject)
body.append(" " * _LANE_GAP)
body.append(
f"{entry.snr:+{_READING_W}.1f} dB" if entry.snr is not None
@@ -473,29 +497,156 @@ class LiveFeedScreen(Screen):
row.style = "brand"
return row
def _feed_subject(self, entry: PacketEntry) -> tuple[str, str]:
"""Name the node lane when an entry carries no resolvable node identity.
def _feed_subject(self, entry: PacketEntry) -> Text:
"""What this packet is *about*, in the terms its own class deals in.
Rather than a useless ``?``, the lane reads the most identifying thing the entry
still carries: the sender a channel message named itself with, then an ack's
code. What it deliberately does *not* do is stand in the packet's class — the
class lane two columns left already says that, straight from
The lane used to be a node lane, which suited the two classes that name a node and
left every other one reading ``—``: on a real mesh that is half the traffic —
the direct messages, requests, responses, path returns, acks and traces that
relay past us naming no origin. Each of those is about *something*, just not
about a node, so the lane asks each class its own question:
* **advert, telemetry, a direct message we received** — about the node that sent
it, so the lane names the node (:func:`~meshterm.ui.packet_viewer.node_label`):
its palette hue, our own node white, a bare hash muted.
* **channel text and channel data** — about the *channel*, named by the key whose
MAC confirms the frame (:func:`~meshterm.core.channels.identify_channel`); a
channel we hold no key for falls back to the fingerprint it advertises.
* **direct message, request, response, returned path, anonymous request** —
about the pair of nodes it travels between, drawn as ``sender → recipient``
(:meth:`_endpoints`).
* **ack** — about the message it acknowledges, named by that message's checksum.
* **trace** — about the walk it is collecting, named by its tag.
* **a channel message** — about whoever signed it, else the channel it arrived on.
What the lane deliberately does *not* do is stand in the packet's class: the class
lane two columns left already says that, straight from
:func:`~meshterm.ui.packet_viewer.class_marks`, and a row that spelled it twice
was reading ``packet`` beside ``channel text`` as though they were two facts.
A relayed flood names no origin, so its lane says so with a dash — the route that
would name nodes is the packet viewer's to draw, not a stub for this row to carry.
read ``packet`` beside ``channel text`` as though they were two facts. A class that
genuinely says nothing about itself still gets the honest dash.
Args:
entry: The packet the row describes.
Returns:
The lane's content, unsized — the caller fits it to the lane.
"""
label, style = node_label(entry, self._resolve, self._self_name)
if label != "?": # the packet named a node: that is what it is about
return Text(label, style=style)
if entry.kind == "message":
sender = _channel_sender(entry.text)
if sender:
ours = self._self_name and sender == self._self_name
# A resolvable sender takes its key-derived hue; a stranger stays
# muted — colour is reserved for keyed identities.
return sender, ("you" if ours else name_style(sender, self._key_of(sender)))
return "channel", "muted"
return self._message_subject(entry)
raw = entry.raw if isinstance(entry.raw, dict) else {}
if raw.get("payload_typename") in CHANNEL_CLASSES:
return self._channel_subject(raw)
if raw.get("dest_hash"):
return self._endpoints(raw)
if raw.get("trace_tag"):
return self._token("tag", raw["trace_tag"])
if raw.get("ack_crc"):
# An ack names no node at all — what it identifies is the message it answers.
return self._token("for", raw["ack_crc"])
if entry.where:
return entry.where, "muted" # an ack's code, or any other stray context
return "—", "muted"
return Text(entry.where, style="muted") # a delivery ack's code
return Text("—", style="muted")
def _message_subject(self, entry: PacketEntry) -> Text:
"""A chat message's subject: whoever signed it, else the channel it arrived on."""
sender = _channel_sender(entry.text)
if sender:
ours = self._self_name and sender == self._self_name
# A resolvable sender takes its key-derived hue; a stranger stays muted —
# colour is reserved for keyed identities.
style = "you" if ours else name_style(sender, self._key_of(sender))
return Text(sender, style=style)
channel = self._channel_names.get(entry.channel) if entry.channel is not None else None
if channel: # an unsigned post: the channel it landed on is what it is about
return Text(channel, style="brand") # named exactly as an overheard frame's is
if entry.where:
return Text(entry.where, style="muted") # …or the slot, when we can't name it
return Text("—", style="muted")
def _channel_subject(self, raw: dict) -> Text:
"""A channel frame's subject: its channel, named by the key its MAC confirms.
The frame names its channel only by a one-byte fingerprint, which several channels
can share — so a name is only shown once a key we hold has reproduced the frame's
own MAC (:func:`~meshterm.core.channels.identify_channel`), never on a fingerprint
match alone. A channel we hold no key for reads as the fingerprint it advertised,
under the app's word for a short derived id: ``hash a3``. That is deliberately not
spelled ``ch a3`` — a message row's ``ch 3`` is a *slot*, and two different numbers
wearing one prefix in the same column would be worse than saying nothing.
"""
named = identify_channel(
raw.get("chan_hash") or "", raw.get("cipher_mac") or "",
raw.get("crypted") or "", self._channels,
)
if named is not None:
return Text(named[0], style="brand")
if raw.get("chan_hash"):
return self._token("hash", raw["chan_hash"])
return Text("—", style="muted")
def _endpoints(self, raw: dict) -> Text:
"""An addressed frame's subject: the two nodes it travels between.
Drawn as ``sender → recipient`` on THE hop-sequence widget
(:mod:`~meshterm.ui.pathline`), so the endpoints of a delivery read exactly as the
hops of a route do — resolved to contact names, each in its key-derived hue, our
own node white the moment a frame is addressed to (or from) us. Plain arrows, not
chips: this is a lane inside a table row, and a chip's padding would cost the very
cells the names need.
Both endpoints are named by a single byte of their key — the same one-byte identity
a relay hop carries, with the same collision odds — so a name here is a good guess,
not a proof, and the frame's own hashes are always one keypress away in the viewer.
An anonymous request is the exception in our favour: it carries its sender's *whole*
key, which resolves exactly, and (unresolved) is shown cut to the same one byte
as everything else in the lane rather than 64 hex digits nobody can read at a
glance.
When both names don't fit the lane the recipient keeps its name and the sender
drops to its hash: the frame is *addressed*, so the addressee is the half worth
the cells.
"""
dest = raw.get("dest_hash") or ""
src = raw.get("src_key") or raw.get("src_hash") or ""
if not src:
return self._token("to", dest)
named = PathLine([self._hop(src), self._hop(dest)], mode="plain").text()
if named.cell_len <= _FEED_SUBJECT_WIDTH:
return named
# Too wide: the sender drops to its hash, so the addressee keeps its name whole (or
# as much of it as the lane holds) instead of being the half that gets amputated.
return PathLine([self._hop(src, named=False), self._hop(dest)], mode="plain").text()
def _hop(self, value: str, *, named: bool = True) -> PathHop:
"""One endpoint as a path hop: its contact name, or its one-byte hash in that hue.
Args:
value: The endpoint's key hash (or, for an anonymous request's sender, its
whole key — shown cut to the lane's one-byte width when it can't be named).
named: Resolve it at all. ``False`` forces the hash, for the crowded-lane
fallback that spends the cells on the other end.
"""
short = value[: 2 * ENDPOINT_HASH_BYTES]
name = self._resolve(value) if named else None
if name and name != value:
return PathHop(name, key=value, you=bool(self._self_name and name == self._self_name))
return PathHop(short, key=value, lit_bytes=ENDPOINT_HASH_BYTES)
@staticmethod
def _token(word: str, value: str) -> Text:
"""A frame's own token under the one word that says what it is.
A trace's tag, an ack's checksum, a channel's fingerprint: hex that identifies
something without being a node's identity, so it takes no palette hue — the
qualifier recedes into ``faint`` and the value itself stays muted-legible.
"""
text = Text(f"{word} ", style="faint")
text.append(value, style="muted")
return text
def _feed_note(self, entry: PacketEntry) -> Optional[Text]:
"""The row's trailing detail — a message's conversation, and nothing else.
@@ -507,15 +658,21 @@ class LiveFeedScreen(Screen):
the route graph, with the room to say the whole thing. So the feed says what
arrived; Enter says how it got here.
A conversation the subject lane already stood in for is not repeated here: an
unsigned channel post files itself *under* its channel, and a row that named the
same conversation twice read as two facts.
Args:
entry: The packet the row describes.
Returns:
The detail to append, or ``None`` when the class carries none.
"""
if entry.kind == "message" and entry.where:
return Text(entry.where, style="muted")
return None
if entry.kind != "message" or not entry.where:
return None
if entry.channel is not None and not _channel_sender(entry.text):
return None # the subject lane is already the conversation
return Text(entry.where, style="muted")
async def open_livefeed(ctx: "AppContext") -> None:
@@ -542,6 +699,7 @@ async def open_livefeed(ctx: "AppContext") -> None:
contacts = []
self_name: Optional[str] = None
channels: list[tuple[str, bytes]] = []
channel_names: dict[int, str] = {}
try:
if ctx.is_connected or ctx.settings.connect_on_start:
# Through the session cache: contacts and the channel-slot probe are the two
@@ -549,7 +707,9 @@ async def open_livefeed(ctx: "AppContext") -> None:
# is what keeps navigation snappy over Bluetooth.
contacts = await ctx.devstate.contacts()
self_name = (await ctx.devstate.self_info()).get("name") or None
channels = [(s.name, s.secret) for s in await ctx.devstate.channel_slots()]
slots = await ctx.devstate.channel_slots()
channels = [(s.name, s.secret) for s in slots]
channel_names = {s.idx: s.name for s in slots}
except Exception: # noqa: BLE001 - the feed renders fine without contact names
contacts = []
# Contacts first, every name the recorder ever overheard as the fallback — the
@@ -568,6 +728,7 @@ async def open_livefeed(ctx: "AppContext") -> None:
prefix_bytes=prefix_bytes,
self_name=self_name,
channels=channels,
channel_names=channel_names,
type_of=type_of,
key_of=key_of,
)
+77 -5
View File
@@ -5,7 +5,9 @@ the rows share this module's per-kind chrome — a colour and a two-cell icon pe
class — and every row can open into the same viewer: a centered dialog over the list
that lays the packet out in full, flavoured by kind. An advert shows the node's
identity, type, and location; telemetry shows the node and its reported values; an
RX-logged packet shows its parsed class and route plus the relay path it rode in on —
RX-logged packet shows its parsed class and route, what it was addressed to (the
recipient and sender read out of the frame body — see :mod:`~meshterm.core.frames` — or
the token a trace or an ack stands on), plus the relay path it rode in on —
as a ``via`` chain on THE path line (:mod:`~meshterm.ui.pathline`), wrapping at hop
boundaries under its own lane, and, when it actually crossed a relay, as THE route graph
(:mod:`~meshterm.ui.pathgraph`): origin → relays → us, the same layered picture the
@@ -36,7 +38,7 @@ from rich.console import RenderableType
from rich.table import Table
from rich.text import Text
from ..core.channels import decrypt_channel_text
from ..core.channels import decrypt_channel_text, identify_channel
from ..core.models import NODE_TYPE_LABELS, Observation
from ..services.trace_runner import NodeResolver
from .map_render import _SELF, _UNKNOWN
@@ -88,6 +90,7 @@ _RAW_ROW_SKIP = frozenset({
"pkt_payload", "pkt_hash", "raw_hex", "payload", "payload_length", "recv_time",
"chan_hash", "cipher_mac", "crypted", "message", "msg_hash", "sender_timestamp",
"attempt", "txt_type",
"dest_hash", "src_hash", "src_key", "trace_tag", "ack_crc",
"adv_key", "adv_name", "adv_type", "adv_lat", "adv_lon",
})
@@ -159,6 +162,8 @@ class PacketEntry:
path: A ``packet`` row's relay path: comma-separated hop hashes in propagation
order (empty = arrived direct; ``None`` = the class carries no path).
where: A message's conversation (``ch 3`` / ``direct``) or an ack's code.
channel: A channel message's slot index — the fact behind ``where``'s prose, kept
apart from it so a list can name the channel rather than number it.
text: A message's body, when it is known.
raw: The raw event payload, for the values a class-specific layout can't name.
"""
@@ -174,6 +179,7 @@ class PacketEntry:
node_type: Optional[int] = None
path: Optional[str] = None
where: Optional[str] = None
channel: Optional[int] = None
text: Optional[str] = None
raw: Optional[dict] = None
@@ -206,7 +212,7 @@ def payload_class(raw: Optional[dict]) -> Optional[str]:
Maps the frame's ``payload_typename`` (``GRP_TXT``, ``TRACE``, …) through
:data:`_PAYLOAD_GLOSS` — the one identifying thing a relayed flood carries when it
names no origin node, so a packet list can read "channel text" / "trace" instead of a
bare ``?``. Shared by the viewer's "class" row and the dashboard feed's node lane.
bare ``?``.
"""
if not isinstance(raw, dict):
return None
@@ -654,12 +660,13 @@ class PacketViewer(Screen):
)
def _packet_rows(self, entry: PacketEntry) -> list[tuple[str, RenderableType]]:
"""A raw ``packet`` entry's parsed class/route, relay path, and — for a
channel-text frame naming a channel we hold the key for — its plaintext.
"""A raw ``packet`` entry's addressing, parsed class/route, relay path, and — for a
channel frame naming a channel we hold the key for — its channel and plaintext.
"""
raw = entry.raw if isinstance(entry.raw, dict) else {}
rows: list[tuple[str, RenderableType]] = []
typename = raw.get("payload_typename") # class itself leads the card (class_chrome)
rows.extend(self._addressing_rows(raw))
route = raw.get("route_typename")
if route:
rows.append(("route", Text(route.replace("_", " ").lower())))
@@ -678,8 +685,73 @@ class PacketViewer(Screen):
rows.append(("", revisits))
if typename == "GRP_TXT":
rows.extend(self._decrypt_rows(raw))
elif typename == "GRP_DATA":
# A datagram's body is not text, so there is nothing to decode for a reader —
# but the same MAC that would authorise decrypting it names its channel.
named = identify_channel(
raw.get("chan_hash") or "", raw.get("cipher_mac") or "",
raw.get("crypted") or "", self._channels,
)
if named is not None:
rows.append(("channel", Text(named[0], style="brand")))
elif raw.get("chan_hash"):
rows.append((
"channel",
Text(f"unknown (hash {raw['chan_hash']})", style="muted"),
))
return rows
def _addressing_rows(self, raw: dict) -> list[tuple[str, RenderableType]]:
"""Who a frame was for, who it says it was from, and the token it carries.
The fields :mod:`~meshterm.core.frames` reads out of the frame body, laid out as
the card's own rows rather than left to the raw dump at the bottom — a recipient is
a node, and a node belongs under a resolved name and THE hash widget, not as a bare
hex value in a debug list. Each endpoint is named by one byte of its key, so the
name is the same good guess a relay hop's is (and the lit hash beside it says
exactly which byte was matched); an anonymous request's sender carries its whole
key, which resolves outright.
Args:
raw: The frame's raw payload.
Returns:
The rows to slot ahead of the route/via block (empty for a class that
addresses nothing).
"""
rows: list[tuple[str, RenderableType]] = []
dest = raw.get("dest_hash")
if dest:
rows.append(("to", self._endpoint(dest)))
src = raw.get("src_key") or raw.get("src_hash")
if src:
rows.append(("from", self._endpoint(src)))
if raw.get("trace_tag"):
rows.append(("tag", Text(raw["trace_tag"], style="muted")))
if raw.get("ack_crc"):
# An ack identifies the message it answers, by that message's own checksum.
rows.append(("acks", Text(raw["ack_crc"], style="muted")))
return rows
def _endpoint(self, value: str) -> Text:
"""One end of an addressed frame: its resolved name, then the key it was named by.
The ``from`` row's own presentation, minus the node-type glyph — a one-byte hash
is too thin an identity to plant a type mark on. A node we can't name shows the
hash alone, lit by :func:`~meshterm.ui.widgets.highlighted_hash` like every other
hash on the card.
"""
text = Text()
named = self._resolve(value)
if named and named != value:
style = "you" if self._self_name and named == self._self_name else name_style(
named, value
)
text.append(named, style=style)
text.append(" ")
text.append_text(highlighted_hash(value, self._prefix_bytes or 1))
return text
def _decrypt_rows(self, raw: dict) -> list[tuple[str, RenderableType]]:
"""Try every known channel's key against an overheard channel-text frame.
+137
View File
@@ -0,0 +1,137 @@
"""Frame-body decoding: what an overheard packet is addressed to.
The companion's RX log hands us every frame's header parsed and its body raw; these
cover the layouts :mod:`meshterm.core.frames` reads out of that body, the length checks
that keep a truncated frame from inventing addressing, and the round trip through
storage — the feed opens on stored history, so what a live frame said about itself has to
survive being written down and read back.
"""
from __future__ import annotations
from datetime import timedelta
from pathlib import Path
from meshterm.core.connection import packet_observation_from_event
from meshterm.core.frames import frame_addressing
from meshterm.core.models import Observation, utcnow
from meshterm.persistence.repository import Repository
class _Event:
"""A meshcore event stand-in: anything exposing a ``payload`` mapping."""
def __init__(self, **payload) -> None:
self.payload = payload
def _frame(typename: str, body: bytes, **extra) -> dict:
return {"payload_typename": typename, "pkt_payload": body, **extra}
def test_addressed_classes_name_both_ends() -> None:
"""A direct message, request, response or returned path opens with dest then src."""
body = bytes.fromhex("3da1") + b"\xab\xcd" + b"\x00" * 16
for typename in ("TEXT_MSG", "REQ", "RESPONSE", "PATH"):
assert frame_addressing(_frame(typename, body)) == {
"dest_hash": "3d", "src_hash": "a1",
}
def test_anonymous_request_carries_its_senders_whole_key() -> None:
"""No shared secret yet, so the sender identifies itself in full — 32 bytes of key."""
key = bytes(range(32))
decoded = frame_addressing(_frame("ANON_REQ", b"\x3d" + key + b"\xab\xcd" + b"\x00" * 16))
assert decoded == {"dest_hash": "3d", "src_key": key.hex()}
def test_tokened_classes_name_their_token() -> None:
"""An ack carries the checksum of what it answers; a trace carries its tag.
The ack's checksum keeps wire order — the form the companion reports its own delivery
acks in — while the trace's tag is the little-endian ``uint32`` the firmware wrote, so
it reads as the same number a trace reply's ``tag`` field does.
"""
assert frame_addressing(_frame("ACK", bytes.fromhex("9b71e004"))) == {
"ack_crc": "9b71e004",
}
trace = bytes.fromhex("102a3c5f") + bytes.fromhex("deadbeef") + b"\x01"
assert frame_addressing(_frame("TRACE", trace)) == {"trace_tag": "5f3c2a10"}
def test_a_future_payload_version_is_left_undecoded() -> None:
"""v2 widens the hashes and the MAC, so v1's offsets would slice the wrong bytes."""
body = bytes.fromhex("3da1") + b"\xab\xcd" + b"\x00" * 16
assert frame_addressing(_frame("TEXT_MSG", body, payload_ver=0))["dest_hash"] == "3d"
assert frame_addressing(_frame("TEXT_MSG", body, payload_ver=1)) == {}
assert frame_addressing(_frame("TEXT_MSG", body))["dest_hash"] == "3d" # unstated: v1
def test_channel_datagram_is_broken_out_like_a_channel_text() -> None:
"""``GRP_DATA`` shares the channel envelope the library only decodes for ``GRP_TXT``."""
decoded = frame_addressing(_frame("GRP_DATA", b"\xa3\xab\xcd" + bytes(range(16))))
assert decoded == {
"chan_hash": "a3", "cipher_mac": "abcd", "crypted": bytes(range(16)).hex(),
}
def test_a_body_too_short_for_its_layout_decodes_to_nothing() -> None:
"""Better a silent lane than a plausible hash sliced out of the wrong bytes."""
assert frame_addressing(_frame("TEXT_MSG", b"\x3d")) == {}
assert frame_addressing(_frame("ANON_REQ", b"\x3d" + bytes(20))) == {}
assert frame_addressing(_frame("ACK", b"\x9b")) == {}
assert frame_addressing(_frame("TRACE", bytes(6))) == {}
assert frame_addressing(_frame("GRP_DATA", b"\xa3")) == {}
def test_classes_that_address_nothing_stay_silent() -> None:
"""An advert names its node in the header; multipart and control say nothing at all."""
for typename in ("ADVERT", "MULTIPART", "CONTROL", "UNK"):
assert frame_addressing(_frame(typename, bytes(64))) == {}
assert frame_addressing({"payload_typename": "TEXT_MSG"}) == {} # no body at all
assert frame_addressing({"pkt_payload": bytes(20)}) == {} # no class at all
def test_rx_log_events_carry_their_addressing_into_the_observation() -> None:
"""The decode happens once, at the edge, so every reader downstream sees the same keys."""
event = _Event(
payload_typename="TEXT_MSG", pkt_payload=bytes.fromhex("3da1abcd") + bytes(16),
path_len=1, path_hash_size=1, path="3d", snr=6.5,
)
obs = packet_observation_from_event(event)
assert obs is not None and obs.raw is not None
assert obs.raw["dest_hash"] == "3d" and obs.raw["src_hash"] == "a1"
assert obs.node is None # an addressed frame still names no *origin* node
def test_stored_frames_remember_what_they_addressed(tmp_path: Path) -> None:
"""A replayed row reads back exactly as the live frame did — same keys, same values.
The feed opens seeded from history, so addressing that lived only in the raw payload
of a live event would leave every row on the opening screen blank.
"""
repo = Repository(tmp_path / "frames.db")
run = repo.start_run("monitor", {}, None)
frames = {
"TEXT_MSG": {"dest_hash": "3d", "src_hash": "a1"},
"ANON_REQ": {"dest_hash": "3d", "src_key": bytes(range(32)).hex()},
"TRACE": {"trace_tag": "5f3c2a10"},
"ACK": {"ack_crc": "9b71e004"},
"GRP_DATA": {"chan_hash": "a3", "cipher_mac": "abcd", "crypted": "00" * 16},
}
for typename, decoded in frames.items():
repo.record_observation(
run,
Observation(
node=None, kind="packet", path="3d",
raw={"payload_typename": typename, **decoded},
),
)
stored = repo.recent_observations(since=utcnow() - timedelta(hours=2))
read_back = {(o.raw or {}).get("payload_typename"): (o.raw or {}) for o in stored}
assert set(read_back) == set(frames)
for typename, decoded in frames.items():
for key, value in decoded.items():
assert read_back[typename][key] == value, (typename, key)
repo.close()
+108 -15
View File
@@ -24,12 +24,18 @@ class _FakeSession:
self.repaints += 1
def _screen(seed=None, active=True) -> LiveFeedScreen:
#: Nodes the feed's resolver can name, by the hash a frame addresses them with (one byte)
#: and by the wider prefixes an advert carries.
_KNOWN = {"a1b2": "Alice", "3d63": "YUL", "a1": "Alice", "3d": "YUL", "c0": "Us"}
def _screen(seed=None, active=True, **kwargs) -> LiveFeedScreen:
screen = LiveFeedScreen(
session=_FakeSession(),
resolve=lambda h: {"a1b2": "Alice", "3d63": "YUL"}.get(h, ""),
resolve=lambda h: _KNOWN.get(h, ""),
seed=list(seed or []),
hub_active=lambda: active,
**kwargs,
)
screen.note_viewport(48) # the frame records this before every real paint
return screen
@@ -105,30 +111,31 @@ def test_livefeed_rows_leave_the_relay_path_to_the_viewer() -> None:
def test_livefeed_class_lane_names_the_payload_class_once() -> None:
"""A raw frame is filed under what it *is*, and the node lane doesn't repeat it.
"""A raw frame is filed under what it *is*, and the subject lane doesn't repeat it.
``packet`` names only the event family the frame arrived in, so the class lane
reads its parsed payload class — the very label the viewer's card headlines. The
node lane is then free to be about the node, and an origin-less flood says so with
a dash rather than standing the class in a second time.
subject lane is then free to be about the packet's own subject, and a class that
carries nothing to be about says so with a dash rather than standing the class in a
second time.
"""
screen = _screen()
raw = {"payload_typename": "TRACE", "route_typename": "FLOOD"}
raw = {"payload_typename": "MULTIPART", "route_typename": "FLOOD"}
screen.on_event(
MeshEvent.observation_event(
_obs(node="", kind="packet", snr=1.0, path="3d63,a1b2", raw=raw)
)
)
row = _rows(screen, 100)[0]
assert "🎯 trace" in row # the class lane, under the payload class's own icon
assert row.count("trace") == 1 # said once, not once per lane
assert "packet" not in row # …and never as the generic event family
assert "—" in row # the node lane: nobody identified themselves
assert "?" not in row # …but never the useless placeholder
assert "🧩 multipart" in row # the class lane, under the payload class's own icon
assert row.count("multipart") == 1 # said once, not once per lane
assert "packet" not in row # …and never as the generic event family
assert "—" in row # the subject lane: the class is about nothing
assert "?" not in row # …but never the useless placeholder
def test_livefeed_names_a_channel_message_by_its_sender() -> None:
"""A channel message's ``Name:`` prefix names the node lane, not the bare channel."""
"""A channel message's ``Name:`` prefix names the subject lane, not the bare channel."""
screen = _screen()
screen.on_event(
MeshEvent.message_event(Message(text="Alice: hi all", channel=3, is_channel=True))
@@ -138,16 +145,102 @@ def test_livefeed_names_a_channel_message_by_its_sender() -> None:
assert "ch 3" in body # …with the channel kept as the trailing context
def test_livefeed_unsigned_channel_post_is_filed_under_its_channel() -> None:
"""With nobody signing it, a channel message is about the channel — named, once."""
screen = _screen(channel_names={3: "Alerts"})
screen.on_event(
MeshEvent.message_event(Message(text="beep boop", channel=3, is_channel=True))
)
row = _rows(screen, 100)[0]
assert "Alerts" in row # the channel name, not its slot number
assert row.count("Alerts") == 1 # …and the trailing note doesn't say it again
assert "ch 3" not in row
def test_livefeed_channel_frame_is_about_its_channel() -> None:
"""An overheard channel frame is filed under the channel whose key confirms its MAC."""
from Crypto.Hash import HMAC, SHA256
from meshterm.core.channels import channel_hash, derive_secret
secret = derive_secret("Public")
crypted = bytes(range(16))
mac = HMAC.new(secret, digestmod=SHA256)
mac.update(crypted)
raw = {
"payload_typename": "GRP_TXT", "chan_hash": channel_hash(secret),
"cipher_mac": mac.digest()[:2].hex(), "crypted": crypted.hex(),
}
screen = _screen(channels=[("Public", secret)])
screen.on_event(MeshEvent.observation_event(_obs(node="", kind="packet", raw=raw)))
assert "Public" in _rows(screen, 100)[0]
# A channel we hold no key for can only be named by the fingerprint it advertised —
# never by a name, since a fingerprint alone is not proof of which channel it is.
other = _screen(channels=[("Public", secret)])
other.on_event(
MeshEvent.observation_event(_obs(node="", kind="packet", raw={**raw, "chan_hash": "a3"}))
)
row = _rows(other, 100)[0]
assert "hash a3" in row and "Public" not in row
def test_livefeed_addressed_frame_is_about_its_two_ends() -> None:
"""A frame that names a recipient reads as sender → recipient, resolved to names."""
screen = _screen(self_name="Us")
raw = {"payload_typename": "TEXT_MSG", "dest_hash": "c0", "src_hash": "a1"}
screen.on_event(MeshEvent.observation_event(_obs(node="", kind="packet", raw=raw)))
row = _rows(screen, 100)[0]
assert "📩 direct message" in row
assert "Alice → Us" in row # …including us, when a frame is addressed to us
# A pair too wide for the lane spends its cells on the addressee: the sender drops to
# the hash it was named by rather than the recipient being the half cut off.
wide = _screen()
wide._resolve = lambda h: {"a1": "Alice-With-A-Long-Name", "3d": "YUL-Cartierville"}.get(h, "")
wide.on_event(
MeshEvent.observation_event(
_obs(node="", kind="packet",
raw={"payload_typename": "REQ", "dest_hash": "3d", "src_hash": "a1"})
)
)
assert "a1 → YUL-Cartierv" in _rows(wide, 100)[0]
def test_livefeed_tokened_classes_are_about_their_token() -> None:
"""An ack names the message it answers; a trace names its tag. Neither says "packet"."""
screen = _screen()
screen.on_event(
MeshEvent.observation_event(
_obs(node="", kind="packet", age_s=1,
raw={"payload_typename": "TRACE", "trace_tag": "5f3c2a10"})
)
)
screen.on_event(
MeshEvent.observation_event(
_obs(node="", kind="packet",
raw={"payload_typename": "ACK", "ack_crc": "9b71e004"})
)
)
acked, traced = _rows(screen, 100)[:2]
assert "for 9b71e004" in acked
assert "tag 5f3c2a10" in traced
def test_livefeed_column_header_sits_over_the_lanes_it_names() -> None:
"""The header names each lane, and every label lands on the values beneath it."""
screen = _screen(seed=[_obs(node="3d63", kind="telemetry", snr=12.8, rssi=-105.0)])
header, row = _stripped(screen.render_body(72))[1:3]
assert header.split() == ["TIME", "CLASS", "NODE", "SNR", "RSSI"]
assert header.split() == ["TIME", "CLASS", "SUBJECT", "SNR", "RSSI"]
# Measured in display cells, not characters — the class icon is one character wide
# but two cells, so a character index would report every later lane one column early.
assert _col(header, "TIME") == _col(row, "03:")
# The clock is read off the row itself: a literal needle would only match during the
# minute it was written in.
stamp = re.search(r"\d\d:\d\d:\d\d", row)
assert stamp is not None
assert _col(header, "TIME") == _col(row, stamp.group(0))
assert _col(header, "CLASS") == _col(row, "telemetry") - _ICON_LANE
assert _col(header, "NODE") == _col(row, "YUL")
assert _col(header, "SUBJECT") == _col(row, "YUL")
# The two readings right-align their number, so their labels end where the digits do.
assert _col(header, "SNR") + 3 == _col(row, "+12.8") + 5
assert _col(header, "RSSI") + 4 == _col(row, "-105") + 4
+62
View File
@@ -264,3 +264,65 @@ def test_packet_viewer_graph_names_a_known_origin_else_a_question_mark() -> None
nameless = PacketEntry(when=utcnow(), kind="packet", path="3d63")
body2 = _plain(_viewer(nameless).render_body(80))
assert "?" in body2 # an origin-less flood draws a plain "?" endpoint
def test_packet_viewer_lays_out_what_a_frame_addressed() -> None:
"""A frame that names no origin still names its two ends — as rows, not raw hex.
The endpoints come out of the frame body (``meshterm.core.frames``), so the card can
say who a relayed direct message was for even though the class carries no origin node.
"""
entry = PacketEntry(
when=utcnow(), kind="packet", path="3d63",
raw={
"payload_typename": "TEXT_MSG", "route_typename": "FLOOD",
"dest_hash": "c0", "src_hash": "a1",
},
)
viewer = PacketViewer(
[entry], 0, resolve=lambda h: {"c0": "Waymarker", "a1": "Alice"}.get(h, ""),
prefix_bytes=1, self_name="Waymarker",
)
lines = _stripped(viewer.render_body(80))
to_row = next(l for l in lines if l.startswith("to"))
from_row = next(l for l in lines if l.startswith("from"))
assert "Waymarker" in to_row and "c0" in to_row # the name, then the hash it was named by
assert "Alice" in from_row and "a1" in from_row
# …and neither is repeated by the generic raw dump at the foot of the card.
assert _plain(lines).count("dest_hash") == 0
def test_packet_viewer_names_a_tokened_frame_by_its_token() -> None:
"""An ack points at the message it answers; a trace at its own tag."""
ack = PacketEntry(
when=utcnow(), kind="packet", path="3d63",
raw={"payload_typename": "ACK", "ack_crc": "9b71e004"},
)
assert "9b71e004" in _plain(_viewer(ack).render_body(80))
trace = PacketEntry(
when=utcnow(), kind="packet", path="3d63",
raw={"payload_typename": "TRACE", "trace_tag": "5f3c2a10"},
)
body = _plain(_viewer(trace).render_body(80))
assert "tag" in body and "5f3c2a10" in body
def test_packet_viewer_names_a_channel_datagram_by_its_confirmed_channel() -> None:
"""A datagram's body is not text, but the MAC that guards it still names its channel."""
secret = derive_secret("Public")
crypted = bytes(range(16))
mac = HMAC.new(secret, digestmod=SHA256)
mac.update(crypted)
entry = PacketEntry(
when=utcnow(), kind="packet", path="3d63",
raw={
"payload_typename": "GRP_DATA", "chan_hash": channel_hash(secret),
"cipher_mac": mac.digest()[:2].hex(), "crypted": crypted.hex(),
},
)
body = _plain(_viewer(entry, channels=[("Public", secret)]).render_body(80))
assert "💽 CHANNEL DATA" in body and "Public" in body
unknown = _plain(_viewer(entry).render_body(80)) # no keys held: named by fingerprint only
assert "unknown" in unknown and "Public" not in unknown