Files
DD3-LoRa-Bridge-MultiSender/tools/hil/hil_common.py
T

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)