app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
Update 40 file(s)
5 files changed
+174−14
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.269+0.9.270
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.269",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.269/adom-bridge-kicad-v0.9.269.zip",- "sha256": "06d48e276cf7722ccf7d8598a8f318ef66964fd19489adf7ca2a8f35a781f469",- "size": 1039639,+ "version": "0.9.270",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.270/adom-bridge-kicad-v0.9.270.zip",+ "sha256": "3ef33acf4fbd10a4812973c2b2b5bfadb3311930b72f695f1737952956750550",+ "size": 1042012, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-09-01T02:43:45Z",+ "released_at": "2026-09-01T02:51:43Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.269",+ "version": "0.9.270", "description": "Reverse bridge for KiCad \u2014 board/schematic introspection, lint via kicad-cli, plugin install, multi-instance probe, in-process DRC.", "homepage": "https://wiki.adom.inc/adom/adom-bridge", "author": "Adom Inc.",
handlers/kicad_log.py+64−6@@ -198,6 +198,47 @@ def _enum_dialog_text(hwnd: int) -> list[str]: return out ++def _uia_text(hwnd: int) -> str:+ """Longest Text node in a window's UIA tree, via ab's desktop_ui_tree.++ Best-effort by design: if ab is old, unreachable, or the window died in the+ microseconds since enumeration, return "" and let the caller report the+ dialog without text rather than failing the whole verb."""+ try:+ from handlers import ad_client+ r = ad_client.call("desktop_ui_tree", {"hwnd": int(hwnd), "maxDepth": 8,+ "maxNodes": 80}, timeout=8) or {}+ except Exception: # pylint: disable=broad-except+ return ""+ out = r.get("output") if isinstance(r, dict) else None+ if isinstance(out, str):+ try:+ import json as _j+ out = _j.loads(out)+ except Exception: # pylint: disable=broad-except+ out = None+ root = ((out or r) or {}).get("root") if isinstance((out or r), dict) else None+ if not root:+ return ""+ texts: list[str] = []++ def walk(n):+ if not isinstance(n, dict):+ return+ if n.get("role") == "Text":+ nm = (n.get("name") or "").strip()+ if nm:+ texts.append(nm)+ for c in n.get("children") or []:+ walk(c)++ walk(root)+ # Skip the icon/instruction placeholders ("MainInstructionIcon") and take the+ # longest real sentence — that is the message every time in KiCad's dialogs.+ texts = [t for t in texts if not t.endswith("Icon")]+ return max(texts, key=len) if texts else ""+ def handle_errors(kicad_info: dict, args: dict) -> dict: """Every KiCad error/warning dialog currently on screen, with its text. @@ -228,17 +269,34 @@ def handle_errors(kicad_info: dict, args: dict) -> dict: texts = _enum_dialog_text(hwnd) # the message is the longest static; buttons are short ("OK", "Cancel") msg = max(texts, key=len) if texts else ""+ via = "win32"+ if not msg:+ # A Windows TASKDIALOG (KiCad 10 uses these): its text is drawn by+ # DirectUI with no per-string child controls, so GetWindowText finds+ # nothing. UI Automation reads it fine.+ #+ # HARVEST IT NOW, IN THIS CALL. The first cut returned the hwnd and+ # told the caller to run desktop_ui_tree themselves; that is a RACE+ # and it lost — the dialog closed between discovery and the+ # follow-up read, so the reason for the failure was gone. A verb+ # that finds an error must return the error's TEXT, not a promise+ # that the text is obtainable.+ #+ # ab owns UIA (desktop_ui_tree, read-only, no foreground), so we+ # call it rather than reimplementing IUIAutomation in here. That is+ # the same rule win_taskbar.py learned: the bridge owns KiCad, ab+ # owns Windows.+ ui = _uia_text(hwnd)+ if ui:+ msg, via = ui, "uia" entry = {"hwnd": hwnd, "title": title, "message": msg,+ "messageVia": via if msg else None, "allText": texts[:12], "level": "error" if _ERR_TITLE.search(title) else "info"} if not msg:- # A TaskDialog. Say so, and hand the caller the exact call that DOES- # read it, rather than reporting an error with no text and leaving- # them to screenshot it.- entry["messageUnavailable"] = "taskdialog_directui"+ entry["messageUnavailable"] = "taskdialog_directui_and_uia_unavailable" entry["readTextWith"] = (f'desktop_ui_tree {{"hwnd":{hwnd},"maxDepth":8}} '- "— the message is the Text node; win32 cannot read "- "DirectUI, UIA can (read-only, no foreground)")+ "— read it IMMEDIATELY; these dialogs are transient") wins.append(entry) return True
handlers/kicad_ui.py+103−1@@ -483,8 +483,110 @@ def _canvas_uniformity(pixels: bytes, width: int, height: int, rect=None) -> dic except Exception: return {"checked": False} +def _ab_capture(hwnd: int, label: str = "") -> dict | None:+ """Capture via AD core's desktop_screenshot_window. None if unavailable.++ WHY AB AND NOT OUR OWN PrintWindow (2026-09-01 audit, John):+ the bridge should own what only it can do — KiCad detection, KiCad's file+ formats, its plugin IPC — and call AD for anything about WINDOWS ITSELF.+ win_taskbar.py learned this the expensive way (an in-process+ ITaskbarList3::SetOverlayIcon returns S_OK cross-process and the shell+ silently ignores it; AD's desktop_taskbar works), and the rule was never+ generalised. This is the generalisation.++ What AD's capture gives us that a local PrintWindow does not:+ * OWNED POPUPS captured with the parent — an error dialog arrives WITH the+ editor that raised it, instead of needing a second enumeration pass;+ * coordMap, so a caller can click what it SEES ({space:"image"}) with no+ DPI or scaling arithmetic;+ * the universal auto-pull, so a cloud caller gets localFullPath and can+ Read the file with no pull_file hop;+ * ONE capture implementation to maintain instead of two that drift.++ What it CANNOT do is judge whether KiCad's OpenGL canvas actually rendered:+ that needs raw pixels and KiCad-specific knowledge of where the canvas is.+ So the split is: AD takes the picture, we keep the diagnosis (_canvas_probe).+ """+ try:+ from handlers import ad_client+ r = ad_client.call("desktop_screenshot_window",+ {"hwnd": int(hwnd),+ "reason": f"Capture the KiCad window {label or hwnd} as evidence for the calling AI"},+ timeout=30) or {}+ except Exception: # pylint: disable=broad-except+ return None+ if not isinstance(r, dict) or not (r.get("success") or r.get("ok")):+ return None+ full = r.get("fullPath") or r.get("fullPathHost") or r.get("savedTo")+ if not full:+ return None+ out = {"success": True, "savedTo": str(full).replace("\\", "/"),+ "fullPath": str(full).replace("\\", "/"),+ "capturedBy": "ad:desktop_screenshot_window"}+ for k in ("localFullPath", "safePath", "coordMap", "shotId",+ "fullWidth", "fullHeight", "ownedPopupCount", "screenshots"):+ if r.get(k) is not None:+ out[k] = r[k]+ if r.get("safeBytes") or r.get("fullBytes"):+ out["sizeKB"] = round((r.get("safeBytes") or r.get("fullBytes")) / 1024, 1)+ return out+++def _canvas_probe(hwnd: int) -> dict | None:+ """Uniformity verdict for the editor canvas WITHOUT writing a file.++ Kept local on purpose: this is the one part of a KiCad screenshot that AD+ cannot do for us, because it needs the raw BGRA buffer and knowledge of+ where KiCad puts its GL canvas. Cheap: one GDI grab, sampled, discarded."""+ try:+ rect = _get_window_rect(hwnd)+ width, height = rect["width"], rect["height"]+ if width <= 0 or height <= 0:+ return None+ 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 _BIH(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 = _BIH()+ bmi.biSize = ctypes.sizeof(_BIH)+ bmi.biWidth, bmi.biHeight, bmi.biPlanes = width, height, 1+ bmi.biBitCount, bmi.biCompression = 32, BI_RGB+ pixel_buf = ctypes.create_string_buffer(width * height * 4)+ gdi32.GetDIBits(hdc_mem, hbmp, 0, height, pixel_buf, ctypes.byref(bmi), DIB_RGB_COLORS)+ buf = pixel_buf.raw+ gdi32.SelectObject(hdc_mem, old_bmp)+ gdi32.DeleteObject(hbmp)+ gdi32.DeleteDC(hdc_mem)+ user32.ReleaseDC(hwnd, hdc_screen)+ if buf is None:+ return None+ return _canvas_uniformity(buf, width, height, _gl_canvas_rect(hwnd))+ except Exception: # pylint: disable=broad-except+ return None++ def _screenshot_hwnd(hwnd: int, label: str = "", max_dim: int = 1568) -> dict:- """Capture a window by HWND using PrintWindow. Returns {success, savedTo, sizeKB}."""+ """Capture a window by HWND. AD core first, local PrintWindow as fallback.++ Returns {success, savedTo, fullPath, sizeKB, canvas}. `canvas` is always+ ours (see _canvas_probe); the IMAGE comes from AD when AD is reachable."""+ _ab = _ab_capture(hwnd, label)+ if _ab:+ _ab["canvas"] = _canvas_probe(hwnd)+ return _ab+ # FALLBACK: an older AD, a closed direct-API port, or a capture AD refused.+ # Never let the evidence channel die just because the preferred path is+ # unavailable — the screenshot loop is the floor under everything else+ # (John, 2026-09-01: "keep the screenshot-and-read-the-pixels loop just in+ # case"), so it must work even when AD does not answer. # A park-minimized window (focus machinery) can't PrintWindow reliably — # restore it OFF-SCREEN without activation just for the capture. try: