feat: add strix command-line client with GCP live view and logging

This commit is contained in:
randogoth 2026-10-25 19:19:13 +02:00
parent 6bee626b7f
commit 76fedf2852
4 changed files with 264 additions and 0 deletions

3
.gitignore vendored
View file

@ -28,3 +28,6 @@ captures/
# Local builds of external tools (onod3000) # Local builds of external tools (onod3000)
.build/ .build/
# Python bytecode
__pycache__/

View file

@ -6,6 +6,8 @@ The firmware serves the same commands over USB serial and over HTTP on its own W
The firmware speaks a line protocol over the board's USB serial port (115200 baud setting, ignored by native USB). Send one command per line. Every reply is a single JSON line, except `entropy`. Errors look like `{"error":"..."}`, and settings changes confirm with `{"ok":true}`. The firmware speaks a line protocol over the board's USB serial port (115200 baud setting, ignored by native USB). Send one command per line. Every reply is a single JSON line, except `entropy`. Errors look like `{"error":"..."}`, and settings changes confirm with `{"ok":true}`.
`tools/strix.py` (`devbox run strix <command>`) wraps the USB protocol. `strix gcp [--log file.csv] [--full]` follows the per-second trials with a live cumulative Z² − 1 and its p = 0.05 envelope, reconnects after USB dropouts and fills gaps from the device's history. The log has GCP-style rows: `13,<unix time>,<UTC date-time>,<g0>,<g1>,<g2>,<g3>` (200-bit sums, empty for unhealthy generators), after a `12,gmtime,Date/Time,g0,g1,g2,g3` header.
## Commands ## Commands
| Command | Reply | | Command | Reply |

View file

@ -50,6 +50,9 @@
], ],
"jitter": [ "jitter": [
"uv run tools/jitter.py --port ${PORT:-/dev/ttyACM0} --out captures \"$@\"" "uv run tools/jitter.py --port ${PORT:-/dev/ttyACM0} --out captures \"$@\""
],
"strix": [
"uv run tools/strix.py \"$@\""
] ]
} }
} }

256
tools/strix.py Normal file
View file

@ -0,0 +1,256 @@
#!/usr/bin/env python3
"""Command-line client for the STRIX firmware over USB (arduino/strix, see API.md).
Subcommands:
id, health, stats, amp, get print the device's reply
set KEY VALUE change a setting
entropy N N entropy bytes as hex, or binary into a file with --out
watch stats|amp print every new trial until Ctrl-C
gcp follow the per-second GCP-format trials: live cumulative
Z^2 - 1 against its p = 0.05 envelope, optional CSV log
send LINE send any command line and print the reply
The port is found by the board's USB ID unless --port or STRIX_PORT is given.
"""
import argparse
import json
import math
import os
import sys
import time
from collections import deque
from datetime import datetime, timezone
import serial
from serial.tools import list_ports
VIDOR_USB_ID = (0x2341, 0x8056)
MAX_ENTROPY_REQUEST = 65536
ENVELOPE_Z = 1.96 # two-sided p = 0.05
def find_port() -> str:
if os.environ.get("STRIX_PORT"):
return os.environ["STRIX_PORT"]
for port in list_ports.comports():
if (port.vid, port.pid) == VIDOR_USB_ID:
return port.device
return "/dev/ttyACM0"
class Device:
def __init__(self, port: str):
self.serial = serial.Serial(port, 115200, timeout=5)
self.serial.reset_input_buffer()
def send(self, line: str) -> None:
self.serial.write(f"{line}\n".encode())
def read_line(self) -> str:
line = self.serial.readline()
if not line:
raise TimeoutError("no reply from the device")
return line.decode(errors="replace").strip()
def json(self, line: str) -> dict:
self.send(line)
return json.loads(self.read_line())
def entropy(self, count: int) -> bytes:
self.send(f"entropy {count} bin")
header = self.read_line()
if header != f"BIN {count}":
raise RuntimeError(header) # an error reply such as an unhealthy stream source
data = self.serial.read(count)
if len(data) != count:
raise RuntimeError(f"stream stopped after {len(data)} of {count} bytes")
return data
def print_reply(reply: dict) -> None:
print(json.dumps(reply, indent=2))
if "error" in reply:
sys.exit(1)
def run_entropy(device: Device, args) -> None:
out = open(args.out, "wb") if args.out else None
remaining = args.count
while remaining:
chunk = device.entropy(min(remaining, MAX_ENTROPY_REQUEST))
if out:
out.write(chunk)
else:
sys.stdout.write(chunk.hex().upper())
remaining -= len(chunk)
if out:
out.close()
else:
print()
def run_watch(device: Device, args) -> None:
reply = device.json(f"watch {args.what}")
if "error" in reply:
print_reply(reply)
try:
while True:
print(device.read_line(), flush=True)
except KeyboardInterrupt:
device.send("watch off")
class GcpFollower:
"""Turns per-second records into timestamped trials and a cumulative network variance.
The device counts seconds since its FPGA started but has no clock, so the host anchors
second numbers to its own clock as live records arrive; records fetched to fill a gap get
times from the same anchor."""
def __init__(self, measure: str, log_path: str | None):
self.measure = measure
self.last_second = None
self.offset = None # host time at the end of second 0
self.trials = 0
self.cumulative = 0.0
self.log = None
if log_path:
new = not os.path.exists(log_path) or os.path.getsize(log_path) == 0
self.log = open(log_path, "a")
if new:
self.log.write("12,gmtime,Date/Time,g0,g1,g2,g3\n")
def anchor(self, second: int) -> None:
"""Called with each live record, which arrives just after its second ended."""
now = time.time()
if self.offset is None or abs(now - (self.offset + second)) > 2:
self.offset = math.floor(now) - second
def handle(self, record: dict) -> None:
second = record["second"]
if self.last_second is not None and second <= self.last_second:
print(f"device restarted (second {second} after {self.last_second})", file=sys.stderr)
elif self.last_second is not None and second > self.last_second + 1:
print(f"{second - self.last_second - 1} second(s) lost", file=sys.stderr)
self.last_second = second
start = self.offset + second - 1
stamp = datetime.fromtimestamp(start, timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
sums = record["sample"]["sums"]
if self.log:
fields = ["" if s is None else str(s) for s in sums]
self.log.write(f"13,{start},{stamp},{','.join(fields)}\n")
self.log.flush()
trial = record[self.measure]
netvar = trial["netvar"]
if netvar is None:
print(f"{stamp} #{second:<7} no healthy generator")
return
self.trials += 1
self.cumulative += netvar - 1
envelope = ENVELOPE_Z * math.sqrt(2 * self.trials)
outside = " *" if abs(self.cumulative) > envelope else ""
shown = " ".join(" -" if s is None else f"{s:3d}" for s in sums)
print(f"{stamp} #{second:<7} sums {shown} Z {trial['stouffer']:+.3f} Z² {netvar:6.3f}"
f" Σ(Z²−1) {self.cumulative:+8.2f} ±{envelope:6.2f}{outside}", flush=True)
def fetch_missed(device: Device, after: int, before: int, pending: deque) -> list[dict]:
"""Seconds after `after` and before `before` from the device's 60-second history. Live
records arriving meanwhile are queued in `pending`."""
device.send(f"gcp {after}")
while True:
line = device.read_line()
if line.startswith('{"records"'):
return [r for r in json.loads(line)["records"] if r["second"] < before]
if line.startswith('{"second"'):
pending.append(line)
def follow_gcp(port: str, follower: GcpFollower, seconds: int | None) -> None:
"""Follows `watch gcp`, reconnecting after USB dropouts and filling gaps from the device's
history."""
end = time.time() + seconds if seconds else None
device = None
pending = deque()
try:
while end is None or time.time() < end:
try:
if device is None:
device = Device(port)
reply = device.json("watch gcp")
if "error" in reply:
print_reply(reply)
line = pending.popleft() if pending else device.read_line()
if not line.startswith('{"second"'):
continue
record = json.loads(line)
second, last = record["second"], follower.last_second
follower.anchor(second)
if last is not None and second > last + 1:
for missed in fetch_missed(device, last, second, pending):
follower.handle(missed)
follower.handle(record)
except (serial.SerialException, OSError, TimeoutError) as error:
print(f"connection lost ({error}), reconnecting", file=sys.stderr)
if device:
device.serial.close()
device = None
pending.clear()
time.sleep(2)
except KeyboardInterrupt:
pass
finally:
if device:
device.send("watch off")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--port", help="serial port (default: the STRIX board, or STRIX_PORT)")
commands = parser.add_subparsers(dest="command", required=True)
for name in ("id", "health", "amp", "get"):
commands.add_parser(name)
stats = commands.add_parser("stats")
stats.add_argument("--reset", action="store_true", help="restart trials and windows")
setting = commands.add_parser("set")
setting.add_argument("key")
setting.add_argument("value")
entropy = commands.add_parser("entropy")
entropy.add_argument("count", type=int)
entropy.add_argument("--out", help="write binary to this file instead of hex to stdout")
watch = commands.add_parser("watch")
watch.add_argument("what", choices=["stats", "amp"])
gcp = commands.add_parser("gcp")
gcp.add_argument("--log", help="append timestamped 200-bit trial sums to this CSV file")
gcp.add_argument("--full", action="store_true", help="track the full-rate trials instead of the 200-bit sample")
gcp.add_argument("--seconds", type=int, help="stop after this many seconds")
send = commands.add_parser("send")
send.add_argument("line", nargs="+")
args = parser.parse_args()
port = args.port or find_port()
if args.command == "gcp":
follow_gcp(port, GcpFollower("full" if args.full else "sample", args.log), args.seconds)
return
device = Device(port)
if args.command == "stats":
print_reply(device.json("stats reset" if args.reset else "stats"))
elif args.command == "set":
print_reply(device.json(f"set {args.key} {args.value}"))
elif args.command == "entropy":
run_entropy(device, args)
elif args.command == "watch":
run_watch(device, args)
elif args.command == "send":
device.send(" ".join(args.line))
print(device.read_line())
else:
print_reply(device.json(args.command))
if __name__ == "__main__":
main()