From 69ba2047139cb9a72b3398adfd550df36696dd06 Mon Sep 17 00:00:00 2001 From: Sam Anthony Date: Tue, 21 Apr 2026 14:21:18 -0400 Subject: hw: add American Embedded build script --- hw/build.py | 643 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 643 insertions(+) create mode 100755 hw/build.py (limited to 'hw/build.py') diff --git a/hw/build.py b/hw/build.py new file mode 100755 index 0000000..e94e8ef --- /dev/null +++ b/hw/build.py @@ -0,0 +1,643 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import csv +import os +import re +import shutil +import subprocess +import sys +import zipfile +from dataclasses import dataclass +from pathlib import Path + +ANSI_RED = "\033[31m" +ANSI_RESET = "\033[0m" + + +class BuildError(RuntimeError): + pass + + +class StepFailed(BuildError): + def __init__(self, message: str, returncode: int) -> None: + super().__init__(message) + self.returncode = returncode + + +@dataclass +class StepOutcome: + returncode: int + output: str = "" + + +class BuildRunner: + def __init__(self, project_file: Path | None, theme_override: str | None) -> None: + self.cwd = Path.cwd() + self.project_file = self._resolve_project_file(project_file) + self.project_name = self.project_file.name.removesuffix(".kicad_pro") + self.schematic_file = self.cwd / f"{self.project_name}.kicad_sch" + self.pcb_file = self.cwd / f"{self.project_name}.kicad_pcb" + + self.build_dir = self.cwd / "build" + self.gerber_dir = self.build_dir / "gerbs" + self.gerber_zip = self.build_dir / "gerbers.zip" + self.raw_positions_file = self.build_dir / "positions_raw.csv" + self.positions_file = self.build_dir / "positions.csv" + self.invalid_build_file = self.build_dir / "INVALID_BUILD" + + self.schematic_theme = ( + theme_override if theme_override is not None else os.environ.get("SCHEMATIC_THEME", "American Embedded Light") + ) + + self.invalid_reasons: list[str] = [] + self.invalid_notified = False + + self._require_command("kicad-cli") + self._require_file(self.project_file) + self._require_file(self.schematic_file) + self._require_file(self.pcb_file) + + self.board_layers = self._extract_board_layers() + if not self.board_layers: + raise BuildError(f"Failed to extract layer list from '{self.pcb_file.name}'.") + + def run(self) -> int: + if self.project_name == "Template": + print("Warning: The project name is still 'Template'.") + print("It is recommended to rename project files before proceeding.") + print() + + self._prepare_build_dir() + + print(f"Using KiCad CLI {self._kicad_version()}") + print(f"Project: {self.project_file.name}") + print() + + gerber_layers = self._collect_layers( + [ + "B.Cu", + "B.Mask", + "B.Paste", + "B.SilkS", + "B.CrtYd", + "B.Fab", + *self._inner_copper_layers(), + "F.Cu", + "F.Mask", + "F.Paste", + "F.SilkS", + "F.CrtYd", + "F.Fab", + "Dwgs.User", + "Cmts.User", + "Edge.Cuts", + ] + ) + pcb_pdf_layers = self._collect_layers( + [ + "B.Cu", + "B.Mask", + "B.Paste", + "B.SilkS", + "B.CrtYd", + "B.Fab", + *self._inner_copper_layers(), + "F.Cu", + "F.Mask", + "F.Paste", + "F.SilkS", + "F.CrtYd", + "F.Fab", + "Dwgs.User", + "Cmts.User", + "User.4", + "User.3", + "User.2", + "User.1", + "Edge.Cuts", + ] + ) + + self._run_step( + "ERC", + lambda: self._run_command( + [ + "kicad-cli", + "sch", + "erc", + "--output", + str(self.build_dir / f"{self.project_name}-erc.rpt"), + "--format", + "report", + "--units", + "mm", + "--severity-warning", + "--severity-error", + "--exit-code-violations", + str(self.schematic_file), + ] + ), + invalidates_build=True, + ) + + self._run_step( + "DRC", + lambda: self._run_command( + [ + "kicad-cli", + "pcb", + "drc", + "--output", + str(self.build_dir / f"{self.project_name}-drc.rpt"), + "--format", + "report", + "--units", + "mm", + "--severity-warning", + "--severity-error", + "--refill-zones", + "--schematic-parity", + "--exit-code-violations", + str(self.pcb_file), + ] + ), + invalidates_build=True, + ) + + schematic_pdf_cmd = [ + "kicad-cli", + "sch", + "export", + "pdf", + "--output", + str(self.build_dir / "schematic.pdf"), + ] + if self.schematic_theme: + schematic_pdf_cmd.extend(["--theme", self.schematic_theme]) + schematic_pdf_cmd.append(str(self.schematic_file)) + + self._run_step("Schematic PDF", lambda: self._run_command(schematic_pdf_cmd)) + + self._run_step( + "JLCPCB BOM", + lambda: self._run_command( + [ + "kicad-cli", + "sch", + "export", + "bom", + "--preset", + "JLCPCB", + "--format-preset", + "CSV", + "--output", + str(self.build_dir / "bom_JLCPCB.csv"), + str(self.schematic_file), + ] + ), + ) + + self._run_step( + "NextPCB BOM", + lambda: self._run_command( + [ + "kicad-cli", + "sch", + "export", + "bom", + "--preset", + "NextPCB", + "--format-preset", + "CSV", + "--output", + str(self.build_dir / "bom_NextPCB.csv"), + str(self.schematic_file), + ] + ), + ) + + self._run_step( + "Gerbers", + lambda: self._run_command( + [ + "kicad-cli", + "pcb", + "export", + "gerbers", + "--output", + str(self.gerber_dir), + "--layers", + gerber_layers, + "--crossout-DNP-footprints-on-fab-layers", + "--sketch-DNP-footprints-on-fab-layers", + "--subtract-soldermask", + "--precision", + "5", + str(self.pcb_file), + ] + ), + ) + + self._run_step( + "Drill files", + lambda: self._run_command( + [ + "kicad-cli", + "pcb", + "export", + "drill", + "--output", + str(self.gerber_dir), + "--format", + "excellon", + "--drill-origin", + "absolute", + "--excellon-units", + "in", + "--excellon-zeros-format", + "decimal", + "--gerber-precision", + "5", + str(self.pcb_file), + ] + ), + ) + + self._run_step("Gerber archive", self._create_gerber_archive) + + self._run_step( + "PCB PDF", + lambda: self._run_command( + [ + "kicad-cli", + "pcb", + "export", + "pdf", + "--output", + str(self.build_dir / "pcb_layout"), + "--layers", + pcb_pdf_layers, + "--black-and-white", + "--crossout-DNP-footprints-on-fab-layers", + "--sketch-DNP-footprints-on-fab-layers", + "--include-border-title", + "--drill-shape-opt", + "2", + "--mode-multipage", + str(self.pcb_file), + ] + ), + ) + + self._run_step( + "Raw positions", + lambda: self._run_command( + [ + "kicad-cli", + "pcb", + "export", + "pos", + "--output", + str(self.raw_positions_file), + "--format", + "csv", + "--units", + "mm", + "--side", + "both", + "--exclude-dnp", + "--use-drill-file-origin", + str(self.pcb_file), + ] + ), + ) + + self._run_step("Placement CSV", self._convert_positions) + + self._run_step( + "STEP model", + lambda: self._run_command( + [ + "kicad-cli", + "pcb", + "export", + "step", + "--output", + str(self.build_dir / "board.step"), + "--force", + "--subst-models", + "--no-dnp", + str(self.pcb_file), + ] + ), + ) + + self._run_step( + "Top render", + lambda: self._run_command( + [ + "kicad-cli", + "pcb", + "render", + "--output", + str(self.build_dir / "top.png"), + "--width", + "1280", + "--height", + "720", + "--side", + "top", + "--background", + "transparent", + "--quality", + "basic", + "--preset", + "follow_pcb_editor", + "--light-top", + "0", + "--light-bottom", + "0", + "--light-side", + "0.5", + "--light-camera", + "0", + "--light-side-elevation", + "60", + str(self.pcb_file), + ] + ), + ) + + self._run_step( + "Bottom render", + lambda: self._run_command( + [ + "kicad-cli", + "pcb", + "render", + "--output", + str(self.build_dir / "bottom.png"), + "--width", + "1280", + "--height", + "720", + "--side", + "bottom", + "--background", + "transparent", + "--quality", + "basic", + "--preset", + "follow_pcb_editor", + "--light-top", + "0", + "--light-bottom", + "0", + "--light-side", + "0.5", + "--light-camera", + "0", + "--light-side-elevation", + "60", + str(self.pcb_file), + ] + ), + ) + + print("Build completed.") + return 0 + + def _run_step(self, label: str, action, *, invalidates_build: bool = False) -> StepOutcome: + print(f"[{label}]") + + try: + outcome = action() + except BuildError as exc: + outcome = StepOutcome(1, f"Error: {exc}\n") + + if outcome.output: + sys.stdout.write(outcome.output) + if not outcome.output.endswith("\n"): + print() + + print() + + if outcome.returncode != 0: + if invalidates_build: + self._mark_invalid(f"{label} failed (exit {outcome.returncode})") + self._handle_invalid_build_status() + + sys.stdout.flush() + self._print_error(f"{label} failed (exit {outcome.returncode}).") + raise StepFailed(f"{label} failed", outcome.returncode) + + return outcome + + def _run_command(self, command: list[str]) -> StepOutcome: + completed = subprocess.run(command, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False) + return StepOutcome(completed.returncode, completed.stdout) + + def _convert_positions(self) -> StepOutcome: + if not self.raw_positions_file.is_file(): + raise BuildError(f"Raw position file was not created: '{self.raw_positions_file.name}'") + + with self.raw_positions_file.open(newline="", encoding="utf-8") as src, self.positions_file.open( + "w", newline="", encoding="utf-8" + ) as dst: + reader = csv.DictReader(src) + required = {"Ref", "PosX", "PosY", "Rot", "Side"} + if reader.fieldnames is None or not required.issubset(reader.fieldnames): + raise BuildError( + f"Unexpected position CSV header in '{self.raw_positions_file.name}': {reader.fieldnames!r}" + ) + + writer = csv.writer(dst) + writer.writerow(["Designator", "Mid X", "Mid Y", "Layer", "Rotation"]) + + for row in reader: + side = (row["Side"] or "").strip().lower() + layer = "T" if side in {"top", "front"} else "B" + writer.writerow([row["Ref"], row["PosX"], row["PosY"], layer, row["Rot"]]) + + return StepOutcome(0, f"Converted positions: {self.positions_file.relative_to(self.cwd)}\n") + + def _create_gerber_archive(self) -> StepOutcome: + if not self.gerber_dir.is_dir(): + raise BuildError(f"Gerber directory was not created: '{self.gerber_dir.name}'") + + with zipfile.ZipFile(self.gerber_zip, "w", compression=zipfile.ZIP_DEFLATED) as archive: + for path in sorted(self.gerber_dir.iterdir()): + if path.is_file(): + archive.write(path, arcname=path.name) + + return StepOutcome(0, f"Created archive '{self.gerber_zip.relative_to(self.cwd)}'.\n") + + def _prepare_build_dir(self) -> None: + self.build_dir.mkdir(exist_ok=True) + shutil.rmtree(self.gerber_dir, ignore_errors=True) + self.gerber_dir.mkdir() + + for path in [ + self.gerber_zip, + self.raw_positions_file, + self.positions_file, + self.invalid_build_file, + self.build_dir / "positions_raw-all-pos", + self.build_dir / "positions_raw-all-pos.csv", + self.build_dir / "schematic.pdf", + self.build_dir / "bom_JLCPCB.csv", + self.build_dir / "bom_NextPCB.csv", + self.build_dir / "board.step", + self.build_dir / "pcb_layout", + self.build_dir / "top.png", + self.build_dir / "bottom.png", + ]: + path.unlink(missing_ok=True) + + for pattern in ("*-erc.rpt", "*-drc.rpt"): + for path in self.build_dir.glob(pattern): + path.unlink(missing_ok=True) + + def _resolve_project_file(self, project_file: Path | None) -> Path: + if project_file is not None: + resolved = (self.cwd / project_file).resolve() if not project_file.is_absolute() else project_file.resolve() + if resolved.parent != self.cwd: + raise BuildError("Project file must be in the current working directory.") + return resolved + + project_files = sorted(self.cwd.glob("*.kicad_pro")) + if not project_files: + raise BuildError("No .kicad_pro file found in the current directory.") + if len(project_files) > 1: + names = "\n".join(path.name for path in project_files) + raise BuildError(f"Multiple .kicad_pro files found in this directory:\n{names}") + return project_files[0] + + def _extract_board_layers(self) -> list[str]: + layers: list[str] = [] + in_layers = False + + with self.pcb_file.open(encoding="utf-8") as pcb: + for line in pcb: + stripped = line.strip() + if stripped == "(layers": + in_layers = True + continue + if in_layers and stripped == ")": + break + if in_layers: + match = re.search(r'"([^"]+)"', line) + if match: + layers.append(match.group(1)) + + return layers + + def _inner_copper_layers(self) -> list[str]: + inner_layers = [layer for layer in self.board_layers if re.fullmatch(r"In\d+\.Cu", layer)] + return sorted(inner_layers, key=lambda layer: int(layer[2:].split(".")[0]), reverse=True) + + def _collect_layers(self, candidates: list[str]) -> str: + board_layer_set = set(self.board_layers) + return ",".join(layer for layer in candidates if layer in board_layer_set) + + def _handle_invalid_build_status(self) -> None: + if not self.invalid_reasons: + self.invalid_build_file.unlink(missing_ok=True) + return + + self._update_invalid_build_file() + self._notify_invalid_build() + + def _mark_invalid(self, reason: str) -> None: + if reason not in self.invalid_reasons: + self.invalid_reasons.append(reason) + self._update_invalid_build_file() + + def _update_invalid_build_file(self) -> None: + if not self.invalid_reasons: + self.invalid_build_file.unlink(missing_ok=True) + return + + lines = [ + f"Project: {self.project_file.name}", + "Status: INVALID_BUILD", + "Reason: ERC and/or DRC failed.", + "", + "Failures:", + *[f"- {reason}" for reason in self.invalid_reasons], + "", + f"Reports: {self.project_name}-erc.rpt, {self.project_name}-drc.rpt", + ] + self.invalid_build_file.write_text("\n".join(lines) + "\n", encoding="utf-8") + + def _notify_invalid_build(self) -> None: + if self.invalid_notified: + return + + title = "KiCad Build Invalid" + body = f"{self.project_name}: ERC/DRC failed. See build/INVALID_BUILD." + + notification_sent = False + if shutil.which("notify-send") is not None: + completed = subprocess.run(["notify-send", title, body], check=False, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + notification_sent = completed.returncode == 0 + + if not notification_sent: + print(f"Notification: {title}: {body}", file=sys.stderr) + + self.invalid_notified = True + + def _kicad_version(self) -> str: + completed = subprocess.run(["kicad-cli", "version"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, check=False) + version = completed.stdout.strip() + return version or "unknown" + + @staticmethod + def _require_command(name: str) -> None: + if shutil.which(name) is None: + raise BuildError(f"Required command not found: '{name}'") + + @staticmethod + def _require_file(path: Path) -> None: + if not path.is_file(): + raise BuildError(f"Required file not found: '{path.name}'") + + @staticmethod + def _print_error(message: str) -> None: + print(f"{ANSI_RED}ERROR:{ANSI_RESET} {message}", file=sys.stderr) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build KiCad outputs directly with kicad-cli.") + parser.add_argument( + "--project-file", + type=Path, + default=None, + help="Project file in the current directory. Defaults to the only *.kicad_pro file found.", + ) + parser.add_argument( + "--theme", + default=None, + help="Schematic PDF theme override. Defaults to SCHEMATIC_THEME or 'American Embedded Light'.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + + try: + runner = BuildRunner(args.project_file, theme_override=args.theme) + return runner.run() + except StepFailed as exc: + return exc.returncode + except BuildError as exc: + BuildRunner._print_error(str(exc)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) -- cgit v1.2.3