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.74: plugin 0.9.1 two-hop background load (filter -> settle 0.8s on worker thread -> activate; fixed DataViewEvent 3-arg ctor) + open_symbol_editor tries the plugin path first, PS nav as fallback
7 files changed
+99−22
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.73+0.9.74
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.73",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.73/adom-bridge-kicad-v0.9.73.zip",- "sha256": "b62a3811b19bcfd1b30db9ccb9463329e8931417ea05da7609cb55c8f5907ddb",- "size": 366949,+ "version": "0.9.74",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.74/adom-bridge-kicad-v0.9.74.zip",+ "sha256": "968d98586a21f4221d0900c2b117f0f032c7ec8c44be79afd2780a3a3dd34cec",+ "size": 367941, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-09T01:40:12Z",+ "released_at": "2026-08-09T01:47:18Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.74.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.73",+ "version": "0.9.74", "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/open_symbol_editor.py+68−7@@ -125,11 +125,50 @@ def _find_child_by_class_and_parent(parent_hwnd: int, child_class: str, parent_t return result[0] +SW_SHOWNOACTIVATE = 4++ def _force_foreground(hwnd: int):- """Bring a window to the foreground."""- user32.ShowWindow(hwnd, SW_RESTORE)- user32.SetForegroundWindow(hwnd)- user32.BringWindowToTop(hwnd)+ """DELIBERATELY does NOT foreground — the bridge must never steal the user's+ focus (John, 2026-07). Just make the window visible without activating it; the+ plugin WM_COMMAND path drives it without focus and the dispatcher restores the+ user's window afterward. Name kept to avoid churning the call sites."""+ try:+ user32.ShowWindow(hwnd, SW_SHOWNOACTIVATE)+ except Exception:+ pass+++def _get_symbol_editor_title() -> str:+ """Current Symbol Editor window title ('' if not found) — load ground truth."""+ try:+ hwnd = _find_symbol_editor()+ if not hwnd:+ return ""+ buf = ctypes.create_unicode_buffer(512)+ user32.GetWindowTextW(hwnd, buf, 512)+ return buf.value or ""+ except Exception: # pylint: disable=broad-except+ return ""+++def _plugin_background_load(symbol_name: str) -> dict:+ """v0.9.74: background canvas-load via the in-process plugin (payload 0.9.1+).++ Two-hop filter->settle->activate inside the plugin, zero focus/keyboard+ theft (John's no-foreground mandate). Uses bridge_client's discovery to find+ the live eeschema instance. {'loaded': False, ...} on any failure so the+ caller can fall through to the legacy paths.+ """+ try:+ from .bridge_client import _resolve_plugin, call as _plugin_call+ info, err = _resolve_plugin("eeschema")+ if err is not None or not info:+ return {"loaded": False, "_error": "no live eeschema plugin"}+ return _plugin_call(info["port"], "select_and_load_symbol",+ {"symbolName": symbol_name}, timeout=15.0) or {"loaded": False}+ except Exception as e: # pylint: disable=broad-except+ return {"loaded": False, "_error": f"{type(e).__name__}: {e}"} def _navigate_to_symbol(sym_hwnd: int, symbol_name: str) -> dict:@@ -292,7 +331,7 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: else f"plugin-failed:{tier1.get('errorCode','')}" ) if tier1.get("success"):- for _ in range(20):+ for _ in range(12): time.sleep(0.5) sym_hwnd = _find_symbol_editor() if sym_hwnd:@@ -329,8 +368,15 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: } try:+ _si = None+ try:+ from handlers import win_focus+ _si = win_focus.background_startupinfo() # open the manager behind the user+ except Exception:+ _si = None subprocess.Popen( [kicad_exe],+ startupinfo=_si, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, )@@ -341,7 +387,7 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: "_hint": "Call close_kicad with {\"force\": true} to clear stuck processes, then retry.", } - for _ in range(20):+ for _ in range(12): time.sleep(0.5) kicad_hwnd = _find_kicad_manager() if kicad_hwnd:@@ -359,7 +405,7 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: user32.PostMessageW(kicad_hwnd, WM_COMMAND, KICAD_SYMBOL_EDITOR_CMD_ID, 0) pathways_tried.append("wm_command") - for _ in range(20):+ for _ in range(12): time.sleep(0.5) sym_hwnd = _find_symbol_editor() if sym_hwnd:@@ -395,6 +441,21 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: # users who want to experiment, but the default kicad_open_symbol_editor # flow keeps using the proven PowerShell path. + # v0.9.74: FIRST try the plugin's background canvas-load (payload 0.9.1's+ # two-hop filter->settle->activate) — zero focus/keyboard theft. The PS+ # navigate below is the fallback when no plugin instance is alive.+ _pl = _plugin_background_load(search_text)+ if str(_pl.get("loaded")) in ("True", "unverified"):+ time.sleep(0.6)+ _title = _get_symbol_editor_title() if "_get_symbol_editor_title" in globals() else ""+ if _pl.get("loaded") is True or (search_text.lower() in (_title or "").lower()):+ return {+ "success": True,+ "output": f"Symbol '{search_text}' loaded (background plugin path): {_title or _pl.get('frameTitle','')}",+ "via": "plugin-background",+ }+ # unverified and title doesn't confirm — fall through to PS nav+ nav_result = _navigate_to_symbol(sym_hwnd, search_text) if nav_result["status"] == "ok":
plugin_install.py+1−1@@ -49,7 +49,7 @@ from typing import Optional # Bridge version we're shipping. Bumped together with adom_bridge.py's # __version__ — version drift between this file and the payload would # cause an infinite reinstall loop; the runtime asserts they match. -PAYLOAD_VERSION = "0.9.0" +PAYLOAD_VERSION = "0.9.1" # Module-level cache: install runs at most ONCE per bridge process boot. # Multiple concurrent kicad_* commands hitting the bridge during startup
plugin_payload/adom_bridge.py+23−7@@ -60,7 +60,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Callable, Optional -__version__ = "0.9.0"+__version__ = "0.9.1" # Exe-name -> port offset. The KiCad bridge in adom-desktop knows this # table too. New exes get appended; never renumber existing entries or@@ -2164,13 +2164,11 @@ def _ui_select_and_load(editor_kind: str, name: str, use_send_input: bool = Fals sel_ok = bool(sel and sel.IsOk()) transport_detail["selection"] = sel_ok if sel_ok:+ # wxPython overloads: (type, ctrl, item) is the one that+ # builds a fully-populated event (the no-arg + SetItem+ # pattern raises TypeError on 4.2). evt = wx.dataview.DataViewEvent(- wx.dataview.wxEVT_DATAVIEW_ITEM_ACTIVATED)- try:- evt.SetEventObject(dvc)- evt.SetItem(sel)- except Exception: # pylint: disable=broad-except- pass+ wx.dataview.wxEVT_DATAVIEW_ITEM_ACTIVATED, dvc, sel) wx.PostEvent(dvc, evt) keys_sent.append("DATAVIEW_ITEM_ACTIVATED(selection)") except Exception as e: # pylint: disable=broad-except@@ -2881,8 +2879,26 @@ def _dispatch(method: str, params: dict) -> Any: if method == "select_and_load_symbol": name = params.get("symbolName") or params.get("name") or "" use_si = bool(params.get("useSendInput"))+ if not use_si:+ # Two-hop background path (v0.9.1): KiCad's LIB_TREE debounces the+ # search filter (~200-500ms on a timer that runs on the UI thread),+ # so filter-then-activate in ONE UI call always sees an empty+ # selection (verified live: first call selection:false, second call+ # loads). We're on the RPC worker thread here, so hop 1 sets the+ # filter, we sleep while the UI thread's debounce fires, then hop 2+ # activates the pre-selected match.+ import time as _t+ _call_on_ui_thread(lambda: _ui_navigate_symbol(name))+ _t.sleep(0.8)+ return _call_on_ui_thread(lambda: _ui_select_and_load("symbol", name, use_send_input=False)) return _call_on_ui_thread(lambda: _ui_select_and_load_symbol(name, use_si), timeout=30.0) if method == "select_and_load_footprint":+ _n = params.get("footprintName") or params.get("name") or ""+ if not bool(params.get("useSendInput")):+ import time as _t+ _call_on_ui_thread(lambda: _ui_navigate_footprint(_n))+ _t.sleep(0.8)+ return _call_on_ui_thread(lambda: _ui_select_and_load("footprint", _n, use_send_input=False)) 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)