← Commit history
handlers/__init__.pyadded

  
handlers/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)+		)+	)+)