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.88: background kicad_demo with structured progress (#24) — percent/ETA/stepLabel polling + kicad_state final payload
6 files changed
+137−9
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.87+0.9.88
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.87",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.87/adom-bridge-kicad-v0.9.87.zip",- "sha256": "670c3979d766601a4aa9429912403116c5625a4b296cb28d647bc90792676667",- "size": 381469,+ "version": "0.9.88",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.88/adom-bridge-kicad-v0.9.88.zip",+ "sha256": "061ac097595f6b62b72faa9591a022bd0a1b80baef20338abe02af0ec42b887a",+ "size": 383168, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-14T18:10:32Z",+ "released_at": "2026-08-14T18:14:16Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.88.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.87",+ "version": "0.9.88", "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.",
handlers/demo.py+129−1@@ -418,8 +418,130 @@ def _run_step(kicad_info: dict, step: str, prep: dict) -> dict: return {"ok": False, "error": f"unknown step '{step}'", "validSteps": STEPS} +# ── #24: long-running background demo with STRUCTURED PROGRESS ────────────────+# A web front-end wants a real progress bar (percent + ETA), not a spinner, and+# the bridge is the only party that knows its own timings. One job at a time.+import threading as _threading++_BEAT_EST = {"prepare": 18, "symbol": 12, "footprint": 12, "part_3d": 16,+ "schematic": 10, "board_2d": 10, "board_3d": 22}++_JOB: dict = {"active": False}+_JOB_LOCK = _threading.Lock()+++def _job_snapshot() -> dict:+ with _JOB_LOCK:+ snap = dict(_JOB)+ return snap+++def _job_update(**kw):+ with _JOB_LOCK:+ _JOB.update(kw)+++def _run_demo_background(kicad_info: dict) -> None:+ """Thread body: the six beats with live progress accounting."""+ import time as _t+ t0 = _t.monotonic()+ total_est = sum(_BEAT_EST.values())+ done_weight = 0.0+ try:+ prep = _prepare(kicad_info)+ _job_update(step="prepare", stepLabel="Preparing the sample project",+ percent=int(100 * _BEAT_EST["prepare"] * 0.5 / total_est),+ elapsedSec=round(_t.monotonic() - t0))+ prep["warm"] = _warm_kicad(kicad_info, prep)+ done_weight += _BEAT_EST["prepare"]+ results = []+ for i, s_ in enumerate(STEPS):+ _job_update(step=s_, stepLabel=f"Opening {s_.replace('_', ' ')}",+ beatIndex=i + 1, beatCount=len(STEPS),+ percent=min(95, int(100 * done_weight / total_est)),+ elapsedSec=round(_t.monotonic() - t0))+ r = _run_step(kicad_info, s_, prep)+ if not r.get("ok"):+ _t.sleep(3)+ r = _run_step(kicad_info, s_, prep)+ r["retried"] = True+ r["step"] = s_+ results.append(r)+ done_weight += _BEAT_EST.get(s_, 10)+ # final payload = the kicad_state shape (#22): every window, base64+ state = {}+ try:+ from handlers.state import handle_state+ state = handle_state(kicad_info, {"maxWidth": 800})+ except Exception as e: # pylint: disable=broad-except+ state = {"success": False, "error": str(e)}+ ok_ct = len([r for r in results if r.get("ok")])+ _job_update(active=False, done=True, percent=100,+ elapsedSec=round(_t.monotonic() - t0),+ result={+ "openedBeats": ok_ct, "totalBeats": len(STEPS),+ "failedSteps": [r["step"] for r in results if not r.get("ok")],+ "steps": [{"step": r["step"], "title": r.get("title"),+ "ok": r.get("ok"), "pointOut": r.get("pointOut")}+ for r in results],+ "narrate": " ".join(r.get("say", "") for r in results if r.get("ok")),+ "state": state,+ })+ except Exception as e: # pylint: disable=broad-except+ _job_update(active=False, done=True, error=f"{type(e).__name__}: {e}",+ elapsedSec=round(_t.monotonic() - t0))++ def handle_demo(kicad_info: dict, args: dict) -> dict:- """Run the bridge's showcase tour (all six steps, or one at a time)."""+ """Run the bridge's showcase tour (all six steps, one at a time, or as a+ BACKGROUND JOB with structured progress — issue #24)."""+ # ---- progress poll (cheap, no side effects) ----+ if args.get("progress"):+ snap = _job_snapshot()+ if not snap or (not snap.get("active") and "done" not in snap):+ return {"success": False, "error": "no demo job has been started",+ "_hint": "Start one with kicad_demo {\"all\":true,\"background\":true}."}+ out = {"success": True, **{k: snap.get(k) for k in+ ("active", "done", "step", "stepLabel", "beatIndex", "beatCount",+ "percent", "elapsedSec", "estimatedSec", "error")}}+ if snap.get("done") and snap.get("result") is not None:+ out["result"] = snap["result"]+ out["_hint"] = ("Demo complete: result.state.shots[] is every KiCad window as base64 "+ "PNG (render them all); speak result.narrate. Tear down with "+ "kicad_close {\"force\":true} when the user is done looking.")+ else:+ out["_hint"] = ("Long-running job — render a real progress bar from percent/"+ "elapsedSec/estimatedSec and show stepLabel. Poll every 2-3s.")+ return out++ # ---- background start ----+ if args.get("all") and args.get("background"):+ snap = _job_snapshot()+ if snap.get("active"):+ return {"success": False, "error": "a demo job is already running",+ "progress": {k: snap.get(k) for k in ("percent", "step", "elapsedSec")},+ "_hint": "Poll kicad_demo {\"progress\":true} instead of starting another."}+ if not kicad_info.get("installed"):+ return {"success": False, "error": "KiCad is not installed",+ "_hint": "Foreground kicad_demo {} offers the install flow; run that first."}+ est = sum(_BEAT_EST.values())+ with _JOB_LOCK:+ _JOB.clear()+ _JOB.update(active=True, done=False, step="starting",+ stepLabel="Starting the tour", beatIndex=0,+ beatCount=len(STEPS), percent=0, elapsedSec=0,+ estimatedSec=est)+ _threading.Thread(target=_run_demo_background, args=(kicad_info,),+ daemon=True, name="kicad-demo-job").start()+ return {"success": True, "started": True, "estimatedSec": est,+ "poll": 'kicad_demo {"progress": true}',+ "_hint": (f"Background tour started (~{est}s). Poll kicad_demo "+ "{\"progress\":true} every 2-3s for percent/stepLabel — draw a real "+ "progress bar, not a spinner. The final poll carries result.state "+ "(every KiCad window as base64 PNG) + result.narrate.")}++ # (staged/foreground flow below is unchanged)+ # KiCad absent is the LIKELY case during an Hydrogen install demo. Never dead-end the # user with "install it yourself" — OFFER, and install it for them on the spot. if not kicad_info.get("installed"):@@ -546,6 +668,12 @@ def handle_demo(kicad_info: dict, args: dict) -> dict: "installedKiCadJustNow": installed_now, "warmedUp": {k: (prep.get("warm") or {}).get(k) for k in ("warmed", "eeschema", "pcbnew")}, "projectDir": prep.get("projectDir"),+ "progress": {+ "beatIndex": STEPS.index(last) + 1 if last in STEPS else None,+ "beatCount": len(STEPS),+ "percent": int(100 * (STEPS.index(last) + 1) / len(STEPS)) if last in STEPS else None,+ "estimatedSecTotal": sum(_BEAT_EST.values()),+ }, "installedParts": prep.get("installed"), "models3d": prep.get("models3d"), "warnings": prep.get("warnings"),
server.py+1−1@@ -339,7 +339,7 @@ _VERB_CATALOG = { "export_step": {"summary": "Export a STEP 3D model from a board.", "long": False, "hint": "Absolute board path + output; exports a STEP assembly via kicad-cli.", "related": ["kicad_open_3d_viewer", "kicad_export_gerber"], "pitfalls": ["only components with 3D models attached appear in the STEP"]},- "demo": {"summary": "Run the bridge's showcase tour: symbol -> footprint -> 3D part -> schematic -> 2D board -> 3D board.", "long": True,+ "demo": {"summary": "Six-beat KiCad showcase tour — staged, or a background job with structured progress (percent/ETA) and a kicad_state final payload", "long": True, "hint": "The demo verb — STAGED + RESUMABLE, same contract as fusion_demo (stage/done/narrate/screenshots/steps). Speak narrate, show screenshots[{label,path}] (captured for you), call data.nextCall until done:true. Generates its own parts + project (ships no sample files), installs them into the user's real KiCad, and opens each view. Every step returns `say` (speak it), `pointOut` (what's on screen) and `window` (match in kicad_screenshot_all). Pass {\"step\":\"symbol\"} to pace it one beat at a time; no args runs all six.", "related": ["kicad_screenshot_all", "kicad_close", "kicad_upgrade"], "pitfalls": ["if KiCad isn't installed it returns an actionable error — call kicad_upgrade, don't send the user to a download page", "windows open in the BACKGROUND; screenshot them instead of stealing focus", "a bulk run opens all six windows at once — step-by-step narrates better"]}, "export_molecule": {"summary": "One-shot molecule export pack: STEP + silk renders + sch/pro discovery for the Adom molecule pipeline.", "long": True,