diff --git a/README.md b/README.md index b7b872f..f48fc24 100644 --- a/README.md +++ b/README.md @@ -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. | β€” | diff --git a/meshterm/core/channels.py b/meshterm/core/channels.py index a2f24d7..a023a9a 100644 --- a/meshterm/core/channels.py +++ b/meshterm/core/channels.py @@ -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) diff --git a/meshterm/core/connection.py b/meshterm/core/connection.py index 32c37b6..13a9c0c 100644 --- a/meshterm/core/connection.py +++ b/meshterm/core/connection.py @@ -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 diff --git a/meshterm/core/frames.py b/meshterm/core/frames.py new file mode 100644 index 0000000..6001ba9 --- /dev/null +++ b/meshterm/core/frames.py @@ -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 {} diff --git a/meshterm/persistence/db.py b/meshterm/persistence/db.py index 75d82b8..d958465 100644 --- a/meshterm/persistence/db.py +++ b/meshterm/persistence/db.py @@ -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 diff --git a/meshterm/persistence/repository.py b/meshterm/persistence/repository.py index b216355..78881bb 100644 --- a/meshterm/persistence/repository.py +++ b/meshterm/persistence/repository.py @@ -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), diff --git a/meshterm/ui/livefeed_screen.py b/meshterm/ui/livefeed_screen.py index 09af52c..357e080 100644 --- a/meshterm/ui/livefeed_screen.py +++ b/meshterm/ui/livefeed_screen.py @@ -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, ) diff --git a/meshterm/ui/packet_viewer.py b/meshterm/ui/packet_viewer.py index 033c3c9..f0731e9 100644 --- a/meshterm/ui/packet_viewer.py +++ b/meshterm/ui/packet_viewer.py @@ -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. diff --git a/tests/test_frames.py b/tests/test_frames.py new file mode 100644 index 0000000..f191871 --- /dev/null +++ b/tests/test_frames.py @@ -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() diff --git a/tests/test_livefeed.py b/tests/test_livefeed.py index 14406a2..21da31d 100644 --- a/tests/test_livefeed.py +++ b/tests/test_livefeed.py @@ -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 diff --git a/tests/test_packet_viewer.py b/tests/test_packet_viewer.py index d7debee..38385e3 100644 --- a/tests/test_packet_viewer.py +++ b/tests/test_packet_viewer.py @@ -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