153 lines
6.4 KiB
Python
153 lines
6.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Collect RX-only, RAM-only production-wrapper timing trials over USB."""
|
|
import argparse
|
|
import json
|
|
import itertools
|
|
import os
|
|
import secrets
|
|
from pathlib import Path
|
|
import time
|
|
|
|
import serial
|
|
from serial.tools import list_ports
|
|
from serial_session import open_configured_session
|
|
|
|
_run_sequences = itertools.count(secrets.randbelow(0x3fffffff) + 1)
|
|
|
|
|
|
def configure_session(port):
|
|
# Native Espressif/Seeed/Adafruit USB and CP2102/CH340 bridges differ in DTR semantics.
|
|
# DTR keeps the native session active; on a bridge it can drive BOOT/GPIO0.
|
|
port.rts = False
|
|
device = os.path.realpath(str(port.port)).casefold()
|
|
port.dtr = any(info.vid in (0x303A, 0x2886, 0x239A)
|
|
and os.path.realpath(str(info.device)).casefold() == device
|
|
for info in list_ports.comports())
|
|
|
|
|
|
def open_session(port):
|
|
"""Open a configured native session or a bridge without pulsing reset."""
|
|
configure_session(port)
|
|
open_configured_session(port)
|
|
|
|
|
|
def read_response(port, key, timeout, expected=None):
|
|
deadline = time.monotonic() + timeout
|
|
pending = bytearray()
|
|
stale = []
|
|
while time.monotonic() < deadline:
|
|
chunk = port.readline()
|
|
pending.extend(chunk)
|
|
if not pending.endswith(b"\n"):
|
|
continue # Serial timeout can split one logical JSON line.
|
|
line = pending.decode("utf-8", errors="replace").strip()
|
|
pending.clear()
|
|
if not line.startswith("{"):
|
|
continue
|
|
reply = json.loads(line)
|
|
if "error" in reply:
|
|
raise RuntimeError(reply["error"])
|
|
if key in reply:
|
|
if expected is not None and reply[key] != expected:
|
|
stale.append(reply)
|
|
continue
|
|
if stale:
|
|
reply["stale_replies"] = stale
|
|
return reply
|
|
raise TimeoutError(f"No complete {key} response on {port.port}")
|
|
|
|
|
|
def read_packet_response(port, key, sequence, timeout):
|
|
try:
|
|
return read_response(port, key, timeout, sequence)
|
|
except (TimeoutError, json.JSONDecodeError):
|
|
# Replay an already recorded result, never the radio operation.
|
|
port.write(f"result {key} {sequence}\n".encode("ascii"))
|
|
result = read_response(port, key, 3, sequence)
|
|
result["transport_recovered"] = True
|
|
return result
|
|
|
|
|
|
def exchange(port, command, key, timeout, expected=None, cached=False):
|
|
if key == "result" and expected is None and command.startswith("run "):
|
|
expected = next(_run_sequences)
|
|
command += f" {expected}"
|
|
cached = True
|
|
port.write((command + "\n").encode("ascii"))
|
|
if cached:
|
|
if expected is None:
|
|
raise ValueError("Cached packet replies require a sequence")
|
|
return read_packet_response(port, key, expected, timeout)
|
|
return read_response(port, key, timeout, expected)
|
|
|
|
|
|
def exchange_ready(port, command, key, timeout, expected=None, cached=False):
|
|
# These two replies occur before the measured run/listener is armed when
|
|
# the normal packet guard refuses initial profile setup. Never retry RF
|
|
# packet loss or a timed hop failure as though it had succeeded.
|
|
for deferred in range(31):
|
|
try:
|
|
result = exchange(port, command, key, timeout, expected, cached)
|
|
result["setup_deferrals"] = deferred
|
|
return result
|
|
except RuntimeError as error:
|
|
if str(error) not in ("primary rejected", "receiver primary rejected") or deferred == 30:
|
|
raise
|
|
time.sleep(0.1)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--port", required=True)
|
|
parser.add_argument("--output", required=True, type=Path)
|
|
parser.add_argument("--count", type=int, default=512)
|
|
parser.add_argument("--rounds", type=int, default=2)
|
|
parser.add_argument("--experiment", choices=("batching", "warm"), default="batching")
|
|
args = parser.parse_args()
|
|
if not 16 <= args.count <= 1000 or not 1 <= args.rounds <= 10:
|
|
parser.error("count must be 16..1000 and rounds 1..10")
|
|
if args.output.exists():
|
|
parser.error("output already exists; choose a fresh result path")
|
|
results = {"schema": 2, "experiment": args.experiment,
|
|
"board": "XIAO ESP32-S3 + WIO SX1262", "port": args.port,
|
|
"primary": {"freq": 909.5, "bw": 62.5, "sf": 7, "cr": 5},
|
|
"secondary": {"freq": 910.5, "bw": 500, "cr": 5},
|
|
"timing": "production tuneProfile entry to return, then BUSY low; RX status verified afterward",
|
|
"excluded": "first 8 successful hops per trial; no application/UI/network scheduling",
|
|
"trials": [], "complete": False}
|
|
port = serial.Serial()
|
|
port.port = args.port
|
|
port.baudrate = 115200
|
|
port.timeout = 0.25
|
|
port.write_timeout = 2
|
|
try:
|
|
open_session(port)
|
|
port.reset_input_buffer()
|
|
info = exchange(port, "info", "bench", 5)
|
|
if info.get("bench") != "production-profile-switch-v8" or not info.get("ready"):
|
|
raise RuntimeError(f"Wrong or unready firmware: {info}")
|
|
for repeat in range(args.rounds):
|
|
for sf in (7, 8, 9):
|
|
for choice in ((0, 1) if repeat % 2 == 0 else (1, 0)):
|
|
warm, batched = (1, choice) if args.experiment == "batching" else (choice, 0)
|
|
result = exchange(port, f"run {warm} {sf} {args.count} {batched} 0 2", "result", 35)
|
|
result["round"] = repeat + 1
|
|
results["trials"].append(result)
|
|
print(json.dumps(result), flush=True)
|
|
args.output.write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
|
|
count = sum(d["total"]["n"] for d in result["directions"])
|
|
if (count != args.count or result["warm"] != bool(warm)
|
|
or result["batched"] != bool(batched) or result["sf500"] != sf
|
|
or any(result[k] for k in ("failures", "busy_timeouts", "rx_mode_errors", "cache_errors"))
|
|
or not result["off_restored_rc"]):
|
|
raise RuntimeError("Trial failed validation; partial results saved")
|
|
results["complete"] = True
|
|
finally:
|
|
port.close()
|
|
if results["trials"]:
|
|
args.output.write_text(json.dumps(results, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|