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
kicad-dashboard v1: the bridge's Launch Page (fusion-dashboard sibling, port 8931) - LED status (relay/box/bridge/kicad/plugin/updates/focus/presence), Settings panel (overlay badges), copy-paste AI prompts, target picker; serve/status/health CLI
1 file changed
+285
dashboard/kicad_dashboard.pyadded+285@@ -0,0 +1,285 @@+#!/usr/bin/env python3+"""kicad-dashboard - the KiCad bridge's Launch Page (fusion-dashboard's sibling).++John (2026-08-17): "did you ever make a dashboard like fusion made for itself?"+Now it does. LED status for every bridge facet, a Settings panel (overlay+badges), copy-paste AI prompts, and a target picker - served in the container,+rendered in a Hydrogen webview / pup / phone browser.++ kicad-dashboard serve [--port 8931] [--target <box>] [--print-url]+ kicad-dashboard status [--target <box>] one-shot LED JSON+ kicad-dashboard health+"""+import json+import subprocess+import sys+import threading+import time+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer+from urllib.parse import urlparse, parse_qs++PORT = 8931+AI_THREAD = "kicad-dashboard"+_cache = {"t": 0.0, "target": None, "data": None}+_cache_lock = threading.Lock()+DEFAULT_TARGET = None # first connected+++def ab(target, verb, args, timeout=30):+ cmd = ["adom-bridge", "--ai-thread", AI_THREAD]+ if target:+ cmd += ["--target", target]+ cmd += [verb, json.dumps(args)]+ try:+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)+ return json.loads(p.stdout.strip() or p.stderr.strip())+ except Exception as e:+ return {"error": str(e)[:120]}+++def get_targets():+ try:+ p = subprocess.run(["adom-bridge", "targets"], capture_output=True, text=True, timeout=20)+ d = json.loads(p.stdout.strip())+ return [t.get("name") for t in d.get("targets") or []]+ except Exception:+ return []+++def build_status(target):+ """The LED model. Each facet: {state: ok|warn|err|off, detail}."""+ targets = get_targets()+ leds = {}+ box_ok = bool(target and target in targets)+ leds["relay"] = {"state": "ok" if targets else "err",+ "detail": f"{len(targets)} box(es) connected" if targets else "no boxes on the relay"}+ leds["box"] = {"state": "ok" if box_ok else "err",+ "detail": target if box_ok else f"'{target}' not connected"}+ if not box_ok:+ for k in ("bridge", "kicad", "plugin", "update", "focus", "presence"):+ leds[k] = {"state": "off", "detail": "box offline"}+ return {"target": target, "targets": targets, "leds": leds,+ "settings": {}, "generatedAt": time.strftime("%H:%M:%S")}++ r = ab(target, "kicad_readiness", {}, timeout=140)+ installed = bool(r.get("installed", r.get("defaultVersion")))+ ver = r.get("defaultVersion") or "?"+ leds["bridge"] = {"state": "ok" if not r.get("error") else "err",+ "detail": "answering" if not r.get("error") else str(r.get("error"))[:80]}+ leds["kicad"] = {"state": "ok" if installed else "err",+ "detail": f"KiCad {ver}" if installed else "not installed (kicad_upgrade installs it)"}+ nudge = r.get("kicadUpdate")+ leds["update"] = ({"state": "warn", "detail": f"{nudge['installed']} -> {nudge['latest']} available"}+ if nudge else {"state": "ok", "detail": "up to date (checked <24h)"})++ bs = ab(target, "kicad_bridge_status", {}, timeout=140)+ summ = bs.get("summary") or {}+ alive = summ.get("alive", 0) if isinstance(summ, dict) else 0+ leds["plugin"] = ({"state": "ok", "detail": f"{alive} live instance(s)"} if alive+ else {"state": "warn", "detail": "no live plugin (binds ~10s after a KiCad launch)"})++ st = ab(target, "kicad_state", {"includeImages": False}, timeout=140)+ fd = st.get("focusDebug") or {}+ ev = st.get("focusEvents") or []+ steals = [e for e in ev if "bounced" in (e.get("action") or "")]+ leds["focus"] = {"state": "ok" if not steals else "warn",+ "detail": (f"quiet - {fd.get('parkedCount', 0)} parked, guards {fd.get('guardCount', 0)}"+ if not steals else f"{len(steals)} self-raise(s) bounced recently")}+ idle = st.get("userIdleSeconds")+ if idle is None:+ leds["presence"] = {"state": "off", "detail": "unknown"}+ elif idle < 600:+ leds["presence"] = {"state": "warn",+ "detail": f"USER ACTIVE ({int(idle)}s idle) - window automation deferred"}+ else:+ leds["presence"] = {"state": "ok", "detail": f"idle {int(idle // 60)} min - automation clear"}++ settings = (ab(target, "kicad_get_settings", {}, timeout=60) or {}).get("settings") or {}+ return {"target": target, "targets": targets, "leds": leds, "settings": settings,+ "windows": [w.get("title") for w in st.get("windows") or []],+ "generatedAt": time.strftime("%H:%M:%S")}+++def cached_status(target):+ with _cache_lock:+ if _cache["data"] and _cache["target"] == target and time.time() - _cache["t"] < 8:+ return _cache["data"]+ data = build_status(target)+ with _cache_lock:+ _cache.update(t=time.time(), target=target, data=data)+ return data+++PROMPTS = [+ ("Run the guided demo",+ "Run the KiCad bridge demo on my machine: kicad_demo {\"all\": true, \"background\": true}, "+ "poll kicad_demo {\"progress\": true} every 3s, show me each stepShot as it arrives, and "+ "speak result.narrate when done. Everything stays in the background."),+ ("Tour a wiki part end to end",+ "Take the wiki part page I give you (it ships .kicad_sym/.kicad_mod/.step), install it with "+ "kicad_install_symbol/kicad_install_footprint {model:{filePath}} via ab send_files, run "+ "kicad_make_part_project {partName}, then show all 8 surfaces (kicad_show_*) and export "+ "gerbers - all background, with screenshots."),+ ("Update KiCad to the latest",+ "Check kicad_check_for_updates; if an update is available, kicad_close then kicad_upgrade {} "+ "(official installer, silent). Verify with a gerber export version stamp."),+]++PAGE = """<!doctype html><html><head><meta charset="utf-8">+<title>KiCad - Launch Page</title>+<meta name="viewport" content="width=device-width, initial-scale=1">+<style>+:root{--bg:#12141a;--card:#1a1e27;--line:#2a3040;--txt:#e5e8ef;--dim:#8b93a7;+--teal:#2dd4bf;--ok:#34d399;--warn:#fbbf24;--err:#f87171;--off:#4b5563}+*{box-sizing:border-box;margin:0;padding:0}+body{background:var(--bg);color:var(--txt);font:15px/1.5 'Segoe UI',system-ui,sans-serif;padding:24px}+h1{font-size:22px;margin-bottom:2px} h1 b{color:var(--teal)}+.sub{color:var(--dim);font-size:13px;margin-bottom:18px}+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(270px,1fr));gap:12px;margin-bottom:20px}+.card{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:14px}+.led{display:flex;align-items:center;gap:10px}+.dot{width:12px;height:12px;border-radius:50%;flex:none;box-shadow:0 0 8px currentColor}+.ok{background:var(--ok);color:var(--ok)} .warn{background:var(--warn);color:var(--warn)}+.err{background:var(--err);color:var(--err)} .off{background:var(--off);color:var(--off);box-shadow:none}+.led .name{font-weight:600;font-size:14px} .led .detail{color:var(--dim);font-size:12.5px}+h2{font-size:15px;margin:22px 0 10px;color:var(--teal)}+select,button{background:var(--card);color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:7px 12px;font-size:14px;cursor:pointer}+.toggle{display:flex;align-items:center;gap:10px;padding:10px 0}+.prompt{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:12px;margin-bottom:10px}+.prompt .t{font-weight:600;margin-bottom:6px}+.prompt pre{white-space:pre-wrap;color:var(--dim);font:12.5px/1.5 Consolas,monospace;margin-bottom:8px}+.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}+.win{color:var(--dim);font-size:12.5px}+#toast{position:fixed;bottom:20px;right:20px;background:var(--card);border:1px solid var(--teal);border-radius:10px;padding:10px 16px;display:none}+</style></head><body>+<h1><b>KiCad</b> - Launch Page</h1>+<div class="sub">Your real KiCad, driven by AI entirely in the background · <span id="gen"></span></div>+<div class="row" style="margin-bottom:14px">+ <label>Box: <select id="target"></select></label>+ <button onclick="refresh(true)">Refresh</button>+</div>+<div class="grid" id="leds"></div>+<div class="card"><h2 style="margin-top:0">Settings</h2>+ <div class="toggle">+ <input type="checkbox" id="overlay" onchange="setSetting()">+ <div><b>Adom overlay badges</b><div class="win">Stamp the Adom favicon on every bridge-driven KiCad taskbar button, so you can see which windows Adom is controlling.</div></div>+ </div>+</div>+<h2>Open KiCad windows</h2><div class="win" id="wins">-</div>+<h2>Copy-paste prompts for your AI</h2><div id="prompts"></div>+<div id="toast"></div>+<script>+const NAMES={relay:'Relay',box:'Box',bridge:'Bridge',kicad:'KiCad',plugin:'Plugin',update:'Updates',focus:'Focus contract',presence:'User presence'};+let cur=null;+async function refresh(force){+ const t=document.getElementById('target').value||'';+ const r=await fetch('/api/status?target='+encodeURIComponent(t)+(force?'&force=1':''));+ const d=await r.json(); cur=d;+ document.getElementById('gen').textContent='updated '+d.generatedAt;+ const sel=document.getElementById('target');+ if(sel.options.length===0&&d.targets){d.targets.forEach(x=>{const o=document.createElement('option');o.value=o.textContent=x;if(x===d.target)o.selected=true;sel.appendChild(o);});}+ const g=document.getElementById('leds');g.innerHTML='';+ Object.entries(d.leds||{}).forEach(([k,v])=>{+ g.insertAdjacentHTML('beforeend',`<div class="card led"><div class="dot ${v.state}"></div><div><div class="name">${NAMES[k]||k}</div><div class="detail">${v.detail}</div></div></div>`);+ });+ document.getElementById('overlay').checked=!!(d.settings||{}).overlayBadges;+ document.getElementById('wins').textContent=(d.windows&&d.windows.length)?d.windows.join(' · '):'none';+}+async function setSetting(){+ const t=document.getElementById('target').value||'';+ const v=document.getElementById('overlay').checked;+ await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target:t,overlayBadges:v})});+ toast('Overlay badges '+(v?'ON':'OFF'));+}+function toast(m){const e=document.getElementById('toast');e.textContent=m;e.style.display='block';setTimeout(()=>e.style.display='none',2500);}+async function loadPrompts(){+ const r=await fetch('/api/prompts');const ps=await r.json();+ const el=document.getElementById('prompts');+ ps.forEach(([t,p])=>{el.insertAdjacentHTML('beforeend',`<div class="prompt"><div class="t">${t}</div><pre>${p}</pre><button onclick="navigator.clipboard.writeText(this.previousElementSibling.textContent).then(()=>toast('Copied'))">Copy</button></div>`);});+}+loadPrompts();refresh();setInterval(()=>refresh(),12000);+</script></body></html>"""+++class Handler(BaseHTTPRequestHandler):+ def log_message(self, *a):+ pass++ def _json(self, obj, code=200):+ body = json.dumps(obj).encode()+ self.send_response(code)+ self.send_header("Content-Type", "application/json")+ self.send_header("Content-Length", str(len(body)))+ self.end_headers()+ self.wfile.write(body)++ def do_GET(self):+ u = urlparse(self.path)+ if u.path == "/" or u.path == "/index.html":+ body = PAGE.encode()+ self.send_response(200)+ self.send_header("Content-Type", "text/html; charset=utf-8")+ self.send_header("Content-Length", str(len(body)))+ self.end_headers()+ self.wfile.write(body)+ elif u.path == "/health":+ self._json({"ok": True, "app": "kicad-dashboard"})+ elif u.path == "/api/prompts":+ self._json(PROMPTS)+ elif u.path == "/api/status":+ q = parse_qs(u.query)+ target = (q.get("target") or [""])[0] or DEFAULT_TARGET or (get_targets() or [None])[0]+ if q.get("force"):+ with _cache_lock:+ _cache["t"] = 0+ self._json(cached_status(target))+ else:+ self._json({"error": "not found"}, 404)++ def do_POST(self):+ u = urlparse(self.path)+ n = int(self.headers.get("Content-Length") or 0)+ try:+ args = json.loads(self.rfile.read(n) or b"{}")+ except Exception:+ args = {}+ if u.path == "/api/settings":+ target = args.pop("target", None) or DEFAULT_TARGET or (get_targets() or [None])[0]+ r = ab(target, "kicad_set_settings", args, timeout=60)+ with _cache_lock:+ _cache["t"] = 0+ self._json({"ok": bool(r.get("success")), "result": {k: r.get(k) for k in ("settings", "changed", "error")}})+ else:+ self._json({"error": "not found"}, 404)+++def main():+ global DEFAULT_TARGET, PORT+ argv = sys.argv[1:]+ cmd = argv[0] if argv else "serve"+ if "--target" in argv:+ DEFAULT_TARGET = argv[argv.index("--target") + 1]+ if "--port" in argv:+ PORT = int(argv[argv.index("--port") + 1])+ if cmd == "health":+ import urllib.request+ try:+ with urllib.request.urlopen(f"http://127.0.0.1:{PORT}/health", timeout=3) as r:+ print(r.read().decode())+ return+ except Exception as e:+ print(json.dumps({"ok": False, "error": str(e)}))+ sys.exit(1)+ if cmd == "status":+ print(json.dumps(build_status(DEFAULT_TARGET or (get_targets() or [None])[0]), indent=2))+ return+ # serve+ srv = ThreadingHTTPServer(("127.0.0.1", PORT), Handler)+ if "--print-url" in argv:+ print(f"http://127.0.0.1:{PORT}/")+ print(f"kicad-dashboard serving on :{PORT}", file=sys.stderr)+ srv.serve_forever()+++if __name__ == "__main__":+ main()