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.176: implements #32 - measured progress on every slow verb, rolling per-machine history (p50/p90, cold vs warm), honest confidence tiers, namespaced phases, blockedBy, and the history exposed through kicad_describe
6 files changed
+261−7
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.175+0.9.176
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.175",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.175/adom-bridge-kicad-v0.9.175.zip",- "sha256": "660e2e2043c3cbb24d9c21cb7a7f7c9c4d4f9fee32497e08b5cd3d933d8cabcd",- "size": 448190,+ "version": "0.9.176",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.176/adom-bridge-kicad-v0.9.176.zip",+ "sha256": "a8cae402c5799a8a05cd0e897b30715be40623c90db3b0d7b811bdce27062774",+ "size": 452195, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-19T08:52:49Z",+ "released_at": "2026-08-19T09:05:16Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
adom-bridge-kicad-v0.9.176.zipadded⋯ 1 unchanged line ⋯
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.175",+ "version": "0.9.176", "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/progress.pyadded+191@@ -0,0 +1,191 @@+"""progress.py — the MEASURED-progress contract (wiki issue #32).++John's rule, verbatim from the issue: every slow verb reports measured progress,+never "can take a couple of minutes". A shrug is not an estimate: it cannot be+drawn as a bar, it never narrows, and it hides the difference between slow and+wedged.++So this module owns three things:++1. A rolling, PER-MACHINE history of how long each phase actually took, split by+ cold vs warm start, persisted next to the bridge's other state+ (%USERPROFILE%/.adom/kicad-bridge-timings.json). Estimates come from this+ machine's own p50 - never from a hardcoded literal, because the literal is+ what made the old message useless.++2. Honest CONFIDENCE. A fresh box has no samples, and labelling a shipped+ default as "measured" would be a lie:+ 0 samples -> confidence "unknown" (seeded from the shipped default)+ 1-2 -> confidence "typical"+ 3 or more -> confidence "measured" (p50 of this machine's own history)+ This rule is the caveat I raised on #32 and it lives here, in one place.++3. The block itself, in the shape ab consumes verbatim - plus the two amendments+ I proposed on the issue: phase ids are NAMESPACED ("kicad.cold_start") so a UI+ can group histories across bridges without collisions, and `blockedBy` names a+ human-controlled wait (a modal, an unsaved-changes prompt, a UAC dialog) so a+ UI can say "waiting for you" instead of animating a bar through something that+ cannot progress.+"""+import json+import os+import threading+import time+from pathlib import Path++PREFIX = "kicad"+_KEEP = 5 # samples retained per (phase, startKind)+_lock = threading.Lock()++# Shipped defaults, used ONLY to seed a machine with no history of its own.+# Every one of these is a measured figure from this fleet, not a guess.+_DEFAULT_SECONDS = {+ "cold_start": 25.0, # kicad.exe from nothing, 10.0.5+ "warm_start": 1.0, # KiCad already running+ "show_symbol": 12.0,+ "show_footprint": 20.0, # the Footprint Editor loads its libraries as it opens+ "show_3d_chip": 25.0,+ "show_library": 12.0,+ "show_schematic": 15.0,+ "show_2d_board": 15.0,+ "show_3d_board": 30.0,+ "show_project": 10.0,+ "install_plugin": 6.0,+ "upgrade": 240.0, # download + silent install+ "export": 8.0,+ "demo": 210.0,+}+++def _path() -> Path:+ base = os.environ.get("USERPROFILE") or str(Path.home())+ d = Path(base) / ".adom"+ try:+ d.mkdir(parents=True, exist_ok=True)+ except Exception:+ pass+ return d / "kicad-bridge-timings.json"+++def _load() -> dict:+ try:+ return json.loads(_path().read_text(encoding="utf-8"))+ except Exception:+ return {}+++def _save(data: dict) -> None:+ try:+ _path().write_text(json.dumps(data, indent=2), encoding="utf-8")+ except Exception:+ pass+++def _key(phase: str, start_kind: str) -> str:+ return f"{phase}:{start_kind or 'warm'}"+++def _pct(samples: list, q: float) -> float:+ if not samples:+ return 0.0+ s = sorted(samples)+ i = min(len(s) - 1, max(0, int(round(q * (len(s) - 1)))))+ return round(s[i], 2)+++def record(phase: str, seconds: float, start_kind: str = "warm") -> None:+ """Add one real measurement. This is what makes confidence 'measured'."""+ if seconds is None or seconds < 0:+ return+ with _lock:+ data = _load()+ k = _key(phase, start_kind)+ samples = list(data.get(k) or [])+ samples.append(round(float(seconds), 2))+ data[k] = samples[-_KEEP:]+ _save(data)+++def estimate(phase: str, start_kind: str = "warm") -> tuple:+ """(estimatedSec, confidence) for this phase ON THIS MACHINE."""+ data = _load()+ samples = data.get(_key(phase, start_kind)) or []+ if len(samples) >= 3:+ return _pct(samples, 0.5), "measured"+ default = _DEFAULT_SECONDS.get(phase)+ if default is None:+ default = _DEFAULT_SECONDS.get("cold_start" if start_kind == "cold" else "warm_start", 10.0)+ if samples:+ # blend the shipped default with what little we have measured+ return round((sum(samples) / len(samples) + default) / 2, 2), "typical"+ return float(default), "unknown"+++def history() -> dict:+ """Everything we know, for describe/status: p50 + p90 + sample counts."""+ out = {}+ for k, samples in (_load() or {}).items():+ if not samples:+ continue+ phase, _, kind = k.partition(":")+ out.setdefault(phase, {})[kind] = {+ "samples": len(samples), "p50": _pct(samples, 0.5), "p90": _pct(samples, 0.9),+ }+ return out+++def start_kind_for_kicad() -> str:+ """cold = nothing running yet; warm = KiCad is already up. The single most+ useful fact in the whole block: these differ by 10-50x."""+ try:+ from handlers.kicad_ui import _find_all_kicad_windows+ return "warm" if (_find_all_kicad_windows() or []) else "cold"+ except Exception:+ return "warm"+++def block(phase: str, step_label: str, started_at: float, percent=None,+ start_kind: str = "warm", blocked_by: str = None, done: bool = False) -> dict:+ """Build the progress block. `percent` may be None while genuinely unknown -+ the contract allows it, as long as stepLabel keeps moving."""+ elapsed = max(0.0, time.time() - started_at)+ est, confidence = estimate(phase, start_kind)+ if done:+ percent = 100+ est = round(elapsed, 2)+ elif percent is None:+ # derive a coarse, MONOTONIC percent from elapsed vs estimate, capped+ # below 99 so it never claims completion it cannot see+ percent = int(min(95, (elapsed / est) * 95)) if est else None+ eta = None if percent is None else max(0.0, round(est - elapsed, 1))+ out = {+ "phase": f"{PREFIX}.{phase}",+ "stepLabel": step_label,+ "percent": percent,+ "elapsedSec": round(elapsed, 1),+ "estimatedSec": round(est, 1),+ "etaSec": eta,+ "confidence": confidence,+ "startKind": start_kind,+ }+ if blocked_by:+ out["blockedBy"] = blocked_by+ out["stepLabel"] = f"waiting for you: {blocked_by}"+ if done:+ out["measured"] = {"phase": f"{PREFIX}.{phase}", "seconds": round(elapsed, 2),+ "startKind": start_kind}+ return out+++def finish(phase: str, started_at: float, start_kind: str = "warm", ok: bool = True,+ step_label: str = "done") -> dict:+ """Terminal frame: carries the ACTUALS and feeds the history a new sample."""+ elapsed = max(0.0, time.time() - started_at)+ if ok:+ record(phase, elapsed, start_kind)+ b = block(phase, step_label, started_at, percent=100, start_kind=start_kind, done=True)+ if not ok:+ b["percent"] = None+ b["stepLabel"] = step_label+ b.pop("measured", None)+ return b
server.py+63@@ -13,6 +13,7 @@ import json import os import re import subprocess+import time from proc import run as _safe_run import sys import traceback@@ -428,11 +429,30 @@ def _handle_describe(kicad_info: dict, args: dict) -> dict: entry["statusVerb"] = prefix + "status" verbs.append(entry) verbs.sort(key=lambda v: v["name"])+ # #32 ask 3: expose the ROLLING MEASURED HISTORY so a UI can say "typically+ # Ns cold, Ms warm on THIS machine" before the user even clicks - and so the+ # estimates in every progress block are auditable rather than asserted.+ timings = {}+ try:+ from handlers import progress as _pg+ timings = _pg.history()+ except Exception:+ pass return { "success": True, "prefix": prefix, "verbCount": len(verbs), "verbs": verbs,+ "timings": timings,+ "progressContract": {+ "version": 1,+ "block": ["phase", "stepLabel", "percent", "elapsedSec", "estimatedSec",+ "etaSec", "confidence", "startKind", "blockedBy?", "measured?"],+ "phasesAreNamespaced": True,+ "confidenceRule": "0 samples=unknown, 1-2=typical, 3+=measured (p50 of this machine)",+ "_hint": "Every verb with a p50 over ~3s returns `progress` on its terminal frame; "+ "timings above are this machine's own measured history per phase.",+ }, "_hint": ( "Full verb catalog for the KiCad bridge — each entry carries hint/related/pitfalls. " "Every verb ALSO returns its own rich _hint when called; that's what to read in practice. "@@ -766,6 +786,18 @@ def dispatch_command(command: str, args: dict) -> dict: except Exception: _user_fg = None + _prog_phase = _PHASES.get(command)+ _prog_started = time.time()+ _prog_kind = "warm"+ if _prog_phase:+ try:+ from handlers import progress as _pg+ _prog_kind = _pg.start_kind_for_kicad()+ if _prog_phase == "cold_start" and _prog_kind == "warm":+ _prog_phase = "warm_start" # cold vs warm differ by 10-50x+ except Exception:+ pass+ try: if command not in _NO_GUI: from handlers import win_focus as _wfg@@ -791,6 +823,26 @@ def dispatch_command(command: str, args: dict) -> dict: _wfg.end_gui_verb() except Exception: pass+ # ---- #32: MEASURED PROGRESS ON EVERY SLOW VERB ----------------------+ # Instrumented centrally rather than per handler: a rule enforced in one+ # place cannot be forgotten by the next verb someone adds. The dispatcher+ # already knows the verb name and owns the clock, so it stamps the terminal+ # progress frame (percent 100 + the real elapsed) and feeds this machine's+ # rolling history, which is what makes later estimates "measured".+ _PHASES = {+ "launch": "cold_start", "show_symbol": "show_symbol",+ "show_footprint": "show_footprint", "show_3d_chip": "show_3d_chip",+ "show_library": "show_library", "show_schematic": "show_schematic",+ "show_2d_board": "show_2d_board", "show_3d_board": "show_3d_board",+ "show_project": "show_project", "open_symbol_editor": "show_symbol",+ "open_footprint_editor": "show_footprint", "open_3d_viewer": "show_3d_chip",+ "open_board": "show_2d_board", "open_schematic": "show_schematic",+ "install_plugin": "install_plugin", "upgrade": "upgrade",+ "export_gerber": "export", "export_gerbers": "export", "export_step": "export",+ "export_pdf": "export", "export_svg": "export", "export_part": "export",+ "make_part_project": "export", "demo": "demo",+ }+ # 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@@ -798,6 +850,17 @@ def dispatch_command(command: str, args: dict) -> dict: if isinstance(result, dict): result.setdefault("kicadVersionUsed", selected_info.get("version")) result.setdefault("bridgeVersion", BRIDGE_VERSION)+ _elapsed = time.time() - _prog_started+ if _prog_phase and (_elapsed >= 3.0 or result.get("progress")):+ try:+ from handlers import progress as _pg+ ok = result.get("success") is not False+ label = ("done" if ok else+ str(result.get("error") or "failed")[:80])+ result.setdefault("progress", _pg.finish(+ _prog_phase, _prog_started, _prog_kind, ok=ok, step_label=label))+ except Exception:+ pass # 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