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
v0.9.159: every verb response now carries bridgeVersion (it only ever reached /health, so no caller could ask which bridge code is running); kicad-dashboard 2.0 reaches fusion-dashboard parity (SSE live channel, /api/action, ui toast+click drivability, LED _hints, targets picker, dock.json)
8 files changed
+885−222
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.158+0.9.159
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.158",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.158/adom-bridge-kicad-v0.9.158.zip",- "sha256": "645c75781bce119d683adfe6b0c011b7e056b07fb9ce01c77a46593fe344756e",- "size": 439000,+ "version": "0.9.159",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.159/adom-bridge-kicad-v0.9.159.zip",+ "sha256": "a5a88aa11d4f80ec9ddbe0f3e44ed6d84e19302b8948f18e22c3dfb226407b26",+ "size": 439770, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-18T00:40:14Z",+ "released_at": "2026-08-18T10:24:38Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.159.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.158",+ "version": "0.9.159", "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+495−214@@ -1,291 +1,572 @@ #!/usr/bin/env python3-"""kicad-dashboard - the KiCad bridge's Launch Page (fusion-dashboard's sibling).+"""kicad-dashboard - the KiCad bridge's Launch Page. -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.+Parity sibling of fusion-dashboard (adom/fusion-bridge dashboard/src/main.rs):+same LED-with-_hint model, same live channel, same AI-drivability contract, same+visual system. Status comes from shelling `adom-bridge`, so the BRIDGE stays the+single source of truth and this server owns no state the bridge could contradict. - kicad-dashboard serve [--port 8931] [--target <box>] [--print-url]- kicad-dashboard status [--target <box>] one-shot LED JSON+Serves:+ GET / the app (dashboard/ui/index.html, read per request+ so a ralph loop only needs a browser reload)+ GET /favicon.svg+ GET /events SSE live channel: hello / state / toast / click+ GET /api/targets every connected box, lightly probed+ GET /api/status[/<t>[/force]] the LED payload (also /state)+ GET /console the server's own log lines (what the AI reads back)+ GET /health liveness + sseClients (live viewer count)+ POST /api/action {action,target,args}+ POST /ui/toast {message,kind} narrate OVER the running app+ POST /ui/click {target} the PAGE clicks its own control++CLI:+ kicad-dashboard serve [--port 8931] [--target BOX] [--print-url]+ kicad-dashboard status [--target BOX]+ kicad-dashboard ui toast "msg" [--kind ok|err|info]+ kicad-dashboard ui click <setup|launch|close|demo|part-tour|settings|refresh>+ kicad-dashboard ui state | ui console+ kicad-dashboard open [--pup | --webview] [--target BOX] kicad-dashboard health++PROXY NOTE (paid for live): adom.cloud STRIPS query strings on sub-paths, so the+target rides the PATH (/api/status/<target>/force), never ?target=. The SSE route+also needs the padding + no-transform headers or the proxy buffers every byte. """ import json+import os+import queue+import re+import shutil import subprocess import sys+import tempfile import threading import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer-from urllib.parse import urlparse, parse_qs+from pathlib import Path+from urllib.parse import unquote, urlparse -PORT = 8931+VERSION = "2.0.0" AI_THREAD = "kicad-dashboard"-_cache = {"t": 0.0, "target": None, "data": None}-_cache_lock = threading.Lock()-DEFAULT_TARGET = None # first connected+DEFAULT_PORT = 8931+UI_DIR = Path(__file__).resolve().parent / "ui"++FAVICON = ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" '+ 'stroke="#00b8b1" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="3"/>'+ '<path d="M8 12h8M12 8v8"/></svg>') -def ab(target, verb, args, timeout=30):+# ---------------------------------------------------------------- bridge calls+def ab(target, verb, args=None, timeout=140):+ """One bridge verb. Returns the parsed payload dict (never raises).""" cmd = ["adom-bridge", "--ai-thread", AI_THREAD]- if target:+ if target and target != "-": cmd += ["--target", target]- cmd += [verb, json.dumps(args)]+ cmd += [verb, json.dumps(args or {})] try: p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)- return json.loads(p.stdout.strip() or p.stderr.strip())+ d = json.loads(p.stdout or "{}")+ # ab wraps some payloads in output-as-JSON-string; unwrap when it is one+ out = d.get("output")+ if isinstance(out, str) and out.strip().startswith("{"):+ try:+ inner = json.loads(out)+ merged = dict(d)+ merged.update(inner.get("data") if isinstance(inner.get("data"), dict) else inner)+ return merged+ except Exception:+ pass+ return d except Exception as e:- return {"error": str(e)[:120]}+ return {"error": str(e)} 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 []]+ p = subprocess.run(["adom-bridge", "targets"], capture_output=True, text=True, timeout=30)+ d = json.loads(p.stdout or "{}")+ return [t.get("name") for t in (d.get("targets") or []) if t.get("name")] except Exception: return [] +# ------------------------------------------------------------------- SSE hub+class Hub:+ def __init__(self):+ self._clients = []+ self._lock = threading.Lock()++ def add(self):+ q = queue.Queue(maxsize=64)+ with self._lock:+ self._clients.append(q)+ return q++ def drop(self, q):+ with self._lock:+ if q in self._clients:+ self._clients.remove(q)++ def count(self):+ with self._lock:+ return len(self._clients)++ def broadcast(self, event, data):+ msg = f"event: {event}\ndata: {json.dumps(data)}\n\n"+ with self._lock:+ for q in list(self._clients):+ try:+ q.put_nowait(msg)+ except queue.Full:+ pass+++HUB = Hub()+CONSOLE = []+_console_lock = threading.Lock()+++def log(line):+ with _console_lock:+ CONSOLE.append(f"{time.strftime('%H:%M:%S')} {line}")+ del CONSOLE[:-300]+ print(line, file=sys.stderr, flush=True)+++# ------------------------------------------------------------------ LED model+def _suite_led():+ """Last daily-suite verdict, plus container-cron liveness (a dead cron means+ the suite silently stops running - it died unnoticed for 4 days once)."""+ try:+ try:+ hb = time.time() - os.path.getmtime("/tmp/cron-heartbeat")+ if hb > 600:+ return {"label": "Daily suite", "state": "err",+ "detail": f"container cron DEAD ({int(hb // 60)} min since heartbeat)",+ "_hint": "The container's cron stopped, so the 6:30am verb suite will not run. "+ "Restart cron in the container and re-check."}+ except OSError:+ pass+ rp = "/home/adom/.kicad-verbtest-last-report.json"+ if not os.path.exists(rp):+ rp = "/tmp/kicad-verbtest/report.json"+ age_h = (time.time() - os.path.getmtime(rp)) / 3600+ t = (json.load(open(rp)).get("totals")) or {}+ det = f"{t.get('pass', '?')} pass / {t.get('fail', '?')} fail ({int(age_h)}h ago)"+ if age_h > 48:+ return {"label": "Daily suite", "state": "warn", "detail": f"stale: {det}",+ "_hint": "No suite run in over 48h. Check the cron entry and /tmp/kicad-verbtest/cron.log."}+ if t.get("fail"):+ return {"label": "Daily suite", "state": "warn", "detail": det,+ "_hint": "Failures are triaged on the wiki issues; the suite files a ticket only for NEW ones."}+ return {"label": "Daily suite", "state": "ok", "detail": det,+ "_hint": "Every verb passed on the last nightly run."}+ except Exception:+ return {"label": "Daily suite", "state": "off", "detail": "no suite report yet",+ "_hint": "The nightly suite writes ~/.kicad-verbtest-last-report.json; "+ "it runs at 6:30am when the box is idle."}++ def build_status(target):- """The LED model. Each facet: {state: ok|warn|err|off, detail}."""+ """The LED payload. Each facet: {label, state: ok|warn|err|off, detail, _hint}.""" targets = get_targets()+ if not target or target == "-":+ target = targets[0] if targets else "" 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"}+ leds["relay"] = {"label": "Relay", "state": "ok" if targets else "err",+ "detail": f"{len(targets)} box(es) connected" if targets else "no boxes on the relay",+ "_hint": "Boxes connect through adom-bridge. `adom-bridge targets` lists them; "+ "if empty, the desktop app is closed or offline."}+ leds["box"] = {"label": "Box", "state": "ok" if box_ok else "err",+ "detail": target if box_ok else f"'{target or 'none'}' not connected",+ "_hint": "Pick a connected box with the pills above; the bridge runs there, not in this container."} 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")}+ 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": "box offline",+ "_hint": "Unknowable while the box is off the relay - dormant, not broken."}+ leds["suite"] = _suite_led()+ return {"target": target, "targets": targets, "leds": leds, "settings": {},+ "windows": [], "dashboardVersion": VERSION,+ "generatedAt": time.strftime("%H:%M:%S"),+ "_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", {}, timeout=140)+ r = ab(target, "kicad_readiness", {}) 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",+ cu = ab(target, "kicad_check_for_updates", {})+ 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],+ "_hint": "The kicad_* verb surface is served by the bridge on the box. If red: "+ "bridge_kill {name:'kicad',confirm:true} respawns it from cache."}+ leds["kicad"] = {"label": "KiCad", "state": "ok" if installed else "err",+ "detail": f"KiCad {ver}" if installed else "not installed",+ "_hint": "KiCad is installed and driveable." if installed+ else "kicad_upgrade {} installs it silently per-user (no UAC prompt)."}+ if cu.get("upgradeAvailable"):+ leds["update"] = {"label": "Updates", "state": "warn",+ "detail": f"{cu.get('currentVersion')} -> {cu.get('latestVersion')} available",+ "_hint": "kicad_close then kicad_upgrade {} installs the official release silently. "+ "Verify with a gerber export version stamp."}+ else:+ leds["update"] = {"label": "Updates", "state": "ok",+ "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", {})+ alive = bs.get("alive") or 0+ leds["plugin"] = ({"label": "Plugin", "state": "ok", "detail": f"{alive} live instance(s)",+ "_hint": "The in-process plugin is bound, so background part navigation is available."}+ if alive else+ {"label": "Plugin", "state": "warn", "detail": "no live plugin",+ "_hint": "Normal while KiCad is closed - it binds ~10s after a launch. "+ "kicad_install_plugin forces a fresh deploy if it never binds."})++ 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", "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 not steals else f"{len(steals)} self-raise(s) bounced recently"),+ "_hint": "Bridge-owned KiCad windows always open in the background; self-raises are "+ "bounced and logged. Your own KiCad launches are never touched."}+ idle = ks.get("userIdleSeconds") if idle is None:- leds["presence"] = {"state": "off", "detail": "unknown"}+ leds["presence"] = {"label": "User presence", "state": "off", "detail": "unknown",+ "_hint": "Presence needs a live bridge; it gates window automation."} elif idle < 600:- leds["presence"] = {"state": "warn",- "detail": f"USER ACTIVE ({int(idle)}s idle) - window automation deferred"}+ leds["presence"] = {"label": "User presence", "state": "warn",+ "detail": f"USER ACTIVE ({int(idle)}s idle)",+ "_hint": "Window-spawning automation defers while a human is at the box. "+ "Headless kicad-cli work (exports, DRC/ERC) is exempt."} else:- leds["presence"] = {"state": "ok", "detail": f"idle {int(idle // 60)} min - automation clear"}+ leds["presence"] = {"label": "User presence", "state": "ok",+ "detail": f"idle {int(idle // 60)} min",+ "_hint": "Idle long enough that the daily suite and window automation may run."}+ leds["suite"] = _suite_led() 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")}+ "windows": [w.get("title") for w in (ks.get("windows") or [])],+ "bridgeVersion": ks.get("bridgeVersion") or r.get("bridgeVersion"),+ "dashboardVersion": VERSION, "generatedAt": time.strftime("%H:%M:%S"),+ "_hint": "Every LED carries its own _hint naming the verb that fixes it. "+ "POST /api/action {action,args} to act; GET /api/status to re-read; "+ "subscribe to /events (SSE) for live pushes; POST /ui/toast to narrate on the app."}+ +_cache = {"t": 0.0, "target": None, "data": None}+_cache_lock = threading.Lock() -def cached_status(target):++def cached_status(target, force=False): with _cache_lock:- if _cache["data"] and _cache["target"] == target and time.time() - _cache["t"] < 8:+ if (not force and _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)+ HUB.broadcast("state", 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;-*{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/'+encodeURIComponent(t||'-')+(force?'/force':''));- 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>"""+def probe_targets():+ rows = []+ for name in get_targets():+ st = ab(name, "kicad_state", {"includeImages": False}, timeout=60)+ 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")})+ 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."}+++# -------------------------------------------------------------------- actions+def part_tour(target, args):+ """Clone a wiki part page, push its CAD files to the box, install symbol ++ footprint + 3D model, then generate a single-component project."""+ page = (args or {}).get("page") or "aravk/in-s42ata"+ steps = []+ tmp = tempfile.mkdtemp(prefix="kdash-part-")+ try:+ p = subprocess.run(["adom-wiki", "repo", "clone", page, "--dir", tmp],+ capture_output=True, text=True, timeout=180)+ if p.returncode != 0:+ return {"success": False, "error": f"clone failed: {p.stderr[:200]}",+ "_hint": "Give an owner/slug page reference that exists, e.g. aravk/in-s42ata."}+ found = {".kicad_sym": None, ".kicad_mod": None, ".step": None}+ for root, _dirs, names in os.walk(tmp):+ for n in names:+ for ext in found:+ if n.lower().endswith(ext) and not found[ext]:+ found[ext] = os.path.join(root, n)+ if not found[".kicad_sym"] and not found[".kicad_mod"]:+ return {"success": False, "error": "page ships no .kicad_sym or .kicad_mod",+ "_hint": "This tour needs a part page carrying KiCad CAD files."}+ steps.append("cloned " + page)++ send = [f for f in found.values() if f]+ sent = ab(target, "send_files", {"filePaths": send}, timeout=180)+ dest = sent.get("destinationPaths") or []+ if not dest:+ return {"success": False, "steps": steps,+ "error": f"send_files failed: {str(sent.get('error'))[:150]}"}+ by_ext = {os.path.splitext(d)[1].lower(): d for d in dest}+ steps.append(f"sent {len(dest)} file(s) to the box")++ part = None+ if by_ext.get(".kicad_sym"):+ r = ab(target, "kicad_install_symbol", {"filePath": by_ext[".kicad_sym"]}, timeout=180)+ part = r.get("symbolName") or r.get("partName")+ steps.append("installed symbol" + (f" ({part})" if part else ""))+ if by_ext.get(".kicad_mod"):+ fargs = {"filePath": by_ext[".kicad_mod"]}+ if by_ext.get(".step"):+ fargs["model"] = {"filePath": by_ext[".step"]}+ ab(target, "kicad_install_footprint", fargs, timeout=180)+ steps.append("installed footprint" + (" + 3D model" if by_ext.get(".step") else ""))+ if not part:+ part = os.path.basename(page)+ proj = ab(target, "kicad_make_part_project", {"partName": part}, timeout=180)+ if proj.get("success") is False:+ return {"success": False, "steps": steps,+ "error": f"project generation failed: {str(proj.get('error'))[:150]}"}+ steps.append("generated project " + str(proj.get("projectDir") or part))+ return {"success": True, "steps": steps, "part": part, "project": proj.get("project"),+ "_hint": "Open it with kicad_open_project, or show any of the eight surfaces "+ "with the kicad_show_* verbs - all in the background."}+ finally:+ shutil.rmtree(tmp, ignore_errors=True)+++def do_action(target, name, args):+ args = args or {}+ if name == "launch":+ return ab(target, "kicad_launch", {})+ if name == "close":+ return ab(target, "kicad_close", {"force": True})+ 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)+ if name == "install_plugin":+ return ab(target, "kicad_install_plugin", {})+ if name == "set_overlay_badges":+ return ab(target, "kicad_set_settings",+ {"overlayBadges": bool(args.get("overlayBadges", True))}, timeout=60)+ if name == "part_tour":+ return part_tour(target, args)+ return {"error": f"unknown action {name}",+ "_hint": "Actions: launch, close, demo, demo_progress, upgrade, install_plugin, "+ "set_overlay_badges, part_tour."} +# --------------------------------------------------------------------- server class Handler(BaseHTTPRequestHandler):+ protocol_version = "HTTP/1.1"+ server_version = "kicad-dashboard/" + VERSION+ def log_message(self, *a): pass - def _json(self, obj, code=200):- body = json.dumps(obj).encode()+ def _send(self, code, ctype, body, extra=None):+ if isinstance(body, str):+ body = body.encode("utf-8") self.send_response(code)- self.send_header("Content-Type", "application/json")+ self.send_header("Content-Type", ctype) self.send_header("Content-Length", str(len(body)))+ self.send_header("Access-Control-Allow-Origin", "*")+ for k, v in (extra or {}).items():+ self.send_header(k, v) self.end_headers() self.wfile.write(body) + def _json(self, obj, code=200):+ self._send(code, "application/json", json.dumps(obj))++ def _read_body(self):+ try:+ n = int(self.headers.get("Content-Length") or 0)+ return json.loads(self.rfile.read(n) or b"{}") if n else {}+ except Exception:+ return {}+ def do_GET(self):- u = urlparse(self.path)- if not u.path.startswith("/api") and u.path != "/health":- 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.startswith("/api/status"):- # params ride the PATH (/api/status/<target>[/force]) — the- # adom.cloud proxy 404s querystring requests on subpaths.- parts = [p for p in u.path.split("/") if p][2:]- q = parse_qs(u.query)- _p0 = (parts[0] if parts else "")- if _p0 == "-":- _p0 = ""- target = (_p0 or (q.get("target") or [""])[0]- or DEFAULT_TARGET or (get_targets() or [None])[0])- if "force" in parts or q.get("force"):- with _cache_lock:- _cache["t"] = 0- self._json(cached_status(target))- else:- self._json({"error": "not found"}, 404)+ path = urlparse(self.path).path.rstrip("/") or "/"+ if path in ("/favicon.svg", "/favicon.ico"):+ return self._send(200, "image/svg+xml", FAVICON)+ if path == "/health":+ return self._json({"ok": True, "app": "kicad-dashboard", "version": VERSION,+ "sseClients": HUB.count()})+ if path == "/console":+ with _console_lock:+ return self._json({"lines": list(CONSOLE)})+ if path == "/events":+ return self._sse()+ if path == "/api/targets":+ return self._json(probe_targets())+ m = re.match(r"^/(?:api/status|state)(?:/([^/]+))?(/force)?$", path)+ if m:+ target = unquote(m.group(1) or "-")+ force = bool(m.group(2))+ log(f"status target={target}{' force' if force else ''}")+ return self._json(cached_status(target, force))+ try: # any other GET renders the app (proxy-friendly: sub-paths still work)+ return self._send(200, "text/html; charset=utf-8",+ (UI_DIR / "index.html").read_text(encoding="utf-8"))+ except Exception as e:+ return self._json({"error": f"ui missing: {e}"}, 500) def do_POST(self):- u = urlparse(self.path)- n = int(self.headers.get("Content-Length") or 0)+ path = urlparse(self.path).path.rstrip("/") or "/"+ body = self._read_body()+ if path == "/api/action":+ name = body.get("action") or ""+ target = body.get("target") or (get_targets() or ["-"])[0]+ log(f"action {name} target={target}")+ return self._json(do_action(target, name, body.get("args")))+ if path == "/ui/toast":+ msg, kind = body.get("message") or "", body.get("kind") or "info"+ log(f"toast [{kind}] {msg}")+ HUB.broadcast("toast", {"message": msg, "kind": kind})+ return self._json({"ok": True, "delivered": HUB.count(),+ "_hint": "delivered = live SSE viewers that showed it. "+ "0 means nobody has the page open - open it first."})+ if path == "/ui/click":+ t = body.get("target") or ""+ log(f"ui click {t}")+ HUB.broadcast("click", {"target": t})+ return self._json({"ok": True, "delivered": HUB.count(),+ "_hint": "Targets: setup, launch, close, demo, part-tour, settings, refresh. "+ "The PAGE performs the click, so what the AI drives is what the user gets."})+ return self._json({"error": "not found",+ "_hint": "POST /api/action {action,args}, /ui/toast {message,kind}, /ui/click {target}"}, 404)++ def _sse(self):+ """Headers immediately, then one flush PER EVENT. The 2KB comment padding+ plus no-transform / X-Accel-Buffering defeat proxy buffering (the adom.cloud+ proxy otherwise holds every byte until the stream ends)."""+ self.send_response(200)+ self.send_header("Content-Type", "text/event-stream")+ self.send_header("Cache-Control", "no-cache, no-transform")+ self.send_header("X-Accel-Buffering", "no")+ self.send_header("Access-Control-Allow-Origin", "*")+ self.send_header("Connection", "keep-alive")+ self.end_headers()+ q = HUB.add()+ log(f"sse client connected ({HUB.count()} live)") try:- args = json.loads(self.rfile.read(n) or b"{}")+ self.wfile.write((": " + "p" * 2048 + "\n\n").encode())+ self.wfile.write(f"event: hello\ndata: {json.dumps({'version': VERSION})}\n\n".encode())+ self.wfile.flush()+ while True:+ try:+ msg = q.get(timeout=15)+ except queue.Empty:+ msg = ": ping\n\n"+ self.wfile.write(msg.encode())+ self.wfile.flush() 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)+ pass+ finally:+ HUB.drop(q)+ log(f"sse client disconnected ({HUB.count()} live)")+++# ------------------------------------------------------------------------ CLI+def proxy_url(port):+ tpl = os.environ.get("VSCODE_PROXY_URI") or ""+ if "{{port}}" in tpl:+ return tpl.replace("{{port}}", str(port))+ return f"http://127.0.0.1:{port}/"+++def local_call(port, path, payload=None):+ import urllib.request+ url = f"http://127.0.0.1:{port}{path}"+ data = json.dumps(payload).encode() if payload is not None else None+ req = urllib.request.Request(url, data=data, method="POST" if data else "GET",+ headers={"Content-Type": "application/json"})+ try:+ with urllib.request.urlopen(req, timeout=20) as r:+ return json.loads(r.read().decode())+ except Exception as e:+ return {"error": str(e),+ "_hint": f"Is the dashboard serving? `kicad-dashboard serve --port {port}` (health: /health)."} 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+ argv = sys.argv[1:] or ["serve"]+ cmd = argv[0]++ def flag(name, default=None):+ return argv[argv.index(name) + 1] if name in argv and argv.index(name) + 1 < len(argv) else default++ port = int(flag("--port", DEFAULT_PORT))+ target = flag("--target", "")++ if cmd == "serve":+ if "--print-url" in argv:+ print(proxy_url(port), flush=True)+ srv = ThreadingHTTPServer(("0.0.0.0", port), Handler)+ srv.daemon_threads = True+ log(f"kicad-dashboard {VERSION} serving on {port} (target={target or 'auto'})") 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)+ srv.serve_forever()+ except KeyboardInterrupt:+ pass+ return+ if cmd == "status":- print(json.dumps(build_status(DEFAULT_TARGET or (get_targets() or [None])[0]), indent=2))+ print(json.dumps(build_status(target or "-"), 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 cmd == "health":+ print(json.dumps(local_call(port, "/health")))+ return++ if cmd == "ui":+ sub = argv[1] if len(argv) > 1 else ""+ if sub == "toast":+ print(json.dumps(local_call(port, "/ui/toast",+ {"message": argv[2] if len(argv) > 2 else "",+ "kind": flag("--kind", "info")})))+ elif sub == "click":+ print(json.dumps(local_call(port, "/ui/click",+ {"target": argv[2] if len(argv) > 2 else ""})))+ elif sub == "state":+ print(json.dumps(local_call(port, "/state"), indent=2))+ elif sub == "console":+ print(json.dumps(local_call(port, "/console"), indent=2))+ else:+ print(json.dumps({"error": "ui <toast|click|state|console>"}))+ return++ if cmd == "open":+ url = proxy_url(port)+ box = target or (get_targets() or [""])[0]+ if "--webview" in argv:+ print(json.dumps({"url": url, "surface": "webview",+ "_hint": "Open this URL in a Hydrogen webview panel (the `webview` skill); "+ "panel placement stays the AI's decision."}))+ return+ r = ab(box, "pup_open_window", {"sessionId": "kicad-dashboard", "url": url}, timeout=120)+ ab(box, "pup_alert_window", {"sessionId": "kicad-dashboard"}, timeout=60)+ print(json.dumps({"url": url, "surface": "pup", "target": box, "opened": r.get("success"),+ "_hint": "Opened in a BACKGROUND pup window and flashed its taskbar button. "+ "Never raise it yourself - let the user click it."}))+ return++ print(json.dumps({"error": f"unknown command {cmd}",+ "_hint": "serve | status | ui <toast|click|state|console> | open | health"})) if __name__ == "__main__":
dashboard/ui/index.htmladded+352@@ -0,0 +1,352 @@+<!doctype html>+<html lang="en">+<head>+<meta charset="utf-8">+<meta name="viewport" content="width=device-width, initial-scale=1">+<title>KiCad Dashboard</title>+<link rel="icon" type="image/svg+xml" href="favicon.svg">+<style>+:root{+ --bg:#0d1117;--surface:#161b22;--elevated:#1c2128;--overlay:#21262d;--border:#30363d;+ --text:#e6edf3;--text-body:#c9d1d9;--text-2:#8b949e;+ --accent:#00b8b1;--accent-hover:#00d4cb;--green:#3fb950;--red:#f85149;--yellow:#d29922;--blue:#64abff;+}+*{box-sizing:border-box;margin:0;padding:0}+@font-face{font-family:'Familjen Grotesk';src:url('https://adom.inc/fonts/FamiljenGrotesk-SemiBold.woff2') format('woff2');font-weight:600}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/Satoshi-Regular.woff2') format('woff2');font-weight:400}+@font-face{font-family:'Satoshi';src:url('https://adom.inc/fonts/Satoshi-Medium.woff2') format('woff2');font-weight:500}+body{background:var(--bg);color:var(--text-body);font:15px/1.5 'Satoshi',sans-serif;min-height:100vh}+header{height:44px;display:flex;align-items:center;gap:12px;padding:0 16px;background:var(--surface);border-bottom:1px solid var(--border)}+.brand{display:flex;align-items:center;gap:8px;font-family:'Familjen Grotesk',sans-serif;font-weight:600;color:var(--accent);white-space:nowrap;cursor:default}+.brand svg{width:16px;height:16px;stroke:var(--text)}+.vsep{width:1px;height:20px;background:var(--border)}+.subject{font-weight:600;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}+.meta{font:12px 'JetBrains Mono',monospace;color:var(--text-2)}+.actions{margin-left:auto;display:flex;gap:8px}+.iconbtn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;background:none;border:1px solid transparent;border-radius:8px;color:var(--text-body);cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}+.iconbtn:hover{background:var(--elevated);border-color:var(--border)}+.iconbtn:active{transform:scale(.9);background:var(--overlay)}+main{max-width:1080px;margin:0 auto;padding:20px 16px 60px}+h2{font-family:'Familjen Grotesk',sans-serif;font-size:17px;color:var(--text);margin:26px 0 12px}+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:12px}+.card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:14px}+.led-row{display:flex;align-items:center;gap:10px}+.led{width:12px;height:12px;border-radius:999px;background:var(--text-2);flex:none;transition:background .15s ease}+.led.on{background:var(--green);box-shadow:0 0 8px rgba(63,185,80,.5)}+.led.off{background:var(--red)}+.led.warn{background:var(--yellow)}+.led.dim{background:var(--text-2)}+.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}+.btn{display:inline-flex;align-items:center;gap:8px;min-height:36px;padding:0 14px;border-radius:8px;border:1px solid var(--border);background:var(--elevated);color:var(--text);font:500 14px 'Satoshi',sans-serif;cursor:pointer;transition:background .15s ease,border-color .15s ease,transform .06s ease}+.btn:hover{background:var(--overlay);border-color:#4b5563}+.btn:active{transform:translateY(1px) scale(.98);background:var(--surface)}+.btn.primary{background:var(--accent);border-color:var(--accent);color:#05221f}+.btn.primary:hover{background:var(--accent-hover)}+.btn.primary:active{background:#009d97;border-color:#009d97;transform:translateY(1px) scale(.98)}+.btn:disabled{opacity:.5;cursor:not-allowed}+.btn:focus-visible,.iconbtn:focus-visible,.tpill:focus-visible,.toggle:focus-visible{outline:2px solid var(--accent);outline-offset:2px}+.btnrow{display:flex;gap:10px;flex-wrap:wrap;margin-top:12px}+.prompt{display:flex;align-items:center;gap:10px;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;transition:border-color .15s ease}+.prompt:hover{border-color:#4b5563}+input:focus-visible{outline:none;border-color:var(--accent) !important}+.prompt code{font:13px 'JetBrains Mono',monospace;color:var(--text-body);flex:1;white-space:normal}+.copy{flex:none}+.bar{height:8px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;overflow:hidden;margin-top:10px;display:none}+.bar>div{height:100%;width:0;background:var(--accent);transition:width .5s ease}+.barlabel{font-size:12.5px;color:var(--text-2);margin-top:6px}+.shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:12px}+.shot{background:var(--elevated);border:1px solid var(--border);border-radius:8px;overflow:hidden}+.shot img{width:100%;display:block}+.shot .cap{font-size:11.5px;color:var(--text-2);padding:6px 8px}+#toasts{position:fixed;left:50%;bottom:18px;transform:translateX(-50%);display:flex;flex-direction:column-reverse;gap:8px;z-index:99999}+.toast{background:var(--overlay);border:1px solid var(--border);border-left:3px solid var(--accent);border-radius:8px;padding:10px 14px;color:var(--text);font-size:14px;box-shadow:0 8px 24px rgba(0,0,0,.4)}+.toast.ok{border-left-color:var(--green)}.toast.err{border-left-color:var(--red)}+dialog{margin:auto;background:var(--overlay);color:var(--text-body);border:1px solid var(--border);border-radius:12px;padding:20px;max-width:520px;width:92vw}+dialog::backdrop{background:rgba(0,0,0,.55)}+.setrow{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:12px 0;border-bottom:1px solid var(--border)}+.toggle{width:42px;height:24px;border-radius:999px;background:var(--elevated);border:1px solid var(--border);position:relative;cursor:pointer;flex:none;transition:border-color .15s ease}+.toggle:hover{border-color:var(--accent)}+.toggle:active::after{width:22px}+.toggle.is-on:active::after{left:17px}+.toggle::after{content:'';position:absolute;top:2px;left:2px;width:18px;height:18px;border-radius:999px;background:var(--text-2);transition:all .15s ease}+.toggle.is-on{background:var(--accent);border-color:var(--accent)}+.toggle.is-on::after{left:21px;background:#05221f}+@media (max-width:720px){.meta{display:none}.grid{grid-template-columns:1fr 1fr}}+@media (max-width:480px){.grid{grid-template-columns:1fr}.brand span{display:none}.subject{display:none}.vsep{display:none}header{gap:8px}}+#tt{position:fixed;z-index:99999;background:var(--overlay);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-size:12.5px;color:var(--text-body);max-width:260px;pointer-events:none;opacity:0;transform:translateY(3px);transition:opacity .18s ease,transform .18s ease}+#tt.show{opacity:1;transform:none}+#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)}+#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)}+.tpill:active{transform:translateY(1px) scale(.97)}+.tpill.active{border-color:var(--accent);color:var(--text);background:var(--elevated)}+.tdot{width:8px;height:8px;border-radius:999px;background:var(--text-2)}+.tdot.on{background:var(--green)}+.note{font-size:12.5px;color:var(--text-2);margin:-6px 0 12px}+.wins{font:12.5px 'JetBrains Mono',monospace;color:var(--text-2)}+</style>+</head>+<body>+<header>+ <div class="brand">+ <svg viewBox="0 0 24 24" fill="none" stroke-width="1.8"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8 12h8M12 8v8"/></svg>+ KiCad Dashboard <span style="color:var(--text-2)">· bridge launch page</span>+ </div>+ <div class="vsep"></div>+ <div class="subject" id="subject">connecting...</div>+ <div class="meta" id="meta"></div>+ <div class="actions">+ <button class="iconbtn" id="btnSettings" data-tip="Settings: bridge defaults like the Adom overlay badge on KiCad taskbar buttons (sticky per box)" aria-label="Settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 1.55V21a2 2 0 1 1-4 0v-.09A1.7 1.7 0 0 0 9 19.4a1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-1.55-1H3a2 2 0 1 1 0-4h.09A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-1.55V3a2 2 0 1 1 4 0v.09a1.7 1.7 0 0 0 1 1.51 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.26.6.86 1 1.51 1H21a2 2 0 1 1 0 4h-.09c-.65 0-1.25.4-1.51 1z"/></svg></button>+ <button class="iconbtn" id="btnRefresh" data-tip="Re-read every facet's status from the bridge right now (also auto-refreshes every 15s)" aria-label="Refresh"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6"/></svg></button>+ </div>+</header>+<main>+ <h2>Status</h2>+ <div id="targets"></div>+ <div class="note" id="boxnote" style="display:none">Many boxes, one bridge: pick where KiCad should run. The bridge only ever drives the KiCad instances IT launched, so your own KiCad is never touched.</div>+ <div class="grid" id="ledGrid"></div>+ <div class="btnrow">+ <button class="btn primary" id="btnSetup">Setup</button>+ <button class="btn" id="btnLaunch">Launch KiCad</button>+ <button class="btn" id="btnClose">Close KiCad</button>+ </div>+ <div class="wins" id="wins" style="margin-top:12px"></div>++ <h2>Demos</h2>+ <div class="card">+ <div class="led-label">Guided demo</div>+ <div class="led-sub">Six beats on a generated sample project: schematic, 2D board, 3D board, symbol, footprint, 3D chip. Runs entirely in the background, and each finished beat streams back a screenshot.</div>+ <div class="btnrow"><button class="btn primary" id="btnDemo">Run the demo</button></div>+ <div class="bar" id="demoBar"><div></div></div>+ <div class="barlabel" id="demoLabel"></div>+ <div class="shots" id="demoShots"></div>+ </div>+ <div class="card" style="margin-top:12px">+ <div class="led-label">Tour a wiki part end to end</div>+ <div class="led-sub">Pulls a part page from wiki.adom.inc, installs its symbol, footprint and 3D model into KiCad, then generates a single-component project you can open.</div>+ <div class="btnrow">+ <input id="partPage" value="aravk/in-s42ata" data-tip="Any wiki part page that ships .kicad_sym / .kicad_mod / .step files" style="flex:1;min-width:180px;background:var(--elevated);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:8px 10px;font:13px 'JetBrains Mono',monospace">+ <button class="btn" id="btnPartTour">Tour this part</button>+ </div>+ <div class="barlabel" id="partLabel"></div>+ </div>++ <h2>Copy-paste prompts for your AI</h2>+ <div id="prompts"></div>+</main>++<dialog id="dlgSettings">+ <h2 style="margin:0 0 8px">Settings</h2>+ <div class="setrow">+ <div>+ <div class="led-label">Adom overlay badges</div>+ <div class="led-sub">Stamp the Adom favicon on every bridge-driven KiCad taskbar button, so you can see at a glance which windows Adom is controlling. Never applied to KiCad windows you launched yourself.</div>+ </div>+ <div class="toggle" id="tglOverlay" role="switch" aria-checked="true" tabindex="0"></div>+ </div>+ <div class="btnrow" style="justify-content:flex-end"><button class="btn" id="btnCloseSettings">Done</button></div>+</dialog>++<dialog id="dlgSetup">+ <h2 style="margin:0 0 8px">Setup</h2>+ <div id="setupBody" class="led-sub" style="font-size:14px"></div>+ <div class="btnrow" style="justify-content:flex-end">+ <button class="btn primary" id="btnSetupNext">Run the next step</button>+ <button class="btn" id="btnCloseSetup">Close</button>+ </div>+</dialog>++<div id="toasts"></div>+<div id="tt" role="tooltip"></div>+<div id="appinfo"></div>+<script>+const $=s=>document.querySelector(s);+const consoleBuf=[];+["log","warn","error"].forEach(k=>{const o=console[k].bind(console);console[k]=(...a)=>{consoleBuf.push(k+": "+a.join(" "));if(consoleBuf.length>300)consoleBuf.shift();o(...a);};});+window.onerror=(m)=>{consoleBuf.push("onerror: "+m);};+function toast(msg,kind){const t=document.createElement('div');t.className='toast '+(kind||'');t.textContent=msg;$('#toasts').appendChild(t);setTimeout(()=>t.remove(),kind==='err'?9000:4000);}+async function api(path,opts){const r=await fetch(path,opts);return r.json();}+async function act(action,args){return api('api/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action,target:curTarget||undefined,args:args||{}})});}++let lastStatus=null;+let curTarget=localStorage.getItem('kdTarget')||'';+let targetsCache=[];+// The adom.cloud proxy STRIPS query strings on sub-paths, so target rides the PATH.+function statusUrl(force){return 'api/status/'+encodeURIComponent(curTarget||'-')+(force?'/force':'');}+async function loadTargets(){+ try{+ const d=await api('api/targets');+ 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);}+ 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):''));+ 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);+ }+ }catch(e){}+}+function renderLeds(st){+ const g=$('#ledGrid');g.innerHTML='';+ const leds=st.leds||{};+ const ORDER=['relay','box','bridge','kicad','update','plugin','focus','presence','suite'];+ 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';+ 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);+ }+ $('#subject').textContent=st.target?('target: '+st.target):'no bridge target';+ $('#meta').textContent='bridge '+(st.bridgeVersion||'?')+' | dashboard '+(st.dashboardVersion||'');+ 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');}}+$('#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);};++// Guided demo with a REAL progress bar + the stepShot stream (one screenshot per finished beat).+$('#btnDemo').onclick=async()=>{+ const b=$('#btnDemo');b.disabled=true;+ toast('starting the guided demo (background)');+ $('#demoShots').innerHTML='';+ const s=await act('demo');const d=s.data||s;+ if(d.success===false){toast('demo failed to start: '+(d.error||'unknown'),'err');b.disabled=false;return;}+ $('#demoBar').style.display='block';+ const poll=setInterval(async()=>{+ const p=await act('demo_progress');const q=p.data||p;+ $('#demoBar>div').style.width=(q.percent||0)+'%';+ $('#demoLabel').textContent=(q.percent||0)+'% '+(q.stepLabel||'');+ if(q.stepShot&&q.stepShot.image){+ const c=document.createElement('div');c.className='shot';+ c.innerHTML='<img src="data:image/png;base64,'+q.stepShot.image+'" alt="'+(q.stepShot.title||'')+'"><div class="cap">'+(q.stepShot.title||q.stepShot.step||'')+'</div>';+ $('#demoShots').appendChild(c);+ }+ if(q.done){clearInterval(poll);b.disabled=false;toast(q.error?('demo failed: '+q.error):'demo complete','' + (q.error?'err':'ok'));refresh(true);}+ },3000);+};+$('#btnPartTour').onclick=async()=>{+ const page=$('#partPage').value.trim();+ const b=$('#btnPartTour');b.disabled=true;+ toast('touring '+page+' (clone, install, generate project)');+ $('#partLabel').textContent='working...';+ const r=await act('part_tour',{page});const d=r.data||r;+ b.disabled=false;+ if(d.success===false){toast('part tour failed: '+(d.error||'unknown'),'err');$('#partLabel').textContent=d.error||'failed';}+ else{toast('part installed and project generated','ok');$('#partLabel').textContent=(d.steps||[]).join(' · ');refresh(true);}+};++// Settings+$('#btnSettings').onclick=()=>{const s=(lastStatus&&lastStatus.settings)||{};+ const t=$('#tglOverlay');const on=s.overlayBadges!==false;t.classList.toggle('is-on',on);t.setAttribute('aria-checked',String(on));+ $('#dlgSettings').showModal();};+$('#btnCloseSettings').onclick=()=>$('#dlgSettings').close();+$('#tglOverlay').onclick=async(e)=>{+ const t=e.currentTarget;const now=!t.classList.contains('is-on');+ t.classList.toggle('is-on',now);t.setAttribute('aria-checked',String(now));+ await act('set_overlay_badges',{overlayBadges:now});+ toast('overlay badges '+(now?'on':'off')+' (sticky)','ok');+};++// Setup: walk the not-green facets in order, one action per click.+const SETUP_ORDER=[+ {led:'kicad',action:'upgrade',label:'Install KiCad (silent, per-user, no UAC)'},+ {led:'update',action:'upgrade',label:'Update KiCad to the latest release'},+ {led:'plugin',action:'launch',label:'Launch KiCad so the reverse-bridge plugin binds'},+];+function setupPending(){if(!lastStatus)return[];return SETUP_ORDER.filter(s=>{const l=lastStatus.leds[s.led];return l&&l.state!=='ok'&&l.state!=='off';});}+function renderSetup(){+ const p=setupPending();+ $('#setupBody').innerHTML=p.length?('Remaining steps:<br>'+p.map((s,i)=>`${i+1}. ${s.label}`).join('<br>')):'Everything is configured. You are ready to go.';+ $('#btnSetupNext').style.display=p.length?'':'none';+}+$('#btnSetup').onclick=async()=>{await refresh(true);renderSetup();$('#dlgSetup').showModal();};+$('#btnCloseSetup').onclick=()=>$('#dlgSetup').close();+$('#btnSetupNext').onclick=async()=>{+ const p=setupPending();if(!p.length)return;+ toast('running: '+p[0].label);+ await act(p[0].action);+ setTimeout(async()=>{await refresh(true);renderSetup();},5000);+};++// Copy-paste AI prompts (teach the user to drive the bridge through their AI).+const PROMPTS=[+ 'run the kicad demo and show me each step screenshot as it arrives',+ 'tour the wiki part aravk/in-s42ata in kicad: symbol, footprint, 3d chip, project, schematic, 2d board, 3d board',+ 'is kicad up to date on all my boxes? update whatever is behind',+ 'show me the symbol and footprint for the part I just installed',+ 'make a single-component project for this part and show me the 3d board',+ 'export gerbers for the open board and tell me the KiCad version stamp',+ 'are any kicad windows in my foreground? prove the bridge stayed in the background',+];+$('#prompts').innerHTML=PROMPTS.map(p=>`<div class="prompt"><code>${p}</code><button class="btn copy" onclick="navigator.clipboard.writeText('${p.replace(/'/g,"\\'")}');toast('copied','ok')">Copy</button></div>`).join('');++// Live channel (SSE): state pushes, AI toasts, CLI click directives.+const CLICK_TARGETS={setup:'#btnSetup',launch:'#btnLaunch',close:'#btnClose',demo:'#btnDemo','part-tour':'#btnPartTour',settings:'#btnSettings',refresh:'#btnRefresh'};+function connectSSE(){+ const es=new EventSource('events');+ es.addEventListener('hello',()=>{console.log('sse connected');});+ es.addEventListener('state',e=>{try{const st=JSON.parse(e.data);if(!curTarget||st.target===curTarget){lastStatus=st;renderLeds(st);}}catch(_){}});+ es.addEventListener('toast',e=>{try{const t=JSON.parse(e.data);toast(t.message,t.kind==='info'?'':t.kind);}catch(_){}});+ es.addEventListener('click',e=>{try{+ const {target}=JSON.parse(e.data);const sel=CLICK_TARGETS[target];+ if(sel&&$(sel)){$(sel).click();}else{toast('unknown click target: '+target,'err');}+ }catch(_){}});+ es.onerror=()=>{es.close();setTimeout(connectSSE,3000);};+}+connectSSE();++// Tooltips: body-appended, fixed, clamped to the viewport on BOTH axes.+const tt=$('#tt');let ttTimer=null;+document.addEventListener('mouseover',e=>{+ const el=e.target.closest('[data-tip]');+ if(!el){clearTimeout(ttTimer);tt.classList.remove('show');return;}+ clearTimeout(ttTimer);+ ttTimer=setTimeout(()=>{+ tt.textContent=el.getAttribute('data-tip');+ tt.style.left='0px';tt.style.top='0px';tt.classList.add('show');+ const r=el.getBoundingClientRect(),M=8,w=tt.offsetWidth,h=tt.offsetHeight;+ let x=r.left+r.width/2-w/2, y=r.bottom+10;+ if(y+h>innerHeight-M) y=r.top-h-10;+ x=Math.max(M,Math.min(x,innerWidth-w-M));+ y=Math.max(M,Math.min(y,innerHeight-h-M));+ tt.style.left=x+'px';tt.style.top=y+'px';+ },600);+});+document.addEventListener('mouseout',e=>{if(e.target.closest('[data-tip]')){clearTimeout(ttTimer);tt.classList.remove('show');}});++// Brand hover popover: app identity lives here, not in the header row.+const ai=$('#appinfo');const brand=document.querySelector('.brand');+let aiShow=null,aiHide=null;+function fillAppInfo(){+ const v=lastStatus?lastStatus.dashboardVersion:'';const bv=lastStatus?(lastStatus.bridgeVersion||'?'):'?';+ ai.innerHTML='<b style="color:var(--text)">KiCad Dashboard</b><br>dashboard v'+(v||'?')+' \u00b7 bridge v'+bv++ '<br>engine: kicad-dashboard (Python) + adom-bridge<br>author: adom<br>'++ '<a href="https://wiki.adom.inc/adom/kicad-bridge" target="_blank" rel="noopener">wiki.adom.inc/adom/kicad-bridge</a>';+}+brand.addEventListener('mouseenter',()=>{clearTimeout(aiHide);aiShow=setTimeout(()=>{+ fillAppInfo();ai.classList.add('show');+ const r=brand.getBoundingClientRect(),M=8;+ let x=Math.max(M,Math.min(r.left,innerWidth-ai.offsetWidth-M));+ ai.style.left=x+'px';ai.style.top=Math.min(r.bottom+8,innerHeight-ai.offsetHeight-M)+'px';+},600);});+brand.addEventListener('mouseleave',()=>{clearTimeout(aiShow);aiHide=setTimeout(()=>ai.classList.remove('show'),220);});+ai.addEventListener('mouseenter',()=>clearTimeout(aiHide));+ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove('show'),220);});++loadTargets().then(()=>refresh());setInterval(()=>refresh(),15000);setInterval(loadTargets,60000);+</script>+</body>+</html>
dock.jsonadded+26@@ -0,0 +1,26 @@+{+ "dock_manifest": "1.1.0",+ "webview": { "title": "KiCad Dashboard", "favicon": "docs/img/dock-kicad.svg" },+ "launch": {+ "mode": "container_command",+ "console": "show",+ "steps": [+ { "label": "Installing the KiCad bridge",+ "run": "adom-wiki pkg install adom/kicad-bridge",+ "skip_if": "command -v kicad-dashboard",+ "timeout_seconds": 180 },+ { "label": "Starting the KiCad Dashboard",+ "run": "kicad-dashboard serve --print-url",+ "await": "url",+ "timeout_seconds": 60 }+ ],+ "on_ready": "navigate_placeholder",+ "ai": {+ "prompt": "The user just opened the KiCad Dashboard from the dock. It shows LED status for every KiCad bridge facet. If any LED is not green, offer to fix it (the /api/status payload carries a _hint per LED naming the exact verb). Everything the bridge does to KiCad happens in the background; never steal their foreground, and never touch a KiCad instance the user launched themselves.",+ "thread": "existing",+ "thread_fallback": "new",+ "submit": "staged",+ "typed": false+ }+ }+}
server.py+5−1@@ -791,9 +791,13 @@ def dispatch_command(command: str, args: dict) -> dict: _wfg.end_gui_verb() except Exception: pass- # Stamp the version actually used so callers can see which install ran+ # Stamp the version actually used so callers can see which install ran, and+ # OUR OWN version — 0.9.159: BRIDGE_VERSION only ever reached /health, so no+ # caller (the dashboard included) could ask which bridge code is running, and+ # "is my fix deployed?" needed a filesystem read on the box. if isinstance(result, dict): result.setdefault("kicadVersionUsed", selected_info.get("version"))+ result.setdefault("bridgeVersion", BRIDGE_VERSION) # CONSTANT DIALOG COVERAGE: after EVERY GUI-affecting verb, sweep for KiCad # dialogs (KiCad throws modal error boxes constantly — "Cannot enumerate # ...Templates (Access denied)", save prompts, format warnings — that