Wait for tx-complete + add per-packet correlation token
All checks were successful
tests / pytest (push) Successful in 1m24s

Two related changes to make sender/receiver logs analyzable:

1. MeshCoreRadio.send no longer returns until the radio firmware
   reports the packet was actually transmitted. send_chan_msg only
   waits for EventType.OK (firmware accepted bytes) — no
   tx-complete event is pushed for channel messages, so we poll
   commands.get_stats_packets() and wait for nb_sent to strictly
   increment past a pre-send baseline. Times out after 30s.

2. Each test packet now embeds a random 8-hex-char token as the
   4th comma-separated field of the prefix, distinct from any
   radio-assigned packet id. Receivers extract it from the text
   and write it on every TSV row, so a sender's queued/sent rows
   tie 1:1 to each receiver's record without depending on
   whatever packet id the radio surfaces on each side.

   TSV gains a new column (3rd, after packet_id):
     queued:   ts queued <id> <token> <freq,bw,sf,cr,pow>
     sent:     ts sent <id> <token> <duration_ms> <text>
     received: ts received <id> <token> <text>
   Missing token writes "-".

UDP simulator regex updated to accept both legacy 3-field test
payloads and the new 4-field format with token.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alec K2XAP 2026-05-07 22:07:32 -04:00
parent 34e40f8ac9
commit 33bf287c30
9 changed files with 287 additions and 41 deletions

View file

@ -4,11 +4,15 @@ from typing import IO
class TsvLogger: class TsvLogger:
"""Append-only TSV logger matching reference/A.tsv + reference/B.tsv format. """Append-only TSV logger.
queued: <ts> queued <packet_id> <freq>,<bw>,<sf>,<cr>,<pow> Columns:
sent: <ts> sent <packet_id> <duration_ms> <text> queued: <ts> queued <packet_id> <token> <freq>,<bw>,<sf>,<cr>,<pow>
received: <ts> received <packet_id> <text> sent: <ts> sent <packet_id> <token> <duration_ms> <text>
received: <ts> received <packet_id> <token> <text>
`token` is the in-payload identifier we control (see modjam.payload),
written as `-` when absent (e.g. for non-test traffic).
""" """
def __init__(self, station: str, log_dir: str | Path = ".", test_name: str | None = None): def __init__(self, station: str, log_dir: str | Path = ".", test_name: str | None = None):
@ -18,14 +22,18 @@ class TsvLogger:
self._fh: IO = open(path, "a", buffering=1) # line-buffered self._fh: IO = open(path, "a", buffering=1) # line-buffered
self.path = path self.path = path
def queued(self, packet_id: int | str, freq: float, bw: float, sf: int, cr: int, pw: int) -> None: @staticmethod
self._fh.write(f"{time()}\tqueued\t{packet_id}\t{freq},{bw},{sf},{cr},{pw}\n") def _tok(token: str | None) -> str:
return token if token else "-"
def sent(self, packet_id: int | str, duration_ms: int, text: str) -> None: def queued(self, packet_id: int | str, token: str | None, freq: float, bw: float, sf: int, cr: int, pw: int) -> None:
self._fh.write(f"{time()}\tsent\t{packet_id}\t{duration_ms}\t{text}\n") self._fh.write(f"{time()}\tqueued\t{packet_id}\t{self._tok(token)}\t{freq},{bw},{sf},{cr},{pw}\n")
def received(self, packet_id: int | str, text: str) -> None: def sent(self, packet_id: int | str, token: str | None, duration_ms: int, text: str) -> None:
self._fh.write(f"{time()}\treceived\t{packet_id}\t{text}\n") self._fh.write(f"{time()}\tsent\t{packet_id}\t{self._tok(token)}\t{duration_ms}\t{text}\n")
def received(self, packet_id: int | str, token: str | None, text: str) -> None:
self._fh.write(f"{time()}\treceived\t{packet_id}\t{self._tok(token)}\t{text}\n")
def close(self) -> None: def close(self) -> None:
try: try:

44
modjam/payload.py Normal file
View file

@ -0,0 +1,44 @@
"""Test-packet payload encoding.
Format: `<wall_ts>,<elapsed>,<seq>,<token>|<padding>`
The trailing token is a random 8-hex-char identifier we control, distinct
from any radio-assigned packet id. It lets us correlate a sender's queued
log entry with each receiver's record of the same payload, without relying
on whatever packet id the radio happens to surface on each side.
"""
from __future__ import annotations
import secrets
import string
from random import choice
TOKEN_LEN = 8
def make_test_payload(size: int, seq: int, elapsed: float, ts: float) -> tuple[str, str]:
"""Build a test-packet text payload of approximately `size` bytes.
Returns (text, token).
"""
token = secrets.token_hex(TOKEN_LEN // 2)
prefix = f"{ts},{elapsed},{seq},{token}|"
if len(prefix) >= size:
return prefix, token
pad = "".join(choice(string.ascii_letters) for _ in range(size - len(prefix)))
return prefix + pad, token
def extract_token(text: str) -> str | None:
"""Extract the token from a test-packet text, or None if not present."""
if not text or "|" not in text:
return None
head = text.split("|", 1)[0]
parts = head.split(",")
if len(parts) < 4:
return None
candidate = parts[3]
if len(candidate) != TOKEN_LEN or not all(c in "0123456789abcdef" for c in candidate):
return None
return candidate

View file

@ -71,14 +71,47 @@ class MeshCoreRadio(Radio):
self._configured_channel = (name, psk_b64) self._configured_channel = (name, psk_b64)
async def send(self, text: str) -> SendResult: async def send(self, text: str) -> SendResult:
"""Send a channel message and return only after the radio reports tx complete.
meshcore's `send_chan_msg` resolves on EventType.OK, which signals the
firmware accepted the bytes the LoRa modem may still be queuing or
transmitting. There is no firmware-emitted tx-complete event for
channel messages, so we poll `get_stats_packets` and wait for
`nb_sent` to increment past its pre-send baseline. (See
reference/meshcore-py/src/meshcore/commands/{messaging,device}.py and
parsing.py for the field definitions.)
"""
assert self._mc assert self._mc
baseline = await self._read_nb_sent()
t0 = time.perf_counter() t0 = time.perf_counter()
result = await self._mc.commands.send_chan_msg(self._channel_idx, text) result = await self._mc.commands.send_chan_msg(self._channel_idx, text)
ms = int((time.perf_counter() - t0) * 1000)
payload = getattr(result, "payload", {}) or {} payload = getattr(result, "payload", {}) or {}
rtype = getattr(getattr(result, "type", None), "name", "")
if rtype == "ERROR" or "error" in payload:
raise RuntimeError(f"send_chan_msg failed: {payload}")
await self._wait_for_tx(baseline)
ms = int((time.perf_counter() - t0) * 1000)
pid = payload.get("packet_id") or payload.get("id") or next(self._counter) pid = payload.get("packet_id") or payload.get("id") or next(self._counter)
return SendResult(packet_id=pid, duration_ms=ms) return SendResult(packet_id=pid, duration_ms=ms)
async def _read_nb_sent(self) -> int:
assert self._mc
ev = await self._mc.commands.get_stats_packets()
return int((getattr(ev, "payload", {}) or {}).get("nb_sent", 0))
async def _wait_for_tx(self, baseline: int, timeout: float = 30.0,
poll_interval: float = 0.05) -> None:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
cur = await self._read_nb_sent()
if cur > baseline:
return
await asyncio.sleep(poll_interval)
raise TimeoutError(
f"radio did not report tx complete within {timeout}s "
f"(nb_sent stuck at {baseline})"
)
def on_message(self, cb: MessageHandler) -> None: def on_message(self, cb: MessageHandler) -> None:
self._handlers.append(cb) self._handlers.append(cb)

View file

@ -15,10 +15,10 @@ from .base import MessageHandler, Radio, RxPacket, SendResult
SIM_PORT = int(os.environ.get("SIM_PORT", "9000")) SIM_PORT = int(os.environ.get("SIM_PORT", "9000"))
# Test packet payload format: `<float_ts>,<float_t>,<int_n>|<padding>`. # Test packet payload format: `<float_ts>,<float_t>,<int_n>[,<token>]|<padding>`.
# Heartbeat/next/done payloads start with `<int_ts>|<station>|...` — no comma # Heartbeat/next/done payloads start with `<int_ts>|<station>|...` — no comma
# before the first `|` — so this regex matches test packets only. # before the first `|` — so this regex matches test packets only.
_TEST_PACKET_RE = re.compile(r"^\d+(?:\.\d+)?,\d+(?:\.\d+)?,\d+\|") _TEST_PACKET_RE = re.compile(r"^\d+(?:\.\d+)?,\d+(?:\.\d+)?,\d+(?:,[0-9a-f]+)?\|")
def _psk_hash(psk_b64: str) -> str: def _psk_hash(psk_b64: str) -> str:

View file

@ -1,12 +1,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import random
import string
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from . import cuesheet, protocol from . import cuesheet, payload, protocol
from .config import RadioConfig, StationConfig from .config import RadioConfig, StationConfig
from .log import TsvLogger from .log import TsvLogger
from .radio.base import Radio, RxPacket from .radio.base import Radio, RxPacket
@ -179,13 +177,16 @@ class TestStation:
while time.time() < cue.end and not self._stop_run.is_set(): while time.time() < cue.end and not self._stop_run.is_set():
n += 1 n += 1
self._sent_count = n self._sent_count = n
text = self._make_payload(cue.size, n, time.time() - case_start) text, token = payload.make_test_payload(cue.size, n, time.time() - case_start, time.time())
assert self._logger assert self._logger
try: try:
# cannot know packet_id before send; log queued with placeholder, then sent # At queue time we don't yet have a radio packet id, so reuse
# the token (our cross-station correlator) for the id column.
self._logger.queued(token, token, cue.freq, cue.bw, cue.sf, cue.cr, cue.pow)
# radio.send returns only after the radio firmware reports the
# packet has actually been transmitted (see MeshCoreRadio).
result = await self.radio.send(text) result = await self.radio.send(text)
self._logger.queued(result.packet_id, cue.freq, cue.bw, cue.sf, cue.cr, cue.pow) self._logger.sent(result.packet_id, token, result.duration_ms, text)
self._logger.sent(result.packet_id, result.duration_ms, text)
except Exception as e: except Exception as e:
print(f"[send] error: {e}") print(f"[send] error: {e}")
try: try:
@ -203,14 +204,6 @@ class TestStation:
except asyncio.TimeoutError: except asyncio.TimeoutError:
pass pass
@staticmethod
def _make_payload(size: int, n: int, t: float) -> str:
prefix = f"{time.time()},{t},{n}|"
if len(prefix) >= size:
return prefix
pad = "".join(random.choice(string.ascii_letters) for _ in range(size - len(prefix)))
return prefix + pad
# ---- helpers ---- # ---- helpers ----
async def _tune(self, rc: RadioConfig) -> None: async def _tune(self, rc: RadioConfig) -> None:
@ -256,4 +249,5 @@ class TestStation:
return return
if self._case_active and self._logger: if self._case_active and self._logger:
self._rcvd_count += 1 self._rcvd_count += 1
self._logger.received(pkt.packet_id, text) token = payload.extract_token(text)
self._logger.received(pkt.packet_id, token, text)

View file

@ -3,9 +3,9 @@ from modjam.log import TsvLogger
def test_tsv_logger_writes_all_event_types(tmp_path): def test_tsv_logger_writes_all_event_types(tmp_path):
logger = TsvLogger("Z", tmp_path) logger = TsvLogger("Z", tmp_path)
logger.queued(42, 915.1, 500.0, 7, 5, 22) logger.queued(42, "deadbeef", 915.1, 500.0, 7, 5, 22)
logger.sent(42, 13, "1234.5,0.0,1|abc") logger.sent(42, "deadbeef", 13, "1234.5,0.0,1,deadbeef|abc")
logger.received(99, "1234.5,1.0,2|xyz") logger.received(99, "cafe1234", "1234.5,1.0,2,cafe1234|xyz")
logger.close() logger.close()
rows = logger.path.read_text().strip().split("\n") rows = logger.path.read_text().strip().split("\n")
@ -14,24 +14,35 @@ def test_tsv_logger_writes_all_event_types(tmp_path):
parts0 = rows[0].split("\t") parts0 = rows[0].split("\t")
assert parts0[1] == "queued" assert parts0[1] == "queued"
assert parts0[2] == "42" assert parts0[2] == "42"
assert parts0[3] == "915.1,500.0,7,5,22" assert parts0[3] == "deadbeef"
assert parts0[4] == "915.1,500.0,7,5,22"
parts1 = rows[1].split("\t") parts1 = rows[1].split("\t")
assert parts1[1] == "sent" assert parts1[1] == "sent"
assert parts1[2] == "42" assert parts1[2] == "42"
assert parts1[3] == "13" assert parts1[3] == "deadbeef"
assert parts1[4] == "1234.5,0.0,1|abc" assert parts1[4] == "13"
assert parts1[5] == "1234.5,0.0,1,deadbeef|abc"
parts2 = rows[2].split("\t") parts2 = rows[2].split("\t")
assert parts2[1] == "received" assert parts2[1] == "received"
assert parts2[2] == "99" assert parts2[2] == "99"
assert parts2[3] == "1234.5,1.0,2|xyz" assert parts2[3] == "cafe1234"
assert parts2[4] == "1234.5,1.0,2,cafe1234|xyz"
def test_tsv_logger_renders_missing_token_as_dash(tmp_path):
logger = TsvLogger("Z", tmp_path)
logger.received(99, None, "no-token-text")
logger.close()
parts = logger.path.read_text().strip().split("\t")
assert parts[3] == "-"
def test_tsv_logger_creates_log_dir(tmp_path): def test_tsv_logger_creates_log_dir(tmp_path):
sub = tmp_path / "nested" / "logs" sub = tmp_path / "nested" / "logs"
logger = TsvLogger("A", sub) logger = TsvLogger("A", sub)
logger.queued(1, 0, 0, 0, 0, 0) logger.queued(1, "tok", 0, 0, 0, 0, 0)
logger.close() logger.close()
assert sub.is_dir() assert sub.is_dir()
assert logger.path.exists() assert logger.path.exists()

View file

@ -0,0 +1,97 @@
"""Tests for MeshCoreRadio.send tx-complete waiting.
Uses a stub `_mc` object so we don't need the meshcore lib or real hardware.
"""
import asyncio
import sys
import types
import pytest
@pytest.fixture(autouse=True)
def _stub_meshcore_module(monkeypatch):
"""Provide a fake `meshcore` module so MeshCoreRadio import inside
`connect()` would succeed if called. The send/wait tests below don't
call connect; they construct the radio manually.
"""
fake = types.ModuleType("meshcore")
fake.MeshCore = object
fake.EventType = types.SimpleNamespace(CHANNEL_MSG_RECV="rx")
monkeypatch.setitem(sys.modules, "meshcore", fake)
class _Event:
def __init__(self, payload, type_name="OK"):
self.payload = payload
self.type = types.SimpleNamespace(name=type_name)
class _StubCommands:
def __init__(self, nb_sent_sequence, send_response=None):
# nb_sent_sequence: list of values returned by successive
# get_stats_packets calls.
self._nb_sent = list(nb_sent_sequence)
self.sends: list[tuple[int, str]] = []
self._send_response = send_response or _Event({"packet_id": 7}, "OK")
async def get_stats_packets(self):
val = self._nb_sent.pop(0) if self._nb_sent else self._nb_sent[-1]
return _Event({"nb_sent": val})
async def send_chan_msg(self, chan, msg):
self.sends.append((chan, msg))
return self._send_response
class _StubMC:
def __init__(self, commands):
self.commands = commands
def _make_radio(commands):
from modjam.radio.meshcore import MeshCoreRadio
r = MeshCoreRadio()
r._mc = _StubMC(commands)
return r
def test_send_waits_until_nb_sent_increments():
# baseline read returns 100; after send poll returns 100, 100, 101
cmds = _StubCommands(nb_sent_sequence=[100, 100, 100, 101])
radio = _make_radio(cmds)
result = asyncio.run(radio.send("hello"))
assert cmds.sends == [(0, "hello")]
assert result.duration_ms >= 0
# baseline + at least 2 polls before increment
assert cmds._nb_sent == [] # all poll values consumed
def test_send_returns_immediately_when_increment_already_observed():
cmds = _StubCommands(nb_sent_sequence=[5, 7]) # baseline=5, first poll=7
radio = _make_radio(cmds)
result = asyncio.run(radio.send("x"))
assert result.packet_id == 7 # from send_chan_msg payload
def test_send_raises_on_error_event():
cmds = _StubCommands(
nb_sent_sequence=[0],
send_response=_Event({"error": "no_radio"}, "ERROR"),
)
radio = _make_radio(cmds)
with pytest.raises(RuntimeError, match="send_chan_msg failed"):
asyncio.run(radio.send("x"))
def test_wait_for_tx_times_out():
# nb_sent never increments
cmds = _StubCommands(nb_sent_sequence=[10] * 1000)
radio = _make_radio(cmds)
async def go():
baseline = await radio._read_nb_sent()
await radio._wait_for_tx(baseline, timeout=0.1, poll_interval=0.01)
with pytest.raises(TimeoutError):
asyncio.run(go())

51
tests/test_payload.py Normal file
View file

@ -0,0 +1,51 @@
from modjam import payload
def test_make_payload_pads_to_size():
text, token = payload.make_test_payload(size=80, seq=1, elapsed=0.5, ts=1234.5)
assert len(text) == 80
assert text.startswith("1234.5,0.5,1,")
assert f",{token}|" in text
def test_make_payload_short_when_prefix_exceeds_size():
text, token = payload.make_test_payload(size=5, seq=99, elapsed=12.34, ts=1234567890.0)
# prefix already > 5; payload returns prefix as-is
assert text.endswith("|")
assert token in text
def test_token_is_hex_and_correct_length():
_, token = payload.make_test_payload(size=80, seq=1, elapsed=0.0, ts=0.0)
assert len(token) == payload.TOKEN_LEN
assert all(c in "0123456789abcdef" for c in token)
def test_tokens_are_unique():
tokens = {payload.make_test_payload(80, 1, 0, 0)[1] for _ in range(50)}
assert len(tokens) == 50 # vanishingly unlikely to collide
def test_extract_token_roundtrip():
text, token = payload.make_test_payload(size=120, seq=42, elapsed=3.0, ts=999.0)
assert payload.extract_token(text) == token
def test_extract_token_returns_none_on_protocol_message():
assert payload.extract_token("1234567890|A|IDLE|-100") is None
assert payload.extract_token("START|name:foo") is None
assert payload.extract_token("STOP") is None
def test_extract_token_returns_none_on_legacy_format():
# 3-field legacy format (no token) — should not falsely extract
assert payload.extract_token("1234.5,1.0,42|abcdef") is None
def test_extract_token_rejects_non_hex():
assert payload.extract_token("1234.5,1.0,42,nothex!!|abc") is None
def test_extract_token_handles_empty():
assert payload.extract_token("") is None
assert payload.extract_token("nopipe") is None

View file

@ -19,7 +19,7 @@ def _msg(**overrides):
"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "pow": 22, "freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "pow": 22,
"channel": "modjam", "psk_hash": "abc123", "channel": "modjam", "psk_hash": "abc123",
"packet_id": 42, "packet_id": 42,
"text": "1234567890.0,1.0,1|payload", "text": "1234567890.0,1.0,1,deadbeef|payload",
"ts": 0.0, "ts": 0.0,
} }
base.update(overrides) base.update(overrides)
@ -96,7 +96,7 @@ def test_packet_loss_drops_test_packets(monkeypatch):
received = [] received = []
r.on_message(lambda p: received.append(p)) r.on_message(lambda p: received.append(p))
for i in range(20): for i in range(20):
r._handle_incoming(_msg(packet_id=i, text=f"1234.5,1.0,{i}|x")) r._handle_incoming(_msg(packet_id=i, text=f"1234.5,1.0,{i},aabbccdd|x"))
assert received == [] # 100% drop assert received == [] # 100% drop
@ -125,7 +125,7 @@ def test_packet_loss_partial_with_seed_is_deterministic(monkeypatch):
out = [] out = []
r.on_message(lambda p: out.append(p.packet_id)) r.on_message(lambda p: out.append(p.packet_id))
for i in range(50): for i in range(50):
r._handle_incoming(_msg(packet_id=i, text=f"100.0,1.0,{i}|x")) r._handle_incoming(_msg(packet_id=i, text=f"100.0,1.0,{i},11223344|x"))
return out return out
a = run_once() a = run_once()
@ -141,5 +141,13 @@ def test_packet_loss_zero_keeps_all_test_packets(monkeypatch):
received = [] received = []
r.on_message(lambda p: received.append(p)) r.on_message(lambda p: received.append(p))
for i in range(10): for i in range(10):
r._handle_incoming(_msg(packet_id=i, text=f"100.0,1.0,{i}|y")) r._handle_incoming(_msg(packet_id=i, text=f"100.0,1.0,{i},55667788|y"))
assert len(received) == 10 assert len(received) == 10
def test_test_packet_regex_matches_legacy_and_token_formats():
from modjam.radio.udp import _TEST_PACKET_RE
assert _TEST_PACKET_RE.match("1234.5,1.0,42|abc") # legacy 3-field
assert _TEST_PACKET_RE.match("1234.5,1.0,42,deadbeef|abc") # with token
assert not _TEST_PACKET_RE.match("1234567890|A|IDLE|-100") # heartbeat
assert not _TEST_PACKET_RE.match("START|name:foo")