← 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)

John Lauer ·4f1e21fb84 ·2mo ago ·parent 1feede1
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"]