← Commit history

v0.9.170: defer the plugin start - KiCad scans its plugin dir before sys.path is complete (inline import died on 'No module named urllib'), so the loader now arms a thread and imports once KiCad is ready; dashboard LEDs gain live busy states

John Lauer ·40e33aac23 ·1mo ago ·parent efa7ec7
7 files changed +149−38
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.169+0.9.170
adom-bridge-kicad-manifest.json+5−5
@@ -1,15 +1,15 @@ {   "manifest_version": 1,   "name": "kicad",-  "version": "0.9.169",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.169/adom-bridge-kicad-v0.9.169.zip",-  "sha256": "712d685502391a40ad2cd888cdfc977c9d46c4c80f41d78ec36a4f9dd87737f1",-  "size": 445013,+  "version": "0.9.170",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.170/adom-bridge-kicad-v0.9.170.zip",+  "sha256": "861804aba9310463e39858066b5f858639a06061c86c17f613ad30d0b23470d2",+  "size": 445363,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-08-18T13:53:22Z",+  "released_at": "2026-08-18T13:57:24Z",   "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png",   "languages": [     "Python",
adom-bridge-kicad-v0.9.170.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.169",+  "version": "0.9.170",   "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.",
dashboard/kicad_dashboard.py+88−5
@@ -224,7 +224,8 @@ def build_status(target):                        "_hint": f"Unknowable while {target} is not answering ({r.get('error')})."}         if DEV_MODE:             leds["suite"] = _suite_led()-        return {"target": target, "targets": targets, "leds": leds, "settings": {},+        return {"target": target, "targets": targets, "leds": _apply_activity(leds),+                "settings": {},                 "windows": [], "dashboardVersion": VERSION,                 "generatedAt": time.strftime("%H:%M:%S"),                 "_hint": "The selected box did not answer; every dependent facet reads unknown "@@ -302,7 +303,8 @@ def build_status(target):         leds["suite"] = _suite_led()      settings = (ab(target, "kicad_get_settings", {}, timeout=T) or {}).get("settings") or {}-    return {"target": target, "targets": targets, "leds": leds, "settings": settings,+    return {"target": target, "targets": targets, "leds": _apply_activity(leds),+            "settings": settings,             "windows": [w.get("title") for w in (ks.get("windows") or [])],             "bridgeVersion": (ks.get("bridgeVersion") or st.get("bridgeVersion")                               or r.get("bridgeVersion") or cu.get("bridgeVersion")),@@ -312,6 +314,49 @@ def build_status(target):                      "subscribe to /events (SSE) for live pushes; POST /ui/toast to narrate on the app."}  +# ---------------------------------------------------------------- activity+# John, 2026-08-18: "while you're opening kicad show an LED indicator that it's+# opening so the user knows there's background activity rather than just showing+# a dead LED". Every long action registers here, the matching LED switches to a+# pulsing `busy` state with a live label, and a state frame is pushed at once so+# the page reacts immediately instead of waiting for the 15s poll.+_activity: dict = {}+_activity_lock = threading.Lock()+++def set_activity(led, detail, target=None):+    with _activity_lock:+        _activity[led] = {"detail": detail, "t": time.time()}+    _push_state(target)+++def clear_activity(led, target=None):+    with _activity_lock:+        _activity.pop(led, None)+    _push_state(target)+++def _apply_activity(leds):+    with _activity_lock:+        live = dict(_activity)+    for led, info in live.items():+        base = leds.get(led) or {"label": led.title(), "_hint": ""}+        leds[led] = {**base, "state": "busy", "detail": info["detail"],+                     "_hint": "Working on it now - this LED is live, not stale. "+                              + (base.get("_hint") or "")}+    return leds+++def _push_state(target=None):+    """Recompute and broadcast, so a busy LED appears the instant work starts."""+    def _go():+        try:+            HUB.broadcast("state", cached_status(target or "-", force=True))+        except Exception:+            pass+    threading.Thread(target=_go, daemon=True).start()++ _cache = {"t": 0.0, "target": None, "data": None} _cache_lock = threading.Lock() @@ -583,6 +628,7 @@ def run_surface(target, key):     hwnd = None     try:         _emit(key, status="running", log=note, percent=5)+        set_activity("kicad", f"opening the {label.lower()} in the background...", target)         hwnd = taskbar_progress(target, None, "indeterminate")         if not ensure_ready(target, key):             raise RuntimeError("KiCad did not come up within 60s - try Launch KiCad, "@@ -613,6 +659,8 @@ def run_surface(target, key):         if hwnd:             taskbar_progress(target, 100, "error", hwnd)         _emit(key, status="error", log=str(e)[:200])+    finally:+        clear_activity("kicad", target)   def _render(target, pick):@@ -636,18 +684,53 @@ def _render(target, pick):     return img, chosen.get("title")  +def _launch_watched(target):+    """Launch with a live LED: 'launching...' then 'waiting for the first+    window...' then back to the real status once it is up."""+    try:+        set_activity("kicad", "launching KiCad in the background...", target)+        r = ab(target, "kicad_launch", {}, timeout=180)+        set_activity("kicad", "waiting for the first window...", target)+        for _ in range(20):+            time.sleep(3)+            st = ab(target, "kicad_state", {"includeImages": False}, timeout=25)+            if ((st.get("kicadState") or st).get("windows")):+                break+        set_activity("plugin", "KiCad is up - waiting for the plugin to bind...", target)+        for _ in range(10):+            time.sleep(6)+            bs = ab(target, "kicad_bridge_status", {}, timeout=25)+            if (bs.get("summary") or {}).get("alive"):+                break+        return r+    finally:+        clear_activity("kicad", target)+        clear_activity("plugin", target)++ def do_action(target, name, args):     args = args or {}     if name == "launch":-        return ab(target, "kicad_launch", {})+        threading.Thread(target=_launch_watched, args=(target,), daemon=True).start()+        return {"success": True, "started": "launch",+                "_hint": "Launching in the background; the KiCad and Plugin LEDs show live "+                         "progress and the page updates over /events as it happens."}     if name == "close":-        return ab(target, "kicad_close", {"force": True})+        set_activity("kicad", "closing the bridge's KiCad windows...", target)+        try:+            return ab(target, "kicad_close", {"force": True}, timeout=180)+        finally:+            clear_activity("kicad", target)     if name == "demo":         return ab(target, "kicad_demo", {"all": True, "background": True})     if name == "demo_progress":         return ab(target, "kicad_demo", {"progress": True}, timeout=60)     if name == "upgrade":-        return ab(target, "kicad_upgrade", {}, timeout=600)+        set_activity("update", "installing the latest KiCad (silent, no UAC)...", target)+        try:+            return ab(target, "kicad_upgrade", {}, timeout=600)+        finally:+            clear_activity("update", target)     if name == "install_plugin":         return ab(target, "kicad_install_plugin", {})     if name == "set_overlay_badges":
dashboard/ui/index.html+7−3
@@ -36,6 +36,10 @@ h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);ma .led.off{background:var(--red)} .led.warn{background:var(--yellow)} .led.dim{background:var(--text-2)}+.led.busy{background:var(--accent);animation:ledpulse 1.1s ease-in-out infinite}+@keyframes ledpulse{0%,100%{opacity:1;box-shadow:0 0 0 0 rgba(0,184,177,.55)}+  50%{opacity:.55;box-shadow:0 0 10px 3px rgba(0,184,177,.25)}}+.card.busy{border-color:rgba(0,184,177,.45)} .led-label{font-weight:500;color:var(--text)} .led-detail{font-size:12.5px;color:var(--text);margin-top:6px} .led-sub{font-size:12.5px;color:var(--text-2);margin-top:6px}@@ -222,8 +226,8 @@ function renderLeds(st){   const keys=ORDER.filter(k=>leds[k]).concat(Object.keys(leds).filter(k=>!ORDER.includes(k)));   for(const key of keys){     const l=leds[key];-    const cls=l.state==='ok'?'on':(l.state==='warn'?'warn':(l.state==='err'?'off':'dim'));-    const el=document.createElement('div');el.className='card';+    const cls=l.state==='busy'?'busy':(l.state==='ok'?'on':(l.state==='warn'?'warn':(l.state==='err'?'off':'dim')));+    const el=document.createElement('div');el.className='card'+(l.state==='busy'?' busy':'');     el.innerHTML=`<div class="led-row"><span class="led ${cls}"></span><span class="led-label">${l.label||key}</span></div>`+                  `<div class="led-detail">${l.detail||''}</div><div class="led-sub">${l._hint||''}</div>`;     g.appendChild(el);@@ -257,7 +261,7 @@ async function refresh(force){   finally{refreshing=false;} } $('#btnRefresh').onclick=()=>{toast('refreshing status');refresh(true);};-$('#btnLaunch').onclick=async()=>{toast('launching KiCad in the background');await act('launch');setTimeout(()=>refresh(true),4000);};+$('#btnLaunch').onclick=async()=>{toast('launching KiCad in the background - watch the KiCad LED');await act('launch');}; $('#btnClose').onclick=async()=>{toast('closing the bridge\u2019s KiCad windows');await act('close');setTimeout(()=>refresh(true),4000);};  // Guided demo with a REAL progress bar + the stepShot stream (one screenshot per finished beat).
plugin_install.py+47−23
@@ -144,45 +144,69 @@ _PLUGIN_DIR_LOADER = """\  THE 10.0.5 FIX (issue #31). usercustomize.py stopped running in 10.0.5 GUI processes, so the reverse bridge never started. KiCad DOES still import every-.py in its scripting/plugins directory (verified live on 10.0.5), so this is the-entry point that still fires.--0.9.168: start the payload DIRECTLY rather than re-importing usercustomize, and-guard per-PROCESS. The first cut guarded with an environment variable, which-child processes INHERIT - so once any KiCad process had run the loader, every-KiCad it spawned skipped it silently (imported, compiled a .pyc, started-nothing). A module attribute cannot leak across processes.+.py in its scripting/plugins directory, so this is the entry point that fires.++0.9.170 - WHY THE START IS DEFERRED. KiCad scans this directory very early,+before the embedded interpreter's sys.path is complete: importing the payload+inline died with ModuleNotFoundError: No module named 'urllib' (raised deep in+http.server -> email.utils, out of KiCad's own Lib). So the plugin only ARMS a+background thread here and does the real import a few seconds later, once KiCad+has finished setting Python up. Everything is logged to+%TEMP%/adom-kicad-plugin-loader.log so a failure is one file away. \"\"\" import os import sys import tempfile+import threading+import time++_LOG = os.path.join(tempfile.gettempdir(), "adom-kicad-plugin-loader.log")   def _log(msg):     try:-        with open(os.path.join(tempfile.gettempdir(),-                               "adom-kicad-plugin-loader.log"), "a") as fh:-            fh.write(msg + chr(10))+        with open(_LOG, "a") as fh:+            fh.write(time.strftime("%H:%M:%S ") + str(msg) + chr(10))     except Exception:         pass  +def _start_when_ready():+    exe = os.path.basename(getattr(sys, "executable", "") or "").lower()+    for attempt in range(12):          # ~60s of patience, then give up quietly+        time.sleep(5)+        try:+            import adom_bridge+        except Exception as e:+            _log("attempt %d in %s: payload not importable yet (%s)" % (attempt + 1, exe, e))+            continue+        try:+            fn = None+            for name in ("start", "start_server", "ensure_started", "main"):+                fn = getattr(adom_bridge, name, None)+                if callable(fn):+                    break+            if not callable(fn):+                _log("payload imported but no start entry point found")+                return+            port = fn()+            _log("started in %s (port %s)" % (exe, port))+            return+        except Exception:+            import traceback+            _log(traceback.format_exc())+            return+    _log("gave up waiting for the payload to become importable in " + exe)++ try:     if getattr(sys, "_adom_kicad_bridge_loaded", False):-        _log("skip: already loaded in this process")+        _log("skip: already armed in this process")     else:         sys._adom_kicad_bridge_loaded = True-        exe = os.path.basename(getattr(sys, "executable", "") or "").lower()-        _log("loading in " + exe)-        import adom_bridge-        started = None-        for name in ("start", "start_server", "main", "ensure_started"):-            fn = getattr(adom_bridge, name, None)-            if callable(fn):-                started = name-                fn()-                break-        _log("adom_bridge." + str(started) + "() called")+        threading.Thread(target=_start_when_ready, daemon=True,+                         name="adom-bridge-start").start()+        _log("armed in " + os.path.basename(getattr(sys, "executable", "") or "")) except Exception:     import traceback     _log(traceback.format_exc())