← 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.

John Lauer ·96aab8ec73 ·1mo ago ·parent d1c2d11
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