Files
RuView/scripts/tests/test_capture_adr018.py
T
Mathew Beaneandclaude-flow 7af5477f04 fix(scripts): parse the ADR-018 header layout in the UDP capture scripts
Both capture scripts decoded a header layout the firmware does not send.
csi_collector.c and esp32_parser.rs use: magic u32 @0, node_id @4,
n_antennas @5, n_subcarriers u16 @6, freq_mhz u32 @8, sequence u32 @12,
rssi i8 @16, noise_floor i8 @17, ppdu_type @18, flags @19, then
n_antennas * n_subcarriers int8 I/Q pairs from @20.

- collect-training-data.py read a 16-byte header and int16 I/Q, so its
  length check rejected every real single-antenna frame. It now reads the
  20-byte header and int8 pairs, records freq/channel/seq/ppdu/flags, and
  lets the recorder stamp receive time (the header has no timestamp).
- record-csi-udp.py read rssi from byte 6 and channel from byte 7 (the
  n_subcarriers bytes) and treated bytes 8-19 as I/Q, so a 64-subcarrier
  frame came out as 70 subcarriers with header bytes in the first six
  amplitudes. It now checks the magic, reads the real fields, and writes
  only the I/Q block to iq_hex.
- Add scripts/tests/test_capture_adr018.py, which builds frames with the
  esp32_parser.rs test layout and checks the decoded fields.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-10-02 22:01:22 -04:00

221 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""ADR-018 header parsing in the UDP capture scripts.
Builds real ADR-018 frames with the layout used by
v2/crates/wifi-densepose-hardware/src/esp32_parser.rs (build_test_frame) and
firmware/esp32-csi-node/main/csi_collector.c, then checks the fields that
scripts/collect-training-data.py and scripts/record-csi-udp.py decode.
Run: python -m pytest scripts/tests/test_capture_adr018.py -q
"""
from __future__ import annotations
import importlib.util
import json
import math
import struct
import time
from pathlib import Path
import pytest
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
ADR018_MAGIC = 0xC5110001
FEATURE_MAGIC = 0xC5110003
def load_script(filename: str):
"""Import a hyphenated script from scripts/ as a module."""
name = filename.replace("-", "_").removesuffix(".py")
spec = importlib.util.spec_from_file_location(name, SCRIPTS_DIR / filename)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
collect = load_script("collect-training-data.py")
record = load_script("record-csi-udp.py")
def build_frame(
node_id: int,
n_antennas: int,
pairs: list[tuple[int, int]],
*,
freq_mhz: int = 2437,
seq: int = 1,
rssi: int = -50,
noise: int = -95,
ppdu: int = 0,
flags: int = 0,
magic: int = ADR018_MAGIC,
) -> bytes:
"""ADR-018 frame, same byte layout as esp32_parser.rs build_test_frame_with_he.
0 magic u32 | 4 node_id | 5 n_antennas | 6 n_subcarriers u16 | 8 freq_mhz u32
| 12 sequence u32 | 16 rssi i8 | 17 noise i8 | 18 ppdu | 19 flags | 20 I/Q i8 pairs
"""
n_sub = len(pairs) // n_antennas if n_antennas else len(pairs)
buf = bytearray()
buf += struct.pack("<I", magic)
buf.append(node_id)
buf.append(n_antennas)
buf += struct.pack("<H", n_sub)
buf += struct.pack("<I", freq_mhz)
buf += struct.pack("<I", seq)
buf += struct.pack("<b", rssi)
buf += struct.pack("<b", noise)
buf.append(ppdu)
buf.append(flags)
for i, q in pairs:
buf += struct.pack("<bb", i, q)
return bytes(buf)
# 64 subcarriers, first pair (3, 4) -> amplitude 5, last pair (-6, 8) -> 10.
PAIRS_64 = [(3, 4)] + [(1, -1)] * 62 + [(-6, 8)]
# Sequence chosen so bytes 14/15 (seq high bytes) differ from rssi/noise.
SEQ = 0x01020304
def test_builder_matches_rust_header_size():
frame = build_frame(1, 1, [(0, 0)] * 64)
assert len(frame) == 20 + 64 * 2 # 148 bytes, the HT20 frame size in #1005
# ── collect-training-data.py ────────────────────────────────────────────────
class TestCollectTrainingData:
def test_single_antenna_header_fields(self):
frame = build_frame(7, 1, PAIRS_64, seq=SEQ, rssi=-50, noise=-95,
ppdu=1, flags=0x01)
out = collect.parse_packet(frame)
assert out is not None
assert out["type"] == "raw_csi"
assert out["node_id"] == 7
assert out["antenna_config"] == 1
assert out["n_antennas"] == 1
assert out["n_subcarriers"] == 64
assert out["freq_mhz"] == 2437
assert out["channel"] == 6
assert out["seq"] == SEQ
assert out["rssi"] == -50.0
assert out["noise_floor"] == -95.0
assert out["ppdu_type"] == 1
assert out["flags"] == 0x01
def test_single_antenna_amplitudes_from_int8_pairs(self):
out = collect.parse_packet(build_frame(7, 1, PAIRS_64))
amps = out["subcarriers"]
assert len(amps) == 64
assert amps[0] == pytest.approx(5.0)
assert amps[1] == pytest.approx(math.sqrt(2))
assert amps[-1] == pytest.approx(10.0)
def test_multi_antenna_reads_all_pairs(self):
pairs = [(3, 4)] * 4 + [(6, 8)] * 4 # antenna 0, then antenna 1
out = collect.parse_packet(build_frame(2, 2, pairs))
assert out["n_antennas"] == 2
assert out["n_subcarriers"] == 4
assert out["subcarriers"] == pytest.approx([5.0] * 4 + [10.0] * 4)
def test_5ghz_channel(self):
out = collect.parse_packet(build_frame(1, 1, PAIRS_64, freq_mhz=5180))
assert out["freq_mhz"] == 5180
assert out["channel"] == 36
def test_no_header_timestamp_so_recorder_uses_receive_time(self, tmp_path):
out = collect.parse_packet(build_frame(3, 1, PAIRS_64, seq=SEQ))
assert "timestamp" not in out
rec = collect.CsiRecorder(str(tmp_path), "t", "walking")
rec.open()
before = time.time()
rec.write_frame(out)
rec.close()
line = json.loads(rec.file_path.read_text().splitlines()[0])
assert before - 1 <= line["timestamp"] <= time.time() + 1
assert line["rssi"] == -50.0
assert line["features"]["seq"] == SEQ
assert len(line["subcarriers"]) == 64
def test_trailing_bytes_ignored(self):
out = collect.parse_packet(build_frame(1, 1, PAIRS_64) + b"\xff" * 8)
assert len(out["subcarriers"]) == 64
def test_truncated_iq_rejected(self):
assert collect.parse_packet(build_frame(1, 1, PAIRS_64)[:-1]) is None
def test_short_header_rejected(self):
assert collect.parse_packet(build_frame(1, 1, PAIRS_64)[:19]) is None
def test_zero_antennas_rejected(self):
frame = bytearray(build_frame(1, 1, PAIRS_64))
frame[5] = 0
assert collect.parse_packet(bytes(frame)) is None
def test_feature_packet_still_parses(self):
feats = [0.1 * i for i in range(8)]
pkt = struct.pack("<IBBHq8f", FEATURE_MAGIC, 4, 0, 9, 123456, *feats)
out = collect.parse_packet(pkt)
assert out["type"] == "features"
assert out["node_id"] == 4
assert out["seq"] == 9
assert out["features"] == pytest.approx(feats)
# ── record-csi-udp.py ───────────────────────────────────────────────────────
class TestRecordCsiUdp:
def test_single_antenna_header_fields(self):
frame = build_frame(7, 1, PAIRS_64, seq=SEQ, rssi=-50, noise=-95)
out = record.parse_csi_packet(frame)
assert out is not None
assert out["node_id"] == 7
assert out["rssi"] == -50
assert out["noise_floor"] == -95
assert out["freq_mhz"] == 2437
assert out["channel"] == 6
assert out["seq"] == SEQ
assert out["n_antennas"] == 1
assert out["n_subcarriers"] == 64
def test_iq_payload_starts_at_offset_20(self):
frame = build_frame(7, 1, PAIRS_64)
out = record.parse_csi_packet(frame)
assert out["subcarriers"] == 64
assert out["iq_hex"] == frame[20:].hex()
assert len(out["amplitudes"]) == 64
assert out["amplitudes"][0] == 5.0
assert out["amplitudes"][-1] == 10.0
def test_multi_antenna(self):
pairs = [(3, 4)] * 4 + [(6, 8)] * 4
out = record.parse_csi_packet(build_frame(2, 2, pairs))
assert out["n_antennas"] == 2
assert out["n_subcarriers"] == 4
assert out["subcarriers"] == 8
assert out["amplitudes"] == [5.0] * 4 + [10.0] * 4
def test_trailing_bytes_not_treated_as_iq(self):
frame = build_frame(1, 1, PAIRS_64)
out = record.parse_csi_packet(frame + b"\x7f" * 6)
assert out["iq_hex"] == frame[20:].hex()
def test_other_magic_rejected(self):
feats = [0.0] * 8
pkt = struct.pack("<IBBHq8f", FEATURE_MAGIC, 4, 0, 9, 123456, *feats)
assert record.parse_csi_packet(pkt) is None
def test_truncated_iq_rejected(self):
assert record.parse_csi_packet(build_frame(1, 1, PAIRS_64)[:-1]) is None
def test_short_header_rejected(self):
assert record.parse_csi_packet(build_frame(1, 1, PAIRS_64)[:19]) is None
def test_timestamps_present(self):
out = record.parse_csi_packet(build_frame(1, 1, PAIRS_64))
assert out["timestamp"].endswith("Z")
assert isinstance(out["ts_ns"], int)