← Commit history

Update 40 file(s)

John Lauer ·31ec3140d7 ·1mo ago ·parent 764431f
4 files changed +267−7
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.262\ No newline at end of file+0.9.263\ 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.262",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.262/adom-bridge-kicad-v0.9.262.zip",-  "sha256": "d25790ca7533c7cae362e482b308eca4a07a052a6396318729c94d6d61216978",-  "size": 1024126,+  "version": "0.9.263",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.263/adom-bridge-kicad-v0.9.263.zip",+  "sha256": "a02a7bab619328c8118a010a11a734c73f740a7c7139f0718cb9efe75b43b37c",+  "size": 1029604,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-08-31T22:09:46Z",+  "released_at": "2026-09-01T01:36:06Z",   "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.262",+  "version": "0.9.263",   "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/pcm.pyadded+260
@@ -0,0 +1,260 @@+"""KiCad Plugin and Content Manager (PCM) automation: install packages from a+PCM repository WITHOUT the dialog, the way PCM itself does it on disk.++Three verbs — `pcm_install`, `pcm_list`, `pcm_uninstall`. Added 2026-09-01+after proving that a wiki page (adom/kicad-pcm) is a valid PCM repository:+KiCad read it fine, but PCM has no CLI and its dialog is an owned window the+bridge's input verbs cannot target, so "click Install" could not be automated+through the GUI. It can be automated on disk, because PCM is only files:++  1. extract  <zip>/<folder>/<contents>  ->  <3rdparty>/<folder>/<pkgid>/<contents>+     (kicad/pcm/pcm_task_manager.cpp extract(): folder must be one of+     plugins/footprints/symbols/3dmodels/resources/colors; identifier dots+     become underscores on disk so Python packages import flat)+  2. record   <config>/installed_packages.json  {"packages":[entry...]}+     (kicad/pcm/pcm_data.cpp to_json(PCM_INSTALLATION_ENTRY): package,+     current_version, repository_id, repository_name, install_timestamp, pinned)+  3. NOTHING ELSE. KiCad registers the libraries itself: symbol_lib_table.cpp+     PCM_SYM_LIB_TRAVERSER and fp_lib_table.cpp PCM_FP_LIB_TRAVERSER scan+     $KICADn_3RD_PARTY/symbols/<pkgid>/*.kicad_sym and footprints/<pkgid>/*.pretty+     when the GLOBAL TABLE LOADS and insert rows named <lib_prefix><name>+     (default prefix "PCM_"), as long as pcm.lib_auto_add is on (the default).+     That scan happens at editor load, which is why a running editor needs a+     restart to see a new package - reported here as restartNeeded, never done.++repository_id is sha256(repository url)[:16] (pcm.cpp SetRepositoryList), so+KiCad's own PCM dialog shows a bridge-installed package as Installed, offers+updates for it, and can uninstall it. The bridge is an automation of PCM, not+a parallel mechanism.+"""++from __future__ import annotations++import hashlib+import json+import os+import re+import shutil+import tempfile+import time+import urllib.request+import zipfile+from pathlib import Path++_FOLDERS = {"plugins", "footprints", "symbols", "3dmodels", "resources", "colors"}+_MAX_ZIP = 512 * 1024 * 1024+_UA = "adom-kicad-bridge pcm"+++def _paths(kicad_info: dict) -> tuple[Path | None, Path | None, str | None]:+    """(3rdparty dir, installed_packages.json, KiCad major version) or Nones."""+    user_dir = kicad_info.get("user_dir")+    config_dir = kicad_info.get("config_dir")+    ver = str(kicad_info.get("version") or "")+    if not user_dir or not config_dir:+        return None, None, None+    return Path(user_dir) / "3rdparty", Path(config_dir) / "installed_packages.json", ver+++def _fetch(url: str, timeout: int = 120) -> bytes:+    req = urllib.request.Request(url, headers={"User-Agent": _UA})+    with urllib.request.urlopen(req, timeout=timeout) as r:+        return r.read()+++def _read_installed(p: Path) -> dict:+    try:+        d = json.loads(p.read_text(encoding="utf-8"))+        if isinstance(d, dict) and isinstance(d.get("packages"), list):+            return d+    except (OSError, ValueError):+        pass+    return {"packages": []}+++def _write_installed(p: Path, d: dict) -> None:+    p.parent.mkdir(parents=True, exist_ok=True)+    tmp = p.with_suffix(".json.tmp")+    tmp.write_text(json.dumps(d, indent=4) + "\n", encoding="utf-8")+    os.replace(tmp, p)+++def _pkgdir(identifier: str) -> str:+    return identifier.replace(".", "_")+++def _extract(zip_path: Path, third: Path, identifier: str) -> dict:+    """PCM's extract(): <folder>/<contents> -> <3rdparty>/<folder>/<pkgid>/<contents>.+    Zip-slip guarded; entries outside the known folders are ignored (as PCM does)."""+    pkgid = _pkgdir(identifier)+    written: list[str] = []+    libs = {"symbols": [], "footprints": [], "3dmodels": []}+    with zipfile.ZipFile(zip_path) as zf:+        total = sum(i.file_size for i in zf.infolist())+        if total > _MAX_ZIP:+            raise ValueError(f"package unpacks to {total} bytes, over the {_MAX_ZIP} cap")+        for info in zf.infolist():+            name = info.filename.replace("\\", "/")+            if not name or name.endswith("/"):+                continue+            parts = [p for p in name.split("/") if p not in ("", ".")]+            if any(p == ".." for p in parts) or re.match(r"^[A-Za-z]:", name) or name.startswith("/"):+                raise ValueError(f"unsafe zip entry: {info.filename}")+            if len(parts) < 2 or parts[0] not in _FOLDERS:+                continue+            target = (third / parts[0] / pkgid).joinpath(*parts[1:])+            if not str(target.resolve()).startswith(str((third / parts[0] / pkgid).resolve())):+                raise ValueError(f"unsafe zip entry: {info.filename}")+            target.parent.mkdir(parents=True, exist_ok=True)+            with zf.open(info) as src, open(target, "wb") as dst:+                shutil.copyfileobj(src, dst)+            written.append(str(target))+            if parts[0] == "symbols" and name.endswith(".kicad_sym"):+                libs["symbols"].append(parts[-1][:-10])+            elif parts[0] == "footprints" and len(parts) >= 3 and parts[1].endswith(".pretty"):+                if parts[1][:-7] not in libs["footprints"]:+                    libs["footprints"].append(parts[1][:-7])+            elif parts[0] == "3dmodels" and len(parts) >= 3 and parts[1] not in libs["3dmodels"]:+                libs["3dmodels"].append(parts[1])+    return {"files": len(written), "libraries": libs, "packageDir": pkgid}+++def handle_pcm_install(kicad_info: dict, args: dict) -> dict:+    """Install a package from a PCM repository, on disk, exactly as PCM would.++    Args:+      repositoryUrl  the repository.json URL (e.g. the adom/kicad-pcm one)+      identifier     package identifier (e.g. inc.adom.kicad.basic-parts)+      version        optional; default = newest listed+      libPrefix      optional; what KiCad will prefix nicknames with (default PCM_),+                     only used to REPORT the nicknames the scan will create+    """+    repo_url = (args.get("repositoryUrl") or "").strip()+    ident = (args.get("identifier") or "").strip()+    want = (args.get("version") or "").strip()+    prefix = args.get("libPrefix") or "PCM_"+    if not repo_url or not ident:+        return {"success": False, "error": "repositoryUrl and identifier are required",+                "_hint": "repositoryUrl is the repository.json URL; identifier like inc.adom.kicad.basic-parts."}+    third, installed_p, ver = _paths(kicad_info)+    if not third:+        return {"success": False, "error": "KiCad user/config directories unknown",+                "_hint": "kicad_readiness first; the bridge needs a detected KiCad to know 3rdparty and settings paths."}+    try:+        repo = json.loads(_fetch(repo_url))+        pkgs_url = repo["packages"]["url"]+        pkgs_raw = _fetch(pkgs_url)+        if hashlib.sha256(pkgs_raw).hexdigest() != repo["packages"].get("sha256"):+            return {"success": False, "error": "packages.json hash does not match repository.json",+                    "_hint": "The repository is mid-update or corrupted; retry in a minute."}+        packages = json.loads(pkgs_raw)["packages"]+    except Exception as e:  # pylint: disable=broad-except+        return {"success": False, "error": f"could not read repository: {type(e).__name__}: {e}"}+    pkg = next((p for p in packages if p.get("identifier") == ident), None)+    if not pkg:+        return {"success": False, "error": f"package not in repository: {ident}",+                "available": [p.get("identifier") for p in packages][:20]}+    versions = pkg.get("versions") or []+    if want:+        vsel = next((v for v in versions if v.get("version") == want), None)+        if not vsel:+            return {"success": False, "error": f"version {want} not offered",+                    "available": [v.get("version") for v in versions]}+    else:+        vsel = versions[-1] if versions else None+    if not vsel or not vsel.get("download_url"):+        return {"success": False, "error": "package has no downloadable version"}++    installed = _read_installed(installed_p)+    prior = next((e for e in installed["packages"] if e.get("package", {}).get("identifier") == ident), None)+    if prior and prior.get("current_version") == vsel["version"] and not args.get("force"):+        return {"success": True, "alreadyInstalled": True, "identifier": ident,+                "version": vsel["version"],+                "_hint": "Already installed at this version; pass force:true to reinstall."}++    fd, tmp = tempfile.mkstemp(suffix=".zip")+    os.close(fd)+    try:+        blob = _fetch(vsel["download_url"], timeout=600)+        got = hashlib.sha256(blob).hexdigest()+        if vsel.get("download_sha256") and got != vsel["download_sha256"]:+            return {"success": False, "error": "download sha256 mismatch",+                    "expected": vsel["download_sha256"], "got": got,+                    "_hint": "Refusing to install a package whose bytes do not match the repository's digest."}+        Path(tmp).write_bytes(blob)+        # replace-on-update: remove the previous extraction first, as PCM does+        for folder in _FOLDERS:+            old = third / folder / _pkgdir(ident)+            if old.exists():+                shutil.rmtree(old, ignore_errors=True)+        ext = _extract(Path(tmp), third, ident)+    except Exception as e:  # pylint: disable=broad-except+        return {"success": False, "error": f"install failed: {type(e).__name__}: {e}"}+    finally:+        Path(tmp).unlink(missing_ok=True)++    entry = {"package": {k: v for k, v in pkg.items() if k != "versions"} | {"versions": [vsel]},+             "current_version": vsel["version"],+             "repository_id": hashlib.sha256(repo_url.encode()).hexdigest()[:16],+             "repository_name": repo.get("name", ""),+             "install_timestamp": int(time.time()),+             "pinned": False}+    installed["packages"] = [e for e in installed["packages"]+                             if e.get("package", {}).get("identifier") != ident] + [entry]+    _write_installed(installed_p, installed)++    running = bool(kicad_info.get("running") or kicad_info.get("runningInstances"))+    nick = lambda names: [f"{prefix}{n}" for n in names]+    return {"success": True, "identifier": ident, "version": vsel["version"],+            "filesInstalled": ext["files"], "installedTo": str(third / "*" / ext["packageDir"]),+            "symbolLibraries": nick(ext["libraries"]["symbols"]),+            "footprintLibraries": nick(ext["libraries"]["footprints"]),+            "modelSets": ext["libraries"]["3dmodels"],+            "kicadRunning": running, "restartNeeded": running,+            "_hint": ("Installed exactly as PCM would; KiCad's PCM dialog will list it as Installed. "+                      "Library nicknames above are created by KiCad's own PCM scan when the global "+                      "library table next LOADS - so a KiCad that is already running must be restarted "+                      "(never done for you) before the libraries appear.")}+++def handle_pcm_list(kicad_info: dict, args: dict) -> dict:+    """Packages KiCad's PCM considers installed on this machine (read-only)."""+    third, installed_p, ver = _paths(kicad_info)+    if not installed_p:+        return {"success": False, "error": "KiCad config directory unknown"}+    d = _read_installed(installed_p)+    out = []+    for e in d["packages"]:+        p = e.get("package", {})+        out.append({"identifier": p.get("identifier"), "name": p.get("name"),+                    "type": p.get("type"), "version": e.get("current_version"),+                    "repository": e.get("repository_name"),+                    "onDisk": any((third / f / _pkgdir(p.get("identifier", ""))).exists() for f in _FOLDERS)})+    return {"success": True, "count": len(out), "packages": out,+            "thirdPartyDir": str(third), "kicadVersion": ver}+++def handle_pcm_uninstall(kicad_info: dict, args: dict) -> dict:+    """Remove a PCM-installed package: its 3rdparty directories + its record."""+    ident = (args.get("identifier") or "").strip()+    if not ident:+        return {"success": False, "error": "identifier is required"}+    third, installed_p, _ = _paths(kicad_info)+    if not third:+        return {"success": False, "error": "KiCad user/config directories unknown"}+    removed = []+    for folder in _FOLDERS:+        d = third / folder / _pkgdir(ident)+        if d.exists():+            shutil.rmtree(d, ignore_errors=True)+            removed.append(str(d))+    installed = _read_installed(installed_p)+    before = len(installed["packages"])+    installed["packages"] = [e for e in installed["packages"]+                             if e.get("package", {}).get("identifier") != ident]+    _write_installed(installed_p, installed)+    running = bool(kicad_info.get("running") or kicad_info.get("runningInstances"))+    return {"success": True, "identifier": ident, "removedDirs": removed,+            "recordRemoved": before != len(installed["packages"]),+            "kicadRunning": running, "restartNeeded": running,+            "_hint": "KiCad drops the PCM_ library rows itself on next load when pcm.lib_auto_remove is on."}