The paths on a DM listed a message's own retransmissions, the other end's traffic, and both of those for every other message sent in the same minute and a half. On one real bob_caribou message that was 30 rows over 8 paths, of which 23 were the same two one-hop routes repeated. Those one-hops were the tell (JP): they were our *own* sends, overheard coming back off the two repeaters in earshot, correctly showing one hop because one hop is all they had travelled — but shown under the message as if they were its route. The channel view has always matched by decrypted content, and asked for here. A direct frame cannot be read, but it does not need to be: its body is [dest 1B][src 1B][MAC 2B][ciphertext], and that MAC is a tag over this message's plaintext. Copies of one message share it; the next message's differ. frame_addressing already knew where those two bytes were and threw them away, and the observations table already had a cipher_mac column its channel sibling used — so a direct message now groups exactly as a channel one does, with no schema change and nothing decrypted. Three filters, in order. The frame ran between the conversation's two ends; it travelled the direction this message did; it carries its group's MAC. The middle one is doing more work than it looks: both ends' hashes sit on every frame either way, so matching on the pair alone was what put our sends under a received message. The window is clamped by the conversation's own messages rather than a flat ±90 s — nine messages fell inside one such window. The clamp reads only messages going the *same* way, which the first cut of it got wrong: an inbound stamp is the sender's clock, so ordering it against our own sends compares two clocks, and a received message came back with an empty window. The directions are also bounded differently, because we know different things about them. A send is stamped by our clock as it leaves, so no frame of it predates it and all the room goes forward to the next send, where the retries are. A received message keeps a centred window, halfway to the received messages either side. Rows are paths now, not frames: identical routes fold into one line carrying its copy count, its first sighting, and the best SNR that path managed. Twelve identical lines said "twelve arrivals" where the truth was "two paths, six times each". Measured on the conversation that prompted this, whose history predates the MAC and so exercises only the direction and window fixes: outbound "Allo! Ça marche JF" 30 rows / 8 paths -> 2 rows (x3 each) inbound "J'ai pas de ack..." 30 rows / 8 paths -> 2 rows, 6 and 7 hops which is exactly what the packet log says by hand. Old history keeps the address-and-time fallback and is billed as such; frames recorded from now on match exactly, and say so in the same words the channel view uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
251 lines
11 KiB
Python
251 lines
11 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, trace_link_snrs
|
|
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_and_their_mac() -> None:
|
|
"""A direct message, request, response or returned path opens dest, src, then MAC.
|
|
|
|
The MAC is a tag over the encrypted body, so it fingerprints *which* message a frame
|
|
carries without the frame ever being read — which is how the message-paths view groups
|
|
a direct message's own retransmissions and tells them from the next message's (see
|
|
:mod:`~meshterm.services.message_paths`).
|
|
"""
|
|
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", "cipher_mac": "abcd",
|
|
}
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
def test_a_trace_path_is_link_readings_not_relay_hashes() -> None:
|
|
"""The one class whose header path field means something else entirely.
|
|
|
|
A trace grows its path by one signed SNR byte per hop, so reading it as hashes both
|
|
invents adjacency and throws away the readings. Bytes here span the wire's signed
|
|
range: +13.25 dB, then two negative legs.
|
|
"""
|
|
payload = _frame("TRACE", bytes(9), path_len=3, path_hash_size=1, path="35eeef")
|
|
assert trace_link_snrs(payload) == [13.25, -4.5, -4.25]
|
|
|
|
|
|
def test_only_a_trace_reads_its_path_that_way() -> None:
|
|
"""Every other class keeps hashes there, so nothing else may be decoded as readings."""
|
|
for typename in ("TEXT_MSG", "ADVERT", "GRP_TXT", "ACK", "PATH"):
|
|
assert trace_link_snrs(_frame(typename, bytes(9), path_len=1, path="35")) is None
|
|
|
|
|
|
def test_an_unrelayed_trace_has_readings_for_no_hops() -> None:
|
|
"""Nobody has forwarded it yet, so there is nothing to have measured it — not a fault."""
|
|
assert trace_link_snrs(_frame("TRACE", bytes(9), path_len=0, path="")) == []
|
|
|
|
|
|
def test_a_trace_path_shorter_than_announced_is_not_invented() -> None:
|
|
"""Three hops promised, one byte delivered: the missing readings stay missing."""
|
|
assert trace_link_snrs(_frame("TRACE", bytes(9), path_len=3, path="35")) is None
|
|
|
|
|
|
def test_a_traces_readings_reach_the_observation_and_its_hops_do_not() -> None:
|
|
"""The readings ride in the raw payload; ``path`` stays empty, having no hops to hold.
|
|
|
|
Storing those bytes as a path fed the topology graph links that were never observed —
|
|
whichever nodes happened to share the leading digits of an SNR reading.
|
|
"""
|
|
obs = packet_observation_from_event(
|
|
_Event(payload_typename="TRACE", pkt_payload=bytes.fromhex("5f3c2a10") + bytes(5),
|
|
path_len=3, path_hash_size=1, path="35eeef", snr=13.75)
|
|
)
|
|
assert obs is not None and obs.raw is not None
|
|
assert obs.path == ""
|
|
assert obs.raw["trace_snrs"] == [13.25, -4.5, -4.25]
|
|
assert obs.raw["trace_tag"] == "102a3c5f"
|
|
|
|
|
|
def test_a_frame_heard_straight_off_its_sender_is_kept() -> None:
|
|
"""Zero relays is the strongest adjacency evidence there is, not the absence of any.
|
|
|
|
This is the whole of what a device sitting beside this one puts on the air: nothing
|
|
has relayed it, so it names no repeater, and only an advert carries an origin key.
|
|
Dropping it made a neighbour's every trace, message and ack invisible.
|
|
"""
|
|
for typename in ("TRACE", "TEXT_MSG", "ACK", "REQ", "GRP_TXT"):
|
|
obs = packet_observation_from_event(
|
|
_Event(payload_typename=typename, pkt_payload=bytes(24),
|
|
path_len=0, path_hash_size=1, path="", snr=9.25, rssi=-61)
|
|
)
|
|
assert obs is not None, typename
|
|
assert obs.path == "" and obs.snr == 9.25
|
|
assert (obs.raw or {})["payload_typename"] == typename
|
|
|
|
|
|
def test_a_frame_with_no_class_at_all_is_still_dropped() -> None:
|
|
"""The library's sentinel for a frame too short to parse teaches nothing about anything."""
|
|
assert packet_observation_from_event(
|
|
_Event(payload_typename="UNK", pkt_payload=b"", path_len=0, path="")
|
|
) is None
|
|
assert packet_observation_from_event(_Event(snr=6.0)) is None
|
|
|
|
|
|
def test_a_traces_readings_survive_being_stored(tmp_path: Path) -> None:
|
|
"""A replayed trace reads back with the same links as the live one, not a blank lane.
|
|
|
|
The feed opens seeded from history, so readings that lived only in a live event's raw
|
|
payload would vanish the moment the screen was drawn from storage — the same gap the
|
|
crypto and addressing columns were added to close.
|
|
"""
|
|
repo = Repository(tmp_path / "trace.db")
|
|
run = repo.start_run("monitor", {}, None)
|
|
repo.record_observation(
|
|
run,
|
|
Observation(
|
|
node=None, kind="packet", path="",
|
|
raw={"payload_typename": "TRACE", "trace_tag": "102a3c5f",
|
|
"trace_snrs": [13.25, -4.5, -4.25]},
|
|
),
|
|
)
|
|
(stored,) = repo.recent_observations(since=utcnow() - timedelta(hours=2))
|
|
assert stored.path == ""
|
|
assert (stored.raw or {})["trace_snrs"] == [13.25, -4.5, -4.25]
|
|
assert (stored.raw or {})["trace_tag"] == "102a3c5f"
|
|
repo.close()
|
|
|
|
|
|
def test_only_a_trace_stores_readings(tmp_path: Path) -> None:
|
|
"""No other class has any, so a stray value on one is not written down as though it did."""
|
|
repo = Repository(tmp_path / "notrace.db")
|
|
run = repo.start_run("monitor", {}, None)
|
|
repo.record_observation(
|
|
run,
|
|
Observation(node=None, kind="packet", path="3d",
|
|
raw={"payload_typename": "TEXT_MSG", "trace_snrs": [1.0]}),
|
|
)
|
|
(stored,) = repo.recent_observations(since=utcnow() - timedelta(hours=2))
|
|
assert "trace_snrs" not in (stored.raw or {})
|
|
repo.close()
|