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 7 file(s)
7 files changed
+217−15
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.194+0.9.195
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.194",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.194/adom-bridge-kicad-v0.9.194.zip",- "sha256": "eac65abb0e01fa6fca01404fed453ed9d0e1c330e39351dda33de587c666e098",- "size": 463369,+ "version": "0.9.195",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.195/adom-bridge-kicad-v0.9.195.zip",+ "sha256": "60cc891dbb286f577796cf6a6e847670d698fcdf111267d630503fd00fda04a8",+ "size": 466617, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-19T23:10:00Z",+ "released_at": "2026-08-20T00:05:00Z", "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.194",+ "version": "0.9.195", "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+21@@ -38,6 +38,21 @@ SW_RESTORE = 9 GW_OWNER = 4 ++def _step(label: str, percent=None) -> None:+ """Report a SUB-STEP of the running show/open phase.++ Wiki issue #35 ask 2 (Colby): "sub-phase timings splitting launch vs+ library-scan vs navigate". One opaque 24-second bar cannot tell a slow+ library index from a wedged window; these labels can. Best-effort by+ design - progress reporting must never be able to fail a real verb."""+ try:+ from handlers import progress as _pg+ for ph in ("show_symbol", "cold_start", "warm_start", "show_library"):+ _pg.step(ph, label, percent)+ except Exception:+ pass+ def _parse_editor_title(title: str) -> tuple[str, str]: """Split a Symbol Editor title into (library, symbol). @@ -497,6 +512,8 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: sym_hwnd = _find_symbol_editor() pathways_tried: list[str] = [] rescanned = False+ _step("Symbol Editor already open" if sym_hwnd+ else "opening the Symbol Editor (KiCad indexes every symbol library here)", 10) # 0.9.192: rescan:true is the remedy for errorCode library_cache_stale. An # editor only indexes its libraries at open, so the ONLY way to make it see a@@ -675,6 +692,7 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: } # Wait for Symbol Editor to fully initialize+ _step("Symbol Editor is up, letting it settle", 55) time.sleep(1.5) # --- Phase 2: Navigate to specific symbol (if requested) ---@@ -718,6 +736,7 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: } _verify = (symbol_name or search_text).lower()+ _step(f"locating '{search_text}' via the reverse-bridge plugin", 70) _pl = _plugin_background_load(search_text, library_name, wait_plugin_s=4.0) if str(_pl.get("loaded")) in ("True", "unverified"): time.sleep(0.6)@@ -733,6 +752,7 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: # v0.9.83: plugin-free win32 tier — filter via WM_SETTEXT + Enter, background. # This is the workhorse while eeschema's Python is cold (issue #21). _filter = f"{library_name} {symbol_name}".strip() if library_name else search_text+ _step(f"navigating the library tree to '{search_text}'", 80) if _background_navigate_win32(sym_hwnd, _filter, _verify, library_name): return { "success": True,@@ -753,6 +773,7 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: # own retry loop out through the API. Do it here, once, before failing. # The first attempt is what warms the tree, so this is a fresh-tree retry, # not a duplicate of a hopeless call.+ _step(f"first pass did not confirm; retrying now the tree is warm", 90) if _background_navigate_win32(sym_hwnd, _filter, _verify, library_name): return { "success": True,
handlers/progress.py+68@@ -189,3 +189,71 @@ def finish(phase: str, started_at: float, start_kind: str = "warm", ok: bool = T b["stepLabel"] = step_label b.pop("measured", None) return b+++# ── LIVE, IN-FLIGHT progress (0.9.195) ──────────────────────────────────────+# John's contract to the team (2026-08-19): "ongoing streaming feedback so+# progress bars could be shown in the front-end" and "show live streaming of the+# progress in your text status line". Until now `block()` was only ever built+# ONCE, on the terminal frame - which is a receipt, not a progress bar. A caller+# watching a 24s cold footprint open saw nothing at all until it finished.+#+# So a running verb publishes its current frame here, and anything that can+# poll a verb (ab's `watch` wrapper, the dashboard's SSE hub, a status line)+# can read it while the work is still happening. In-memory on purpose: this is+# liveness, and a stale frame surviving a bridge restart would be a lie.+_live: dict = {}+_live_seq = 0+++def begin(phase: str, start_kind: str = "warm", step_label: str = "starting") -> None:+ """Mark a phase as in flight. Safe to call for phases nobody watches."""+ global _live_seq+ with _lock:+ _live_seq += 1+ _live[phase] = {"startedAt": time.time(), "startKind": start_kind,+ "stepLabel": step_label, "percent": None,+ "blockedBy": None, "seq": _live_seq}+++def step(phase: str, step_label: str, percent=None, blocked_by: str = None) -> None:+ """Advance the visible sub-step of an in-flight phase.++ This is what turns one opaque 24-second bar into "launching KiCad" ->+ "indexing symbol libraries" -> "navigating to IN-S42ATR", which is the+ sub-phase breakdown Colby asked for on wiki issue #35."""+ with _lock:+ cur = _live.get(phase)+ if not cur:+ return+ cur["stepLabel"] = step_label+ cur["blockedBy"] = blocked_by+ if percent is not None:+ # never let a bar go backwards: a bar that retreats reads as broken+ cur["percent"] = max(percent, cur.get("percent") or 0)+++def end(phase: str) -> None:+ with _lock:+ _live.pop(phase, None)+++def live() -> list:+ """Every phase currently in flight, as full progress blocks.++ Percent/ETA are recomputed at READ time from this machine's own history, so+ a poller gets a bar that keeps moving even if the verb has not reported a+ new sub-step since its last one."""+ out = []+ with _lock:+ snapshot = list(_live.items())+ for phase, cur in snapshot:+ try:+ b = block(phase, cur["stepLabel"], cur["startedAt"],+ percent=cur.get("percent"), start_kind=cur.get("startKind", "warm"),+ blocked_by=cur.get("blockedBy"))+ b["seq"] = cur.get("seq")+ out.append(b)+ except Exception:+ pass+ return out
handlers/show.py+75−7@@ -76,8 +76,10 @@ def handle_show_symbol(kicad_info: dict, args: dict) -> dict: "symbol editor", a.get("symbolName") or "", a.get("libraryName") or a.get("library") or "") if already:+ _ev = _capture_evidence(already, "symbol-editor-reused") return {"success": True, "surface": "symbol", "reused": True, "window": already.get("title"),+ **({"evidence": _ev} if _ev else {}), "output": f"Symbol Editor is already showing {already.get('title')} - " f"reused it (no relaunch, no library rescan).", "_hint": "Returned the existing editor instead of running the cold path. "@@ -90,24 +92,79 @@ def handle_show_symbol(kicad_info: dict, args: dict) -> dict: return r -def _find_by_title(*needles) -> bool:- """Is a window whose title contains ALL needles on screen right now?"""+def _find_by_title(*needles):+ """The window whose title contains ALL needles, or None.++ 0.9.195: this used to return a bare bool. The window DICT is what lets+ _verify_window capture the thing it just verified, so evidence comes from+ the same lookup that decided the verb succeeded - not a second, possibly+ different, window found later.""" try: from handlers.kicad_ui import _find_all_kicad_windows for w in _find_all_kicad_windows() or []: t = (w.get("title") or "").lower() if all(n in t for n in needles):- return True+ return w except Exception: pass- return False+ return None+++def _window_title(hwnd: int) -> str:+ try:+ from handlers.kicad_ui import _get_window_title+ return _get_window_title(hwnd) or ""+ except Exception:+ return ""+++def _capture_evidence(win, label: str) -> dict | None:+ """Screenshot the window a show_* verb just opened, in the BACKGROUND.++ John's contract to the team (2026-08-19): "you should always get a+ screenshot of the kicad or fusion window after the item opens in it so you+ can show that in a thumbnail". Before this, the verbs only TOLD the caller+ to go screenshot something - which is how a caller ends up screenshotting+ the project manager and captioning it as a footprint (0.9.172). Capturing+ here, from the verified hwnd, means the evidence and the success verdict+ come from the same window and cannot disagree.++ PrintWindow captures an occluded window, so this never foregrounds."""+ try:+ # The finders are not uniform: show.py's own return a window dict, while+ # _find_footprint_editor / _find_pcb_editor return a bare hwnd. Accept+ # both, or the footprint and board surfaces - the ones most likely to be+ # mistaken for the project window - would be the only ones with no proof.+ if isinstance(win, dict):+ hwnd, title = win.get("hwnd"), win.get("title")+ else:+ hwnd, title = win, _window_title(win)+ if not hwnd:+ return None+ from handlers.kicad_ui import _screenshot_hwnd+ shot = _screenshot_hwnd(hwnd, label=label)+ if not shot or not shot.get("success"):+ return None+ return {+ "window": title,+ "hwnd": hwnd,+ "screenshotPath": shot.get("fullPath") or shot.get("savedTo"),+ "sizeKB": shot.get("sizeKB"),+ "capturedInBackground": True,+ "_hint": ("Proof this surface is really on screen, captured from the same "+ "window that satisfied the verb. ab auto-pulls fullPath into the "+ "caller's container, so it can be read or shown as a thumbnail "+ "without a separate pull_file."),+ }+ except Exception:+ return None -def _find_symbol_editor() -> bool:+def _find_symbol_editor(): return _find_by_title("symbol editor") -def _find_3d_viewer() -> bool:+def _find_3d_viewer(): return _find_by_title("3d viewer") @@ -122,7 +179,16 @@ def _verify_window(result: dict, finder, label: str, waits: int = 12) -> dict: if not isinstance(result, dict) or not result.get("success"): return result for _ in range(waits):- if finder():+ win = finder()+ if win:+ # Evidence is part of the contract, not a nicety - but a failed+ # capture must never turn a real success into a failure, so it is+ # reported as absent rather than raised.+ ev = _capture_evidence(win, label)+ if ev:+ result["evidence"] = ev+ else:+ result["evidenceUnavailable"] = "window verified but the capture failed" return result time.sleep(0.5) return {**result, "success": False,@@ -209,8 +275,10 @@ def handle_show_footprint(kicad_info: dict, args: dict) -> dict: already_fp = _open_editor_showing("footprint editor", a2.get("footprintName") or "", a2.get("library") or a2.get("libraryName") or "") if already_fp:+ _ev = _capture_evidence(already_fp, "footprint-editor-reused") return {"success": True, "surface": "footprint", "reused": True, "window": already_fp.get("title"),+ **({"evidence": _ev} if _ev else {}), "output": f"Footprint Editor is already showing {already_fp.get('title')} - " f"reused it (no relaunch, no library rescan).", "_hint": "Returned the existing editor instead of running the cold path "
server.py+46−1@@ -540,6 +540,36 @@ def _detect_running_kicad_instances() -> list[dict]: return [] ++def _handle_progress(kicad_info: dict, args: dict) -> dict:+ """kicad_progress — what is this bridge doing RIGHT NOW, as progress blocks.++ 0.9.195. The measured-progress contract (#32) only ever produced a terminal+ frame, which is a receipt: a caller watching a 24s cold footprint open saw+ nothing until it was over. This verb is the live half, so a front-end can+ draw a real bar and a text status line can stream sub-steps while the work+ is still happening. Read-only, cheap, and safe to poll every second."""+ try:+ from handlers import progress as _pg+ frames = _pg.live()+ except Exception as e:+ return {"success": False, "error": f"progress unavailable: {e}"[:140]}+ return {+ "success": True,+ "busy": bool(frames),+ "active": frames,+ "history": _pg.history(),+ "_hint": ("Poll this while a long verb runs (1s is fine). `active` holds one "+ "progress block per in-flight phase: stepLabel is the human line for "+ "a status bar, percent/etaSec drive the bar, and confidence says "+ "whether the estimate is measured on THIS machine or still a default. "+ "An empty `active` with busy:false means the bridge is idle - it does "+ "NOT mean a verb failed. blockedBy names a human-controlled wait (a "+ "modal dialog), where a bar should stop animating and say so."),+ "_next": ["kicad_status — bridge/plugin liveness",+ "kicad_describe — the progressContract this conforms to"],+ }+ COMMAND_HANDLERS = { "open_board": handle_open_board, "open_schematic": handle_open_schematic,@@ -587,6 +617,7 @@ COMMAND_HANDLERS = { "uninstall": handle_uninstall, "install_plugin": plugin_install.handle_install_plugin, "plugin_diagnose": handle_plugin_diagnose,+ "progress": _handle_progress, # v1.7.12+: headless lint pre-checks via kicad-cli "run_erc": handle_run_erc, "lint_board": handle_lint_board,@@ -776,7 +807,7 @@ def dispatch_command(command: str, args: dict) -> dict: _NO_GUI = {"run_drc", "run_erc", "lint_board", "lint_schematic", "lint_library", "format_upgrade", "export_gerber", "export_gerbers", "export_pdf", "export_svg", "export_step", "export_bom_csv", "adom_library_status",- "list_versions", "install_library"}+ "list_versions", "install_library", "progress"} # Only verbs that actually SPAWN a window get the ~3s settle-poll (they can # trigger an async dialog). Read-only reporters (open_editors, screenshot_all, # window_info) don't spawn windows → one cheap scan, no needless 3s poll.@@ -824,6 +855,10 @@ def dispatch_command(command: str, args: dict) -> dict: _prog_kind = _pg.start_kind_for_kicad() if _prog_phase == "cold_start" and _prog_kind == "warm": _prog_phase = "warm_start" # cold vs warm differ by 10-50x+ # 0.9.195: publish a LIVE frame for the whole duration, not just a+ # receipt at the end. This is what a front-end progress bar and a+ # status line read while the verb is still running.+ _pg.begin(_prog_phase, _prog_kind, step_label=f"{command} starting") except Exception: pass @@ -852,6 +887,16 @@ def dispatch_command(command: str, args: dict) -> dict: _wfg.end_gui_verb() except Exception: pass+ # Retire the live progress frame HERE, in the one block that runs even+ # when the handler raises. Anywhere later is unreachable on the error+ # path, and a leaked frame shows a poller a bar that never completes for+ # a call that is long over - worse than showing no bar at all.+ try:+ if _prog_phase:+ from handlers import progress as _pg3+ _pg3.end(_prog_phase)+ except Exception:+ pass # Stamp the version actually used so callers can see which install ran, and # OUR OWN version — 0.9.159: BRIDGE_VERSION only ever reached /health, so no # caller (the dashboard included) could ask which bridge code is running, and