262 lines
9.3 KiB
Python
262 lines
9.3 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
try:
|
|
import tomllib
|
|
except ModuleNotFoundError: # pragma: no cover - Python >=3.11 is documented
|
|
tomllib = None
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
DEFAULT_CONFIG = ROOT / ".hil" / "local.toml"
|
|
ARTIFACT_ROOT = ROOT / "artifacts" / "hil"
|
|
HIL_PREFIX = b"HIL:"
|
|
|
|
|
|
class HilError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PortConfig:
|
|
name: str
|
|
device: Path
|
|
approved_access: bool
|
|
approved_flash: bool = False
|
|
|
|
|
|
def utc_stamp() -> str:
|
|
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
|
|
|
|
def new_artifact_dir(label: str = "run") -> Path:
|
|
path = ARTIFACT_ROOT / f"{utc_stamp()}-{label}"
|
|
suffix = 1
|
|
while path.exists():
|
|
path = ARTIFACT_ROOT / f"{utc_stamp()}-{label}-{suffix}"
|
|
suffix += 1
|
|
path.mkdir(parents=True)
|
|
return path
|
|
|
|
|
|
def load_config(path: Path = DEFAULT_CONFIG) -> dict[str, Any]:
|
|
if tomllib is None:
|
|
raise HilError("Python 3.11 or newer is required (tomllib is unavailable)")
|
|
if not path.is_file():
|
|
raise HilError(f"missing {path}; copy .hil/local.example.toml and enter only approved ports")
|
|
with path.open("rb") as handle:
|
|
config = tomllib.load(handle)
|
|
return config
|
|
|
|
|
|
def load_config_if_present(path: Path = DEFAULT_CONFIG) -> dict[str, Any]:
|
|
return load_config(path) if path.is_file() else {}
|
|
|
|
|
|
def configured_port(config: dict[str, Any], name: str, operation: str) -> PortConfig:
|
|
raw = config.get("ports", {}).get(name, {})
|
|
device_text = str(raw.get("device", "")).strip()
|
|
if not device_text:
|
|
raise HilError(f"ports.{name}.device is not configured")
|
|
device = Path(device_text)
|
|
if not str(device).startswith("/dev/serial/by-id/"):
|
|
raise HilError(f"{name} must use a stable /dev/serial/by-id path, got {device}")
|
|
if not device.exists():
|
|
raise HilError(f"configured {name} device does not exist: {device}")
|
|
access = raw.get("approved_access") is True
|
|
flash = raw.get("approved_flash") is True
|
|
if operation in {"access", "flash"} and not access:
|
|
raise HilError(f"first access to {device} is not approved in {DEFAULT_CONFIG}")
|
|
if operation == "flash" and not flash:
|
|
raise HilError(f"flashing {device} is not approved in {DEFAULT_CONFIG}")
|
|
return PortConfig(name, device, access, flash)
|
|
|
|
|
|
def pair_ports(config: dict[str, Any], operation: str) -> tuple[PortConfig, PortConfig]:
|
|
sender = configured_port(config, "sender", operation)
|
|
receiver = configured_port(config, "receiver", operation)
|
|
if sender.device.resolve() == receiver.device.resolve():
|
|
raise HilError("sender and receiver resolve to the same device")
|
|
return sender, receiver
|
|
|
|
|
|
def pio_command() -> str:
|
|
for candidate in (shutil.which("pio"), shutil.which("platformio")):
|
|
if candidate:
|
|
return candidate
|
|
local = Path.home() / ".platformio" / "penv" / "bin" / "pio"
|
|
if local.is_file() and os.access(local, os.X_OK):
|
|
return str(local)
|
|
raise HilError("PlatformIO CLI was not found on PATH or in ~/.platformio/penv/bin/pio")
|
|
|
|
|
|
def run_checked(command: list[str], *, cwd: Path = ROOT, log: Path | None = None) -> str:
|
|
process = subprocess.run(command, cwd=cwd, text=True, stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT, check=False)
|
|
if log:
|
|
log.parent.mkdir(parents=True, exist_ok=True)
|
|
log.write_text(process.stdout, encoding="utf-8")
|
|
sys.stdout.write(process.stdout)
|
|
if process.returncode:
|
|
raise HilError(f"command failed ({process.returncode}): {' '.join(command)}")
|
|
return process.stdout
|
|
|
|
|
|
def parse_hil_line(line: bytes) -> dict[str, Any] | None:
|
|
marker = line.find(HIL_PREFIX)
|
|
if marker < 0:
|
|
return None
|
|
try:
|
|
value = json.loads(line[marker + len(HIL_PREFIX):].decode("utf-8", "strict").strip())
|
|
except (UnicodeDecodeError, json.JSONDecodeError):
|
|
return None
|
|
return value if isinstance(value, dict) and isinstance(value.get("event"), str) else None
|
|
|
|
|
|
def serial_module():
|
|
try:
|
|
import serial
|
|
except ModuleNotFoundError as exc:
|
|
raise HilError("pyserial is required; install requirements-hil.txt in a project-local virtualenv") from exc
|
|
return serial
|
|
|
|
|
|
class PairCapture:
|
|
def __init__(self, ports: tuple[PortConfig, PortConfig], baud: int, output: Path):
|
|
self.ports = ports
|
|
self.baud = baud
|
|
self.output = output
|
|
self.stop = threading.Event()
|
|
self.events: list[dict[str, Any]] = []
|
|
self._lock = threading.Lock()
|
|
self._handles: dict[str, Any] = {}
|
|
self._threads: list[threading.Thread] = []
|
|
|
|
def _reader(self, port: PortConfig) -> None:
|
|
raw_path = self.output / f"{port.name}.raw"
|
|
log_path = self.output / f"{port.name}.log"
|
|
handle = self._handles[port.name]
|
|
with raw_path.open("ab", buffering=0) as raw, log_path.open("a", encoding="utf-8", buffering=1) as log:
|
|
while not self.stop.is_set():
|
|
line = handle.readline()
|
|
if not line:
|
|
continue
|
|
host_ns = time.time_ns()
|
|
raw.write(line)
|
|
rendered = line.decode("utf-8", "replace").rstrip("\r\n")
|
|
log.write(f"{datetime.fromtimestamp(host_ns / 1e9, timezone.utc).isoformat()} {rendered}\n")
|
|
event = parse_hil_line(line)
|
|
if event is not None:
|
|
event["host_time_ns"] = host_ns
|
|
event["source"] = port.name
|
|
with self._lock:
|
|
self.events.append(event)
|
|
|
|
def start(self) -> None:
|
|
serial = serial_module()
|
|
self.output.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
for port in self.ports:
|
|
# Opening is deliberately delayed until approval checks have completed.
|
|
self._handles[port.name] = serial.Serial(str(port.device), self.baud, timeout=0.25,
|
|
write_timeout=1, exclusive=True)
|
|
for port in self.ports:
|
|
thread = threading.Thread(target=self._reader, args=(port,), daemon=True,
|
|
name=f"hil-{port.name}")
|
|
thread.start()
|
|
self._threads.append(thread)
|
|
except Exception:
|
|
self.close()
|
|
raise
|
|
|
|
def send_fault(self, target: str, fault: str) -> None:
|
|
allowed = {"drop_chunk", "duplicate_chunk", "corrupt_chunk", "suppress_ack",
|
|
"wrong_ack_batch", "clear"}
|
|
if fault not in allowed and not fault.startswith("delay_ack:"):
|
|
raise HilError(f"unsupported HIL fault: {fault}")
|
|
handle = self._handles.get(target)
|
|
if handle is None:
|
|
raise HilError(f"serial target is not open: {target}")
|
|
handle.write(f"HILCMD:{fault}\n".encode("ascii"))
|
|
handle.flush()
|
|
|
|
def reset_pair(self) -> None:
|
|
"""Pulse ESP32 EN through the normal USB auto-reset circuit.
|
|
|
|
DTR remains deasserted so GPIO0 is not held low and the boards boot the
|
|
flashed application rather than the ROM download mode.
|
|
"""
|
|
for handle in self._handles.values():
|
|
handle.dtr = False
|
|
handle.rts = True
|
|
time.sleep(0.15)
|
|
for handle in self._handles.values():
|
|
handle.rts = False
|
|
time.sleep(0.25)
|
|
|
|
def close(self) -> None:
|
|
self.stop.set()
|
|
for thread in self._threads:
|
|
thread.join(timeout=2)
|
|
for handle in self._handles.values():
|
|
try:
|
|
handle.close()
|
|
except Exception:
|
|
pass
|
|
self._handles.clear()
|
|
ordered = sorted(self.events, key=lambda item: item["host_time_ns"])
|
|
with (self.output / "events.jsonl").open("w", encoding="utf-8") as stream:
|
|
for event in ordered:
|
|
stream.write(json.dumps(event, separators=(",", ":"), sort_keys=True) + "\n")
|
|
|
|
def run(self, seconds: float, on_tick: Callable[[float, "PairCapture"], None] | None = None,
|
|
reset_on_start: bool = False) -> None:
|
|
self.start()
|
|
if reset_on_start:
|
|
self.reset_pair()
|
|
started = time.monotonic()
|
|
try:
|
|
while time.monotonic() - started < seconds and not self.stop.is_set():
|
|
elapsed = time.monotonic() - started
|
|
if on_tick:
|
|
on_tick(elapsed, self)
|
|
time.sleep(0.2)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
self.close()
|
|
|
|
|
|
def read_events(artifact: Path) -> list[dict[str, Any]]:
|
|
path = artifact / "events.jsonl"
|
|
if not path.is_file():
|
|
return []
|
|
events = []
|
|
for line in path.read_text(encoding="utf-8").splitlines():
|
|
try:
|
|
item = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(item, dict):
|
|
events.append(item)
|
|
return events
|
|
|
|
|
|
def main_guard(function: Callable[[], int]) -> None:
|
|
try:
|
|
raise SystemExit(function())
|
|
except HilError as exc:
|
|
print(f"HIL error: {exc}", file=sys.stderr)
|
|
raise SystemExit(2)
|