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
Update 40 file(s)
4 files changed
+261−170
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.261+0.9.262
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.261",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.261/adom-bridge-kicad-v0.9.261.zip",- "sha256": "940c1a68b0c040f2248611b1b2a709305aae4ab1af05e0c0ec9a3714b6c4cb49",- "size": 1020974,+ "version": "0.9.262",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.262/adom-bridge-kicad-v0.9.262.zip",+ "sha256": "d25790ca7533c7cae362e482b308eca4a07a052a6396318729c94d6d61216978",+ "size": 1024126, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-08-27T23:23:45Z",+ "released_at": "2026-08-31T22:09:46Z", "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png", "languages": [ "Python",
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.261",+ "version": "0.9.262", "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/netlist.py+254−163@@ -1,184 +1,275 @@-"""Handlers for netlist export and connection tracing."""+"""Netlist extraction + connectivity queries via `kicad-cli sch export netlist`. -import json-import os-import subprocess-import tempfile-from pathlib import Path+Four read-only verbs — `extract_netlist`, `trace_net`, `find_connections`,+`analyze_connections` — that give an AI the ability to UNDERSTAND a schematic+rather than only operate the GUI around it. Added 2026-08-28 after a fleet+audit of eight third-party KiCad MCP servers: netlist/connectivity tooling+appeared in seven of the eight and this bridge had none. +Design follows kicad_cli_lint.py deliberately: headless kicad-cli (fast, no+wx, no event loop), file-existence + magic sniff before the subprocess, a+`_hint` on every response telling the calling AI what to do next. -def handle_generate_netlist(kicad_info: dict, args: dict) -> dict:- """Export a netlist from a schematic using kicad-cli."""- if not kicad_info.get("installed"):- return {- "success": False,- "error": "KiCad not installed",- "_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",- }+The one structural difference from the lint module: `sch export netlist+--format kicadxml` writes XML, not JSON, so this module has its own runner+and parses the well-known <export><components/><nets/> shape with+xml.etree. All four verbs share ONE export + parse; the queries are pure+functions over the parsed model, so tracing a second net costs nothing.+""" - file_path = args.get("filePath", "")- if not file_path:- return {"success": False, "error": "No filePath specified"}+from __future__ import annotations - sch_path = Path(file_path)- if not sch_path.exists():- return {"success": False, "error": f"Schematic file not found: {file_path}"}+import subprocess+import tempfile+import os+import xml.etree.ElementTree as ET+from pathlib import Path - cli_exe = kicad_info.get("kicad_cli_exe")- if not cli_exe or not Path(cli_exe).exists():- return {"success": False, "error": "kicad-cli not found"}+from handlers.kicad_cli_lint import _cli_exe_or_error, _file_format_sniff - fmt = args.get("format", "kicadxml")- if fmt not in ("kicadxml", "cadstar"):- return {"success": False, "error": f"Unsupported netlist format: {fmt}"}+_NETLIST_TIMEOUT = 120 - tmp_fd, output_path = tempfile.mkstemp(suffix=".xml" if fmt == "kicadxml" else ".net")- os.close(tmp_fd) +def _export_netlist_xml(cli_exe: str, sch_path: str) -> dict:+ """Run `kicad-cli sch export netlist --format kicadxml` and return+ {ok, xmlPath?|_error}. Caller parses and always unlinks xmlPath."""+ fd, tmp_path = tempfile.mkstemp(suffix=".xml")+ os.close(fd) try:- cmd = [- cli_exe,- "sch", "export", "netlist",- "--format", fmt,- "--output", output_path,- str(sch_path),- ]-- result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)-- output_file = Path(output_path)- if not output_file.exists() or output_file.stat().st_size == 0:- return {"success": False, "error": f"Netlist export produced no output. stderr: {result.stderr}"}-- netlist_text = output_file.read_text(encoding="utf-8")-- return {- "success": True,- "output": f"Netlist exported ({len(netlist_text)} bytes)",- "data": {"netlist": netlist_text, "format": fmt},- }-+ proc = subprocess.run(+ [cli_exe, "sch", "export", "netlist", "--format", "kicadxml",+ "--output", tmp_path, sch_path],+ capture_output=True, text=True, timeout=_NETLIST_TIMEOUT,+ )+ if proc.returncode != 0 or not Path(tmp_path).exists() \+ or Path(tmp_path).stat().st_size == 0:+ Path(tmp_path).unlink(missing_ok=True)+ return {"ok": False,+ "_error": f"kicad-cli netlist export failed (rc={proc.returncode})",+ "stderr": (proc.stderr or "")[-800:],+ "_hint": ("Run kicad_lint_schematic first: a parse error there explains "+ "this failure in one second instead of a cryptic rc here.")}+ return {"ok": True, "xmlPath": tmp_path} except subprocess.TimeoutExpired:- return {"success": False, "error": "Netlist export timed out after 60 seconds"}- except Exception as e:- return {"success": False, "error": f"Netlist export failed: {e}"}- finally:- try:- Path(output_path).unlink(missing_ok=True)- except OSError:- pass+ Path(tmp_path).unlink(missing_ok=True)+ return {"ok": False, "_error": f"kicad-cli timed out after {_NETLIST_TIMEOUT}s"}+ except Exception as e: # pylint: disable=broad-except+ Path(tmp_path).unlink(missing_ok=True)+ return {"ok": False, "_error": f"netlist export failed: {type(e).__name__}: {e}"} -def handle_trace_connection(kicad_info: dict, args: dict) -> dict:- """Trace connections for a component pin through the schematic.+def _parse_netlist_xml(xml_path: str) -> dict:+ """Parse KiCad's kicadxml netlist into {components, nets}. - Uses the schematic parser to find which net a component's pin- belongs to, then lists all other components connected to that net.+ components: {ref: {value, footprint, libsource, description}}+ nets: [{name, code, nodes:[{ref, pin, pinfunction, pintype}]}] """- import sys- sys.path.insert(0, str(Path(__file__).parent.parent))- from parsers.schematic import parse_schematic-- file_path = args.get("filePath", "")- reference = args.get("reference", "")-+ root = ET.parse(xml_path).getroot()+ components = {}+ for comp in root.iter("comp"):+ ref = comp.get("ref") or ""+ libsource = comp.find("libsource")+ components[ref] = {+ "value": (comp.findtext("value") or "").strip(),+ "footprint": (comp.findtext("footprint") or "").strip(),+ "libsource": ((libsource.get("lib") + ":" + libsource.get("part"))+ if libsource is not None+ and libsource.get("lib") and libsource.get("part") else ""),+ "description": (comp.findtext("description")+ or (libsource.get("description") if libsource is not None else "")+ or "").strip(),+ }+ nets = []+ for net in root.iter("net"):+ nodes = [{"ref": n.get("ref") or "", "pin": n.get("pin") or "",+ "pinfunction": n.get("pinfunction") or "",+ "pintype": n.get("pintype") or ""}+ for n in net.findall("node")]+ nets.append({"name": net.get("name") or "", "code": net.get("code") or "",+ "nodes": nodes})+ return {"components": components, "nets": nets}+++def _model_for(kicad_info: dict, args: dict) -> tuple[dict | None, dict | None]:+ """Shared front half of every verb: validate path, locate kicad-cli,+ export, parse. Returns (model, None) or (None, error_response)."""+ file_path = (args.get("filePath") or "").strip() if not file_path:- return {"success": False, "error": "No filePath specified"}- if not reference:- return {"success": False, "error": "No reference specified"}-- sch_path = Path(file_path)- if not sch_path.exists():- return {"success": False, "error": f"Schematic not found: {file_path}"}-+ return None, {"success": False, "error": "filePath is required",+ "_hint": "Absolute Windows path to a .kicad_sch."}+ sniff = _file_format_sniff(Path(file_path), "kicad_sch")+ if not sniff.get("ok"):+ return None, {"success": False, "error": sniff.get("_error", "bad file"),+ "_hint": sniff.get("_hint", "Expected a .kicad_sch schematic.")}+ cli_exe, err = _cli_exe_or_error(kicad_info)+ if err:+ return None, err+ exp = _export_netlist_xml(cli_exe, file_path)+ if not exp.get("ok"):+ return None, {"success": False, **exp}+ xml_path = exp["xmlPath"] try:- data = parse_schematic(sch_path)- except Exception as e:- return {"success": False, "error": f"Failed to parse schematic: {e}"}-- # Find the target component- target = None- for comp in data["components"]:- if comp["reference"] == reference:- target = comp- break-- if not target:- return {- "success": False,- "error": f"Component '{reference}' not found in schematic",- }+ model = _parse_netlist_xml(xml_path)+ except ET.ParseError as e:+ return None, {"success": False,+ "_error": f"could not parse netlist XML: {e}"}+ finally:+ Path(xml_path).unlink(missing_ok=True)+ return model, None - return {- "success": True,- "output": f"Component {reference} ({target['value']}) found",- "data": {- "reference": reference,- "value": target["value"],- "lib_id": target["lib_id"],- "footprint": target["footprint"],- "position": target["position"],- "properties": target["properties"],- },- }---def handle_get_net_connections(kicad_info: dict, args: dict) -> dict:- """Get all components connected to a named net via PCB data.-- Uses the PCB parser to find all pads on a given net, identifying- which footprints (components) are connected.- """- import sys- sys.path.insert(0, str(Path(__file__).parent.parent))- from parsers.pcb import parse_pcb - file_path = args.get("filePath", "")- net_name = args.get("netName", "")+def handle_extract_netlist(kicad_info: dict, args: dict) -> dict:+ """Full netlist: every component and every net with its nodes. - if not file_path:- return {"success": False, "error": "No filePath specified"}+ Args: filePath (.kicad_sch). Optional includeNodes:false to get net+ names/sizes only (large schematics; the default full form can be big).+ """+ # Raw-export mode, kept from the never-registered 2025 netlist module this+ # one replaced: rawFormat exports the netlist FILE in another tool's format+ # (spice, orcadpcb2, kicadsexpr, cadstar, allegro) and returns the text,+ # instead of the parsed model. kicad_export_* is for fab outputs; this is+ # for feeding OTHER tools a netlist.+ raw_fmt = (args.get("rawFormat") or "").strip()+ if raw_fmt:+ allowed = {"kicadsexpr", "kicadxml", "cadstar", "orcadpcb2", "spice", "spicemodel", "allegro", "pads"}+ if raw_fmt not in allowed:+ return {"success": False, "error": f"unsupported rawFormat: {raw_fmt}",+ "allowed": sorted(allowed)}+ file_path = (args.get("filePath") or "").strip()+ sniff = _file_format_sniff(Path(file_path), "kicad_sch")+ if not sniff.get("ok"):+ return {"success": False, "error": sniff.get("_error", "bad file")}+ cli_exe, cli_err = _cli_exe_or_error(kicad_info)+ if cli_err:+ return cli_err+ fd, tmp_path = tempfile.mkstemp(suffix="." + raw_fmt)+ os.close(fd)+ try:+ proc = subprocess.run(+ [cli_exe, "sch", "export", "netlist", "--format", raw_fmt,+ "--output", tmp_path, file_path],+ capture_output=True, text=True, timeout=_NETLIST_TIMEOUT)+ if proc.returncode != 0:+ return {"success": False,+ "error": f"raw netlist export failed (rc={proc.returncode})",+ "stderr": (proc.stderr or "")[-800:]}+ text = Path(tmp_path).read_text(encoding="utf-8", errors="replace")+ capped = len(text) > 200_000+ return {"success": True, "rawFormat": raw_fmt,+ "bytes": len(text), "truncated": capped,+ "netlist": text[:200_000],+ "_hint": "Raw netlist text in the requested format; parsed queries ignore rawFormat."}+ finally:+ Path(tmp_path).unlink(missing_ok=True)++ model, err = _model_for(kicad_info, args)+ if err:+ return err+ nets = model["nets"]+ include_nodes = args.get("includeNodes", True)+ out_nets = nets if include_nodes else [+ {"name": n["name"], "code": n["code"], "nodeCount": len(n["nodes"])}+ for n in nets]+ return {"success": True,+ "componentCount": len(model["components"]),+ "netCount": len(nets),+ "components": model["components"],+ "nets": out_nets,+ "_hint": ("Read-only. For one net use kicad_trace_net; for one component "+ "use kicad_find_connections — both re-derive from the same export.")}+++def handle_trace_net(kicad_info: dict, args: dict) -> dict:+ """Every node on one named net. Args: filePath, netName.++ Matches exact first, then case-insensitive, then substring — and on a+ miss returns the candidates rather than an empty success, because a+ typo'd net name that 'succeeds' with zero nodes reads like a broken+ schematic when it is a broken QUERY."""+ net_name = (args.get("netName") or "").strip() if not net_name:- return {"success": False, "error": "No netName specified"}-- pcb_path = Path(file_path)- if not pcb_path.exists():- return {"success": False, "error": f"PCB file not found: {file_path}"}-- try:- data = parse_pcb(pcb_path)- except Exception as e:- return {"success": False, "error": f"Failed to parse PCB: {e}"}-- # Find net number- net_number = None- for net in data["nets"]:- if net["name"] == net_name:- net_number = net["number"]- break-- if net_number is None:- return {"success": False, "error": f"Net '{net_name}' not found"}-- # Find all footprints with pads on this net- connections = []- for fp in data["footprints"]:- for pad in fp.get("pads", []):- if pad.get("net_number") == net_number:- connections.append({- "reference": fp["reference"],- "value": fp["value"],- "footprint": fp["footprint"],- "pad_number": pad.get("number", ""),- "pad_type": pad.get("type", ""),- })-- return {- "success": True,- "output": f"Net '{net_name}': {len(connections)} connection(s)",- "data": {- "net_name": net_name,- "net_number": net_number,- "connections": connections,- },- }+ return {"success": False, "error": "netName is required",+ "_hint": "kicad_extract_netlist {includeNodes:false} lists every net name."}+ model, err = _model_for(kicad_info, args)+ if err:+ return err+ nets = model["nets"]+ exact = [n for n in nets if n["name"] == net_name]+ if not exact:+ exact = [n for n in nets if n["name"].lower() == net_name.lower()]+ if exact:+ n = exact[0]+ return {"success": True, "net": n["name"], "code": n["code"],+ "nodeCount": len(n["nodes"]), "nodes": n["nodes"],+ "_hint": "Read-only trace from the schematic netlist."}+ near = [n["name"] for n in nets if net_name.lower() in n["name"].lower()][:12]+ return {"success": False, "error": f"net not found: {net_name}",+ "candidates": near,+ "netCount": len(nets),+ "_hint": ("No net has that exact name. `candidates` holds substring "+ "matches; KiCad auto-names unlabeled nets like '/Net-(R1-Pad1)'.")}+++def handle_find_connections(kicad_info: dict, args: dict) -> dict:+ """Everything connected to one component: per-pin net + peer pins.++ Args: filePath, reference (e.g. "U1")."""+ ref = (args.get("reference") or "").strip()+ if not ref:+ return {"success": False, "error": "reference is required",+ "_hint": "A component reference like U1, R5, J2."}+ model, err = _model_for(kicad_info, args)+ if err:+ return err+ if ref not in model["components"]:+ near = [r for r in model["components"] if r.lower() == ref.lower()] or \+ [r for r in sorted(model["components"]) if r.lower().startswith(ref[:1].lower())][:12]+ return {"success": False, "error": f"component not found: {ref}",+ "candidates": near,+ "_hint": "kicad_extract_netlist lists every reference."}+ pins = []+ for net in model["nets"]:+ for node in net["nodes"]:+ if node["ref"] != ref:+ continue+ peers = [{"ref": p["ref"], "pin": p["pin"], "pinfunction": p["pinfunction"]}+ for p in net["nodes"] if p["ref"] != ref]+ pins.append({"pin": node["pin"], "pinfunction": node["pinfunction"],+ "pintype": node["pintype"], "net": net["name"],+ "connectedTo": peers})+ pins.sort(key=lambda p: (len(p["pin"]), p["pin"]))+ return {"success": True, "reference": ref,+ "component": model["components"][ref],+ "pinCount": len(pins), "pins": pins,+ "_hint": "Read-only. Pins absent here are not in any net (check analyze_connections.unconnected)."}+++def handle_analyze_connections(kicad_info: dict, args: dict) -> dict:+ """Connectivity overview: totals, suspected-unconnected pins, power nets,+ highest-fanout nets and components. Args: filePath."""+ model, err = _model_for(kicad_info, args)+ if err:+ return err+ comps, nets = model["components"], model["nets"]+ single = [{"net": n["name"], "node": n["nodes"][0]}+ for n in nets if len(n["nodes"]) == 1]+ power = [{"net": n["name"], "nodeCount": len(n["nodes"])} for n in nets+ if any(p["pintype"] in ("power_in", "power_out") for p in n["nodes"])]+ fanout_nets = sorted(({"net": n["name"], "nodeCount": len(n["nodes"])} for n in nets),+ key=lambda x: -x["nodeCount"])[:10]+ per_comp: dict[str, int] = {}+ for n in nets:+ for node in n["nodes"]:+ per_comp[node["ref"]] = per_comp.get(node["ref"], 0) + 1+ fanout_comps = sorted(({"ref": r, "connectedPins": c} for r, c in per_comp.items()),+ key=lambda x: -x["connectedPins"])[:10]+ return {"success": True,+ "componentCount": len(comps), "netCount": len(nets),+ "suspectedUnconnected": single,+ "powerNets": sorted(power, key=lambda x: -x["nodeCount"])[:10],+ "highestFanoutNets": fanout_nets,+ "mostConnectedComponents": fanout_comps,+ "_hint": ("Heuristic overview from the netlist alone. suspectedUnconnected "+ "lists nets with a single node — usually a missed wire, sometimes "+ "an intentional test point or no-connect; kicad_run_erc is the "+ "authoritative check and knows about no-connect flags.")}