← Commit history

Update 40 file(s)

John Lauer ·817d1d2075 ·1mo ago ·parent e273f1b
5 files changed +183−8
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.265\ No newline at end of file+0.9.266\ 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.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. "