app
KiCad - the 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.
← Commit history
v0.9.0: fix /status health endpoint (bridgeRunning root cause) + capability/version-awareness report
25 files changed
+10726
handlers/__init__.pyhandlers/bom.py+100handlers/bridge_client.py+418handlers/close_windows.py+672handlers/export.py+275handlers/fix_keyboard.py+109handlers/install_footprint.py+152handlers/install_library.py+269handlers/install_symbol.py+118handlers/kicad_cli_lint.py+843handlers/kicad_ui.py+674handlers/library_browser.py+451handlers/netlist.py+184handlers/open_files.py+75handlers/open_footprint_editor.py+706handlers/open_symbol_editor.py+439handlers/run_drc.py+136parsers/__init__.py+1parsers/pcb.py+528parsers/schematic.py+459parsers/sexpr.py+165plugin_payload/adom_bridge.py+3006plugin_payload/usercustomize.py+88tour-pack-rp2040/AdomRP2040.pretty/ADOM_MEDIUM_PIN_SHORTER_v1.kicad_mod+149tour-pack-rp2040/AdomRP2040.pretty/QFN-56_AdomRP2040.kicad_mod+709handlers/__init__.pyaddedhandlers/bom.pyadded+100@@ -0,0 +1,100 @@+"""Handler for BOM (Bill of Materials) generation from schematics."""++import sys+from pathlib import Path+++def handle_generate_bom(kicad_info: dict, args: dict) -> dict:+ """Generate a BOM from a schematic file.++ Groups components by value + footprint, counts quantities,+ and returns a structured BOM.+ """+ sys.path.insert(0, str(Path(__file__).parent.parent))+ from parsers.schematic import parse_schematic++ 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 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.",+ }++ try:+ data = parse_schematic(sch_path)+ except Exception as e:+ return {"success": False, "error": f"Failed to parse schematic: {e}"}++ # Filter: only in_bom, non-power components+ power_uuids = {ps["uuid"] for ps in data["power_symbols"]}+ components = [+ c for c in data["components"]+ if c["in_bom"] and c["uuid"] not in power_uuids and not c["dnp"]+ ]++ # Group by value + footprint+ groups = {}+ for comp in components:+ key = (comp["value"], comp["footprint"])+ if key not in groups:+ groups[key] = {+ "value": comp["value"],+ "footprint": comp["footprint"],+ "references": [],+ "lib_id": comp["lib_id"],+ "description": comp["properties"].get("Description", ""),+ "manufacturer": comp["properties"].get("Manufacturer", ""),+ "mpn": comp["properties"].get("MPN", comp["properties"].get("Manufacturer_Part_Number", "")),+ "datasheet": comp["properties"].get("Datasheet", ""),+ }+ groups[key]["references"].append(comp["reference"])++ # Sort references naturally within each group+ bom = []+ for group in groups.values():+ group["references"].sort(key=_natural_sort_key)+ group["quantity"] = len(group["references"])+ group["reference_list"] = ", ".join(group["references"])+ bom.append(group)++ # Sort BOM by reference prefix then number+ bom.sort(key=lambda g: _natural_sort_key(g["references"][0] if g["references"] else ""))++ # DNP items (reported separately)+ dnp_items = [+ {"reference": c["reference"], "value": c["value"], "footprint": c["footprint"]}+ for c in data["components"]+ if c["dnp"] and c["uuid"] not in power_uuids+ ]++ total_unique = len(bom)+ total_parts = sum(g["quantity"] for g in bom)++ return {+ "success": True,+ "output": f"BOM: {total_unique} unique part(s), {total_parts} total",+ "data": {+ "bom": bom,+ "summary": {+ "unique_parts": total_unique,+ "total_parts": total_parts,+ "dnp_count": len(dnp_items),+ },+ "dnp_items": dnp_items,+ },+ }+++def _natural_sort_key(s: str):+ """Sort key that handles mixed alpha-numeric strings naturally.++ E.g., R1, R2, R10 sort correctly instead of R1, R10, R2.+ """+ import re+ parts = re.split(r'(\d+)', s)+ return [int(p) if p.isdigit() else p.lower() for p in parts]
handlers/bridge_client.pyadded+418@@ -0,0 +1,418 @@+"""bridge_client.py - talk to the adom_bridge.py plugin running inside+each KiCad GUI process.++The Phase 2 reverse bridge (see PLAN.md and `adom_bridge.py` deployed+at KiCad's USER_SITE / `usercustomize.py`) exposes a small HTTP/JSON+RPC endpoint inside every running KiCad GUI exe. This module gives the+kicad-bridge HTTP server (this server.py) a thin client for those+endpoints, plus a `bridge_status` handler that enumerates which+processes have an active plugin connection.++Discovery+=========+Each running KiCad process writes a JSON file at+%TEMP%/adom-kicad-bridge-<exeName>.json+with `{port, pid, version, exeName, endpoint, ...}`. We enumerate+those files, then probe each one's /status to verify it's still live+(the file might be stale from a killed KiCad).+"""++from __future__ import annotations++import json+import os+import time+import urllib.error+import urllib.request+from pathlib import Path+from typing import Any, Optional++_TEMP = Path(os.environ.get("TEMP") or os.environ.get("TMP") or "/tmp")+# v1.7.6+: discovery files are per-PID. Match BOTH the new per-PID+# pattern AND the v1.7.5 exe-only pattern so the bridge keeps working+# during version skew (user on v1.7.5 desktop binary + v1.7.6 plugin+# payload, or vice versa).+_DISCOVERY_PATTERN = "adom-kicad-bridge-*.json"+++def _discovery_files() -> list[Path]:+ return sorted(_TEMP.glob(_DISCOVERY_PATTERN))+++def _parse_discovery_name(path: Path) -> tuple[str, int | None]:+ """Parse exeName and optional pid from a discovery filename.++ Per-PID format (v1.7.6+): `adom-kicad-bridge-<exe>-<pid>.json`+ Legacy format (v1.7.5): `adom-kicad-bridge-<exe>.json`++ Returns (exeName, pid_or_None). exeName is "" if parse fails.+ """+ stem = path.stem # e.g. "adom-kicad-bridge-eeschema-12345"+ prefix = "adom-kicad-bridge-"+ if not stem.startswith(prefix):+ return ("", None)+ remainder = stem[len(prefix):]+ if not remainder:+ return ("", None)+ parts = remainder.rsplit("-", 1)+ if len(parts) == 2 and parts[1].isdigit():+ return (parts[0], int(parts[1]))+ return (remainder, None)+++def _files_for_exe(exe_name: str) -> list[Path]:+ """All discovery files matching this exe (per-PID variants + legacy)."""+ out = []+ for p in _discovery_files():+ ex, _pid = _parse_discovery_name(p)+ if ex == exe_name:+ out.append(p)+ return out+++def _read_discovery(path: Path) -> Optional[dict]:+ try:+ return json.loads(path.read_text(encoding="utf-8"))+ except (OSError, json.JSONDecodeError):+ return None+++def _probe(port: int, timeout: float = 2.0) -> Optional[dict]:+ """GET http://127.0.0.1:<port>/status. Returns None if unreachable."""+ try:+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/status", timeout=timeout) as resp:+ return json.loads(resp.read().decode("utf-8"))+ except (urllib.error.URLError, OSError, json.JSONDecodeError):+ return None+++def call(port: int, method: str, params: Optional[dict] = None, timeout: float = 10.0) -> dict:+ """Make a POST /rpc call. Returns the parsed JSON-RPC response."""+ body = json.dumps({+ "id": f"{method}-{time.time_ns()}",+ "method": method,+ "params": params or {},+ }).encode("utf-8")+ req = urllib.request.Request(+ f"http://127.0.0.1:{port}/rpc",+ data=body,+ headers={"Content-Type": "application/json"},+ method="POST",+ )+ try:+ with urllib.request.urlopen(req, timeout=timeout) as resp:+ return json.loads(resp.read().decode("utf-8"))+ except urllib.error.HTTPError as e:+ # Server returned an HTTP error code; body still has JSON-RPC error+ try:+ return json.loads(e.read().decode("utf-8"))+ except (OSError, json.JSONDecodeError, AttributeError):+ return {"id": None, "error": {"code": -32000, "message": f"http {e.code}: {e.reason}"}}+ except urllib.error.URLError as e:+ return {"id": None, "error": {"code": -32002, "message": f"unreachable: {e.reason}"}}+ except (OSError, json.JSONDecodeError) as e:+ return {"id": None, "error": {"code": -32000, "message": f"transport error: {e}"}}+++def handle_bridge_status(kicad_info: dict, args: dict) -> dict:+ """Enumerate all adom-kicad-bridge discovery files; probe each.++ v1.7.6+: handles per-PID discovery files; multiple instances of the+ same exe each get their own row. Optionally filter to a specific+ exeName or pid via args.++ Args (all optional):+ exeName: filter to one exe (kicad / pcbnew / eeschema / kicad-cli)+ pid: filter to a specific PID+ pruneStale: if true, delete discovery files for plugins that don't+ respond to /status (defaults false; left for explicit+ cleanup runs to avoid racing on a freshly-started bridge)+ """+ filter_exe = args.get("exeName")+ filter_pid = args.get("pid")+ prune_stale = bool(args.get("pruneStale"))++ plugins = []+ for path in _discovery_files():+ meta = _read_discovery(path)+ if not meta:+ plugins.append({+ "discoveryFile": str(path),+ "alive": False,+ "_error": "could not parse discovery JSON",+ })+ continue+ exe = meta.get("exeName")+ pid = meta.get("pid")+ if filter_exe and exe != filter_exe:+ continue+ if filter_pid is not None and pid != filter_pid:+ continue+ entry = {+ "exeName": exe,+ "pid": pid,+ "port": meta.get("port"),+ "version": meta.get("version"),+ "endpoint": meta.get("endpoint"),+ "startedAt": meta.get("startedAt"),+ "discoveryFile": str(path),+ "alive": False,+ }+ status = _probe(meta.get("port", 0)) if meta.get("port") else None+ if status:+ entry["alive"] = True+ entry["uptimeMs"] = status.get("uptimeMs")+ entry["requestCount"] = status.get("requestCount")+ elif prune_stale:+ try:+ path.unlink()+ entry["pruned"] = True+ except OSError:+ pass+ plugins.append(entry)++ alive = sum(1 for p in plugins if p.get("alive"))+ return {+ "success": True,+ "plugins": plugins,+ "summary": {"total": len(plugins), "alive": alive},+ "_hint": (+ "If alive=0 but you expect a running KiCad, the adom_bridge.py plugin "+ "isn't deployed at its USER_SITE location. Call kicad_install_plugin "+ "to force a fresh deploy (auto-install runs once per bridge boot; "+ "the explicit verb bypasses the cache). Otherwise the file is stale "+ "— pass pruneStale:true to clean those up. v1.7.6+ supports multiple "+ "instances per exe (one row per PID)."+ ),+ }+++def _resolve_plugin(exe_name: str, pid: int | None = None) -> tuple[dict | None, dict | None]:+ """Resolve which discovery file to use for an (exeName, optional pid)+ pair.++ Returns (meta_dict, error_dict). On success: (meta, None). On failure:+ (None, error_dict) where error_dict is ready to return to the caller.++ Picks ALIVE plugins first; if multiple alive instances of the same exe,+ picks the newest one (most recent startedAt).+ """+ paths = _files_for_exe(exe_name)+ if not paths:+ return None, {+ "success": False,+ "error": f"no live plugin for exe '{exe_name}'",+ "errorCode": "plugin_not_running",+ "_hint": (+ f"Either no instance of {exe_name}.exe is running, or it doesn't have "+ "the adom_bridge plugin deployed (Phase 2 not installed for this KiCad). "+ "Run kicad_bridge_status to see what plugins are active. If you expected "+ "the plugin to be there, call kicad_install_plugin to force-deploy + "+ "then ask the user to restart KiCad."+ ),+ }+ candidates: list[dict] = []+ for path in paths:+ meta = _read_discovery(path)+ if not meta:+ continue+ if pid is not None and meta.get("pid") != pid:+ continue+ candidates.append(meta)+ if not candidates:+ return None, {+ "success": False,+ "error": f"no plugin for exe '{exe_name}' pid={pid}",+ "errorCode": "plugin_not_running",+ "_hint": (+ f"Found {len(paths)} discovery file(s) for {exe_name}, but none matched "+ f"pid={pid}. Drop the pid arg to talk to any instance, or call "+ "kicad_bridge_status to see live PIDs."+ ),+ }+ # Probe to find ALIVE ones; prefer the most recently started alive instance.+ alive = []+ for meta in candidates:+ port = meta.get("port")+ if not port:+ continue+ status = _probe(port)+ if status:+ meta = dict(meta)+ meta["_uptimeMs"] = status.get("uptimeMs")+ alive.append(meta)+ if not alive:+ return None, {+ "success": False,+ "error": f"plugin(s) for '{exe_name}' have stale discovery files (not responding to /status)",+ "errorCode": "plugin_not_running",+ "_hint": (+ f"Found {len(candidates)} discovery file(s) for {exe_name} but none responded. "+ f"Likely cause: the KiCad process exited without cleaning up its discovery file. "+ "Call kicad_bridge_status with pruneStale:true to clean them up, then ask the "+ "user to relaunch KiCad."+ ),+ }+ # Pick the newest alive instance.+ alive.sort(key=lambda m: m.get("startedAt", ""), reverse=True)+ return alive[0], None+++def handle_bridge_call(kicad_info: dict, args: dict) -> dict:+ """Generic passthrough: call any RPC method on a specific plugin.++ Args:+ exeName: which KiCad exe to talk to (kicad, pcbnew, eeschema, etc.)+ method: RPC method name (ping, list_frames, get_menu_ids, wm_command,+ get_board_info, get_schematic_info)+ params: optional dict of method params+ timeout: optional float (default 10s)+ pid: optional — pin to a specific KiCad process PID. Without this,+ picks the newest alive instance.++ Returns the JSON-RPC response wrapped in {"success": ...}.+ """+ exe_name = args.get("exeName")+ method = args.get("method")+ pid = args.get("pid")+ if not exe_name:+ return {"success": False, "error": "exeName is required",+ "_hint": "exeName must be one of: kicad, pcbnew, eeschema, kicad-cli"}+ if not method:+ return {"success": False, "error": "method is required",+ "_hint": "method must be one of: ping, list_frames, get_menu_ids, wm_command, get_board_info, get_schematic_info, get_open_editors"}++ meta, err = _resolve_plugin(exe_name, pid if isinstance(pid, int) else None)+ if err:+ return err+ port = meta["port"]++ timeout = float(args.get("timeout") or 10.0)+ rpc_response = call(port, method, args.get("params") or {}, timeout=timeout)+ if "error" in rpc_response:+ return {+ "success": False,+ "error": rpc_response["error"].get("message", "rpc error"),+ "errorCode": "rpc_error",+ "rpcError": rpc_response["error"],+ "exeName": exe_name,+ "pid": meta.get("pid"),+ "port": port,+ }+ return {+ "success": True,+ "result": rpc_response.get("result"),+ "exeName": exe_name,+ "pid": meta.get("pid"),+ "port": port,+ }+++def handle_open_editors(kicad_info: dict, args: dict) -> dict:+ """Cross-process editor inventory: hit every alive plugin's+ get_open_editors method and concatenate. Replaces the old+ handle_window_info screen-scraping for the common case.++ Returns:+ {success, editors:[{kind, exeName, pid, frameTitle, hwnd, isShown}],+ byKind:{schematic_editor:[...], pcb_editor:[...], ...}, summary:{total, byKind:{...}}}++ Args (all optional):+ includeStale: include exited-process discovery files (default false)+ """+ out_editors: list[dict] = []+ by_kind: dict[str, list[dict]] = {}+ probed_plugins = 0+ failed_plugins = 0++ for path in _discovery_files():+ meta = _read_discovery(path)+ if not meta:+ continue+ port = meta.get("port")+ if not port:+ continue+ # Probe alive first+ status = _probe(port, timeout=1.0)+ if not status:+ continue+ probed_plugins += 1+ rpc = call(port, "get_open_editors", {}, timeout=5.0)+ if "error" in rpc:+ failed_plugins += 1+ continue+ result = rpc.get("result") or {}+ if not result.get("available"):+ continue+ for ed in result.get("editors", []):+ row = {+ "kind": ed.get("kind"),+ "exeName": result.get("exeName"),+ "pid": result.get("pid"),+ "frameClass": ed.get("frameClass"),+ "frameTitle": ed.get("frameTitle"),+ "hwnd": ed.get("hwnd"),+ "isShown": ed.get("isShown"),+ }+ out_editors.append(row)+ by_kind.setdefault(row["kind"], []).append(row)++ return {+ "success": True,+ "editors": out_editors,+ "byKind": by_kind,+ "summary": {+ "totalEditors": len(out_editors),+ "probedPlugins": probed_plugins,+ "failedPlugins": failed_plugins,+ "byKindCounts": {k: len(v) for k, v in by_kind.items()},+ },+ "_hint": (+ "Cross-process inventory: each KiCad GUI exe's plugin contributes its own "+ "wx top-level frames. If totalEditors=0 but the user has KiCad windows open, "+ "the plugins likely haven't deployed yet OR KiCad was running before "+ "v1.7.5 plugin install (those instances need a restart to load the bridge). "+ "Call kicad_bridge_status to confirm plugins are alive."+ ),+ }+++# ── Phase 3 — three-tier fallback for menu-driven actions ──────────────++def try_wm_command_via_bridge(exe_name: str, menu_id: int, frame_index: int = 0,+ pid: int | None = None, timeout: float = 10.0) -> dict:+ """Tier 1 of Phase 3's 3-tier fallback chain.++ Send a `wm_command` RPC to the bridge. Returns:+ {success: True, pathway: "plugin", pid, port, ...} on success+ {success: False, pathway: "plugin", reason: "..."} on any failure++ Callers should fall through to tier-2 (Win32 PostMessage from outside)+ when success is False. Tier-3 is SendKeys via menu nav (out of MVP).+ """+ meta, err = _resolve_plugin(exe_name, pid)+ if err:+ return {"success": False, "pathway": "plugin",+ "reason": err.get("error", "plugin not available"),+ "errorCode": err.get("errorCode")}+ rpc = call(meta["port"], "wm_command",+ {"frame_index": frame_index, "menu_id": menu_id},+ timeout=timeout)+ if "error" in rpc:+ return {"success": False, "pathway": "plugin",+ "reason": rpc["error"].get("message", "rpc error"),+ "errorCode": "rpc_error"}+ result = rpc.get("result") or {}+ return {+ "success": bool(result.get("posted")),+ "pathway": "plugin",+ "exeName": exe_name,+ "pid": meta.get("pid"),+ "port": meta.get("port"),+ "frame": {+ "frameClass": result.get("frameClass"),+ "frameTitle": result.get("frameTitle"),+ "hwnd": result.get("hwnd"),+ },+ "menuId": menu_id,+ }
handlers/close_windows.pyadded+672@@ -0,0 +1,672 @@+"""Handlers for close_symbol_editor, close_footprint_editor, close_3d_viewer, and close_kicad.++Cross-platform: Windows uses Win32 (WM_CLOSE + taskkill), macOS uses+osascript (graceful quit) and pkill (force).++Window enumeration is process-based on Windows (find PIDs running from KiCad's+bin_dir, then enumerate top-level windows belonging to those PIDs). On macOS+we can't enumerate windows without Accessibility-framework permissions, so+window_info returns the running editor processes as a flat list instead.+"""++import ctypes+import ctypes.wintypes+import os+import subprocess+import sys+import time++IS_WINDOWS = sys.platform == "win32"+IS_MACOS = sys.platform == "darwin"++WM_CLOSE = 0x0010+GW_OWNER = 4+PROCESS_QUERY_LIMITED_INFORMATION = 0x1000++# ╔═══════════════════════════════════════════════════════════════════════════╗+# ║ WINDOWS ║+# ╚═══════════════════════════════════════════════════════════════════════════╝++if IS_WINDOWS:+ user32 = ctypes.windll.user32+ kernel32 = ctypes.windll.kernel32+else:+ user32 = None+ kernel32 = None++if IS_WINDOWS:++ # KiCad sub-process names that may survive WM_CLOSE+ WIN_KICAD_PROCESS_NAMES = [+ "eeschema.exe",+ "pcbnew.exe",+ "bitmap2component.exe",+ "pcb_calculator.exe",+ "pl_editor.exe",+ "kicad.exe",+ ]++ _callbacks = []+++ def _win_get_kicad_pids(kicad_info: dict) -> set:+ """Find PIDs of all running processes whose exe lives under KiCad's bin_dir."""+ bin_dir = kicad_info.get("bin_dir", "")+ if not bin_dir:+ return set()+ bin_dir_norm = os.path.normcase(os.path.normpath(bin_dir))++ pids = set()+ max_pids = 4096+ arr = (ctypes.wintypes.DWORD * max_pids)()+ bytes_returned = ctypes.wintypes.DWORD()+ psapi = ctypes.windll.psapi+ if not psapi.EnumProcesses(ctypes.byref(arr), ctypes.sizeof(arr), ctypes.byref(bytes_returned)):+ return pids++ count = bytes_returned.value // ctypes.sizeof(ctypes.wintypes.DWORD)+ buf = ctypes.create_unicode_buffer(1024)++ for i in range(count):+ pid = arr[i]+ if pid == 0:+ continue+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)+ if not handle:+ continue+ try:+ size = ctypes.wintypes.DWORD(1024)+ if kernel32.QueryFullProcessImageNameW(handle, 0, buf, ctypes.byref(size)):+ exe_path = os.path.normcase(os.path.normpath(buf.value))+ if exe_path.startswith(bin_dir_norm):+ pids.add(pid)+ finally:+ kernel32.CloseHandle(handle)++ return pids+++ def _win_find_windows_by_pid(pids: set) -> list:+ """Find all visible top-level windows belonging to any of the given PIDs."""+ results = []+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def callback(hwnd, _lparam):+ if not user32.IsWindowVisible(hwnd):+ return True+ pid = ctypes.wintypes.DWORD()+ user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))+ if pid.value in pids:+ length = user32.GetWindowTextLengthW(hwnd)+ title = ""+ if length > 0:+ buf = ctypes.create_unicode_buffer(length + 1)+ user32.GetWindowTextW(hwnd, buf, length + 1)+ title = buf.value+ owner = user32.GetWindow(hwnd, GW_OWNER)+ results.append((hwnd, title, owner))+ return True++ cb = WNDENUMPROC(callback)+ _callbacks.append(cb)+ user32.EnumWindows(cb, 0)+ _callbacks.remove(cb)+ return results+++ def _win_find_all_kicad_windows(kicad_info: dict) -> list:+ pids = _win_get_kicad_pids(kicad_info)+ if not pids:+ return []+ return _win_find_windows_by_pid(pids)+++ def _win_find_windows_by_title(substring: str) -> list:+ """Find all visible windows whose title contains the given substring."""+ results = []+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def callback(hwnd, _lparam):+ if user32.IsWindowVisible(hwnd):+ length = user32.GetWindowTextLengthW(hwnd)+ if length > 0:+ buf = ctypes.create_unicode_buffer(length + 1)+ user32.GetWindowTextW(hwnd, buf, length + 1)+ if substring in buf.value:+ owner = user32.GetWindow(hwnd, GW_OWNER)+ results.append((hwnd, buf.value, owner))+ return True++ cb = WNDENUMPROC(callback)+ _callbacks.append(cb)+ user32.EnumWindows(cb, 0)+ _callbacks.remove(cb)+ return results+++ def _win_get_dialog_body_text(hwnd: int) -> str:+ """Read a dialog's message text from its Static (label) child controls.++ Returns the concatenated body (e.g. "Insufficient permissions to save…")+ so the AI can read + handle an error dialog WITHOUT a screenshot round-trip.+ Only Static class (skips buttons), skips icon-only statics + consecutive+ dups. Best-effort: returns "" on any failure.+ """+ parts: list = []+ try:+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def child_cb(child, _lparam):+ cls = ctypes.create_unicode_buffer(64)+ user32.GetClassNameW(child, cls, 64)+ if cls.value == "Static":+ length = user32.GetWindowTextLengthW(child)+ if length > 0:+ b = ctypes.create_unicode_buffer(length + 1)+ user32.GetWindowTextW(child, b, length + 1)+ t = b.value.strip()+ if t and any(ord(c) >= 32 for c in t):+ parts.append(t)+ return True++ cb = WNDENUMPROC(child_cb)+ _callbacks.append(cb)+ user32.EnumChildWindows(hwnd, cb, 0)+ _callbacks.remove(cb)+ except Exception:+ return ""+ out: list = []+ for p in parts:+ if not out or out[-1] != p:+ out.append(p)+ return " ".join(out).strip()+++ def _win_close_and_wait(hwnd: int, title: str, timeout: float = 5.0) -> bool:+ """Send WM_CLOSE and wait for the window to disappear."""+ user32.PostMessageW(hwnd, WM_CLOSE, 0, 0)++ deadline = time.monotonic() + timeout+ while time.monotonic() < deadline:+ time.sleep(0.3)+ if not user32.IsWindow(hwnd) or not user32.IsWindowVisible(hwnd):+ return True+ return False+++ def _win_force_kill_kicad_processes() -> list:+ """Force-kill all KiCad sub-processes via taskkill. Returns list of killed names."""+ killed = []+ for name in WIN_KICAD_PROCESS_NAMES:+ try:+ result = subprocess.run(+ ["taskkill", "/F", "/IM", name],+ capture_output=True, text=True, timeout=5,+ creationflags=0x08000000, # CREATE_NO_WINDOW+ )+ if result.returncode == 0:+ killed.append(name)+ except Exception:+ pass+ return killed+++# ╔═══════════════════════════════════════════════════════════════════════════╗+# ║ macOS ║+# ╚═══════════════════════════════════════════════════════════════════════════╝++# Bundle ids / app names for AppleScript "tell application X to quit".+# Display names (left side) match the on-disk .app bundle name; AppleScript+# accepts these directly.+MAC_APP_NAMES = ["KiCad", "PCB Editor", "Schematic Editor", "Footprint Editor",+ "Symbol Editor", "GerbView", "Image Converter", "PCB Calculator",+ "Page Layout Editor"]++# Process-name patterns for pgrep / pkill — covers both binary names and the+# editor bundles whose Mach-O is named after the editor.+MAC_KICAD_PROCESS_NAMES = ["kicad", "pcbnew", "eeschema", "gerbview",+ "bitmap2component", "pcb_calculator", "pl_editor"]+++def _mac_pgrep(pattern: str) -> list:+ """Run `pgrep -f <pattern>` and return list of matching PIDs."""+ try:+ result = subprocess.run(+ ["pgrep", "-f", pattern],+ capture_output=True, text=True, timeout=3,+ )+ if result.returncode != 0:+ return []+ return [int(line) for line in result.stdout.split() if line.strip().isdigit()]+ except Exception:+ return []+++def _mac_get_kicad_pids(kicad_info: dict) -> set:+ """Find PIDs of running KiCad processes by matching binary names.+ Filters by the install's base_dir so we don't hit a different KiCad install."""+ base_dir = kicad_info.get("base_dir", "")+ pids: set = set()+ # ps -A -o pid,comm,args is the most reliable way to filter by exe path+ try:+ result = subprocess.run(+ ["ps", "-A", "-o", "pid=,comm="],+ capture_output=True, text=True, timeout=3,+ )+ if result.returncode != 0:+ return pids+ for line in result.stdout.splitlines():+ line = line.strip()+ if not line:+ continue+ parts = line.split(None, 1)+ if len(parts) != 2:+ continue+ pid_str, comm = parts+ if not pid_str.isdigit():+ continue+ # comm is the full path to the executable on macOS+ # Filter to KiCad install if we have a base_dir; otherwise match by name+ if base_dir and base_dir in comm:+ pids.add(int(pid_str))+ elif not base_dir:+ bin_name = os.path.basename(comm).lower()+ if any(name in bin_name for name in MAC_KICAD_PROCESS_NAMES):+ pids.add(int(pid_str))+ except Exception:+ pass+ return pids+++def _mac_running_editors(kicad_info: dict) -> list:+ """Return a list of dicts describing currently running KiCad editor processes.+ Used as a stand-in for window enumeration on macOS (where we'd need+ Accessibility-framework permission to enumerate windows of other apps)."""+ pids = _mac_get_kicad_pids(kicad_info)+ out = []+ for pid in pids:+ try:+ result = subprocess.run(+ ["ps", "-o", "comm=", "-p", str(pid)],+ capture_output=True, text=True, timeout=2,+ )+ comm = result.stdout.strip()+ if not comm:+ continue+ bin_name = os.path.basename(comm)+ # Title is best-effort: use the binary name as the title surrogate+ out.append({+ "pid": pid,+ "title": bin_name,+ "binary": comm,+ })+ except Exception:+ pass+ return out+++def _mac_quit_app(app_name: str, timeout: float = 5.0) -> bool:+ """Ask an app to quit gracefully via AppleScript.+ Returns True if the quit message was delivered (the app may still+ show a save-prompt dialog before fully quitting)."""+ try:+ subprocess.run(+ ["osascript", "-e", f'tell application "{app_name}" to quit'],+ capture_output=True, text=True, timeout=timeout,+ )+ # osascript returns 0 even if the app isn't running, so we always+ # claim success — caller should re-check process state.+ return True+ except Exception:+ return False+++def _mac_force_kill_kicad_processes() -> list:+ """Force-kill all KiCad sub-processes via pkill. Returns list of killed names."""+ killed = []+ for name in MAC_KICAD_PROCESS_NAMES:+ try:+ result = subprocess.run(+ ["pkill", "-9", "-x", name],+ capture_output=True, text=True, timeout=3,+ )+ if result.returncode == 0:+ killed.append(name)+ except Exception:+ pass+ return killed+++def _mac_wait_for_quit(name_substr: str, timeout: float = 5.0) -> bool:+ """Wait up to `timeout` for processes whose binary name contains `name_substr` to disappear."""+ deadline = time.monotonic() + timeout+ name_substr = name_substr.lower()+ while time.monotonic() < deadline:+ try:+ result = subprocess.run(+ ["ps", "-A", "-o", "comm="],+ capture_output=True, text=True, timeout=2,+ )+ running = any(name_substr in line.lower()+ for line in result.stdout.splitlines())+ if not running:+ return True+ except Exception:+ pass+ time.sleep(0.3)+ return False+++# ╔═══════════════════════════════════════════════════════════════════════════╗+# ║ PUBLIC API (cross-platform) ║+# ╚═══════════════════════════════════════════════════════════════════════════╝++def _is_manager_window(title: str) -> bool:+ """The KiCad project manager's title is just 'KiCad <version>' or 'KiCad'+ — it doesn't contain 'Editor', 'Viewer', etc."""+ lower = title.lower()+ if lower.startswith("kicad") and "editor" not in lower and "viewer" not in lower:+ return True+ return False+++def handle_window_info(kicad_info: dict, args: dict) -> dict:+ """Return all KiCad windows (editors, project manager, modal dialogs).++ Windows: enumerates real windows via Win32. Bundles modal dialogs separately.+ macOS: returns a flat list of running editor processes. Modal-dialog+ enumeration requires Accessibility-framework permission, which we don't+ request — handlers that need to dismiss dialogs should ask the user.+ """+ if IS_WINDOWS:+ all_found = _win_find_all_kicad_windows(kicad_info)+ editors = []+ dialogs = []+ manager = None+ for hwnd, title, owner in all_found:+ if owner != 0:+ # `body` = the dialog's message text (read from its Static child+ # controls) so the AI can read+handle the error WITHOUT a screenshot+ # round-trip (e.g. "Insufficient permissions to save...").+ dialogs.append({+ "hwnd": hwnd,+ "title": title,+ "ownerHwnd": owner,+ "body": _win_get_dialog_body_text(hwnd),+ })+ elif _is_manager_window(title):+ manager = {"hwnd": hwnd, "title": title}+ else:+ editors.append({"hwnd": hwnd, "title": title})+ data = {+ "projectManager": manager,+ "editors": editors,+ "modalDialogs": dialogs,+ "hasModalDialogs": len(dialogs) > 0,+ }+ if dialogs:+ # In-`data` hint (siblings the flag, so it's not missed): tells the AI+ # a blocking dialog is up and how to read + dismiss it.+ data["_dialogHint"] = (+ "hasModalDialogs is true — KiCad has a BLOCKING modal dialog. Read each "+ "modalDialogs[].body for the message text; if body is empty, screenshot "+ "modalDialogs[].hwnd via desktop_screenshot_window. Dismiss with "+ "kicad_send_key {hwnd, key:'return'} (OK/default) or 'escape' (cancel)."+ )+ return {+ "success": True,+ "output": f"{len(editors)} editor(s), {len(dialogs)} dialog(s)",+ "data": data,+ }++ if IS_MACOS:+ editors = _mac_running_editors(kicad_info)+ manager = None+ editor_list = []+ for e in editors:+ if _is_manager_window(e["title"]):+ manager = {"pid": e["pid"], "title": e["title"]}+ else:+ editor_list.append({"pid": e["pid"], "title": e["title"]})+ return {+ "success": True,+ "output": f"{len(editor_list)} editor(s) running",+ "data": {+ "projectManager": manager,+ "editors": editor_list,+ "modalDialogs": [],+ "hasModalDialogs": False,+ "_hint": ("macOS reports editors as running processes; modal dialog "+ "enumeration requires Accessibility permission and is not "+ "implemented. If a modal dialog is blocking, ask the user "+ "to dismiss it manually."),+ },+ }++ return {+ "success": False,+ "error": f"window_info not implemented on platform {sys.platform!r}",+ }+++def handle_close_symbol_editor(kicad_info: dict, args: dict) -> dict:+ """Close the KiCad Symbol Editor window."""+ if IS_WINDOWS:+ windows = _win_find_windows_by_title("Symbol Editor")+ sym_windows = [(h, t) for h, t, _ in windows if "symbol editor" in t.lower()]+ if not sym_windows:+ return {"success": True, "output": "Symbol Editor is not open."}+ closed, failed = [], []+ for hwnd, title in sym_windows:+ (closed if _win_close_and_wait(hwnd, title) else failed).append(title)+ if failed:+ return {+ "success": False,+ "error": f"Failed to close: {', '.join(failed)}",+ "_hint": "Call close_kicad with {\"force\": true} to force-kill the window, then retry.",+ }+ return {+ "success": True,+ "output": f"Symbol Editor closed: {closed[0]}" if len(closed) == 1+ else f"Closed {len(closed)} Symbol Editor window(s).",+ }++ if IS_MACOS:+ if not _mac_pgrep("Symbol Editor"):+ return {"success": True, "output": "Symbol Editor is not open."}+ _mac_quit_app("Symbol Editor")+ if _mac_wait_for_quit("Symbol Editor"):+ return {"success": True, "output": "Symbol Editor closed."}+ return {+ "success": False,+ "error": "Symbol Editor did not close within 5s",+ "_hint": "Call close_kicad with {\"force\": true} to force-kill the editor, then retry.",+ }++ return {"success": False, "error": f"unsupported platform {sys.platform!r}"}+++def handle_close_footprint_editor(kicad_info: dict, args: dict) -> dict:+ """Close the KiCad Footprint Editor window."""+ if IS_WINDOWS:+ windows = _win_find_windows_by_title("Footprint Editor")+ fp_windows = [(h, t) for h, t, _ in windows if "footprint editor" in t.lower()]+ if not fp_windows:+ return {"success": True, "output": "Footprint Editor is not open."}+ closed, failed = [], []+ for hwnd, title in fp_windows:+ (closed if _win_close_and_wait(hwnd, title) else failed).append(title)+ if failed:+ return {+ "success": False,+ "error": f"Failed to close: {', '.join(failed)}",+ "_hint": "Call close_kicad with {\"force\": true} to force-kill the window, then retry.",+ }+ return {+ "success": True,+ "output": f"Footprint Editor closed: {closed[0]}" if len(closed) == 1+ else f"Closed {len(closed)} Footprint Editor window(s).",+ }++ if IS_MACOS:+ if not _mac_pgrep("Footprint Editor"):+ return {"success": True, "output": "Footprint Editor is not open."}+ _mac_quit_app("Footprint Editor")+ if _mac_wait_for_quit("Footprint Editor"):+ return {"success": True, "output": "Footprint Editor closed."}+ return {+ "success": False,+ "error": "Footprint Editor did not close within 5s",+ "_hint": "Call close_kicad with {\"force\": true} to force-kill the editor, then retry.",+ }++ return {"success": False, "error": f"unsupported platform {sys.platform!r}"}+++def handle_close_3d_viewer(kicad_info: dict, args: dict) -> dict:+ """Close the KiCad 3D Viewer window."""+ if IS_WINDOWS:+ windows = _win_find_windows_by_title("3D Viewer")+ viewer_windows = [(h, t) for h, t, _ in windows if "3d viewer" in t.lower()]+ if not viewer_windows:+ return {"success": True, "output": "3D Viewer is not open."}+ closed, failed = [], []+ for hwnd, title in viewer_windows:+ (closed if _win_close_and_wait(hwnd, title) else failed).append(title)+ if failed:+ return {+ "success": False,+ "error": f"Failed to close: {', '.join(failed)}",+ "_hint": "Call close_kicad with {\"force\": true} to force-kill the window, then retry.",+ }+ return {"success": True, "output": "3D Viewer closed."}++ if IS_MACOS:+ # The 3D Viewer on macOS is a sub-window of PCB Editor; closing it+ # requires either Accessibility-driven UI scripting or a force-kill of+ # PCB Editor (which would also close the board). We surface the+ # limitation rather than killing the whole editor.+ return {+ "success": False,+ "error": "close_3d_viewer is not implemented on macOS",+ "_hint": ("On macOS the 3D Viewer is a child window of PCB Editor; "+ "closing it requires Accessibility permission. Ask the user "+ "to close it manually, or use close_kicad to close the whole editor."),+ }++ return {"success": False, "error": f"unsupported platform {sys.platform!r}"}+++def handle_close_kicad(kicad_info: dict, args: dict) -> dict:+ """Close all KiCad windows (editors first, then the project manager).++ Args:+ kicad_info: KiCad detection info dict.+ args: Optional dict with:+ force (bool): If True, skip graceful close and go straight to kill.+ Default False.+ """+ force = bool(args.get("force", False)) if args else False++ if IS_WINDOWS:+ if force:+ killed = _win_force_kill_kicad_processes()+ if killed:+ return {+ "success": True,+ "output": f"Force-killed: {', '.join(killed)}",+ "data": {"forceKilled": killed},+ }+ return {"success": True, "output": "KiCad is not running (nothing to kill)."}++ all_windows = _win_find_all_kicad_windows(kicad_info)+ editors, manager = [], None+ for hwnd, title, owner in all_windows:+ if owner != 0:+ continue+ if _is_manager_window(title):+ manager = (hwnd, title)+ else:+ editors.append((hwnd, title))++ if not editors and not manager:+ return {"success": True, "output": "KiCad is not running."}++ closed, failed = [], []+ for hwnd, title in editors:+ (closed if _win_close_and_wait(hwnd, title) else failed).append(title)+ if manager:+ hwnd, title = manager+ (closed if _win_close_and_wait(hwnd, title) else failed).append(title)++ force_killed = []+ if failed:+ time.sleep(1)+ force_killed = _win_force_kill_kicad_processes()+ if force_killed:+ closed.extend(f"{name} (force-killed)" for name in force_killed)+ failed = []++ if failed:+ return {+ "success": False,+ "error": f"Failed to close: {', '.join(failed)}",+ "output": f"Closed: {', '.join(closed)}" if closed else "",+ "_hint": "Call close_kicad with {\"force\": true} to force-kill stuck windows.",+ }++ return {+ "success": True,+ "output": f"KiCad closed ({len(closed)} window{'s' if len(closed) != 1 else ''}).",+ "data": {"forceKilled": force_killed} if force_killed else {},+ }++ if IS_MACOS:+ if force:+ killed = _mac_force_kill_kicad_processes()+ if killed:+ return {+ "success": True,+ "output": f"Force-killed: {', '.join(killed)}",+ "data": {"forceKilled": killed},+ }+ return {"success": True, "output": "KiCad is not running (nothing to kill)."}++ # Graceful: ask each editor app to quit+ editors = _mac_running_editors(kicad_info)+ if not editors:+ return {"success": True, "output": "KiCad is not running."}++ for app_name in MAC_APP_NAMES:+ _mac_quit_app(app_name)++ # Wait for each known process to disappear+ still_running = []+ for proc_name in MAC_KICAD_PROCESS_NAMES:+ if not _mac_wait_for_quit(proc_name, timeout=3.0):+ still_running.append(proc_name)++ force_killed = []+ if still_running:+ force_killed = _mac_force_kill_kicad_processes()++ if still_running and not force_killed:+ return {+ "success": False,+ "error": f"Failed to close: {', '.join(still_running)}",+ "_hint": "Call close_kicad with {\"force\": true} to force-kill stuck processes.",+ }++ return {+ "success": True,+ "output": f"KiCad closed ({len(editors)} process{'es' if len(editors) != 1 else ''}).",+ "data": {"forceKilled": force_killed} if force_killed else {},+ }++ return {"success": False, "error": f"unsupported platform {sys.platform!r}"}
handlers/export.pyadded+275@@ -0,0 +1,275 @@+"""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/fix_keyboard.pyadded+109@@ -0,0 +1,109 @@+"""Handler for fix_keyboard command.++Windows: releases stuck modifier keys (Alt, Ctrl, Shift, Win) and unlocks+the foreground window lock — recovery for when keybd_event/SendKeys leaves+keys held at the OS level after KiCad UI automation.++macOS: no-op. Stuck modifier keys aren't a problem on macOS because we don't+use SendKeys-style automation against KiCad windows (UI scripting requires+Accessibility permission, which we don't request).+"""++import ctypes+import ctypes.wintypes+import sys++IS_WINDOWS = sys.platform == "win32"++if IS_WINDOWS:+ user32 = ctypes.windll.user32+else:+ user32 = None++# Key-up flag+KEYEVENTF_KEYUP = 0x0002++# Virtual key codes for all modifier keys+_MODIFIER_KEYS = [+ 0x12, # VK_MENU (Alt generic)+ 0xA4, # VK_LMENU (Left Alt)+ 0xA5, # VK_RMENU (Right Alt)+ 0x11, # VK_CONTROL (Ctrl generic)+ 0xA2, # VK_LCONTROL (Left Ctrl)+ 0xA3, # VK_RCONTROL (Right Ctrl)+ 0x10, # VK_SHIFT (Shift generic)+ 0xA0, # VK_LSHIFT (Left Shift)+ 0xA1, # VK_RSHIFT (Right Shift)+ 0x5B, # VK_LWIN (Left Win)+ 0x5C, # VK_RWIN (Right Win)+]++# SystemParametersInfo constants+SPI_SETFOREGROUNDLOCKTIMEOUT = 0x2001+SPIF_UPDATEINIFILE = 0x01+SPIF_SENDCHANGE = 0x02+++def _get_stuck_keys() -> list[str]:+ """Check which modifier keys are currently pressed/stuck."""+ stuck = []+ names = {+ 0x12: "Alt", 0x11: "Ctrl", 0x10: "Shift", 0x5B: "LWin", 0x5C: "RWin",+ }+ for vk, name in names.items():+ state = user32.GetAsyncKeyState(vk)+ if state & 0x8000:+ stuck.append(name)+ return stuck+++def handle_fix_keyboard(kicad_info: dict, args: dict) -> dict:+ """Release all stuck modifier keys and unlock foreground window focus.++ Windows-only recovery for keybd_event/SendKeys leaving modifiers stuck.+ On macOS this returns a no-op success since we don't have the same problem.+ """+ if not IS_WINDOWS:+ return {+ "success": True,+ "output": "fix_keyboard is a Windows-only recovery command (no-op on this platform).",+ }++ # 1. Check which keys are stuck before fix+ stuck_before = _get_stuck_keys()++ # 2. Release all modifier keys (10 rounds for reliability)+ for _ in range(10):+ for vk in _MODIFIER_KEYS:+ user32.keybd_event(vk, 0, KEYEVENTF_KEYUP, 0)++ # 3. Unlock foreground window lock (LSFW_UNLOCK = 2)+ user32.LockSetForegroundWindow(2)++ # 4. Reset foreground lock timeout to 0 (allow any app to take foreground)+ user32.SystemParametersInfoW(+ SPI_SETFOREGROUNDLOCKTIMEOUT, 0, None,+ SPIF_UPDATEINIFILE | SPIF_SENDCHANGE,+ )++ # 5. Verify+ stuck_after = _get_stuck_keys()++ if stuck_before:+ detail = f"Stuck keys detected: {', '.join(stuck_before)}. "+ else:+ detail = "No stuck keys detected via GetAsyncKeyState. "++ if stuck_after:+ detail += f"WARNING: Keys still stuck after fix: {', '.join(stuck_after)}. "+ detail += "User may need to sign out and back in."+ return {+ "success": True,+ "output": detail + " Modifier keys released, foreground lock cleared.",+ }+ else:+ detail += "All modifier keys released successfully."+ return {+ "success": True,+ "output": detail + " Foreground lock cleared.",+ }
handlers/install_footprint.pyadded+152@@ -0,0 +1,152 @@+"""Handler for install_footprint command.++Atomic command that decodes a base64-encoded .kicad_mod file, places it+into the global Adom.pretty footprint library, registers the library in+fp-lib-table if needed, and optionally opens the Footprint Editor.++Steps:+1. Decode base64 file content+2. Write .kicad_mod into Adom.pretty/ (creates directory if needed)+3. Register Adom in fp-lib-table if not already registered+4. Optionally open Footprint Editor with the footprint loaded+"""++import base64+import sys+from pathlib import Path++sys.path.insert(0, str(Path(__file__).parent.parent))++from lib_table import LibTable, LibEntry+from handlers.open_footprint_editor import handle_open_footprint_editor++ADOM_FP_LIB_NAME = "Adom"+ADOM_FP_DIR_NAME = "Adom.pretty"+ADOM_FP_LIB_DESCRIPTION = "Adom Desktop - custom footprints"+++def handle_install_footprint(kicad_info: dict, args: dict) -> dict:+ """Install a KiCad footprint from base64-encoded file content.++ Args:+ kicad_info: KiCad detection info dict.+ args: Dict with:+ - fileName (required): Original filename (e.g. "BMI423.kicad_mod")+ - fileContent (required): Base64-encoded .kicad_mod file content+ - footprintName (optional): Specific footprint to open in the editor+ - openEditor (optional): Whether to open Footprint Editor (default: true)++ Returns:+ Dict with:+ - success: bool+ - installedPath: path to the .kicad_mod file+ - footprintName: name of the footprint+ - libraryName: always "Adom"+ - editorOpened: bool+ - output: human-readable summary+ - error: error message (only if success is false)+ """+ 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_name = args.get("fileName", "")+ file_content = args.get("fileContent", "")+ footprint_name = args.get("footprintName", "")+ quiet_install = args.get("quietInstall", False)+ open_editor = False if quiet_install else args.get("openEditor", True)++ if not file_name:+ return {"success": False, "error": "No fileName specified"}+ if not file_content:+ return {"success": False, "error": "No fileContent specified"}++ # --- Step 1: Decode the file ---++ try:+ decoded = base64.b64decode(file_content)+ footprint_text = decoded.decode("utf-8")+ except Exception as e:+ return {"success": False, "error": f"Failed to decode fileContent: {e}"}++ # Extract footprint name from file content if not provided+ if not footprint_name:+ import re+ name_match = re.search(r'\(footprint "([^"]+)"', footprint_text)+ if name_match:+ footprint_name = name_match.group(1)+ else:+ footprint_name = Path(file_name).stem++ # --- Step 2: Write into Adom.pretty ---++ user_dir = kicad_info.get("user_dir")+ config_dir = kicad_info.get("config_dir")+ if not user_dir or not config_dir:+ return {"success": False, "error": "KiCad user/config directory not found"}++ fp_dir = Path(user_dir) / "footprints"+ fp_dir.mkdir(parents=True, exist_ok=True)++ pretty_dir = fp_dir / ADOM_FP_DIR_NAME+ pretty_dir.mkdir(parents=True, exist_ok=True)++ # The .kicad_mod file must be named to match the footprint name inside it+ dest_file = pretty_dir / f"{footprint_name}.kicad_mod"+ dest_file.write_text(footprint_text, encoding="utf-8")++ # --- Step 3: Register in fp-lib-table ---++ table_path = Path(config_dir) / "fp-lib-table"+ table = LibTable.parse_file(str(table_path))++ registered = False+ if not table.has_library(ADOM_FP_LIB_NAME):+ uri = str(pretty_dir).replace("\\", "/")+ entry = LibEntry(+ name=ADOM_FP_LIB_NAME,+ lib_type="KiCad",+ uri=uri,+ options="",+ descr=ADOM_FP_LIB_DESCRIPTION,+ )+ table.add_library(entry)+ table.write_file(str(table_path))+ registered = True++ # --- Step 4: Optionally open Footprint Editor ---++ editor_opened = False+ editor_output = ""++ if open_editor:+ editor_result = handle_open_footprint_editor(kicad_info, {+ "footprintName": footprint_name,+ "libraryName": ADOM_FP_LIB_NAME,+ })+ editor_opened = editor_result.get("success", False)+ editor_output = editor_result.get("output", editor_result.get("error", ""))++ # --- Build result ---++ output_parts = [+ f"Footprint '{footprint_name}' installed to '{ADOM_FP_LIB_NAME}' library"+ ]+ if registered:+ output_parts.append("registered in fp-lib-table")+ if editor_opened:+ output_parts.append(f"Footprint Editor: {editor_output}")+ elif open_editor:+ output_parts.append(f"Footprint Editor: {editor_output}")++ return {+ "success": True,+ "output": " | ".join(output_parts),+ "installedPath": str(dest_file),+ "footprintName": footprint_name,+ "libraryName": ADOM_FP_LIB_NAME,+ "editorOpened": editor_opened,+ }
handlers/install_library.pyadded+269@@ -0,0 +1,269 @@+"""Handler for install_library command."""++import re+import shutil+import sys+from pathlib import Path++sys.path.insert(0, str(Path(__file__).parent.parent))+from lib_table import LibTable, LibEntry++# Map library type to configuration+LIB_TYPE_CONFIG = {+ "symbol": {+ "table_file": "sym-lib-table",+ "table_type": "sym_lib_table",+ "user_subdir": "symbols",+ "kicad_type": "KiCad",+ },+ "footprint": {+ "table_file": "fp-lib-table",+ "table_type": "fp_lib_table",+ "user_subdir": "footprints",+ "kicad_type": "KiCad",+ },+}+++def _install_footprint(kicad_info: dict, args: dict) -> dict:+ """Install a footprint library (.kicad_mod → .pretty directory)."""+ lib_path = args.get("libraryPath", "")+ lib_name = args.get("libraryName", "")+ description = args.get("description", "Installed by Adom Desktop")+ model_3d_path = args.get("model3dPath", "")++ source = Path(lib_path)+ if not source.exists():+ return {+ "success": False,+ "error": f"Footprint source not found: {lib_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.",+ }++ config_dir = kicad_info.get("config_dir")+ user_dir = kicad_info.get("user_dir")+ if not config_dir or not user_dir:+ return {"success": False, "error": "KiCad config/user directory not found"}++ # Footprint libraries are .pretty directories containing .kicad_mod files+ footprints_dir = Path(user_dir) / "footprints"+ footprints_dir.mkdir(parents=True, exist_ok=True)++ if source.is_dir() and source.suffix == ".pretty":+ # Source is already a .pretty directory — copy as-is+ if not lib_name:+ lib_name = source.stem+ pretty_dir = footprints_dir / source.name+ if pretty_dir.exists():+ shutil.rmtree(pretty_dir)+ shutil.copytree(str(source), str(pretty_dir))+ elif source.suffix == ".kicad_mod":+ # Single .kicad_mod file — wrap in a .pretty directory+ if not lib_name:+ lib_name = source.stem+ pretty_name = f"{lib_name}.pretty"+ pretty_dir = footprints_dir / pretty_name+ pretty_dir.mkdir(parents=True, exist_ok=True)++ # KiCad requires the .kicad_mod filename to match the footprint name+ # declared inside the file. Extract it and rename if needed.+ fp_text = source.read_text(encoding="utf-8")+ fp_name_match = re.search(r'\(footprint "([^"]+)"', fp_text)+ if fp_name_match:+ correct_name = f"{fp_name_match.group(1)}.kicad_mod"+ else:+ correct_name = source.name+ dest_file = pretty_dir / correct_name+ shutil.copy2(str(source), str(dest_file))+ else:+ return {+ "success": False,+ "error": f"Invalid footprint source: expected .kicad_mod file or .pretty directory, got {source.name}",+ }++ # Install 3D model if provided+ model_installed_path = ""+ if model_3d_path:+ model_source = Path(model_3d_path)+ if model_source.exists():+ models_dir = Path(user_dir) / "3dmodels" / f"{lib_name}.3dshapes"+ models_dir.mkdir(parents=True, exist_ok=True)+ model_dest = models_dir / model_source.name+ shutil.copy2(str(model_source), str(model_dest))+ model_installed_path = str(model_dest).replace("\\", "/")++ # Update the footprint file to add the 3D model reference+ if source.suffix == ".kicad_mod":+ _add_model_to_footprint(pretty_dir / source.name, model_installed_path)++ # Register in fp-lib-table+ table_path = Path(config_dir) / "fp-lib-table"+ table = LibTable.parse_file(str(table_path))++ uri = str(pretty_dir).replace("\\", "/")++ if not table.has_library(lib_name):+ entry = LibEntry(+ name=lib_name,+ lib_type="KiCad",+ uri=uri,+ options="",+ descr=description,+ )+ table.add_library(entry)+ table.write_file(str(table_path))+ registered = True+ else:+ registered = False++ result = {+ "success": True,+ "output": f"Footprint library '{lib_name}' installed to {pretty_dir}"+ + (f" (already registered)" if not registered else f" and registered in fp-lib-table"),+ "data": {+ "name": lib_name,+ "path": str(pretty_dir),+ "table": "fp-lib-table",+ },+ "_hint": (+ f"✅ Footprint library '{lib_name}' is now in fp-lib-table. "+ f"For next-time confidence, run `kicad_lint_library '{{\"libraryPath\":\"<path-to-.pretty-dir>\"}}'` "+ f"BEFORE install to validate each .kicad_mod inside in ~100ms (catches malformed files, "+ f"outdated formats). Any open KiCad windows need to be closed + reopened to pick up the new "+ f"fp-lib-table entry."+ ),+ }+ if model_installed_path:+ result["data"]["model3dPath"] = model_installed_path+ result["output"] += f". 3D model installed to {model_installed_path}"++ return result+++def _add_model_to_footprint(footprint_path: Path, model_path: str):+ """Add a (model ...) reference to a .kicad_mod file."""+ text = footprint_path.read_text(encoding="utf-8")++ # Remove any existing model reference+ text = re.sub(r'\s*\(model\s+"[^"]*"[^)]*\(offset[^)]*\)[^)]*\(scale[^)]*\)[^)]*\(rotate[^)]*\)\s*\)', '', text)++ # Insert model reference before the final closing paren+ model_block = (+ f'\n (model "{model_path}"\n'+ f' (offset (xyz 0 0 0))\n'+ f' (scale (xyz 1 1 1))\n'+ f' (rotate (xyz 0 0 0))\n'+ f' )'+ )+ # Find last ) and insert before it+ idx = text.rfind(")")+ if idx >= 0:+ text = text[:idx] + model_block + "\n" + text[idx:]++ footprint_path.write_text(text, encoding="utf-8")+++def handle_install_library(kicad_info: dict, args: dict) -> dict:+ """Install a library into KiCad's global lib table."""+ 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.",+ }++ lib_path = args.get("libraryPath", "")+ lib_type = args.get("libraryType", "symbol")++ if not lib_path:+ return {"success": False, "error": "No libraryPath specified"}++ # Footprints need special handling (.pretty directories)+ if lib_type == "footprint":+ return _install_footprint(kicad_info, args)++ # --- Symbol library install (original logic) ---+ lib_name = args.get("libraryName", "")+ description = args.get("description", "Installed by Adom Desktop")++ source = Path(lib_path)+ if not source.exists():+ return {+ "success": False,+ "error": f"Library source not found: {lib_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.",+ }++ if not lib_name:+ lib_name = source.stem++ type_config = LIB_TYPE_CONFIG.get(lib_type)+ if not type_config:+ return {+ "success": False,+ "error": f"Unknown library type: {lib_type}. Use 'symbol' or 'footprint'",+ }++ config_dir = kicad_info.get("config_dir")+ if not config_dir:+ return {"success": False, "error": "KiCad config directory not found"}++ user_dir = kicad_info.get("user_dir")+ if not user_dir:+ return {"success": False, "error": "KiCad user directory not found"}++ # Copy library file to user's library directory+ dest_dir = Path(user_dir) / type_config["user_subdir"]+ dest_dir.mkdir(parents=True, exist_ok=True)++ if source.is_dir():+ dest = dest_dir / source.name+ if dest.exists():+ shutil.rmtree(dest)+ shutil.copytree(str(source), str(dest))+ else:+ dest = dest_dir / source.name+ shutil.copy2(str(source), str(dest))++ # Parse the lib table+ table_path = Path(config_dir) / type_config["table_file"]+ table = LibTable.parse_file(str(table_path))++ # Use forward slashes for KiCad compatibility+ uri = str(dest).replace("\\", "/")++ entry = LibEntry(+ name=lib_name,+ lib_type=type_config["kicad_type"],+ uri=uri,+ options="",+ descr=description,+ )++ if table.has_library(lib_name):+ return {+ "success": False,+ "error": f"Library '{lib_name}' already exists in {type_config['table_file']}",+ "_hint": "Choose a different libraryName, or call list_symbol_libraries / list_footprint_libraries to see existing entries.",+ }++ table.add_library(entry)+ table.write_file(str(table_path))++ return {+ "success": True,+ "output": f"Library '{lib_name}' installed to {dest} and registered in {type_config['table_file']}",+ "data": {+ "name": lib_name,+ "path": str(dest),+ "table": type_config["table_file"],+ },+ "_hint": (+ f"✅ Library '{lib_name}' is now registered. For next-time confidence, run "+ f"`kicad_lint_library '{{\"libraryPath\":\"<path>\"}}'` BEFORE install to "+ f"catch malformed files, outdated formats, or wrong-filetype-renamed-with-"+ f".kicad_sym surprises in ~100ms — much faster than discovering a broken "+ f"library inside the GUI later. Any KiCad windows already open need to be "+ f"closed + reopened to pick up the new library entry (sym-lib-table is read at startup)."+ ),+ }
handlers/install_symbol.pyadded+118@@ -0,0 +1,118 @@+"""Handler for install_symbol command.++Atomic command that decodes a base64-encoded .kicad_sym file, merges its+symbol(s) into the single global Adom library (Adom.kicad_sym), and+optionally opens the Symbol Editor.++All symbols installed by Adom live in one KiCad symbol library called "Adom".+This library is created automatically on first use and registered in the+global sym-lib-table. Subsequent installs simply add/update symbols inside+that same file -- no need to create new libraries or restart KiCad for+library table changes.++Steps:+1. Decode base64 file content+2. Merge symbol(s) into Adom.kicad_sym (creates library if needed)+3. Optionally open Symbol Editor with the symbol loaded+"""++import base64+import sys+from pathlib import Path++sys.path.insert(0, str(Path(__file__).parent.parent))++from adom_library import add_symbol, ADOM_LIB_NAME+from handlers.open_symbol_editor import handle_open_symbol_editor+++def handle_install_symbol(kicad_info: dict, args: dict) -> dict:+ """Install a KiCad symbol from base64-encoded file content.++ Args:+ kicad_info: KiCad detection info dict.+ args: Dict with:+ - fileName (required): Original filename (e.g. "BQ76952.kicad_sym")+ - fileContent (required): Base64-encoded .kicad_sym file content+ - symbolName (optional): Specific symbol to open in the editor+ - openEditor (optional): Whether to open Symbol Editor (default: true)++ Returns:+ Dict with:+ - success: bool+ - installedPath: path to Adom.kicad_sym+ - symbolNames: list of symbol names added/updated+ - libraryName: always "Adom"+ - editorOpened: bool+ - output: human-readable summary+ - error: error message (only if success is false)+ """+ 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_name = args.get("fileName", "")+ file_content = args.get("fileContent", "")+ symbol_name = args.get("symbolName", "")+ quiet_install = args.get("quietInstall", False)+ open_editor = False if quiet_install else args.get("openEditor", True)++ if not file_name:+ return {"success": False, "error": "No fileName specified"}+ if not file_content:+ return {"success": False, "error": "No fileContent specified"}++ # --- Step 1: Decode the file ---++ try:+ decoded = base64.b64decode(file_content)+ symbol_text = decoded.decode("utf-8")+ except Exception as e:+ return {"success": False, "error": f"Failed to decode fileContent: {e}"}++ # --- Step 2: Merge into Adom.kicad_sym ---++ result = add_symbol(kicad_info, symbol_text)++ if not result.get("success"):+ return {"success": False, "error": result.get("error", "Unknown error")}++ added_names = result.get("added", [])+ lib_path = result.get("lib_path", "")++ # --- Step 3: Optionally open Symbol Editor ---++ editor_opened = False+ editor_output = ""++ if open_editor and added_names:+ # Search for the specific symbol, or the first one added+ search_name = symbol_name or added_names[0]+ editor_result = handle_open_symbol_editor(kicad_info, {+ "symbolName": search_name,+ "libraryName": ADOM_LIB_NAME,+ })+ editor_opened = editor_result.get("success", False)+ editor_output = editor_result.get("output", editor_result.get("error", ""))++ # --- Build result ---++ output_parts = [+ f"Symbol(s) {', '.join(added_names)} added to '{ADOM_LIB_NAME}' library"+ ]+ if editor_opened:+ output_parts.append(f"Symbol Editor: {editor_output}")+ elif open_editor:+ output_parts.append(f"Symbol Editor: {editor_output}")++ return {+ "success": True,+ "output": " | ".join(output_parts),+ "installedPath": lib_path,+ "symbolNames": added_names,+ "libraryName": ADOM_LIB_NAME,+ "editorOpened": editor_opened,+ }
handlers/kicad_cli_lint.pyadded+843@@ -0,0 +1,843 @@+"""Headless KiCad lint pre-checks via `kicad-cli`.++This module ships three top-level handlers — `handle_run_erc`,+`handle_lint_board`, `handle_lint_schematic` — that wrap `kicad-cli`+subprocess invocations to give AIs fast, structured validation+*before* attempting GUI automation. GUI automation is slow and fragile;+kicad-cli is fast (no wx, no event loop) and emits clean JSON.++Why lint before GUI:+ - Bad file? Parse error caught in 1s instead of a confusing+ "the board didn't open" 30 seconds later.+ - Schematic ↔ PCB parity broken? Surface the mismatch via DRC+ `--schematic-parity` flag BEFORE the AI thinks the board is fine.+ - File-format outdated? Tell the user to upgrade BEFORE opening+ triggers KiCad's modal "this file was made with an older version"+ dialog (which blocks the GUI and confuses screenshot-based recovery).++Each handler returns a `_hint` field that explicitly tells the calling+AI what to do next: "ok to proceed to kicad_open_board", "fix these+violations first", "ask user to upgrade", etc.++Shared concerns extracted into helpers:+ - File existence + extension check+ - Magic-byte sniff (first ~50 bytes, looking for `(kicad_pcb` etc.)+ - subprocess wrapper with timeout + JSON capture+ - Violation parser (DRC + ERC share JSON shape mostly)+"""++from __future__ import annotations++import json+import os+import subprocess+import tempfile+from pathlib import Path+from typing import Any+++# Magic-byte sniff: KiCad files are S-expression with a known root atom.+# This catches "wrong file type" before kicad-cli even runs.+_PCB_MAGIC_TOKENS = ("(kicad_pcb", "(kicad_pcb (")+_SCH_MAGIC_TOKENS = ("(kicad_sch", "(kicad_sch (")+++def _file_format_sniff(path: Path, expected: str) -> dict:+ """Return {ok, expected, magic, fileVersion?, _error?}.++ expected: "kicad_pcb" or "kicad_sch"+ """+ if not path.exists():+ return {+ "ok": False,+ "_error": f"file not found: {path}",+ "_hint": "Verify the path. If the AI is on Docker, the path must exist on the Windows host (use push_file to copy from Docker first).",+ }+ if not path.is_file():+ return {"ok": False, "_error": f"not a file: {path}"}++ try:+ with open(path, "rb") as f:+ head = f.read(512)+ except OSError as e:+ return {"ok": False, "_error": f"read failed: {e}"}++ head_text = head.decode("utf-8", errors="replace").lstrip()+ magic_tokens = _PCB_MAGIC_TOKENS if expected == "kicad_pcb" else _SCH_MAGIC_TOKENS+ magic_match = any(head_text.startswith(t) for t in magic_tokens)+ if not magic_match:+ # The file might be valid KiCad but a different type+ # (.kicad_sch passed where .kicad_pcb expected), or pure garbage.+ wrong_type = None+ if expected == "kicad_pcb" and head_text.startswith("(kicad_sch"):+ wrong_type = "kicad_sch"+ elif expected == "kicad_sch" and head_text.startswith("(kicad_pcb"):+ wrong_type = "kicad_pcb"+ return {+ "ok": False,+ "expected": expected,+ "actual": wrong_type or "non-kicad",+ "_error": f"file is not a {expected} (first bytes: {head_text[:80]!r})",+ "_hint": (+ f"You passed what looks like a {wrong_type} file to a verb "+ f"expecting {expected}. Use the matching verb instead "+ f"(kicad_open_board for .kicad_pcb, kicad_open_schematic / "+ f"kicad_lint_schematic for .kicad_sch)."+ if wrong_type else+ f"The file doesn't start with the expected {expected} S-expression header. "+ "It may be corrupted, truncated, or not a KiCad file at all."+ ),+ }++ # Try to pluck the file-format version. KiCad ≥6 embeds a `(version YYYYMMDD)`+ # near the top. We look for the first `(version ...)` in the first 512 bytes.+ file_version = None+ try:+ import re+ m = re.search(r"\(version\s+(\d+)\)", head_text)+ if m:+ file_version = int(m.group(1))+ except Exception: # pylint: disable=broad-except+ pass++ return {+ "ok": True,+ "expected": expected,+ "magic": "matched",+ "fileVersion": file_version,+ "_note": "magic + format sniff only — kicad-cli will do the real validation",+ }+++def _run_kicad_cli_json(cli_exe: str, args: list[str], timeout_secs: int = 120) -> dict:+ """Run kicad-cli and capture the JSON output it wrote to --output.++ Both `pcb drc` and `sch erc` write their structured report to a file+ path supplied via `--output`; stdout/stderr just carry status text.+ We make a tempfile, pass it in, parse it back, clean up.++ Returns {ok, exitCode, stdout, stderr, data?, _error?}.+ Caller adds higher-level `_hint`s based on what the data says.+ """+ fd, tmp_path = tempfile.mkstemp(suffix=".json")+ os.close(fd)+ full_args = list(args) + ["--output", tmp_path]+ try:+ proc = subprocess.run(+ [cli_exe] + full_args,+ capture_output=True,+ text=True,+ timeout=timeout_secs,+ )+ out_file = Path(tmp_path)+ data: Any = None+ if out_file.exists() and out_file.stat().st_size > 0:+ try:+ with open(tmp_path, "r", encoding="utf-8") as f:+ data = json.load(f)+ except json.JSONDecodeError as e:+ return {+ "ok": False,+ "exitCode": proc.returncode,+ "stdout": proc.stdout,+ "stderr": proc.stderr,+ "_error": f"could not parse kicad-cli JSON output: {e}",+ }+ return {+ "ok": True,+ "exitCode": proc.returncode,+ "stdout": proc.stdout,+ "stderr": proc.stderr,+ "data": data,+ }+ except subprocess.TimeoutExpired:+ return {+ "ok": False,+ "_error": f"kicad-cli timed out after {timeout_secs}s",+ "_hint": "Bump the timeout, or the board/schematic may be pathologically large.",+ }+ except Exception as e: # pylint: disable=broad-except+ return {+ "ok": False,+ "_error": f"kicad-cli invocation failed: {type(e).__name__}: {e}",+ }+ finally:+ try:+ Path(tmp_path).unlink(missing_ok=True)+ except OSError:+ pass+++def _parse_violations(data: dict | None) -> tuple[list[dict], dict]:+ """Extract violation list + per-severity counts from DRC/ERC JSON.++ Returns (violations[], counts{error,warning,info,exclusion}).+ """+ if not data or not isinstance(data, dict):+ return [], {"error": 0, "warning": 0, "info": 0, "exclusion": 0}+ out: list[dict] = []+ counts = {"error": 0, "warning": 0, "info": 0, "exclusion": 0}+ # `violations` is the standard list. `unconnected_items` is a DRC-only+ # sibling that surfaces ratlines kicad-cli reports separately.+ for bucket_key, bucket_label in (("violations", None), ("unconnected_items", "unconnected_items")):+ for v in data.get(bucket_key, []):+ row = {+ "type": v.get("type", bucket_label or "unknown"),+ "severity": v.get("severity", "warning"),+ "description": v.get("description", ""),+ "items": [],+ }+ for item in v.get("items", []):+ row["items"].append({+ "description": item.get("description", ""),+ "pos": item.get("pos", {}),+ })+ out.append(row)+ sev = row["severity"]+ counts[sev] = counts.get(sev, 0) + 1+ return out, counts+++def _cli_exe_or_error(kicad_info: dict) -> tuple[str | None, dict | None]:+ cli = kicad_info.get("kicad_cli_exe")+ if not cli or not Path(cli).exists():+ return None, {+ "success": False,+ "error": "kicad-cli not found",+ "_hint": "Run kicad_list_versions to check the install. If kicad_cli_exe is missing, the user should reinstall KiCad — kicad-cli ships in the same Bin dir as kicad.exe.",+ }+ return cli, None+++# ── handle_run_erc ──────────────────────────────────────────────────++def handle_run_erc(kicad_info: dict, args: dict) -> dict:+ """Run ERC on a .kicad_sch via `kicad-cli sch erc --format json`.++ Mirrors handle_run_drc. Args: {filePath}. Returns the same structured+ shape as run_drc: {success, output, data:{violations, summary,+ source_file}}.++ This was missing as a top-level verb prior to v1.7.12 — schematic+ designers had no equivalent of `kicad_run_drc` for catching wiring+ errors / missing labels / un-driven nets / etc. before opening the+ GUI.+ """+ if not kicad_info.get("installed"):+ return {"success": False, "error": "KiCad not installed",+ "_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."}+ file_path = args.get("filePath", "")+ if not file_path:+ return {"success": False, "error": "filePath is required",+ "_hint": "Pass {\"filePath\":\"<absolute path to a .kicad_sch>\"}."}+ sch_path = Path(file_path)+ sniff = _file_format_sniff(sch_path, "kicad_sch")+ if not sniff.get("ok"):+ return {"success": False, **sniff}++ cli, err = _cli_exe_or_error(kicad_info)+ if err:+ return err++ res = _run_kicad_cli_json(cli, [+ "sch", "erc",+ "--format", "json",+ "--severity-all",+ str(sch_path),+ ])+ if not res.get("ok"):+ return {+ "success": False,+ "error": res.get("_error", "kicad-cli failed"),+ "stdout": res.get("stdout"),+ "stderr": res.get("stderr"),+ "_hint": "If exitCode is nonzero with no parsed data, KiCad's CLI couldn't even open the file — fix the file before retrying.",+ }+ violations, counts = _parse_violations(res.get("data"))+ total = len(violations)+ return {+ "success": True,+ "output": f"ERC complete: {counts.get('error', 0)} error(s), {counts.get('warning', 0)} warning(s), {total} total",+ "data": {+ "violations": violations,+ "summary": {+ "total": total,+ "errors": counts.get("error", 0),+ "warnings": counts.get("warning", 0),+ "info": counts.get("info", 0),+ },+ "fileFormat": {+ "expected": "kicad_sch",+ "fileVersion": sniff.get("fileVersion"),+ },+ "source_file": str(sch_path),+ },+ "_hint": (+ "ERC passed cleanly — safe to open this schematic in eeschema for further editing."+ if counts.get("error", 0) == 0+ else+ f"{counts.get('error', 0)} ERC error(s). Common causes: unconnected pins (add a NoConnect flag if intentional), "+ "duplicate references (run Annotate Schematic), hierarchical-label mismatches, mixed input/output drivers on the "+ "same net. Inspect violations[].description for specifics. Fix in eeschema (kicad_open_schematic) before running "+ "DRC on the corresponding board."+ ),+ }+++# ── handle_lint_board ───────────────────────────────────────────────++def handle_lint_board(kicad_info: dict, args: dict) -> dict:+ """Comprehensive pre-flight lint for a .kicad_pcb. Combines:+ - File-existence + magic-byte check+ - DRC with --severity-all + --all-track-errors+ - Schematic parity (DRC --schematic-parity) — catches board-vs-+ schematic drift, a common bug class that's hard to spot from+ the PCB alone.++ Returns a single structured payload designed for AI consumption:+ `{success, fileFormat, drc:{violations, summary}, _hint}`.++ Distinct from `kicad_run_drc`:+ - run_drc is the existing minimal wrapper around `pcb drc`.+ - lint_board adds the file-format sanity layer, enables schematic-+ parity (which run_drc didn't), and returns a single AI-friendly+ hint string that EXPLICITLY tells the calling AI what to do+ next (proceed / fix violations / upgrade file / etc.).+ """+ if not kicad_info.get("installed"):+ return {"success": False, "error": "KiCad not installed",+ "_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."}+ file_path = args.get("filePath", "")+ if not file_path:+ return {"success": False, "error": "filePath is required",+ "_hint": "Pass {\"filePath\":\"<absolute path to a .kicad_pcb>\"}."}+ pcb_path = Path(file_path)+ sniff = _file_format_sniff(pcb_path, "kicad_pcb")+ if not sniff.get("ok"):+ return {"success": False, **sniff}++ cli, err = _cli_exe_or_error(kicad_info)+ if err:+ return err++ schematic_parity = bool(args.get("schematicParity", True))+ cli_args = [+ "pcb", "drc",+ "--format", "json",+ "--severity-all",+ "--all-track-errors",+ str(pcb_path),+ ]+ if schematic_parity:+ # Only meaningful if the matching .kicad_sch exists in the project dir+ cli_args.insert(-1, "--schematic-parity")+ res = _run_kicad_cli_json(cli, cli_args)+ if not res.get("ok"):+ return {+ "success": False,+ "error": res.get("_error", "kicad-cli failed"),+ "stdout": res.get("stdout"),+ "stderr": res.get("stderr"),+ "fileFormat": {+ "expected": "kicad_pcb",+ "fileVersion": sniff.get("fileVersion"),+ },+ "_hint": (+ "If exitCode is nonzero and stderr mentions 'older format', the file needs upgrading — "+ "`kicad-cli pcb upgrade` (or just opening it in KiCad and saving) does this. "+ "If schematic-parity failed because no matching .kicad_sch was found in the project dir, "+ "retry with schematicParity:false."+ ),+ }++ violations, counts = _parse_violations(res.get("data"))+ total = len(violations)+ errors = counts.get("error", 0)+ warnings = counts.get("warning", 0)++ # Build a tiered _hint so the AI knows what to do next+ if errors == 0 and warnings == 0:+ hint = "✅ DRC clean. Safe to open kicad_open_board, generate gerbers via export, or hand off to fabrication. No human intervention needed."+ elif errors == 0:+ hint = (f"⚠️ {warnings} DRC warning(s), 0 errors. The board is fabricable as-is but the warnings may "+ "indicate sloppy design (silkscreen overlapping pads, courtyard violations, etc.). "+ "Review violations[].description and decide if any need fixing. Safe to proceed with kicad_open_board.")+ else:+ # Group failure types for the hint+ types = sorted({v["type"] for v in violations if v["severity"] == "error"})[:5]+ types_str = ", ".join(types) if types else "various"+ hint = (f"❌ {errors} DRC error(s) + {warnings} warning(s). Top error types: {types_str}. "+ "DO NOT proceed to fabrication. Open kicad_open_board and resolve these in the PCB Editor, "+ "OR fix the underlying schematic if these are schematic-parity errors (e.g. nets connected on the schematic "+ "but unrouted on the board). Re-run kicad_lint_board after fixing to confirm clean.")++ return {+ "success": True,+ "output": f"Lint complete: {errors} error(s), {warnings} warning(s), {total} total",+ "data": {+ "violations": violations,+ "summary": {+ "total": total,+ "errors": errors,+ "warnings": warnings,+ "info": counts.get("info", 0),+ },+ "fileFormat": {+ "expected": "kicad_pcb",+ "fileVersion": sniff.get("fileVersion"),+ },+ "schematicParityChecked": schematic_parity,+ "source_file": str(pcb_path),+ },+ "_hint": hint,+ }+++# ── handle_lint_schematic ───────────────────────────────────────────++def handle_lint_schematic(kicad_info: dict, args: dict) -> dict:+ """Comprehensive pre-flight lint for a .kicad_sch.++ File-existence + magic-byte check + ERC with --severity-all. Same+ AI-friendly hint structure as kicad_lint_board.+ """+ if not kicad_info.get("installed"):+ return {"success": False, "error": "KiCad not installed",+ "_hint": "Ask the user to install KiCad from https://www.kicad.org/download/."}+ file_path = args.get("filePath", "")+ if not file_path:+ return {"success": False, "error": "filePath is required",+ "_hint": "Pass {\"filePath\":\"<absolute path to a .kicad_sch>\"}."}+ sch_path = Path(file_path)+ sniff = _file_format_sniff(sch_path, "kicad_sch")+ if not sniff.get("ok"):+ return {"success": False, **sniff}++ cli, err = _cli_exe_or_error(kicad_info)+ if err:+ return err++ res = _run_kicad_cli_json(cli, [+ "sch", "erc",+ "--format", "json",+ "--severity-all",+ str(sch_path),+ ])+ if not res.get("ok"):+ return {+ "success": False,+ "error": res.get("_error", "kicad-cli failed"),+ "stdout": res.get("stdout"),+ "stderr": res.get("stderr"),+ "fileFormat": {+ "expected": "kicad_sch",+ "fileVersion": sniff.get("fileVersion"),+ },+ "_hint": "If exitCode is nonzero and stderr mentions an older format, run `kicad-cli sch upgrade` (or open + save in eeschema).",+ }++ violations, counts = _parse_violations(res.get("data"))+ total = len(violations)+ errors = counts.get("error", 0)+ warnings = counts.get("warning", 0)++ if errors == 0 and warnings == 0:+ hint = "✅ ERC clean. Safe to open kicad_open_schematic or move on to PCB layout."+ elif errors == 0:+ hint = (f"⚠️ {warnings} ERC warning(s), 0 errors. Common warnings: pin-type mismatch on connections, "+ "missing power flags. Inspect violations[].description and decide. Schematic is functionally OK.")+ else:+ types = sorted({v["type"] for v in violations if v["severity"] == "error"})[:5]+ types_str = ", ".join(types) if types else "various"+ hint = (f"❌ {errors} ERC error(s) + {warnings} warning(s). Top error types: {types_str}. "+ "Common causes: unconnected pins (add NoConnect flag), duplicate references (run Annotate Schematic), "+ "hierarchical-label mismatches, no-driver on a net (e.g. an input pin with no source). "+ "Fix in eeschema via kicad_open_schematic, then re-run kicad_lint_schematic to confirm.")++ return {+ "success": True,+ "output": f"Lint complete: {errors} error(s), {warnings} warning(s), {total} total",+ "data": {+ "violations": violations,+ "summary": {+ "total": total,+ "errors": errors,+ "warnings": warnings,+ "info": counts.get("info", 0),+ },+ "fileFormat": {+ "expected": "kicad_sch",+ "fileVersion": sniff.get("fileVersion"),+ },+ "source_file": str(sch_path),+ },+ "_hint": hint,+ }+++# ── handle_lint_library ─────────────────────────────────────────────++# Magic patterns for library files+_LIB_MAGIC = {+ "kicad_sym": ("(kicad_symbol_lib",),+ "kicad_mod": ("(footprint", "(module",), # KiCad ≤6 used "module"+}+++def _parse_kicad_sym(path: Path) -> dict:+ """Parse a .kicad_sym file. Returns:+ {symbolCount, symbols:[{name, hasPins, unitCount?, ...}],+ fileVersion, generator, _warnings:[...]}.++ Uses a regex over the file (s-expr parser would be cleaner but+ KiCad's symbol files have predictable top-level structure that's+ easy to grep — same approach kicad_detect.py uses for adomLibrary)."""+ import re+ out: dict = {+ "symbolCount": 0,+ "symbols": [],+ "fileVersion": None,+ "generator": None,+ "_warnings": [],+ }+ try:+ text = path.read_text(encoding="utf-8", errors="replace")+ except OSError as e:+ out["_warnings"].append(f"read failed: {e}")+ return out+ # File-format version+ m = re.search(r"\(version\s+(\d+)\)", text[:500])+ if m:+ out["fileVersion"] = int(m.group(1))+ m = re.search(r"\(generator\s+(?:\"([^\"]+)\"|(\S+))\)", text[:500])+ if m:+ out["generator"] = m.group(1) or m.group(2)++ # Top-level symbols: `(symbol "Name"` at the start of a line, NOT+ # nested unit variants (those have names like `R_0_0` or `R_1_1`).+ # The top-level `(symbol "Name"` lines have NO `_<digit>_<digit>` suffix.+ for sym_match in re.finditer(r'^\s*\(symbol\s+"([^"]+)"', text, re.MULTILINE):+ name = sym_match.group(1)+ # Skip nested unit variants like "R_0_0", "R_1_1"+ if re.search(r"_\d+_\d+$", name):+ continue+ # Snapshot a small window around this symbol to inspect pin count+ # and units. Find the matching closing paren — quick heuristic:+ # count up to ~5000 chars or until paren-balance reaches 0.+ start = sym_match.start()+ window = text[start:start + 5000]+ # Count pins (rough): `(pin <type> <shape>` lines belonging to+ # this symbol. May over-count if window slices into the next+ # symbol; for inventory purposes this is good enough.+ pin_count = len(re.findall(r"\(pin\s+\w+", window))+ # Unit count: look for `_<N>_<M>` subsymbol declarations+ units = set(re.findall(rf'\(symbol\s+"{re.escape(name)}_(\d+)_\d+"', text))+ out["symbols"].append({+ "name": name,+ "pinCount": pin_count,+ "unitCount": max(1, len(units)),+ })+ out["symbolCount"] = len(out["symbols"])+ return out+++def _parse_kicad_mod(path: Path) -> dict:+ """Parse a .kicad_mod file. Returns:+ {name, padCount, layers, fileVersion, generator, _warnings:[...]}.+ """+ import re+ out: dict = {+ "name": None,+ "padCount": 0,+ "layers": [],+ "fileVersion": None,+ "generator": None,+ "_warnings": [],+ }+ try:+ text = path.read_text(encoding="utf-8", errors="replace")+ except OSError as e:+ out["_warnings"].append(f"read failed: {e}")+ return out++ # Name: `(footprint "Name"` (KiCad 7+) or `(module Name` (KiCad ≤6)+ m = re.search(r'\(footprint\s+"([^"]+)"', text[:500])+ if m:+ out["name"] = m.group(1)+ else:+ m = re.search(r"\(module\s+(\S+)", text[:500])+ if m:+ out["name"] = m.group(1)+ # Version + generator+ m = re.search(r"\(version\s+(\d+)\)", text[:500])+ if m:+ out["fileVersion"] = int(m.group(1))+ m = re.search(r"\(generator\s+(?:\"([^\"]+)\"|(\S+))\)", text[:500])+ if m:+ out["generator"] = m.group(1) or m.group(2)+ # Pad count+ out["padCount"] = len(re.findall(r"\(pad\s", text))+ # Top-layers used by pads / drawings (rough)+ layer_strs = set(re.findall(r'\(layer\s+(?:"([^"]+)"|(\S+))\)', text))+ out["layers"] = sorted({a or b for a, b in layer_strs})[:10]+ return out+++def handle_lint_library(kicad_info: dict, args: dict) -> dict:+ """Validate a KiCad library file or directory. v1.7.13+.++ Inputs accepted:+ - Single `.kicad_sym` file → returns symbol inventory + file-version+ - Single `.kicad_mod` file → returns footprint metadata + version+ - `.pretty` directory → walks .kicad_mod files inside+ - Directory containing one or more `.kicad_sym` files → walks them++ Does NOT call kicad-cli (no --dry-run is available for sym/fp upgrade,+ and the upgrade commands actually rewrite files which we don't want+ during lint). Instead uses lightweight regex parsing — same approach+ as kicad_detect.py's adomLibrary inventory code.++ Returns a single AI-friendly structured response with a tiered `_hint`+ that tells the calling AI whether to proceed with install or fix the+ library first.+ """+ file_path = args.get("libraryPath", "") or args.get("filePath", "")+ if not file_path:+ return {+ "success": False,+ "error": "libraryPath is required",+ "_hint": "Pass {\"libraryPath\":\"<absolute path to .kicad_sym / .kicad_mod / .pretty dir>\"}.",+ }+ path = Path(file_path)+ if not path.exists():+ return {+ "success": False,+ "error": f"path 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.",+ }++ # Discover what kind of input this is+ files_found: dict = {"kicad_sym": [], "kicad_mod": []}+ kind = None+ if path.is_file():+ if path.suffix.lower() == ".kicad_sym":+ files_found["kicad_sym"].append(path)+ kind = "symbol_lib_file"+ elif path.suffix.lower() == ".kicad_mod":+ files_found["kicad_mod"].append(path)+ kind = "footprint_file"+ else:+ return {+ "success": False,+ "error": f"unsupported file extension: {path.suffix}",+ "_hint": "Pass a .kicad_sym, .kicad_mod, or a directory containing them (.pretty for footprint libraries).",+ }+ elif path.is_dir():+ if path.suffix.lower() == ".pretty" or path.name.endswith(".pretty"):+ kind = "footprint_lib_dir"+ files_found["kicad_mod"] = sorted(path.glob("*.kicad_mod"))+ else:+ kind = "mixed_dir"+ files_found["kicad_sym"] = sorted(path.glob("*.kicad_sym"))+ files_found["kicad_mod"] = sorted(path.glob("*.kicad_mod"))+ if not files_found["kicad_sym"] and not files_found["kicad_mod"]:+ return {+ "success": False,+ "error": f"directory contains no .kicad_sym or .kicad_mod files: {path}",+ "_hint": "Symbol libraries are single .kicad_sym files. Footprint libraries are .pretty/ directories containing .kicad_mod files.",+ }++ # Parse each file+ parsed_sym = []+ for f in files_found["kicad_sym"]:+ # Magic check+ try:+ with open(f, "rb") as fh:+ head = fh.read(200).decode("utf-8", errors="replace").lstrip()+ if not any(head.startswith(t) for t in _LIB_MAGIC["kicad_sym"]):+ parsed_sym.append({"path": str(f), "_error": "not a kicad_symbol_lib (bad magic)"})+ continue+ except OSError as e:+ parsed_sym.append({"path": str(f), "_error": f"read failed: {e}"})+ continue+ info = _parse_kicad_sym(f)+ info["path"] = str(f)+ parsed_sym.append(info)++ parsed_mod = []+ for f in files_found["kicad_mod"]:+ try:+ with open(f, "rb") as fh:+ head = fh.read(200).decode("utf-8", errors="replace").lstrip()+ if not any(head.startswith(t) for t in _LIB_MAGIC["kicad_mod"]):+ parsed_mod.append({"path": str(f), "_error": "not a kicad_mod (bad magic)"})+ continue+ except OSError as e:+ parsed_mod.append({"path": str(f), "_error": f"read failed: {e}"})+ continue+ info = _parse_kicad_mod(f)+ info["path"] = str(f)+ parsed_mod.append(info)++ # Summary numbers+ symbol_count = sum(s.get("symbolCount", 0) for s in parsed_sym if "_error" not in s)+ footprint_count = sum(1 for m in parsed_mod if "_error" not in m)+ sym_file_errors = [s for s in parsed_sym if "_error" in s]+ mod_file_errors = [m for m in parsed_mod if "_error" in m]++ # All file versions seen+ versions = set()+ for s in parsed_sym + parsed_mod:+ v = s.get("fileVersion")+ if v:+ versions.add(v)++ # Build tiered hint+ if sym_file_errors or mod_file_errors:+ hint = (+ f"❌ {len(sym_file_errors)} symbol file(s) + {len(mod_file_errors)} footprint file(s) "+ f"failed to parse. Inspect _errors[] for specifics. Common causes: file truncated mid-write, "+ f"wrong file type renamed with .kicad_sym extension, file from a much newer KiCad than installed. "+ f"DO NOT install via kicad_install_library / kicad_install_symbol / kicad_install_footprint — "+ f"the install will land broken files."+ )+ else:+ # Detect outdated format. KiCad 10 uses YYYYMMDD versions like+ # 20240108. Anything below 20200101 is KiCad 5-era, definitely+ # outdated. Versions in the 2023xxxx range are KiCad 7-era and+ # may still load but warrant a `kicad_format_upgrade` first.+ outdated = sorted([v for v in versions if v < 20231101])+ if outdated:+ hint = (+ f"⚠️ {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, "+ f"BUT file-format version(s) {outdated} are pre-KiCad-7. KiCad 10 can read these but "+ f"will silently upgrade them on first save. To migrate proactively, call "+ f"kicad_format_upgrade (or just run `kicad-cli sym/fp upgrade` manually). "+ f"OK to proceed with install but expect a one-time format change."+ )+ else:+ hint = (+ f"✅ {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, "+ f"format version(s) {sorted(versions) if versions else 'unspecified'}. Safe to install via "+ f"kicad_install_library / kicad_install_symbol / kicad_install_footprint."+ )++ return {+ "success": True,+ "output": f"Lint: {symbol_count} symbol(s), {footprint_count} footprint(s), "+ f"{len(sym_file_errors) + len(mod_file_errors)} file error(s)",+ "data": {+ "kind": kind,+ "source_path": str(path),+ "summary": {+ "symbolCount": symbol_count,+ "footprintCount": footprint_count,+ "symFileErrorCount": len(sym_file_errors),+ "modFileErrorCount": len(mod_file_errors),+ "fileFormatVersions": sorted(versions),+ },+ "symbols": parsed_sym,+ "footprints": parsed_mod,+ },+ "_hint": hint,+ }+++# ── handle_format_upgrade ───────────────────────────────────────────++def handle_format_upgrade(kicad_info: dict, args: dict) -> dict:+ """Wraps `kicad-cli {pcb,sch,sym,fp} upgrade` to migrate older+ KiCad files to the current format. v1.7.13+.++ Args:+ filePath: required — file or directory to upgrade+ kind: optional — "pcb" | "sch" | "sym" | "fp". Auto-detected+ from extension or directory contents if omitted.+ force: optional bool (default false) — passes `--force` to+ kicad-cli, which resaves the file even if it's already+ at the current version. Useful for round-tripping any+ quirks the importer fixed.++ WARNING: This MUTATES the file in place (kicad-cli has no --dry-run).+ Callers should expect the file to be rewritten. If you want+ inspection without write, call kicad_lint_library first.+ """+ if not kicad_info.get("installed"):+ return {"success": False, "error": "KiCad not installed",+ "_hint": "Ask the user to install KiCad."}+ file_path = args.get("filePath", "")+ if not file_path:+ return {"success": False, "error": "filePath is required",+ "_hint": "Pass {\"filePath\":\"<path>\", \"kind\":\"pcb|sch|sym|fp\"}."}+ path = Path(file_path)+ if not path.exists():+ return {"success": False, "error": f"path not found: {file_path}",+ "_hint": "Verify the path; use push_file from Docker if needed."}++ # Auto-detect kind if not given+ kind = (args.get("kind") or "").lower()+ if not kind:+ if path.is_file():+ if path.suffix == ".kicad_pcb":+ kind = "pcb"+ elif path.suffix == ".kicad_sch":+ kind = "sch"+ elif path.suffix == ".kicad_sym":+ kind = "sym"+ elif path.suffix == ".kicad_mod":+ kind = "fp"+ elif path.is_dir():+ # .pretty dir = footprint lib; dir with .kicad_sym = sym lib+ if path.suffix.lower() == ".pretty" or any(path.glob("*.kicad_mod")):+ kind = "fp"+ elif any(path.glob("*.kicad_sym")):+ kind = "sym"+ if kind not in ("pcb", "sch", "sym", "fp"):+ return {+ "success": False,+ "error": f"could not auto-detect kind for {file_path}",+ "_hint": "Pass kind explicitly: \"pcb\" / \"sch\" / \"sym\" / \"fp\".",+ }++ cli, err = _cli_exe_or_error(kicad_info)+ if err:+ return err++ cli_args = [kind, "upgrade"]+ if args.get("force"):+ cli_args.append("--force")+ cli_args.append(str(path))++ try:+ proc = subprocess.run(+ [cli] + cli_args,+ capture_output=True,+ text=True,+ timeout=120,+ )+ except subprocess.TimeoutExpired:+ return {"success": False, "error": "kicad-cli upgrade timed out after 120s",+ "_hint": "Likely a huge directory of footprints. Re-run with a single file at a time."}+ except Exception as e: # pylint: disable=broad-except+ return {"success": False, "error": f"{type(e).__name__}: {e}"}++ success = proc.returncode == 0+ return {+ "success": success,+ "exitCode": proc.returncode,+ "kind": kind,+ "stdout": proc.stdout,+ "stderr": proc.stderr,+ "source_path": str(path),+ "_hint": (+ f"✅ kicad-cli {kind} upgrade completed. File(s) rewritten to current KiCad format. "+ f"Any open KiCad GUI windows with this file loaded should be closed + reopened — they "+ f"have the OLD in-memory state. Re-run kicad_lint_{kind if kind in ('board','schematic') else 'library'} "+ f"to confirm the post-upgrade file is clean."+ if success else+ f"❌ kicad-cli {kind} upgrade failed with exitCode={proc.returncode}. "+ f"stderr likely names the offending file or syntax issue. If it's a 'file format too new' "+ f"error, the user's KiCad install is OLDER than the source file — surface to user. "+ f"If 'file already up to date', pass force:true to resave anyway."+ ),+ }
handlers/kicad_ui.pyadded+674@@ -0,0 +1,674 @@+"""Win32-based tools for interacting with KiCad's UI.++Windows: provides screenshot, click, and keyboard input against KiCad windows+(project manager, eeschema, pcbnew, footprint editor, symbol editor, 3D viewer,+and their dialogs). Mirrors the Fusion 360 UI handler at+plugins/fusion360/handlers/fusion_ui.py.++macOS: handlers return "not implemented" responses. Faithful UI scripting+against another app on macOS requires Accessibility-framework permission,+which we don't request. The screenshot path is handled by the Tauri app's+CoreGraphics implementation in src-tauri/src/screenshot/macos.rs instead.+"""++import ctypes+import ctypes.wintypes+import os+import struct+import sys+import time++IS_WINDOWS = sys.platform == "win32"+IS_MACOS = sys.platform == "darwin"++# ctypes.Structure / Union / WINFUNCTYPE are cross-platform; only ctypes.windll+# is Windows-only. So the module-level INPUT/MOUSEINPUT/etc. structs below+# load fine on macOS — they just never get used because handlers branch+# at the entry point.+if IS_WINDOWS:+ user32 = ctypes.windll.user32+ gdi32 = ctypes.windll.gdi32++ # Enable DPI awareness so GetWindowRect returns physical pixel coordinates+ try:+ ctypes.windll.shcore.SetProcessDpiAwareness(2)+ except Exception:+ try:+ user32.SetProcessDPIAware()+ except Exception:+ pass+else:+ # Sentinels so module-level expressions referencing user32/gdi32 don't+ # NameError. Helper functions that touch these are guarded by IS_WINDOWS+ # at call time via the handler entry points.+ user32 = None+ gdi32 = None++# --- Constants ---++BI_RGB = 0+DIB_RGB_COLORS = 0+GW_OWNER = 4+PW_RENDERFULLCONTENT = 0x00000002++INPUT_MOUSE = 0+INPUT_KEYBOARD = 1+MOUSEEVENTF_ABSOLUTE = 0x8000+MOUSEEVENTF_MOVE = 0x0001+MOUSEEVENTF_LEFTDOWN = 0x0002+MOUSEEVENTF_LEFTUP = 0x0004+KEYEVENTF_KEYUP = 0x0002++VK_MAP = {+ "enter": 0x0D, "return": 0x0D,+ "escape": 0x1B, "esc": 0x1B,+ "tab": 0x09,+ "space": 0x20,+ "up": 0x26, "down": 0x28, "left": 0x25, "right": 0x27,+ "backspace": 0x08, "delete": 0x2E,+ "home": 0x24, "end": 0x23,+ "pageup": 0x21, "pagedown": 0x22,+ "f1": 0x70, "f2": 0x71, "f3": 0x72, "f4": 0x73,+ "f5": 0x74, "f6": 0x75, "f7": 0x76, "f8": 0x77,+ "f9": 0x78, "f10": 0x79, "f11": 0x7A, "f12": 0x7B,+}++SCREENSHOT_DIR = "C:/tmp/adom-desktop-screenshots"++# KiCad window title keywords (wxWidgets windows use class wxWindowNR)+KICAD_TITLE_KEYWORDS = [+ "KiCad", "Symbol Editor", "Footprint Editor", "3D Viewer",+ "eeschema", "pcbnew", "Schematic Editor", "PCB Editor",+]++# Keep callback references alive to prevent GC+_callbacks = []+++# --- SendInput structures ---++class MOUSEINPUT(ctypes.Structure):+ _fields_ = [+ ("dx", ctypes.wintypes.LONG),+ ("dy", ctypes.wintypes.LONG),+ ("mouseData", ctypes.wintypes.DWORD),+ ("dwFlags", ctypes.wintypes.DWORD),+ ("time", ctypes.wintypes.DWORD),+ ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),+ ]+++class KEYBDINPUT(ctypes.Structure):+ _fields_ = [+ ("wVk", ctypes.wintypes.WORD),+ ("wScan", ctypes.wintypes.WORD),+ ("dwFlags", ctypes.wintypes.DWORD),+ ("time", ctypes.wintypes.DWORD),+ ("dwExtraInfo", ctypes.POINTER(ctypes.c_ulong)),+ ]+++class _INPUT_UNION(ctypes.Union):+ _fields_ = [+ ("mi", MOUSEINPUT),+ ("ki", KEYBDINPUT),+ ]+++class INPUT(ctypes.Structure):+ _fields_ = [+ ("type", ctypes.wintypes.DWORD),+ ("union", _INPUT_UNION),+ ]+++def _send_input(*inputs):+ """Send one or more INPUT structures via SendInput."""+ n = len(inputs)+ arr = (INPUT * n)(*inputs)+ user32.SendInput(n, ctypes.pointer(arr), ctypes.sizeof(INPUT))+++# --- Window enumeration ---++def _get_window_title(hwnd) -> str:+ length = user32.GetWindowTextLengthW(hwnd)+ if length <= 0:+ return ""+ buf = ctypes.create_unicode_buffer(length + 1)+ user32.GetWindowTextW(hwnd, buf, length + 1)+ return buf.value+++def _get_class_name(hwnd) -> str:+ buf = ctypes.create_unicode_buffer(256)+ user32.GetClassNameW(hwnd, buf, 256)+ return buf.value+++def _get_window_rect(hwnd) -> dict:+ rect = ctypes.wintypes.RECT()+ user32.GetWindowRect(hwnd, ctypes.byref(rect))+ return {+ "left": rect.left, "top": rect.top,+ "right": rect.right, "bottom": rect.bottom,+ "width": rect.right - rect.left,+ "height": rect.bottom - rect.top,+ }+++def _find_all_kicad_windows() -> list:+ """Find all visible KiCad-related windows.++ Returns list of dicts with hwnd, title, className, rect, ownerHwnd.+ """+ results = []+ seen = set()+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def callback(hwnd, _lparam):+ if not user32.IsWindowVisible(hwnd):+ return True+ title = _get_window_title(hwnd)+ class_name = _get_class_name(hwnd)++ # Match KiCad windows by title keywords or wxWindowNR class with owner+ is_kicad = any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS)++ # Also catch #32770 (Win32 dialog) windows owned by KiCad+ if not is_kicad and class_name == "#32770":+ owner = user32.GetWindow(hwnd, GW_OWNER)+ if owner and owner not in seen:+ # Check if owner is a KiCad window+ owner_title = _get_window_title(owner)+ is_kicad = any(kw.lower() in owner_title.lower() for kw in KICAD_TITLE_KEYWORDS)++ if is_kicad and hwnd not in seen:+ seen.add(hwnd)+ owner = user32.GetWindow(hwnd, GW_OWNER)+ results.append({+ "hwnd": hwnd,+ "title": title,+ "className": class_name,+ "rect": _get_window_rect(hwnd),+ "ownerHwnd": owner if owner else 0,+ })+ return True++ cb = WNDENUMPROC(callback)+ _callbacks.append(cb)+ user32.EnumWindows(cb, 0)+ _callbacks.remove(cb)+ return results+++def _find_foreground_kicad_hwnd() -> int:+ """Find the foreground KiCad window, or the first KiCad window found."""+ fg = user32.GetForegroundWindow()+ if fg:+ title = _get_window_title(fg)+ if any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS):+ return fg++ # Fallback: first KiCad window+ windows = _find_all_kicad_windows()+ if windows:+ return windows[0]["hwnd"]+ return 0+++# --- Screenshot ---++def _write_bmp(path: str, width: int, height: int, pixel_data: bytes):+ """Write a BMP file from raw BGR pixel data (bottom-up)."""+ row_size_24 = (width * 3 + 3) & ~3+ pixel_size_24 = row_size_24 * height+ file_size = 14 + 40 + pixel_size_24++ with open(path, "wb") as f:+ f.write(b"BM")+ f.write(struct.pack("<I", file_size))+ f.write(struct.pack("<HH", 0, 0))+ f.write(struct.pack("<I", 14 + 40))+ f.write(struct.pack("<I", 40))+ f.write(struct.pack("<i", width))+ f.write(struct.pack("<i", height))+ f.write(struct.pack("<HH", 1, 24))+ f.write(struct.pack("<I", BI_RGB))+ f.write(struct.pack("<I", pixel_size_24))+ f.write(struct.pack("<ii", 2835, 2835))+ f.write(struct.pack("<II", 0, 0))++ src_row_size = width * 4+ for y in range(height):+ row_start = y * src_row_size+ row_24 = bytearray()+ for x in range(width):+ px = row_start + x * 4+ row_24 += pixel_data[px:px + 3]+ padding = row_size_24 - len(row_24)+ if padding > 0:+ row_24 += b"\x00" * padding+ f.write(bytes(row_24))+++def _screenshot_hwnd(hwnd: int, label: str = "") -> dict:+ """Capture a window by HWND using PrintWindow. Returns {success, savedTo, sizeKB}."""+ rect = _get_window_rect(hwnd)+ width = rect["width"]+ height = rect["height"]++ if width <= 0 or height <= 0:+ return {"success": False, "error": f"Invalid rect for hwnd {hwnd}"}++ hdc_screen = user32.GetDC(hwnd)+ hdc_mem = gdi32.CreateCompatibleDC(hdc_screen)+ hbmp = gdi32.CreateCompatibleBitmap(hdc_screen, width, height)+ old_bmp = gdi32.SelectObject(hdc_mem, hbmp)+ user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)++ class BITMAPINFOHEADER(ctypes.Structure):+ _fields_ = [+ ("biSize", ctypes.wintypes.DWORD),+ ("biWidth", ctypes.wintypes.LONG),+ ("biHeight", ctypes.wintypes.LONG),+ ("biPlanes", ctypes.wintypes.WORD),+ ("biBitCount", ctypes.wintypes.WORD),+ ("biCompression", ctypes.wintypes.DWORD),+ ("biSizeImage", ctypes.wintypes.DWORD),+ ("biXPelsPerMeter", ctypes.wintypes.LONG),+ ("biYPelsPerMeter", ctypes.wintypes.LONG),+ ("biClrUsed", ctypes.wintypes.DWORD),+ ("biClrImportant", ctypes.wintypes.DWORD),+ ]++ bmi = BITMAPINFOHEADER()+ bmi.biSize = ctypes.sizeof(BITMAPINFOHEADER)+ bmi.biWidth = width+ bmi.biHeight = height+ bmi.biPlanes = 1+ bmi.biBitCount = 32+ bmi.biCompression = BI_RGB++ buf_size = width * height * 4+ pixel_buf = ctypes.create_string_buffer(buf_size)+ gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)++ gdi32.SelectObject(hdc_mem, old_bmp)+ gdi32.DeleteObject(hbmp)+ gdi32.DeleteDC(hdc_mem)+ user32.ReleaseDC(hwnd, hdc_screen)++ os.makedirs(SCREENSHOT_DIR, exist_ok=True)+ timestamp = int(time.time() * 1000)+ # Slugify the label so filenames have NO spaces/punctuation (clean for a+ # cloud caller to pull). v0.8.8: "pcb_editor pic_programmer Read Only" -> "pcb-editor-pic-programmer-read-only".+ slug = "".join(c if (c.isalnum() or c in "._-") else "-" for c in (label or "")).strip("-").lower()+ while "--" in slug:+ slug = slug.replace("--", "-")+ suffix = f"-{slug}" if slug else ""++ # v0.8.8: default to PNG (container-side callers want PNG; was lossless WEBP,+ # which forced a host-side pull + convert). WEBP only on explicit fmt="webp".+ try:+ from PIL import Image+ img = Image.frombytes("RGBA", (width, height), pixel_buf.raw, "raw", "BGRA")+ img = img.transpose(Image.FLIP_TOP_BOTTOM)+ MAX_DIM = 1568+ if max(width, height) > MAX_DIM:+ ratio = MAX_DIM / max(width, height)+ img = img.resize((int(width * ratio), int(height * ratio)), Image.LANCZOS)+ path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.png")+ img.save(path, "PNG", optimize=True)+ except ImportError:+ path = os.path.join(SCREENSHOT_DIR, f"kicad{suffix}-{timestamp}.bmp")+ _write_bmp(path, width, height, pixel_buf.raw)++ size_kb = os.path.getsize(path) / 1024+ posix = path.replace("\\", "/")+ return {+ "success": True,+ # `fullPath` is the field the CLI's universal screenshot auto-pull keys on —+ # it copies the host file to the cloud container's /tmp/ad-shots/ and adds+ # localFullPath, so a cloud caller can Read it directly (no pull_file hop).+ # `savedTo` kept for back-compat.+ "savedTo": posix,+ "fullPath": posix,+ "sizeKB": round(size_kb, 1),+ }+++# --- Public handler functions ---++def handle_screenshot_all(kicad_info: dict, args: dict) -> dict:+ """Screenshot all KiCad windows (main + editors + dialogs).++ Windows: enumerates windows via Win32 and captures each via PrintWindow.+ macOS: not implemented at the plugin level. The Tauri app exposes+ desktop_screenshot_screen / desktop_screenshot_window via CoreGraphics —+ callers should use those instead.+ """+ if IS_MACOS:+ return {+ "success": False,+ "error": "kicad_screenshot_all is not implemented on macOS",+ "_hint": ("Use desktop_screenshot_screen or desktop_screenshot_window "+ "(via the Tauri app) — those use CoreGraphics and capture "+ "any visible window including KiCad."),+ }+ if not IS_WINDOWS:+ return {"success": False, "error": f"screenshot_all not implemented on {sys.platform!r}"}++ from handlers.close_windows import handle_window_info++ info = handle_window_info(kicad_info, {})+ data = info.get("data", {})++ # Collect windows in order: editors first, project manager, dialogs last+ windows = []+ for ed in data.get("editors", []):+ windows.append({"hwnd": ed["hwnd"], "title": ed["title"], "type": "editor"})+ pm = data.get("projectManager")+ if pm:+ windows.append({"hwnd": pm["hwnd"], "title": pm["title"], "type": "main"})+ for dlg in data.get("modalDialogs", []):+ windows.append({"hwnd": dlg["hwnd"], "title": dlg["title"], "type": "dialog"})++ if not windows:+ return {"success": False, "error": "No KiCad windows found."}++ screenshots = []+ for w in windows:+ hwnd = w["hwnd"]+ title = w["title"]+ win_type = w["type"]++ # Sanitize title for filename+ safe_title = "".join(c if c.isalnum() or c in "-_ " else "" for c in title)[:40].strip()+ label = f"{win_type}-{safe_title}" if safe_title else win_type++ result = _screenshot_hwnd(hwnd, label)+ entry = {+ "type": win_type,+ "title": title,+ "hwnd": hwnd,+ }+ if result.get("success"):+ entry["savedTo"] = result["savedTo"]+ entry["fullPath"] = result.get("fullPath", result["savedTo"])+ entry["sizeKB"] = result["sizeKB"]+ else:+ entry["error"] = result.get("error", "screenshot failed")++ screenshots.append(entry)++ return {+ "success": True,+ "output": f"Captured {len(screenshots)} KiCad window(s)",+ "data": {"screenshots": screenshots},+ }+++# v0.7.1+: modifier VKs for combo parsing in handle_send_key. The four standard+# Windows modifiers; combos like "ctrl+shift+f10" or "alt+3" decompose into a+# list of these wrapping the base key.+_MODIFIER_VK = {+ "ctrl": 0x11, "control": 0x11,+ "alt": 0x12, "menu": 0x12,+ "shift": 0x10,+ "win": 0x5B, "super": 0x5B, "lwin": 0x5B,+}+++def _parse_modifier_combo(key: str) -> tuple[list[int], str] | None:+ """Parse a 'ctrl+alt+f10' style combo.++ Returns (modifier_vks, base_key) where base_key is the trailing token after+ the last "+". Modifiers must come BEFORE the base key. Returns None if `key`+ isn't a combo (no "+" or only one token).+ """+ if "+" not in key:+ return None+ tokens = [t.strip().lower() for t in key.split("+") if t.strip()]+ if len(tokens) < 2:+ return None+ *mods, base = tokens+ mod_vks: list[int] = []+ for m in mods:+ vk = _MODIFIER_VK.get(m)+ if vk is None:+ # Not a known modifier — bail and let the caller treat the whole+ # thing as an unknown key.+ return None+ mod_vks.append(vk)+ return mod_vks, base+++def handle_send_key(kicad_info: dict, args: dict) -> dict:+ """Send a keystroke to the foreground KiCad window.++ Supports modifier-combo syntax (v0.7.1+): "ctrl+s", "alt+3", "shift+f10",+ "ctrl+shift+s". Modifiers: ctrl/control, alt/menu, shift, win/super/lwin.+ The base key after the last "+" is parsed via the same VK_MAP / single-char+ path as standalone keys.++ Windows-only: macOS UI scripting against another app requires Accessibility+ permission, which we don't request. Ask the user to interact directly.+ """+ if not IS_WINDOWS:+ return {+ "success": False,+ "error": "kicad_send_key is not implemented on macOS",+ "_hint": ("On macOS, ask the user to press the key in the KiCad window "+ "directly. UI scripting needs Accessibility permission."),+ }++ key = args.get("key", "").strip()+ if not key:+ return {"success": False, "error": "Missing required arg: key"}++ target_hwnd = args.get("hwnd")+ if target_hwnd:+ target_hwnd = int(target_hwnd)+ else:+ target_hwnd = _find_foreground_kicad_hwnd()++ if not target_hwnd:+ return {"success": False, "error": "No KiCad window found to send key to."}++ title = _get_window_title(target_hwnd)++ # Bring to front+ user32.SetForegroundWindow(target_hwnd)+ time.sleep(0.1)++ # v0.7.1+: detect modifier combos first. If "key" parses as one (e.g.+ # "alt+3"), wrap the base key in mod-down/up events. Falls through to the+ # original path if it's a single key or unknown-modifier combo.+ combo = _parse_modifier_combo(key)+ if combo is not None:+ mod_vks, base = combo+ # Resolve the base key to a VK via the same path as a standalone key.+ base_vk = VK_MAP.get(base)+ base_shift = False+ if base_vk is None and len(base) == 1:+ vk_scan = user32.VkKeyScanW(ord(base))+ base_vk = vk_scan & 0xFF+ base_shift = bool((vk_scan >> 8) & 0x01)+ if base_vk == 0xFF:+ return {"success": False, "error": f"Cannot map base character '{base}' to a virtual key."}+ if base_vk is None:+ return {"success": False, "error": f"Unknown base key '{base}' in combo '{key}'."}++ # Build the input sequence: ALL modifiers down, base down + up, modifiers up+ # (reverse order on the way up so the OS sees clean nesting).+ inputs: list[INPUT] = []+ # If the base char itself needs Shift AND Shift wasn't already in the combo,+ # add it. (Caller intent: "shift+s" already has shift; "alt+!" should add shift.)+ if base_shift and 0x10 not in mod_vks:+ mod_vks = mod_vks + [0x10]+ for vk in mod_vks:+ d = INPUT(); d.type = INPUT_KEYBOARD; d.union.ki.wVk = vk+ inputs.append(d)+ kd = INPUT(); kd.type = INPUT_KEYBOARD; kd.union.ki.wVk = base_vk+ inputs.append(kd)+ ku = INPUT(); ku.type = INPUT_KEYBOARD; ku.union.ki.wVk = base_vk+ ku.union.ki.dwFlags = KEYEVENTF_KEYUP+ inputs.append(ku)+ for vk in reversed(mod_vks):+ u = INPUT(); u.type = INPUT_KEYBOARD; u.union.ki.wVk = vk+ u.union.ki.dwFlags = KEYEVENTF_KEYUP+ inputs.append(u)++ _send_input(*inputs)+ return {+ "success": True,+ "output": f"Sent '{key}' to {title}",+ "data": {"key": key, "modifiers": mod_vks, "baseVk": base_vk,+ "baseShift": base_shift, "hwnd": target_hwnd, "title": title},+ }++ key_lower = key.lower().strip()+ vk = VK_MAP.get(key_lower)++ if vk is None:+ if len(key) == 1:+ vk_scan = user32.VkKeyScanW(ord(key))+ vk = vk_scan & 0xFF+ shift = (vk_scan >> 8) & 0x01+ if vk == 0xFF:+ return {"success": False, "error": f"Cannot map character '{key}' to a virtual key."}++ inputs = []+ if shift:+ s_down = INPUT()+ s_down.type = INPUT_KEYBOARD+ s_down.union.ki.wVk = 0x10+ inputs.append(s_down)++ kd = INPUT()+ kd.type = INPUT_KEYBOARD+ kd.union.ki.wVk = vk+ inputs.append(kd)++ ku = INPUT()+ ku.type = INPUT_KEYBOARD+ ku.union.ki.wVk = vk+ ku.union.ki.dwFlags = KEYEVENTF_KEYUP+ inputs.append(ku)++ if shift:+ s_up = INPUT()+ s_up.type = INPUT_KEYBOARD+ s_up.union.ki.wVk = 0x10+ s_up.union.ki.dwFlags = KEYEVENTF_KEYUP+ inputs.append(s_up)++ _send_input(*inputs)+ return {"success": True,+ "output": f"Sent '{key}' to {title}",+ "data": {"key": key, "vk": vk, "shift": bool(shift),+ "hwnd": target_hwnd, "title": title}}+ else:+ return {"success": False, "error": f"Unknown key: '{key}'. Use a named key (enter/escape/tab/space/f1-f12), a single character, or a modifier combo like 'alt+3', 'ctrl+s', 'ctrl+shift+s' (modifiers: ctrl/control, alt/menu, shift, win/super)."}++ kd = INPUT()+ kd.type = INPUT_KEYBOARD+ kd.union.ki.wVk = vk++ ku = INPUT()+ ku.type = INPUT_KEYBOARD+ ku.union.ki.wVk = vk+ ku.union.ki.dwFlags = KEYEVENTF_KEYUP++ _send_input(kd, ku)+ return {"success": True,+ "output": f"Sent '{key_lower}' to {title}",+ "data": {"key": key_lower, "vk": vk,+ "hwnd": target_hwnd, "title": title}}+++def handle_click(kicad_info: dict, args: dict) -> dict:+ """Click at coordinates within a KiCad window.++ Windows-only: macOS UI scripting against another app requires Accessibility+ permission, which we don't request. Ask the user to click directly.+ """+ if not IS_WINDOWS:+ return {+ "success": False,+ "error": "kicad_click is not implemented on macOS",+ "_hint": ("On macOS, ask the user to click in the KiCad window directly. "+ "UI scripting needs Accessibility permission."),+ }++ hwnd = args.get("hwnd")+ if not hwnd:+ return {"success": False, "error": "Missing required arg: hwnd"}+ hwnd = int(hwnd)++ x = args.get("x")+ y = args.get("y")+ if x is None or y is None:+ return {"success": False, "error": "Missing required args: x, y"}++ relative = args.get("relative", True)++ title = _get_window_title(hwnd)+ if not title:+ return {"success": False, "error": f"HWND {hwnd} is not a valid window."}++ rect = _get_window_rect(hwnd)+ width = rect["width"]+ height = rect["height"]++ # Bring to front+ user32.SetForegroundWindow(hwnd)+ time.sleep(0.1)++ if relative:+ screen_x = rect["left"] + int(float(x) * width)+ screen_y = rect["top"] + int(float(y) * height)+ else:+ screen_x = rect["left"] + int(x)+ screen_y = rect["top"] + int(y)++ # Convert to absolute coordinates for SendInput (0-65535 range)+ screen_w = user32.GetSystemMetrics(0)+ screen_h = user32.GetSystemMetrics(1)+ abs_x = int(screen_x * 65535 / screen_w)+ abs_y = int(screen_y * 65535 / screen_h)++ move = INPUT()+ move.type = INPUT_MOUSE+ move.union.mi.dx = abs_x+ move.union.mi.dy = abs_y+ move.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE++ down = INPUT()+ down.type = INPUT_MOUSE+ down.union.mi.dx = abs_x+ down.union.mi.dy = abs_y+ down.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTDOWN++ up = INPUT()+ up.type = INPUT_MOUSE+ up.union.mi.dx = abs_x+ up.union.mi.dy = abs_y+ up.union.mi.dwFlags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE | MOUSEEVENTF_LEFTUP++ _send_input(move, down, up)++ return {+ "success": True,+ "output": f"Clicked ({screen_x}, {screen_y}) on {title}",+ "data": {+ "clickedAt": {"screenX": screen_x, "screenY": screen_y},+ "windowOffset": {"x": screen_x - rect["left"], "y": screen_y - rect["top"]},+ "relative": relative,+ "hwnd": hwnd,+ "title": title,+ },+ }
handlers/library_browser.pyadded+451@@ -0,0 +1,451 @@+"""Handler for browsing and searching KiCad symbol libraries."""++import os+import re+import sys+from pathlib import Path+++def handle_list_symbol_libraries(kicad_info: dict, args: dict) -> dict:+ """List all symbol libraries registered in KiCad's sym-lib-table."""+ sys.path.insert(0, str(Path(__file__).parent.parent))+ from lib_table import LibTable++ sym_lib_table = kicad_info.get("sym_lib_table")+ if not sym_lib_table:+ return {"success": False, "error": "KiCad sym-lib-table path not found"}++ try:+ table = LibTable.parse_file(sym_lib_table)+ except Exception as e:+ return {"success": False, "error": f"Failed to parse sym-lib-table: {e}"}++ libraries = []+ for entry in table.entries:+ # Resolve ${KICAD9_SYMBOL_DIR} and similar env vars+ uri = _resolve_kicad_vars(entry.uri, kicad_info)+ exists = Path(uri).exists() if uri else False+ libraries.append({+ "name": entry.name,+ "type": entry.lib_type,+ "uri": entry.uri,+ "resolved_path": uri,+ "exists": exists,+ "description": entry.descr,+ })++ return {+ "success": True,+ "output": f"Found {len(libraries)} symbol libraries",+ "data": {"libraries": libraries},+ }+++def handle_search_symbols(kicad_info: dict, args: dict) -> dict:+ """Search across all symbol libraries by name/keyword.++ Args (in args dict):+ query: Search term (regex supported)+ limit: Max results (default 50)+ """+ sys.path.insert(0, str(Path(__file__).parent.parent))+ from lib_table import LibTable+ from parsers.sexpr import parse_file, find_nodes, find_node, node_value++ query = args.get("query", "")+ if not query:+ return {"success": False, "error": "No query specified"}++ limit = int(args.get("limit", 50))++ sym_lib_table = kicad_info.get("sym_lib_table")+ if not sym_lib_table:+ return {"success": False, "error": "KiCad sym-lib-table path not found"}++ try:+ table = LibTable.parse_file(sym_lib_table)+ except Exception as e:+ return {"success": False, "error": f"Failed to parse sym-lib-table: {e}"}++ try:+ pattern = re.compile(query, re.IGNORECASE)+ except re.error as e:+ return {"success": False, "error": f"Invalid regex pattern: {e}"}++ results = []+ searched = 0++ for entry in table.entries:+ uri = _resolve_kicad_vars(entry.uri, kicad_info)+ if not uri or not Path(uri).exists():+ continue++ searched += 1++ try:+ tree = parse_file(uri)+ except Exception:+ continue++ for sym in find_nodes(tree, "symbol"):+ if len(sym) < 2 or not isinstance(sym[1], str):+ continue+ sym_name = sym[1]++ # Skip internal sub-symbols (e.g., "OpAmp_0_1", "OpAmp_1_1")+ if re.match(r'.*_\d+_\d+$', sym_name):+ continue++ # Check name match+ if not pattern.search(sym_name):+ # Also check Description and keywords properties+ desc_prop = find_node(sym, "property")+ match_found = False+ for child in sym:+ if isinstance(child, list) and child and child[0] == "property":+ prop_name = child[1] if len(child) > 1 else ""+ prop_val = child[2] if len(child) > 2 else ""+ if prop_name in ("Description", "ki_keywords") and isinstance(prop_val, str):+ if pattern.search(prop_val):+ match_found = True+ break+ if not match_found:+ continue++ # Extract symbol info+ props = {}+ for child in sym:+ if isinstance(child, list) and child and child[0] == "property" and len(child) >= 3:+ props[child[1]] = child[2]++ # Count pins (follow extends chain if needed)+ pin_count = _count_pins(sym, tree)++ results.append({+ "library": entry.name,+ "name": sym_name,+ "description": props.get("Description", ""),+ "footprint": props.get("Footprint", ""),+ "datasheet": props.get("Datasheet", ""),+ "keywords": props.get("ki_keywords", ""),+ "pin_count": pin_count,+ })++ if len(results) >= limit:+ break++ if len(results) >= limit:+ break++ return {+ "success": True,+ "output": f"Found {len(results)} symbol(s) matching '{query}' (searched {searched} libraries)",+ "data": {+ "results": results,+ "query": query,+ "libraries_searched": searched,+ "truncated": len(results) >= limit,+ },+ }+++def handle_get_symbol_info(kicad_info: dict, args: dict) -> dict:+ """Get detailed info about a specific symbol.++ Args:+ library: Library name+ symbol: Symbol name+ """+ sys.path.insert(0, str(Path(__file__).parent.parent))+ from lib_table import LibTable+ from parsers.sexpr import parse_file, find_node, find_nodes, node_value++ library = args.get("library", "")+ symbol = args.get("symbol", "")+ if not library:+ return {"success": False, "error": "No library specified"}+ if not symbol:+ return {"success": False, "error": "No symbol specified"}++ sym_lib_table = kicad_info.get("sym_lib_table")+ if not sym_lib_table:+ return {"success": False, "error": "KiCad sym-lib-table path not found"}++ try:+ table = LibTable.parse_file(sym_lib_table)+ except Exception as e:+ return {"success": False, "error": f"Failed to parse sym-lib-table: {e}"}++ # Find the library entry+ lib_entry = None+ for entry in table.entries:+ if entry.name == library:+ lib_entry = entry+ break++ if not lib_entry:+ return {+ "success": False,+ "error": f"Library '{library}' not found",+ "_hint": "Call list_symbol_libraries to see valid library names.",+ }++ uri = _resolve_kicad_vars(lib_entry.uri, kicad_info)+ if not uri or not Path(uri).exists():+ return {"success": False, "error": f"Library file not found: {lib_entry.uri}"}++ try:+ tree = parse_file(uri)+ except Exception as e:+ return {"success": False, "error": f"Failed to parse library: {e}"}++ # Find the symbol+ sym_node = None+ for sym in find_nodes(tree, "symbol"):+ if len(sym) >= 2 and sym[1] == symbol:+ sym_node = sym+ break++ if not sym_node:+ return {+ "success": False,+ "error": f"Symbol '{symbol}' not found in library '{library}'",+ "_hint": "Call search_symbols to find the correct symbol name, or list_library_symbols to enumerate symbols in this library.",+ }++ # Extract properties+ props = {}+ for child in sym_node:+ if isinstance(child, list) and child and child[0] == "property" and len(child) >= 3:+ props[child[1]] = child[2]++ # Extract pins from sub-symbols (follow extends chain if needed)+ pins = _extract_pins(sym_node, tree)++ # Check flags+ is_power = any(+ (isinstance(c, list) and c == ["power"]) or c == "power"+ for c in sym_node+ )++ return {+ "success": True,+ "output": f"Symbol '{symbol}' from '{library}': {len(pins)} pin(s)",+ "data": {+ "library": library,+ "name": symbol,+ "properties": props,+ "pins": pins,+ "pin_count": len(pins),+ "is_power": is_power,+ "description": props.get("Description", ""),+ "footprint": props.get("Footprint", ""),+ "datasheet": props.get("Datasheet", ""),+ },+ }+++def handle_search_footprints(kicad_info: dict, args: dict) -> dict:+ """Search across all footprint libraries by name/keyword.++ Args (in args dict):+ query: Search term (regex supported)+ limit: Max results (default 50)+ """+ sys.path.insert(0, str(Path(__file__).parent.parent))+ from lib_table import LibTable+ from parsers.sexpr import parse_file, find_node, find_nodes, node_value++ query = args.get("query", "")+ if not query:+ return {"success": False, "error": "No query specified"}++ limit = int(args.get("limit", 50))++ fp_lib_table = kicad_info.get("fp_lib_table")+ if not fp_lib_table:+ return {"success": False, "error": "KiCad fp-lib-table path not found"}++ try:+ table = LibTable.parse_file(fp_lib_table)+ except Exception as e:+ return {"success": False, "error": f"Failed to parse fp-lib-table: {e}"}++ try:+ pattern = re.compile(query, re.IGNORECASE)+ except re.error as e:+ return {"success": False, "error": f"Invalid regex pattern: {e}"}++ results = []+ searched = 0++ # Build list of (library_name, pretty_dir_path) to search.+ # Includes both fp-lib-table entries AND system footprint libraries.+ lib_dirs = []+ for entry in table.entries:+ uri = _resolve_kicad_vars(entry.uri, kicad_info)+ if uri:+ lib_dirs.append((entry.name, Path(uri)))++ # Also include system footprint libraries (not always in user fp-lib-table)+ base_dir = kicad_info.get("base_dir", "")+ if base_dir:+ sys_fp_dir = Path(base_dir) / "share" / "kicad" / "footprints"+ if sys_fp_dir.is_dir():+ seen = {d.name for _, d in lib_dirs}+ for pretty in sorted(sys_fp_dir.iterdir()):+ if pretty.suffix == ".pretty" and pretty.name not in seen:+ lib_name = pretty.stem # e.g. "Capacitor_SMD"+ lib_dirs.append((lib_name, pretty))++ for lib_name, pretty_dir in lib_dirs:+ if not pretty_dir.is_dir():+ continue++ searched += 1++ # Check if the library name itself matches (all footprints qualify)+ lib_match = pattern.search(lib_name)++ for mod_file in sorted(pretty_dir.glob("*.kicad_mod")):+ fp_name = mod_file.stem # filename without .kicad_mod++ # Fast match: filename or library name only (no file parse)+ if not (pattern.search(fp_name) or lib_match):+ continue++ # Only parse the file for metadata on matches (fast path)+ descr = ""+ tags = ""+ pad_count = 0+ fp_type = ""+ try:+ tree = parse_file(str(mod_file))+ descr_node = find_node(tree, "descr")+ tags_node = find_node(tree, "tags")+ descr = node_value(descr_node) or "" if descr_node else ""+ tags = node_value(tags_node) or "" if tags_node else ""+ pad_count = len(find_nodes(tree, "pad"))+ attr_node = find_node(tree, "attr")+ if attr_node:+ fp_type = node_value(attr_node) or ""+ except Exception:+ pass++ results.append({+ "library": lib_name,+ "name": fp_name,+ "description": descr,+ "tags": tags,+ "pad_count": pad_count,+ "type": fp_type,+ })++ if len(results) >= limit:+ break++ if len(results) >= limit:+ break++ return {+ "success": True,+ "output": f"Found {len(results)} footprint(s) matching '{query}' (searched {searched} libraries)",+ "data": {+ "results": results,+ "query": query,+ "libraries_searched": searched,+ "truncated": len(results) >= limit,+ },+ }+++def _count_pins(sym_node: list, tree: list) -> int:+ """Count pins in a symbol, following extends chains."""+ from parsers.sexpr import find_node, find_nodes, node_value++ # Check for extends+ extends_node = find_node(sym_node, "extends")+ if extends_node:+ parent_name = node_value(extends_node)+ if parent_name:+ for sym in find_nodes(tree, "symbol"):+ if len(sym) >= 2 and sym[1] == parent_name:+ return _count_pins(sym, tree)+ return 0++ count = 0+ for child in sym_node:+ if isinstance(child, list) and child and child[0] == "symbol":+ count += len(find_nodes(child, "pin"))+ return count+++def _extract_pins(sym_node: list, tree: list) -> list[dict]:+ """Extract pins from a symbol, following extends chains."""+ from parsers.sexpr import find_node, find_nodes, node_value++ # Check for extends+ extends_node = find_node(sym_node, "extends")+ if extends_node:+ parent_name = node_value(extends_node)+ if parent_name:+ for sym in find_nodes(tree, "symbol"):+ if len(sym) >= 2 and sym[1] == parent_name:+ return _extract_pins(sym, tree)+ return []++ pins = []+ for child in sym_node:+ if isinstance(child, list) and child and child[0] == "symbol":+ for pin in find_nodes(child, "pin"):+ if len(pin) >= 3:+ pin_type = pin[1] if isinstance(pin[1], str) else ""+ pin_style = pin[2] if len(pin) > 2 and isinstance(pin[2], str) else ""+ name_node = find_node(pin, "name")+ number_node = find_node(pin, "number")+ pins.append({+ "type": pin_type,+ "style": pin_style,+ "name": node_value(name_node) or "",+ "number": node_value(number_node) or "",+ })+ return pins+++def _resolve_kicad_vars(uri: str, kicad_info: dict) -> str:+ """Resolve KiCad environment variables in URIs.++ e.g., ${KICAD9_SYMBOL_DIR}/Device.kicad_sym+ """+ if "${" not in uri:+ return uri++ base_dir = kicad_info.get("base_dir", "")++ # Common KiCad environment variables+ replacements = {+ "KICAD9_SYMBOL_DIR": os.path.join(base_dir, "share", "kicad", "symbols") if base_dir else "",+ "KICAD8_SYMBOL_DIR": os.path.join(base_dir, "share", "kicad", "symbols") if base_dir else "",+ "KICAD7_SYMBOL_DIR": os.path.join(base_dir, "share", "kicad", "symbols") if base_dir else "",+ "KICAD_SYMBOL_DIR": os.path.join(base_dir, "share", "kicad", "symbols") if base_dir else "",+ "KICAD9_FOOTPRINT_DIR": os.path.join(base_dir, "share", "kicad", "footprints") if base_dir else "",+ "KICAD8_FOOTPRINT_DIR": os.path.join(base_dir, "share", "kicad", "footprints") if base_dir else "",+ "KICAD7_FOOTPRINT_DIR": os.path.join(base_dir, "share", "kicad", "footprints") if base_dir else "",+ "KICAD_FOOTPRINT_DIR": os.path.join(base_dir, "share", "kicad", "footprints") if base_dir else "",+ "KICAD9_3DMODEL_DIR": os.path.join(base_dir, "share", "kicad", "3dmodels") if base_dir else "",+ "KICAD8_3DMODEL_DIR": os.path.join(base_dir, "share", "kicad", "3dmodels") if base_dir else "",+ "KICAD7_3DMODEL_DIR": os.path.join(base_dir, "share", "kicad", "3dmodels") if base_dir else "",+ }++ # Also check OS environment variables+ for var_name in re.findall(r'\$\{(\w+)\}', uri):+ if var_name not in replacements:+ env_val = os.environ.get(var_name, "")+ if env_val:+ replacements[var_name] = env_val++ result = uri+ for var_name, var_value in replacements.items():+ result = result.replace(f"${{{var_name}}}", var_value)++ return result
handlers/netlist.pyadded+184@@ -0,0 +1,184 @@+"""Handlers for netlist export and connection tracing."""++import json+import os+import subprocess+import tempfile+from pathlib import Path+++def handle_generate_netlist(kicad_info: dict, args: dict) -> dict:+ """Export a netlist from 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}"}++ 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"}++ fmt = args.get("format", "kicadxml")+ if fmt not in ("kicadxml", "cadstar"):+ return {"success": False, "error": f"Unsupported netlist format: {fmt}"}++ tmp_fd, output_path = tempfile.mkstemp(suffix=".xml" if fmt == "kicadxml" else ".net")+ os.close(tmp_fd)++ try:+ cmd = [+ cli_exe,+ "sch", "export", "netlist",+ "--format", fmt,+ "--output", output_path,+ str(sch_path),+ ]++ result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)++ output_file = Path(output_path)+ if not output_file.exists() or output_file.stat().st_size == 0:+ return {"success": False, "error": f"Netlist export produced no output. stderr: {result.stderr}"}++ netlist_text = output_file.read_text(encoding="utf-8")++ return {+ "success": True,+ "output": f"Netlist exported ({len(netlist_text)} bytes)",+ "data": {"netlist": netlist_text, "format": fmt},+ }++ except subprocess.TimeoutExpired:+ return {"success": False, "error": "Netlist export timed out after 60 seconds"}+ except Exception as e:+ return {"success": False, "error": f"Netlist export failed: {e}"}+ finally:+ try:+ Path(output_path).unlink(missing_ok=True)+ except OSError:+ pass+++def handle_trace_connection(kicad_info: dict, args: dict) -> dict:+ """Trace connections for a component pin through the schematic.++ Uses the schematic parser to find which net a component's pin+ belongs to, then lists all other components connected to that net.+ """+ import sys+ sys.path.insert(0, str(Path(__file__).parent.parent))+ from parsers.schematic import parse_schematic++ file_path = args.get("filePath", "")+ reference = args.get("reference", "")++ if not file_path:+ return {"success": False, "error": "No filePath specified"}+ if not reference:+ return {"success": False, "error": "No reference specified"}++ sch_path = Path(file_path)+ if not sch_path.exists():+ return {"success": False, "error": f"Schematic not found: {file_path}"}++ try:+ data = parse_schematic(sch_path)+ except Exception as e:+ return {"success": False, "error": f"Failed to parse schematic: {e}"}++ # Find the target component+ target = None+ for comp in data["components"]:+ if comp["reference"] == reference:+ target = comp+ break++ if not target:+ return {+ "success": False,+ "error": f"Component '{reference}' not found in schematic",+ }++ return {+ "success": True,+ "output": f"Component {reference} ({target['value']}) found",+ "data": {+ "reference": reference,+ "value": target["value"],+ "lib_id": target["lib_id"],+ "footprint": target["footprint"],+ "position": target["position"],+ "properties": target["properties"],+ },+ }+++def handle_get_net_connections(kicad_info: dict, args: dict) -> dict:+ """Get all components connected to a named net via PCB data.++ Uses the PCB parser to find all pads on a given net, identifying+ which footprints (components) are connected.+ """+ import sys+ sys.path.insert(0, str(Path(__file__).parent.parent))+ from parsers.pcb import parse_pcb++ file_path = args.get("filePath", "")+ net_name = args.get("netName", "")++ if not file_path:+ return {"success": False, "error": "No filePath specified"}+ if not net_name:+ return {"success": False, "error": "No netName specified"}++ pcb_path = Path(file_path)+ if not pcb_path.exists():+ return {"success": False, "error": f"PCB file not found: {file_path}"}++ try:+ data = parse_pcb(pcb_path)+ except Exception as e:+ return {"success": False, "error": f"Failed to parse PCB: {e}"}++ # Find net number+ net_number = None+ for net in data["nets"]:+ if net["name"] == net_name:+ net_number = net["number"]+ break++ if net_number is None:+ return {"success": False, "error": f"Net '{net_name}' not found"}++ # Find all footprints with pads on this net+ connections = []+ for fp in data["footprints"]:+ for pad in fp.get("pads", []):+ if pad.get("net_number") == net_number:+ connections.append({+ "reference": fp["reference"],+ "value": fp["value"],+ "footprint": fp["footprint"],+ "pad_number": pad.get("number", ""),+ "pad_type": pad.get("type", ""),+ })++ return {+ "success": True,+ "output": f"Net '{net_name}': {len(connections)} connection(s)",+ "data": {+ "net_name": net_name,+ "net_number": net_number,+ "connections": connections,+ },+ }
handlers/open_files.pyadded+75@@ -0,0 +1,75 @@+"""Handlers for open_board and open_schematic commands."""++import subprocess+from pathlib import Path+++def handle_open_board(kicad_info: dict, args: dict) -> dict:+ """Open a .kicad_pcb file in pcbnew."""+ return _open_file(kicad_info, args, "pcbnew_exe", "PCB board")+++def handle_open_schematic(kicad_info: dict, args: dict) -> dict:+ """Open a .kicad_sch file in eeschema."""+ return _open_file(kicad_info, args, "eeschema_exe", "schematic")+++def _open_file(kicad_info: dict, args: dict, exe_key: str, file_type_label: str) -> dict:+ 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"}++ path = Path(file_path)+ if not path.exists():+ return {+ "success": False,+ "error": f"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.",+ }++ exe = kicad_info.get(exe_key)+ if not exe or not Path(exe).exists():+ return {+ "success": False,+ "error": f"KiCad executable not found: {exe_key}",+ "_hint": "Report to the user and ask them to reinstall KiCad — the installation appears corrupted.",+ }++ try:+ subprocess.Popen(+ [exe, str(path)],+ stdout=subprocess.DEVNULL,+ stderr=subprocess.DEVNULL,+ )+ # v1.7.12+: hint nudging the AI toward kicad_lint_board /+ # kicad_lint_schematic for unfamiliar files. The lint verbs are+ # ~1-3s via kicad-cli and surface DRC/ERC violations + file-format+ # issues BEFORE the GUI even tries to render — much faster than+ # screenshotting a confused GUI to figure out what went wrong.+ is_pcb = "pcbnew" in exe_key+ lint_verb = "kicad_lint_board" if is_pcb else "kicad_lint_schematic"+ return {+ "success": True,+ "output": f"Launched {file_type_label} editor with {path.name}",+ "_hint": (+ f"GUI launched. For unfamiliar files (ones you didn't generate / haven't validated), "+ f"prefer running `{lint_verb}` FIRST. It uses kicad-cli to give you structured "+ f"DRC/ERC + file-format info in ~1-3s, much faster + more reliable than recovering "+ f"from a confused GUI via screenshots. If the GUI shows a modal dialog (file-version "+ f"upgrade prompt, missing-symbol warning, recovery prompt), use kicad_window_info → "+ f"kicad_screenshot_all → kicad_send_key to handle it; or close + retry after lint."+ ),+ }+ except Exception as e:+ return {+ "success": False,+ "error": f"Failed to launch: {e}",+ "_hint": "Call close_kicad with {\"force\": true} to clear any stuck processes, then retry.",+ }
handlers/open_footprint_editor.pyadded+706@@ -0,0 +1,706 @@+"""Handler for open_footprint_editor and open_3d_viewer commands.++Windows: clicks the launcher icon in the KiCad project manager (the launcher+buttons in KiCad 9 use dynamic WM_COMMAND IDs we can't rely on), then optionally+loads a footprint via PowerShell SendKeys against the search box.++macOS: launches the Footprint Editor app directly via `open -a`. Footprint-name+navigation requires Accessibility-framework permission and is not implemented.+"""++import ctypes+import ctypes.wintypes+import subprocess+import sys+import time+from pathlib import Path++IS_WINDOWS = sys.platform == "win32"+IS_MACOS = sys.platform == "darwin"++# Win32 constants — cross-platform safe (just integers).+WM_CLOSE = 0x0010+SW_RESTORE = 9+GW_OWNER = 4+KEYEVENTF_KEYUP = 0x0002+VK_MENU = 0x12+MOUSEEVENTF_LEFTDOWN = 0x0002+MOUSEEVENTF_LEFTUP = 0x0004+++class RECT(ctypes.Structure):+ _fields_ = [("left", ctypes.c_long), ("top", ctypes.c_long),+ ("right", ctypes.c_long), ("bottom", ctypes.c_long)]+++if IS_WINDOWS:+ user32 = ctypes.windll.user32+ kernel32 = ctypes.windll.kernel32+else:+ user32 = None+ kernel32 = None++# Keep callback references alive to prevent GC+_callbacks = []++# Reuse the same navigate_symbol.ps1 script (works for both editors)+_SCRIPT_DIR = Path(__file__).resolve().parent.parent+_NAVIGATE_SCRIPT = _SCRIPT_DIR / "navigate_symbol.ps1"+++def _find_windows_by_title(substring: str) -> list:+ """Find all visible windows whose title contains the given substring."""+ results = []+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def callback(hwnd, _lparam):+ if user32.IsWindowVisible(hwnd):+ length = user32.GetWindowTextLengthW(hwnd)+ if length > 0:+ buf = ctypes.create_unicode_buffer(length + 1)+ user32.GetWindowTextW(hwnd, buf, length + 1)+ if substring in buf.value:+ owner = user32.GetWindow(hwnd, GW_OWNER)+ results.append((hwnd, buf.value, owner))+ return True++ cb = WNDENUMPROC(callback)+ _callbacks.append(cb)+ user32.EnumWindows(cb, 0)+ _callbacks.remove(cb)+ return results+++def _find_kicad_manager() -> int | None:+ """Find the KiCad project manager window handle."""+ windows = _find_windows_by_title("KiCad")+ for hwnd, title, owner in windows:+ lower = title.lower()+ if ("editor" not in lower+ and "viewer" not in lower+ and "calculator" not in lower+ and owner == 0):+ return hwnd+ return None+++def _find_footprint_editor() -> int | None:+ """Find an open Footprint Editor window."""+ windows = _find_windows_by_title("Footprint Editor")+ for hwnd, title, _owner in windows:+ if "footprint editor" in title.lower():+ return hwnd+ return None+++def _find_child_by_text(parent_hwnd: int, text: str) -> int | None:+ """Find a child window with exact text match."""+ result = [None]+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def callback(hwnd, _lparam):+ length = user32.GetWindowTextLengthW(hwnd)+ if length > 0:+ buf = ctypes.create_unicode_buffer(length + 1)+ user32.GetWindowTextW(hwnd, buf, length + 1)+ if buf.value == text:+ result[0] = hwnd+ return False+ return True++ cb = WNDENUMPROC(callback)+ _callbacks.append(cb)+ user32.EnumChildWindows(parent_hwnd, cb, 0)+ _callbacks.remove(cb)+ return result[0]+++def _find_icon_panel_near(parent_hwnd: int, near_y: int) -> tuple | None:+ """Find a small (~51x51) wxWindowNR 'panel' child near the given y coordinate."""+ results = []+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def callback(hwnd, _lparam):+ cls = ctypes.create_unicode_buffer(256)+ user32.GetClassNameW(hwnd, cls, 256)+ if cls.value == "wxWindowNR":+ txt_len = user32.GetWindowTextLengthW(hwnd)+ txt = ""+ if txt_len > 0:+ buf = ctypes.create_unicode_buffer(txt_len + 1)+ user32.GetWindowTextW(hwnd, buf, txt_len + 1)+ txt = buf.value+ if txt == "panel":+ rect = RECT()+ user32.GetWindowRect(hwnd, ctypes.byref(rect))+ w = rect.right - rect.left+ h = rect.bottom - rect.top+ if 40 <= w <= 60 and 40 <= h <= 60:+ if abs(rect.top - near_y) < 15:+ results.append((hwnd, rect))+ return True++ cb = WNDENUMPROC(callback)+ _callbacks.append(cb)+ user32.EnumChildWindows(parent_hwnd, cb, 0)+ _callbacks.remove(cb)+ return results[0] if results else None+++def _find_child_by_class_and_parent(parent_hwnd: int, child_class: str, parent_text: str) -> int | None:+ """Find a child window by class name and parent window text."""+ result = [None]+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def callback(hwnd, _lparam):+ cls_buf = ctypes.create_unicode_buffer(256)+ user32.GetClassNameW(hwnd, cls_buf, 256)+ if cls_buf.value == child_class:+ parent = user32.GetParent(hwnd)+ p_buf = ctypes.create_unicode_buffer(256)+ user32.GetWindowTextW(parent, p_buf, 256)+ if p_buf.value == parent_text:+ result[0] = hwnd+ return False+ return True++ cb = WNDENUMPROC(callback)+ _callbacks.append(cb)+ user32.EnumChildWindows(parent_hwnd, cb, 0)+ _callbacks.remove(cb)+ return result[0]+++def _force_foreground(hwnd: int):+ """Bring a window to the foreground using the Alt-key trick.++ 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.+ """+ 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)+++def _click_at(x: int, y: int):+ """Simulate a mouse click at screen coordinates."""+ user32.SetCursorPos(x, y)+ time.sleep(0.15)+ user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, ctypes.POINTER(ctypes.c_ulong)())+ time.sleep(0.05)+ user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, ctypes.POINTER(ctypes.c_ulong)())+++def _open_footprint_editor_via_click(kicad_hwnd: int) -> bool:+ """Open the Footprint Editor by clicking its launcher icon in the project manager.++ Returns True if the Footprint Editor window appeared.+ """+ label_hwnd = _find_child_by_text(kicad_hwnd, "Footprint Editor")+ if not label_hwnd:+ return False++ label_rect = RECT()+ user32.GetWindowRect(label_hwnd, ctypes.byref(label_rect))++ # Find the icon panel (small ~51x51 wxWindowNR "panel" near the label's y position)+ icon_result = _find_icon_panel_near(kicad_hwnd, label_rect.top - 6)+ if icon_result:+ _, icon_rect = icon_result+ click_x = (icon_rect.left + icon_rect.right) // 2+ click_y = (icon_rect.top + icon_rect.bottom) // 2+ else:+ # Fallback: click just left of the label+ click_x = label_rect.left - 30+ click_y = (label_rect.top + label_rect.bottom) // 2++ # Bring KiCad to foreground and click+ _force_foreground(kicad_hwnd)+ time.sleep(0.5)+ _click_at(click_x, click_y)++ # Wait for the Footprint Editor to appear+ for _ in range(20):+ time.sleep(0.5)+ if _find_footprint_editor():+ return True++ return False+++def _send_ctrl_shift_e(hwnd: int):+ """Send Ctrl+Shift+E to load the selected footprint in the Footprint Editor."""+ VK_CONTROL = 0x11+ VK_SHIFT = 0x10+ VK_E = 0x45+ _force_foreground(hwnd)+ time.sleep(0.3)+ user32.keybd_event(VK_CONTROL, 0, 0, 0)+ user32.keybd_event(VK_SHIFT, 0, 0, 0)+ user32.keybd_event(VK_E, 0, 0, 0)+ user32.keybd_event(VK_E, 0, KEYEVENTF_KEYUP, 0)+ user32.keybd_event(VK_SHIFT, 0, KEYEVENTF_KEYUP, 0)+ user32.keybd_event(VK_CONTROL, 0, KEYEVENTF_KEYUP, 0)+++def _navigate_to_footprint(fp_hwnd: int, footprint_name: str, load_after: bool = True) -> dict:+ """Call the PowerShell script to navigate to a specific footprint.++ Uses the same navigate_symbol.ps1 script as the Symbol Editor handler.+ If load_after is True, sends Ctrl+Shift+E to load the footprint after+ searching.+ """+ if not _NAVIGATE_SCRIPT.exists():+ return {"status": "error", "error": f"Script not found: {_NAVIGATE_SCRIPT}"}++ search_edit = _find_child_by_class_and_parent(fp_hwnd, "Edit", "searchCtrl")+ if not search_edit:+ return {"status": "error", "error": "Search edit control not found in Footprint Editor"}++ try:+ subprocess.run(+ [+ "powershell", "-ExecutionPolicy", "Bypass",+ "-File", str(_NAVIGATE_SCRIPT),+ "-SymEditorHwnd", str(fp_hwnd),+ "-SearchEditHwnd", str(search_edit),+ "-SymbolName", footprint_name,+ ],+ capture_output=True, text=True, timeout=20,+ )+ except subprocess.TimeoutExpired:+ pass+ except Exception:+ pass++ time.sleep(0.5)++ windows = _find_windows_by_title("Footprint Editor")+ for _, title, _ in windows:+ if footprint_name in title:+ return {"status": "ok", "title": title}+ if "footprint editor" in title.lower() and "[no footprint loaded]" not in title.lower():+ return {"status": "partial", "title": title}++ # Footprint found in search but not loaded — try Ctrl+Shift+E+ if load_after:+ _send_ctrl_shift_e(fp_hwnd)+ time.sleep(1.5)++ windows = _find_windows_by_title("Footprint Editor")+ for _, title, _ in windows:+ if footprint_name in title:+ return {"status": "ok", "title": title}+ if "footprint editor" in title.lower() and "[no footprint loaded]" not in title.lower():+ return {"status": "partial", "title": title}++ return {"status": "noload", "title": "[no footprint loaded]"}+++def _mac_open_footprint_editor(kicad_info: dict, args: dict) -> dict:+ """macOS: launch the Footprint Editor app directly. Footprint navigation+ is not implemented (requires Accessibility permission)."""+ parent = Path(kicad_info.get("base_dir", "")).parent+ fp_app = parent / "Footprint Editor.app"+ if not fp_app.exists():+ return {+ "success": False,+ "error": f"Footprint Editor.app not found at {fp_app}",+ "_hint": "Reinstall KiCad — the Footprint Editor app bundle is missing.",+ }+ try:+ subprocess.Popen(+ ["open", "-a", str(fp_app)],+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,+ )+ except Exception as e:+ return {"success": False, "error": f"Failed to launch Footprint Editor: {e}"}+ fp_name = args.get("footprintName", "") or args.get("footprint", "")+ lib_name = args.get("libraryName", "") or args.get("library", "")+ if fp_name or lib_name:+ return {+ "success": True,+ "output": "Footprint Editor opened. Navigation to a specific footprint is not implemented on macOS.",+ "_hint": "Ask the user to type the footprint name in the editor's search box.",+ }+ return {"success": True, "output": "Footprint Editor opened."}+++def handle_open_footprint_editor(kicad_info: dict, args: dict) -> dict:+ """Open the KiCad Footprint Editor and optionally load a specific footprint.++ Args:+ kicad_info: KiCad detection info dict.+ args: Optional dict with:+ - footprintName: Name of footprint to load (e.g. "ADOM_R0805").+ - libraryName: Name of the library containing the footprint.+ """+ 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.",+ }++ if IS_MACOS:+ return _mac_open_footprint_editor(kicad_info, args or {})+ if not IS_WINDOWS:+ return {"success": False, "error": f"open_footprint_editor not implemented on {sys.platform!r}"}++ footprint_name = args.get("footprintName", "") or args.get("footprint", "")+ library_name = args.get("libraryName", "") or args.get("library", "")++ # Check if the footprint is already loaded+ if footprint_name:+ fp_hwnd = _find_footprint_editor()+ if fp_hwnd:+ windows = _find_windows_by_title("Footprint Editor")+ for hwnd, title, _ in windows:+ if footprint_name in title:+ _force_foreground(hwnd)+ return {+ "success": True,+ "output": f"Footprint '{footprint_name}' is already loaded in the Footprint Editor.",+ }++ # --- Phase 1: Ensure Footprint Editor is open ---++ fp_hwnd = _find_footprint_editor()+ pathways_tried: list[str] = []++ # 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+ # the flaky icon-click fallback documented at the top of this file+ # (KiCad 9 launcher icons use dynamic WM_COMMAND IDs we can't rely on).+ KICAD_FOOTPRINT_EDITOR_CMD_ID = 20013 # noqa: N806+ if not fp_hwnd:+ try:+ from handlers.bridge_client import try_wm_command_via_bridge+ tier1 = try_wm_command_via_bridge(+ exe_name="kicad", menu_id=KICAD_FOOTPRINT_EDITOR_CMD_ID, frame_index=0,+ )+ pathways_tried.append("plugin" if tier1.get("success") else f"plugin-failed:{tier1.get('errorCode','')}")+ if tier1.get("success"):+ for _ in range(20):+ 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 bridge.",+ "pathway": "plugin",+ "pathwaysTried": pathways_tried,+ "bridgePid": tier1.get("pid"),+ }+ # Fall through to navigate_footprint for footprint_name/library_name+ except Exception as e: # pylint: disable=broad-except+ pathways_tried.append(f"plugin-import-error:{type(e).__name__}")++ if not fp_hwnd:+ kicad_hwnd = _find_kicad_manager()++ if not kicad_hwnd:+ kicad_exe = kicad_info.get("kicad_exe")+ if not kicad_exe or not Path(kicad_exe).exists():+ return {+ "success": False,+ "error": "KiCad executable not found",+ "_hint": "Report to the user and ask them to reinstall KiCad — the installation appears corrupted.",+ }++ try:+ subprocess.Popen(+ [kicad_exe],+ stdout=subprocess.DEVNULL,+ stderr=subprocess.DEVNULL,+ )+ except Exception as e:+ return {+ "success": False,+ "error": f"Failed to launch KiCad: {e}",+ "_hint": "Call close_kicad with {\"force\": true} to clear stuck processes, then retry.",+ }++ for _ in range(20):+ time.sleep(0.5)+ kicad_hwnd = _find_kicad_manager()+ if kicad_hwnd:+ break++ if not kicad_hwnd:+ return {+ "success": False,+ "error": "KiCad launched but project manager window not found after 10s",+ "_hint": "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry.",+ }+ 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).+ 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 not opened:+ return {+ "success": False,+ "error": "Could not open Footprint Editor. Launcher icon click failed after 3 attempts.",+ "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.",+ }++ fp_hwnd = _find_footprint_editor()+ if not fp_hwnd:+ return {+ "success": False,+ "error": "Footprint Editor launcher clicked but window did not appear.",+ "_hint": "Call screenshot_all to check for a modal dialog, dismiss with send_key escape, then retry.",+ }++ time.sleep(1.5)++ # --- Phase 2: Navigate to specific footprint (if requested) ---++ if not footprint_name and not library_name:+ _force_foreground(fp_hwnd)+ return {"success": True, "output": "Footprint Editor opened successfully."}++ search_text = footprint_name or library_name++ nav_result = _navigate_to_footprint(fp_hwnd, search_text)++ if nav_result["status"] == "ok":+ return {+ "success": True,+ "output": f"Footprint '{search_text}' loaded in the Footprint Editor: {nav_result['title']}",+ }+ elif nav_result["status"] == "partial":+ return {+ "success": True,+ "output": f"Footprint Editor loaded: {nav_result['title']}",+ }+ elif nav_result["status"] == "noload":+ time.sleep(0.5)+ nav_result2 = _navigate_to_footprint(fp_hwnd, search_text)+ if nav_result2["status"] == "ok":+ return {+ "success": True,+ "output": f"Footprint '{search_text}' loaded in the Footprint Editor: {nav_result2['title']}",+ }+ elif nav_result2["status"] == "partial":+ return {+ "success": True,+ "output": f"Footprint Editor loaded: {nav_result2['title']}",+ }+ else:+ return {+ "success": True,+ "output": f"Footprint Editor opened and searched for '{search_text}'. "+ f"Use Ctrl+Shift+E to load the footprint if it didn't auto-load.",+ }+ else:+ _force_foreground(fp_hwnd)+ return {+ "success": True,+ "output": f"Footprint Editor opened but navigation failed: {nav_result.get('error', 'unknown')}. "+ f"Search for '{search_text}' manually.",+ }+++def _find_pcb_editor() -> int | None:+ """Find an open PCB Editor (pcbnew) window."""+ windows = _find_windows_by_title("PCB Editor")+ for hwnd, title, _owner in windows:+ if "pcb editor" in title.lower():+ return hwnd+ return None+++def handle_open_3d_viewer(kicad_info: dict, args: dict) -> dict:+ """Open the 3D Viewer from the Footprint Editor or the PCB Editor.++ Args:+ editor: "auto" (default), "fp", or "pcb". Picks which editor to open+ the 3D viewer from. v0.7.1+ — added to fix the bug where the FP+ editor was always tried first and blocked the PCB fallback when+ FP's WM_COMMAND silently failed (KiCad 10's command IDs differ+ from KiCad 9's, so the FP path can fail without opening a+ viewer AND without raising an error).++ Windows: sends WM_COMMAND 20500 to Footprint Editor (KiCad 9; may differ+ on 10), or Alt+3 keystroke / WM_COMMAND 20563 to PCB Editor.+ macOS: not implemented — invoking a menu item in another app requires+ Accessibility-framework permission.+ """+ if IS_MACOS:+ return {+ "success": False,+ "error": "open_3d_viewer is not implemented on macOS",+ "_hint": ("On macOS, ask the user to open the 3D Viewer from the editor's "+ "View menu (or press Alt+3 in PCB Editor). UI scripting requires "+ "Accessibility permission."),+ }+ if not IS_WINDOWS:+ return {"success": False, "error": f"open_3d_viewer not implemented on {sys.platform!r}"}++ # Editor selection. "auto" means: try FP if open AND PCB isn't (since FP+ # 3D viewer is per-footprint, PCB 3D viewer is per-board — caller usually+ # has a strong preference even on "auto" once one editor dominates the+ # screen). When BOTH are open, prefer PCB — that matches the common tour+ # flow (FP gets the chip in step 4; PCB gets the board in step 7).+ editor_choice = (args.get("editor") or "auto").lower()+ if editor_choice not in {"auto", "fp", "footprint", "pcb", "board"}:+ return {+ "success": False,+ "error": f"Unknown editor '{editor_choice}'. Use 'auto', 'fp', or 'pcb'.",+ }++ fp_hwnd = _find_footprint_editor()+ pcb_hwnd = _find_pcb_editor()+ if editor_choice in {"fp", "footprint"}:+ if not fp_hwnd:+ return {+ "success": False,+ "error": "Footprint Editor is not open.",+ "_hint": "Call open_footprint_editor first or pass editor='pcb'.",+ }+ pcb_hwnd = None # skip pcb fallback when caller explicitly wants fp+ elif editor_choice in {"pcb", "board"}:+ if not pcb_hwnd:+ return {+ "success": False,+ "error": "PCB Editor is not open.",+ "_hint": "Call open_board first or pass editor='fp'.",+ }+ fp_hwnd = None # skip fp attempt when caller explicitly wants pcb+ else:+ # auto: prefer PCB when both are open (tour-like flow)+ if pcb_hwnd and fp_hwnd:+ fp_hwnd = None++ # 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)++ 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",+ }++ # 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+ # to PCB attempt rather than dead-ending here.+ if editor_choice == "auto" and pcb_hwnd is None:+ # We nulled it above on auto+both; redo the lookup to recover.+ pcb_hwnd = _find_pcb_editor()+ if not pcb_hwnd:+ return {+ "success": False,+ "error": "3D Viewer did not open from Footprint Editor.",+ "_hint": "Call open_footprint_editor with a footprintName first, OR "+ "pass editor='pcb' to target the board's 3D viewer instead.",+ }+ # else: fall through to PCB Editor attempt below++ # Try PCB Editor (pcbnew) — Phase 3 prefers tier-1 bridge wm_command+ # over the legacy Alt+3 keystroke. The bridge posts WM_COMMAND 20563+ # (3D Viewer menu id from PCB Editor's View menu) which doesn't+ # 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+ pathways_tried: list[str] = []++ # Tier 1: bridge wm_command via pcbnew plugin+ try:+ from handlers.bridge_client import try_wm_command_via_bridge+ tier1 = try_wm_command_via_bridge(+ exe_name="pcbnew", menu_id=PCB_3D_VIEWER_CMD, frame_index=0,+ )+ pathways_tried.append("plugin" if tier1.get("success") else f"plugin-failed:{tier1.get('errorCode','')}")+ if tier1.get("success"):+ 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": "plugin",+ "pathwaysTried": pathways_tried,+ "bridgePid": tier1.get("pid"),+ }+ 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,+ }++ return {+ "success": False,+ "error": "3D Viewer did not open from PCB Editor. A board must be loaded in pcbnew.",+ "pathwaysTried": pathways_tried,+ "_hint": "Call open_board {\"filePath\":\"...\"} to load a PCB in pcbnew first, then retry.",+ }++ return {+ "success": False,+ "error": "Neither Footprint Editor nor PCB Editor is open. Open one first with open_footprint_editor or open_pcb.",+ "_hint": "Call open_footprint_editor or open_board {\"filePath\":\"...\"} first, then retry.",+ }
handlers/open_symbol_editor.pyadded+439@@ -0,0 +1,439 @@+"""Handler for open_symbol_editor command.++Opens the KiCad Symbol Editor and optionally loads a specific symbol.++Windows: two-phase Win32 approach —+ 1. Send WM_COMMAND 20012 to the KiCad project manager to open the Symbol Editor+ 2. If a symbolName is specified, call navigate_symbol.ps1 which clicks the+ search box, types the symbol name via SendKeys, and sends Ctrl+Shift+E.++macOS: launches the Symbol Editor app directly via `open -a`. Symbol-name+navigation requires Accessibility-framework permission for UI scripting,+which we don't request — handlers report this limitation in the response.+"""++import ctypes+import ctypes.wintypes+import subprocess+import sys+import time+from pathlib import Path++IS_WINDOWS = sys.platform == "win32"+IS_MACOS = sys.platform == "darwin"++# Win32 constants — defined cross-platform so module-level references don't+# error on macOS. The Win32 helpers below only use them through user32/kernel32,+# which are None on macOS; handlers branch on platform before calling helpers.+WM_COMMAND = 0x0111+# v1.7.6 fix (KiCad v2 Phase 4 menu dump audit): KiCad 10's Project+# Manager Tools menu IDs are: 20010=Schematic Editor, 20011=Symbol Editor,+# 20012=PCB Editor, 20013=Footprint Editor, 20014=Gerber Viewer.+# Previous value of 20012 sent the wrong command (PCB Editor) so this+# handler silently failed for KiCad 10 users — Symbol Editor never opened,+# PCB Editor sometimes flashed instead. Caught when Phase 3 tier-1 routing+# also sent 20012 to the bridge and got identical "did not appear" failure.+KICAD_SYMBOL_EDITOR_CMD_ID = 20011 # Project Manager > Tools > Symbol Editor+SW_RESTORE = 9+GW_OWNER = 4++if IS_WINDOWS:+ user32 = ctypes.windll.user32+ kernel32 = ctypes.windll.kernel32+else:+ user32 = None+ kernel32 = None++# Keep callback references alive to prevent GC+_callbacks = []++# Path to the navigate_symbol.ps1 script (same directory as the plugin root)+_SCRIPT_DIR = Path(__file__).resolve().parent.parent+_NAVIGATE_SCRIPT = _SCRIPT_DIR / "navigate_symbol.ps1"+++def _find_windows_by_title(substring: str) -> list:+ """Find all visible windows whose title contains the given substring."""+ results = []+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def callback(hwnd, _lparam):+ if user32.IsWindowVisible(hwnd):+ length = user32.GetWindowTextLengthW(hwnd)+ if length > 0:+ buf = ctypes.create_unicode_buffer(length + 1)+ user32.GetWindowTextW(hwnd, buf, length + 1)+ if substring in buf.value:+ owner = user32.GetWindow(hwnd, GW_OWNER)+ results.append((hwnd, buf.value, owner))+ return True++ cb = WNDENUMPROC(callback)+ _callbacks.append(cb)+ user32.EnumWindows(cb, 0)+ _callbacks.remove(cb)+ return results+++def _find_kicad_manager() -> int | None:+ """Find the KiCad project manager window handle."""+ windows = _find_windows_by_title("KiCad")+ for hwnd, title, owner in windows:+ lower = title.lower()+ if ("editor" not in lower+ and "viewer" not in lower+ and "calculator" not in lower+ and owner == 0):+ return hwnd+ return None+++def _find_symbol_editor() -> int | None:+ """Find an open Symbol Editor window."""+ windows = _find_windows_by_title("Symbol Editor")+ for hwnd, title, _owner in windows:+ if "symbol editor" in title.lower():+ return hwnd+ return None+++def _find_child_by_class_and_parent(parent_hwnd: int, child_class: str, parent_text: str) -> int | None:+ """Find a child window by class name and parent window text."""+ result = [None]+ WNDENUMPROC = ctypes.WINFUNCTYPE(+ ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM+ )++ def callback(hwnd, _lparam):+ cls_buf = ctypes.create_unicode_buffer(256)+ user32.GetClassNameW(hwnd, cls_buf, 256)+ if cls_buf.value == child_class:+ parent = user32.GetParent(hwnd)+ p_buf = ctypes.create_unicode_buffer(256)+ user32.GetWindowTextW(parent, p_buf, 256)+ if p_buf.value == parent_text:+ result[0] = hwnd+ return False+ return True++ cb = WNDENUMPROC(callback)+ _callbacks.append(cb)+ user32.EnumChildWindows(parent_hwnd, cb, 0)+ _callbacks.remove(cb)+ return result[0]+++def _force_foreground(hwnd: int):+ """Bring a window to the foreground."""+ user32.ShowWindow(hwnd, SW_RESTORE)+ user32.SetForegroundWindow(hwnd)+ user32.BringWindowToTop(hwnd)+++def _navigate_to_symbol(sym_hwnd: int, symbol_name: str) -> dict:+ """Call the PowerShell script to navigate to a specific symbol.++ Finds the search Edit control HWND from Python (reliable cross-process),+ then passes it to the PowerShell script which handles focus, typing, and shortcuts.++ Returns a dict with 'status' ('ok', 'partial', 'noload', 'error') and 'title' or 'error'.+ """+ if not _NAVIGATE_SCRIPT.exists():+ return {"status": "error", "error": f"Script not found: {_NAVIGATE_SCRIPT}"}++ # Find the search Edit control from Python (more reliable than C# cross-process)+ search_edit = _find_child_by_class_and_parent(sym_hwnd, "Edit", "searchCtrl")+ if not search_edit:+ return {"status": "error", "error": "Search edit control not found in Symbol Editor"}++ try:+ subprocess.run(+ [+ "powershell", "-ExecutionPolicy", "Bypass",+ "-File", str(_NAVIGATE_SCRIPT),+ "-SymEditorHwnd", str(sym_hwnd),+ "-SearchEditHwnd", str(search_edit),+ "-SymbolName", symbol_name,+ ],+ capture_output=True, text=True, timeout=20,+ )+ except subprocess.TimeoutExpired:+ pass # Script timed out, but actions may have been performed+ except Exception:+ pass # Script may crash during .NET cleanup but actions still succeed++ # Verify result from Python by checking the window title directly.+ # The PowerShell script often crashes during .NET cleanup (0xC0000409)+ # but the actual Win32 actions (focus, click, type, shortcut) succeed.+ time.sleep(0.5)++ # Re-find the Symbol Editor (HWND may have changed if editor was reopened)+ sym_hwnd_check = _find_symbol_editor()+ if not sym_hwnd_check:+ return {"status": "error", "error": "Symbol Editor closed unexpectedly"}++ windows = _find_windows_by_title("Symbol Editor")+ for _, title, _ in windows:+ if symbol_name in title:+ return {"status": "ok", "title": title}+ if "symbol editor" in title.lower() and "[no symbol loaded]" not in title:+ return {"status": "partial", "title": title}++ return {"status": "noload", "title": "[no symbol loaded]"}+++def _mac_open_symbol_editor(kicad_info: dict, args: dict) -> dict:+ """macOS: launch the Symbol Editor app directly. Symbol/library navigation+ after launch is not supported (would require Accessibility permission)."""+ parent = Path(kicad_info.get("base_dir", "")).parent+ sym_app = parent / "Symbol Editor.app"+ if not sym_app.exists():+ return {+ "success": False,+ "error": f"Symbol Editor.app not found at {sym_app}",+ "_hint": "Reinstall KiCad — the Symbol Editor app bundle is missing.",+ }++ try:+ subprocess.Popen(+ ["open", "-a", str(sym_app)],+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,+ )+ except Exception as e:+ return {"success": False, "error": f"Failed to launch Symbol Editor: {e}"}++ symbol_name = args.get("symbolName", "")+ library_name = args.get("libraryName", "")+ if symbol_name or library_name:+ return {+ "success": True,+ "output": "Symbol Editor opened. Navigation to a specific symbol is not implemented on macOS.",+ "_hint": ("On macOS, ask the user to type the symbol name in the editor's "+ "search box manually. UI automation requires Accessibility permission."),+ }+ return {"success": True, "output": "Symbol Editor opened."}+++def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict:+ """Open the KiCad Symbol Editor and optionally load a specific symbol.++ Args:+ kicad_info: KiCad detection info dict.+ args: Optional dict with:+ - symbolName: Name of symbol to load (e.g. "ADOM_MCU").+ - libraryName: Name of the library containing the symbol.+ """+ 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.",+ }++ if IS_MACOS:+ return _mac_open_symbol_editor(kicad_info, args or {})++ if not IS_WINDOWS:+ return {"success": False, "error": f"open_symbol_editor not implemented on {sys.platform!r}"}++ symbol_name = args.get("symbolName", "")+ library_name = args.get("libraryName", "")++ # Check if the symbol is already loaded in the Symbol Editor+ if symbol_name:+ sym_hwnd = _find_symbol_editor()+ if sym_hwnd:+ windows = _find_windows_by_title("Symbol Editor")+ for hwnd, title, _ in windows:+ if symbol_name in title:+ _force_foreground(hwnd)+ return {+ "success": True,+ "output": f"Symbol '{symbol_name}' is already loaded in the Symbol Editor.",+ }++ # --- Phase 1: Ensure Symbol Editor is open ---++ sym_hwnd = _find_symbol_editor()+ pathways_tried: list[str] = []++ if not sym_hwnd:+ # Phase 3 Tier 1: try the reverse bridge first. Two sub-paths+ # because Symbol Editor is hosted INSIDE eeschema.exe (not as a+ # separate process):+ # 1a) If eeschema is already running, send menu_id 20390 to+ # its Schematic Editor frame — proven path from Phase 2+ # MVP testing. Opens Symbol Editor as a sub-window in the+ # existing eeschema process.+ # 1b) Else, send menu_id 20011 to kicad.exe Project Manager's+ # Tools menu — this spawns a NEW eeschema.exe with Symbol+ # Editor as its top-level window. Slower (new process) but+ # works from a cold start.+ try:+ from handlers.bridge_client import try_wm_command_via_bridge++ # 1a: prefer eeschema if alive+ tier1 = try_wm_command_via_bridge(+ exe_name="eeschema", menu_id=20390, frame_index=0,+ )+ tier1_source = "eeschema-20390"+ if not tier1.get("success"):+ # 1b: fall through to kicad project manager+ tier1_pm = try_wm_command_via_bridge(+ exe_name="kicad", menu_id=KICAD_SYMBOL_EDITOR_CMD_ID, frame_index=0,+ )+ if tier1_pm.get("success"):+ tier1 = tier1_pm+ tier1_source = "kicad-20011"+ pathways_tried.append(+ f"plugin-{tier1_source}" if tier1.get("success")+ else f"plugin-failed:{tier1.get('errorCode','')}"+ )+ if tier1.get("success"):+ for _ in range(20):+ time.sleep(0.5)+ sym_hwnd = _find_symbol_editor()+ if sym_hwnd:+ break+ if sym_hwnd:+ _force_foreground(sym_hwnd)+ if not symbol_name and not library_name:+ return {+ "success": True,+ "output": f"Symbol Editor opened successfully via bridge ({tier1_source}).",+ "pathway": "plugin",+ "pathwaysTried": pathways_tried,+ "bridgePid": tier1.get("pid"),+ }+ # If symbol_name/library_name was requested, fall+ # through to the navigate step below (uses legacy+ # PowerShell SendKeys path).+ except Exception as e: # pylint: disable=broad-except+ # Bridge import failed (unlikely) — record and fall through.+ pathways_tried.append(f"plugin-import-error:{type(e).__name__}")++ if not sym_hwnd:+ # Tier 2: legacy Win32 PostMessage from outside the KiCad process.+ # Find or launch KiCad project manager+ kicad_hwnd = _find_kicad_manager()++ if not kicad_hwnd:+ kicad_exe = kicad_info.get("kicad_exe")+ if not kicad_exe or not Path(kicad_exe).exists():+ return {+ "success": False,+ "error": "KiCad executable not found",+ "_hint": "Report to the user and ask them to reinstall KiCad — the installation appears corrupted.",+ }++ try:+ subprocess.Popen(+ [kicad_exe],+ stdout=subprocess.DEVNULL,+ stderr=subprocess.DEVNULL,+ )+ except Exception as e:+ return {+ "success": False,+ "error": f"Failed to launch KiCad: {e}",+ "_hint": "Call close_kicad with {\"force\": true} to clear stuck processes, then retry.",+ }++ for _ in range(20):+ time.sleep(0.5)+ kicad_hwnd = _find_kicad_manager()+ if kicad_hwnd:+ break++ if not kicad_hwnd:+ return {+ "success": False,+ "error": "KiCad launched but project manager window not found after 10s",+ "_hint": "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry.",+ }+ time.sleep(2.0)++ # Open Symbol Editor via WM_COMMAND+ user32.PostMessageW(kicad_hwnd, WM_COMMAND, KICAD_SYMBOL_EDITOR_CMD_ID, 0)+ pathways_tried.append("wm_command")++ for _ in range(20):+ time.sleep(0.5)+ sym_hwnd = _find_symbol_editor()+ if sym_hwnd:+ break++ if not sym_hwnd:+ return {+ "success": False,+ "error": "WM_COMMAND sent to KiCad but Symbol Editor did not appear within 10s.",+ "pathwaysTried": pathways_tried,+ "_hint": "Call screenshot_all to check for a modal dialog blocking KiCad, dismiss with send_key escape, then retry.",+ }++ # Wait for Symbol Editor to fully initialize+ time.sleep(1.5)++ # --- Phase 2: Navigate to specific symbol (if requested) ---++ if not symbol_name and not library_name:+ _force_foreground(sym_hwnd)+ return {"success": True, "output": "Symbol Editor opened successfully.",+ "pathwaysTried": pathways_tried}++ search_text = symbol_name or library_name++ # NOTE: Phase 2.aa shipped a `select_and_load_symbol` bridge RPC that+ # would normally replace the legacy PowerShell SendKeys path here.+ # However, the v0.5.0 implementation segfaults eeschema when walking+ # the wx.dataview.DataViewModel — needs a different approach+ # (likely keystroke simulation: WXK_DOWN + WXK_RETURN to the tree).+ # Until v2.ab fixes that, we deliberately DO NOT auto-route through+ # the bridge here. The bridge RPC remains callable manually for+ # users who want to experiment, but the default kicad_open_symbol_editor+ # flow keeps using the proven PowerShell path.++ nav_result = _navigate_to_symbol(sym_hwnd, search_text)++ if nav_result["status"] == "ok":+ return {+ "success": True,+ "output": f"Symbol '{search_text}' loaded in the Symbol Editor: {nav_result['title']}",+ }+ elif nav_result["status"] == "partial":+ return {+ "success": True,+ "output": f"Symbol Editor loaded: {nav_result['title']}",+ }+ elif nav_result["status"] == "noload":+ # The search typed but Ctrl+Shift+E didn't load anything.+ # This can happen if the tree didn't filter in time.+ # Try once more with a longer wait.+ time.sleep(0.5)+ nav_result2 = _navigate_to_symbol(sym_hwnd, search_text)+ if nav_result2["status"] == "ok":+ return {+ "success": True,+ "output": f"Symbol '{search_text}' loaded in the Symbol Editor: {nav_result2['title']}",+ }+ elif nav_result2["status"] == "partial":+ return {+ "success": True,+ "output": f"Symbol Editor loaded: {nav_result2['title']}",+ }+ else:+ return {+ "success": True,+ "output": f"Symbol Editor opened and searched for '{search_text}'. "+ f"Use Ctrl+Shift+E to load the symbol if it didn't auto-load.",+ }+ else:+ # Error from the script+ _force_foreground(sym_hwnd)+ return {+ "success": True,+ "output": f"Symbol Editor opened but navigation failed: {nav_result.get('error', 'unknown')}. "+ f"Search for '{search_text}' manually.",+ }
handlers/run_drc.pyadded+136@@ -0,0 +1,136 @@+"""Handler for run_drc command."""++import json+import subprocess+import tempfile+from pathlib import Path+++def handle_run_drc(kicad_info: dict, args: dict) -> dict:+ """Run DRC on a PCB file using kicad-cli and parse JSON results."""+ 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.",+ }++ # Create a temp file for the JSON output+ tmp_fd, output_path = tempfile.mkstemp(suffix=".json")+ import os+ os.close(tmp_fd)++ try:+ cmd = [+ cli_exe,+ "pcb", "drc",+ "--format", "json",+ "--severity-all",+ "--all-track-errors",+ "--output", output_path,+ str(pcb_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"DRC produced no output. stderr: {result.stderr}",+ }++ with open(output_path, "r", encoding="utf-8") as f:+ drc_data = json.load(f)++ violations = _parse_violations(drc_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"DRC complete: {errors} error(s), {warnings} warning(s), {total} total",+ "data": {+ "violations": violations,+ "summary": {+ "total": total,+ "errors": errors,+ "warnings": warnings,+ },+ "source_file": str(pcb_path),+ },+ }++ except subprocess.TimeoutExpired:+ return {"success": False, "error": "DRC timed out after 120 seconds"}+ except json.JSONDecodeError as e:+ return {"success": False, "error": f"Failed to parse DRC JSON output: {e}"}+ except Exception as e:+ return {"success": False, "error": f"DRC failed: {e}"}+ finally:+ try:+ Path(output_path).unlink(missing_ok=True)+ except OSError:+ pass+++def _parse_violations(drc_data: dict) -> list:+ """Extract structured violation list from DRC JSON output."""+ violations = []++ for v in drc_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)++ for v in drc_data.get("unconnected_items", []):+ violation = {+ "type": "unconnected_items",+ "severity": v.get("severity", "error"),+ "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
parsers/__init__.pyadded+1@@ -0,0 +1 @@+"""KiCad file format parsers."""
parsers/pcb.pyadded+528@@ -0,0 +1,528 @@+"""Parser for KiCad PCB layout files (.kicad_pcb).++Extracts footprints, nets, tracks, vias, zones, layers, and board+dimensions from KiCad PCB files using the S-expression parser.+"""++from __future__ import annotations++from pathlib import Path++from .sexpr import parse_file, find_node, find_nodes, node_value+++def parse_pcb(filepath: str | Path) -> dict:+ """Parse a .kicad_pcb file and return structured data."""+ tree = parse_file(filepath)+ return _extract_pcb(tree, filepath)+++def _extract_pcb(tree: list, filepath: str | Path) -> dict:+ """Extract all relevant data from a parsed PCB tree."""+ return {+ "file": str(filepath),+ "version": node_value(find_node(tree, "version")),+ "generator": node_value(find_node(tree, "generator")),+ "general": _extract_general(find_node(tree, "general")),+ "paper": node_value(find_node(tree, "paper")),+ "title_block": _extract_title_block(find_node(tree, "title_block")),+ "layers": _extract_layers(tree),+ "nets": _extract_nets(tree),+ "footprints": _extract_footprints(tree),+ "segments": _extract_segments(tree),+ "vias": _extract_vias(tree),+ "zones": _extract_zones(tree),+ "dimensions": _extract_board_dimensions(tree),+ }+++def _extract_general(node: list | None) -> dict:+ """Extract general board settings."""+ if not node:+ return {}+ result = {}+ thickness = find_node(node, "thickness")+ if thickness:+ try:+ result["thickness"] = float(node_value(thickness))+ except (ValueError, TypeError):+ pass+ return result+++def _extract_title_block(node: list | None) -> dict:+ """Extract title block metadata."""+ if not node:+ return {}+ result = {}+ for field in ("title", "date", "rev", "company"):+ val = node_value(find_node(node, field))+ if val:+ result[field] = val+ for child in node:+ if isinstance(child, list) and child and child[0] == "comment" and len(child) >= 3:+ result[f"comment_{child[1]}"] = child[2]+ return result+++def _extract_layers(tree: list) -> list[dict]:+ """Extract layer definitions."""+ layers_node = find_node(tree, "layers")+ if not layers_node:+ return []+ layers = []+ for child in layers_node[1:]:+ if isinstance(child, list) and len(child) >= 3:+ layer = {+ "number": child[0] if isinstance(child[0], str) else str(child[0]),+ "name": child[1] if isinstance(child[1], str) else "",+ "type": child[2] if len(child) > 2 and isinstance(child[2], str) else "",+ }+ if len(child) > 3 and isinstance(child[3], str):+ layer["canonical_name"] = child[3]+ layers.append(layer)+ return layers+++def _extract_nets(tree: list) -> list[dict]:+ """Extract net definitions."""+ nets = []+ for node in find_nodes(tree, "net"):+ if len(node) >= 3:+ try:+ nets.append({+ "number": int(node[1]),+ "name": node[2] if isinstance(node[2], str) else "",+ })+ except (ValueError, TypeError):+ pass+ return nets+++def _extract_position(node: list) -> dict | None:+ """Extract position from an (at x y [rot]) node."""+ at_node = find_node(node, "at")+ if not at_node or len(at_node) < 3:+ return None+ try:+ result = {"x": float(at_node[1]), "y": float(at_node[2])}+ if len(at_node) >= 4:+ try:+ result["rotation"] = float(at_node[3])+ except (ValueError, TypeError):+ pass+ return result+ except (ValueError, TypeError):+ return None+++def _extract_property(node: list) -> dict | None:+ """Extract a property node."""+ if not node or node[0] != "property" or len(node) < 3:+ return None+ return {"name": node[1], "value": node[2]}+++def _extract_footprints(tree: list) -> list[dict]:+ """Extract footprint instances from the PCB."""+ footprints = []+ for node in find_nodes(tree, "footprint"):+ if len(node) < 2:+ continue+ fp_name = node[1] if isinstance(node[1], str) else ""++ fp = {+ "footprint": fp_name,+ "layer": node_value(find_node(node, "layer")),+ "uuid": node_value(find_node(node, "uuid")),+ "position": _extract_position(node),+ "locked": find_node(node, "locked") is not None,+ }++ # Extract properties+ props = {}+ for child in node:+ p = _extract_property(child) if isinstance(child, list) else None+ if p:+ props[p["name"]] = p["value"]+ fp["properties"] = props+ fp["reference"] = props.get("Reference", "")+ fp["value"] = props.get("Value", "")++ # Path (links to schematic)+ path_node = find_node(node, "path")+ if path_node:+ fp["path"] = node_value(path_node)++ # Sheet info+ fp["sheetname"] = node_value(find_node(node, "sheetname")) or ""+ fp["sheetfile"] = node_value(find_node(node, "sheetfile")) or ""++ # Attributes+ attr_node = find_node(node, "attr")+ if attr_node and len(attr_node) > 1:+ fp["attr"] = node_value(attr_node)+ else:+ fp["attr"] = ""++ # Count pads+ pads = find_nodes(node, "pad")+ fp["pad_count"] = len(pads)++ # Extract pad details+ pad_list = []+ for pad in pads:+ if len(pad) >= 4:+ pad_info = {+ "number": pad[1] if isinstance(pad[1], str) else "",+ "type": pad[2] if isinstance(pad[2], str) else "",+ "shape": pad[3] if isinstance(pad[3], str) else "",+ }+ pad_at = find_node(pad, "at")+ if pad_at and len(pad_at) >= 3:+ try:+ pad_info["x"] = float(pad_at[1])+ pad_info["y"] = float(pad_at[2])+ except (ValueError, TypeError):+ pass+ size_node = find_node(pad, "size")+ if size_node and len(size_node) >= 3:+ try:+ pad_info["width"] = float(size_node[1])+ pad_info["height"] = float(size_node[2])+ except (ValueError, TypeError):+ pass+ net_node = find_node(pad, "net")+ if net_node and len(net_node) >= 3:+ try:+ pad_info["net_number"] = int(net_node[1])+ pad_info["net_name"] = net_node[2] if isinstance(net_node[2], str) else ""+ except (ValueError, TypeError):+ pass+ pad_list.append(pad_info)+ fp["pads"] = pad_list++ footprints.append(fp)+ return footprints+++def _extract_segments(tree: list) -> list[dict]:+ """Extract track segments."""+ segments = []+ for node in find_nodes(tree, "segment"):+ seg = {"uuid": node_value(find_node(node, "uuid"))}+ start = find_node(node, "start")+ if start and len(start) >= 3:+ try:+ seg["start"] = {"x": float(start[1]), "y": float(start[2])}+ except (ValueError, TypeError):+ pass+ end = find_node(node, "end")+ if end and len(end) >= 3:+ try:+ seg["end"] = {"x": float(end[1]), "y": float(end[2])}+ except (ValueError, TypeError):+ pass+ width = find_node(node, "width")+ if width:+ try:+ seg["width"] = float(node_value(width))+ except (ValueError, TypeError):+ pass+ layer = find_node(node, "layer")+ if layer:+ seg["layer"] = node_value(layer)+ net = find_node(node, "net")+ if net:+ try:+ seg["net"] = int(node_value(net))+ except (ValueError, TypeError):+ pass+ segments.append(seg)+ return segments+++def _extract_vias(tree: list) -> list[dict]:+ """Extract vias."""+ vias = []+ for node in find_nodes(tree, "via"):+ via = {"uuid": node_value(find_node(node, "uuid"))}+ # Via type is the second element if it's a string (e.g., "blind")+ if len(node) >= 2 and isinstance(node[1], str) and node[1] not in ("(", ")"):+ # Check if it looks like a type keyword (not a sub-node)+ at_node = find_node(node, "at")+ if at_node != node[1]:+ via["type"] = node[1]+ at = find_node(node, "at")+ if at and len(at) >= 3:+ try:+ via["position"] = {"x": float(at[1]), "y": float(at[2])}+ except (ValueError, TypeError):+ pass+ size = find_node(node, "size")+ if size:+ try:+ via["size"] = float(node_value(size))+ except (ValueError, TypeError):+ pass+ drill = find_node(node, "drill")+ if drill:+ try:+ via["drill"] = float(node_value(drill))+ except (ValueError, TypeError):+ pass+ layers = find_node(node, "layers")+ if layers:+ via["layers"] = [l for l in layers[1:] if isinstance(l, str)]+ net = find_node(node, "net")+ if net:+ try:+ via["net"] = int(node_value(net))+ except (ValueError, TypeError):+ pass+ vias.append(via)+ return vias+++def _extract_zones(tree: list) -> list[dict]:+ """Extract zone definitions (simplified — no polygon data)."""+ zones = []+ for node in find_nodes(tree, "zone"):+ zone = {"uuid": node_value(find_node(node, "uuid"))}+ net = find_node(node, "net")+ if net:+ try:+ zone["net"] = int(node_value(net))+ except (ValueError, TypeError):+ pass+ net_name = find_node(node, "net_name")+ if net_name:+ zone["net_name"] = node_value(net_name)+ layer = find_node(node, "layer")+ if layer:+ zone["layer"] = node_value(layer)+ layers = find_node(node, "layers")+ if layers:+ zone["layers"] = [l for l in layers[1:] if isinstance(l, str)]+ name = find_node(node, "name")+ if name:+ zone["name"] = node_value(name)+ priority = find_node(node, "priority")+ if priority:+ try:+ zone["priority"] = int(node_value(priority))+ except (ValueError, TypeError):+ pass+ zones.append(zone)+ return zones+++def _extract_board_dimensions(tree: list) -> dict | None:+ """Calculate board dimensions from Edge.Cuts layer graphics.++ Scans for gr_line, gr_rect, gr_arc, and gr_circle on Edge.Cuts.+ Returns bounding box in mm.+ """+ min_x = float("inf")+ min_y = float("inf")+ max_x = float("-inf")+ max_y = float("-inf")+ found = False++ def update_bounds(x, y):+ nonlocal min_x, min_y, max_x, max_y, found+ min_x = min(min_x, x)+ min_y = min(min_y, y)+ max_x = max(max_x, x)+ max_y = max(max_y, y)+ found = True++ for tag in ("gr_line", "gr_rect", "gr_arc", "gr_circle", "gr_poly"):+ for node in find_nodes(tree, tag):+ layer = find_node(node, "layer")+ if not layer or node_value(layer) != "Edge.Cuts":+ continue+ # Extract coordinates from start/end/center nodes+ for coord_tag in ("start", "end", "center", "mid"):+ coord = find_node(node, coord_tag)+ if coord and len(coord) >= 3:+ try:+ update_bounds(float(coord[1]), float(coord[2]))+ except (ValueError, TypeError):+ pass+ # For polygons, scan pts+ pts = find_node(node, "pts")+ if pts:+ for xy in find_nodes(pts, "xy"):+ if len(xy) >= 3:+ try:+ update_bounds(float(xy[1]), float(xy[2]))+ except (ValueError, TypeError):+ pass++ # Also check fp_line/fp_rect on Edge.Cuts inside footprints+ for fp in find_nodes(tree, "footprint"):+ fp_at = find_node(fp, "at")+ fp_x, fp_y, fp_rot = 0.0, 0.0, 0.0+ if fp_at and len(fp_at) >= 3:+ try:+ fp_x = float(fp_at[1])+ fp_y = float(fp_at[2])+ if len(fp_at) >= 4:+ fp_rot = float(fp_at[3])+ except (ValueError, TypeError):+ pass+ for tag in ("fp_line", "fp_rect", "fp_arc"):+ for node in find_nodes(fp, tag):+ layer = find_node(node, "layer")+ if not layer or node_value(layer) != "Edge.Cuts":+ continue+ for coord_tag in ("start", "end", "center", "mid"):+ coord = find_node(node, coord_tag)+ if coord and len(coord) >= 3:+ try:+ # fp_line coordinates are relative to footprint+ x = float(coord[1]) + fp_x+ y = float(coord[2]) + fp_y+ update_bounds(x, y)+ except (ValueError, TypeError):+ pass++ if not found:+ return None++ return {+ "min_x": round(min_x, 3),+ "min_y": round(min_y, 3),+ "max_x": round(max_x, 3),+ "max_y": round(max_y, 3),+ "width": round(max_x - min_x, 3),+ "height": round(max_y - min_y, 3),+ }+++# ── High-level query functions ──────────────────────────────────────+++def list_pcb_footprints(filepath: str | Path) -> list[dict]:+ """List all footprint instances on the PCB.++ Returns a list of dicts with: reference, value, footprint, layer,+ position, rotation, pad_count, attr.+ """+ data = parse_pcb(filepath)+ result = []+ for fp in data["footprints"]:+ pos = fp.get("position") or {}+ result.append({+ "reference": fp["reference"],+ "value": fp["value"],+ "footprint": fp["footprint"],+ "layer": fp["layer"],+ "x": pos.get("x"),+ "y": pos.get("y"),+ "rotation": pos.get("rotation", 0),+ "pad_count": fp["pad_count"],+ "attr": fp["attr"],+ })+ return result+++def get_pcb_statistics(filepath: str | Path) -> dict:+ """Get summary statistics for a PCB layout."""+ data = parse_pcb(filepath)++ # Count copper layers+ copper_layers = [l for l in data["layers"] if l.get("type") in ("signal", "power", "mixed", "copper")]++ # Count unique nets (exclude net 0 which is unconnected)+ active_nets = [n for n in data["nets"] if n["number"] != 0]++ # Count total pads+ total_pads = sum(fp["pad_count"] for fp in data["footprints"])++ # SMD vs through-hole+ smd_count = sum(1 for fp in data["footprints"] if fp.get("attr") == "smd")+ th_count = sum(1 for fp in data["footprints"] if fp.get("attr") == "through_hole")++ return {+ "file": str(filepath),+ "title": data["title_block"].get("title", ""),+ "board_thickness": data["general"].get("thickness"),+ "dimensions": data["dimensions"],+ "copper_layer_count": len(copper_layers),+ "total_layer_count": len(data["layers"]),+ "footprint_count": len(data["footprints"]),+ "smd_footprints": smd_count,+ "through_hole_footprints": th_count,+ "total_pads": total_pads,+ "net_count": len(active_nets),+ "track_count": len(data["segments"]),+ "via_count": len(data["vias"]),+ "zone_count": len(data["zones"]),+ }+++def list_pcb_nets(filepath: str | Path) -> list[dict]:+ """List all nets in the PCB with connected pad count.++ Returns nets sorted by number, excluding net 0 (unconnected).+ """+ data = parse_pcb(filepath)++ # Build pad count per net from footprint pads+ net_pad_count = {}+ for fp in data["footprints"]:+ for pad in fp.get("pads", []):+ net_num = pad.get("net_number")+ if net_num is not None and net_num != 0:+ net_pad_count[net_num] = net_pad_count.get(net_num, 0) + 1++ result = []+ for net in data["nets"]:+ if net["number"] == 0:+ continue+ result.append({+ "number": net["number"],+ "name": net["name"],+ "pad_count": net_pad_count.get(net["number"], 0),+ })++ return sorted(result, key=lambda n: n["number"])+++def find_tracks_by_net(filepath: str | Path, net_name: str) -> dict:+ """Find all track segments and vias for a specific net.++ Args:+ filepath: Path to .kicad_pcb file+ net_name: Net name to search for++ Returns dict with segments, vias, and zone info for the net.+ """+ data = parse_pcb(filepath)++ # Find net number from name+ net_number = None+ for net in data["nets"]:+ if net["name"] == net_name:+ net_number = net["number"]+ break++ if net_number is None:+ return {"error": f"Net '{net_name}' not found", "segments": [], "vias": [], "zones": []}++ segments = [s for s in data["segments"] if s.get("net") == net_number]+ vias = [v for v in data["vias"] if v.get("net") == net_number]+ zones = [z for z in data["zones"] if z.get("net") == net_number]++ return {+ "net_name": net_name,+ "net_number": net_number,+ "segment_count": len(segments),+ "via_count": len(vias),+ "zone_count": len(zones),+ "segments": segments,+ "vias": vias,+ "zones": [{"layer": z.get("layer"), "name": z.get("name"), "priority": z.get("priority")} for z in zones],+ }
parsers/schematic.pyadded+459@@ -0,0 +1,459 @@+"""Parser for KiCad schematic files (.kicad_sch).++Extracts components, nets, wires, labels, and hierarchy information+from KiCad schematics using the S-expression parser.+"""++from __future__ import annotations++from pathlib import Path++from .sexpr import parse_file, find_node, find_nodes, find_nodes_recursive, node_value+++def parse_schematic(filepath: str | Path) -> dict:+ """Parse a .kicad_sch file and return structured data.++ Returns a dict with keys: version, generator, uuid, paper, title_block,+ lib_symbols, components, wires, junctions, labels, no_connects,+ sheets, sheet_instances.+ """+ tree = parse_file(filepath)+ return _extract_schematic(tree, filepath)+++def _extract_schematic(tree: list, filepath: str | Path) -> dict:+ """Extract all relevant data from a parsed schematic tree."""+ return {+ "file": str(filepath),+ "version": node_value(find_node(tree, "version")),+ "generator": node_value(find_node(tree, "generator")),+ "uuid": node_value(find_node(tree, "uuid")),+ "paper": node_value(find_node(tree, "paper")),+ "title_block": _extract_title_block(find_node(tree, "title_block")),+ "components": _extract_components(tree),+ "wires": _extract_wires(tree),+ "labels": _extract_labels(tree),+ "global_labels": _extract_global_labels(tree),+ "hierarchical_labels": _extract_hierarchical_labels(tree),+ "junctions": _extract_junctions(tree),+ "no_connects": _extract_no_connects(tree),+ "sheets": _extract_sheets(tree),+ "power_symbols": _extract_power_symbols(tree),+ }+++def _extract_title_block(node: list | None) -> dict:+ """Extract title block metadata."""+ if not node:+ return {}+ result = {}+ for field in ("title", "date", "rev", "company"):+ val = node_value(find_node(node, field))+ if val:+ result[field] = val+ # Comments are (comment N "text")+ for child in node:+ if isinstance(child, list) and child and child[0] == "comment" and len(child) >= 3:+ result[f"comment_{child[1]}"] = child[2]+ return result+++def _extract_property(node: list) -> dict | None:+ """Extract a property node: (property "Name" "Value" (at x y rot) ...)."""+ if not node or node[0] != "property" or len(node) < 3:+ return None+ name = node[1]+ value = node[2]+ hidden = False+ # Check for (effects ... (hide yes))+ effects = find_node(node, "effects")+ if effects:+ hide = find_node(effects, "hide")+ if hide and node_value(hide) == "yes":+ hidden = True+ return {"name": name, "value": value, "hidden": hidden}+++def _extract_position(node: list) -> dict | None:+ """Extract position from an (at x y [rot]) node."""+ at_node = find_node(node, "at")+ if not at_node or len(at_node) < 3:+ return None+ try:+ result = {"x": float(at_node[1]), "y": float(at_node[2])}+ if len(at_node) >= 4:+ result["rotation"] = float(at_node[3])+ return result+ except (ValueError, TypeError):+ return None+++def _extract_components(tree: list) -> list[dict]:+ """Extract component instances from the schematic.++ Components are (symbol ...) nodes at the sheet level that have a+ (lib_id "...") child — distinct from symbol definitions in lib_symbols+ which don't have lib_id.+ """+ components = []+ for node in tree:+ if not isinstance(node, list) or not node or node[0] != "symbol":+ continue+ lib_id_node = find_node(node, "lib_id")+ if not lib_id_node:+ continue # This is a lib_symbols definition, not an instance++ comp = {+ "lib_id": node_value(lib_id_node),+ "uuid": node_value(find_node(node, "uuid")),+ "position": _extract_position(node),+ "unit": node_value(find_node(node, "unit")),+ "dnp": node_value(find_node(node, "dnp")) == "yes",+ "exclude_from_sim": node_value(find_node(node, "exclude_from_sim")) == "yes",+ "in_bom": node_value(find_node(node, "in_bom")) != "no",+ "on_board": node_value(find_node(node, "on_board")) != "no",+ }++ # Extract properties (Reference, Value, Footprint, etc.)+ props = {}+ for child in node:+ p = _extract_property(child) if isinstance(child, list) else None+ if p:+ props[p["name"]] = p["value"]+ comp["properties"] = props+ comp["reference"] = props.get("Reference", "")+ comp["value"] = props.get("Value", "")+ comp["footprint"] = props.get("Footprint", "")++ # Extract instance references (for hierarchical designs)+ instances_node = find_node(node, "instances")+ if instances_node:+ instances = []+ for proj in find_nodes(instances_node, "project"):+ proj_name = proj[1] if len(proj) > 1 and isinstance(proj[1], str) else ""+ for path_node in find_nodes(proj, "path"):+ path_str = path_node[1] if len(path_node) > 1 and isinstance(path_node[1], str) else ""+ ref_node = find_node(path_node, "reference")+ unit_node = find_node(path_node, "unit")+ instances.append({+ "project": proj_name,+ "path": path_str,+ "reference": node_value(ref_node) or "",+ "unit": node_value(unit_node) or "",+ })+ comp["instances"] = instances++ components.append(comp)+ return components+++def _extract_wires(tree: list) -> list[dict]:+ """Extract wire segments."""+ wires = []+ for node in find_nodes(tree, "wire"):+ pts_node = find_node(node, "pts")+ if not pts_node:+ continue+ points = []+ for xy in find_nodes(pts_node, "xy"):+ if len(xy) >= 3:+ try:+ points.append({"x": float(xy[1]), "y": float(xy[2])})+ except (ValueError, TypeError):+ pass+ wires.append({+ "points": points,+ "uuid": node_value(find_node(node, "uuid")),+ })+ return wires+++def _extract_labels(tree: list) -> list[dict]:+ """Extract net labels."""+ labels = []+ for node in find_nodes(tree, "label"):+ if len(node) < 2:+ continue+ labels.append({+ "name": node[1] if isinstance(node[1], str) else "",+ "position": _extract_position(node),+ "uuid": node_value(find_node(node, "uuid")),+ })+ return labels+++def _extract_global_labels(tree: list) -> list[dict]:+ """Extract global labels."""+ labels = []+ for node in find_nodes(tree, "global_label"):+ if len(node) < 2:+ continue+ labels.append({+ "name": node[1] if isinstance(node[1], str) else "",+ "shape": node_value(find_node(node, "shape")),+ "position": _extract_position(node),+ "uuid": node_value(find_node(node, "uuid")),+ })+ return labels+++def _extract_hierarchical_labels(tree: list) -> list[dict]:+ """Extract hierarchical labels."""+ labels = []+ for node in find_nodes(tree, "hierarchical_label"):+ if len(node) < 2:+ continue+ labels.append({+ "name": node[1] if isinstance(node[1], str) else "",+ "shape": node_value(find_node(node, "shape")),+ "position": _extract_position(node),+ "uuid": node_value(find_node(node, "uuid")),+ })+ return labels+++def _extract_junctions(tree: list) -> list[dict]:+ """Extract junction points."""+ junctions = []+ for node in find_nodes(tree, "junction"):+ junctions.append({+ "position": _extract_position(node),+ "uuid": node_value(find_node(node, "uuid")),+ })+ return junctions+++def _extract_no_connects(tree: list) -> list[dict]:+ """Extract no-connect markers."""+ ncs = []+ for node in find_nodes(tree, "no_connect"):+ ncs.append({+ "position": _extract_position(node),+ "uuid": node_value(find_node(node, "uuid")),+ })+ return ncs+++def _extract_sheets(tree: list) -> list[dict]:+ """Extract sub-sheet references."""+ sheets = []+ for node in find_nodes(tree, "sheet"):+ sheet = {+ "uuid": node_value(find_node(node, "uuid")),+ "position": _extract_position(node),+ }+ # Size+ size_node = find_node(node, "size")+ if size_node and len(size_node) >= 3:+ try:+ sheet["width"] = float(size_node[1])+ sheet["height"] = float(size_node[2])+ except (ValueError, TypeError):+ pass++ # Properties (sheet name, sheet file)+ props = {}+ for child in node:+ p = _extract_property(child) if isinstance(child, list) else None+ if p:+ props[p["name"]] = p["value"]+ sheet["properties"] = props+ sheet["name"] = props.get("Sheetname", props.get("Sheet name", ""))+ sheet["file"] = props.get("Sheetfile", props.get("Sheet file", ""))++ # Pins on the sheet symbol+ pins = []+ for pin_node in find_nodes(node, "pin"):+ if len(pin_node) >= 2:+ pin = {"name": pin_node[1] if isinstance(pin_node[1], str) else ""}+ direction = node_value(find_node(pin_node, "direction"))+ if direction:+ pin["direction"] = direction+ pins.append(pin)+ sheet["pins"] = pins++ sheets.append(sheet)+ return sheets+++def _extract_power_symbols(tree: list) -> list[dict]:+ """Identify power symbols among the components.++ Power symbols have lib_id containing a power flag or their lib_symbols+ entry has the (power) tag.+ """+ # First collect all lib_symbols that are marked as power+ power_lib_ids = set()+ lib_symbols = find_node(tree, "lib_symbols")+ if lib_symbols:+ for sym in find_nodes(lib_symbols, "symbol"):+ if len(sym) < 2:+ continue+ # Check if the symbol has a bare (power) child+ for child in sym:+ if isinstance(child, list) and child == ["power"]:+ power_lib_ids.add(sym[1] if isinstance(sym[1], str) else "")+ break+ if child == "power":+ power_lib_ids.add(sym[1] if isinstance(sym[1], str) else "")+ break++ # Filter components to only power symbols+ power_syms = []+ for node in tree:+ if not isinstance(node, list) or not node or node[0] != "symbol":+ continue+ lib_id_node = find_node(node, "lib_id")+ if not lib_id_node:+ continue+ lib_id = node_value(lib_id_node)+ if lib_id in power_lib_ids:+ props = {}+ for child in node:+ p = _extract_property(child) if isinstance(child, list) else None+ if p:+ props[p["name"]] = p["value"]+ power_syms.append({+ "lib_id": lib_id,+ "value": props.get("Value", ""),+ "reference": props.get("Reference", ""),+ "position": _extract_position(node),+ "uuid": node_value(find_node(node, "uuid")),+ })+ return power_syms+++# ── High-level query functions ──────────────────────────────────────+++def list_schematic_components(filepath: str | Path) -> list[dict]:+ """List all component instances in a schematic.++ Returns a list of dicts with: reference, value, footprint, lib_id,+ position, dnp, in_bom, on_board.+ """+ data = parse_schematic(filepath)+ result = []+ for comp in data["components"]:+ result.append({+ "reference": comp["reference"],+ "value": comp["value"],+ "footprint": comp["footprint"],+ "lib_id": comp["lib_id"],+ "position": comp["position"],+ "dnp": comp["dnp"],+ "in_bom": comp["in_bom"],+ "on_board": comp["on_board"],+ })+ return result+++def get_component_details(filepath: str | Path, reference: str) -> dict | None:+ """Get detailed info for a specific component by reference designator."""+ data = parse_schematic(filepath)+ for comp in data["components"]:+ if comp["reference"] == reference:+ return comp+ # Also check instance references for hierarchical designs+ for inst in comp.get("instances", []):+ if inst.get("reference") == reference:+ result = dict(comp)+ result["reference"] = reference+ result["instance_path"] = inst.get("path", "")+ return result+ return None+++def list_schematic_nets(filepath: str | Path) -> list[dict]:+ """List all net names found in the schematic via labels.++ Returns nets from labels, global_labels, and power symbols.+ """+ data = parse_schematic(filepath)+ nets = {}++ for label in data["labels"]:+ name = label["name"]+ if name not in nets:+ nets[name] = {"name": name, "type": "local", "count": 0}+ nets[name]["count"] += 1++ for label in data["global_labels"]:+ name = label["name"]+ if name not in nets:+ nets[name] = {"name": name, "type": "global", "count": 0}+ nets[name]["count"] += 1++ for ps in data["power_symbols"]:+ name = ps["value"]+ if name not in nets:+ nets[name] = {"name": name, "type": "power", "count": 0}+ nets[name]["count"] += 1++ return list(nets.values())+++def get_schematic_stats(filepath: str | Path) -> dict:+ """Get summary statistics for a schematic."""+ data = parse_schematic(filepath)++ # Count non-power components+ components = [c for c in data["components"]+ if not any(ps["uuid"] == c["uuid"] for ps in data["power_symbols"])]++ # Unique footprints+ footprints = set(c["footprint"] for c in components if c["footprint"])++ return {+ "file": str(filepath),+ "title": data["title_block"].get("title", ""),+ "component_count": len(components),+ "power_symbol_count": len(data["power_symbols"]),+ "wire_count": len(data["wires"]),+ "junction_count": len(data["junctions"]),+ "label_count": len(data["labels"]),+ "global_label_count": len(data["global_labels"]),+ "hierarchical_label_count": len(data["hierarchical_labels"]),+ "no_connect_count": len(data["no_connects"]),+ "sheet_count": len(data["sheets"]),+ "unique_footprints": len(footprints),+ "dnp_count": sum(1 for c in components if c["dnp"]),+ }+++def get_schematic_hierarchy(filepath: str | Path) -> dict:+ """Get the sheet hierarchy starting from this schematic.++ Returns a tree structure of sheets with their sub-sheets.+ """+ data = parse_schematic(filepath)+ base_dir = Path(filepath).parent++ result = {+ "file": str(filepath),+ "title": data["title_block"].get("title", ""),+ "sheets": [],+ }++ for sheet in data["sheets"]:+ sheet_file = sheet.get("file", "")+ sub = {+ "name": sheet.get("name", ""),+ "file": sheet_file,+ }+ # Recursively parse sub-sheets if the file exists+ sub_path = base_dir / sheet_file+ if sub_path.exists():+ try:+ sub_data = parse_schematic(sub_path)+ sub["component_count"] = len(sub_data["components"])+ sub["sheets"] = [+ {"name": s.get("name", ""), "file": s.get("file", "")}+ for s in sub_data["sheets"]+ ]+ except Exception:+ sub["error"] = "Failed to parse sub-sheet"+ result["sheets"].append(sub)++ return result
parsers/sexpr.pyadded+165@@ -0,0 +1,165 @@+"""Lightweight S-expression tokenizer and tree builder for KiCad files.++KiCad uses S-expressions for all file formats (.kicad_sch, .kicad_pcb,+.kicad_sym, sym-lib-table, etc.). This module provides a generic parser+that turns the text into nested Python lists, plus helpers for querying+the resulting tree.++Example:+ >>> tree = parse('(kicad_sch (version 20250114) (generator "eeschema"))')+ >>> tree+ ['kicad_sch', ['version', '20250114'], ['generator', 'eeschema']]+ >>> find_node(tree, 'version')+ ['version', '20250114']+"""++from __future__ import annotations++import re+from pathlib import Path+from typing import Union++# A parsed node is either a string (atom) or a list of nodes.+Node = Union[str, list]++# Regex that matches one token at a time:+# - quoted string (captures content without quotes)+# - open/close paren+# - unquoted atom (anything that isn't whitespace or parens)+_TOKEN_RE = re.compile(r'"((?:[^"\\]|\\.)*)"|(\()|(\))|([^\s()]+)')+++def tokenize(text: str):+ """Yield tokens from S-expression text.++ Tokens are: '(', ')', or string values (with quotes stripped).+ """+ for m in _TOKEN_RE.finditer(text):+ quoted, lparen, rparen, atom = m.groups()+ if lparen:+ yield "("+ elif rparen:+ yield ")"+ elif quoted is not None:+ # Unescape backslash-escaped characters inside quoted strings+ yield quoted.replace('\\"', '"').replace("\\\\", "\\")+ else:+ yield atom+++def parse(text: str) -> Node:+ """Parse S-expression text into a nested list structure.++ Returns the first top-level expression found.+ Raises ValueError if the text is malformed.+ """+ tokens = list(tokenize(text))+ if not tokens:+ raise ValueError("Empty S-expression")++ result, _ = _parse_expr(tokens, 0)+ return result+++def parse_all(text: str) -> list[Node]:+ """Parse all top-level S-expressions from text."""+ tokens = list(tokenize(text))+ results = []+ pos = 0+ while pos < len(tokens):+ if tokens[pos] == "(":+ node, pos = _parse_expr(tokens, pos)+ results.append(node)+ else:+ pos += 1+ return results+++def _parse_expr(tokens: list[str], pos: int) -> tuple[Node, int]:+ """Parse one expression starting at `pos`. Returns (node, new_pos)."""+ if tokens[pos] == "(":+ pos += 1 # skip '('+ children = []+ while pos < len(tokens) and tokens[pos] != ")":+ child, pos = _parse_expr(tokens, pos)+ children.append(child)+ if pos < len(tokens):+ pos += 1 # skip ')'+ return children, pos+ else:+ return tokens[pos], pos + 1+++def parse_file(filepath: str | Path) -> Node:+ """Parse a KiCad file and return the top-level S-expression tree."""+ text = Path(filepath).read_text(encoding="utf-8")+ return parse(text)+++# ── Tree query helpers ──────────────────────────────────────────────+++def find_node(tree: list, tag: str) -> list | None:+ """Find the first direct child node with the given tag name.++ A "node" is a list whose first element is a string matching `tag`.+ Returns None if not found.+ """+ if not isinstance(tree, list):+ return None+ for child in tree:+ if isinstance(child, list) and child and child[0] == tag:+ return child+ return None+++def find_nodes(tree: list, tag: str) -> list[list]:+ """Find all direct child nodes with the given tag name."""+ if not isinstance(tree, list):+ return []+ return [c for c in tree if isinstance(c, list) and c and c[0] == tag]+++def find_node_recursive(tree: list, tag: str) -> list | None:+ """Find the first node with the given tag anywhere in the tree (DFS)."""+ if not isinstance(tree, list):+ return None+ if tree and tree[0] == tag:+ return tree+ for child in tree:+ if isinstance(child, list):+ result = find_node_recursive(child, tag)+ if result is not None:+ return result+ return None+++def find_nodes_recursive(tree: list, tag: str) -> list[list]:+ """Find all nodes with the given tag anywhere in the tree (DFS)."""+ results = []+ _find_nodes_recursive_impl(tree, tag, results)+ return results+++def _find_nodes_recursive_impl(tree, tag, results):+ if not isinstance(tree, list):+ return+ if tree and tree[0] == tag:+ results.append(tree)+ for child in tree:+ if isinstance(child, list):+ _find_nodes_recursive_impl(child, tag, results)+++def node_value(node: list | None) -> str | None:+ """Get the second element of a node as a string, or None."""+ if node and len(node) >= 2 and isinstance(node[1], str):+ return node[1]+ return None+++def node_values(node: list | None) -> list[str]:+ """Get all string elements after the tag name."""+ if not node:+ return []+ return [v for v in node[1:] if isinstance(v, str)]
plugin_payload/adom_bridge.pyadded+3006@@ -0,0 +1,3006 @@+"""adom_bridge.py - Phase 2 reverse bridge for adom-desktop's KiCad v2.++Runs INSIDE every KiCad GUI process (kicad.exe, pcbnew.exe, eeschema.exe,+kicad-cli.exe) thanks to the usercustomize.py loader at KiCad USER_SITE.++What this does+==============+1. Starts a tiny HTTP server on 127.0.0.1:<port> where port is+ 8870 + exe_offset (kicad=0, pcbnew=1, eeschema=2, kicad-cli=3).+2. Writes a discovery JSON to %TEMP%\\adom-kicad-bridge-<exe>.json so+ adom-desktop's Python kicad-bridge can find which port to talk to+ for each KiCad process.+3. Accepts JSON-RPC-style requests over POST /rpc and dispatches+ them onto KiCad's main UI thread via `wx.CallAfter`. Results return+ in the HTTP response body.++Why HTTP instead of WebSocket+=============================+KiCad 10's bundled Python doesn't ship `websockets`, `aiohttp`, or+`tornado`. Plain `http.server` is in stdlib though. For MVP we don't+need server-push - every command is request-response. If a future+phase needs streaming (e.g. DRC progress events), we can add Server-+Sent Events on a side endpoint, still stdlib-only.++MVP methods (this iteration):+ * ping - returns version + pid + exe + uptime_ms.+ * list_frames - returns wx.GetTopLevelWindows() summary+ (frame class, title, hwnd, menubarItemCount).+ * get_menu_ids - returns the full menu walk for every frame.+ * wm_command - posts a wx.EVT_MENU to a target frame so menu+ commands fire on the UI thread.+ * shutdown - stops accepting connections (does NOT kill KiCad).++Threading model+===============+The HTTP server runs on a dedicated daemon thread. Each incoming+request is handled in its own thread (`ThreadingHTTPServer`). When a+request needs wx state, it submits a callable to wx's main thread via+`wx.CallAfter` and waits on a `threading.Event` for the result.++Safety+======+* Listens on 127.0.0.1 only - never bound to a public interface.+* Honors ADOM_KICAD_BRIDGE_DISABLE=1 env var as kill-switch.+* Wraps EVERYTHING in try/except - a bridge failure cannot crash KiCad.+* Daemon thread - dies with the host process, no leaked sockets.+"""++from __future__ import annotations++import json+import os+import socket+import sys+import threading+import time+import traceback+from datetime import datetime+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer+from pathlib import Path+from typing import Any, Callable, Optional++__version__ = "0.8.0"++# Exe-name -> port offset. The KiCad bridge in adom-desktop knows this+# table too. New exes get appended; never renumber existing entries or+# the kicad-bridge's per-exe routing breaks.+_EXE_PORT_OFFSET = {+ "kicad": 0,+ "pcbnew": 1,+ "eeschema": 2,+ "kicad-cli": 3,+ "python": 9, # bundled python.exe (rare, for headless tests)+}+_BASE_PORT = 8870++_OUT_DIR = Path(os.environ.get("TEMP") or os.environ.get("TMP") or "/tmp")+_STARTED_AT_MS = int(time.time() * 1000)++# Module-level mutable state. The HTTP handler reads these.+_state: dict[str, Any] = {+ "exe_name": (Path(sys.executable).stem or "unknown").lower(),+ "pid": os.getpid(),+ "port": None,+ "running": False,+ "request_count": 0,+}+++# --- discovery breadcrumb ---------------------------------------------------++def _discovery_path() -> Path:+ """Per-PID discovery file (Phase 2.x).++ Naming: adom-kicad-bridge-<exe>-<pid>.json so multiple instances of+ the same exe can run side-by-side (e.g. two pcbnew windows on two+ different boards) without overwriting each other. The kicad-bridge+ enumerates all matching files and picks the right plugin for the+ caller. v1.7.5 used <exe>.json which lost multi-instance support."""+ return _OUT_DIR / f"adom-kicad-bridge-{_state['exe_name']}-{_state['pid']}.json"+++def _write_discovery():+ try:+ _discovery_path().write_text(json.dumps({+ "version": __version__,+ "exeName": _state["exe_name"],+ "pid": _state["pid"],+ "port": _state["port"],+ "startedAt": datetime.utcfromtimestamp(_STARTED_AT_MS / 1000).isoformat() + "Z",+ "host": "127.0.0.1",+ "endpoint": f"http://127.0.0.1:{_state['port']}/rpc",+ "transport": "http",+ }, indent=2), encoding="utf-8")+ except OSError:+ pass+++def _remove_discovery():+ try:+ p = _discovery_path()+ if p.exists():+ p.unlink()+ # Also clean any v1.7.5-style exe-only path this process may have+ # written (the legacy bridge_client may write to both paths during+ # transition). Belt-and-suspenders cleanup.+ legacy = _OUT_DIR / f"adom-kicad-bridge-{_state['exe_name']}.json"+ if legacy.exists():+ try:+ payload = json.loads(legacy.read_text(encoding="utf-8"))+ if payload.get("pid") == _state["pid"]:+ legacy.unlink()+ except (OSError, ValueError):+ pass+ except OSError:+ pass+++# --- wx marshalling helpers -------------------------------------------------++def _call_on_ui_thread(fn: Callable[[], Any], timeout: float = 5.0) -> Any:+ """Run `fn` on wx's UI thread; return its result. Re-raises exceptions.++ The HTTP handler runs on a worker thread. wx state can only be+ touched from the main thread that owns the wx.App. wx.CallAfter+ posts onto that thread's idle queue. We hand-roll a result-shuttle+ using a threading.Event so the worker can wait for the UI return.+ """+ import wx # type: ignore[import-not-found]++ holder: dict[str, Any] = {"ok": False, "result": None, "err": None}+ event = threading.Event()++ def _runner():+ try:+ holder["result"] = fn()+ holder["ok"] = True+ except Exception as e: # pylint: disable=broad-except+ holder["err"] = (type(e).__name__, str(e), traceback.format_exc())+ finally:+ event.set()++ wx.CallAfter(_runner)+ if not event.wait(timeout):+ raise TimeoutError(f"wx UI thread did not respond within {timeout:.1f}s")+ if not holder["ok"]:+ kind, msg, tb = holder["err"]+ raise RuntimeError(f"UI-thread error: {kind}: {msg}\n{tb}")+ return holder["result"]+++# --- frame + menu introspection (run on UI thread) -------------------------++def _ui_list_frames() -> list[dict]:+ """Return wx.GetTopLevelWindows() summary. Must run on UI thread."""+ import wx # type: ignore[import-not-found]+ out = []+ for win in wx.GetTopLevelWindows():+ try:+ mb = win.GetMenuBar() if hasattr(win, "GetMenuBar") else None+ mb_count = 0+ if mb is not None:+ try:+ mb_count = sum(+ mb.GetMenu(i).GetMenuItemCount()+ for i in range(mb.GetMenuCount())+ )+ except Exception: # pylint: disable=broad-except+ mb_count = -1+ out.append({+ "frameClass": type(win).__name__,+ "frameTitle": win.GetTitle() if hasattr(win, "GetTitle") else "",+ "hwnd": int(win.GetHandle()) if hasattr(win, "GetHandle") else None,+ "menubarTopCount": mb.GetMenuCount() if mb is not None else 0,+ "menubarTotalItems": mb_count,+ "isShown": bool(win.IsShown()) if hasattr(win, "IsShown") else None,+ })+ except Exception as e: # pylint: disable=broad-except+ out.append({"_frameError": f"{type(e).__name__}: {e}"})+ return out+++def _walk_menu(menu, top_label: str, out: list[dict]):+ for item in menu.GetMenuItems():+ try:+ out.append({+ "topMenu": top_label,+ "label": item.GetItemLabelText(),+ "id": item.GetId(),+ "kind": str(item.GetKind()),+ "accelerator": item.GetItemLabel(),+ "helpString": item.GetHelp() or "",+ })+ if item.IsSubMenu():+ sub = item.GetSubMenu()+ if sub is not None:+ _walk_menu(sub, f"{top_label} > {item.GetItemLabelText()}", out)+ except Exception as e: # pylint: disable=broad-except+ out.append({"topMenu": top_label, "_itemError": f"{type(e).__name__}: {e}"})+++def _ui_get_menu_ids() -> list[dict]:+ import wx # type: ignore[import-not-found]+ out = []+ for win in wx.GetTopLevelWindows():+ try:+ mb = win.GetMenuBar() if hasattr(win, "GetMenuBar") else None+ menus: list[dict] = []+ if mb is not None:+ for i in range(mb.GetMenuCount()):+ _walk_menu(mb.GetMenu(i), mb.GetMenuLabel(i), menus)+ out.append({+ "frameClass": type(win).__name__,+ "frameTitle": win.GetTitle() if hasattr(win, "GetTitle") else "",+ "menuItems": menus,+ })+ except Exception as e: # pylint: disable=broad-except+ out.append({"_frameError": f"{type(e).__name__}: {e}"})+ return out+++_BOARD_TITLE_NEEDLES = ("pcb editor", "footprint editor")+_SCHEMATIC_TITLE_NEEDLES = ("schematic editor", "symbol editor")+++def _ui_get_board_info() -> dict:+ """Return PCB board statistics. Only meaningful inside pcbnew.exe+ (the only KiCad GUI exe that exposes a usable `pcbnew` Python+ module). Returns hasBoard:False if called from a different process+ or if pcbnew has no board loaded.+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable (wrong process — must be inside pcbnew.exe)"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}+ try:+ bb = board.GetBoundingBox()+ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "trackCount": len(board.GetTracks()),+ "footprintCount": len(board.GetFootprints()),+ "drawingsCount": len(board.GetDrawings()),+ "netCount": board.GetNetCount(),+ "copperLayerCount": board.GetCopperLayerCount(),+ "designSettingsLayerCount": board.GetCopperLayerCount(),+ "boundingBoxMm": {+ "widthMm": bb.GetWidth() / 1_000_000,+ "heightMm": bb.GetHeight() / 1_000_000,+ "centerXMm": bb.GetCenter().x / 1_000_000,+ "centerYMm": bb.GetCenter().y / 1_000_000,+ },+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_schematic_info() -> dict:+ """Return schematic info — eeschema doesn't expose its sheet/symbol+ state as a Python module like pcbnew does, so this is a best-effort+ introspection via wx frame walk.++ Returns the schematic editor's wx frame title (which encodes the+ filename + dirty state, e.g. "design.kicad_sch — Schematic Editor"+ or "untitled [Unsaved] — Schematic Editor"). Phase 2.y will replace+ this with a real eeschema Python binding once KiCad 11 ships its+ IPC API (`kipy`).+ """+ try:+ import wx # type: ignore[import-not-found]+ except ImportError:+ return {"available": False, "_error": "wx not loaded — must be inside eeschema.exe"}+ try:+ frames = []+ for win in wx.GetTopLevelWindows():+ title = ""+ if hasattr(win, "GetTitle"):+ try:+ title = win.GetTitle()+ except Exception: # pylint: disable=broad-except+ pass+ tl = title.lower()+ if any(n in tl for n in _SCHEMATIC_TITLE_NEEDLES):+ # Parse filename from title. Format examples:+ # "untitled [Unsaved] — Schematic Editor"+ # "design [Unsaved] — Schematic Editor"+ # "C:\\path\\design.kicad_sch — Schematic Editor"+ # "design.kicad_sch [Unsaved] — Schematic Editor"+ hwnd = int(win.GetHandle()) if hasattr(win, "GetHandle") else None+ fr = {+ "frameClass": type(win).__name__,+ "frameTitle": title,+ "hwnd": hwnd,+ "isShown": bool(win.IsShown()) if hasattr(win, "IsShown") else None,+ }+ # Decode the title shape+ parts = title.rsplit("—", 1)+ if len(parts) == 2:+ file_part = parts[0].strip()+ editor_part = parts[1].strip()+ fr["editorKind"] = editor_part # "Schematic Editor" / "Symbol Editor"+ # Strip trailing " [Unsaved]" if present+ dirty = "[Unsaved]" in file_part+ file_part = file_part.replace("[Unsaved]", "").strip()+ fr["fileName"] = file_part or None+ fr["isUnsaved"] = dirty+ fr["isUntitled"] = file_part.lower() in ("untitled", "")+ frames.append(fr)+ return {+ "available": True,+ "frames": frames,+ "frameCount": len(frames),+ }+ except Exception as e: # pylint: disable=broad-except+ return {"available": False, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_open_editors() -> dict:+ """Cross-process best-effort: list every wx top-level window in THIS+ process and classify it.++ Each bridge process only sees its own frames. The kicad-bridge+ (server.py) calls this on every plugin instance to build a+ cross-process picture — see `handle_open_editors` in bridge_client.py.+ """+ try:+ import wx # type: ignore[import-not-found]+ except ImportError:+ return {"available": False, "_error": "wx not loaded"}+ try:+ editors: list[dict] = []+ for win in wx.GetTopLevelWindows():+ title = ""+ if hasattr(win, "GetTitle"):+ try:+ title = win.GetTitle()+ except Exception: # pylint: disable=broad-except+ pass+ cls = type(win).__name__+ hwnd = int(win.GetHandle()) if hasattr(win, "GetHandle") else None+ tl = title.lower()+ kind = "unknown"+ if "schematic editor" in tl:+ kind = "schematic_editor"+ elif "symbol editor" in tl:+ kind = "symbol_editor"+ elif "pcb editor" in tl:+ kind = "pcb_editor"+ elif "footprint editor" in tl:+ kind = "footprint_editor"+ elif "kicad" in tl and "editor" not in tl:+ kind = "project_manager"+ editors.append({+ "kind": kind,+ "frameClass": cls,+ "frameTitle": title,+ "hwnd": hwnd,+ "isShown": bool(win.IsShown()) if hasattr(win, "IsShown") else None,+ })+ return {+ "available": True,+ "exeName": _state["exe_name"],+ "pid": _state["pid"],+ "editors": editors,+ "editorCount": len(editors),+ }+ except Exception as e: # pylint: disable=broad-except+ return {"available": False, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_pcb_footprints() -> dict:+ """Return every footprint on the board with position, rotation, layer,+ reference, value, and footprint library ID. Only meaningful inside+ pcbnew.exe with a board loaded.++ Coordinates are in millimeters (KiCad internally uses nm; we divide+ by 1_000_000). Rotation is in degrees.+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable (wrong process)"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}+ try:+ footprints = []+ for fp in board.GetFootprints():+ try:+ pos = fp.GetPosition()+ fpid = fp.GetFPID()+ # FPID is "library:name" — split for clarity+ lib_name = ""+ item_name = ""+ try:+ lib_name = fpid.GetLibNickname().wx_str()+ item_name = fpid.GetLibItemName().wx_str()+ except Exception: # pylint: disable=broad-except+ pass+ # Bounding box (excludes courtyard, includes drawn shapes)+ bb = fp.GetBoundingBox()+ pad_count = fp.GetPadCount() if hasattr(fp, "GetPadCount") else len(fp.Pads())+ footprints.append({+ "ref": fp.GetReference(),+ "value": fp.GetValue(),+ "libNickname": lib_name,+ "itemName": item_name,+ "fpid": f"{lib_name}:{item_name}" if lib_name else item_name,+ "x_mm": pos.x / 1_000_000,+ "y_mm": pos.y / 1_000_000,+ "rotationDeg": fp.GetOrientation().AsDegrees(),+ "layer": fp.GetLayerName(),+ "isFlipped": bool(fp.IsFlipped()),+ "isLocked": bool(fp.IsLocked()),+ "padCount": pad_count,+ "boundingBoxMm": {+ "minXMm": bb.GetX() / 1_000_000,+ "minYMm": bb.GetY() / 1_000_000,+ "widthMm": bb.GetWidth() / 1_000_000,+ "heightMm": bb.GetHeight() / 1_000_000,+ },+ })+ except Exception as e: # pylint: disable=broad-except+ footprints.append({"_error": f"{type(e).__name__}: {e}"})+ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "footprintCount": len(footprints),+ "footprints": footprints,+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_pcb_layers() -> dict:+ """Return the active layer stackup: every enabled copper + technical+ layer with its name, layer ID, and type (copper / dielectric / mask /+ silk / paste / fab / other). pcbnew only.+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}+ try:+ # Enumerate every layer the board enables. Layer IDs are in+ # PCB_LAYER_ID enum but we'll skip naming the enum and use the+ # board's own canonical names.+ layers = []+ for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):+ if not board.IsLayerEnabled(layer_id):+ continue+ name = board.GetLayerName(layer_id)+ # Classify. `pcbnew.IsCopperLayer(id)` is the authoritative+ # source for copper (works across KiCad 9/10 layer-ID+ # rearrangements). Fall back to name-pattern classification+ # for the rest. The first iteration of this code used+ # `layer_id < 32` which over-counted because KiCad interleaves+ # mask/silk into the low-ID range.+ kind = "other"+ lname = name.lower()+ is_copper = False+ try:+ is_copper = bool(pcbnew.IsCopperLayer(layer_id))+ except Exception: # pylint: disable=broad-except+ pass+ if is_copper:+ kind = "copper"+ elif "mask" in lname:+ kind = "mask"+ elif "silkscreen" in lname or "silk" in lname:+ kind = "silk"+ elif "paste" in lname:+ kind = "paste"+ elif "courtyard" in lname:+ kind = "courtyard"+ elif "adhesive" in lname or "adhes" in lname:+ kind = "adhesive"+ elif "edge" in lname:+ kind = "edge"+ elif "fab" in lname:+ kind = "fab"+ elif "comments" in lname or "comment" in lname:+ kind = "comments"+ elif "user" in lname or "drawings" in lname:+ kind = "user"+ elif "margin" in lname:+ kind = "margin"+ layers.append({+ "id": layer_id,+ "name": name,+ "kind": kind,+ "isCopper": is_copper,+ })+ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "copperLayerCount": board.GetCopperLayerCount(),+ "totalEnabledLayers": len(layers),+ "layers": layers,+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_pcb_nets() -> dict:+ """Return every net on the board with name, code, pad count, and+ total track length (in mm). pcbnew only."""+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}+ try:+ # Walk all tracks once, building a per-net length map.+ # GetLength() is in nm.+ net_lengths_nm: dict[int, int] = {}+ for tr in board.GetTracks():+ try:+ netcode = tr.GetNetCode()+ length = tr.GetLength() if hasattr(tr, "GetLength") else 0+ net_lengths_nm[netcode] = net_lengths_nm.get(netcode, 0) + length+ except Exception: # pylint: disable=broad-except+ pass++ # Walk pads to count pads per net.+ net_pad_counts: dict[int, int] = {}+ for fp in board.GetFootprints():+ for pad in fp.Pads():+ try:+ netcode = pad.GetNetCode()+ net_pad_counts[netcode] = net_pad_counts.get(netcode, 0) + 1+ except Exception: # pylint: disable=broad-except+ pass++ # Get nets from board.GetNetInfo().+ nets = []+ try:+ netinfo = board.GetNetInfo()+ for netcode in range(board.GetNetCount()):+ try:+ net = netinfo.GetNetItem(netcode)+ if net is None:+ continue+ name = net.GetNetname() if hasattr(net, "GetNetname") else ""+ nets.append({+ "code": netcode,+ "name": name,+ "padCount": net_pad_counts.get(netcode, 0),+ "trackLengthMm": net_lengths_nm.get(netcode, 0) / 1_000_000,+ })+ except Exception as e: # pylint: disable=broad-except+ nets.append({"code": netcode, "_error": f"{type(e).__name__}: {e}"})+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}++ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "netCount": len(nets),+ "totalTrackLengthMm": sum(n.get("trackLengthMm", 0) for n in nets if isinstance(n.get("trackLengthMm"), (int, float))),+ "nets": nets,+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_pcb_design_rules() -> dict:+ """Return the board's design rules: minimum track width, via dimensions,+ clearances. pcbnew only.++ These come from board.GetDesignSettings() and BOARD_DESIGN_SETTINGS.+ Units in millimeters."""+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}+ try:+ ds = board.GetDesignSettings()+ # Best-effort field extraction — different KiCad versions expose+ # different attrs. Wrap each in its own try/except.+ rules: dict = {"hasBoard": True, "fileName": board.GetFileName()}++ def _safe_attr(name, getter, divisor=1_000_000):+ try:+ val = getter()+ rules[name] = val / divisor if divisor else val+ except Exception as e: # pylint: disable=broad-except+ rules[name] = None+ rules.setdefault("_errors", {})[name] = f"{type(e).__name__}: {e}"++ # Track widths (the first entry is the "default" / "current" track width)+ try:+ tw = list(ds.m_TrackWidthList) if hasattr(ds, "m_TrackWidthList") else []+ rules["trackWidthsMm"] = [w / 1_000_000 for w in tw]+ except Exception as e: # pylint: disable=broad-except+ rules["trackWidthsMm"] = None+ rules.setdefault("_errors", {})["trackWidthsMm"] = f"{type(e).__name__}: {e}"++ # Via dimensions+ try:+ vds = list(ds.m_ViasDimensionsList) if hasattr(ds, "m_ViasDimensionsList") else []+ rules["viaDimensions"] = [+ {"diameterMm": v.m_Diameter / 1_000_000, "drillMm": v.m_Drill / 1_000_000}+ for v in vds+ ]+ except Exception as e: # pylint: disable=broad-except+ rules["viaDimensions"] = None+ rules.setdefault("_errors", {})["viaDimensions"] = f"{type(e).__name__}: {e}"++ # Min clearance+ _safe_attr("minClearanceMm", lambda: ds.GetSmallestClearanceValue() if hasattr(ds, "GetSmallestClearanceValue") else ds.m_MinClearance)++ # Hole sizes+ _safe_attr("minThroughHoleMm", lambda: ds.m_MinThroughDrill if hasattr(ds, "m_MinThroughDrill") else 0)+ _safe_attr("minViaDiameterMm", lambda: ds.m_ViasMinSize)++ return rules+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++# ── Track / pad / via / drawing introspection ─────────────────────────++def _ui_get_pcb_tracks() -> dict:+ """Return every track (segment, arc, or via) with position, layer,+ width, and net info. pcbnew only.++ PCB_TRACK covers normal segment tracks; PCB_ARC tracks; PCB_VIA via+ objects. We classify each by type so callers can filter."""+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}++ try:+ tracks: list[dict] = []+ # Build a netcode → netname map so each track row carries the name+ try:+ netinfo = board.GetNetInfo()+ netname_for: dict[int, str] = {}+ for code in range(board.GetNetCount()):+ try:+ n = netinfo.GetNetItem(code)+ if n is not None and hasattr(n, "GetNetname"):+ netname_for[code] = n.GetNetname()+ except Exception: # pylint: disable=broad-except+ pass+ except Exception: # pylint: disable=broad-except+ netname_for = {}++ for tr in board.GetTracks():+ try:+ cls = type(tr).__name__+ start = tr.GetStart() if hasattr(tr, "GetStart") else None+ end = tr.GetEnd() if hasattr(tr, "GetEnd") else None+ width = tr.GetWidth() if hasattr(tr, "GetWidth") else 0+ netcode = tr.GetNetCode() if hasattr(tr, "GetNetCode") else 0+ layer_id = tr.GetLayer() if hasattr(tr, "GetLayer") else -1+ row = {+ "type": cls,+ "layer": layer_id,+ "layerName": board.GetLayerName(layer_id) if layer_id >= 0 else None,+ "widthMm": width / 1_000_000 if width else None,+ "netCode": netcode,+ "netName": netname_for.get(netcode, ""),+ "lengthMm": (tr.GetLength() / 1_000_000) if hasattr(tr, "GetLength") else None,+ }+ if start is not None:+ row["startMm"] = {"x": start.x / 1_000_000, "y": start.y / 1_000_000}+ if end is not None:+ row["endMm"] = {"x": end.x / 1_000_000, "y": end.y / 1_000_000}+ tracks.append(row)+ except Exception as e: # pylint: disable=broad-except+ tracks.append({"_error": f"{type(e).__name__}: {e}"})++ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "trackCount": len(tracks),+ "tracks": tracks,+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_pcb_vias() -> dict:+ """Return every via (PCB_VIA in pcbnew's track list).++ Differentiates through-hole / blind / buried via type. pcbnew only.+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}++ try:+ # Net name lookup (same pattern as get_pcb_tracks)+ try:+ netinfo = board.GetNetInfo()+ netname_for: dict[int, str] = {}+ for code in range(board.GetNetCount()):+ n = netinfo.GetNetItem(code)+ if n is not None and hasattr(n, "GetNetname"):+ netname_for[code] = n.GetNetname()+ except Exception: # pylint: disable=broad-except+ netname_for = {}++ # Map via-type enum → string. KiCad pcbnew.VIATYPE_THROUGH etc.+ type_names = {}+ for attr in ("VIATYPE_THROUGH", "VIATYPE_BLIND_BURIED", "VIATYPE_MICROVIA"):+ try:+ type_names[getattr(pcbnew, attr)] = attr.replace("VIATYPE_", "").lower()+ except AttributeError:+ pass++ vias: list[dict] = []+ for tr in board.GetTracks():+ if type(tr).__name__ != "PCB_VIA":+ continue+ try:+ pos = tr.GetPosition()+ width = tr.GetWidth()+ drill = tr.GetDrillValue() if hasattr(tr, "GetDrillValue") else 0+ vtype = tr.GetViaType() if hasattr(tr, "GetViaType") else None+ netcode = tr.GetNetCode()+ top_layer = tr.TopLayer() if hasattr(tr, "TopLayer") else None+ bottom_layer = tr.BottomLayer() if hasattr(tr, "BottomLayer") else None+ vias.append({+ "positionMm": {"x": pos.x / 1_000_000, "y": pos.y / 1_000_000},+ "diameterMm": width / 1_000_000,+ "drillMm": drill / 1_000_000,+ "viaType": type_names.get(vtype, str(vtype)) if vtype is not None else None,+ "topLayer": top_layer,+ "topLayerName": board.GetLayerName(top_layer) if top_layer is not None else None,+ "bottomLayer": bottom_layer,+ "bottomLayerName": board.GetLayerName(bottom_layer) if bottom_layer is not None else None,+ "netCode": netcode,+ "netName": netname_for.get(netcode, ""),+ })+ except Exception as e: # pylint: disable=broad-except+ vias.append({"_error": f"{type(e).__name__}: {e}"})++ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "viaCount": len(vias),+ "vias": vias,+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_pcb_pads() -> dict:+ """Return every pad across every footprint. pcbnew only.++ Includes pad name (e.g. "1", "GND"), parent footprint reference,+ absolute position (post-rotation), drill (if THT), layers mask.+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}++ try:+ # Net name lookup+ try:+ netinfo = board.GetNetInfo()+ netname_for: dict[int, str] = {}+ for code in range(board.GetNetCount()):+ n = netinfo.GetNetItem(code)+ if n is not None and hasattr(n, "GetNetname"):+ netname_for[code] = n.GetNetname()+ except Exception: # pylint: disable=broad-except+ netname_for = {}++ pads: list[dict] = []+ for fp in board.GetFootprints():+ try:+ fp_ref = fp.GetReference()+ for pad in fp.Pads():+ try:+ pos = pad.GetPosition()+ size = pad.GetSize() if hasattr(pad, "GetSize") else None+ drill = pad.GetDrillSize() if hasattr(pad, "GetDrillSize") else None+ netcode = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0+ # GetShape returns an enum; stringify+ try:+ shape_id = pad.GetShape()+ except Exception: # pylint: disable=broad-except+ shape_id = None+ row = {+ "footprintRef": fp_ref,+ "padName": pad.GetName() if hasattr(pad, "GetName") else "",+ "positionMm": {"x": pos.x / 1_000_000, "y": pos.y / 1_000_000},+ "shapeId": shape_id,+ "netCode": netcode,+ "netName": netname_for.get(netcode, ""),+ }+ if size is not None:+ try:+ row["sizeMm"] = {"x": size.x / 1_000_000, "y": size.y / 1_000_000}+ except Exception: # pylint: disable=broad-except+ pass+ if drill is not None:+ try:+ row["drillMm"] = {"x": drill.x / 1_000_000, "y": drill.y / 1_000_000}+ except Exception: # pylint: disable=broad-except+ pass+ # Pad-stack attribute: THT, SMD, or NPTH+ try:+ row["attribute"] = pad.GetAttribute()+ except Exception: # pylint: disable=broad-except+ pass+ pads.append(row)+ except Exception as e: # pylint: disable=broad-except+ pads.append({"footprintRef": fp_ref, "_error": f"{type(e).__name__}: {e}"})+ except Exception as e: # pylint: disable=broad-except+ pads.append({"_error": f"footprint walk: {type(e).__name__}: {e}"})++ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "padCount": len(pads),+ "pads": pads,+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_pcb_drawings() -> dict:+ """Return every drawing on the board (silk text, edge cuts, dimensions,+ user-drawn lines). pcbnew only.++ Includes type, layer, position. For text, includes the text content.+ For shapes, includes start/end/center/radius as applicable."""+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}++ try:+ drawings: list[dict] = []+ for dr in board.GetDrawings():+ try:+ cls = type(dr).__name__+ layer_id = dr.GetLayer() if hasattr(dr, "GetLayer") else -1+ row = {+ "type": cls,+ "layer": layer_id,+ "layerName": board.GetLayerName(layer_id) if layer_id >= 0 else None,+ }+ # Text drawings expose GetText() / GetTextHeight() / GetTextWidth()+ if hasattr(dr, "GetText"):+ try:+ row["text"] = dr.GetText()+ except Exception: # pylint: disable=broad-except+ pass+ # Position (works for text + most shapes)+ if hasattr(dr, "GetPosition"):+ try:+ pos = dr.GetPosition()+ row["positionMm"] = {"x": pos.x / 1_000_000, "y": pos.y / 1_000_000}+ except Exception: # pylint: disable=broad-except+ pass+ # Shape start/end (lines, arcs, rects)+ for name, getter in (("startMm", "GetStart"), ("endMm", "GetEnd"), ("centerMm", "GetCenter")):+ if hasattr(dr, getter):+ try:+ pt = getattr(dr, getter)()+ row[name] = {"x": pt.x / 1_000_000, "y": pt.y / 1_000_000}+ except Exception: # pylint: disable=broad-except+ pass+ # Arc radius / shape kind+ if hasattr(dr, "GetRadius"):+ try:+ r = dr.GetRadius()+ row["radiusMm"] = r / 1_000_000+ except Exception: # pylint: disable=broad-except+ pass+ drawings.append(row)+ except Exception as e: # pylint: disable=broad-except+ drawings.append({"_error": f"{type(e).__name__}: {e}"})++ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "drawingCount": len(drawings),+ "drawings": drawings,+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_net_topology(net_name: str) -> dict:+ """Graph traversal from a named net outward through the components+ that pin it. pcbnew only. Phase 2.ac+.++ Returns the full footprint-level topology for one net:+ - Direct pads (footprint refs + pad names on this net)+ - Reachable components (every footprint that has a pad on this+ net) with their OTHER nets — i.e. "if I trace VCC, what+ components touch it, and what other signals do they bridge?"++ Example output for the demo board's VCC net:+ {+ "netName": "VCC",+ "padCount": 6, # 6 pads share this net+ "componentCount": 3, # spread across 3 footprints+ "components": [+ {"ref": "R1", "value": "10k", "padsOnNet": ["1"],+ "otherPads": [{"padName": "2", "netName": "OUT"}]},+ ...+ ],+ "bridgedNets": ["OUT", "GND"] # nets reachable in 1 hop+ }+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"found": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"found": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"found": False, "_error": "no board loaded"}++ try:+ # Net code lookup+ target_netcode = None+ netname_for: dict[int, str] = {}+ netinfo = board.GetNetInfo()+ for code in range(board.GetNetCount()):+ n = netinfo.GetNetItem(code)+ if n is not None and hasattr(n, "GetNetname"):+ nm = n.GetNetname()+ netname_for[code] = nm+ if nm == net_name:+ target_netcode = code++ if target_netcode is None:+ return {+ "found": False,+ "netName": net_name,+ "_error": f"net {net_name!r} not found",+ "_hint": "Call get_pcb_nets to see the list of net names on this board. Net names are case-sensitive.",+ }++ # Walk every footprint's pads. For each pad on the target net,+ # record the parent footprint. For each footprint that has at+ # least one pad on the target net, also list its OTHER pads+ # (so the caller can see what other signals that component+ # bridges).+ components: dict[str, dict] = {} # ref → {value, padsOnNet, otherPads}+ bridged_netcodes: set[int] = set()++ for fp in board.GetFootprints():+ try:+ fp_ref = fp.GetReference()+ pads_on_net = []+ other_pads = []+ for pad in fp.Pads():+ nc = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0+ pad_name = pad.GetName() if hasattr(pad, "GetName") else ""+ if nc == target_netcode:+ pads_on_net.append(pad_name)+ elif nc != 0:+ other_pads.append({+ "padName": pad_name,+ "netCode": nc,+ "netName": netname_for.get(nc, ""),+ })+ if pads_on_net:+ components[fp_ref] = {+ "ref": fp_ref,+ "value": fp.GetValue() if hasattr(fp, "GetValue") else "",+ "footprintId": str(fp.GetFPID().GetLibItemName().wx_str()) if hasattr(fp, "GetFPID") else "",+ "padsOnNet": pads_on_net,+ "otherPads": other_pads,+ }+ for op in other_pads:+ bridged_netcodes.add(op["netCode"])+ except Exception as e: # pylint: disable=broad-except+ components[f"_error_{len(components)}"] = {"_error": f"{type(e).__name__}: {e}"}++ # Total pad count (sum across components)+ pad_count = sum(len(c.get("padsOnNet", [])) for c in components.values())++ # Bridged net names (the ones component-co-located with our target net)+ bridged_nets = sorted({netname_for.get(nc, "") for nc in bridged_netcodes if netname_for.get(nc)})++ return {+ "found": True,+ "netName": net_name,+ "netCode": target_netcode,+ "padCount": pad_count,+ "componentCount": len(components),+ "components": sorted(components.values(), key=lambda c: c.get("ref", "")),+ "bridgedNets": bridged_nets,+ "_hint": (+ f"Net {net_name!r} reaches {pad_count} pad(s) across {len(components)} "+ f"component(s). Components co-located with this net bridge to: "+ f"{', '.join(bridged_nets) if bridged_nets else '(no other nets — net is isolated)'}. "+ "For length-matching on each branch, call get_track_paths_by_net "+ f"{{\"netName\":{net_name!r}}}. For pad-level details (positions, sizes), "+ "filter get_pcb_pads to this footprintRef list."+ ),+ }+ except Exception as e: # pylint: disable=broad-except+ return {"found": False, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_pcb_zones() -> dict:+ """Return every copper zone / keepout on the board.++ Each zone: {netCode, netName, layer, layerName, priority, isFilled,+ isKeepout, clearanceMm, minWidthMm, hatchStyle, polygonVertexCount,+ boundingBoxMm}.+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}++ try:+ # Net-name lookup+ try:+ netinfo = board.GetNetInfo()+ netname_for: dict[int, str] = {}+ for code in range(board.GetNetCount()):+ n = netinfo.GetNetItem(code)+ if n is not None and hasattr(n, "GetNetname"):+ netname_for[code] = n.GetNetname()+ except Exception: # pylint: disable=broad-except+ netname_for = {}++ zones: list[dict] = []+ # board.Zones() returns ZONE objects (PCB_ZONE). Each can span+ # multiple layers in KiCad 7+ but we report per-layer where useful.+ try:+ zone_iter = list(board.Zones())+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"board.Zones() failed: {type(e).__name__}: {e}"}++ for z in zone_iter:+ try:+ # Layer set — KiCad 7+ zones can be multi-layer+ layer_ids: list[int] = []+ try:+ lset = z.GetLayerSet()+ # LayerSet has Seq() returning vector of layer IDs+ for lid in lset.Seq():+ layer_ids.append(int(lid))+ except Exception: # pylint: disable=broad-except+ # Fallback to single-layer+ try:+ layer_ids = [z.GetLayer()]+ except Exception: # pylint: disable=broad-except+ pass+ netcode = z.GetNetCode() if hasattr(z, "GetNetCode") else 0+ # Bounding box+ try:+ bb = z.GetBoundingBox()+ bbox_mm = {+ "minXMm": bb.GetX() / 1_000_000,+ "minYMm": bb.GetY() / 1_000_000,+ "widthMm": bb.GetWidth() / 1_000_000,+ "heightMm": bb.GetHeight() / 1_000_000,+ }+ except Exception: # pylint: disable=broad-except+ bbox_mm = None+ # Polygon vertex count (one polygon per outline)+ vertex_count = 0+ try:+ # GetOutline() returns SHAPE_POLY_SET+ poly = z.Outline()+ vertex_count = poly.FullPointCount() if hasattr(poly, "FullPointCount") else 0+ except Exception: # pylint: disable=broad-except+ pass+ zones.append({+ "netCode": netcode,+ "netName": netname_for.get(netcode, ""),+ "layers": layer_ids,+ "layerNames": [board.GetLayerName(lid) for lid in layer_ids],+ "priority": z.GetAssignedPriority() if hasattr(z, "GetAssignedPriority") else None,+ "isFilled": bool(z.IsFilled()) if hasattr(z, "IsFilled") else None,+ "isKeepout": bool(z.GetIsRuleArea()) if hasattr(z, "GetIsRuleArea") else None,+ "clearanceMm": (z.GetLocalClearance() / 1_000_000) if hasattr(z, "GetLocalClearance") else None,+ "minWidthMm": (z.GetMinThickness() / 1_000_000) if hasattr(z, "GetMinThickness") else None,+ "polygonVertexCount": vertex_count,+ "boundingBoxMm": bbox_mm,+ })+ except Exception as e: # pylint: disable=broad-except+ zones.append({"_error": f"{type(e).__name__}: {e}"})++ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "zoneCount": len(zones),+ "zones": zones,+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_footprint_connections(ref: str) -> dict:+ """For a given footprint reference (e.g. "U1"), return its pin/pad-level+ connectivity. Each pad with a non-empty net reports which OTHER pads+ (on other footprints) share that net.++ Returns: {found, ref, pads:[{padName, netCode, netName,+ connectedTo:[{ref, padName}]}]}+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"found": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"found": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"found": False, "_error": "no board loaded"}++ try:+ # Build the per-net pad index once: netcode → [(ref, padName)]+ per_net: dict[int, list[tuple[str, str]]] = {}+ target_fp = None+ for fp in board.GetFootprints():+ try:+ fp_ref = fp.GetReference()+ if fp_ref == ref:+ target_fp = fp+ for pad in fp.Pads():+ nc = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0+ nm = pad.GetName() if hasattr(pad, "GetName") else ""+ if nc:+ per_net.setdefault(nc, []).append((fp_ref, nm))+ except Exception: # pylint: disable=broad-except+ continue++ if target_fp is None:+ return {+ "found": False,+ "ref": ref,+ "error": f"footprint with reference {ref!r} not found",+ "_hint": "Call get_pcb_footprints to see the full list of refs on the board.",+ }++ # Net name lookup+ try:+ netinfo = board.GetNetInfo()+ netname_for: dict[int, str] = {}+ for code in range(board.GetNetCount()):+ n = netinfo.GetNetItem(code)+ if n is not None and hasattr(n, "GetNetname"):+ netname_for[code] = n.GetNetname()+ except Exception: # pylint: disable=broad-except+ netname_for = {}++ pads_out: list[dict] = []+ for pad in target_fp.Pads():+ try:+ nc = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0+ pad_name = pad.GetName() if hasattr(pad, "GetName") else ""+ row: dict = {+ "padName": pad_name,+ "netCode": nc,+ "netName": netname_for.get(nc, ""),+ "connectedTo": [],+ }+ if nc:+ # Other pads on the same net (excluding self)+ for (other_ref, other_pad) in per_net.get(nc, []):+ if other_ref == ref and other_pad == pad_name:+ continue+ row["connectedTo"].append({"ref": other_ref, "padName": other_pad})+ pads_out.append(row)+ except Exception as e: # pylint: disable=broad-except+ pads_out.append({"_error": f"{type(e).__name__}: {e}"})++ return {+ "found": True,+ "ref": ref,+ "value": target_fp.GetValue() if hasattr(target_fp, "GetValue") else "",+ "padCount": len(pads_out),+ "pads": pads_out,+ }+ except Exception as e: # pylint: disable=broad-except+ return {"found": False, "_error": f"{type(e).__name__}: {e}"}+++def _ui_run_drc_inprocess() -> dict:+ """Trigger DRC inside the running pcbnew via the DRC menu item, wait+ a short while, then read get_drc_markers. Replaces shelling out to+ `kicad-cli pcb drc` (which takes ~3-5s subprocess startup).++ NOTE: pcbnew's full DRC_ENGINE isn't exposed in KiCad 10's SWIG+ bindings (DRC_ENGINE.RunTests is C++-only). The cleanest available+ path is to POST the DRC menu command (menu_id 20088 on the PCB+ Editor frame), which opens the DRC dialog and triggers a run. We+ then poll get_drc_markers until markers appear or timeout.++ Trade-off: this opens the DRC dialog visually (the user sees it+ flash). True invisible DRC needs KiCad 11's kipy / a future+ DRC_ENGINE Python binding.++ Returns: {hasBoard, dialogTriggered, markers:[{...}],+ markerCount, bySeverityCounts, _hint}.+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ import wx # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew/wx not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False, "_error": "no board loaded"}++ # Find PCB Editor frame+ target_frame = None+ for win in wx.GetTopLevelWindows():+ try:+ title = win.GetTitle() if hasattr(win, "GetTitle") else ""+ except Exception: # pylint: disable=broad-except+ title = ""+ if "pcb editor" in title.lower():+ target_frame = win+ break+ if target_frame is None:+ return {+ "hasBoard": True,+ "dialogTriggered": False,+ "_error": "PCB Editor frame not found",+ "_hint": "Open pcbnew with a board first via kicad_open_board.",+ }++ # Post WM_COMMAND 20088 (DRC) via Win32 PostMessage — proven path+ # for posting menu commands to KiCad. wx.PostEvent fires the event+ # but KiCad's TOOL_DISPATCHER doesn't pick wx events up; only Win32+ # WM_COMMAND.+ try:+ import ctypes+ import ctypes.wintypes as wt+ WM_COMMAND = 0x0111+ DRC_MENU_ID = 20088+ hwnd = int(target_frame.GetHandle())+ user32 = ctypes.windll.user32+ PostMessageW = user32.PostMessageW+ PostMessageW.argtypes = [wt.HWND, ctypes.c_uint, wt.WPARAM, wt.LPARAM]+ PostMessageW.restype = wt.BOOL+ ok = PostMessageW(hwnd, WM_COMMAND, DRC_MENU_ID, 0)+ except Exception as e: # pylint: disable=broad-except+ return {+ "hasBoard": True,+ "dialogTriggered": False,+ "_error": f"PostMessage failed: {type(e).__name__}: {e}",+ }++ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "dialogTriggered": bool(ok),+ "menuIdPosted": DRC_MENU_ID,+ "_hint": (+ "DRC menu command posted to pcbnew. The DRC dialog will appear "+ "and KiCad will run the rule check. Wait ~2-5s (depending on "+ "board complexity), then call get_drc_markers to read the "+ "results. Note: this opens the DRC dialog visually — the user "+ "sees it flash. For invisible DRC, fall back to "+ "`kicad-cli pcb drc --output drc.json` (current behavior of "+ "kicad_run_drc) until KiCad 11's kipy IPC API ships a true "+ "in-process trigger."+ ),+ }+++def _ui_get_track_paths_by_net(net_name: str) -> dict:+ """Group all tracks belonging to a net into connected polyline paths.++ A "path" is a chain of track segments where consecutive segments share+ an endpoint. Returns one entry per path with start/end + total length.+ Useful for length-matching analysis (DDR data lines, RF traces, etc.)+ where you want each branch of the net measured independently.++ Returns: {hasBoard, netName, pathCount, paths:[{+ segmentCount, totalLengthMm,+ startMm:{x,y}, endMm:{x,y},+ layers:[layerName],+ segments:[{startMm, endMm, widthMm, layer}]+ }]}+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}++ try:+ # Resolve netcode for the requested net+ netcode = None+ try:+ netinfo = board.GetNetInfo()+ for code in range(board.GetNetCount()):+ n = netinfo.GetNetItem(code)+ if n is not None and hasattr(n, "GetNetname"):+ if n.GetNetname() == net_name:+ netcode = code+ break+ except Exception: # pylint: disable=broad-except+ pass++ if netcode is None:+ return {+ "hasBoard": True,+ "found": False,+ "netName": net_name,+ "_error": f"net {net_name!r} not found",+ "_hint": "Call get_pcb_nets to see the list of net names on this board.",+ }++ # Collect every segment-like track on this net. Skip vias (they're+ # zero-length points; user can correlate via get_pcb_vias on the+ # same net).+ segs = []+ for tr in board.GetTracks():+ try:+ if tr.GetNetCode() != netcode:+ continue+ cls = type(tr).__name__+ if cls == "PCB_VIA":+ continue+ start = tr.GetStart()+ end = tr.GetEnd()+ width = tr.GetWidth()+ layer_id = tr.GetLayer()+ segs.append({+ "startMm": (start.x / 1_000_000, start.y / 1_000_000),+ "endMm": (end.x / 1_000_000, end.y / 1_000_000),+ "widthMm": width / 1_000_000,+ "layer": layer_id,+ "layerName": board.GetLayerName(layer_id),+ "lengthMm": (tr.GetLength() / 1_000_000) if hasattr(tr, "GetLength") else 0,+ })+ except Exception: # pylint: disable=broad-except+ continue++ if not segs:+ return {+ "hasBoard": True,+ "found": True,+ "netName": net_name,+ "pathCount": 0,+ "paths": [],+ "_hint": f"Net {net_name!r} exists but has 0 routed track segments. "+ "It's still defined (pads on it) but unrouted.",+ }++ # Build adjacency by endpoint match. Two segments share an endpoint+ # when the (rounded-to-µm) coordinates match. Use 0.0001mm = 100nm+ # tolerance to handle floating-point quirks.+ def _key(pt):+ return (round(pt[0], 4), round(pt[1], 4))++ # endpoint -> set of segment indices touching it+ endpoint_to_segs: dict = {}+ for i, s in enumerate(segs):+ for ep in (_key(s["startMm"]), _key(s["endMm"])):+ endpoint_to_segs.setdefault(ep, []).append(i)++ # Union-find on segment indices: two segs are in the same component+ # if they share any endpoint.+ parent = list(range(len(segs)))+ def find(i):+ while parent[i] != i:+ parent[i] = parent[parent[i]]+ i = parent[i]+ return i+ def union(a, b):+ ra, rb = find(a), find(b)+ if ra != rb:+ parent[ra] = rb++ for seg_list in endpoint_to_segs.values():+ for j in range(1, len(seg_list)):+ union(seg_list[0], seg_list[j])++ # Group segs by component root+ components: dict = {}+ for i in range(len(segs)):+ components.setdefault(find(i), []).append(i)++ paths = []+ for _root, seg_ids in components.items():+ comp_segs = [segs[i] for i in seg_ids]+ total_length = sum(s["lengthMm"] for s in comp_segs)+ # Pick "start" / "end" as the two endpoints that appear in only+ # ONE segment of this component (the dangling ends). If the+ # component is a loop, just pick the first segment's endpoints.+ endpoint_count: dict = {}+ for s in comp_segs:+ for ep in (_key(s["startMm"]), _key(s["endMm"])):+ endpoint_count[ep] = endpoint_count.get(ep, 0) + 1+ ends = [ep for ep, c in endpoint_count.items() if c == 1]+ if len(ends) >= 2:+ start_ep = {"x": ends[0][0], "y": ends[0][1]}+ end_ep = {"x": ends[1][0], "y": ends[1][1]}+ else:+ start_ep = {"x": comp_segs[0]["startMm"][0], "y": comp_segs[0]["startMm"][1]}+ end_ep = {"x": comp_segs[0]["endMm"][0], "y": comp_segs[0]["endMm"][1]}+ layers = sorted(set(s["layerName"] for s in comp_segs))+ paths.append({+ "segmentCount": len(comp_segs),+ "totalLengthMm": total_length,+ "startMm": start_ep,+ "endMm": end_ep,+ "layers": layers,+ "segments": [+ {+ "startMm": {"x": s["startMm"][0], "y": s["startMm"][1]},+ "endMm": {"x": s["endMm"][0], "y": s["endMm"][1]},+ "widthMm": s["widthMm"],+ "layer": s["layer"],+ "layerName": s["layerName"],+ "lengthMm": s["lengthMm"],+ }+ for s in comp_segs+ ],+ })++ # Sort paths longest-first (typical use is "which is the longest+ # branch on this DDR data line?")+ paths.sort(key=lambda p: p["totalLengthMm"], reverse=True)++ return {+ "hasBoard": True,+ "found": True,+ "netName": net_name,+ "pathCount": len(paths),+ "paths": paths,+ "_hint": (+ "Each `path` is a connected chain of track segments sharing "+ "endpoints. Paths are sorted longest-first. For length-"+ "matching (DDR/RF), compare `totalLengthMm` across nets — "+ "e.g. fetch the same path-count for each DDR data line and "+ "diff their totals. Vias are excluded (they have zero "+ "horizontal length); for via inventory call get_pcb_vias "+ "filtered to this net's code."+ ),+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++def _ui_get_drc_markers() -> dict:+ """Read existing DRC markers from the board.++ Markers are produced by the most recent DRC run (manual or kicad-cli).+ This method does NOT trigger DRC; for that, use the legacy kicad_run_drc+ verb (which shells out to kicad-cli). A future 2.ab method will trigger+ in-process via pcbnew's DRC_ENGINE.++ Returns: {hasBoard, markerCount, markers:[{severity, type, message,+ positionMm, layer?, ...}]}+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False}++ try:+ # Markers live in board.GetItems() but we want only PCB_MARKER_T.+ # GetMarkers() returns a vector<PCB_MARKER*>.+ try:+ marker_iter = list(board.Markers()) if hasattr(board, "Markers") else []+ except Exception: # pylint: disable=broad-except+ marker_iter = []+ # Fallback: walk board.GetItems(PCB_MARKER_T) — older SWIG bindings+ if not marker_iter and hasattr(pcbnew, "PCB_MARKER_T"):+ try:+ # Some SWIG bindings expose this; many don't. Best-effort.+ pass+ except Exception: # pylint: disable=broad-except+ pass++ # Build severity / type enum lookup+ severity_names: dict[int, str] = {}+ for attr, label in (+ ("SEVERITY_ERROR", "error"),+ ("SEVERITY_WARNING", "warning"),+ ("SEVERITY_ACTION", "action"),+ ("SEVERITY_INFO", "info"),+ ("SEVERITY_EXCLUSION", "exclusion"),+ ("SEVERITY_IGNORE", "ignore"),+ ):+ try:+ severity_names[getattr(pcbnew, attr)] = label+ except AttributeError:+ pass++ markers: list[dict] = []+ for m in marker_iter:+ try:+ row: dict = {}+ # Severity+ if hasattr(m, "GetSeverity"):+ try:+ sev = m.GetSeverity()+ row["severity"] = severity_names.get(sev, str(sev))+ row["severityCode"] = sev+ except Exception: # pylint: disable=broad-except+ pass+ # The reporter holds the message+ if hasattr(m, "GetReporter"):+ try:+ rep = m.GetReporter()+ if rep is not None:+ if hasattr(rep, "GetErrorMessage"):+ row["message"] = rep.GetErrorMessage()+ if hasattr(rep, "GetErrorText"):+ row["errorText"] = rep.GetErrorText()+ except Exception as e: # pylint: disable=broad-except+ row.setdefault("_errors", {})["reporter"] = f"{type(e).__name__}: {e}"+ # Layer+ if hasattr(m, "GetLayer"):+ try:+ lid = m.GetLayer()+ row["layer"] = lid+ row["layerName"] = board.GetLayerName(lid) if lid >= 0 else None+ except Exception: # pylint: disable=broad-except+ pass+ # Position+ if hasattr(m, "GetPosition"):+ try:+ p = m.GetPosition()+ row["positionMm"] = {"x": p.x / 1_000_000, "y": p.y / 1_000_000}+ except Exception: # pylint: disable=broad-except+ pass+ # Marker class+ row["markerClass"] = type(m).__name__+ markers.append(row)+ except Exception as e: # pylint: disable=broad-except+ markers.append({"_error": f"{type(e).__name__}: {e}"})++ # Group by severity for the AI's summary+ by_severity: dict[str, int] = {}+ for r in markers:+ by_severity[r.get("severity", "unknown")] = by_severity.get(r.get("severity", "unknown"), 0) + 1++ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "markerCount": len(markers),+ "bySeverityCounts": by_severity,+ "markers": markers,+ "_note": "Markers reflect the LAST DRC run (manual or kicad-cli). To trigger a fresh DRC, use kicad_run_drc; this method only READS existing markers.",+ }+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}+++# ── PCB export (in-process via pcbnew.PLOT_CONTROLLER) ────────────────++def _ui_export_pcb_plot(fmt_label: str, params: dict) -> dict:+ """Export PCB to SVG or PDF using pcbnew.PLOT_CONTROLLER. In-process —+ no kicad-cli subprocess startup cost (~3-5s per call savings).++ fmt_label: "svg" or "pdf"++ params (all optional):+ outputDir: destination dir (default: %TEMP%/adom-kicad-export/)+ layers: list of layer names to plot (default: copper+silk)+ accepts canonical names like "F.Cu", "B.SilkS", "Edge.Cuts"+ mirror: bool, mirror the plot (default false)+ plotFrameRef:bool, plot the page border (default false)+ useAuxOrigin:bool, use aux origin instead of page origin (default false)+ subtractMaskFromSilk: bool (default false)+ drillMarks: one of "none", "small", "full" (default "small")+ filenamePrefix: stem prepended to each layer's filename (default: board basename)+ """+ try:+ import pcbnew # type: ignore[import-not-found]+ except ImportError:+ return {"hasBoard": False, "_error": "pcbnew not importable"}+ try:+ board = pcbnew.GetBoard()+ except Exception as e: # pylint: disable=broad-except+ return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}+ if board is None:+ return {"hasBoard": False, "_error": "no board loaded"}++ # Resolve format+ fmt = fmt_label.lower()+ if fmt == "svg":+ plot_format = pcbnew.PLOT_FORMAT_SVG+ elif fmt == "pdf":+ plot_format = pcbnew.PLOT_FORMAT_PDF+ else:+ return {"hasBoard": True, "_error": f"unsupported export format: {fmt_label}"}++ # Output directory+ try:+ out_dir_str = params.get("outputDir") or os.path.join(+ os.environ.get("TEMP") or "/tmp",+ "adom-kicad-export",+ )+ out_dir = Path(out_dir_str)+ out_dir.mkdir(parents=True, exist_ok=True)+ except OSError as e:+ return {"hasBoard": True, "_error": f"could not create output dir: {e}"}++ # Layers to plot+ requested_layer_names = params.get("layers")+ layer_ids: list[int] = []+ if requested_layer_names:+ # User-specified list+ for name in requested_layer_names:+ for lid in range(pcbnew.PCB_LAYER_ID_COUNT):+ if board.IsLayerEnabled(lid) and board.GetLayerName(lid) == name:+ layer_ids.append(lid)+ break+ else:+ # Couldn't match — record but continue+ pass+ else:+ # Default: every enabled copper layer + F.SilkS + B.SilkS + Edge.Cuts+ for lid in range(pcbnew.PCB_LAYER_ID_COUNT):+ if not board.IsLayerEnabled(lid):+ continue+ name = board.GetLayerName(lid)+ try:+ is_copper = bool(pcbnew.IsCopperLayer(lid))+ except Exception: # pylint: disable=broad-except+ is_copper = False+ if is_copper or name in ("F.Silkscreen", "B.Silkscreen", "F.SilkS", "B.SilkS", "Edge.Cuts"):+ layer_ids.append(lid)++ # Configure plot controller+ try:+ plot_controller = pcbnew.PLOT_CONTROLLER(board)+ except AttributeError:+ try:+ plot_controller = pcbnew.PCB_PLOT_CONTROLLER(board)+ except AttributeError:+ return {"hasBoard": True, "_error": "PLOT_CONTROLLER not available in this pcbnew SWIG binding"}++ plot_options = plot_controller.GetPlotOptions()+ plot_options.SetFormat(plot_format)+ plot_options.SetOutputDirectory(str(out_dir))+ plot_options.SetPlotFrameRef(bool(params.get("plotFrameRef", False)))+ plot_options.SetMirror(bool(params.get("mirror", False)))+ plot_options.SetUseAuxOrigin(bool(params.get("useAuxOrigin", False)))+ try:+ plot_options.SetSubtractMaskFromSilk(bool(params.get("subtractMaskFromSilk", False)))+ except Exception: # pylint: disable=broad-except+ pass+ # Drill marks+ drill_marks = (params.get("drillMarks") or "small").lower()+ try:+ # KiCad's PCB_PLOT_PARAMS exposes the enum as nested constants+ drill_enum = {+ "none": pcbnew.DRILL_MARKS_NO_DRILL_SHAPE,+ "small": pcbnew.DRILL_MARKS_SMALL_DRILL_SHAPE,+ "full": pcbnew.DRILL_MARKS_FULL_DRILL_SHAPE,+ }.get(drill_marks)+ if drill_enum is not None and hasattr(plot_options, "SetDrillMarksType"):+ plot_options.SetDrillMarksType(drill_enum)+ except Exception: # pylint: disable=broad-except+ pass++ # Filename prefix. KiCad's PLOT_CONTROLLER always prepends the board+ # basename to the OpenPlotfile sheet name. So:+ # - Default (no prefix): sheet_name=<layer> → "<basename>-<layer>.svg"+ # - With prefix: sheet_name=<prefix>-<layer> → "<basename>-<prefix>-<layer>.svg"+ # The previous default used basename as the prefix, producing the+ # ugly "<basename>-<basename>-<layer>.svg" doubling.+ board_path = board.GetFileName()+ board_basename = Path(board_path).stem if board_path else "board" # noqa: F841 — kept for caller refs+ prefix = params.get("filenamePrefix")++ # Plot each layer+ written: list[dict] = []+ plotted_count = 0+ try:+ for lid in layer_ids:+ try:+ plot_controller.SetLayer(lid)+ layer_name = board.GetLayerName(lid)+ # OpenPlotfile takes (sheetName, format, sheetDescription).+ # For multi-layer SVG/PDF this opens a new file per layer.+ # KiCad always prepends the board basename to the output+ # filename, so the actual path is:+ # <output_dir>/<board_basename>-<sheetName>.<ext>+ sheet_name = f"{prefix}-{layer_name}" if prefix else layer_name+ plot_controller.OpenPlotfile(sheet_name, plot_format, "")+ plot_controller.PlotLayer()+ plot_controller.ClosePlot()+ plotted_count += 1+ # Compute the path KiCad wrote to. Different KiCad versions+ # name it slightly differently; we search the output dir+ # after the fact and capture whatever .svg/.pdf was just+ # created.+ # First attempt: predictable naming.+ ext = "svg" if fmt == "svg" else "pdf"+ # KiCad's output filename is usually <prefix>-<safe_layer_name>.<ext>+ # where safe_layer_name replaces dots with underscores.+ # Just sample the dir for the most recently modified matching file.+ candidates = sorted(out_dir.glob(f"*-{layer_name.replace('.', '_')}.{ext}"),+ key=lambda p: p.stat().st_mtime, reverse=True)+ if not candidates:+ candidates = sorted(out_dir.glob(f"*.{ext}"),+ key=lambda p: p.stat().st_mtime, reverse=True)+ actual_path = str(candidates[0]) if candidates else None+ written.append({+ "layerId": lid,+ "layerName": layer_name,+ "filePath": actual_path,+ "fileSize": candidates[0].stat().st_size if candidates else None,+ })+ except Exception as e: # pylint: disable=broad-except+ written.append({+ "layerId": lid,+ "layerName": board.GetLayerName(lid) if lid >= 0 else None,+ "_error": f"{type(e).__name__}: {e}",+ })+ finally:+ try:+ plot_controller.ClosePlot()+ except Exception: # pylint: disable=broad-except+ pass++ return {+ "hasBoard": True,+ "fileName": board.GetFileName(),+ "format": fmt,+ "outputDir": str(out_dir),+ "plottedLayerCount": plotted_count,+ "files": written,+ }+++# ── Library navigation (Symbol Editor / Footprint Editor search-box) ──++def _find_search_ctrl_recursive(window) -> object:+ """Recursively find a wx.SearchCtrl (or wx.TextCtrl with placeholder+ containing 'search') under `window`. Returns the control or None.++ wx widget trees are arbitrary deep — Symbol Editor's library panel+ has the search box nested inside a wxAuiManager pane inside a+ wxSplitter inside the main frame. Walk every descendant."""+ try:+ import wx # type: ignore[import-not-found]+ except ImportError:+ return None+ try:+ # SearchCtrl is the obvious target+ if isinstance(window, wx.SearchCtrl):+ return window+ # KiCad sometimes uses a plain TextCtrl with a search hint+ if isinstance(window, wx.TextCtrl):+ try:+ hint = window.GetHint() if hasattr(window, "GetHint") else ""+ if hint and "search" in hint.lower():+ return window+ except Exception: # pylint: disable=broad-except+ pass+ for child in window.GetChildren():+ found = _find_search_ctrl_recursive(child)+ if found is not None:+ return found+ except Exception: # pylint: disable=broad-except+ pass+ return None+++def _ui_navigate_in_editor(editor_kind: str, query: str) -> dict:+ """Generic library-search navigation. Finds the editor's wx frame,+ locates its search control, sets the value, and posts a text-changed+ event so the live filter activates.++ editor_kind: "symbol" or "footprint" (matched against frame title)+ query: text to type into the search box (substring match in KiCad's+ library filter)++ Returns {found, searched, frameTitle, searchCtrlClass, error?}.++ NOTE: This is a NON-DESTRUCTIVE filter. It does NOT click "load symbol"+ or open the editor with the result selected — it just sets the search+ text so the user (or a follow-up wm_command) can act on the filtered list.+ """+ try:+ import wx # type: ignore[import-not-found]+ except ImportError:+ return {"found": False, "_error": "wx not loaded"}++ needle = f"{editor_kind} editor".lower()+ target_frame = None+ for win in wx.GetTopLevelWindows():+ try:+ title = win.GetTitle() if hasattr(win, "GetTitle") else ""+ except Exception: # pylint: disable=broad-except+ title = ""+ if needle in title.lower():+ target_frame = win+ break++ if target_frame is None:+ return {+ "found": False,+ "error": f"no '{editor_kind} editor' frame is open",+ "_hint": f"call kicad_open_{editor_kind}_editor first to launch the editor.",+ }++ sctrl = _find_search_ctrl_recursive(target_frame)+ if sctrl is None:+ return {+ "found": False,+ "frameTitle": target_frame.GetTitle(),+ "error": "could not locate search control inside the editor frame",+ "_hint": "the wx widget hierarchy may have changed in this KiCad version. "+ "Report the KiCad version and try the legacy PowerShell SendKeys path.",+ }++ try:+ sctrl.SetValue(query)+ # Post EVT_TEXT so the live filter activates+ try:+ evt = wx.CommandEvent(wx.EVT_TEXT.typeId, sctrl.GetId())+ evt.SetEventObject(sctrl)+ evt.SetString(query)+ wx.PostEvent(sctrl, evt)+ except Exception: # pylint: disable=broad-except+ pass+ return {+ "found": True,+ "frameTitle": target_frame.GetTitle(),+ "searchCtrlClass": type(sctrl).__name__,+ "searched": query,+ "_hint": (+ f"Set the library search filter to {query!r}. "+ f"This is FILTER-ONLY — it does NOT load the {editor_kind}. "+ f"The user can now SEE the matching entries in the library "+ f"tree. To filter AND load the first match in one call, use "+ f"select_and_load_{editor_kind} instead (v0.6.0+, "+ f"keystroke-based, no longer segfaults). To revert the "+ f"filter, call navigate_{editor_kind} with an empty query."+ ),+ }+ except Exception as e: # pylint: disable=broad-except+ return {+ "found": False,+ "frameTitle": target_frame.GetTitle(),+ "error": f"failed to set search value: {type(e).__name__}: {e}",+ }+++def _ui_navigate_symbol(symbol_name: str) -> dict:+ return _ui_navigate_in_editor("symbol", symbol_name)+++def _ui_navigate_footprint(footprint_name: str) -> dict:+ return _ui_navigate_in_editor("footprint", footprint_name)+++def _find_dataview_ctrl_recursive(window) -> object:+ """Recursively find the wx.dataview.DataViewCtrl that holds the+ library tree in Symbol Editor / Footprint Editor. Returns the+ control or None."""+ try:+ import wx # type: ignore[import-not-found]+ import wx.dataview # type: ignore[import-not-found]+ except ImportError:+ return None+ try:+ if isinstance(window, wx.dataview.DataViewCtrl):+ return window+ for child in window.GetChildren():+ found = _find_dataview_ctrl_recursive(child)+ if found is not None:+ return found+ except Exception: # pylint: disable=broad-except+ pass+ return None+++def _find_treectrl_recursive(window) -> object:+ """Older KiCad versions or alternative panels may use a plain TreeCtrl.+ Fallback when DataViewCtrl isn't found."""+ try:+ import wx # type: ignore[import-not-found]+ except ImportError:+ return None+ try:+ if isinstance(window, wx.TreeCtrl):+ return window+ for child in window.GetChildren():+ found = _find_treectrl_recursive(child)+ if found is not None:+ return found+ except Exception: # pylint: disable=broad-except+ pass+ return None+++def _ui_select_and_load(editor_kind: str, name: str, use_send_input: bool = False) -> dict:+ """Filter the editor's library tree by `name`, then KEYSTROKE-SIMULATE+ "first match selected + activated" to load it.++ v0.8.0 (Phase 2.ad): with `use_send_input=True`, foreground+ acquisition uses `_ui_robust_focus` — the AttachThreadInput dance+ PLUS a SendInput LEFT-CLICK at the search ctrl's screen center.+ Previously v0.7.0 used a naive `SetForegroundWindow` which Windows+ frequently downgraded to a taskbar flash, causing SendInput+ keystrokes to land in the terminal/VS Code instead of KiCad. Now+ the click guarantees foreground + child focus; cursor pos is+ snapshotted + restored.++ v0.7.0 (Phase 2.ac): added `use_send_input` opt-in. When True,+ after setting the search filter we focus the editor frame ++ inject real Win32 keystrokes via SendInput (vs the default+ wx.PostEvent which may not propagate to KiCad's+ TOOL_DISPATCHER). SendInput is more reliable but briefly steals+ foreground focus.++ v0.6.0 rewrite: previous impl walked the wx.dataview.DataViewModel+ to find the first leaf, then posted EVT_DATAVIEW_ITEM_ACTIVATED on+ the picked item. That worked on small libraries but SEGFAULTED+ eeschema on real KiCad symbol libraries.++ Returns: {found, loaded?, frameTitle, searchedFor, transport,+ keystrokeMode, error?, _hint?}.+ """+ try:+ import wx # type: ignore[import-not-found]+ except ImportError:+ return {"found": False, "_error": "wx not loaded"}++ # Step 1+2: navigate (sets the search filter via _ui_navigate_in_editor)+ nav = _ui_navigate_in_editor(editor_kind, name)+ if not nav.get("found"):+ return {**nav, "loaded": False}++ # Step 3: re-resolve the editor frame and the search control to send+ # keystrokes to.+ needle = f"{editor_kind} editor".lower()+ target_frame = None+ for win in wx.GetTopLevelWindows():+ try:+ title = win.GetTitle() if hasattr(win, "GetTitle") else ""+ except Exception: # pylint: disable=broad-except+ title = ""+ if needle in title.lower():+ target_frame = win+ break+ if target_frame is None:+ return {"found": True, "loaded": False, "searchedFor": name,+ "error": "editor frame disappeared between navigate and activate"}++ sctrl = _find_search_ctrl_recursive(target_frame)+ if sctrl is None:+ return {+ "found": True,+ "loaded": False,+ "searchedFor": name,+ "frameTitle": target_frame.GetTitle(),+ "error": "search control not found",+ "_hint": "navigate_in_editor returned found=True but the search ctrl "+ "vanished. Likely a wx widget hierarchy change in this "+ "KiCad version. Fall back to the legacy PowerShell SendKeys "+ "path via kicad_open_symbol_editor.",+ }++ # Step 4: dispatch the Down + Enter keystrokes.+ #+ # Two transports:+ # - wx.PostEvent (default): enqueues wx.KeyEvent on wx's event+ # loop. Doesn't steal foreground. BUT often doesn't propagate+ # to KiCad's native Win32 / TOOL_DISPATCHER handler.+ # - SendInput (opt-in via use_send_input): real Win32 keyboard+ # events. Always reaches KiCad. BUT briefly steals foreground.+ keystroke_mode = "send_input" if use_send_input else "wx_post_event"++ try:+ # Focus the search ctrl regardless of transport — SendInput+ # needs the right window focused, wx.PostEvent benefits from it.+ try:+ sctrl.SetFocus()+ except Exception: # pylint: disable=broad-except+ pass++ # Give wx one cycle to actually move focus (otherwise SendInput+ # injects into the previous foreground window — typically the+ # CLI terminal or VS Code).+ try:+ wx.YieldIfNeeded()+ except Exception: # pylint: disable=broad-except+ pass++ if use_send_input:+ # v0.8.0 (Phase 2.ad): robust foreground acquisition before+ # the SendInput keystrokes. v0.7.0 used a naive+ # `SetForegroundWindow(hwnd)` + 150ms sleep, which Windows+ # frequently downgraded to a taskbar flash because the+ # bridge thread didn't have recent user-input context —+ # SendInput keystrokes then landed in whatever window WAS+ # foreground (terminal, VS Code, etc.) instead of KiCad,+ # and verify_loaded always reported hasLoaded:false.+ #+ # _ui_robust_focus does TWO things together:+ # 1. The same keybd_event(0,0) + AttachThreadInput ++ # BringWindowToTop + SetForegroundWindow dance Tauri's+ # screenshot.rs uses — documented Win32 pattern.+ # 2. A SendInput LEFT-CLICK at the center of the search+ # ctrl's screen rect — mouse clicks bypass the+ # foreground restriction unconditionally AND set+ # keyboard focus inside the child control. Matches+ # the legacy navigate_symbol.ps1 mouse_event path.+ # 3. Cursor position is snapshotted + restored so the+ # user's mouse doesn't end up in a surprising spot.+ try:+ hwnd = int(target_frame.GetHandle())+ except Exception: # pylint: disable=broad-except+ hwnd = 0++ # Compute the search ctrl's screen center.+ click_x: int | None = None+ click_y: int | None = None+ try:+ screen_rect = sctrl.GetScreenRect()+ click_x = screen_rect.x + screen_rect.width // 2+ click_y = screen_rect.y + screen_rect.height // 2+ except Exception: # pylint: disable=broad-except+ pass++ focus_detail = _ui_robust_focus(hwnd, click_x, click_y)++ # Send the PROVEN keystroke sequence that the legacy+ # navigate_symbol.ps1 / navigate_footprint.ps1 PowerShell+ # path uses: ENTER to commit the search filter, then+ # CTRL+SHIFT+E to load the highlighted entry. WXK_DOWN+ # alone isn't enough — KiCad's library tree needs the+ # explicit "load symbol" shortcut.+ VK_RETURN = 0x0D+ VK_CONTROL = 0x11+ VK_SHIFT = 0x10+ VK_E = 0x45+ si = _ui_send_input_keystrokes([+ VK_RETURN, # commit search+ (VK_CONTROL, VK_SHIFT, VK_E), # Ctrl+Shift+E+ ])+ keys_sent = ["SendInput VK_RETURN", "SendInput Ctrl+Shift+E"] if si.get("ok") else [f"SendInput failed: {si}"]+ transport_detail = {"sendInput": si, "robustFocus": focus_detail}+ else:+ keys_sent = []+ for keycode, label in ((wx.WXK_DOWN, "WXK_DOWN"), (wx.WXK_RETURN, "WXK_RETURN")):+ try:+ key_down = wx.KeyEvent(wx.wxEVT_KEY_DOWN)+ key_down.SetEventObject(sctrl)+ key_down.SetKeyCode(keycode)+ wx.PostEvent(sctrl, key_down)++ key_up = wx.KeyEvent(wx.wxEVT_KEY_UP)+ key_up.SetEventObject(sctrl)+ key_up.SetKeyCode(keycode)+ wx.PostEvent(sctrl, key_up)+ keys_sent.append(label)+ except Exception as e: # pylint: disable=broad-except+ keys_sent.append(f"{label}:error={type(e).__name__}")+ transport_detail = None++ return {+ "found": True,+ # "unverified" — caller must verify_loaded to confirm. SendInput+ # is more reliable but neither is guaranteed; the editor frame+ # title is the ground truth.+ "loaded": "unverified",+ "searchedFor": name,+ "frameTitle": target_frame.GetTitle(),+ "searchCtrlClass": type(sctrl).__name__,+ "keystrokeMode": keystroke_mode,+ "keysSent": keys_sent,+ "transportDetail": transport_detail,+ "_hint": (+ "Keystrokes sent. To CONFIRM the symbol/footprint actually "+ "loaded, call `kicad_bridge_call verify_loaded "+ f"{{\"kind\":\"{editor_kind}\",\"expectedName\":{name!r}}}` after "+ "~500ms. If verify_loaded reports hasLoaded:false, the keystroke "+ "didn't dispatch — retry with useSendInput:true (real Win32 "+ "keyboard input, briefly steals foreground; more reliable but "+ "noisier) or fall back to the legacy path via "+ f"`kicad_open_{editor_kind}_editor '{{\"{editor_kind}Name\":\"...\"}}'`."+ if not use_send_input else+ "Keystrokes sent via Win32 SendInput (real keyboard input). "+ f"Foreground was briefly stolen to {target_frame.GetTitle()!r}. "+ "Call `kicad_bridge_call verify_loaded "+ f"{{\"kind\":\"{editor_kind}\",\"expectedName\":{name!r}}}` to "+ "confirm. If still hasLoaded:false, the search returned zero "+ "matches — refine the query."+ ),+ }+ except Exception as e: # pylint: disable=broad-except+ return {+ "found": True,+ "loaded": False,+ "searchedFor": name,+ "frameTitle": target_frame.GetTitle(),+ "_error": f"{type(e).__name__}: {e}",+ }+++def _ui_dataview_activate_first(dvc, frame, searched_for: str) -> dict:+ """Walk a wx.dataview.DataViewCtrl's model, find the first leaf,+ select it, and post EVT_DATAVIEW_ITEM_ACTIVATED to trigger load."""+ try:+ import wx # type: ignore[import-not-found]+ import wx.dataview # type: ignore[import-not-found]+ except ImportError:+ return {"loaded": False, "_error": "wx.dataview unavailable"}++ try:+ model = dvc.GetModel()+ if model is None:+ return {+ "found": True, "loaded": False, "searchedFor": searched_for,+ "frameTitle": frame.GetTitle(),+ "error": "DataViewCtrl has no model",+ }+ # Walk the model: roots → children → leaves. KiCad's library tree+ # model has libraries at the root, symbols/footprints as children.+ # After the filter, hidden items aren't in IsEnabled but are still+ # walkable via GetChildren. We want the first item that IS rendered+ # (i.e. matches the filter).+ #+ # wxPython's DataViewItemArray is list-like — use len() + [i],+ # NOT the C++ GetCount()/Item() which aren't exposed in SWIG.+ children_root = wx.dataview.DataViewItemArray()+ model.GetChildren(wx.dataview.DataViewItem(0), children_root)++ first_leaf = None+ # Recurse: for each root, expand and look for leaf children.+ def _walk(item, depth=0):+ nonlocal first_leaf+ if first_leaf is not None or depth > 8:+ return+ kids = wx.dataview.DataViewItemArray()+ model.GetChildren(item, kids)+ if len(kids) == 0:+ # This is a leaf — pick it+ first_leaf = item+ return+ for i in range(len(kids)):+ _walk(kids[i], depth + 1)++ # Walk each top-level (library) until we find a leaf+ for i in range(len(children_root)):+ _walk(children_root[i])+ if first_leaf is not None:+ break++ if first_leaf is None:+ return {+ "found": True, "loaded": False, "searchedFor": searched_for,+ "frameTitle": frame.GetTitle(),+ "error": "no leaf items in (filtered) library tree",+ "_hint": "The search filter may have hidden all entries. Refine the query or check spelling.",+ }++ # Select + ensure visible+ try:+ dvc.UnselectAll()+ dvc.Select(first_leaf)+ dvc.EnsureVisible(first_leaf)+ except Exception: # pylint: disable=broad-except+ pass++ # Get a human label for the activated item+ activated_label = ""+ try:+ value = model.GetValue(first_leaf, 0)+ activated_label = str(value) if value is not None else ""+ except Exception: # pylint: disable=broad-except+ pass++ # Post EVT_DATAVIEW_ITEM_ACTIVATED — same effect as double-clicking+ try:+ evt_type = wx.dataview.EVT_DATAVIEW_ITEM_ACTIVATED.typeId+ evt = wx.dataview.DataViewEvent(evt_type, dvc, first_leaf)+ evt.SetEventObject(dvc)+ wx.PostEvent(dvc, evt)+ posted = True+ except Exception as e: # pylint: disable=broad-except+ posted = False+ post_err = f"{type(e).__name__}: {e}"++ result = {+ "found": True,+ "loaded": posted,+ "searchedFor": searched_for,+ "frameTitle": frame.GetTitle(),+ "activatedItem": activated_label,+ "treeControlClass": "DataViewCtrl",+ }+ if not posted:+ result["error"] = "PostEvent failed for EVT_DATAVIEW_ITEM_ACTIVATED"+ result["_postError"] = post_err+ return result+ except Exception as e: # pylint: disable=broad-except+ return {+ "found": True, "loaded": False, "searchedFor": searched_for,+ "frameTitle": frame.GetTitle(),+ "_error": f"{type(e).__name__}: {e}",+ }+++def _ui_treectrl_activate_first(tree, frame, searched_for: str) -> dict:+ """Fallback: classic wx.TreeCtrl. Walk to first leaf + post+ EVT_TREE_ITEM_ACTIVATED."""+ try:+ import wx # type: ignore[import-not-found]+ except ImportError:+ return {"loaded": False, "_error": "wx unavailable"}+ try:+ root = tree.GetRootItem()+ if not root.IsOk():+ return {+ "found": True, "loaded": False, "searchedFor": searched_for,+ "frameTitle": frame.GetTitle(),+ "error": "TreeCtrl has no root",+ }++ def _walk_tree(item):+ # If item has no children, it's a leaf.+ cookie = 0+ try:+ child, cookie = tree.GetFirstChild(item)+ except Exception: # pylint: disable=broad-except+ child = None+ if not child or not child.IsOk():+ return item+ while child and child.IsOk():+ found = _walk_tree(child)+ if found is not None:+ return found+ try:+ child, cookie = tree.GetNextChild(item, cookie)+ except Exception: # pylint: disable=broad-except+ break+ return None++ leaf = None+ # If the root is hidden, walk its children+ if tree.HasFlag(wx.TR_HIDE_ROOT):+ cookie = 0+ try:+ child, cookie = tree.GetFirstChild(root)+ except Exception: # pylint: disable=broad-except+ child = None+ while child and child.IsOk() and leaf is None:+ leaf = _walk_tree(child)+ try:+ child, cookie = tree.GetNextChild(root, cookie)+ except Exception: # pylint: disable=broad-except+ break+ else:+ leaf = _walk_tree(root)++ if leaf is None:+ return {+ "found": True, "loaded": False, "searchedFor": searched_for,+ "frameTitle": frame.GetTitle(),+ "error": "no leaf in (filtered) TreeCtrl",+ }++ tree.SelectItem(leaf)+ tree.EnsureVisible(leaf)+ label = tree.GetItemText(leaf)+ # Post EVT_TREE_ITEM_ACTIVATED+ try:+ evt = wx.TreeEvent(wx.EVT_TREE_ITEM_ACTIVATED.typeId, tree, leaf)+ evt.SetEventObject(tree)+ wx.PostEvent(tree, evt)+ posted = True+ except Exception: # pylint: disable=broad-except+ posted = False+ return {+ "found": True,+ "loaded": posted,+ "searchedFor": searched_for,+ "frameTitle": frame.GetTitle(),+ "activatedItem": label,+ "treeControlClass": "TreeCtrl",+ }+ except Exception as e: # pylint: disable=broad-except+ return {"found": True, "loaded": False, "searchedFor": searched_for,+ "frameTitle": frame.GetTitle(),+ "_error": f"{type(e).__name__}: {e}"}+++def _ui_select_and_load_symbol(symbol_name: str, use_send_input: bool = False) -> dict:+ return _ui_select_and_load("symbol", symbol_name, use_send_input=use_send_input)+++def _ui_select_and_load_footprint(footprint_name: str, use_send_input: bool = False) -> dict:+ return _ui_select_and_load("footprint", footprint_name, use_send_input=use_send_input)+++# ── verify_loaded + SendInput fallback (Phase 2.ac) ──────────────────++# KiCad's editor frame titles when nothing is loaded. Used by both+# verify_loaded and select_and_load to confirm load success.+_NO_LOAD_MARKERS = {+ "symbol": ("[no symbol loaded]",),+ "footprint": ("[no footprint loaded]",),+}+++def _ui_verify_loaded(editor_kind: str, expected_name: str = "") -> dict:+ """Read the editor frame title and report whether the editor has a+ symbol/footprint loaded. Phase 2.ac+.++ Args:+ editor_kind: "symbol" or "footprint"+ expected_name: optional — if non-empty, check the title CONTAINS+ this substring. Use to confirm a specific symbol/+ footprint was loaded by a prior select_and_load_*.++ Returns:+ {found: bool, — was the editor frame found at all?+ frameTitle: str,+ hasLoaded: bool, — is something loaded? (title doesn't contain+ "[no <kind> loaded]" marker)+ loadedName: str?, — best-effort: extracted symbol/footprint name+ matchesExpected: bool? — only present if expected_name was passed+ _hint}+ """+ try:+ import wx # type: ignore[import-not-found]+ except ImportError:+ return {"found": False, "_error": "wx not loaded"}++ needle = f"{editor_kind} editor".lower()+ target_frame = None+ for win in wx.GetTopLevelWindows():+ try:+ title = win.GetTitle() if hasattr(win, "GetTitle") else ""+ except Exception: # pylint: disable=broad-except+ title = ""+ if needle in title.lower():+ target_frame = win+ break+ if target_frame is None:+ return {+ "found": False,+ "_hint": f"No '{editor_kind} editor' frame is currently open. "+ f"Call kicad_open_{editor_kind}_editor first, then retry.",+ }++ title = target_frame.GetTitle()+ no_load_markers = _NO_LOAD_MARKERS.get(editor_kind, ())+ has_loaded = not any(marker in title for marker in no_load_markers)++ # Extract the loaded name from the title. KiCad's editor title format:+ # "Library:Name — Symbol Editor" (saved/loaded item, no edits)+ # "Library:Name * — Symbol Editor" (loaded but modified)+ # "[no symbol loaded] — Symbol Editor" (nothing loaded)+ # The "— Symbol Editor" / "— Footprint Editor" suffix is constant; we+ # split on it to isolate the prefix.+ loaded_name = None+ if has_loaded:+ # Try em-dash separator first (KiCad uses U+2014)+ for sep in ("—", "-"):+ if sep in title:+ prefix = title.split(sep, 1)[0].strip()+ # Strip trailing modification marker+ prefix = prefix.rstrip(" *")+ if prefix:+ loaded_name = prefix+ break++ result: dict = {+ "found": True,+ "frameTitle": title,+ "hasLoaded": has_loaded,+ "loadedName": loaded_name,+ }++ if expected_name:+ if has_loaded and loaded_name:+ # Allow substring match — "R_US" matches "Device:R_US",+ # "RP2040" matches "Adom:RP2040", etc.+ result["matchesExpected"] = expected_name.lower() in loaded_name.lower()+ else:+ result["matchesExpected"] = False+ result["expectedName"] = expected_name++ if not has_loaded:+ result["_hint"] = (+ f"The {editor_kind} editor is OPEN but nothing is loaded yet. "+ f"If you just called select_and_load_{editor_kind}, the keystroke "+ f"may not have dispatched (wx KeyEvent doesn't always reach KiCad's "+ f"TOOL_DISPATCHER). Retry with useSendInput:true for real Win32 "+ f"keyboard input, OR fall back to "+ f"kicad_open_{editor_kind}_editor '{{\"{editor_kind}Name\":\"...\"}}' "+ f"which uses the proven PowerShell SendKeys path."+ )+ elif expected_name and not result["matchesExpected"]:+ result["_hint"] = (+ f"The {editor_kind} editor has {loaded_name!r} loaded, NOT "+ f"{expected_name!r}. The search filter may have matched a different "+ f"item first. Refine your query (e.g. include the library prefix: "+ f"\"Device:R_US\" instead of just \"R\"), or use "+ f"kicad_open_{editor_kind}_editor with the exact name."+ )+ else:+ # Loaded + (no expected or matches)+ result["_hint"] = (+ f"✅ {editor_kind} editor has {loaded_name!r} loaded. "+ f"Safe to drive further edits via the editor's GUI (or wait for "+ f"future Phase 2.ad bridge methods to manipulate the loaded item in-place)."+ )++ return result+++def _ui_send_input_keystrokes(keys) -> dict:+ """Inject real Win32 keystrokes via SendInput. Each item in `keys` is+ either:+ - a virtual key code (int) for a tap (down + up), e.g. VK_RETURN+ - a tuple of VK ints to chord (e.g. (VK_CONTROL, VK_SHIFT, 0x45)+ for Ctrl+Shift+E — held down in order, then released in reverse).++ Trade-off vs wx.PostEvent: SendInput sends keystrokes at the OS+ level — they reach whatever window has foreground focus. So:+ 1. The caller must ensure the right window has focus FIRST+ (we do via SetForegroundWindow on the editor frame).+ 2. SendInput briefly STEALS foreground from whatever was active.++ Returns: {ok: bool, totalEvents: int, _error?}.+ """+ try:+ import ctypes+ import ctypes.wintypes as wt+ except ImportError:+ return {"ok": False, "_error": "ctypes not available"}++ INPUT_KEYBOARD = 1+ KEYEVENTF_KEYUP = 0x0002++ class KEYBDINPUT(ctypes.Structure):+ _fields_ = [+ ("wVk", wt.WORD),+ ("wScan", wt.WORD),+ ("dwFlags", wt.DWORD),+ ("time", wt.DWORD),+ ("dwExtraInfo", ctypes.POINTER(wt.ULONG)),+ ]++ class _INPUT_UNION(ctypes.Union):+ _fields_ = [("ki", KEYBDINPUT), ("padding", ctypes.c_byte * 32)]++ class INPUT(ctypes.Structure):+ _anonymous_ = ("u",)+ _fields_ = [("type", wt.DWORD), ("u", _INPUT_UNION)]++ SendInput = ctypes.windll.user32.SendInput+ SendInput.argtypes = [wt.UINT, ctypes.POINTER(INPUT), ctypes.c_int]+ SendInput.restype = wt.UINT++ def _add_event(inputs_list, vk, key_up):+ inp = INPUT()+ inp.type = INPUT_KEYBOARD+ inp.ki = KEYBDINPUT(+ wVk=vk,+ wScan=0,+ dwFlags=KEYEVENTF_KEYUP if key_up else 0,+ time=0,+ dwExtraInfo=ctypes.cast(ctypes.pointer(wt.ULONG(0)),+ ctypes.POINTER(wt.ULONG)),+ )+ inputs_list.append(inp)++ inputs: list[INPUT] = []+ for item in keys:+ if isinstance(item, (tuple, list)):+ # Chord: press each VK down in order, then release in reverse+ for vk in item:+ _add_event(inputs, vk, False)+ for vk in reversed(item):+ _add_event(inputs, vk, True)+ else:+ # Tap: down + up+ _add_event(inputs, item, False)+ _add_event(inputs, item, True)++ arr_type = INPUT * len(inputs)+ arr = arr_type(*inputs)+ sent = SendInput(len(inputs), arr, ctypes.sizeof(INPUT))+ return {"ok": sent == len(inputs), "totalEvents": len(inputs)}+++def _ui_robust_focus(hwnd: int,+ click_x: int | None = None,+ click_y: int | None = None,+ restore_cursor: bool = True) -> dict:+ """Reliably bring a Win32 window to foreground AND set keyboard focus+ inside it.++ The naive `SetForegroundWindow(hwnd)` is unreliable on modern Windows:+ Microsoft restricts foreground-switching to processes that recently+ handled user input, so a bare call often just flashes the taskbar+ button instead of actually granting foreground. v0.7.0's+ `_ui_select_and_load(use_send_input=True)` did exactly the naive call+ and SendInput keystrokes frequently landed in the previously-focused+ window (terminal, VS Code, etc.) instead of KiCad.++ v0.8.0 (Phase 2.ad): two complementary techniques run together:+ 1. The same `force_foreground` dance Tauri's screenshot.rs uses —+ send a phantom keybd_event to mark "this thread had user input,"+ AttachThreadInput to share the foreground thread's input queue,+ then BringWindowToTop + SetForegroundWindow. Documented Win32+ pattern; works reliably across UAC/elevation boundaries.+ 2. If `click_x`/`click_y` are provided, a SendInput LEFT-CLICK at+ those screen coordinates. Mouse clicks are unconditionally+ treated as user input by Windows — they bring the owning window+ to foreground AND, for clicks inside an edit control, set+ keyboard focus there. This matches the legacy PowerShell+ `navigate_symbol.ps1` `mouse_event` path that historically+ worked for KiCad's symbol-chooser search ctrl. `restore_cursor`+ (default True) snapshots cursor pos before the click and+ restores it after — avoids surprising the user with a cursor+ jump on a different monitor.++ Returns: {ok, hwnd, foregroundOk, clicked, restoreCursorOk, _error?}.+ """+ try:+ import ctypes+ import ctypes.wintypes as wt+ import time+ except ImportError:+ return {"ok": False, "_error": "ctypes not available"}++ user32 = ctypes.windll.user32+ kernel32 = ctypes.windll.kernel32++ # ── Step 1: AttachThreadInput dance to acquire real foreground. ──+ foreground_ok = False+ try:+ # Phantom keybd_event(0, 0, 0, 0): VK=0 is invalid, no real+ # keystroke fires, but Windows flips an internal bit saying+ # "this thread just had user input" — which is the gate that+ # allows SetForegroundWindow to actually grant foreground.+ user32.keybd_event(0, 0, 0, 0)+ user32.keybd_event(0, 0, 0x0002, 0) # KEYEVENTF_KEYUP++ target_tid = user32.GetWindowThreadProcessId(hwnd, None)+ our_tid = kernel32.GetCurrentThreadId()+ attached = False+ if target_tid != 0 and target_tid != our_tid:+ attached = bool(user32.AttachThreadInput(our_tid, target_tid, True))++ # Un-iconify if minimized.+ if user32.IsIconic(hwnd):+ user32.ShowWindow(hwnd, 9) # SW_RESTORE++ user32.BringWindowToTop(hwnd)+ foreground_ok = bool(user32.SetForegroundWindow(hwnd))++ if attached:+ user32.AttachThreadInput(our_tid, target_tid, False)+ except Exception as e: # pylint: disable=broad-except+ return {"ok": False, "hwnd": hwnd,+ "_error": f"foreground dance failed: {type(e).__name__}: {e}"}++ # ── Step 2: optional left-click at (click_x, click_y). ──+ clicked = False+ restore_cursor_ok = False+ saved_pt = None+ if click_x is not None and click_y is not None:+ try:+ if restore_cursor:+ class POINT(ctypes.Structure):+ _fields_ = [("x", wt.LONG), ("y", wt.LONG)]+ saved_pt = POINT()+ if user32.GetCursorPos(ctypes.byref(saved_pt)):+ pass+ else:+ saved_pt = None++ # Move cursor to target coords (absolute pixels).+ user32.SetCursorPos(int(click_x), int(click_y))+ # Brief settle — too fast and Windows may merge events.+ time.sleep(0.05)++ # SendInput left-click. We use mouse_event for simplicity+ # (it's still supported on modern Windows; the SendInput+ # path requires building a MOUSEINPUT structure and we'd+ # otherwise share no code with the keyboard helper).+ MOUSEEVENTF_LEFTDOWN = 0x0002+ MOUSEEVENTF_LEFTUP = 0x0004+ user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)+ time.sleep(0.03)+ user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)+ clicked = True+ time.sleep(0.08) # let the click propagate (focus change)++ # Restore cursor to where the user had it.+ if restore_cursor and saved_pt is not None:+ if user32.SetCursorPos(saved_pt.x, saved_pt.y):+ restore_cursor_ok = True+ except Exception as e: # pylint: disable=broad-except+ return {+ "ok": foreground_ok, # foreground may have succeeded+ "hwnd": hwnd,+ "foregroundOk": foreground_ok,+ "clicked": clicked,+ "restoreCursorOk": restore_cursor_ok,+ "_error": f"mouse-click step failed: {type(e).__name__}: {e}",+ }++ return {+ "ok": foreground_ok,+ "hwnd": hwnd,+ "foregroundOk": foreground_ok,+ "clicked": clicked,+ "restoreCursorOk": restore_cursor_ok,+ }+++def _ui_wm_command(frame_index: int, menu_id: int) -> dict:+ """Send a Win32 WM_COMMAND to the given top-level frame's HWND.++ NOTE: Initial impl used wx.PostEvent with EVT_MENU — events fired but+ KiCad's menu-handler chain (which lives in C++ on TOOL_ACTIONs) did+ NOT pick them up. The proven path is Win32 PostMessageW(WM_COMMAND),+ which is how the external Phase 4 probe-drive.py opened sub-editors+ successfully. The native menu-bar dispatch routes through KiCad's+ TOOL_DISPATCHER which DOES respond to Win32 messages.++ Trade-off: only works on Windows. Cross-platform port (Phase 2.x)+ would need to find the wx handler that KiCad bound at C++ level —+ likely needs a small C++ shim or a call into KiCad's TOOL_MANAGER+ via the pcbnew swig binding.+ """+ import wx # type: ignore[import-not-found]+ import ctypes+ import ctypes.wintypes as wt++ windows = list(wx.GetTopLevelWindows())+ if not (0 <= frame_index < len(windows)):+ raise IndexError(f"frame_index {frame_index} out of range (have {len(windows)} frames)")+ win = windows[frame_index]+ hwnd = int(win.GetHandle()) if hasattr(win, "GetHandle") else 0+ if not hwnd:+ raise RuntimeError(f"frame[{frame_index}] has no HWND")++ WM_COMMAND = 0x0111+ user32 = ctypes.windll.user32+ PostMessageW = user32.PostMessageW+ PostMessageW.argtypes = [wt.HWND, ctypes.c_uint, wt.WPARAM, wt.LPARAM]+ PostMessageW.restype = wt.BOOL+ ok = PostMessageW(hwnd, WM_COMMAND, menu_id, 0)++ return {+ "frameClass": type(win).__name__,+ "frameTitle": win.GetTitle() if hasattr(win, "GetTitle") else "",+ "hwnd": hwnd,+ "menuId": menu_id,+ "transport": "win32-postmessage-wm-command",+ "posted": bool(ok),+ }+++# --- request dispatcher ----------------------------------------------------++def _dispatch(method: str, params: dict) -> Any:+ if method == "ping":+ return {+ "version": __version__,+ "exeName": _state["exe_name"],+ "pid": _state["pid"],+ "uptimeMs": int(time.time() * 1000) - _STARTED_AT_MS,+ "requestCount": _state["request_count"],+ }+ if method == "list_frames":+ return {"frames": _call_on_ui_thread(_ui_list_frames)}+ if method == "get_menu_ids":+ return {"frames": _call_on_ui_thread(_ui_get_menu_ids)}+ if method == "wm_command":+ frame_index = int(params.get("frame_index", 0))+ menu_id = int(params["menu_id"])+ return _call_on_ui_thread(lambda: _ui_wm_command(frame_index, menu_id))+ if method == "get_board_info":+ return _call_on_ui_thread(_ui_get_board_info)+ if method == "get_schematic_info":+ return _call_on_ui_thread(_ui_get_schematic_info)+ if method == "get_open_editors":+ return _call_on_ui_thread(_ui_get_open_editors)+ if method == "get_pcb_footprints":+ return _call_on_ui_thread(_ui_get_pcb_footprints)+ if method == "get_pcb_layers":+ return _call_on_ui_thread(_ui_get_pcb_layers)+ if method == "get_pcb_nets":+ return _call_on_ui_thread(_ui_get_pcb_nets)+ if method == "get_pcb_design_rules":+ return _call_on_ui_thread(_ui_get_pcb_design_rules)+ if method == "navigate_symbol":+ query = params.get("symbolName") or params.get("query") or ""+ return _call_on_ui_thread(lambda: _ui_navigate_symbol(query))+ if method == "navigate_footprint":+ query = params.get("footprintName") or params.get("query") or ""+ return _call_on_ui_thread(lambda: _ui_navigate_footprint(query))+ if method == "get_pcb_tracks":+ return _call_on_ui_thread(_ui_get_pcb_tracks)+ if method == "get_pcb_vias":+ return _call_on_ui_thread(_ui_get_pcb_vias)+ if method == "get_pcb_pads":+ return _call_on_ui_thread(_ui_get_pcb_pads)+ if method == "get_pcb_drawings":+ return _call_on_ui_thread(_ui_get_pcb_drawings)+ if method == "export_svg":+ return _call_on_ui_thread(lambda: _ui_export_pcb_plot("svg", params), timeout=60.0)+ if method == "export_pdf":+ return _call_on_ui_thread(lambda: _ui_export_pcb_plot("pdf", params), timeout=60.0)+ if method == "get_pcb_zones":+ return _call_on_ui_thread(_ui_get_pcb_zones)+ if method == "get_footprint_connections":+ ref = params.get("ref") or ""+ if not ref:+ raise ValueError("get_footprint_connections requires params.ref (e.g. 'U1')")+ return _call_on_ui_thread(lambda: _ui_get_footprint_connections(ref))+ if method == "get_drc_markers":+ return _call_on_ui_thread(_ui_get_drc_markers)+ if method == "run_drc_inprocess":+ return _call_on_ui_thread(_ui_run_drc_inprocess, timeout=15.0)+ if method == "get_track_paths_by_net":+ net = params.get("netName") or params.get("net") or ""+ if not net:+ raise ValueError("get_track_paths_by_net requires params.netName (e.g. 'VCC')")+ return _call_on_ui_thread(lambda: _ui_get_track_paths_by_net(net))+ # Phase 2.ac (v0.7.0): keystroke-based, no more segfault. Optional+ # useSendInput:true escalates from wx.PostEvent (safe, may not+ # propagate) to real Win32 SendInput (reliable, briefly steals+ # foreground). Always pair with verify_loaded for confirmation.+ if method == "select_and_load_symbol":+ name = params.get("symbolName") or params.get("name") or ""+ use_si = bool(params.get("useSendInput"))+ return _call_on_ui_thread(lambda: _ui_select_and_load_symbol(name, use_si), timeout=30.0)+ if method == "select_and_load_footprint":+ name = params.get("footprintName") or params.get("name") or ""+ use_si = bool(params.get("useSendInput"))+ return _call_on_ui_thread(lambda: _ui_select_and_load_footprint(name, use_si), timeout=30.0)+ if method == "verify_loaded":+ kind = params.get("kind") or ""+ if kind not in ("symbol", "footprint"):+ raise ValueError("verify_loaded requires params.kind = 'symbol' or 'footprint'")+ expected = params.get("expectedName", "")+ return _call_on_ui_thread(lambda: _ui_verify_loaded(kind, expected))+ if method == "get_net_topology":+ net = params.get("netName") or params.get("net") or ""+ if not net:+ raise ValueError("get_net_topology requires params.netName (e.g. 'VCC')")+ return _call_on_ui_thread(lambda: _ui_get_net_topology(net))+ raise ValueError(f"unknown method: {method}")+++# --- HTTP server -----------------------------------------------------------++class _RpcHandler(BaseHTTPRequestHandler):+ # Silence the default stderr request logging - it spams the KiCad+ # console window. If we ever need to debug, set+ # ADOM_KICAD_BRIDGE_VERBOSE=1 in the environment.+ def log_message(self, format, *args): # noqa: A002 - matches stdlib sig+ if os.environ.get("ADOM_KICAD_BRIDGE_VERBOSE") == "1":+ super().log_message(format, *args)++ def _send_json(self, status: int, payload: dict):+ body = json.dumps(payload).encode("utf-8")+ self.send_response(status)+ self.send_header("Content-Type", "application/json")+ self.send_header("Content-Length", str(len(body)))+ self.send_header("Access-Control-Allow-Origin", "*")+ self.end_headers()+ self.wfile.write(body)++ def do_GET(self): # noqa: N802 - stdlib name+ if self.path == "/" or self.path == "/status":+ self._send_json(200, {+ "ok": True,+ "version": __version__,+ "exeName": _state["exe_name"],+ "pid": _state["pid"],+ "port": _state["port"],+ "requestCount": _state["request_count"],+ "uptimeMs": int(time.time() * 1000) - _STARTED_AT_MS,+ })+ return+ self._send_json(404, {"ok": False, "error": "not found"})++ def do_POST(self): # noqa: N802 - stdlib name+ if self.path != "/rpc":+ self._send_json(404, {"ok": False, "error": "post to /rpc"})+ return+ _state["request_count"] += 1+ try:+ length = int(self.headers.get("Content-Length", "0"))+ except ValueError:+ length = 0+ try:+ raw = self.rfile.read(length) if length > 0 else b""+ req = json.loads(raw.decode("utf-8")) if raw else {}+ except json.JSONDecodeError as e:+ self._send_json(400, {"id": None, "error": {"code": -32700, "message": f"parse error: {e}"}})+ return++ req_id = req.get("id")+ method = req.get("method")+ params = req.get("params") or {}+ if not method:+ self._send_json(400, {"id": req_id, "error": {"code": -32600, "message": "missing 'method'"}})+ return+ try:+ result = _dispatch(method, params)+ self._send_json(200, {"id": req_id, "result": result})+ except (ValueError, KeyError, IndexError) as e:+ self._send_json(400, {"id": req_id, "error": {"code": -32602, "message": str(e)}})+ except TimeoutError as e:+ self._send_json(504, {"id": req_id, "error": {"code": -32001, "message": f"ui timeout: {e}"}})+ except Exception as e: # pylint: disable=broad-except+ self._send_json(500, {"id": req_id, "error": {+ "code": -32000,+ "message": f"{type(e).__name__}: {e}",+ "data": traceback.format_exc(),+ }})+++def _run_server(host: str, port: int):+ """Entry point for the bridge thread."""+ try:+ server = ThreadingHTTPServer((host, port), _RpcHandler)+ server.daemon_threads = True+ _state["running"] = True+ _state["port"] = port+ _write_discovery()+ try:+ server.serve_forever(poll_interval=0.5)+ finally:+ _state["running"] = False+ _remove_discovery()+ except Exception: # pylint: disable=broad-except+ try:+ (_OUT_DIR / f"adom-kicad-bridge-{_state['exe_name']}.error.log").write_text(+ traceback.format_exc(), encoding="utf-8"+ )+ except OSError:+ pass+++def _pick_port() -> Optional[int]:+ """Return the port this exe should listen on, or None if every+ attempt is taken."""+ offset = _EXE_PORT_OFFSET.get(_state["exe_name"])+ if offset is None:+ offset = max(_EXE_PORT_OFFSET.values()) + 1+ base = _BASE_PORT + offset+ for attempt in range(11):+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:+ try:+ s.bind(("127.0.0.1", base + attempt))+ return base + attempt+ except OSError:+ continue+ return None+++_bridge_thread: Optional[threading.Thread] = None+++def start():+ """Public entry point - called from usercustomize.py.++ Idempotent: a second call does nothing. Honors the kill-switch env+ var. Returns the port the bridge is listening on, or None if it+ didn't start.+ """+ global _bridge_thread # pylint: disable=global-statement+ if os.environ.get("ADOM_KICAD_BRIDGE_DISABLE") == "1":+ return None+ if _bridge_thread is not None and _bridge_thread.is_alive():+ return _state.get("port")++ # Only start if wx is actually loaded. Headless python.exe+ # invocations have nothing to bridge.+ try:+ import wx # type: ignore[import-not-found, unused-import] # noqa: F401+ except ImportError:+ return None++ port = _pick_port()+ if port is None:+ return None++ _bridge_thread = threading.Thread(+ target=_run_server,+ args=("127.0.0.1", port),+ name=f"adom-kicad-bridge-{_state['exe_name']}",+ daemon=True,+ )+ _bridge_thread.start()+ # Give the HTTP server a beat to bind. If it failed, the discovery+ # file won't appear and the orchestrator will know.+ time.sleep(0.1)+ return port+++def status() -> dict:+ return dict(_state)
plugin_payload/usercustomize.pyadded+88@@ -0,0 +1,88 @@+"""adom-desktop loader for KiCad embedded Python.++This file is a tiny shim that fires at every KiCad-bundled Python init+(see PLAN.md Phase 4 v4 architectural notes for the discovery story).++It does TWO things:++1. Writes a process-discovery breadcrumb to %TEMP%\\adom-kicad-process-<exe>.json+ so adom-desktop can confirm which KiCad executables exposed Python.+ This is the same diagnostic file the original Phase 4 probe wrote;+ adom-desktop's kicad-detect code uses it for the `embeddedPython`+ field of `kicad_list_versions`.++2. Calls `adom_bridge.start()` to spin up the per-process WebSocket+ server (Phase 2 reverse bridge). If `adom_bridge` import fails+ (e.g. plugin not deployed yet, or websockets lib unavailable in an+ older KiCad), we just log the error and the discovery breadcrumb+ still gets written so the orchestrator sees this process at all.++Both steps are wrapped in try/except so a bug here never crashes KiCad.+Honors ADOM_KICAD_BRIDGE_DISABLE=1 (skip the bridge but still write+discovery) and ADOM_KICAD_PROBE_DISABLE=1 (skip everything).+"""++try:+ import json+ import os+ import sys+ import traceback+ from datetime import datetime+ from pathlib import Path++ if os.environ.get("ADOM_KICAD_PROBE_DISABLE") == "1":+ raise SystemExit++ _OUT_DIR = Path(os.environ.get("TEMP") or os.environ.get("TMP") or "/tmp")+ _EXE_NAME = (Path(sys.executable).stem or "unknown").lower()+ _PROCESS_PATH = _OUT_DIR / f"adom-kicad-process-{_EXE_NAME}.json"+ _ERR_PATH = _OUT_DIR / f"adom-kicad-loader-{_EXE_NAME}.error.log"++ # -- Step 1: discovery breadcrumb (lightweight, always written) --+ try:+ _PROCESS_PATH.write_text(json.dumps({+ "stage": "usercustomize-fired",+ "firedAt": datetime.utcnow().isoformat() + "Z",+ "exeName": _EXE_NAME,+ "executable": sys.executable,+ "argv": sys.argv,+ "cwd": os.getcwd(),+ "pythonVersion": sys.version,+ "pid": os.getpid(),+ }, indent=2, default=str), encoding="utf-8")+ except OSError:+ pass++ # -- Step 2: start the reverse bridge (Phase 2 MVP) --+ try:+ import adom_bridge # type: ignore[import-not-found]+ port = adom_bridge.start()+ # Stash the port back into the discovery file so consumers can+ # find it without parsing both files.+ if port is not None:+ try:+ payload = json.loads(_PROCESS_PATH.read_text(encoding="utf-8"))+ payload["bridgePort"] = port+ payload["bridgeVersion"] = adom_bridge.__version__+ _PROCESS_PATH.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")+ except (OSError, ValueError):+ pass+ except Exception:+ # adom_bridge missing or its start() raised. Log but don't fail.+ try:+ _ERR_PATH.write_text(traceback.format_exc(), encoding="utf-8")+ except OSError:+ pass++except SystemExit:+ pass+except Exception:+ # Last-resort safety net.+ try:+ import os as _os+ import traceback as _tb+ _tmp = _os.environ.get("TEMP") or _os.environ.get("TMP") or "/tmp"+ with open(_os.path.join(_tmp, "adom-kicad-usercustomize-fatal.log"), "w", encoding="utf-8") as _f:+ _f.write(_tb.format_exc())+ except Exception:+ pass
tour-pack-rp2040/AdomRP2040.pretty/ADOM_MEDIUM_PIN_SHORTER_v1.kicad_modadded+149@@ -0,0 +1,149 @@+(footprint "ADOM_MEDIUM_PIN_SHORTER_v1"+ (version 20240108)+ (generator "pcbnew")+ (generator_version "8.0")+ (layer "F.Cu")+ (property "Reference" "PIN**"+ (at 0 -1.4 0)+ (unlocked yes)+ (layer "F.SilkS")+ (hide yes)+ (uuid "c4131164-c3b6-494b-b3f0-321d3f28f2c5")+ (effects+ (font+ (size 0.5 0.5)+ (thickness 0.1)+ )+ )+ )+ (property "Value" "ADOM_MEDIUM_PIN_SHORTER_v1"+ (at 0 -2 0)+ (unlocked yes)+ (layer "F.Fab")+ (hide yes)+ (uuid "c546103f-091e-4361-99e9-be89cecc214d")+ (effects+ (font+ (size 0.25 0.25)+ (thickness 0.0625)+ (bold yes)+ )+ )+ )+ (property "Footprint" "ADOM_MEDIUM_PIN_SHORTER_v1"+ (at 0 0 0)+ (unlocked yes)+ (layer "F.Fab")+ (hide yes)+ (uuid "1d6771c4-1307-4765-90bb-cd6f1ea8a61a")+ (effects+ (font+ (size 1.27 1.27)+ (thickness 0.15)+ )+ )+ )+ (property "Datasheet" ""+ (at 0 0 0)+ (unlocked yes)+ (layer "F.Fab")+ (hide yes)+ (uuid "1132a3c4-c498-432a-9d2b-0937d982048a")+ (effects+ (font+ (size 1.27 1.27)+ (thickness 0.15)+ )+ )+ )+ (property "Description" ""+ (at 0 0 0)+ (unlocked yes)+ (layer "F.Fab")+ (hide yes)+ (uuid "40af74d9-984e-42f2-a021-8662da837ce6")+ (effects+ (font+ (size 1.27 1.27)+ (thickness 0.15)+ )+ )+ )+ (attr through_hole exclude_from_pos_files)+ (fp_circle+ (center 0 0)+ (end 0.9 0)+ (stroke+ (width 0.05)+ (type default)+ )+ (fill none)+ (layer "F.SilkS")+ (uuid "878a39fe-9942-4275-af69-c09e51db232a")+ )+ (fp_circle+ (center 0 0)+ (end 0.99 0)+ (stroke+ (width 0.05)+ (type default)+ )+ (fill none)+ (layer "B.CrtYd")+ (uuid "61ae1f9a-8512-40c0-b5cf-8fc6df1f239b")+ )+ (fp_circle+ (center 0 0)+ (end 0.99 0)+ (stroke+ (width 0.05)+ (type default)+ )+ (fill none)+ (layer "F.CrtYd")+ (uuid "39d7b857-1f06-4fd2-8ec0-9eee130ac38b")+ )+ (fp_rect+ (start -1 -1)+ (end 1 1)+ (stroke+ (width 0.05)+ (type default)+ )+ (fill none)+ (layer "F.Fab")+ (uuid "0f82184b-a0fe-45a0-b598-792ca76da420")+ )+ (fp_text user "${REFERENCE}"+ (at 0 0 0)+ (unlocked yes)+ (layer "F.Fab")+ (uuid "5dff51ed-34a4-465c-b861-d36491f669c5")+ (effects+ (font+ (size 0.25 0.25)+ (thickness 0.0625)+ (bold yes)+ )+ )+ )+ (pad "1" thru_hole circle+ (at 0 0)+ (size 1.6 1.6)+ (drill 1.1)+ (layers "*.Cu" "*.Mask")+ (remove_unused_layers no)+ (uuid "fb9c9b40-0220-46a1-907b-54477ba4f011")+ )+ (model "${ADOM_LIBRARY_DIR}/Adom Footprint Library.pretty/3D_Models/MachinePin_Medium_Short.step"+ (offset+ (xyz 0 0 0)+ )+ (scale+ (xyz 1 1 1)+ )+ (rotate+ (xyz 0 0 0)+ )+ )+)
tour-pack-rp2040/AdomRP2040.pretty/QFN-56_AdomRP2040.kicad_modadded+709@@ -0,0 +1,709 @@+(footprint "QFN-56_AdomRP2040"+ (version 20260206)+ (generator "kicad-footprint-generator")+ (layer "F.Cu")+ (descr "QFN, 56 Pin (https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf#page=607)")+ (tags "QFN NoLead")+ (property "Reference" "REF**"+ (at 0 -4.83 0)+ (layer "F.SilkS")+ (effects+ (font+ (size 1 1)+ (thickness 0.15)+ )+ )+ )+ (property "Value" "QFN-56-1EP_7x7mm_P0.4mm_EP3.2x3.2mm"+ (at 0 4.83 0)+ (layer "F.Fab")+ (effects+ (font+ (size 1 1)+ (thickness 0.15)+ )+ )+ )+ (property "KiLib_Generator" "package/no_lead"+ (at 0 0 0)+ (layer "F.SilkS")+ (hide yes)+ (effects+ (font+ (size 1 1)+ (thickness 0.15)+ )+ )+ )+ (attr smd)+ (duplicate_pad_numbers_are_jumpers no)+ (fp_line+ (start -3.61 -3.61)+ (end -2.96 -3.61)+ (stroke+ (width 0.12)+ (type solid)+ )+ (layer "F.SilkS")+ )+ (fp_line+ (start -3.61 -2.96)+ (end -3.61 -3.61)+ (stroke+ (width 0.12)+ (type solid)+ )+ (layer "F.SilkS")+ )+ (fp_line+ (start -3.61 3.61)+ (end -3.61 2.96)+ (stroke+ (width 0.12)+ (type solid)+ )+ (layer "F.SilkS")+ )+ (fp_line+ (start -2.96 3.61)+ (end -3.61 3.61)+ (stroke+ (width 0.12)+ (type solid)+ )+ (layer "F.SilkS")+ )+ (fp_line+ (start 2.96 -3.61)+ (end 3.61 -3.61)+ (stroke+ (width 0.12)+ (type solid)+ )+ (layer "F.SilkS")+ )+ (fp_line+ (start 3.61 -3.61)+ (end 3.61 -2.96)+ (stroke+ (width 0.12)+ (type solid)+ )+ (layer "F.SilkS")+ )+ (fp_line+ (start 3.61 2.96)+ (end 3.61 3.61)+ (stroke+ (width 0.12)+ (type solid)+ )+ (layer "F.SilkS")+ )+ (fp_line+ (start 3.61 3.61)+ (end 2.96 3.61)+ (stroke+ (width 0.12)+ (type solid)+ )+ (layer "F.SilkS")+ )+ (fp_poly+ (pts+ (xy -4.14 -2.6)+ (xy -4.47 -2.36)+ (xy -4.47 -2.84)+ )+ (stroke+ (width 0.12)+ (type solid)+ )+ (fill yes)+ (layer "F.SilkS")+ )+ (fp_line+ (start -4.13 -2.95)+ (end -3.75 -2.95)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start -4.13 2.95)+ (end -4.13 -2.95)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start -3.75 -3.75)+ (end -2.95 -3.75)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start -3.75 -2.95)+ (end -3.75 -3.75)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start -3.75 2.95)+ (end -4.13 2.95)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start -3.75 3.75)+ (end -3.75 2.95)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start -2.95 -4.13)+ (end 2.95 -4.13)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start -2.95 -3.75)+ (end -2.95 -4.13)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start -2.95 3.75)+ (end -3.75 3.75)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start -2.95 4.13)+ (end -2.95 3.75)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start 2.95 -4.13)+ (end 2.95 -3.75)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start 2.95 -3.75)+ (end 3.75 -3.75)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start 2.95 3.75)+ (end 2.95 4.13)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start 2.95 4.13)+ (end -2.95 4.13)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start 3.75 -3.75)+ (end 3.75 -2.95)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start 3.75 -2.95)+ (end 4.13 -2.95)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start 3.75 2.95)+ (end 3.75 3.75)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start 3.75 3.75)+ (end 2.95 3.75)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start 4.13 -2.95)+ (end 4.13 2.95)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_line+ (start 4.13 2.95)+ (end 3.75 2.95)+ (stroke+ (width 0.05)+ (type solid)+ )+ (layer "F.CrtYd")+ )+ (fp_poly+ (pts+ (xy -2.5 -3.5)+ (xy 3.5 -3.5)+ (xy 3.5 3.5)+ (xy -3.5 3.5)+ (xy -3.5 -2.5)+ )+ (stroke+ (width 0.1)+ (type solid)+ )+ (fill no)+ (layer "F.Fab")+ )+ (fp_text user "${REFERENCE}"+ (at 0 0 0)+ (layer "F.Fab")+ (effects+ (font+ (size 1 1)+ (thickness 0.15)+ )+ )+ )+ (pad "" smd roundrect+ (at -0.8 -0.8)+ (size 1.29 1.29)+ (layers "F.Paste")+ (roundrect_rratio 0.193798)+ )+ (pad "" smd roundrect+ (at -0.8 0.8)+ (size 1.29 1.29)+ (layers "F.Paste")+ (roundrect_rratio 0.193798)+ )+ (pad "" smd roundrect+ (at 0.8 -0.8)+ (size 1.29 1.29)+ (layers "F.Paste")+ (roundrect_rratio 0.193798)+ )+ (pad "" smd roundrect+ (at 0.8 0.8)+ (size 1.29 1.29)+ (layers "F.Paste")+ (roundrect_rratio 0.193798)+ )+ (pad "1" smd roundrect+ (at -3.4375 -2.6)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "2" smd roundrect+ (at -3.4375 -2.2)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "3" smd roundrect+ (at -3.4375 -1.8)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "4" smd roundrect+ (at -3.4375 -1.4)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "5" smd roundrect+ (at -3.4375 -1)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "6" smd roundrect+ (at -3.4375 -0.6)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "7" smd roundrect+ (at -3.4375 -0.2)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "8" smd roundrect+ (at -3.4375 0.2)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "9" smd roundrect+ (at -3.4375 0.6)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "10" smd roundrect+ (at -3.4375 1)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "11" smd roundrect+ (at -3.4375 1.4)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "12" smd roundrect+ (at -3.4375 1.8)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "13" smd roundrect+ (at -3.4375 2.2)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "14" smd roundrect+ (at -3.4375 2.6)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "15" smd roundrect+ (at -2.6 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "16" smd roundrect+ (at -2.2 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "17" smd roundrect+ (at -1.8 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "18" smd roundrect+ (at -1.4 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "19" smd roundrect+ (at -1 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "20" smd roundrect+ (at -0.6 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "21" smd roundrect+ (at -0.2 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "22" smd roundrect+ (at 0.2 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "23" smd roundrect+ (at 0.6 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "24" smd roundrect+ (at 1 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "25" smd roundrect+ (at 1.4 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "26" smd roundrect+ (at 1.8 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "27" smd roundrect+ (at 2.2 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "28" smd roundrect+ (at 2.6 3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "29" smd roundrect+ (at 3.4375 2.6)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "30" smd roundrect+ (at 3.4375 2.2)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "31" smd roundrect+ (at 3.4375 1.8)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "32" smd roundrect+ (at 3.4375 1.4)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "33" smd roundrect+ (at 3.4375 1)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "34" smd roundrect+ (at 3.4375 0.6)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "35" smd roundrect+ (at 3.4375 0.2)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "36" smd roundrect+ (at 3.4375 -0.2)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "37" smd roundrect+ (at 3.4375 -0.6)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "38" smd roundrect+ (at 3.4375 -1)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "39" smd roundrect+ (at 3.4375 -1.4)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "40" smd roundrect+ (at 3.4375 -1.8)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "41" smd roundrect+ (at 3.4375 -2.2)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "42" smd roundrect+ (at 3.4375 -2.6)+ (size 0.875 0.2)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "43" smd roundrect+ (at 2.6 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "44" smd roundrect+ (at 2.2 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "45" smd roundrect+ (at 1.8 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "46" smd roundrect+ (at 1.4 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "47" smd roundrect+ (at 1 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "48" smd roundrect+ (at 0.6 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "49" smd roundrect+ (at 0.2 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "50" smd roundrect+ (at -0.2 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "51" smd roundrect+ (at -0.6 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "52" smd roundrect+ (at -1 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "53" smd roundrect+ (at -1.4 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "54" smd roundrect+ (at -1.8 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "55" smd roundrect+ (at -2.2 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "56" smd roundrect+ (at -2.6 -3.4375)+ (size 0.2 0.875)+ (layers "F.Cu" "F.Mask" "F.Paste")+ (roundrect_rratio 0.25)+ )+ (pad "57" smd rect+ (at 0 0)+ (size 3.2 3.2)+ (property pad_prop_heatsink)+ (layers "F.Cu" "F.Mask")+ (zone_connect 2)+ )+ (embedded_fonts no)+ (model "${KICAD10_3DMODEL_DIR}/Package_DFN_QFN.3dshapes/QFN-56-1EP_7x7mm_P0.4mm_EP3.2x3.2mm.step"+ (offset+ (xyz 0 0 0)+ )+ (scale+ (xyz 1 1 1)+ )+ (rotate+ (xyz 0 0 0)+ )+ )+)