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)
7 files changed
+191−50
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.270+0.9.271
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "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,+ "version": "0.9.271",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.271/adom-bridge-kicad-v0.9.271.zip",+ "sha256": "6245ed3354e7b5144684c0398374da7eb761a7c4d438179187ab96583c31d5ef",+ "size": 1045024, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-09-01T02:51:43Z",+ "released_at": "2026-09-01T02:57:17Z", "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.270",+ "version": "0.9.271", "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+14−23@@ -245,27 +245,23 @@ def handle_errors(kicad_info: dict, args: dict) -> dict: Read-only and background: enumerates windows and reads their text. Never raises, never clicks, never screenshots. Args: includeAll:true to return every KiCad dialog rather than only ones whose title looks like an error."""- import ctypes- from ctypes import wintypes- user32 = ctypes.windll.user32+ # Window discovery comes from handlers/kicad_windows (AD's desktop_list_windows+ # when reachable, local EnumWindows otherwise). This used to run its own+ # EnumWindows and decide "is this KiCad?" by MATCHING THE TITLE, which is the+ # exact mistake skills/kicad-uia warns about: titles are localised and they+ # lie. Worse, it silently skipped any dialog whose caption does not contain a+ # KiCad word — a bare Win32 "#32770" error box owned by pcbnew.exe never+ # appeared at all. The shared finder decides by OWNING EXECUTABLE.+ from handlers import kicad_windows wins: list[dict] = []+ rows = kicad_windows.find(fresh=True) - @ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.HWND, wintypes.LPARAM)- def cb(hwnd, _lp):- if not user32.IsWindowVisible(hwnd):- return True- buf = ctypes.create_unicode_buffer(512)- user32.GetWindowTextW(hwnd, buf, 512)- title = (buf.value or "").strip()+ for row in rows:+ hwnd, title = row["hwnd"], (row.get("title") or "").strip() if not title:- return True- low = title.lower()- looks_kicad = any(k in low for k in ("kicad", "pcb editor", "schematic editor",- "symbol editor", "footprint editor", "3d viewer"))- if not looks_kicad:- return True+ continue if not args.get("includeAll") and not _ERR_TITLE.search(title):- return True+ continue texts = _enum_dialog_text(hwnd) # the message is the longest static; buttons are short ("OK", "Cancel") msg = max(texts, key=len) if texts else ""@@ -298,14 +294,9 @@ def handle_errors(kicad_info: dict, args: dict) -> dict: entry["readTextWith"] = (f'desktop_ui_tree {{"hwnd":{hwnd},"maxDepth":8}} ' "— read it IMMEDIATELY; these dialogs are transient") wins.append(entry)- return True-- try:- user32.EnumWindows(cb, 0)- except Exception as e: # pylint: disable=broad-except- return {"success": False, "error": f"window enumeration failed: {type(e).__name__}: {e}"} return {"success": True, "count": len(wins), "dialogs": wins,+ "windowSource": kicad_windows.source(), "_hint": ("KiCad has NO log file and in GUI mode its errors go to DIALOGS, not " "stderr — so this is where a GUI failure reason lives. Read-only: nothing " "is clicked or raised. THREE channels, use all three: (1) this verb; "
handlers/kicad_windows.pyadded+154@@ -0,0 +1,154 @@+"""ONE place that answers "which KiCad windows exist right now".++Six modules each had their own EnumWindows + filter loop (close_windows.py,+open_symbol_editor.py, open_footprint_editor.py, kicad_ui.py, kicad_log.py,+server.py). They agreed by luck, not by construction, and a fix to one — say,+starting to include owned popups so an error dialog is visible — silently did+not reach the others. This is the single source of truth.++AD CORE FIRST, LOCAL FALLBACK+-----------------------------+The list comes from AD's `desktop_list_windows` when AD is reachable. That is+the 2026-09-01 audit rule (John): the bridge owns KiCad, AD owns Windows. It+also buys something the local path never had — the **z-order** field, which is+the only honest way to tell whether an action stole the user's foreground (see+skills/kicad-uia: response fields lie about this, z-order does not).++When AD is not reachable we enumerate locally, because window discovery is on+the path of nearly every verb and must not become a hard dependency on a+loopback call.++WHY THE CACHE EXISTS (the re-entrancy answer)+---------------------------------------------+AD calls this bridge; a bridge calling back into AD is re-entrant, and a verb+that looks up windows five times would make five loopback round trips (~8ms+each, plus AD's own enumeration) inside a call AD is already blocked on. A+short TTL collapses that to ONE lookup per verb invocation while staying fresh+enough for "what is on screen now": windows do not appear and vanish inside+250ms, and every mutating operation calls invalidate() anyway.++Do NOT raise the TTL to "make things faster". The staleness window is a+correctness budget: `kicad_errors` reading a dialog that closed 900ms ago+reports a failure that is no longer on screen.+"""++from __future__ import annotations++import time++_TTL_S = 0.25+_cache: dict = {"at": 0.0, "rows": None, "source": ""}+++def invalidate() -> None:+ """Call after anything that opens, closes, or retitles a KiCad window."""+ _cache["at"] = 0.0+ _cache["rows"] = None+++def _from_ad() -> list | None:+ """AD's view: hwnd, title, className, rect, kind, and z. None if unreachable."""+ try:+ from handlers import ad_client+ r = ad_client.call("desktop_list_windows", {}, timeout=6) or {}+ except Exception: # pylint: disable=broad-except+ return None+ 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+ return None+ data = (out or {}).get("data") if isinstance(out, dict) else None+ wins = (data or {}).get("windows") if isinstance(data, dict) else None+ if not isinstance(wins, list):+ return None+ rows = []+ for w in wins:+ if not isinstance(w, dict) or not w.get("hwnd"):+ continue+ rect = w.get("rect") or {}+ rows.append({+ "hwnd": int(w["hwnd"]),+ "title": w.get("title") or "",+ "className": w.get("className") or "",+ "rect": {"x": rect.get("left", rect.get("x", 0)),+ "y": rect.get("top", rect.get("y", 0)),+ "width": rect.get("width", 0), "height": rect.get("height", 0)},+ "z": w.get("z"),+ "kind": w.get("kind"),+ })+ return rows+++def _from_local() -> list:+ """Local EnumWindows. The fallback, and the only path when AD is old."""+ try:+ from handlers.kicad_ui import _find_all_kicad_windows+ return list(_find_all_kicad_windows() or [])+ except Exception: # pylint: disable=broad-except+ return []+++def _is_kicad(row: dict) -> bool:+ """Owned by a KiCad executable. Uses the ONE predicate everybody already+ shares (win_focus.is_kicad_window, which checks the owning exe) rather than+ matching on window titles — titles are localised and lie ('[no symbol+ loaded]' while a symbol is selected)."""+ try:+ from handlers.win_focus import is_kicad_window+ return bool(is_kicad_window(row["hwnd"]))+ except Exception: # pylint: disable=broad-except+ t = (row.get("title") or "").lower()+ return any(k in t for k in ("kicad", "pcb editor", "schematic editor",+ "symbol editor", "footprint editor", "3d viewer"))+++def find(all_windows: bool = False, fresh: bool = False) -> list:+ """KiCad windows now. all_windows=True returns every top-level window+ (needed when hunting a dialog whose owner is unclear)."""+ now = time.monotonic()+ if fresh or _cache["rows"] is None or (now - _cache["at"]) > _TTL_S:+ rows = _from_ad()+ src = "ad:desktop_list_windows"+ if rows is None:+ rows, src = _from_local(), "local:EnumWindows"+ _cache.update({"at": now, "rows": rows, "source": src})+ rows = _cache["rows"] or []+ return list(rows) if all_windows else [r for r in rows if _is_kicad(r)]+++def source() -> str:+ """Which path produced the cached list — for a verb that wants to say so."""+ return _cache.get("source") or "(not yet queried)"+++def _owner_hwnd(hwnd: int) -> int:+ """GW_OWNER for a window, 0 if it owns itself. AD's window list does not+ carry ownership, and the project manager is identified BY being unowned, so+ this one bit stays local."""+ try:+ import ctypes+ return int(ctypes.windll.user32.GetWindow(int(hwnd), 4) or 0) # GW_OWNER+ except Exception: # pylint: disable=broad-except+ return 0+++def find_project_manager() -> int | None:+ """The KiCad project-manager window, or None.++ This exact function was duplicated byte-for-byte in open_symbol_editor.py+ and open_footprint_editor.py. It is the manager when the title says KiCad,+ does NOT name a sub-tool, and the window is unowned (an editor or dialog is+ owned by, or titled after, its tool)."""+ for row in find():+ title = (row.get("title") or "")+ low = title.lower()+ if "kicad" not in low:+ continue+ if any(k in low for k in ("editor", "viewer", "calculator")):+ continue+ if _owner_hwnd(row["hwnd"]) == 0:+ return row["hwnd"]+ return None
handlers/open_footprint_editor.py+8−10@@ -85,16 +85,14 @@ def _find_windows_by_title(substring: str) -> list: 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+ """Find the KiCad project manager window handle.++ Delegates to handlers/kicad_windows, which is the ONE window finder (AD's+ desktop_list_windows when reachable, local EnumWindows otherwise). This body+ used to be duplicated byte-for-byte here and in the other editor module, so a+ fix to one never reached the other."""+ from handlers import kicad_windows+ return kicad_windows.find_project_manager()
handlers/open_symbol_editor.py+8−10@@ -241,16 +241,14 @@ def _find_windows_by_title(substring: str) -> list: 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+ """Find the KiCad project manager window handle.++ Delegates to handlers/kicad_windows, which is the ONE window finder (AD's+ desktop_list_windows when reachable, local EnumWindows otherwise). This body+ used to be duplicated byte-for-byte here and in the other editor module, so a+ fix to one never reached the other."""+ from handlers import kicad_windows+ return kicad_windows.find_project_manager() def _find_symbol_editor() -> int | None: