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.279: Wiki #71 second cause, measured on ConfRoomROG with KiCad's own parser: one malformed symbol makes KiCad refuse a whole .kicad_sym (Symbol Editor lists the library EMPTY) while the bridge's structural reader called it clean. New handlers/kicad_parse_check.py runs kicad-cli sym export svg (read-only) as KiCad's verdict and inventory. kicad_install_symbol and kicad_install_library refuse a file KiCad refuses before writing (kicad_refuses_symbol / kicad_refuses_library); kicad_adom_library_status, kicad_lint_library and the kicad_show_library error carry kicadParser; kicad_adom_library_heal mode:prune drops the symbols KiCad refuses (dryRun names them, original kept as .pruned.bak). Lint's checkmark now says whether KiCad was consulted. Also measured: KiCad's library index is process-wide; a reopened editor frame keeps the old symbol set until kicad.exe fully restarts.
7 files changed
+264−11
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.278+0.9.279
SKILL.md+6−1@@ -100,6 +100,7 @@ If you pass a version that's not installed, the bridge returns a hint listing th | `kicad_send_key` | `send_key` | Single key to an EXACT `hwnd` (e.g. Enter/Escape to a dialog). For modifier CHORDS ("ctrl+s", "alt+3") use Bridge's `desktop_press_key` — don't route chords here. | `hwnd`, `key` | | `kicad_click` | `click` | Send a click at (x, y) in a KiCad window | `hwnd`, `x`, `y` | | `kicad_adom_library_status` | `adom_library_status` | Report whether the Adom shared library is registered | — |+| `kicad_adom_library_heal` | `adom_library_heal` | Reconcile the Adom library: `mode` merge (default) / repoint / prune; `dryRun` previews. From 0.9.279 `prune` drops symbols KiCad refuses | `mode`, `dryRun` | ## The `show_*` family — START HERE for "show me X" @@ -158,7 +159,7 @@ authoritative check and knows about no-connect flags. | `kicad_export_molecule` | One-shot molecule pack: STEP + silk renders + sch/pro discovery (Adom molecule pipeline) | project/part | | `kicad_lint_board` | DRC pre-check on a `.kicad_pcb` (kicad-cli, read-only) | `filePath` | | `kicad_lint_schematic` | ERC pre-check on a `.kicad_sch` (kicad-cli, read-only) | `filePath` |-| `kicad_lint_library` | Lint a symbol/footprint library | `libraryPath` |+| `kicad_lint_library` | Lint a symbol/footprint library. From 0.9.279 `kicadParser` is KiCad's OWN verdict (`kicad-cli sym export svg`, read-only); `ok:false` when KiCad refuses a file the structural check passed | `libraryPath` | | `kicad_run_erc` | Run ERC on a schematic (read-only) | `filePath` | | `kicad_format_upgrade` | Upgrade a file to the current KiCad file format | `filePath` | | `kicad_get_settings` | Read the bridge's user-visible settings (overlay badges, ...) | — |@@ -229,3 +230,7 @@ If you ever find yourself wanting to talk directly to the KiCad bridge process: - `adom-desktop-fusion` — sibling bridge for Fusion 360. - `adom-desktop-direct-api` — the direct API contract (port 47200 + 47201-47209 fallback, discovery file at `~/.adom/direct-api-port`). - Repo: `adom-inc/adom-desktop/plugins/kicad/`++## KiCad's verdict vs the bridge's reader (from 0.9.279)++A `.kicad_sym` the bridge's reader balances can still be one KiCad refuses, and KiCad then lists that library EMPTY in the Symbol Editor (one malformed symbol hides the whole file). `kicadParser` on `kicad_adom_library_status`, `kicad_lint_library`, install results and the `kicad_show_library` error is KiCad's own answer: `ok`, `symbols` (KiCad's inventory), `error` (KiCad's words). `kicad_install_symbol` and `kicad_install_library` refuse a file KiCad refuses (`errorCode kicad_refuses_symbol` / `kicad_refuses_library`) and write nothing. If a library is already poisoned: `kicad_adom_library_heal {"mode":"prune","dryRun":true}` names the culprit(s); without `dryRun` it rebuilds from the symbols KiCad accepts and keeps the original as `.pruned.bak`.
adom_library.py+71−1@@ -427,6 +427,33 @@ def add_symbol(kicad_info: dict, symbol_text: str) -> dict: f"({len(rebuilt)} < {len(nodes)}); the library on disk was " "NOT touched. This is a bridge bug - report it."} + # THE KICAD GATE (wiki #71, 2026-09-03). The balance check above is OUR+ # parser. KiCad's parser refused a library our reader called clean, and+ # one refused symbol hides the WHOLE library in the Symbol Editor. So ask+ # KiCad (kicad-cli sym export svg on a temp copy) before touching the file:+ # first the incoming symbols alone, so the error names the upload and not+ # the user's library; then the merged result. No kicad-cli = no verdict,+ # and we say so instead of pretending it passed.+ from handlers import kicad_parse_check as _kpc+ incoming_v = _kpc.verdict_text(kicad_info, _kpc.wrap_symbol_blocks([b for _n, b in incoming_symbols]))+ if incoming_v.get("available") and not incoming_v.get("ok"):+ return {"success": False, "errorCode": "kicad_refuses_symbol",+ "kicadParser": incoming_v,+ "error": (f"KiCad's own parser refuses the uploaded symbol(s) {added_names}: "+ f"{incoming_v.get('error')}. The library on disk was NOT touched, because one "+ f"refused symbol makes KiCad drop the whole library."),+ "_hint": ("Fix the .kicad_sym so `kicad-cli sym export svg` accepts it (common: bare "+ "`hide yes` inside (effects ...) instead of `(hide yes)`, or a pre-20231120 "+ "shape). kicad_lint_library reports KiCad's verdict as kicadParser.")}+ merged_v = _kpc.verdict_text(kicad_info, new_text)+ if merged_v.get("available") and not merged_v.get("ok"):+ return {"success": False, "errorCode": "kicad_refuses_merged_library",+ "kicadParser": merged_v,+ "error": (f"KiCad's parser accepts the upload alone but refuses the merged library: "+ f"{merged_v.get('error')}. Nothing was written. The existing library is "+ f"probably already refused by KiCad; run kicad_adom_library_status "+ f"(kicadParser) and kicad_adom_library_heal mode:prune to drop the culprit.")}+ # One-generation backup beside the library, then an atomic-ish replace. try: lib_path.with_suffix(".kicad_sym.bak").write_text(existing_text, encoding="utf-8")@@ -438,6 +465,9 @@ def add_symbol(kicad_info: dict, symbol_text: str) -> dict: "success": True, "added": added_names, "lib_path": str(lib_path),+ "kicadParser": ({"available": True, "ok": True, "symbols": merged_v.get("symbols"), "units": merged_v.get("units")}+ if merged_v.get("available") else+ {"available": False, "ok": None, "note": "kicad-cli not found; KiCad's verdict was not obtained"}), } @@ -544,6 +574,46 @@ def heal_split(kicad_info: dict, mode: str = "merge", dry_run: bool = False) -> Never runs on its own: the bridge cannot know which of two libraries is the user's real one. This exists because a human said which.""" rep = library_split_report(kicad_info)+ if mode == "prune":+ # wiki #71 (2026-09-03): KiCad drops a WHOLE library when one symbol in+ # it is malformed, and the bridge's own reader does not notice. prune+ # asks KiCad about each symbol alone and rebuilds the registered file+ # from the ones it accepts; the original is kept as .kicad_sym.pruned.bak.+ from handlers import kicad_parse_check as _kpc+ reg = rep.get("registeredPath")+ if not reg or not Path(reg).exists():+ return {"success": False, "error": "no registered Adom library file to prune", "report": rep}+ text = Path(reg).read_text(encoding="utf-8")+ whole = _kpc.verdict_file(kicad_info, reg)+ if not whole.get("available"):+ return {"success": False, "error": whole.get("error"), "kicadParser": whole}+ blocks, notes = _extract_symbols_tolerant(text)+ if whole.get("ok"):+ return {"success": True, "healed": False, "mode": "prune", "dryRun": dry_run,+ "kicadParser": whole, "dropped": [], "kept": [n for n, _ in blocks],+ "_hint": "KiCad already loads this library; nothing to prune."}+ per = _kpc.refused_symbols(kicad_info, blocks)+ kept = [(n, b) for n, b in blocks if n in per["accepted"]]+ out = {"success": True, "mode": "prune", "dryRun": dry_run, "registeredPath": reg,+ "kicadParserBefore": whole, "dropped": per["refused"], "kept": [n for n, _ in kept],+ "sourceNotes": notes}+ if dry_run:+ out["healed"] = False+ out["_hint"] = (f"Would rebuild {reg} with {len(kept)} symbol(s) KiCad accepts and drop "+ f"{len(per['refused'])} it refuses (names in dropped). Nothing written.")+ return out+ new_text = _kpc.wrap_symbol_blocks([b for _n, b in kept]).replace('"adom-parse-check"', '"adom-desktop"')+ after = _kpc.verdict_text(kicad_info, new_text)+ if not after.get("ok"):+ return {**out, "success": False, "healed": False, "kicadParserAfter": after,+ "error": "the pruned library is STILL refused by KiCad; nothing written (report this with the file)"}+ Path(reg + ".pruned.bak").write_text(text, encoding="utf-8")+ Path(reg).write_text(new_text, encoding="utf-8")+ out.update({"healed": True, "kicadParserAfter": after, "backup": reg + ".pruned.bak",+ "_hint": (f"Rebuilt with {len(kept)} symbol(s) KiCad accepts; dropped {len(per['refused'])} "+ f"(see dropped, with KiCad's reason each). Original saved beside it. KiCad sees "+ f"the change after its next library load.")})+ return out if not rep.get("split"): return {"success": True, "healed": False, "reason": "no split", **rep} registered = Path(rep["registeredPath"])@@ -562,7 +632,7 @@ def heal_split(kicad_info: dict, mode: str = "merge", dry_run: bool = False) -> "rowNowPointsAt": str(orphan).replace("\\", "/"), "_hint": "KiCad reads the repointed file after its next library load (restart)."} if mode != "merge":- return {"success": False, "error": f"unknown mode {mode!r}", "allowed": ["merge", "repoint"]}+ return {"success": False, "error": f"unknown mode {mode!r}", "allowed": ["merge", "repoint", "prune"]} try: orphan_text = orphan.read_text(encoding="utf-8") except Exception as e: # pylint: disable=broad-except
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.278",+ "version": "0.9.279", "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/install_library.py+15@@ -283,6 +283,21 @@ def handle_install_library(kicad_info: dict, args: dict) -> dict: # right verb; do not refuse, because what this verb did is not wrong. wrong_tool = _single_part_hint(source, lib_name) + # wiki #71 (2026-09-03): ask KiCad's own parser before registering a+ # .kicad_sym. A file our reader accepts can still be one KiCad refuses,+ # and KiCad then shows the library empty with every bridge call saying ok.+ if source.is_file() and source.suffix.lower() == ".kicad_sym":+ try:+ from handlers import kicad_parse_check as _kpc+ _v = _kpc.verdict_file(kicad_info, source)+ except Exception: # pylint: disable=broad-except+ _v = {"available": False}+ if _v.get("available") and not _v.get("ok"):+ return {"success": False, "errorCode": "kicad_refuses_library", "kicadParser": _v,+ "error": (f"KiCad's own parser refuses {source.name}: {_v.get('error')}. Not copied, "+ f"not registered: KiCad would list this library empty."),+ "_hint": "Fix the file until `kicad-cli sym export svg` accepts it; kicad_lint_library shows the same verdict as kicadParser."}+ type_config = LIB_TYPE_CONFIG.get(lib_type) if not type_config: return {
handlers/kicad_cli_lint.py+45−7@@ -723,8 +723,33 @@ def handle_lint_library(kicad_info: dict, args: dict) -> dict: if v: versions.add(v) + # wiki #71 (2026-09-03): the parse above is the BRIDGE's reader. KiCad's own+ # parser refused a library this verb had called clean, and KiCad then showed+ # the library empty. Ask KiCad for every .kicad_sym (read-only export) and+ # let its verdict override the checkmark.+ kicad_verdicts = []+ kicad_refused = []+ try:+ from handlers import kicad_parse_check as _kpc+ for f in files_found["kicad_sym"]:+ v = _kpc.verdict_file(kicad_info, f)+ v["path"] = str(f)+ kicad_verdicts.append(v)+ if v.get("available") and not v.get("ok"):+ kicad_refused.append(v)+ except Exception: # pylint: disable=broad-except+ pass+ # Build tiered hint- if sym_file_errors or mod_file_errors:+ if kicad_refused:+ hint = (+ f"❌ KICAD REFUSES {len(kicad_refused)} symbol file(s) that the bridge's structural check passed: "+ + "; ".join(f"{Path(v['path']).name}: {v.get('error')}" for v in kicad_refused[:3])+ + ". KiCad lists such a library EMPTY, and one malformed symbol does that to the whole file. "+ "DO NOT install. Fix the file until `kicad-cli sym export svg` accepts it "+ "(common: bare `hide yes` inside (effects ...) instead of `(hide yes)`)."+ )+ elif sym_file_errors or mod_file_errors: hint = ( f"❌ {len(sym_file_errors)} symbol file(s) + {len(mod_file_errors)} footprint file(s) " f"failed to parse. Inspect _errors[] for specifics. Common causes: file truncated mid-write, "@@ -747,16 +772,29 @@ def handle_lint_library(kicad_info: dict, args: dict) -> dict: f"OK to proceed with install but expect a one-time format change." ) else:- hint = (- f"✅ {symbol_count} symbol(s) + {footprint_count} footprint(s) parsed cleanly, "- f"format version(s) {sorted(versions) if versions else 'unspecified'}. Safe to install via "- f"kicad_install_library / kicad_install_symbol / kicad_install_footprint."- )+ _kp_ok = [v for v in kicad_verdicts if v.get("available") and v.get("ok")]+ _kp_syms = sum(len(v.get("symbols") or []) for v in _kp_ok)+ if kicad_verdicts and all(v.get("available") for v in kicad_verdicts):+ hint = (+ f"✅ KiCad's own parser loads {_kp_syms} symbol(s) from {len(_kp_ok)} file(s) (kicadParser); "+ f"the bridge's structural check counts {symbol_count} symbol(s) + {footprint_count} footprint(s), "+ f"format version(s) {sorted(versions) if versions else 'unspecified'}. Safe to install via "+ f"kicad_install_library / kicad_install_symbol / kicad_install_footprint."+ )+ else:+ hint = (+ f"✅ {symbol_count} symbol(s) + {footprint_count} footprint(s) balanced by the BRIDGE's reader, "+ f"format version(s) {sorted(versions) if versions else 'unspecified'}. KiCad's own parser was "+ f"NOT consulted (kicad-cli not found), so this is a structural check only, not KiCad's verdict."+ ) return { "success": True,+ "ok": not (kicad_refused or sym_file_errors or mod_file_errors),+ "kicadParser": (kicad_verdicts[0] if len(kicad_verdicts) == 1 else kicad_verdicts) if kicad_verdicts else None, "output": f"Lint: {symbol_count} symbol(s), {footprint_count} footprint(s), "- f"{len(sym_file_errors) + len(mod_file_errors)} file error(s)",+ f"{len(sym_file_errors) + len(mod_file_errors)} file error(s)"+ + (f", KiCad refuses {len(kicad_refused)} file(s)" if kicad_refused else ""), "data": { "kind": kind, "source_path": str(path),
handlers/kicad_parse_check.pyadded+125@@ -0,0 +1,125 @@+"""KiCad's OWN verdict on a symbol library, via `kicad-cli sym export svg`.++Why this module exists (wiki #71, 2026-09-03, measured on ConfRoomROG):+the bridge's structural reader (`adom_library`, `kicad_lint_library`) said a+five-symbol Adom.kicad_sym "parsed cleanly", while KiCad itself answered+"Unable to load library" and the Symbol Editor showed the Adom library EMPTY.+Three minimal test symbols carried `hide yes` as bare tokens inside+`(effects ...)`; our reader only balances parentheses, KiCad's parser does+not accept the tokens, and ONE bad symbol makes KiCad drop the WHOLE library.+The two well-formed symbols alone loaded fine.++So: our parser is not KiCad's parser. Anything that writes into a library+KiCad reads must ask KiCad first. `sym export svg` is the one kicad-cli+command that parses a .kicad_sym read-only (the `upgrade` command rewrites+the file) and prints one "Plotting symbol 'NAME' unit N" line per unit it+loaded, so it doubles as a symbol inventory from KiCad's point of view.+"""+from __future__ import annotations++import os+import re+import shutil+import subprocess+import tempfile+import time+from pathlib import Path++_PLOT_RE = re.compile(r"Plotting symbol '([^']+)' unit (\d+)")+++def cli_exe(kicad_info: dict) -> str | None:+ cli = (kicad_info or {}).get("kicad_cli_exe")+ if cli and Path(cli).exists():+ return cli+ base = (kicad_info or {}).get("base_dir")+ if base:+ for cand in (Path(base) / "bin" / "kicad-cli.exe", Path(base) / "bin" / "kicad-cli"):+ if cand.exists():+ return str(cand)+ return None+++def verdict_file(kicad_info: dict, lib_path: str | Path, timeout_s: float = 60.0) -> dict:+ """KiCad's verdict on an existing .kicad_sym.++ Returns {available, ok, symbols[], units, error, exitCode, durationMs}.+ available=False means kicad-cli was not found: no verdict either way, and+ callers must SAY that rather than treat it as a pass."""+ cli = cli_exe(kicad_info)+ if not cli:+ return {"available": False, "ok": None, "symbols": [], "units": 0,+ "error": "kicad-cli not found; KiCad's parser could not be asked"}+ lib_path = str(lib_path)+ if not Path(lib_path).exists():+ return {"available": True, "ok": False, "symbols": [], "units": 0,+ "error": f"file not found: {lib_path}"}+ out_dir = tempfile.mkdtemp(prefix="adom-kicad-parse-")+ t0 = time.monotonic()+ try:+ proc = subprocess.run([cli, "sym", "export", "svg", "-o", out_dir, lib_path],+ capture_output=True, text=True, timeout=timeout_s,+ creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))+ text = (proc.stdout or "") + "\n" + (proc.stderr or "")+ names: list[str] = []+ units = 0+ for m in _PLOT_RE.finditer(text):+ units += 1+ if m.group(1) not in names:+ names.append(m.group(1))+ ok = proc.returncode == 0+ err = ""+ if not ok:+ lines = [ln.strip() for ln in text.splitlines() if ln.strip() and not _PLOT_RE.search(ln)]+ err = " | ".join(lines[:3])[:400] or f"kicad-cli exit {proc.returncode}"+ return {"available": True, "ok": ok, "symbols": names, "units": units,+ "error": err, "exitCode": proc.returncode,+ "durationMs": int((time.monotonic() - t0) * 1000)}+ except subprocess.TimeoutExpired:+ return {"available": True, "ok": False, "symbols": [], "units": 0,+ "error": f"kicad-cli did not answer within {timeout_s:.0f}s"}+ except Exception as e: # pylint: disable=broad-except+ return {"available": True, "ok": False, "symbols": [], "units": 0,+ "error": f"{type(e).__name__}: {e}"[:300]}+ finally:+ shutil.rmtree(out_dir, ignore_errors=True)+++def verdict_text(kicad_info: dict, lib_text: str, timeout_s: float = 60.0) -> dict:+ """Same verdict for library TEXT that has not been written anywhere yet:+ the gate an install runs BEFORE it touches the user's file."""+ fd, tmp = tempfile.mkstemp(suffix=".kicad_sym", prefix="adom-candidate-")+ os.close(fd)+ try:+ Path(tmp).write_text(lib_text, encoding="utf-8")+ return verdict_file(kicad_info, tmp, timeout_s)+ finally:+ try:+ os.unlink(tmp)+ except OSError:+ pass+++def wrap_symbol_blocks(blocks: list[str]) -> str:+ return ('(kicad_symbol_lib (version 20231120) (generator "adom-parse-check")\n'+ + "\n".join(blocks) + "\n)\n")+++def refused_symbols(kicad_info: dict, symbol_blocks: list[tuple[str, str]],+ timeout_s: float = 60.0) -> dict:+ """Which of these (name, block) symbols does KiCad refuse ON THEIR OWN?++ One bad symbol hides a whole library, so this is how a repair finds the+ culprit(s): each block is wrapped as a one-symbol library and parsed alone.+ Returns {available, accepted[], refused{name: error}}."""+ cli = cli_exe(kicad_info)+ if not cli:+ return {"available": False, "accepted": [], "refused": {}}+ accepted, refused = [], {}+ for name, blk in symbol_blocks:+ v = verdict_text(kicad_info, wrap_symbol_blocks([blk]), timeout_s)+ if v.get("ok"):+ accepted.append(name)+ else:+ refused[name] = v.get("error") or "refused"+ return {"available": True, "accepted": accepted, "refused": refused}