mirror of
https://github.com/allaunthefox/Research-Stack.git
synced 2026-07-31 03:05:21 +00:00
153 lines
4.9 KiB
Python
Executable file
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())
|