Add receive-only same-image display, SPI, modem/IQ-cache and scoped-priority experiments with checkpointed collectors. Keep rejected clock-phase correction and elevated-priority experiments out of production. Publish sanitized timing data, Full Companion/Bluetooth/USB checks and redraw measurements with their limits. Wire the new behavioral checks into CI.
147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Collect receive-only T-Deck display/radio scheduler A/B measurements.
|
|
|
|
Flash env:tdeck_display_switch first. This tool does not build, erase, flash,
|
|
reset, or transmit radio packets. Example:
|
|
python tools/hil/tdeck_display_switch.py COM10 --seconds 5 --repeats 3 \
|
|
--output tdeck-display-results.json
|
|
|
|
Modes: 0 static screen; 1 original direct redraw; 2 buffered strips with radio
|
|
service callbacks. SPI policies: original bytewise 2 MHz and buffered 8 MHz.
|
|
--full-spi-matrix adds the two intermediate policies to separate those effects.
|
|
Exact min/mean/max and coarse p95/p99 bucket upper bounds come from firmware.
|
|
"""
|
|
|
|
import argparse
|
|
import datetime as dt
|
|
import json
|
|
from pathlib import Path
|
|
import time
|
|
|
|
import serial
|
|
from profile_switch import open_session
|
|
|
|
|
|
def arguments():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("port")
|
|
parser.add_argument("--seconds", type=int, choices=range(1, 11), default=5)
|
|
parser.add_argument("--fps", type=int, choices=range(1, 31), default=20)
|
|
parser.add_argument("--repeats", type=int, default=3)
|
|
parser.add_argument("--full-spi-matrix", action="store_true")
|
|
parser.add_argument("--output", type=Path, required=True)
|
|
return parser.parse_args()
|
|
|
|
|
|
class Bench:
|
|
def __init__(self, port):
|
|
self.device = serial.Serial()
|
|
self.device.port = port
|
|
self.device.baudrate = 115200
|
|
self.device.timeout = 0.2
|
|
self.device.write_timeout = 2
|
|
# Reuse the native-USB DTR policy and no-reset bridge opener used by
|
|
# the other production-wrapper HIL tools.
|
|
open_session(self.device)
|
|
self.raw_lines = []
|
|
|
|
def close(self):
|
|
self.device.close()
|
|
|
|
def send(self, command):
|
|
self.device.write((command + "\n").encode("ascii"))
|
|
self.device.flush()
|
|
|
|
def receive(self, predicate, timeout):
|
|
deadline = time.monotonic() + timeout
|
|
pending = bytearray()
|
|
while time.monotonic() < deadline:
|
|
pending.extend(self.device.readline())
|
|
if len(pending) > 16384:
|
|
raise RuntimeError("Firmware response exceeded bounded line capture")
|
|
if not pending.endswith(b"\n"):
|
|
continue # A serial read timeout can split one logical line.
|
|
raw = pending.decode("utf-8", errors="replace").strip()
|
|
pending.clear()
|
|
if not raw:
|
|
continue
|
|
self.raw_lines.append(raw)
|
|
try:
|
|
value = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if "error" in value:
|
|
raise RuntimeError(f"Firmware rejected command: {value['error']}")
|
|
if predicate(value):
|
|
return value
|
|
raise TimeoutError("Timed out waiting for firmware JSON")
|
|
|
|
|
|
def main():
|
|
args = arguments()
|
|
if not 1 <= args.repeats <= 100:
|
|
raise SystemExit("--repeats must be between 1 and 100")
|
|
if args.output.exists():
|
|
raise SystemExit(f"Refusing to overwrite existing report: {args.output}")
|
|
report = {
|
|
"recorded_utc": dt.datetime.now(dt.timezone.utc).isoformat(),
|
|
"port": args.port,
|
|
"seconds_per_run": args.seconds,
|
|
"requested_fps": args.fps,
|
|
"repeats": args.repeats,
|
|
"receive_only": True,
|
|
"results": [],
|
|
}
|
|
bench = Bench(args.port)
|
|
try:
|
|
# Flush any partial input command, then request a fresh self-description.
|
|
bench.send("")
|
|
bench.send("info")
|
|
info = bench.receive(lambda value: value.get("bench") in (
|
|
"tdeck-display-radio2-v1", "tdeck-display-radio2-v2")
|
|
and "ready" in value, 5)
|
|
report["firmware"] = info
|
|
if not info["ready"]:
|
|
raise RuntimeError("Firmware is present but hardware initialization failed")
|
|
policies = [(2, 0), (8, 1)]
|
|
if args.full_spi_matrix:
|
|
policies = [(2, 0), (8, 0), (2, 1), (8, 1)]
|
|
sequence = 1
|
|
for repeat in range(args.repeats):
|
|
# Rotate display order between repeats to reduce run-order bias.
|
|
modes = [0, 1, 2]
|
|
modes = modes[repeat % 3:] + modes[:repeat % 3]
|
|
for mhz, bulk in policies:
|
|
for mode in modes:
|
|
command = (f"run {mode} {mhz} {bulk} {args.seconds} "
|
|
f"{args.fps} {sequence}")
|
|
print(f"{command} (repeat {repeat + 1})", flush=True)
|
|
bench.send(command)
|
|
try:
|
|
result = bench.receive(lambda value: value.get("result") == sequence,
|
|
args.seconds + 6)
|
|
except TimeoutError:
|
|
# Native USB may lose an output line while the stored
|
|
# firmware result remains available without rerunning.
|
|
bench.send(f"result {sequence}")
|
|
result = bench.receive(lambda value: value.get("result") == sequence, 4)
|
|
result["repeat"] = repeat + 1
|
|
report["results"].append(result)
|
|
print(" switches={switches}, frame max={frame[max_us]} us, "
|
|
"service gap max={service_gap[max_us]} us, chip errors="
|
|
"{chip_errors_after}".format(**result), flush=True)
|
|
sequence += 1
|
|
except BaseException as exc:
|
|
report["collection_error"] = str(exc)
|
|
raise
|
|
finally:
|
|
report["raw_lines"] = bench.raw_lines
|
|
bench.close()
|
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.output.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
|
print(f"Saved {len(report['results'])} runs to {args.output}", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|