← Commit history

v0.9.88: background kicad_demo with structured progress (#24) — percent/ETA/stepLabel polling + kicad_state final payload

John Lauer ·ed15b7dfb3 ·2mo ago ·parent 4f1e21f
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,