← 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

John Lauer ·662b2b7df3 ·1mo ago ·parent b3eb86f
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()