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.283: Wiki #73: kicad_list_design_rules and kicad_set_design_rules consume adom/pcb-design-rules at call time (fab, layers, process; layers defaults to the board's copper count; stages <stem>.kicad_dru with backup; runs DRC with it). Wiki #62: kicad_list_symbols (bridge inventory + kicadParser), and kicad_show_library resolves an MPN/file name to the real symbol name (exact, case-insensitive, unique prefix) reporting aliasedFrom/aliasHow. Navigation trace no longer implies the filter was not applied when wx answers WM_GETTEXT with ''. kicad-bridge-dev skill records the #71 lessons.
2 files changed
+283
handlers/design_rules.pyadded+209@@ -0,0 +1,209 @@+"""Design rules by fab (wiki adom/kicad-bridge#73).++The canonical per-vendor rules live on the wiki page adom/pcb-design-rules,+which renders one KiCad custom-rules file per (vendor, process, layer count):++ https://wiki.adom.inc/adom/pcb-design-rules/render/kicad/<slug>[-<process>]-<layers>L.kicad_dru++and a machine-readable index at .../render/rules/index.json. A rule change is+therefore a wiki publish, not a bridge release: this module never carries rule+values, it only resolves (fab, layers) to the page's file, stages it next to+the board as <board stem>.kicad_dru (the name KiCad loads automatically), and+optionally runs DRC so the caller sees the fab's limits applied.++InstaPCB is the house baseline and is listed first.+"""++from __future__ import annotations++import json+import shutil+import time+import urllib.request+from pathlib import Path++PAGE = "https://wiki.adom.inc/adom/pcb-design-rules/render/"+INDEX_URL = PAGE + "rules/index.json"+HOUSE_SLUG = "adom-instapcb"+_TIMEOUT = 20+++def _fetch(url: str) -> bytes:+ req = urllib.request.Request(url, headers={"User-Agent": "adom-bridge-kicad design_rules"})+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as r: # nosec - fixed wiki host+ return r.read()+++def _index() -> list[dict]:+ data = json.loads(_fetch(INDEX_URL).decode("utf-8"))+ vendors = data.get("vendors") or []+ # House baseline first, then the page's order.+ vendors.sort(key=lambda v: 0 if v.get("vendorSlug") == HOUSE_SLUG else 1)+ return vendors+++def _norm(s: str) -> str:+ return "".join(ch for ch in (s or "").lower() if ch.isalnum())+++def _match_fab(vendors: list[dict], fab: str) -> list[dict]:+ """Accept the slug, the display name, or a distinctive substring of either."""+ want = _norm(fab)+ if not want:+ return []+ exact = [v for v in vendors if _norm(v.get("vendorSlug")) == want or _norm(v.get("vendor")) == want]+ if exact:+ return exact+ return [v for v in vendors if want in _norm(v.get("vendorSlug")) or want in _norm(v.get("vendor"))]+++def handle_list_design_rules(kicad_info: dict, args: dict) -> dict:+ """List the fabs, processes and layer counts the wiki page carries."""+ try:+ vendors = _index()+ except Exception as e: # pylint: disable=broad-except+ return {"success": False, "error": f"Could not read {INDEX_URL}: {e}",+ "_hint": "The rules page is on wiki.adom.inc; check the desktop's network, then retry. Nothing is cached locally on purpose so a rule change is a wiki publish."}+ fab = args.get("fab")+ if fab:+ vendors = _match_fab(vendors, fab)+ rows = []+ for v in vendors:+ f = v.get("files") or {}+ rows.append({+ "fab": v.get("vendor"), "slug": v.get("vendorSlug"), "process": v.get("process"),+ "layers": v.get("layerCount"),+ "kicadDruUrl": PAGE + f["kicadDru"] if f.get("kicadDru") else None,+ "boardSetupUrl": PAGE + f["kicadBoardSetup"] if f.get("kicadBoardSetup") else None,+ "house": v.get("vendorSlug") == HOUSE_SLUG,+ })+ return {"success": True, "count": len(rows), "rules": rows, "indexUrl": INDEX_URL,+ "_hint": ("Pick a row and call kicad_set_design_rules {\"fab\": <slug>, \"layers\": <n>, \"boardPath\": <.kicad_pcb>}. "+ "adom-instapcb is the house baseline. Values come from the wiki page at call time, so do not copy them into code.")}+++def handle_set_design_rules(kicad_info: dict, args: dict) -> dict:+ """Stage a fab's KiCad rules file next to a board and (by default) run DRC.++ Args: fab (slug or name; default adom-instapcb), layers (int; default from+ the board's copper layer count when readable, else 2), process (optional),+ boardPath (.kicad_pcb, required), druPath (local override instead of the+ wiki file), runDrc (default true), keepExisting (default false: an existing+ <stem>.kicad_dru is backed up as .kicad_dru.bak-<timestamp> and replaced).+ """+ board = (args.get("boardPath") or args.get("filePath") or "").strip()+ if not board:+ return {"success": False, "error": "boardPath is required",+ "_hint": "Pass {\"boardPath\": \"<absolute path to the .kicad_pcb>\"}. The rules file is staged next to it as <stem>.kicad_dru, which KiCad loads automatically."}+ board_path = Path(board)+ if not board_path.exists() or board_path.suffix.lower() != ".kicad_pcb":+ return {"success": False, "error": f"Board not found or not a .kicad_pcb: {board}",+ "_hint": "The path must exist on the Windows host. Use push_file first if the board lives in the container."}++ dru_target = board_path.with_suffix(".kicad_dru")+ source: dict = {}+ override = (args.get("druPath") or "").strip()+ if override:+ src = Path(override)+ if not src.exists():+ return {"success": False, "error": f"druPath not found: {override}"}+ body = src.read_bytes()+ source = {"kind": "local", "path": str(src)}+ else:+ try:+ vendors = _index()+ except Exception as e: # pylint: disable=broad-except+ return {"success": False, "error": f"Could not read {INDEX_URL}: {e}",+ "_hint": "Pass druPath with a local .kicad_dru to work offline, or retry when wiki.adom.inc is reachable."}+ fab = args.get("fab") or HOUSE_SLUG+ cands = _match_fab(vendors, fab)+ if not cands:+ return {"success": False, "error": f"No fab matches {fab!r}", "errorCode": "fab_not_found",+ "knownFabs": sorted({v.get("vendorSlug") for v in vendors}),+ "_hint": "Use one of knownFabs (kicad_list_design_rules shows names, processes and layer counts)."}+ layers = args.get("layers")+ if layers is None:+ layers = _board_copper_layers(board_path) or 2+ try:+ layers = int(layers)+ except (TypeError, ValueError):+ return {"success": False, "error": f"layers must be an integer, got {layers!r}"}+ process = args.get("process")+ pick = [v for v in cands if int(v.get("layerCount") or 0) == layers+ and (not process or _norm(v.get("process")) == _norm(process))]+ if not pick:+ return {"success": False, "errorCode": "no_rules_for_layers",+ "error": f"{cands[0].get('vendor')} has no {layers}-layer{(' ' + process) if process else ''} rules on the page",+ "available": [{"process": v.get("process"), "layers": v.get("layerCount")} for v in cands],+ "_hint": "Pick one of `available`, or pass druPath with your own file."}+ v = pick[0]+ rel = (v.get("files") or {}).get("kicadDru")+ if not rel:+ return {"success": False, "error": f"The page lists no KiCad file for {v.get('vendor')} {layers}L"}+ url = PAGE + rel+ try:+ body = _fetch(url)+ except Exception as e: # pylint: disable=broad-except+ return {"success": False, "error": f"Could not download {url}: {e}"}+ source = {"kind": "wiki", "url": url, "fab": v.get("vendor"), "slug": v.get("vendorSlug"),+ "process": v.get("process"), "layers": layers}++ text = body.decode("utf-8", errors="replace")+ if "(version" not in text[:200] or "(rule" not in text:+ return {"success": False, "errorCode": "not_a_kicad_dru",+ "error": "The rules file does not look like a KiCad custom-rules file ('(version N)' header and '(rule ...)' blocks)",+ "source": source, "head": text[:160]}++ backup = None+ if dru_target.exists():+ if args.get("keepExisting"):+ return {"success": False, "errorCode": "dru_exists",+ "error": f"{dru_target.name} already exists and keepExisting is set",+ "existing": str(dru_target), "source": source,+ "_hint": "Drop keepExisting to replace it (the old file is backed up next to it)."}+ backup = dru_target.with_name(dru_target.name + f".bak-{time.strftime('%Y%m%d-%H%M%S')}")+ shutil.copy2(dru_target, backup)+ dru_target.write_bytes(body)++ rule_names = [ln.split('"')[1] for ln in text.splitlines() if ln.strip().startswith("(rule ") and '"' in ln]+ out = {"success": True, "druPath": str(dru_target), "backup": str(backup) if backup else None,+ "source": source, "ruleCount": len(rule_names), "rules": rule_names[:40]}++ if args.get("runDrc", True):+ try:+ from handlers.kicad_cli_lint import handle_lint_board+ drc = handle_lint_board(kicad_info, {"filePath": str(board_path), "schematicParity": bool(args.get("schematicParity", False))})+ except Exception as e: # pylint: disable=broad-except+ drc = {"success": False, "error": f"DRC did not run: {e}"}+ out["drc"] = drc+ viol = ((drc.get("drc") or {}).get("summary") or {}) if isinstance(drc, dict) else {}+ out["_hint"] = (f"Rules staged as {dru_target.name} (KiCad loads <board stem>.kicad_dru automatically; an open PCB editor picks it up on the next DRC run). "+ f"DRC ran with them: {json.dumps(viol) if viol else (drc.get('_hint') or drc.get('error') or 'see drc')}"+ ). strip()+ else:+ out["_hint"] = f"Rules staged as {dru_target.name}. Run kicad_lint_board or kicad_run_drc on the board to apply them."+ return out+++def _board_copper_layers(board_path: Path) -> int | None:+ """Count copper layers from the board's (layers ...) block, cheaply."""+ try:+ head = board_path.read_text(encoding="utf-8", errors="replace")[:20000]+ i = head.find("(layers")+ if i < 0:+ return None+ depth = 0+ j = i+ while j < len(head):+ if head[j] == "(":+ depth += 1+ elif head[j] == ")":+ depth -= 1+ if depth == 0:+ break+ j += 1+ block = head[i:j]+ n = sum(1 for ln in block.splitlines() if '.Cu"' in ln and ("signal" in ln or "power" in ln or "mixed" in ln or "jumper" in ln))+ return n or None+ except Exception: # pylint: disable=broad-except+ return None
handlers/list_symbols.pyadded+74@@ -0,0 +1,74 @@+"""kicad_list_symbols: the names in a registered symbol library (wiki #62 finding 2).++A caller who only knows the MPN (the file name) has no way to learn the symbol+name inside the file without parsing s-expressions. This verb answers with the+bridge's own inventory AND KiCad's (kicadParser), because the two disagreed+once (wiki #71): a library the bridge counted fine was one KiCad refused.+"""++from __future__ import annotations++from pathlib import Path+++def handle_list_symbols(kicad_info: dict, args: dict) -> dict:+ 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."}+ lib_name = (args.get("libraryName") or args.get("library") or "Adom").strip()+ explicit = (args.get("libraryPath") or "").strip()++ lib_path: Path | None = None+ how = ""+ if explicit:+ lib_path, how = Path(explicit), "libraryPath argument"+ elif lib_name.lower() == "adom":+ try:+ from adom_library import _get_paths+ got = _get_paths(kicad_info)+ if got:+ lib_path, how = Path(got[1]), "sym-lib-table row 'Adom' (the file KiCad reads)" # _get_paths -> (table, lib)+ except Exception as e: # pylint: disable=broad-except+ return {"success": False, "error": f"Could not resolve the Adom library: {e}"}+ else:+ try:+ from lib_table import LibTable+ from adom_library import _expand_kicad_uri+ cfg = kicad_info.get("config_dir")+ table = LibTable.parse_file(str(Path(cfg) / "sym-lib-table")) if cfg else None+ row = next((e for e in (table.entries if table else []) if e.name == lib_name), None)+ if row is None:+ names = [e.name for e in (table.entries if table else [])]+ return {"success": False, "errorCode": "library_not_registered",+ "error": f"No '{lib_name}' row in sym-lib-table", "registeredLibraries": names[:60],+ "_hint": "Pick one of registeredLibraries, or pass libraryPath for a file that is not registered."}+ p = _expand_kicad_uri(row.uri, kicad_info)+ lib_path, how = (Path(p) if p else None), f"sym-lib-table row '{lib_name}' ({row.uri})"+ except Exception as e: # pylint: disable=broad-except+ return {"success": False, "error": f"Could not read sym-lib-table: {e}"}++ if not lib_path or not lib_path.exists():+ return {"success": False, "errorCode": "library_file_missing", "error": f"Library file not found: {lib_path}",+ "resolvedVia": how}++ from adom_library import list_symbols+ names = list_symbols(lib_path) or []+ out = {"success": True, "libraryName": lib_name, "libraryPath": str(lib_path), "resolvedVia": how,+ "symbolCount": len(names), "symbols": names}++ try:+ from handlers import kicad_parse_check as _kpc+ v = _kpc.verdict_file(kicad_info, str(lib_path))+ out["kicadParser"] = v+ if v.get("available") and not v.get("ok"):+ out["_hint"] = (f"KiCad REFUSES this library ('{v.get('error')}'); the Symbol Editor shows it empty regardless of the "+ f"{len(names)} name(s) above. kicad_adom_library_heal {{\"mode\":\"prune\"}} names the refused symbol(s).")+ elif v.get("available"):+ ks = set(v.get("symbols") or [])+ missing = [n for n in names if n not in ks]+ if missing:+ out["_hint"] = f"KiCad lists {len(ks)} symbol(s) but not {missing[:5]}; trust kicadParser.symbols for what the editor can show."+ except Exception: # pylint: disable=broad-except+ pass+ out.setdefault("_hint", "Use one of `symbols` as symbolName for kicad_show_library; the file name is often an MPN and not the symbol name (wiki #62).")+ return out