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>
138 lines
6.1 KiB
Python
138 lines
6.1 KiB
Python
"""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()
|