Research-Stack/4-Infrastructure/shim/fpga_topological_extension_probe.py
2026-05-11 22:18:31 -05:00

153 lines
4.9 KiB
Python
Executable file

#!/usr/bin/env python3
"""Detect a newly attached USB FPGA as a topological extension.
The probe records USB devices, serial nodes, and sysfs ancestry. It can compare
against a prior snapshot and identify new nodes without relying on kernel logs.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import time
from pathlib import Path
from typing import Any
DEFAULT_OUT = Path("4-Infrastructure/shim/fpga_topological_extension_snapshot.json")
def run_text(cmd: list[str]) -> str:
try:
return subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL)
except (FileNotFoundError, subprocess.CalledProcessError):
return ""
def list_usb() -> list[dict[str, str]]:
rows = []
for line in run_text(["lsusb"]).splitlines():
parts = line.split()
if len(parts) < 6:
continue
rows.append(
{
"bus": parts[1],
"device": parts[3].rstrip(":"),
"id": parts[5],
"description": " ".join(parts[6:]),
"raw": line,
}
)
return rows
def readlink(path: Path) -> str:
try:
return os.readlink(path)
except OSError:
return ""
def serial_nodes() -> list[dict[str, str]]:
nodes: dict[str, dict[str, str]] = {}
for pattern in ("/dev/ttyUSB*", "/dev/ttyACM*"):
for node in sorted(Path("/dev").glob(Path(pattern).name)):
nodes[str(node)] = {
"node": str(node),
"by_id": "",
"sysfs": "",
"driver": "",
}
by_id = Path("/dev/serial/by-id")
if by_id.exists():
for link in sorted(by_id.iterdir()):
target = (by_id / link.name).resolve()
entry = nodes.setdefault(
str(target),
{"node": str(target), "by_id": "", "sysfs": "", "driver": ""},
)
entry["by_id"] = str(link)
for entry in nodes.values():
node_name = Path(entry["node"]).name
sys_path = Path("/sys/class/tty") / node_name
if sys_path.exists():
entry["sysfs"] = str(sys_path.resolve())
entry["driver"] = readlink(sys_path / "device" / "driver")
return sorted(nodes.values(), key=lambda item: item["node"])
def snapshot() -> dict[str, Any]:
payload = {
"schema": "fpga_topological_extension_snapshot_v1",
"timestamp_unix": time.time(),
"usb": list_usb(),
"serial": serial_nodes(),
}
digest_input = json.dumps(payload, sort_keys=True).encode("utf-8")
payload["sha256"] = hashlib.sha256(digest_input).hexdigest()
return payload
def keyset(snapshot_payload: dict[str, Any], name: str) -> set[str]:
if "snapshot" in snapshot_payload:
snapshot_payload = snapshot_payload["snapshot"]
if name == "usb":
return {row.get("raw", "") for row in snapshot_payload.get("usb", [])}
if name == "serial":
return {row.get("node", "") + "|" + row.get("by_id", "") for row in snapshot_payload.get("serial", [])}
return set()
def diff(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
return {
"schema": "fpga_topological_extension_diff_v1",
"before_sha256": before.get("sha256", ""),
"after_sha256": after.get("sha256", ""),
"new_usb": sorted(keyset(after, "usb") - keyset(before, "usb")),
"removed_usb": sorted(keyset(before, "usb") - keyset(after, "usb")),
"new_serial": sorted(keyset(after, "serial") - keyset(before, "serial")),
"removed_serial": sorted(keyset(before, "serial") - keyset(after, "serial")),
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--out", type=Path, default=DEFAULT_OUT)
parser.add_argument("--before", type=Path)
parser.add_argument("--watch", action="store_true")
parser.add_argument("--interval", type=float, default=1.0)
parser.add_argument("--timeout", type=float, default=60.0)
args = parser.parse_args()
before_raw = json.loads(args.before.read_text()) if args.before else None
before = before_raw.get("snapshot", before_raw) if isinstance(before_raw, dict) else None
start = time.time()
current = snapshot()
if args.watch and before is not None:
while time.time() - start < args.timeout:
current = snapshot()
current_diff = diff(before, current)
if current_diff["new_usb"] or current_diff["new_serial"]:
break
time.sleep(args.interval)
result: dict[str, Any] = {"snapshot": current}
if before is not None:
result["diff"] = diff(before, current)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())