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)