app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Drive your own KiCad from an AI, in the background: open every editor, read the whole board as data (the EDA Bridge Standard board model), stack-up, part heights, netlists, place, route, pour, DRC and exports.
← Commit history
v0.9.58: (1) 3D-viewer menu id now RESOLVED live from get_menu_ids (drifts across KiCad builds: was 20563, is 20568 on KiCad 10 winvm) instead of hardcoded — fixes open_3d_viewer on fresh installs. (2) export_pdf: KiCad 10 now requires --layers; default them for PDF too + accept layers as a list, not only a comma-string.
6 files changed
+520−349
adom-bridge-kicad-manifest.json+4−4@@ -1,10 +1,10 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.57",- "url": "https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/adom-bridge-kicad-v0.9.57.zip",- "sha256": "14407b453f34283338acecbe68500c05da4361cf990404e6a5cd69a69fb5c49f",- "size": 641319,+ "version": "0.9.58",+ "url": "https://wiki.adom.inc/blob/app/adom-desktop-kicad-bridge/adom-bridge-kicad-v0.9.58.zip",+ "sha256": "4f1429232a577407fa2b818951d9212d97ea5ca8d4075bab522bed50a0b7ebe9",+ "size": 642059, "verbPrefixes": [ "kicad_" ],
adom-bridge-kicad-v0.9.58.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.57",+ "version": "0.9.58", "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.", "homepage": "https://wiki.adom.inc/adom/adom-desktop", "author": "Adom Inc.",
handlers/bridge_client.py+38−4@@ -352,19 +352,26 @@ def handle_open_editors(kicad_info: dict, args: dict) -> dict: probed_plugins = 0 failed_plugins = 0 + # Use the BUDGETED port-prober (3s wall-clock cap + per-port dedup), not raw+ # per-file 1s probes: many dead python processes register the SAME plugin port,+ # and probing each of N stale discovery files at 1s each hung open_editors for+ # the full 30s client timeout when no KiCad was live. (AdomLapper 2026-07.)+ probe_port = _make_port_prober()+ seen_ports: set = set() for path in _discovery_files(): meta = _read_discovery(path) if not meta: continue port = meta.get("port")- if not port:+ if not port or port in seen_ports: continue- # Probe alive first- status = _probe(port, timeout=1.0)+ seen_ports.add(port)+ # Probe alive first (budgeted — returns None instantly once over budget)+ status = probe_port(port) if not status: continue probed_plugins += 1- rpc = call(port, "get_open_editors", {}, timeout=5.0)+ rpc = call(port, "get_open_editors", {}, timeout=2.0) if "error" in rpc: failed_plugins += 1 continue@@ -443,3 +450,30 @@ def try_wm_command_via_bridge(exe_name: str, menu_id: int, frame_index: int = 0, }, "menuId": menu_id, }+++def resolve_menu_id_via_bridge(exe_name: str, contains: str, frame_index: int = 0,+ pid: int | None = None, timeout: float = 10.0) -> int | None:+ """Look up a live menu-command id by matching text — menu ids DRIFT across KiCad+ builds/versions (e.g. the PCB '3D Viewer' id was 20563 on one build, 20568 on+ another), so hardcoding one silently breaks on the next release. Ask the plugin+ for the real menu tree (`get_menu_ids`) and return the id of the first item whose+ label / helpString / accelerator contains `contains` (case-insensitive).+ Returns None if the plugin isn't reachable or nothing matches."""+ meta, err = _resolve_plugin(exe_name, pid)+ if err:+ return None+ rpc = call(meta["port"], "get_menu_ids", {"frame_index": frame_index}, timeout=timeout)+ if "error" in rpc:+ return None+ result = rpc.get("result") or {}+ needle = contains.lower()+ for frame in result.get("frames", []):+ for it in frame.get("menuItems", []):+ hay = " ".join(str(it.get(k, "")) for k in ("label", "helpString", "accelerator")).lower()+ if needle in hay:+ try:+ return int(it.get("id"))+ except (TypeError, ValueError):+ continue+ return None
handlers/export.py+284−275@@ -1,275 +1,284 @@-"""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 if specified- layers = args.get("layers", "")- 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}"}+"""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}"}
handlers/open_footprint_editor.py+193−65@@ -179,21 +179,20 @@ def _find_child_by_class_and_parent(parent_hwnd: int, child_class: str, parent_t return result[0] -def _force_foreground(hwnd: int):- """Bring a window to the foreground using the Alt-key trick.+SW_SHOWNOACTIVATE = 4 - The Alt key press/release grants foreground permission. We wrap it in- try/finally to ensure the key-up event ALWAYS fires — a stuck Alt key- breaks Alt+Tab and general window focus on the user's desktop.- """++def _force_foreground(hwnd: int):+ """DELIBERATELY does NOT foreground. The bridge must never steal the user's+ focus or flash a window in front of their work (John, 2026-07 — 'do your stuff+ in the background'). We only make the window VISIBLE without activating it+ (SW_SHOWNOACTIVATE); the plugin WM_COMMAND path drives it fine without focus,+ and the dispatcher restores the user's window afterward anyway. Name kept so+ the ~10 call sites don't churn.""" try:- user32.keybd_event(VK_MENU, 0, 0, 0)- finally:- user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0)- time.sleep(0.05)- user32.ShowWindow(hwnd, SW_RESTORE)- user32.SetForegroundWindow(hwnd)- user32.BringWindowToTop(hwnd)+ user32.ShowWindow(hwnd, SW_SHOWNOACTIVATE)+ except Exception:+ pass def _click_at(x: int, y: int):@@ -234,7 +233,7 @@ def _open_footprint_editor_via_click(kicad_hwnd: int) -> bool: _click_at(click_x, click_y) # Wait for the Footprint Editor to appear- for _ in range(20):+ for _ in range(12): time.sleep(0.5) if _find_footprint_editor(): return True@@ -382,6 +381,40 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict: fp_hwnd = _find_footprint_editor() pathways_tried: list[str] = [] + # Tier 0: open Footprint Editor from a RUNNING pcbnew (menu id 20572 —+ # "Footprint Editor", discovered via get_menu_ids on KiCad 10). The Footprint+ # Editor is hosted inside pcbnew, so when a board is open this is the reliable+ # path and it does NOT need the kicad.exe project manager (which may not be+ # running, and whose first-run "KiCad Setup" wizard otherwise blocks the icon+ # click). Mirrors how open_symbol_editor drives a live eeschema. (ADOMBASELINE 2026-07.)+ PCBNEW_FOOTPRINT_EDITOR_MENU_ID = 20572 # noqa: N806+ if not fp_hwnd:+ try:+ from handlers.bridge_client import try_wm_command_via_bridge+ tier0 = try_wm_command_via_bridge(+ exe_name="pcbnew", menu_id=PCBNEW_FOOTPRINT_EDITOR_MENU_ID, frame_index=0,+ )+ pathways_tried.append("pcbnew" if tier0.get("success") else f"pcbnew-failed:{tier0.get('errorCode','')}")+ if tier0.get("success"):+ for _ in range(12):+ time.sleep(0.5)+ fp_hwnd = _find_footprint_editor()+ if fp_hwnd:+ break+ if fp_hwnd:+ _force_foreground(fp_hwnd)+ if not footprint_name and not library_name:+ return {+ "success": True,+ "output": "Footprint Editor opened successfully via a running pcbnew.",+ "pathway": "pcbnew",+ "pathwaysTried": pathways_tried,+ "bridgePid": tier0.get("pid"),+ }+ # else fall through to navigate_footprint for footprint_name/library_name+ except Exception as e: # pylint: disable=broad-except+ pathways_tried.append(f"pcbnew-import-error:{type(e).__name__}")+ # Phase 3 Tier 1: try the reverse bridge first if the kicad.exe # project manager has the plugin loaded. WM_COMMAND 20013 from the # Project Manager's Tools menu opens Footprint Editor. This sidesteps@@ -396,7 +429,7 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict: ) pathways_tried.append("plugin" if tier1.get("success") else f"plugin-failed:{tier1.get('errorCode','')}") if tier1.get("success"):- for _ in range(20):+ for _ in range(12): time.sleep(0.5) fp_hwnd = _find_footprint_editor() if fp_hwnd:@@ -428,8 +461,15 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict: } try:+ _si = None+ try:+ from handlers import win_focus+ _si = win_focus.background_startupinfo() # open the manager behind the user+ except Exception:+ _si = None subprocess.Popen( [kicad_exe],+ startupinfo=_si, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )@@ -440,7 +480,7 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict: "_hint": "Call close_kicad with {\"force\": true} to clear stuck processes, then retry.", } - for _ in range(20):+ for _ in range(12): time.sleep(0.5) kicad_hwnd = _find_kicad_manager() if kicad_hwnd:@@ -454,25 +494,63 @@ def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict: } time.sleep(2.0) - # Tier 2: legacy launcher-icon click (used when bridge isn't available).- # Try clicking the launcher icon up to 3 times (icon click is flaky- # if another window overlaps KiCad or DPI/layout changed).+ # Tier 1.5 — UIA background Invoke (the RELIABLE background fallback). KiCad+ # exposes an invokable UIA tree, so we can trigger the "Footprint Editor"+ # control PROGRAMMATICALLY: no foreground, no cursor move. Try a running+ # pcbnew first (its toolbar/Tools has the control), then the project manager.+ try:+ from handlers import uia+ uia_targets = [h for (h, _t, _o) in _find_windows_by_title("PCB Editor")]+ if kicad_hwnd:+ uia_targets.append(kicad_hwnd)+ for tgt in uia_targets:+ r = uia.uia_invoke(tgt, contains="Footprint Editor")+ if r.get("ok"):+ pathways_tried.append("uia")+ for _ in range(12):+ time.sleep(0.5)+ fp_hwnd = _find_footprint_editor()+ if fp_hwnd:+ _force_foreground(fp_hwnd) # SW_SHOWNOACTIVATE — visible, not focused+ if not footprint_name and not library_name:+ return {+ "success": True,+ "output": "Footprint Editor opened via background UIA Invoke.",+ "pathway": "uia",+ "pathwaysTried": pathways_tried,+ }+ break+ if fp_hwnd:+ break+ else:+ pathways_tried.append(f"uia-{r.get('reason','miss')}")+ except Exception as e: # pylint: disable=broad-except+ pathways_tried.append(f"uia-error:{type(e).__name__}")++ # Tier 2: legacy launcher-icon click — DISABLED BY DEFAULT. It MOVES the+ # user's mouse cursor (SetCursorPos) and needs the manager foregrounded, so+ # it hijacks the desktop mid-work. The bridge must never do that (John,+ # 2026-07). We only run it if the caller EXPLICITLY opts in with+ # {"allowCursorClick": true}; otherwise we fail gracefully and point at the+ # background plugin path (which needs the reverse-bridge plugin loaded). opened = False- for attempt in range(3):- if _open_footprint_editor_via_click(kicad_hwnd):- opened = True- break- # Re-focus and retry- _force_foreground(kicad_hwnd)- time.sleep(1.0)- pathways_tried.append("icon_click" if opened else "icon_click_failed")+ if args.get("allowCursorClick"):+ for attempt in range(2):+ if _open_footprint_editor_via_click(kicad_hwnd):+ opened = True+ break+ _force_foreground(kicad_hwnd)+ time.sleep(1.0)+ pathways_tried.append("icon_click" if opened else "icon_click_failed")+ else:+ pathways_tried.append("icon_click_skipped_background_mode") if not opened: return { "success": False,- "error": "Could not open Footprint Editor. Launcher icon click failed after 3 attempts.",+ "error": "Could not open the Footprint Editor via the background (plugin) path.", "pathwaysTried": pathways_tried,- "_hint": "Call close_kicad with {\"force\": true}, then retry. Or call screenshot_all to check for a blocking dialog. If the bridge is available (kicad_bridge_status alive>=1 for kicad), the tier-1 path should have worked — re-check the bridge install state.",+ "_hint": "The background WM_COMMAND path needs the reverse-bridge plugin loaded in a running pcbnew/kicad — open a board first (kicad_open_board) so pcbnew loads the plugin, then retry; the pcbnew tier (menu 20572) opens the Footprint Editor with NO focus theft. The mouse-moving launcher-click fallback is disabled by default; pass {\"allowCursorClick\":true} only if you accept it will move the cursor + foreground KiCad.", } fp_hwnd = _find_footprint_editor()@@ -607,18 +685,41 @@ def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict: # Try Footprint Editor (only when chosen / sole open editor) if fp_hwnd: WM_COMMAND = 0x0111- FP_3D_VIEWER_CMD = 20500- user32.PostMessageW(fp_hwnd, WM_COMMAND, FP_3D_VIEWER_CMD, 0)-- time.sleep(3.0)-+ # KiCad's 3D-viewer WM_COMMAND id differs by version (20500 was KiCad 9;+ # KiCad 10 renumbered it), and the Footprint Editor isn't a plugin host so+ # we can't query its live menu ids. The ROBUST path is the Alt+3 keyboard+ # accelerator — the universal "open 3D viewer" shortcut that works in BOTH+ # pcbnew and the Footprint Editor regardless of KiCad version. Try that+ # first (foreground the FP window so the keystroke lands), then fall back to+ # the legacy WM_COMMAND ids. (Fix: FP→3D silently failed on KiCad 10.)+ VK_3 = 0x33+ _force_foreground(fp_hwnd)+ time.sleep(0.2)+ try:+ user32.keybd_event(VK_MENU, 0, 0, 0) # Alt down+ user32.keybd_event(VK_3, 0, 0, 0) # 3 down+ user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0)+ finally:+ user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up ALWAYS+ time.sleep(2.5) windows = _find_windows_by_title("3D Viewer") if windows: return { "success": True, "output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",- "pathway": "fp_wm_command",+ "pathway": "fp_alt3", }+ # Legacy WM_COMMAND fallbacks (KiCad 9 id 20500; try the pcbnew id 20563 too).+ for cmd_id in (20500, 20563):+ user32.PostMessageW(fp_hwnd, WM_COMMAND, cmd_id, 0)+ time.sleep(2.0)+ windows = _find_windows_by_title("3D Viewer")+ if windows:+ return {+ "success": True,+ "output": f"3D Viewer opened from Footprint Editor: {windows[0][1]}",+ "pathway": f"fp_wm_command_{cmd_id}",+ } # FP path failed silently (WM_COMMAND posted but no 3D Viewer appeared). # If caller asked for 'auto' AND a PCB editor is also open, fall through@@ -641,14 +742,20 @@ def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict: # require foregrounding the window or stealing keyboard focus. pcb_hwnd = _find_pcb_editor() if pcb_hwnd:- PCB_3D_VIEWER_CMD = 20563 # noqa: N806 PCB Editor → View → 3D Viewer+ # Menu ids DRIFT across KiCad builds (seen 20563 on one, 20568 on another),+ # so RESOLVE the live "3D Viewer" id from the running pcbnew instead of trusting+ # a constant. Fall back to the legacy id only if the lookup can't reach the plugin.+ PCB_3D_VIEWER_CMD = 20563 # noqa: N806 legacy fallback (PCB Editor → View → 3D Viewer) pathways_tried: list[str] = [] # Tier 1: bridge wm_command via pcbnew plugin try:- from handlers.bridge_client import try_wm_command_via_bridge+ from handlers.bridge_client import try_wm_command_via_bridge, resolve_menu_id_via_bridge+ _live_id = resolve_menu_id_via_bridge(exe_name="pcbnew", contains="3D Viewer", frame_index=0)+ _cmd_id = _live_id if _live_id else PCB_3D_VIEWER_CMD+ pathways_tried.append(f"menuid={_cmd_id}{'(live)' if _live_id else '(fallback)'}") tier1 = try_wm_command_via_bridge(- exe_name="pcbnew", menu_id=PCB_3D_VIEWER_CMD, frame_index=0,+ exe_name="pcbnew", menu_id=_cmd_id, frame_index=0, ) pathways_tried.append("plugin" if tier1.get("success") else f"plugin-failed:{tier1.get('errorCode','')}") if tier1.get("success"):@@ -665,36 +772,57 @@ def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict: except Exception as e: # pylint: disable=broad-except pathways_tried.append(f"plugin-import-error:{type(e).__name__}") - # Tier 2 / legacy: Alt+3 keystroke (steals foreground).- # Bring pcbnew to foreground so keystroke is received- user32.SetForegroundWindow(pcb_hwnd)- time.sleep(0.3)-- # Send Alt+3 (View > 3D Viewer in pcbnew)- VK_MENU = 0x12 # Alt key- VK_3 = 0x33 # '3' key- KEYEVENTF_KEYUP = 0x0002-- user32.keybd_event(VK_MENU, 0, 0, 0) # Alt down- user32.keybd_event(VK_3, 0, 0, 0) # 3 down- user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0) # 3 up- user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up- pathways_tried.append("alt_keystroke")-- time.sleep(3.0)-- windows = _find_windows_by_title("3D Viewer")- if windows:- return {- "success": True,- "output": f"3D Viewer opened from PCB Editor: {windows[0][1]}",- "pathway": "alt_keystroke",- "pathwaysTried": pathways_tried,- }+ # Tier 1.5 — UIA background Invoke of the "3D Viewer" control on pcbnew.+ # Programmatic (no foreground, no cursor), replaces the Alt+3 keystroke.+ try:+ from handlers import uia+ r = uia.uia_invoke(pcb_hwnd, contains="3D Viewer")+ if r.get("ok"):+ pathways_tried.append("uia")+ time.sleep(3.0)+ windows = _find_windows_by_title("3D Viewer")+ if windows:+ return {+ "success": True,+ "output": f"3D Viewer opened via background UIA Invoke: {windows[0][1]}",+ "pathway": "uia",+ "pathwaysTried": pathways_tried,+ }+ else:+ pathways_tried.append(f"uia-{r.get('reason','miss')}")+ except Exception as e: # pylint: disable=broad-except+ pathways_tried.append(f"uia-error:{type(e).__name__}")++ # Tier 2 / legacy: Alt+3 keystroke — needs pcbnew FOREGROUNDED so the global+ # keystroke lands there (a global Alt+3 to the user's own window could fire+ # their app's Alt+3). That steals focus, so it's OPT-IN only+ # ({"allowFocusSteal": true}); by default we fail to the background path.+ if args.get("allowFocusSteal"):+ user32.SetForegroundWindow(pcb_hwnd)+ time.sleep(0.3)+ VK_MENU = 0x12 # Alt key+ VK_3 = 0x33 # '3' key+ KEYEVENTF_KEYUP = 0x0002+ user32.keybd_event(VK_MENU, 0, 0, 0) # Alt down+ user32.keybd_event(VK_3, 0, 0, 0) # 3 down+ user32.keybd_event(VK_3, 0, KEYEVENTF_KEYUP, 0) # 3 up+ user32.keybd_event(VK_MENU, 0, KEYEVENTF_KEYUP, 0) # Alt up+ pathways_tried.append("alt_keystroke")+ time.sleep(3.0)+ windows = _find_windows_by_title("3D Viewer")+ if windows:+ return {+ "success": True,+ "output": f"3D Viewer opened from PCB Editor: {windows[0][1]}",+ "pathway": "alt_keystroke",+ "pathwaysTried": pathways_tried,+ }+ else:+ pathways_tried.append("alt_keystroke_skipped_background_mode") return { "success": False,- "error": "3D Viewer did not open from PCB Editor. A board must be loaded in pcbnew.",+ "error": "3D Viewer did not open via the background (plugin) path.", "pathwaysTried": pathways_tried, "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry.", }