|
| 1 | +#!/usr/bin/env python3 |
| 2 | +""" |
| 3 | +Lightweight ESP32 CSI UDP recorder (ADR-079). |
| 4 | +
|
| 5 | +Captures raw CSI packets from ESP32 nodes over UDP and writes to JSONL. |
| 6 | +Runs alongside collect-ground-truth.py for synchronized capture. |
| 7 | +
|
| 8 | +Usage: |
| 9 | + python scripts/record-csi-udp.py --duration 300 --output data/recordings |
| 10 | +""" |
| 11 | + |
| 12 | +import argparse |
| 13 | +import json |
| 14 | +import os |
| 15 | +import socket |
| 16 | +import struct |
| 17 | +import time |
| 18 | + |
| 19 | + |
| 20 | +def parse_csi_packet(data): |
| 21 | + """Parse ADR-018 binary CSI packet into dict.""" |
| 22 | + if len(data) < 8: |
| 23 | + return None |
| 24 | + |
| 25 | + # ADR-018 header: [magic(2), len(2), node_id(1), seq(1), rssi(1), channel(1), iq_data...] |
| 26 | + # Simplified: extract what we can from the raw packet |
| 27 | + node_id = data[4] if len(data) > 4 else 0 |
| 28 | + rssi = struct.unpack('b', bytes([data[6]]))[0] if len(data) > 6 else 0 |
| 29 | + channel = data[7] if len(data) > 7 else 0 |
| 30 | + |
| 31 | + # IQ data starts at offset 8 |
| 32 | + iq_data = data[8:] if len(data) > 8 else b'' |
| 33 | + n_subcarriers = len(iq_data) // 2 # I,Q pairs |
| 34 | + |
| 35 | + # Compute amplitudes |
| 36 | + amplitudes = [] |
| 37 | + for i in range(0, len(iq_data) - 1, 2): |
| 38 | + I = struct.unpack('b', bytes([iq_data[i]]))[0] |
| 39 | + Q = struct.unpack('b', bytes([iq_data[i + 1]]))[0] |
| 40 | + amplitudes.append(round((I * I + Q * Q) ** 0.5, 2)) |
| 41 | + |
| 42 | + return { |
| 43 | + "type": "raw_csi", |
| 44 | + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S.") + f"{int(time.time() * 1000) % 1000:03d}Z", |
| 45 | + "ts_ns": time.time_ns(), |
| 46 | + "node_id": node_id, |
| 47 | + "rssi": rssi, |
| 48 | + "channel": channel, |
| 49 | + "subcarriers": n_subcarriers, |
| 50 | + "amplitudes": amplitudes, |
| 51 | + "iq_hex": iq_data.hex(), |
| 52 | + } |
| 53 | + |
| 54 | + |
| 55 | +def main(): |
| 56 | + parser = argparse.ArgumentParser(description="Record ESP32 CSI over UDP") |
| 57 | + parser.add_argument("--port", type=int, default=5005, help="UDP port (default: 5005)") |
| 58 | + parser.add_argument("--duration", type=int, default=300, help="Duration in seconds (default: 300)") |
| 59 | + parser.add_argument("--output", default="data/recordings", help="Output directory") |
| 60 | + args = parser.parse_args() |
| 61 | + |
| 62 | + os.makedirs(args.output, exist_ok=True) |
| 63 | + filename = f"csi-{int(time.time())}.csi.jsonl" |
| 64 | + filepath = os.path.join(args.output, filename) |
| 65 | + |
| 66 | + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) |
| 67 | + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) |
| 68 | + sock.bind(("0.0.0.0", args.port)) |
| 69 | + sock.settimeout(1) |
| 70 | + |
| 71 | + print(f"Recording CSI on UDP :{args.port} for {args.duration}s") |
| 72 | + print(f"Output: {filepath}") |
| 73 | + |
| 74 | + count = 0 |
| 75 | + start = time.time() |
| 76 | + nodes_seen = set() |
| 77 | + |
| 78 | + with open(filepath, "w") as f: |
| 79 | + try: |
| 80 | + while time.time() - start < args.duration: |
| 81 | + try: |
| 82 | + data, addr = sock.recvfrom(4096) |
| 83 | + frame = parse_csi_packet(data) |
| 84 | + if frame: |
| 85 | + f.write(json.dumps(frame) + "\n") |
| 86 | + count += 1 |
| 87 | + nodes_seen.add(frame["node_id"]) |
| 88 | + |
| 89 | + if count % 500 == 0: |
| 90 | + elapsed = time.time() - start |
| 91 | + rate = count / elapsed |
| 92 | + print(f" {count} frames | {rate:.0f} fps | " |
| 93 | + f"nodes: {sorted(nodes_seen)} | " |
| 94 | + f"{elapsed:.0f}s / {args.duration}s") |
| 95 | + except socket.timeout: |
| 96 | + continue |
| 97 | + except KeyboardInterrupt: |
| 98 | + print("\nStopped by user") |
| 99 | + |
| 100 | + sock.close() |
| 101 | + elapsed = time.time() - start |
| 102 | + print(f"\n=== CSI Recording Complete ===") |
| 103 | + print(f" Frames: {count}") |
| 104 | + print(f" Duration: {elapsed:.0f}s") |
| 105 | + print(f" Rate: {count / max(elapsed, 1):.0f} fps") |
| 106 | + print(f" Nodes: {sorted(nodes_seen)}") |
| 107 | + print(f" Output: {filepath}") |
| 108 | + |
| 109 | + |
| 110 | +if __name__ == "__main__": |
| 111 | + main() |
0 commit comments