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
+267−7
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.262+0.9.263
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."}