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)
5 files changed
+183−8
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.265+0.9.266
adom-bridge-kicad-manifest.json+5−5@@ -1,15 +1,15 @@ { "manifest_version": 1, "name": "kicad",- "version": "0.9.265",- "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.265/adom-bridge-kicad-v0.9.265.zip",- "sha256": "743ae641dec347f38e814dee3027008ffff1b070418e930d4564e6e058b2a408",- "size": 1033495,+ "version": "0.9.266",+ "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.266/adom-bridge-kicad-v0.9.266.zip",+ "sha256": "53b43dbac7db105ecc771b4c31e56eb982d37680bc52907f21d2065ad7ac8a37",+ "size": 1037305, "verbPrefixes": [ "kicad_" ], "healthEndpoint": "/status",- "released_at": "2026-09-01T02:23:49Z",+ "released_at": "2026-09-01T02:28:40Z", "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.265",+ "version": "0.9.266", "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/kicad_log.pyadded+142@@ -0,0 +1,142 @@+"""Capture KiCad's own diagnostics, and read the new lines since last time.++WHY THIS EXISTS+---------------+KiCad writes NO log file. Ever. Its diagnostics go through wxLogTrace /+wxLogError to **stderr**, and on Windows a GUI process has no console, so stderr+goes to the void unless whoever launched it supplies a handle. This bridge+launches KiCad, and until now it launched it with:++ subprocess.Popen(..., stdout=DEVNULL, stderr=DEVNULL)++i.e. it was actively throwing away the only channel KiCad has for telling us what+went wrong. That is why a failed board load could only be discovered by+screenshotting a modal dialog and reading the pixels.++Two switches are needed and both are ours to set at launch:+ * KICAD_ENABLE_WXTRACE=1 — in a RELEASE build wx logging is off; KiCad turns it+ on only when this is set (common/single_top.cpp:202, kicad/kicad.cpp:579).+ * WXTRACE=<masks> — wxWidgets' own selector for WHICH trace masks print.+ KiCad's masks are named things like KICAD_LIBRARIES, KICAD_ENV_VARS. Errors+ and warnings (wxLogError/wxLogWarning) do NOT need a mask: they print once+ logging is enabled at all, which is the 90% case for us.++The log is per-launch, under the bridge's own state dir, and callers read it with+`kicad_log_tail`, which returns only what is NEW since a byte offset the caller+carries. That makes the natural loop cheap: tail, act, tail again, and the second+tail is exactly the consequences of your action.++WHAT THIS IS NOT: it is not a replacement for a verb's own error contract. A verb+that fails must still say so. This is the channel for what KiCad says about+ITSELF, which no verb can otherwise see.+"""++from __future__ import annotations++import os+import re+import time+from pathlib import Path++# One directory, one file per launch, newest wins. Kept beside the bridge's other+# state so an uninstall sweeps it up.+_LOG_DIR_NAME = "kicad-logs"+_KEEP_LOGS = 10+++def log_dir() -> Path:+ base = Path(os.environ.get("LOCALAPPDATA") or Path.home() / ".local/share")+ d = base / "Adom Bridge" / _LOG_DIR_NAME+ d.mkdir(parents=True, exist_ok=True)+ return d+++def new_log_path(tag: str = "kicad") -> Path:+ """A fresh per-launch log file, and prune old ones so this never grows without+ bound on a machine that launches KiCad all day."""+ d = log_dir()+ try:+ olds = sorted(d.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)+ for p in olds[_KEEP_LOGS - 1:]:+ p.unlink(missing_ok=True)+ except OSError:+ pass+ safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", tag)[:40] or "kicad"+ return d / f"{time.strftime('%Y%m%d-%H%M%S')}-{safe}.log"+++def latest_log() -> Path | None:+ try:+ logs = sorted(log_dir().glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)+ return logs[0] if logs else None+ except OSError:+ return None+++def trace_env(masks: str | None = None) -> dict:+ """Environment additions that make KiCad actually say something.++ Pass masks (comma-separated wx trace masks) only when you want the firehose;+ errors and warnings arrive without any mask once logging is enabled."""+ env = dict(os.environ)+ env["KICAD_ENABLE_WXTRACE"] = "1"+ if masks:+ env["WXTRACE"] = masks+ return env+++_LEVEL = re.compile(r"^\s*(?P<lvl>Error|Warning|Trace|Info|Debug|Fatal)\b[: ]", re.I)+++def _classify(line: str) -> str:+ m = _LEVEL.match(line)+ if m:+ return m.group("lvl").lower()+ low = line.lower()+ if "error" in low or "failed" in low or "cannot" in low or "unable to" in low:+ return "error"+ if "warning" in low or "deprecat" in low:+ return "warning"+ return "info"+++def handle_log_tail(kicad_info: dict, args: dict) -> dict:+ """New KiCad log output since a byte offset (read-only).++ Args:+ sinceOffset byte offset from a previous call (default 0 = whole file)+ maxBytes cap on returned text (default 200000, newest kept)+ errorsOnly true -> only lines classified error/warning+ path explicit log file; default = the newest launch log+ """+ p = Path(args["path"]) if args.get("path") else latest_log()+ if not p or not p.exists():+ return {"success": True, "available": False, "lines": [], "nextOffset": 0,+ "_hint": ("No KiCad log yet. Logs exist only for KiCad instances THIS bridge "+ "launched after 0.9.266 — a KiCad the user started themselves writes "+ "nothing, because its stderr belongs to whoever launched it.")}+ since = int(args.get("sinceOffset") or 0)+ max_bytes = int(args.get("maxBytes") or 200_000)+ size = p.stat().st_size+ if since > size: # file rotated/truncated under us+ since = 0+ with open(p, "rb") as f:+ f.seek(since)+ blob = f.read()+ truncated = len(blob) > max_bytes+ if truncated:+ blob = blob[-max_bytes:]+ text = blob.decode("utf-8", errors="replace")+ lines = [ln for ln in text.splitlines() if ln.strip()]+ classified = [{"level": _classify(ln), "text": ln} for ln in lines]+ if args.get("errorsOnly"):+ classified = [c for c in classified if c["level"] in ("error", "warning", "fatal")]+ counts: dict[str, int] = {}+ for c in classified:+ counts[c["level"]] = counts.get(c["level"], 0) + 1+ return {"success": True, "available": True, "logPath": str(p),+ "sinceOffset": since, "nextOffset": size, "truncated": truncated,+ "lineCount": len(classified), "levels": counts, "lines": classified,+ "_hint": ("Carry nextOffset into the next call and you get ONLY what happened "+ "since — tail, act, tail again, and the second read is your action's "+ "consequences. errorsOnly:true filters to error/warning.")}
handlers/launch.py+34−1@@ -77,9 +77,32 @@ def handle_launch(kicad_info: dict, args: dict) -> dict: _si = win_focus.background_startupinfo() except Exception: pass+ # CAPTURE KICAD'S OWN DIAGNOSTICS INSTEAD OF DISCARDING THEM.+ #+ # This used to be stdout=DEVNULL, stderr=DEVNULL. KiCad writes no log file:+ # everything it has to say about itself goes to stderr via wxLogError /+ # wxLogTrace, and a Windows GUI process has no console, so DEVNULL meant the+ # ONLY channel KiCad has for reporting its own failures was being thrown away+ # by the very thing driving it. A failed board load could then only be found+ # by screenshotting a modal dialog and reading the pixels.+ #+ # KICAD_ENABLE_WXTRACE=1 is required in a RELEASE build or wx logging is off+ # entirely (common/single_top.cpp:202, kicad/kicad.cpp:579). Errors and+ # warnings need no WXTRACE mask; masks only add the trace firehose.+ _log_path = None+ _log_fh = None+ try:+ from handlers.kicad_log import new_log_path, trace_env+ _log_path = new_log_path("launch")+ _log_fh = open(_log_path, "ab", buffering=0)+ _env = trace_env(args.get("traceMasks"))+ except Exception: # never let logging break launching+ _log_fh, _env = None, None try: _p = subprocess.Popen([exe] + ([project] if project else []),- stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,+ stdout=(_log_fh or subprocess.DEVNULL),+ stderr=(_log_fh or subprocess.DEVNULL),+ env=_env, startupinfo=_si) try: from handlers.win_focus import register_owned_pid@@ -89,6 +112,14 @@ def handle_launch(kicad_info: dict, args: dict) -> dict: except Exception as e: return {"success": False, "error": f"Failed to launch KiCad: {e}"} + # The parent's copy of the handle is not needed once the child owns it; the+ # child keeps writing after we close ours.+ try:+ if _log_fh:+ _log_fh.close()+ except Exception:+ pass+ wait_s = float(args.get("waitSeconds") or 15) deadline = time.monotonic() + max(3.0, wait_s) windows: list = []@@ -104,6 +135,7 @@ def handle_launch(kicad_info: dict, args: dict) -> dict: "launched": True, "windows": [w.get("title") for w in windows][:10], "output": f"KiCad launched — {len(windows)} window(s) up.",+ "logPath": str(_log_path) if _log_path else None, "_hint": ("Launched in the BACKGROUND (no focus steal). First run on a fresh install " "may show a setup wizard — kicad_window_info self-heals benign dialogs; " "kicad_screenshot_all shows every window if something looks stuck."),@@ -111,6 +143,7 @@ def handle_launch(kicad_info: dict, args: dict) -> dict: } return { "success": False,+ "logPath": str(_log_path) if _log_path else None, "error": f"kicad.exe started but no KiCad window appeared within {int(wait_s)}s", "_hint": ("Cold start on a slow box (or software-OpenGL) can exceed the wait — retry " "kicad_window_info in a few seconds before treating this as a failure. "