app
Adom Desktop - KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge — multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
#!/usr/bin/env python3
"""KiCad tour runner — executes the 8-step tour via `adom-desktop kicad_*`
CLI calls and writes each step's screenshot to an output dir.
Usage:
python tour_runner.py --pack tour-pack --output /tmp/tour
python tour_runner.py --pack tour-pack-rp2040 --output /tmp/rp2040-tour
The script is deliberately a thin orchestrator: every step is a single
CLI invocation, and every wait is a fixed `sleep`. Caller can read the
script as the canonical sequence of bridge calls; the same calls work
from a `bash` tour or any other orchestrator.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
# Per-pack config — each pack has its own symbol/footprint/template names
PACK_CONFIG = {
"tour-pack": {
"library": "AdomTour",
"symbol": "R_AdomTour",
"footprint": "R_0805_AdomTour",
"sch_filename": "tour-demo.kicad_sch",
"pcb_filename": "tour-demo.kicad_pcb",
},
"tour-pack-rp2040": {
"library": "AdomRP2040",
"symbol": "RP2040_AdomTour",
"footprint": "QFN-56_AdomRP2040",
"sch_filename": "rp2040-breakout.kicad_sch",
"pcb_filename": "rp2040-breakout.kicad_pcb",
},
}
def adom(verb: str, args: dict | None = None) -> dict[str, Any]:
"""Run `adom-desktop <verb> '<json>'` and return the parsed response."""
cmd = ["adom-desktop", verb]
if args is not None:
cmd.append(json.dumps(args))
print(f" $ adom-desktop {verb} {json.dumps(args) if args else ''}")
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
if proc.returncode != 0 and not proc.stdout.strip():
print(f" ! exit {proc.returncode}: {proc.stderr.strip()[:200]}")
try:
return json.loads(proc.stdout)
except json.JSONDecodeError:
return {"success": False, "error": f"non-JSON stdout: {proc.stdout[:300]}"}
def pull_screenshot(hwnd: int, dest: Path, label: str) -> bool:
"""Capture a window via desktop_screenshot_window + adom-desktop pull_file."""
resp = adom("desktop_screenshot_window", {"hwnd": hwnd})
full = resp.get("fullPathHost") or (resp.get("data") or {}).get("fullPathHost")
if not full:
print(f" ! {label}: no fullPathHost in response")
return False
# Try the safe (downscaled) variant if the full one is unwieldy
safe = resp.get("safePathHost") or (resp.get("data") or {}).get("safePathHost")
src = safe or full
pull = adom("pull_file", {"filePaths": [src], "saveTo": str(dest.parent)})
# adom-desktop's pull_file lands files in saveTo with their basename. Rename.
landed = dest.parent / Path(src).name
if landed.exists():
landed.rename(dest)
print(f" ✓ {label} → {dest.name}")
return True
# Fall back to copying via WSL bash if pull_file didn't land (relay vs direct)
if Path(src.replace("C:", "/c").replace("\\", "/")).exists():
shutil.copy(src.replace("C:", "/c").replace("\\", "/"), dest)
print(f" ✓ {label} → {dest.name} (fallback copy)")
return True
print(f" ! {label}: pull_file did not land the file ({src})")
return False
def find_window(title_substring: str) -> int | None:
"""Find a KiCad window whose title contains `title_substring`."""
resp = adom("kicad_window_info")
editors = (resp.get("data") or {}).get("editors") or []
for w in editors:
if title_substring in (w.get("title") or ""):
return int(w["hwnd"])
return None
def main() -> int:
parser = argparse.ArgumentParser(description="KiCad tour runner.")
parser.add_argument("--pack", required=True, choices=list(PACK_CONFIG.keys()))
parser.add_argument(
"--pack-dir",
default=None,
help="Override the pack root path. Default uses the bundled location.",
)
parser.add_argument("--output", required=True, help="Output dir for screenshots.")
parser.add_argument(
"--no-cleanup",
action="store_true",
help="Don't call kicad_close at the end.",
)
parser.add_argument(
"--skip-fp-3d",
action="store_true",
help="Skip step 4 (FP-editor 3D viewer) — KiCad 10 doesn't always respond to the WM_COMMAND.",
)
args = parser.parse_args()
cfg = PACK_CONFIG[args.pack]
pack_dir = Path(args.pack_dir) if args.pack_dir else Path(
f"C:/Github/adom-desktop/plugins/kicad/{args.pack}"
)
out_dir = Path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
sym_lib = pack_dir / f"{cfg['library']}.kicad_sym"
fp_lib = pack_dir / f"{cfg['library']}.pretty"
sch_path = pack_dir / "template" / cfg["sch_filename"]
pcb_path = pack_dir / "template" / cfg["pcb_filename"]
print(f"\n=== KiCad Tour: {args.pack} ===")
print(f" library : {cfg['library']}")
print(f" symbol : {cfg['symbol']}")
print(f" footprint: {cfg['footprint']}")
print(f" output : {out_dir}\n")
fails: list[str] = []
def step(n: int, name: str) -> None:
print(f"\n--- Step {n}: {name} ---")
# Step 1a — install symbol library
step(1, "install symbol library")
resp = adom("kicad_install_library", {
"libraryPath": str(sym_lib).replace("\\", "/"),
"libraryType": "symbol",
"libraryName": cfg["library"],
})
if not resp.get("success"):
fails.append(f"1a: {resp.get('error')}")
# Step 1b — install footprint library
step(1, "install footprint library")
resp = adom("kicad_install_library", {
"libraryPath": str(fp_lib).replace("\\", "/"),
"libraryType": "footprint",
"libraryName": cfg["library"],
})
if not resp.get("success"):
fails.append(f"1b: {resp.get('error')}")
# Step 2 — open Symbol Editor
step(2, f"open Symbol Editor on {cfg['symbol']}")
resp = adom("kicad_open_symbol_editor", {
"libraryName": cfg["library"],
"symbolName": cfg["symbol"],
})
if not resp.get("success"):
fails.append(f"2: {resp.get('error')}")
time.sleep(6)
sym_hwnd = find_window("Symbol Editor")
if sym_hwnd:
pull_screenshot(sym_hwnd, out_dir / "02-symbol-editor.png", "Symbol Editor")
else:
fails.append("2: Symbol Editor window not found")
# Step 3 — open Footprint Editor
step(3, f"open Footprint Editor on {cfg['footprint']}")
resp = adom("kicad_open_footprint_editor", {
"libraryName": cfg["library"],
"footprintName": cfg["footprint"],
})
if not resp.get("success"):
fails.append(f"3: {resp.get('error')}")
time.sleep(6)
fp_hwnd = find_window("Footprint Editor")
if fp_hwnd:
pull_screenshot(fp_hwnd, out_dir / "03-footprint-editor.png", "Footprint Editor")
else:
fails.append("3: Footprint Editor window not found")
# Step 4 — show 3D viewer for footprint (optional)
if not args.skip_fp_3d:
step(4, "FP-editor 3D viewer (Alt+3)")
resp = adom("kicad_open_3d_viewer", {"editor": "fp"})
if resp.get("success"):
time.sleep(4)
v_hwnd = find_window("3D Viewer")
if v_hwnd:
pull_screenshot(v_hwnd, out_dir / "04-footprint-3d.png", "FP 3D Viewer")
adom("kicad_close_3d_viewer")
time.sleep(2)
else:
print(" ⚠ FP 3D viewer reported success but window not found.")
else:
print(f" ⚠ FP 3D viewer failed (known KiCad 10 issue): {resp.get('error')}")
# Close FP editor so step 7's 3D viewer routes to PCB
adom("kicad_close_footprint_editor")
time.sleep(2)
# Step 5 — open schematic
step(5, "open schematic")
resp = adom("kicad_open_schematic", {"filePath": str(sch_path).replace("\\", "/")})
if not resp.get("success"):
fails.append(f"5: {resp.get('error')}")
time.sleep(8)
sch_hwnd = find_window("Schematic Editor")
if sch_hwnd:
pull_screenshot(sch_hwnd, out_dir / "05-schematic.png", "Schematic Editor")
else:
fails.append("5: Schematic Editor window not found")
# Step 6 — open board
step(6, "open board")
resp = adom("kicad_open_board", {"filePath": str(pcb_path).replace("\\", "/")})
if not resp.get("success"):
fails.append(f"6: {resp.get('error')}")
time.sleep(8)
pcb_hwnd = find_window("PCB Editor")
if pcb_hwnd:
pull_screenshot(pcb_hwnd, out_dir / "06-board.png", "PCB Editor")
else:
fails.append("6: PCB Editor window not found")
# Step 7 — show 3D viewer for board (always works; uses editor='pcb' to
# skip the FP path)
step(7, "PCB 3D viewer")
resp = adom("kicad_open_3d_viewer", {"editor": "pcb"})
if not resp.get("success"):
fails.append(f"7: {resp.get('error')}")
time.sleep(6)
v_hwnd = find_window("3D Viewer")
if v_hwnd:
pull_screenshot(v_hwnd, out_dir / "07-board-3d.png", "Board 3D Viewer")
else:
fails.append("7: 3D Viewer window not found")
# Step 8 — export gerbers
step(8, "export gerbers")
gerber_dir = str(out_dir / "gerbers").replace("\\", "/")
resp = adom("kicad_export_gerbers", {
"filePath": str(pcb_path).replace("\\", "/"),
"outputDir": gerber_dir,
})
if not resp.get("success"):
fails.append(f"8: {resp.get('error')}")
files = (resp.get("data") or {}).get("files") or []
print(f" ✓ wrote {len(files)} gerber files to {gerber_dir}")
# Cleanup
if not args.no_cleanup:
print("\n--- Cleanup: close KiCad ---")
adom("kicad_close")
print(f"\n=== Done — {len(fails)} failures ===")
for f in fails:
print(f" • {f}")
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())