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:
1 parent
64ac7dc2ed
commit
01ca242c98
11 files changed
+897
-110
No files matched your search
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user