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>
153 lines
4.6 KiB
Python
153 lines
4.6 KiB
Python
import os
|
|
|
|
import pytest
|
|
|
|
from modjam.radio.udp import UDPRadio, _params_match, _psk_hash
|
|
|
|
|
|
@pytest.fixture
|
|
def radio(monkeypatch):
|
|
"""Construct a UDPRadio without binding sockets."""
|
|
monkeypatch.delenv("SIM_PACKET_LOSS", raising=False)
|
|
monkeypatch.delenv("SIM_SEED", raising=False)
|
|
return UDPRadio(station="A", peers=["B", "C"])
|
|
|
|
|
|
def _msg(**overrides):
|
|
base = {
|
|
"sender": "B",
|
|
"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "pow": 22,
|
|
"channel": "modjam", "psk_hash": "abc123",
|
|
"packet_id": 42,
|
|
"text": "1234567890.0,1.0,1,deadbeef|payload",
|
|
"ts": 0.0,
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
def _tune(radio, **overrides):
|
|
radio._cur.update({
|
|
"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "pow": 22,
|
|
"channel": "modjam", "psk_hash": "abc123",
|
|
})
|
|
radio._cur.update(overrides)
|
|
|
|
|
|
def test_params_match_exact():
|
|
a = {"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "channel": "x", "psk_hash": "h"}
|
|
b = dict(a)
|
|
assert _params_match(a, b) is True
|
|
|
|
|
|
@pytest.mark.parametrize("field,bad", [
|
|
("freq", 916.0),
|
|
("bw", 250.0),
|
|
("sf", 8),
|
|
("cr", 6),
|
|
("channel", "other"),
|
|
("psk_hash", "different"),
|
|
])
|
|
def test_params_match_rejects_mismatch(field, bad):
|
|
a = {"freq": 915.1, "bw": 500.0, "sf": 7, "cr": 5, "channel": "x", "psk_hash": "h"}
|
|
b = dict(a)
|
|
b[field] = bad
|
|
assert _params_match(a, b) is False
|
|
|
|
|
|
def test_psk_hash_deterministic_and_short():
|
|
h1 = _psk_hash("hello")
|
|
h2 = _psk_hash("hello")
|
|
assert h1 == h2 and len(h1) == 16
|
|
assert _psk_hash("hello") != _psk_hash("world")
|
|
|
|
|
|
def test_handle_incoming_drops_self(radio):
|
|
_tune(radio)
|
|
received = []
|
|
radio.on_message(lambda p: received.append(p))
|
|
radio._handle_incoming(_msg(sender="A"))
|
|
assert received == []
|
|
|
|
|
|
def test_handle_incoming_drops_param_mismatch(radio):
|
|
_tune(radio, sf=8)
|
|
received = []
|
|
radio.on_message(lambda p: received.append(p))
|
|
radio._handle_incoming(_msg(sf=7))
|
|
assert received == []
|
|
|
|
|
|
def test_handle_incoming_delivers_match(radio):
|
|
_tune(radio)
|
|
received = []
|
|
radio.on_message(lambda p: received.append(p))
|
|
radio._handle_incoming(_msg())
|
|
assert len(received) == 1
|
|
assert received[0].sender == "B"
|
|
assert received[0].text.startswith("1234567890.0,")
|
|
|
|
|
|
def test_packet_loss_drops_test_packets(monkeypatch):
|
|
monkeypatch.setenv("SIM_PACKET_LOSS", "1.0") # drop everything
|
|
monkeypatch.setenv("SIM_SEED", "fixed")
|
|
r = UDPRadio(station="A", peers=["B"])
|
|
_tune(r)
|
|
received = []
|
|
r.on_message(lambda p: received.append(p))
|
|
for i in range(20):
|
|
r._handle_incoming(_msg(packet_id=i, text=f"1234.5,1.0,{i},aabbccdd|x"))
|
|
assert received == [] # 100% drop
|
|
|
|
|
|
def test_packet_loss_does_not_drop_protocol_messages(monkeypatch):
|
|
monkeypatch.setenv("SIM_PACKET_LOSS", "1.0")
|
|
monkeypatch.setenv("SIM_SEED", "fixed")
|
|
r = UDPRadio(station="A", peers=["B"])
|
|
_tune(r)
|
|
received = []
|
|
r.on_message(lambda p: received.append(p))
|
|
# heartbeat + START + STOP should never drop, regardless of loss.
|
|
r._handle_incoming(_msg(text="1234567890|B|IDLE|-100"))
|
|
r._handle_incoming(_msg(text="START|name:foo"))
|
|
r._handle_incoming(_msg(text="STOP"))
|
|
r._handle_incoming(_msg(text="1234567890|B|next:915.1/500/7/5/22/40"))
|
|
assert len(received) == 4
|
|
|
|
|
|
def test_packet_loss_partial_with_seed_is_deterministic(monkeypatch):
|
|
monkeypatch.setenv("SIM_PACKET_LOSS", "0.5")
|
|
monkeypatch.setenv("SIM_SEED", "fixed-seed")
|
|
|
|
def run_once():
|
|
r = UDPRadio(station="A", peers=["B"])
|
|
_tune(r)
|
|
out = []
|
|
r.on_message(lambda p: out.append(p.packet_id))
|
|
for i in range(50):
|
|
r._handle_incoming(_msg(packet_id=i, text=f"100.0,1.0,{i},11223344|x"))
|
|
return out
|
|
|
|
a = run_once()
|
|
b = run_once()
|
|
assert a == b # deterministic
|
|
assert 0 < len(a) < 50 # some dropped, some kept
|
|
|
|
|
|
def test_packet_loss_zero_keeps_all_test_packets(monkeypatch):
|
|
monkeypatch.setenv("SIM_PACKET_LOSS", "0")
|
|
r = UDPRadio(station="A", peers=["B"])
|
|
_tune(r)
|
|
received = []
|
|
r.on_message(lambda p: received.append(p))
|
|
for i in range(10):
|
|
r._handle_incoming(_msg(packet_id=i, text=f"100.0,1.0,{i},55667788|y"))
|
|
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")
|