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.341: verb-test runner (full coverage, foreground victim rule, user discovery, pcbnew ping), routing/readiness/library tests, release tool push verification, notes
7 files changed
+214−20
RELEASE_NOTES.md+16@@ -10,6 +10,22 @@ unnamed. These entries were reconstructed after the fact from the work they contain, which is exactly the situation the mandatory-notes gate now prevents. Treat them as accurate but secondhand. +## 0.9.341 (2026-09-07): release-day fixes from the multi-host verification++- #80: `${KICAD10_SYMBOL_DIR}` and the other stock library variables now resolve to the installed+ KiCad's share directory (per-user installs included) with Configure Paths overrides honoured;+ `kicad_list_symbols {"libraryName":"Device"}` works on every box.+- #81: `kicad_screenshot_all` no longer dies with an internal sizeKB error when a capture reply+ carries no size.+- #82: `kicad_readiness` and `kicad_list_versions` re-detect when a cached kicad.exe has vanished+ (KiCad uninstalled while the bridge ran) instead of reporting ready until a bridge restart.+- Live routing (PR 82, Codex): DRC snapshots refill zones; capped violation lists are flagged and the+ error-delta check refuses to certify a truncated report (`drc_incomplete`, no copper changed).+- #79: the release tool verifies every source push and refuses to release on a failed chunk.+- #78: the verb test runner ships its rp2040 fixture, learns the Windows user from the box, covers every+ live verb (tests or a stated skip), pings pcbnew instead of eeschema (no scripting there on KiCad 9/10),+ and only counts a foreground steal when a user window was actually displaced.+ ## 0.9.340 (2026-09-07 16:56 UTC) Live routing verbs through KiCad's IPC API (routing_state, route_net, remove_route, routing_validate), offline copper verbs (board_pads, add_track, add_via, route), kicad_ipc_api, KiCad 10 name-based nets, vendored routing deps
skills/kicad-bridge-test/run_verb_tests.py+82−17@@ -100,7 +100,27 @@ def matrix(w: dict) -> dict: note="SAFETY: diagnoseOnly only — never a real install"), "uninstall": dict(phase=1, args={"dryRun": True}, check="dry_report", note="SAFETY: dryRun only — never a real removal"),+ # ---- phase 1b: more read-only verbs (#78 coverage)+ "get_settings": dict(phase=1, args={}),+ "list_design_rules": dict(phase=1, args={}, timeout=90, note="rows come from the wiki at call time"),+ "list_footprints": dict(phase=1, args={"library": "Resistor_SMD"}, timeout=90),+ "list_symbols": dict(phase=1, args={"libraryName": "Device"}, timeout=90,+ note="the #80 regression: a STOCK library must resolve to KiCad's share dir"),+ "log_tail": dict(phase=1, args={}),+ "progress": dict(phase=1, args={}),+ "verb_times": dict(phase=1, args={}),+ "errors": dict(phase=1, args={}, note="win32 text read of KiCad message boxes; none open is fine"),+ "plugin_diagnose": dict(phase=1, args={}),+ "pcm_list": dict(phase=1, args={}),+ "ipc_api": dict(phase=1, args={}, note="READ ONLY here: no enable arg, so nothing is written"),+ "adom_library_heal": dict(phase=1, args={"mode": "prune", "dryRun": True},+ note="SAFETY: dryRun only; reports what a prune would do"), # ---- phase 2: headless exports on the sample board (kicad-cli)+ "extract_netlist": dict(phase=2, args={"filePath": S, "includeNodes": False}, timeout=120),+ "analyze_connections": dict(phase=2, args={"filePath": S}, timeout=120),+ "find_connections": dict(phase=2, args={"filePath": S, "reference": "U1"}, timeout=120),+ "trace_net": dict(phase=2, args={"filePath": S, "netName": "GND"}, timeout=120,+ note="a net that exists in the rp2040 fixture; a miss is success:false with candidates"), "run_drc": dict(phase=2, args={"filePath": B}, timeout=200), "run_erc": dict(phase=2, args={"filePath": S}, timeout=200), "lint_board": dict(phase=2, args={"filePath": B}, timeout=200),@@ -149,14 +169,19 @@ def matrix(w: dict) -> dict: note="escape to the KiCad window: safe, cancels nothing persistent"), "click": dict(phase=3, args={"__special": "click_3d_toolbar"}, note="clicks the 3D viewer's zoom-in toolbar button (window-targeted)"),- "bridge_call": dict(phase=3, args={"exeName": "eeschema", "method": "ping", "params": {}},- note="plugin IPC liveness"),+ "bridge_call": dict(phase=3, args={"exeName": "pcbnew", "method": "ping", "params": {}},+ note="plugin IPC liveness. pcbnew, not eeschema: KiCad 9/10 eeschema has no "+ "Python scripting, so the plugin can only ever live in the PCB side (#81)"), # ---- phase 4a: library installs (write into the Adom lib, then verify) "install_library": dict(phase=4, args={"__special": "install_library"}, timeout=160), "install_symbol": dict(phase=4, args={"__special": "install_symbol"}, timeout=160), "install_footprint": dict(phase=4, args={"__special": "install_footprint"}, timeout=160), "install_plugin": dict(phase=4, args={}, timeout=160, note="idempotent payload re-deploy"), "place_footprint": dict(phase=4, args={"__special": "place_footprint"}, timeout=220),+ "make_part_project": dict(phase=4, args={"partName": "ADOM_VERBTEST"}, timeout=220,+ note="after install_symbol/install_footprint: a pro/sch/pcb around the test part"),+ "export_part": dict(phase=4, args={"partName": "ADOM_VERBTEST", "what": ["symbol", "footprint"]}, timeout=160,+ note="writes Documents/adom-part-exports/ADOM_VERBTEST/"), # ---- phase 5: demo (one beat — the full 6-beat tour is --full) "demo": dict(phase=5, args={}, check="demo_beat", timeout=240, note="beat 1 only by default; --full runs all six"),@@ -170,6 +195,16 @@ def matrix(w: dict) -> dict: **{verb: dict(skip="Covered by tests/test_routing_ipc.py and demo/routing/run_demo.py; requires a dedicated unrouted IPC board") for verb in ("routing_state", "route_net", "remove_route", "routing_validate", "board_pads", "add_track", "add_via", "route")},+ "install_library_bundle": dict(skip="needs a Cloud Library bundle zip staged on the box; covered by the "+ "install_library/install_symbol/install_footprint trio it wraps"),+ "pcm_install": dict(skip="writes into %KICAD_3RD_PARTY% and installed_packages.json (a real PCM package "+ "download); run by hand when touching handlers/pcm.py"),+ "pcm_uninstall": dict(skip="pair of pcm_install; same reason"),+ "rescan_libraries": dict(skip="a full KiCad restart (~150 s, #51); exercised implicitly by the install verbs' "+ "staleEditorOpen path"),+ "set_design_rules": dict(skip="rewrites the board's design rules from the wiki; run by hand on a copy when "+ "touching handlers/design_rules.py"),+ "set_settings": dict(skip="mutates the user's bridge settings (overlay badges); get_settings covers the read side"), # ---- explicit skips (reason required — counted as covered) "enable_software_opengl": dict(skip="mutates the user's KiCad bin/ (mesa deploy) — only for GPU-less " "VMs; run manually on ADOMBASELINE when testing that path"),@@ -233,10 +268,20 @@ def main(): # discover the Windows user for paths user = "john" if not a.plan:- st = ab(a.target, "status", {}, timeout=60)- raw = json.dumps(st)- if "Users\\\\" in raw:- user = raw.split("Users\\\\", 1)[1].split("\\\\", 1)[0]+ # The Windows user comes from the box, never from an assumption (#78):+ # kicad_diagnostics carries C:/Users/<name>/... paths in every reply.+ import re as _re+ found = None+ for probe in (("kicad_diagnostics", {}), ("status", {})):+ raw = json.dumps(ab(a.target, probe[0], probe[1], timeout=90))+ m = _re.search(r"Users[\\\\/]+([^\\\\/\"]+)[\\\\/]", raw)+ if m and m.group(1).lower() not in ("public", "default"):+ found = m.group(1)+ break+ if found:+ user = found+ else:+ print("FATAL: could not learn the Windows user from kicad_diagnostics/status; pass --plan"); return 2 base = WINDIR.format(user=user) w = { "board": f"{base}/rp2040-breakout.kicad_pcb",@@ -346,10 +391,20 @@ def main(): # v0.9.104 (John): NEVER raise the user's window from here. The bridge's # own guardian pushes KiCad windows back; the suite only OBSERVES. - def fg_assert(verb):+ fg_notes = []+ fg_before = {}++ def fg_assert(verb, before=""): """After every GUI verb: the OS foreground window must NOT be KiCad.- This is the enforcement of the ALWAYS-background contract — a verb that- passes functionally but steals the foreground FAILS the run."""+ This is the enforcement of the ALWAYS-background contract: a verb that+ passes functionally but steals the foreground FAILS the run.++ `before` is the foreground title measured right before the verb. A steal+ needs a victim: when nothing non-KiCad owned the foreground beforehand+ (a headless VM with no console session, or KiCad already on top from an+ earlier verb) Windows hands a new window the foreground by itself, and+ that is the environment, not the bridge (issue #81, winvm 2026-09-07).+ Such cases are recorded in foreground_notes, never as failures.""" if a.skip_gui: return time.sleep(1.0)@@ -359,13 +414,18 @@ def main(): ws = (inner.get("data") or inner).get("windows") or [] top = next((x for x in ws if x.get("z") == 0), None) title = (top or {}).get("title", "")- if any(k in title for k in ("KiCad", "PCB Editor", "Schematic Editor",- "Symbol Editor", "Footprint Editor", "3D Viewer")):- fg_violations.append({"after_verb": verb, "foreground": title})- if verb in results and results[verb]["result"] == "PASS":- results[verb]["result"] = "FAIL"- results[verb]["reason"] = f"FOREGROUND STEAL: '{title}' on top after verb"- print(f" !! FOREGROUND STEAL after kicad_{verb}: {title[:60]}")+ if any(k in title for k in _kw):+ had_victim = bool(before) and not any(k in before for k in _kw) and before not in ("Program Manager",)+ if had_victim:+ fg_violations.append({"after_verb": verb, "foreground": title, "before": before})+ if verb in results and results[verb]["result"] == "PASS":+ results[verb]["result"] = "FAIL"+ results[verb]["reason"] = f"FOREGROUND STEAL: '{title}' on top after verb (was '{before[:40]}')"+ print(f" !! FOREGROUND STEAL after kicad_{verb}: {title[:60]}")+ else:+ fg_notes.append({"after_verb": verb, "foreground": title, "before": before,+ "note": "no user window owned the foreground before the verb; not counted"})+ print(f" .. KiCad on top after kicad_{verb} but nothing to steal from (before: {before[:40]!r})") except Exception: pass _keep_background() # push KiCad behind the user's window either way@@ -417,6 +477,10 @@ def main(): t = spec.get("timeout", 150) if spec.get("phase") == 2 or verb in ("open_footprint_editor", "open_symbol_editor", "open_3d_viewer"): args.setdefault("timeout", max(90, t - 20)) # relay-side per-call timeout override+ before_top = ""+ if not a.skip_gui and spec.get("phase", 9) >= 3:+ before_top = (_current_top() or {}).get("title") or ""+ fg_before[verb] = before_top r = ab(a.target, f"kicad_{verb}", args, timeout=t) if r.get("status") == "timeout": time.sleep(5) # transient relay/kicad-cli slowness — one retry@@ -450,7 +514,7 @@ def main(): for verb, spec in entries: run(verb, spec) if phase == 3:- fg_assert(verb)+ fg_assert(verb, fg_before.get(verb, '')) if phase == 3 and not a.skip_gui: time.sleep(2) @@ -475,6 +539,7 @@ def main(): "totals": {"pass": len(passes), "fail": len(fails), "skip": len(skips)}, "coverage_gap": missing, "failures": fails, "results": results, "foreground_violations": fg_violations,+ "foreground_notes": fg_notes, } (outdir / "report.json").write_text(json.dumps(report, indent=2)) md = [f"# KiCad bridge verb test — {a.target} — {report['started'][:16]}",
tests/test_library_uri.pyadded+26@@ -0,0 +1,26 @@+import sys, unittest+from pathlib import Path+from unittest.mock import patch+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))+import adom_library++INFO = {"version": "10.0", "base_dir": "C:/Users/arav/AppData/Local/Programs/KiCad/10.0", "user_dir": "C:/Users/arav/Documents/KiCad/10.0"}++class ExpandUri(unittest.TestCase):+ def test_stock_symbol_dir_is_the_install_share(self):+ with patch.object(adom_library, "_kicad_env_overrides", return_value={}):+ self.assertEqual(adom_library._expand_kicad_uri("${KICAD10_SYMBOL_DIR}/Device.kicad_sym", INFO),+ "C:/Users/arav/AppData/Local/Programs/KiCad/10.0/share/kicad/symbols/Device.kicad_sym")+ self.assertEqual(adom_library._expand_kicad_uri("${KICAD10_FOOTPRINT_DIR}/Resistor_SMD.pretty", INFO),+ "C:/Users/arav/AppData/Local/Programs/KiCad/10.0/share/kicad/footprints/Resistor_SMD.pretty")+ def test_user_override_wins(self):+ with patch.object(adom_library, "_kicad_env_overrides", return_value={"KICAD10_SYMBOL_DIR": "D:/libs/sym"}):+ self.assertEqual(adom_library._expand_kicad_uri("${KICAD10_SYMBOL_DIR}/Device.kicad_sym", INFO), "D:/libs/sym/Device.kicad_sym")+ def test_project_relative_and_unknown_refused(self):+ with patch.object(adom_library, "_kicad_env_overrides", return_value={}):+ self.assertIsNone(adom_library._expand_kicad_uri("${KIPRJMOD}/x.kicad_sym", INFO))+ self.assertIsNone(adom_library._expand_kicad_uri("${MYVAR}/x.kicad_sym", INFO))+ self.assertEqual(adom_library._expand_kicad_uri("C:\\\\plain\\\\Adom.kicad_sym", INFO), "C:/plain/Adom.kicad_sym")++if __name__ == "__main__":+ unittest.main()
tests/test_live_routing.py+46@@ -19,6 +19,52 @@ def board(): 'pads':[{'number':'1','x':0,'y':0,'net_name':'GND','layers':['B.Cu'],'type':'smd'}]}]} +class DrcSnapshot(unittest.TestCase):+ def test_capped_opens_are_lower_bound_without_blocking_error_comparison(self):+ limits = live._report_limits([], [{}] * 499)+ self.assertTrue(limits['unconnectedCountIsLowerBound'])+ self.assertFalse(limits['violationReportMayBeTruncated'])+ self.assertEqual(live._new_errors({'violations': [], **limits}, {'violations': [], **limits}), [])+ self.assertFalse(live._report_limits([], [{}] * 498)['unconnectedCountIsLowerBound'])++ def test_per_type_caps_block_error_delta_even_when_reports_match(self):+ for kind, cap in [('clearance', 499), ('shorting_items', 199)]:+ v = {'type': kind, 'severity': 'error', 'items': []}+ self.assertFalse(live._report_limits([v] * (cap - 1), [])['violationReportMayBeTruncated'])+ report = {'violations': [v] * cap, **live._report_limits([v] * cap, [])}+ with self.assertRaises(live.RoutingError) as cm:+ live._new_errors(report, report)+ self.assertEqual(cm.exception.code, 'drc_incomplete')+ self.assertFalse(cm.exception.detail['mutated'])++ def test_refills_snapshot_with_project_rules_without_changing_source(self):+ import json+ from types import SimpleNamespace+ with tempfile.TemporaryDirectory() as tmp:+ source = Path(tmp) / 'board.kicad_pcb'+ source.write_text('original board')+ source.with_suffix('.kicad_pro').write_text('project rules')+ source.with_suffix('.kicad_dru').write_text('custom rules')+ exe = Path(tmp) / 'kicad-cli'+ exe.touch()+ def run(command, **kwargs):+ candidate = Path(command[-1])+ self.assertNotEqual(candidate, source)+ self.assertEqual(candidate.read_text(), 'live snapshot')+ self.assertEqual(candidate.with_suffix('.kicad_pro').read_text(), 'project rules')+ self.assertEqual(candidate.with_suffix('.kicad_dru').read_text(), 'custom rules')+ self.assertIn('--refill-zones', command)+ Path(command[command.index('--output') + 1]).write_text(json.dumps({+ 'violations': [], 'unconnected_items': [{'items': []}]}))+ return SimpleNamespace(returncode=0, stderr='', stdout='')+ with patch.object(live.subprocess, 'run', side_effect=run):+ result = live._drc({'kicad_cli_exe': str(exe)}, {'filePath': str(source)}, 'live snapshot')+ self.assertTrue(result['zonesRefilled'])+ self.assertEqual(result['unconnected'], 1)+ self.assertFalse(result['clean'])+ self.assertEqual(source.read_text(), 'original board')++ class Planning(unittest.TestCase): def test_rotated_pad_coordinates_and_final_layer_transition(self): p=live._plan(board(), {'net':'GND','points':['J1.1',{'pad':'J2.1','layer':'B.Cu'}]})
tests/test_readiness_refresh.pyadded+21@@ -0,0 +1,21 @@+import sys, unittest+from pathlib import Path+from unittest.mock import patch+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))+import server++class Refresh(unittest.TestCase):+ def test_vanished_exe_triggers_redetect(self):+ with patch.object(server, "all_kicad_versions", [{"version": "10.0", "kicad_exe": "/nonexistent/kicad.exe"}]), \+ patch.object(server, "detect_all_kicad_versions", return_value=[]):+ r = server._handle_readiness({}, {})+ self.assertFalse(r["detected"]); self.assertEqual(r["versionCount"], 0)+ def test_present_exe_keeps_cache(self):+ exe = str(Path(__file__).resolve())+ with patch.object(server, "all_kicad_versions", [{"version": "10.0", "kicad_exe": exe}]), \+ patch.object(server, "detect_all_kicad_versions", side_effect=AssertionError("must not re-detect")):+ r = server._handle_readiness({}, {})+ self.assertTrue(r["detected"]); self.assertEqual(r["versions"][0]["exePath"], exe)++if __name__ == "__main__":+ unittest.main()
tests/test_routing_ipc.py+7−1@@ -140,7 +140,13 @@ class OfflineTests(unittest.TestCase): def test_atomic_file_edit_and_open_board_refusal(self): with tempfile.TemporaryDirectory() as d:- path = make(d); before = path.read_bytes()+ # A user can have the demonstration's default "live-routing" board+ # open on the Windows test host. Give this disposable fixture a+ # unique title so the real open-editor guard does not match it.+ fixture = make(d)+ path = fixture.with_name(Path(d).name + '-offline-test.kicad_pcb')+ fixture.rename(path)+ before = path.read_bytes() r = route.handle_route({}, {'filePath':str(path),'net':'NET_1','points':['J1.1','J2.1']}) self.assertTrue(r['success'], r) self.assertEqual(Path(str(path)+'.adom-bak').read_bytes(), before)
tools/build_release.py+16−2@@ -212,9 +212,23 @@ def main() -> int: # -m matters twice: the page reuses the commit message as its changelog # verbatim, so an unset message is where "- Update N file(s)" came from. msg = f"{version}: {notes}" if notes else f"{version}"+ # Every push reply is checked (issue #79: five handlers silently never reached+ # master across twenty releases because a failed chunk was ignored).+ failed = [] for i in range(0, len(push_files), CHUNK):- subprocess.run(["adom-wiki", "repo", "push", REF, "-m", msg, "--files"]- + push_files[i:i + CHUNK], check=False)+ chunk = push_files[i:i + CHUNK]+ proc = subprocess.run(["adom-wiki", "repo", "push", REF, "-m", msg, "--files"] + chunk,+ capture_output=True, text=True)+ ok = '"ok": true' in proc.stdout+ print(f" push {i // CHUNK + 1}: {len(chunk)} file(s) -> {'ok' if ok else 'FAILED'}")+ if not ok:+ print((proc.stdout + proc.stderr)[-600:])+ failed.extend(chunk)+ if failed:+ print("REFUSING TO RELEASE: source push failed for", len(failed), "file(s); the repo would not match the zip:")+ for f in failed[:20]:+ print(" ", f)+ return 1 subprocess.run(["adom-wiki", "release", "create", REF, version, "--changelog", notes], check=False) subprocess.run(["adom-wiki", "release", "upload", REF, version, out], check=False)