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
0.9.310: Tour layout for the recording (John): each beat's window is placed at 80% of the primary screen in its slot (symbol upper-left, footprint nudged, chip 3D upper-right, schematic lower-left nudged, board 2D lower-left, board 3D lower-right) and the KiCad manager is centered at its own size; every bound is saved first and restored at the tour's end, stop, failure, or the next start (leftovers file), so KiCad's sticky sizes end up as the user had them. Every caption's reply is recorded on progress; a press on the remote gets an immediate acknowledgement caption at the top of the screen; the settle check waits up to 8 s for two identical captures instead of sampling once.
3 files changed
+165−13
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.309+0.9.310
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.309",+ "version": "0.9.310", "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+163−11@@ -628,6 +628,123 @@ def _job_update(**kw): _JOB.update(kw) +# ── Window layout for the recording (John, 2026-09-03) ───────────────────────+# "make the kicad windows about 80% the size of the screen but place them a bit+# offset from each other so the user sorta sees the other windows in the+# background: symbol upper-left, 3D view upper-right, 2D board lower-left, 3D+# board lower-right, and the main KiCad window centered (it is smaller)."+# KiCad remembers a frame's size when it closes, so every bound we touch is+# saved first and restored at the end of the tour (or its stop, or a crash on+# the next tour start via the leftovers file).+_LAYOUT_SLOTS = { # (x-anchor, y-anchor): 0 = top/left edge, 1 = bottom/right edge+ "symbol": (0.0, 0.0), "footprint": (0.15, 0.0), "footprint3d": (1.0, 0.0),+ "schematic": (0.0, 1.0), "board": (0.15, 1.0), "board3d": (1.0, 1.0),+}+_SAVED_BOUNDS: dict = {}+++def _primary_monitor() -> dict | None:+ try:+ from handlers import ad_client+ r = ad_client.call("desktop_list_monitors", {}, timeout=6) or {}+ out = r.get("output") if isinstance(r, dict) else None+ if isinstance(out, str):+ import json as _j+ out = _j.loads(out)+ d = ((out or {}).get("data") if isinstance(out, dict) else None) or out or {}+ mons = d.get("monitors") or []+ prim = next((m for m in mons if m.get("primary")), mons[0] if mons else None)+ return (prim or {}).get("bounds")+ except Exception: # pylint: disable=broad-except+ return None+++def _leftovers_path() -> Path:+ return _demo_dir() / "window-bounds-to-restore.json"+++def _save_bounds(hwnd: int, title: str) -> None:+ if hwnd in _SAVED_BOUNDS:+ return+ try:+ from handlers import kicad_windows+ row = next((r for r in (kicad_windows.find(all_windows=True, fresh=True) or []) if int(r.get("hwnd") or 0) == int(hwnd)), None)+ if row and row.get("rect"):+ rc = row["rect"]+ _SAVED_BOUNDS[int(hwnd)] = {"title": title, "x": rc.get("x", rc.get("left", 0)), "y": rc.get("y", rc.get("top", 0)),+ "w": rc.get("width"), "h": rc.get("height")}+ _leftovers_path().write_text(json.dumps(_SAVED_BOUNDS), encoding="utf-8")+ except Exception: # pylint: disable=broad-except+ pass+++def _place_window(hwnd: int, step: str, title: str) -> dict:+ """80% of the primary screen, anchored per step; returns what was applied."""+ mon = _primary_monitor()+ if not mon or not hwnd:+ return {"placed": False, "why": "no monitor bounds or no hwnd"}+ ax, ay = _LAYOUT_SLOTS.get(step, (0.5, 0.5))+ W, H = int(mon.get("width") or 0), int(mon.get("height") or 0)+ w, h = int(W * 0.8), int(H * 0.8)+ x = int(mon.get("x", 0) + ax * (W - w)); y = int(mon.get("y", 0) + ay * (H - h))+ _save_bounds(hwnd, title)+ try:+ from handlers import ad_client+ r = ad_client.call("desktop_set_window_bounds", {"hwnd": int(hwnd), "x": x, "y": y, "w": w, "h": h,+ "reason": f"KiCad tour: place the {title} at 80% of the screen in its slot"}, timeout=8) or {}+ ok = bool(r.get("success")) and not r.get("error")+ return {"placed": ok, "x": x, "y": y, "w": w, "h": h, "error": r.get("error")}+ except Exception as e: # pylint: disable=broad-except+ return {"placed": False, "error": f"{type(e).__name__}: {e}"}+++def _center_main_window() -> None:+ """The KiCad manager stays its own size, centered on the primary screen."""+ try:+ from handlers import kicad_windows, ad_client+ mon = _primary_monitor()+ rows = kicad_windows.find(all_windows=True, fresh=True) or []+ main = next((r for r in rows if (r.get("title") or "").endswith("KiCad 10.0") or " KiCad " in (r.get("title") or "")), None)+ if not (mon and main and main.get("rect")):+ return+ rc = main["rect"]; w, h = int(rc.get("width") or 0), int(rc.get("height") or 0)+ if not (w and h):+ return+ _save_bounds(int(main["hwnd"]), main.get("title") or "KiCad")+ x = int(mon.get("x", 0) + (mon["width"] - w) / 2); y = int(mon.get("y", 0) + (mon["height"] - h) / 2)+ ad_client.call("desktop_set_window_bounds", {"hwnd": int(main["hwnd"]), "x": x, "y": y, "w": w, "h": h,+ "reason": "KiCad tour: center the KiCad manager on the screen"}, timeout=8)+ except Exception: # pylint: disable=broad-except+ pass+++def _restore_bounds(source: str = "end") -> int:+ """Put every window the tour moved back where the user had it."""+ n = 0+ saved = dict(_SAVED_BOUNDS)+ if not saved:+ try:+ saved = json.loads(_leftovers_path().read_text(encoding="utf-8"))+ except Exception: # pylint: disable=broad-except+ saved = {}+ try:+ from handlers import ad_client, kicad_windows+ live = {int(r.get("hwnd") or 0) for r in (kicad_windows.find(all_windows=True, fresh=True) or [])}+ for h, b in saved.items():+ if int(h) in live and b.get("w") and b.get("h"):+ ad_client.call("desktop_set_window_bounds", {"hwnd": int(h), "x": int(b["x"]), "y": int(b["y"]), "w": int(b["w"]), "h": int(b["h"]),+ "reason": f"KiCad tour {source}: restore this window to where the user had it"}, timeout=8)+ n += 1+ except Exception: # pylint: disable=broad-except+ pass+ _SAVED_BOUNDS.clear()+ try:+ _leftovers_path().unlink()+ except Exception: # pylint: disable=broad-except+ pass+ return n++ def _verify_beat(hwnd: int | None, expected_title: str, tries: int = 2) -> dict: """MEASURE, never assume (John, 2026-09-03: "you have to tool your bridge code to definitively know that the correct thing is showing"). Four facts@@ -680,10 +797,16 @@ def _verify_beat(hwnd: int | None, expected_title: str, tries: int = 2) -> dict: r = _screenshot_hwnd(int(hwnd), label="verify", max_dim=480) pth = r.get("fullPath") or r.get("savedTo") return _h.sha1(open(pth, "rb").read()).hexdigest() if pth else None- a = _digest(); _t.sleep(0.8); b = _digest()- v["stable"] = bool(a and b and a == b)- if not v["stable"]:- _t.sleep(1.2); c = _digest(); v["stable"] = bool(b and c and b == c)+ # "perfectly knowing when a window is done loading" is a measurement with a+ # bound: identical captures 0.8 s apart, retried for up to 8 s.+ prev = _digest(); v["stable"] = False; v["settleMs"] = 0+ t0 = _t.monotonic()+ while _t.monotonic() - t0 < 8.0:+ _t.sleep(0.8); cur = _digest()+ if prev and cur and prev == cur:+ v["stable"] = True; break+ prev = cur+ v["settleMs"] = int((_t.monotonic() - t0) * 1000) except Exception: # pylint: disable=broad-except v["stable"] = None v["ok"] = bool(v["front"] and v["title"] and v["painted"] is not False)@@ -1071,6 +1194,12 @@ def on_panel_event(ev: dict) -> dict: if at is not None: _PANEL_SEEN.add(str(at)) b = str(ev.get("button") or "").lower()+ _ACK = {"mute": "Remote: muted", "unmute": "Remote: narration back on",+ "pause": "Remote: pausing after this beat", "play": "Remote: playing",+ "next": "Remote: next, after this beat", "back": "Remote: back, after this beat",+ "stop": "Remote: stopping the tour"}+ if b in _ACK:+ _caption_ack(_ACK[b]) if b in ("mute", "unmute"): _apply_mute(b == "mute", "panel") return {"applied": b}@@ -1222,15 +1351,31 @@ def _caption(text: str, duration_ms: int = 60000) -> None: # reason is REQUIRED (ab refuses a caption without one; that is why no # caption painted during the 2026-09-03 tours) and John asked for the # MEDIUM size explicitly.- ad_client.call("desktop_caption",- {"id": _CAPTION_ID, "text": text, "duration": duration_ms,- "size": "medium", "position": "bottom",- "reason": "Narrate the KiCad tour the user started, one line per beat"},- timeout=6)+ r = ad_client.call("desktop_caption",+ {"id": _CAPTION_ID, "text": text, "duration": duration_ms,+ "size": "medium", "position": "bottom",+ "reason": "Narrate the KiCad tour the user started, one line per beat"},+ timeout=6) or {}+ ok = bool(r.get("success")) and not r.get("error")+ with _JOB_LOCK:+ _JOB.setdefault("captions", []).append({"text": text[:60], "ok": ok, "error": (r.get("error") or None)}) except Exception: pass +def _caption_ack(text: str) -> None:+ """Immediate acknowledgement of a remote press, at the TOP so it never+ fights the beat caption (John: 'show me an alternate caption immediately+ indicating you got my signal')."""+ try:+ from handlers import ad_client+ ad_client.call("desktop_caption", {"id": "kicad-demo-ack", "text": text, "duration": 3500,+ "size": "medium", "position": "top",+ "reason": "Acknowledge a press on the KiCad tour remote the instant it arrives"}, timeout=6)+ except Exception: # pylint: disable=broad-except+ pass++ def _adopt_tour_identity() -> None: """Re-apply the identity of the request that STARTED the tour on this thread. @@ -1298,7 +1443,9 @@ def _run_demo_background(kicad_info: dict) -> None: with _CTRL_LOCK: _CTRL["cmd"] = None # a control queued against the previous tour must not steer this one _drain_stale_presses()+ _restore_bounds("leftovers") # a crashed earlier tour never leaves windows moved _close_stale_tour_windows()+ _center_main_window() _panel_show(0, "playing") _caption(_BEAT_CAPTIONS["prepare"], duration_ms=_BEAT_CAPTION_MS["prepare"]) _play_narration("prepare")@@ -1382,6 +1529,9 @@ def _run_demo_background(kicad_info: dict) -> None: exited = True break if r.get("ok"):+ _target0 = r.get("hwnd") or _hwnd_for_title(r.get("window") or "")+ if _target0:+ r["placed"] = _place_window(_target0, s_, r.get("window") or s_) r["foregrounded"] = _foreground_once(r.get("window") or r.get("windowTitle") or "", hwnd=r.get("hwnd")) # MEASURE before moving: the right window, in front, painted, settled.@@ -1438,6 +1588,7 @@ def _run_demo_background(kicad_info: dict) -> None: ok_ct = len([r for r in results if r.get("ok")]) _stop_narration() _panel_hide()+ _job_update(windowsRestored=_restore_bounds("end")) try: from handlers import ad_client ad_client.call("notify_dismiss", {"id": _CTL_TOAST_ID}, timeout=4)@@ -1464,7 +1615,7 @@ def _run_demo_background(kicad_info: dict) -> None: "foregrounded": r.get("foregrounded"), "motion": r.get("motion"), "hwnd": r.get("hwnd"), "window": r.get("window"),- "verified": r.get("verified"),+ "verified": r.get("verified"), "placed": r.get("placed"), "failReason": (None if r.get("ok") else { "handlerSuccess": bool((r.get("raw") or {}).get("success")), "error": str((r.get("raw") or {}).get("error") or "")[:200],@@ -1476,6 +1627,7 @@ def _run_demo_background(kicad_info: dict) -> None: }) except Exception as e: # pylint: disable=broad-except _panel_hide()+ _restore_bounds("error") _job_update(active=False, done=True, error=f"{type(e).__name__}: {e}", elapsedSec=round(_t.monotonic() - t0)) @@ -1514,7 +1666,7 @@ def handle_demo(kicad_info: dict, args: dict) -> dict: out = {"success": True, **{k: snap.get(k) for k in ("done", "step", "stepLabel", "beatIndex", "beatCount", "percent", "elapsedSec", "estimatedSec", "error",- "panel", "muted", "muteSource", "lastPress", "paused", "stopping", "verified")}}+ "panel", "muted", "muteSource", "lastPress", "paused", "stopping", "verified", "captions", "windowsRestored")}} # 0.9.199 (wiki issue #39, John): `active` used to be a BOOLEAN here and # an ARRAY in kicad_progress - the same key, two types, from one bridge. # A renderer written as `if (resp.active)` then treats a finished job as