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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
"""Handlers for KiCad CLI export commands (Gerber, PDF, SVG, STEP, BOM CSV)."""
import os
import subprocess
import tempfile
from pathlib import Path
def handle_export_gerber(kicad_info: dict, args: dict) -> dict:
"""Export Gerber files from a PCB using kicad-cli."""
return _run_pcb_export(kicad_info, args, "gerbers", "Gerber")
def handle_export_pdf(kicad_info: dict, args: dict) -> dict:
"""Export PDF from a PCB or schematic using kicad-cli."""
file_path = args.get("filePath", "")
if not file_path:
return {"success": False, "error": "No filePath specified"}
if file_path.endswith(".kicad_sch"):
return _run_sch_export(kicad_info, args, "pdf", "PDF")
else:
return _run_pcb_export(kicad_info, args, "pdf", "PDF")
def handle_export_svg(kicad_info: dict, args: dict) -> dict:
"""Export SVG from a PCB using kicad-cli."""
return _run_pcb_export(kicad_info, args, "svg", "SVG")
def handle_export_step(kicad_info: dict, args: dict) -> dict:
"""Export 3D STEP file from a PCB using kicad-cli."""
return _run_pcb_export(kicad_info, args, "step", "STEP")
def handle_export_bom_csv(kicad_info: dict, args: dict) -> dict:
"""Export BOM CSV from a schematic using kicad-cli."""
return _run_sch_export(kicad_info, args, "bom", "BOM CSV")
def handle_run_erc(kicad_info: dict, args: dict) -> dict:
"""Run ERC (Electrical Rules Check) on a schematic using kicad-cli."""
if not kicad_info.get("installed"):
return {
"success": False,
"error": "KiCad not installed",
"_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",
}
file_path = args.get("filePath", "")
if not file_path:
return {"success": False, "error": "No filePath specified"}
sch_path = Path(file_path)
if not sch_path.exists():
return {
"success": False,
"error": f"Schematic file not found: {file_path}",
"_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",
}
cli_exe = kicad_info.get("kicad_cli_exe")
if not cli_exe or not Path(cli_exe).exists():
return {
"success": False,
"error": "kicad-cli not found",
"_hint": "Report to the user and ask them to reinstall KiCad — kicad-cli is missing from the installation.",
}
tmp_fd, output_path = tempfile.mkstemp(suffix=".json")
os.close(tmp_fd)
try:
cmd = [
cli_exe,
"sch", "erc",
"--format", "json",
"--severity-all",
"--output", output_path,
str(sch_path),
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
output_file = Path(output_path)
if not output_file.exists() or output_file.stat().st_size == 0:
return {"success": False, "error": f"ERC produced no output. stderr: {result.stderr}"}
import json
erc_data = json.loads(output_file.read_text(encoding="utf-8"))
violations = _parse_erc_violations(erc_data)
total = len(violations)
errors = sum(1 for v in violations if v["severity"] == "error")
warnings = sum(1 for v in violations if v["severity"] == "warning")
return {
"success": True,
"output": f"ERC complete: {errors} error(s), {warnings} warning(s), {total} total",
"data": {
"violations": violations,
"summary": {"total": total, "errors": errors, "warnings": warnings},
"source_file": str(sch_path),
},
}
except subprocess.TimeoutExpired:
return {"success": False, "error": "ERC timed out after 120 seconds"}
except Exception as e:
return {"success": False, "error": f"ERC failed: {e}"}
finally:
try:
Path(output_path).unlink(missing_ok=True)
except OSError:
pass
def _parse_erc_violations(erc_data: dict) -> list:
"""Extract structured violation list from ERC JSON output."""
violations = []
for v in erc_data.get("violations", []):
violation = {
"type": v.get("type", "unknown"),
"severity": v.get("severity", "warning"),
"description": v.get("description", ""),
"items": [],
}
for item in v.get("items", []):
violation["items"].append({
"description": item.get("description", ""),
"pos": item.get("pos", {}),
})
violations.append(violation)
return violations
def _run_pcb_export(kicad_info: dict, args: dict, export_type: str, label: str) -> dict:
"""Generic PCB export via kicad-cli pcb export <type>."""
if not kicad_info.get("installed"):
return {
"success": False,
"error": "KiCad not installed",
"_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",
}
file_path = args.get("filePath", "")
if not file_path:
return {"success": False, "error": "No filePath specified"}
pcb_path = Path(file_path)
if not pcb_path.exists():
return {
"success": False,
"error": f"PCB file not found: {file_path}",
"_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",
}
cli_exe = kicad_info.get("kicad_cli_exe")
if not cli_exe or not Path(cli_exe).exists():
return {
"success": False,
"error": "kicad-cli not found",
"_hint": "Report to the user and ask them to reinstall KiCad — kicad-cli is missing from the installation.",
}
# Determine output path
output_dir = args.get("outputDir", "")
if not output_dir:
output_dir = str(pcb_path.parent / f"{export_type}_output")
# For gerbers, output is a directory; for others, it's a file
if export_type == "gerbers":
os.makedirs(output_dir, exist_ok=True)
output_arg = output_dir
elif export_type == "step":
os.makedirs(output_dir, exist_ok=True)
output_arg = os.path.join(output_dir, pcb_path.stem + ".step")
else:
os.makedirs(output_dir, exist_ok=True)
ext = ".pdf" if export_type == "pdf" else ".svg"
output_arg = os.path.join(output_dir, pcb_path.stem + ext)
try:
cmd = [cli_exe, "pcb", "export", export_type, "--output", output_arg, str(pcb_path)]
# Add layers for SVG/PDF. kicad-cli `pcb export svg` AND (on KiCad 10)
# `pcb export pdf` both REQUIRE --layers (they error "At least one layer must
# be specified" with none), so when the caller didn't specify any we default
# to a useful full-board set for BOTH. Accept `layers` as either a list
# (["F.Cu","B.Cu"]) or a comma string ("F.Cu,B.Cu") — a list used to blow up
# with "expected str … not list" when handed straight to cmd.extend. (winvm 2026-07.)
layers = args.get("layers", "")
if isinstance(layers, (list, tuple)):
layers = ",".join(str(x) for x in layers)
if not layers and export_type in ("svg", "pdf"):
layers = "F.Cu,B.Cu,F.Silkscreen,B.Silkscreen,F.Mask,B.Mask,Edge.Cuts"
if layers and export_type in ("svg", "pdf"):
cmd.extend(["--layers", layers])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
return {"success": False, "error": f"{label} export failed: {result.stderr}"}
# List output files
if export_type == "gerbers":
files = [f.name for f in Path(output_dir).iterdir() if f.is_file()]
else:
files = [Path(output_arg).name] if Path(output_arg).exists() else []
return {
"success": True,
"output": f"{label} exported to {output_arg} ({len(files)} file(s))",
"data": {"output_path": output_arg, "files": files},
}
except subprocess.TimeoutExpired:
return {"success": False, "error": f"{label} export timed out after 120 seconds"}
except Exception as e:
return {"success": False, "error": f"{label} export failed: {e}"}
def _run_sch_export(kicad_info: dict, args: dict, export_type: str, label: str) -> dict:
"""Generic schematic export via kicad-cli sch export <type>."""
if not kicad_info.get("installed"):
return {
"success": False,
"error": "KiCad not installed",
"_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",
}
file_path = args.get("filePath", "")
if not file_path:
return {"success": False, "error": "No filePath specified"}
sch_path = Path(file_path)
if not sch_path.exists():
return {
"success": False,
"error": f"Schematic file not found: {file_path}",
"_hint": "The path may exist on Docker but not on the Windows host. Use push_file to copy from Docker first, or verify the host-side path.",
}
cli_exe = kicad_info.get("kicad_cli_exe")
if not cli_exe or not Path(cli_exe).exists():
return {
"success": False,
"error": "kicad-cli not found",
"_hint": "Report to the user and ask them to reinstall KiCad — kicad-cli is missing from the installation.",
}
output_dir = args.get("outputDir", "")
if not output_dir:
output_dir = str(sch_path.parent / f"{export_type}_output")
os.makedirs(output_dir, exist_ok=True)
ext_map = {"pdf": ".pdf", "bom": ".csv"}
ext = ext_map.get(export_type, f".{export_type}")
output_arg = os.path.join(output_dir, sch_path.stem + ext)
try:
cmd = [cli_exe, "sch", "export", export_type, "--output", output_arg, str(sch_path)]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
if result.returncode != 0:
return {"success": False, "error": f"{label} export failed: {result.stderr}"}
exists = Path(output_arg).exists()
return {
"success": True,
"output": f"{label} exported to {output_arg}",
"data": {
"output_path": output_arg,
"files": [Path(output_arg).name] if exists else [],
},
}
except subprocess.TimeoutExpired:
return {"success": False, "error": f"{label} export timed out after 120 seconds"}
except Exception as e:
return {"success": False, "error": f"{label} export failed: {e}"}