feat: add HIL diagnostics and meter health handling
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user