← Commit history

Update 40 file(s)

John Lauer ·43e42fde62 ·1mo ago ·parent d4f01ab
4 files changed +261−170
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.261\ No newline at end of file+0.9.262\ No newline at end of file
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.")}