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
dashboard: ralph-test fixes - instant skeleton so the page never sits empty, first paint no longer blocks on /api/targets, unresponsive boxes report unknown instead of confident falsehoods, no raw exceptions in the UI, surfaces auto-launch KiCad (automation doctrine) and render from state.shots, one state read so the plugin LED cannot contradict the window list
2 files changed
+133−23
dashboard/kicad_dashboard.py+92−19@@ -80,8 +80,12 @@ def ab(target, verb, args=None, timeout=140): except Exception: pass return d+ except subprocess.TimeoutExpired:+ return {"error": f"no answer within {timeout}s", "timedOut": True} except Exception as e:- return {"error": str(e)}+ # never leak a raw Python exception (a TimeoutExpired repr used to land+ # verbatim in the Bridge LED as "Command '['adom-bridge', ...]'")+ return {"error": str(e).split("\n")[0][:120]} def get_targets():@@ -202,9 +206,31 @@ def build_status(target): "_hint": "Every LED carries its own _hint naming the verb that fixes it. " "POST /api/action {action,args} to act; subscribe to /events for live pushes."} - r = ab(target, "kicad_readiness", {})+ T = 25 # per-facet ceiling: a box that cannot answer in 25s is "not responding"+ r = ab(target, "kicad_readiness", {}, timeout=T)+ if r.get("error"):+ # THE TRUTHFULNESS RULE (ralph iter-3): if the box did not answer, we do+ # not KNOW anything downstream. Saying "KiCad not installed" or "up to+ # date" here would be a confident lie built on a failed call.+ leds["box"] = {"label": "Box", "state": "err",+ "detail": f"{target} not responding",+ "_hint": "The box is on the relay but its bridge did not answer in time. "+ "It may be asleep, mid-upgrade, or the desktop app is wedged - "+ "pick another box with the pills, or retry with Refresh."}+ for k, lbl in (("bridge", "Bridge"), ("kicad", "KiCad"), ("update", "Updates"),+ ("plugin", "Plugin"), ("focus", "Focus contract"),+ ("presence", "User presence")):+ leds[k] = {"label": lbl, "state": "off", "detail": "unknown",+ "_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": {},+ "windows": [], "dashboardVersion": VERSION,+ "generatedAt": time.strftime("%H:%M:%S"),+ "_hint": "The selected box did not answer; every dependent facet reads unknown "+ "rather than guessing. Switch boxes or retry."} installed = bool(r.get("installed", r.get("defaultVersion")))- cu = ab(target, "kicad_check_for_updates", {})+ cu = ab(target, "kicad_check_for_updates", {}, timeout=T) ver = cu.get("currentVersion") or r.get("defaultVersion") or "?" leds["bridge"] = {"label": "Bridge", "state": "ok" if not r.get("error") else "err", "detail": "answering" if not r.get("error") else str(r.get("error"))[:70],@@ -224,12 +250,16 @@ def build_status(target): "detail": f"up to date (latest {cu.get('latestVersion') or '?'})", "_hint": "The bridge checks upstream every 24h and nudges the AI when a release lands."} - bs = ab(target, "kicad_bridge_status", {})+ bs = ab(target, "kicad_bridge_status", {}, timeout=T) summary = bs.get("summary") or {} alive = summary.get("alive") or bs.get("alive") or 0 # alive lives under summary registered = summary.get("total") or len(bs.get("plugins") or [])- kicad_open = bool((ab(target, "kicad_state", {"includeImages": False})- .get("kicadState") or {}).get("windows"))+ # ONE state read for the whole payload: the plugin LED used to make its own+ # call, so it could say "KiCad closed" while the windows line right below it+ # listed an open window (ralph iter-7).+ st = ab(target, "kicad_state", {"includeImages": False}, timeout=T)+ ks = st.get("kicadState") or st+ kicad_open = bool(ks.get("windows")) if alive: leds["plugin"] = {"label": "Plugin", "state": "ok", "detail": f"{alive} live instance(s)",@@ -248,8 +278,6 @@ def build_status(target): "usually binds it. Everything else works without it - only " "background part navigation is degraded."} - st = ab(target, "kicad_state", {"includeImages": False})- ks = st.get("kicadState") or st fd = ks.get("focusDebug") or {} steals = [e for e in (ks.get("focusEvents") or [])[-20:] if "bounced" in str(e.get("action"))] leds["focus"] = {"label": "Focus contract", "state": "ok" if not steals else "warn",@@ -273,7 +301,7 @@ def build_status(target): if DEV_MODE: leds["suite"] = _suite_led() - settings = (ab(target, "kicad_get_settings", {}, timeout=60) or {}).get("settings") or {}+ settings = (ab(target, "kicad_get_settings", {}, timeout=T) or {}).get("settings") or {} return {"target": target, "targets": targets, "leds": leds, "settings": settings, "windows": [w.get("title") for w in (ks.get("windows") or [])], "bridgeVersion": (ks.get("bridgeVersion") or st.get("bridgeVersion")@@ -300,14 +328,25 @@ def cached_status(target, force=False): return data +_targets_cache = {"t": 0.0, "rows": None}++ def probe_targets():+ """Names first, detail second. This endpoint gates the page's first paint,+ so it must never wait on a wedged box: a 10s per-box ceiling, results cached+ for 30s, and a box that does not answer still appears (just without detail)."""+ if _targets_cache["rows"] and time.time() - _targets_cache["t"] < 30:+ return {"targets": _targets_cache["rows"], "cached": True,+ "_hint": "Cached for 30s; the page paints from names alone."} rows = [] for name in get_targets():- st = ab(name, "kicad_state", {"includeImages": False}, timeout=60)+ st = ab(name, "kicad_state", {"includeImages": False}, timeout=10) ks = st.get("kicadState") or st wins = ks.get("windows") or []- rows.append({"name": name, "kicadRunning": bool(wins),- "windows": len(wins), "kicadVersion": ks.get("kicadVersion")})+ rows.append({"name": name, "kicadRunning": bool(wins), "windows": len(wins),+ "kicadVersion": ks.get("kicadVersion"),+ "responding": not bool(st.get("error"))})+ _targets_cache.update(t=time.time(), rows=rows) return {"targets": rows, "_hint": "Pick where KiCad should run. The bridge only drives instances IT launched, " "so a KiCad the user opened on any box is never parked, bounced, or badged."}@@ -512,6 +551,24 @@ def _surface_args(key, staged_project): }[key] +def ensure_ready(target, surface):+ """A click is a GOAL, not a request for an audit (the Adom automation+ doctrine). If KiCad is not running, launch it and wait - never hand the user+ an error they would fix by pressing a button we could have pressed."""+ st = ab(target, "kicad_state", {"includeImages": False}, timeout=30)+ if (st.get("kicadState") or st).get("windows"):+ return True+ _emit(surface, log="KiCad is closed - launching it in the background first", percent=15)+ ab(target, "kicad_launch", {}, timeout=120)+ for _ in range(20): # up to ~60s for the first window+ time.sleep(3)+ st = ab(target, "kicad_state", {"includeImages": False}, timeout=30)+ if (st.get("kicadState") or st).get("windows"):+ _emit(surface, log="KiCad is up", percent=25)+ return True+ return False++ def run_surface(target, key): """Stage what this surface needs, open it in the BACKGROUND, then render it from kicad_state. Streams progress over SSE and onto the taskbar button."""@@ -527,6 +584,9 @@ def run_surface(target, key): try: _emit(key, status="running", log=note, percent=5) hwnd = taskbar_progress(target, None, "indeterminate")+ if not ensure_ready(target, key):+ raise RuntimeError("KiCad did not come up within 60s - try Launch KiCad, "+ "or check the KiCad LED for an install problem") project = None if need == "part": stage_part(target, key)@@ -541,8 +601,14 @@ def run_surface(target, key): _emit(key, log="rendering it from kicad_state", percent=92) shot, title = _render(target, pick) taskbar_progress(target, 100, "none", hwnd)+ if not shot:+ # ralph iter-4: the job used to report success with no picture at+ # all, which is the one thing this card exists to show.+ _emit(key, status="error", percent=100,+ log=f"{label} opened, but no window could be captured to render it")+ return _emit(key, status="done", percent=100, image=shot, title=title,- log=f"{label} shown in the background" + (f" ({title})" if title else ""))+ log=f"{label} shown in the background" + (f" - {title}" if title else "")) except Exception as e: if hwnd: taskbar_progress(target, 100, "error", hwnd)@@ -550,16 +616,23 @@ def run_surface(target, key): def _render(target, pick):- """Pull this surface's window out of kicad_state as a base64 PNG."""- st = ab(target, "kicad_state", {"maxWidth": 900}, timeout=240)- wins = (st.get("kicadState") or st).get("windows") or []+ """Pull this surface's window out of kicad_state as a base64 PNG.++ ralph iter-5: the captures live in state.SHOTS (each {title, image, hwnd,+ path}), NOT in state.windows - windows carries geometry only, so the render+ always came back empty and the card had nothing to show. `includeImages` is+ what produces shots at all."""+ st = ab(target, "kicad_state", {"includeImages": True, "maxWidth": 900}, timeout=240)+ ks = st.get("kicadState") or st+ shots = ks.get("shots") or [] chosen = None if pick:- chosen = next((w for w in wins if pick in (w.get("title") or "").lower()), None)- chosen = chosen or (wins[-1] if wins else None)+ chosen = next((w for w in shots if pick in (w.get("title") or "").lower()), None)+ chosen = chosen or (shots[-1] if shots else None) if not chosen: return None, None- img = chosen.get("image") or chosen.get("screenshot")+ img = (chosen.get("image") or chosen.get("screenshot")+ or chosen.get("imageBase64") or chosen.get("png")) return img, chosen.get("title")
dashboard/ui/index.html+41−4@@ -88,6 +88,8 @@ dialog::backdrop{background:rgba(0,0,0,.55)} #appinfo{position:fixed;z-index:99998;background:var(--overlay);border:1px solid var(--border);border-radius:10px;padding:12px 14px;font-size:13px;color:var(--text-body);box-shadow:0 8px 24px rgba(0,0,0,.4);display:none;pointer-events:auto;min-width:220px} #appinfo.show{display:block} #appinfo a{color:var(--accent)}+main a{color:var(--accent);text-decoration:none;border-bottom:1px solid rgba(0,184,177,.35)}+main a:hover{border-bottom-color:var(--accent)} #targets{display:flex;gap:8px;margin:0 0 14px;flex-wrap:wrap} .tpill{display:inline-flex;align-items:center;gap:8px;padding:7px 14px;border-radius:999px;border:1px solid var(--border);background:var(--surface);color:var(--text-body);cursor:pointer;font:500 13.5px 'Satoshi',sans-serif;transition:background .15s ease,border-color .15s ease,color .15s ease,transform .06s ease} .tpill:hover{border-color:#4b5563;background:var(--elevated);color:var(--text)}@@ -196,10 +198,17 @@ async function loadTargets(){ targetsCache=d.targets||[]; const el=$('#targets');el.innerHTML=''; $('#boxnote').style.display=targetsCache.length>1?'':'none';- if(!curTarget&&targetsCache.length){curTarget=(targetsCache.find(t=>t.kicadRunning)||targetsCache[0]).name;localStorage.setItem('kdTarget',curTarget);}+ const known=targetsCache.map(t=>t.name);+ if(targetsCache.length&&(!curTarget||!known.includes(curTarget))){+ if(curTarget)toast(curTarget+' is no longer connected - switching to '+known[0]);+ curTarget=(targetsCache.find(t=>t.kicadRunning)||targetsCache[0]).name;+ localStorage.setItem('kdTarget',curTarget);+ } for(const t of targetsCache){ const b=document.createElement('button');b.className='tpill'+(t.name===curTarget?' active':'');- b.setAttribute('data-tip',(t.kicadRunning?'KiCad is running here':'KiCad is closed here')+(t.kicadVersion?(' - KiCad '+t.kicadVersion):''));+ const resp=(t.responding===false)?'not responding':(t.kicadRunning?'KiCad is running here':'KiCad is closed here');+ b.setAttribute('data-tip',resp+(t.kicadVersion?(' - KiCad '+t.kicadVersion):''));+ if(t.responding===false)b.style.opacity='.55'; b.innerHTML='<span class="tdot'+(t.kicadRunning?' on':'')+'"></span>'+t.name; b.onclick=()=>{curTarget=t.name;localStorage.setItem('kdTarget',curTarget);loadTargets();refresh();toast('switched to '+t.name)}; el.appendChild(b);@@ -224,7 +233,29 @@ function renderLeds(st){ const w=st.windows||[]; $('#wins').textContent=w.length?('open KiCad windows: '+w.join(' ยท ')):'no KiCad windows open'; }-async function refresh(force){try{lastStatus=await api(statusUrl(force));renderLeds(lastStatus);}catch(e){toast('status failed: '+e,'err');}}+const LED_SKELETON=['relay','box','bridge','kicad','update','plugin','focus','presence'];+function renderSkeleton(){+ const g=$('#ledGrid');if(g.children.length)return; // only before the first real paint+ g.innerHTML=LED_SKELETON.map(k=>`<div class="card"><div class="led-row"><span class="led dim"></span>`++ `<span class="led-label" style="text-transform:capitalize">${k}</span></div>`++ `<div class="led-detail">checking...</div></div>`).join('');+ $('#subject').textContent='checking '+(curTarget||'the default box')+'...';+}+let refreshing=false;+async function refresh(force){+ if(refreshing)return;refreshing=true;renderSkeleton();+ try{lastStatus=await api(statusUrl(force));renderLeds(lastStatus);}+ catch(e){+ $('#subject').textContent='status unavailable';+ const g=$('#ledGrid');+ g.innerHTML='<div class="card"><div class="led-row"><span class="led off"></span>'++ '<span class="led-label">Status unavailable</span></div>'++ '<div class="led-detail">'+String(e).slice(0,80)+'</div>'++ '<div class="led-sub">The dashboard could not read this box. Pick another with the pills above, or press Refresh.</div></div>';+ toast('status failed: '+e,'err');+ }+ 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);}; $('#btnClose').onclick=async()=>{toast('closing the bridge\u2019s KiCad windows');await act('close');setTimeout(()=>refresh(true),4000);};@@ -402,8 +433,14 @@ brand.addEventListener('mouseleave',()=>{clearTimeout(aiShow);aiHide=setTimeout( ai.addEventListener('mouseenter',()=>clearTimeout(aiHide)); ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove('show'),220);}); +// FIRST PAINT MUST NOT WAIT ON THE NETWORK (ralph iter-2: /api/targets probes+// every box, so one wedged laptop left the whole page on "connecting..." with+// no LEDs and no pills). Skeleton now, then status and targets INDEPENDENTLY.+renderSkeleton(); buildSurfaces();-loadTargets().then(()=>refresh());setInterval(()=>refresh(),15000);setInterval(loadTargets,60000);+refresh();+loadTargets();+setInterval(()=>refresh(),15000);setInterval(loadTargets,60000); </script> </body> </html>