from __future__ import annotations import json import os import shutil import subprocess from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] FIRMWARE_DIR = ROOT / "firmware" EVIDENCE_ROOT = ROOT / "docs" / "evidence" CAD_STACK = ROOT / "tools" / "hw" / "cad_stack.sh" PIO_MODE_ENV = "KILL_LIFE_PIO_MODE" BUILD_ENV_BY_TARGET = { "esp": "esp32s3_arduino", "esp32s3_arduino": "esp32s3_arduino", "esp32_arduino": "esp32_arduino", } TEST_ENV_BY_TARGET = { "linux": "native", "native": "native", } @dataclass(frozen=True) class TargetSpec: requested: str env: str mode: str def now_utc() -> str: return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") def write_json(path: Path, payload: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def relative_to_root(path: Path) -> str: try: return str(path.resolve().relative_to(ROOT)).replace("\\", "/") except ValueError: return str(path.resolve()) def ensure_evidence_dir(target: str) -> Path: path = EVIDENCE_ROOT / target path.mkdir(parents=True, exist_ok=True) return path def pio_mode() -> str: value = os.environ.get(PIO_MODE_ENV, "auto").strip().lower() or "auto" if value not in {"auto", "native", "container"}: raise SystemExit( f"Unsupported {PIO_MODE_ENV}='{value}'. Supported values: auto, native, container" ) return value def native_pio_available() -> bool: return shutil.which("pio") is not None def container_pio_available() -> bool: return CAD_STACK.exists() and shutil.which("bash") is not None and shutil.which("docker") is not None def firmware_project_dir() -> str: return relative_to_root(FIRMWARE_DIR) def resolve_target(target: str, expected_mode: str | None = None) -> TargetSpec: if target in BUILD_ENV_BY_TARGET: spec = TargetSpec(requested=target, env=BUILD_ENV_BY_TARGET[target], mode="build") elif target in TEST_ENV_BY_TARGET: spec = TargetSpec(requested=target, env=TEST_ENV_BY_TARGET[target], mode="test") else: supported = sorted({*BUILD_ENV_BY_TARGET.keys(), *TEST_ENV_BY_TARGET.keys()}) raise SystemExit(f"Unsupported target '{target}'. Supported targets: {', '.join(supported)}") if expected_mode and spec.mode != expected_mode: raise SystemExit(f"Target '{target}' only supports mode '{spec.mode}', not '{expected_mode}'") return spec def platformio_command(spec: TargetSpec, step: str) -> tuple[list[str], Path, str]: mode = pio_mode() native_available = native_pio_available() use_container = mode == "container" or (mode == "auto" and not native_available) pio_subcommand = "run" if step == "build" else "test" project_dir = firmware_project_dir() args = [pio_subcommand, "-d", project_dir, "-e", spec.env] if use_container: return ["bash", str(CAD_STACK), "pio", *args], ROOT, "cad-stack-container" return ["pio", *args], ROOT, "native-pio" def resolved_pio_runner(spec: TargetSpec, step: str) -> str: return platformio_command(spec, step)[2] def run_logged_command(spec: TargetSpec, step: str, cmd: list[str], cwd: Path = FIRMWARE_DIR) -> int: evidence_dir = ensure_evidence_dir(spec.requested) runner = "subprocess" try: result = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True) stdout = result.stdout stderr = result.stderr returncode = result.returncode except FileNotFoundError as exc: stdout = "" stderr = str(exc) returncode = 127 (evidence_dir / f"{step}.stdout.txt").write_text(stdout, encoding="utf-8") (evidence_dir / f"{step}.stderr.txt").write_text(stderr, encoding="utf-8") write_json( evidence_dir / f"{step}.result.json", { "target": spec.requested, "env": spec.env, "mode": spec.mode, "step": step, "cwd": relative_to_root(cwd), "command": cmd, "runner": runner, "returncode": returncode, "generated_at_utc": now_utc(), }, ) return returncode def run_platformio_step(spec: TargetSpec, step: str) -> int: cmd, cwd, runner = platformio_command(spec, step) evidence_dir = ensure_evidence_dir(spec.requested) try: result = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True) stdout = result.stdout stderr = result.stderr returncode = result.returncode except FileNotFoundError as exc: stdout = "" stderr = str(exc) returncode = 127 (evidence_dir / f"{step}.stdout.txt").write_text(stdout, encoding="utf-8") (evidence_dir / f"{step}.stderr.txt").write_text(stderr, encoding="utf-8") write_json( evidence_dir / f"{step}.result.json", { "target": spec.requested, "env": spec.env, "mode": spec.mode, "step": step, "cwd": relative_to_root(cwd), "project_dir": firmware_project_dir(), "command": cmd, "runner": runner, "returncode": returncode, "generated_at_utc": now_utc(), }, ) return returncode def collect_artifacts(spec: TargetSpec) -> list[str]: base = FIRMWARE_DIR / ".pio" / "build" / spec.env if spec.mode == "build": candidates = [ base / "firmware.bin", base / "firmware.elf", base / "firmware.map", base, ] else: candidates = [base] return [relative_to_root(path) for path in candidates if path.exists()]