app
KiCad - the KiCad Bridge
Public Made by Adomby adom
Reference implementation of the KiCad bridge: multi-instance Python server, forward path via kicad-cli, reverse path via in-process plugin. Most complex of the three bundled bridges.
← Commit history
v0.9.87: kicad_state verb (#22, all windows as base64 + logical state) + process-verified window matching (#23, no more Chrome-tab-as-KiCad)
10 files changed
+158−11
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.86+0.9.87
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.86",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.86/adom-bridge-kicad-v0.9.86.zip",- "sha256": "79971a758f954299d7a0df3db3df4ad7809b30e979df5bdca3522c0ac19c0d16",- "size": 378689,+ "version": "0.9.87",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.87/adom-bridge-kicad-v0.9.87.zip",+ "sha256": "670c3979d766601a4aa9429912403116c5625a4b296cb28d647bc90792676667",+ "size": 381469, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-14T15:41:37Z",+ "released_at": "2026-08-14T18:10:32Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.87.zipadded⋯ 1 unchanged line ⋯
bridge.json+3−2@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.86",+ "version": "0.9.87", "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.",@@ -64,7 +64,8 @@ "kicad_upgrade", "kicad_demo", "kicad_launch",- "kicad_uninstall"+ "kicad_uninstall",+ "kicad_state" ], "statusVerb": "kicad_status", "timeouts": {
handlers/kicad_ui.py+14−3@@ -174,8 +174,18 @@ def _find_all_kicad_windows() -> list: 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)+ # Match KiCad windows by title keywords or wxWindowNR class with owner.+ # #23: the title match ALONE once matched a Chrome tab named "KiCad - ..."+ # (this bridge's own wiki page open in pup) — every title hit must also+ # belong to a real KiCad process.+ def _really_kicad(h):+ try:+ from handlers.win_focus import is_kicad_window+ return is_kicad_window(h)+ except Exception:+ return True # fail open: better a stray match than blind+ is_kicad = (any(kw.lower() in title.lower() for kw in KICAD_TITLE_KEYWORDS)+ and _really_kicad(hwnd)) # Also catch #32770 (Win32 dialog) windows owned by KiCad if not is_kicad and class_name == "#32770":@@ -183,7 +193,8 @@ def _find_all_kicad_windows() -> list: 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)+ is_kicad = (any(kw.lower() in owner_title.lower() for kw in KICAD_TITLE_KEYWORDS)+ and _really_kicad(owner)) if is_kicad and hwnd not in seen: seen.add(hwnd)
handlers/open_footprint_editor.py+11@@ -62,6 +62,17 @@ def _find_windows_by_title(substring: str) -> list: buf = ctypes.create_unicode_buffer(length + 1) user32.GetWindowTextW(hwnd, buf, length + 1) if substring in buf.value:+ # #23: title-substring alone matched a CHROME TAB named+ # "KiCad - ..." (the bridge's own wiki page in pup!) as the+ # running app, so we WM_COMMAND'd a browser and never+ # launched KiCad. Require the window to belong to a real+ # KiCad process before treating it as KiCad.+ try:+ from handlers.win_focus import is_kicad_window+ if not is_kicad_window(hwnd):+ return True+ except Exception:+ pass owner = user32.GetWindow(hwnd, GW_OWNER) results.append((hwnd, buf.value, owner)) return True
handlers/open_symbol_editor.py+11@@ -66,6 +66,17 @@ def _find_windows_by_title(substring: str) -> list: buf = ctypes.create_unicode_buffer(length + 1) user32.GetWindowTextW(hwnd, buf, length + 1) if substring in buf.value:+ # #23: title-substring alone matched a CHROME TAB named+ # "KiCad - ..." (the bridge's own wiki page in pup!) as the+ # running app, so we WM_COMMAND'd a browser and never+ # launched KiCad. Require the window to belong to a real+ # KiCad process before treating it as KiCad.+ try:+ from handlers.win_focus import is_kicad_window+ if not is_kicad_window(hwnd):+ return True+ except Exception:+ pass owner = user32.GetWindow(hwnd, GW_OWNER) results.append((hwnd, buf.value, owner)) return True
handlers/state.pyadded+103@@ -0,0 +1,103 @@+"""state.py — kicad_state: KiCad's full state of the union in one read-only call.++Issue #22 (John, 2026-08-14, adopted from the Bridge SDK suggested pattern):+a generic title-match screenshot returns ONE window, but KiCad is a multi-window+app — so callers (web pages, AIs that can't reach the desktop filesystem) got a+cropped sub-window as "proof". Only the bridge knows the real window set.++kicad_state returns, in one response:+ - `shots[]` — a screenshot of EVERY window KiCad owns right now, captured by+ real hwnd, inline as base64 PNG (`image`) plus the host file path.+ - `windows[]` — hwnd/title/class/rect for each (no images when includeImages=false).+ - `editors` — the plugin's logical editor inventory when a plugin is alive+ (kind/frameTitle per editor), else the reason it isn't.+ - `modalDialogs` + `running` + `activeVersion` — enough to decide the next move.++Read-only, non-gated, cheap to poll (images are the only cost — cap via maxWidth).+"""+import base64+import os+import sys++IS_WINDOWS = sys.platform == "win32"+++def handle_state(kicad_info: dict, args: dict) -> dict:+ """Args (all optional):+ includeImages: bool (default true) — inline base64 PNGs in shots[]+ maxWidth: int (default 1100) — downscale cap for inline images+ """+ if not IS_WINDOWS:+ return {"success": False, "error": f"kicad_state not implemented on {sys.platform!r}"}++ from handlers.kicad_ui import _find_all_kicad_windows, _screenshot_hwnd++ include_images = args.get("includeImages", True)+ max_width = int(args.get("maxWidth") or 1100)++ windows = _find_all_kicad_windows()+ running = len(windows) > 0++ shots = []+ if include_images:+ for w in windows:+ entry = {"title": w.get("title"), "hwnd": w.get("hwnd"),+ "className": w.get("className")}+ try:+ shot = _screenshot_hwnd(w["hwnd"], label="state")+ path = shot.get("fullPath") or shot.get("path")+ if shot.get("success") and path and path.lower().endswith(".png"):+ if max_width:+ try:+ from PIL import Image+ img = Image.open(path)+ if img.width > max_width:+ r = max_width / img.width+ img = img.resize((max_width, int(img.height * r)))+ img.save(path, "PNG", optimize=True)+ entry["width"], entry["height"] = img.width, img.height+ except Exception:+ pass+ with open(path, "rb") as f:+ entry["image"] = base64.b64encode(f.read()).decode()+ entry["path"] = path+ else:+ entry["error"] = shot.get("error") or "capture failed (non-png fallback?)"+ except Exception as e: # pylint: disable=broad-except+ entry["error"] = f"{type(e).__name__}: {e}"+ shots.append(entry)++ editors: dict = {}+ try:+ from handlers.bridge_client import handle_open_editors+ editors = handle_open_editors(kicad_info, {})+ except Exception as e: # pylint: disable=broad-except+ editors = {"success": False, "error": str(e)}++ modal = []+ try:+ from handlers.close_windows import _win_scan_kicad_dialogs+ modal = [{"hwnd": d.get("hwnd"), "title": d.get("title"), "body": d.get("body")}+ for d in _win_scan_kicad_dialogs(kicad_info) if d.get("isDialog")]+ except Exception:+ pass++ return {+ "success": True,+ "running": running,+ "activeVersion": kicad_info.get("version"),+ "windows": [{k: w.get(k) for k in ("hwnd", "title", "className", "rect")}+ for w in windows],+ "shots": shots,+ "editors": {+ "kicadRunning": editors.get("kicadRunning"),+ "list": editors.get("editors"),+ "summary": editors.get("summary"),+ },+ "modalDialogs": modal,+ "_hint": ("The full state of the union: every KiCad-owned window is in shots[] as inline "+ "base64 PNG (render them all — one window is NOT the app). modalDialogs non-empty "+ "means something is blocking; editors.list is the plugin's logical inventory "+ "(null while the editor's Python is still binding, issue #21). Poll before/after "+ "an action to confirm it landed. includeImages:false for a cheap logical poll."),+ }
server.py+6@@ -45,6 +45,7 @@ from handlers.fix_keyboard import handle_fix_keyboard from handlers.kicad_ui import handle_screenshot_all, handle_send_key, handle_click from handlers.bridge_client import handle_bridge_status, handle_bridge_call, handle_open_editors from handlers.launch import handle_launch+from handlers.state import handle_state from handlers.uninstall import handle_uninstall from handlers.kicad_cli_lint import ( handle_run_erc, handle_lint_board, handle_lint_schematic,@@ -316,6 +317,10 @@ _VERB_CATALOG = { "open_editors": {"summary": "List currently open KiCad editor windows.", "long": False, "hint": "Read-only INVENTORY (despite the name): lists editors inside a running KiCad. It never launches anything — to cold-start KiCad use kicad_launch.", "related": ["kicad_launch", "kicad_window_info", "kicad_screenshot_all", "kicad_close"], "pitfalls": ["with KiCad closed it succeeds with 0 editors — that is not an error; kicad_launch is the verb that starts KiCad"]},+ "state": {"summary": "KiCad's full state of the union: every owned window as inline base64 PNG + logical state.", "long": True,+ "hint": "Read-only, non-gated. shots[] = base64 screenshot of EVERY KiCad window (render all of them — one window is not the app); plus editors inventory, modal dialogs, running flag. Poll before/after an action to confirm it landed. includeImages:false for a cheap logical poll; maxWidth caps image size.",+ "related": ["kicad_screenshot_all", "kicad_open_editors", "kicad_window_info"],+ "pitfalls": ["shots are inline base64 — the response is large; use includeImages:false when you only need the logical state"]}, "launch": {"summary": "Cold-start KiCad (project manager) when nothing is running.", "long": True, "hint": "Idempotent: no-op with a hint if KiCad is already up. Opens in the BACKGROUND (no focus steal). Optional project: path to a .kicad_pro.", "related": ["kicad_open_editors", "kicad_window_info", "kicad_readiness", "kicad_upgrade"], "pitfalls": ["first launch on a fresh install can show a setup wizard — kicad_window_info self-heals benign dialogs"]},@@ -479,6 +484,7 @@ COMMAND_HANDLERS = { "bridge_call": handle_bridge_call, "open_editors": handle_open_editors, "launch": handle_launch,+ "state": handle_state, "uninstall": handle_uninstall, "install_plugin": plugin_install.handle_install_plugin, # v1.7.12+: headless lint pre-checks via kicad-cli
skills/kicad-bridge-test/run_verb_tests.py+4@@ -78,6 +78,7 @@ CHECKS = { "diagnose_facts": lambda r: ok(r) or "facts" in json.dumps(r)[:2000] or "uacEnableLUA" in json.dumps(r), "launched_or_running": lambda r: ok(r) and (r.get("launched") or r.get("alreadyRunning")), "demo_beat": lambda r: ok(r) and (r.get("say") or (r.get("data") or {}).get("say") or r.get("stage")),+ "state_shots": lambda r: ok(r) and r.get("running") and any(s.get("image") for s in (r.get("shots") or [])), } def matrix(w: dict) -> dict:@@ -126,6 +127,8 @@ def matrix(w: dict) -> dict: "open_footprint_editor": dict(phase=3, args={}, timeout=160), "open_3d_viewer": dict(phase=3, args={"reason": "daily verb test"}, timeout=200), "open_editors": dict(phase=3, args={}, check="editors_listed"),+ "state": dict(phase=3, args={"maxWidth": 640}, check="state_shots", timeout=160,+ note="#22: every KiCad window as inline base64 + logical state"), "window_info": dict(phase=3, args={}), "screenshot_all": dict(phase=3, args={}, check="screenshot_files", timeout=160), "dismiss_dialogs": dict(phase=3, args={}),@@ -349,6 +352,7 @@ def main(): print(f" kicad_{verb:24} {'PASS' if passed else 'FAIL'}") PHASE3_ORDER = ["launch", "open_board", "open_schematic", "open_symbol_editor",+ "state", "open_footprint_editor", "open_3d_viewer", "open_editors", "window_info", "screenshot_all", "dismiss_dialogs", "send_key", "click", "bridge_call"]