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
0.9.274: Wiki #71 root cause: on a OneDrive-redirected Documents the bridge used %USERPROFILE%/Documents while KiCad resolves the shell Documents folder, so the bridge wrote and reported symbols in a file KiCad never reads. Fix: kicad_detect resolves user_dir like KiCad (handlers/win_paths), the Adom symbol and footprint libraries are ROW-FIRST (install writes to the file sym-/fp-lib-table registers), adom_library_status reports registeredPath/bridgeDefaultPath/split/orphanedLibrary, and the library_cache_stale verdict is built from that measurement instead of a cache story (both prior explanations were disproved by Ray).
7 files changed
+190−39
BRIDGE_VERSION+1−1@@ -1 +1 @@-0.9.273+0.9.274
adom_library.py+67@@ -60,12 +60,79 @@ def _get_paths(kicad_info: dict) -> tuple[Path, Path] | None: if not config_dir or not user_dir: return None table_path = Path(config_dir) / "sym-lib-table"+ # ROW FIRST (wiki #71). KiCad reads the file the sym-lib-table row points at+ # and nothing else. If an Adom row already exists, THAT uri is the library,+ # and the bridge writes there - regardless of where user_dir says a fresh+ # library would go. The old order (compute a path from user_dir, then+ # register it only if no row exists) let a box carry a registered Adom+ # library in one place and a bridge-written one in another, with install,+ # status and KiCad each looking at whichever file their own rule picked.+ try:+ table = LibTable.parse_file(str(table_path)) if table_path.exists() else None+ row = next((e for e in (table.entries if table else []) if e.name == ADOM_LIB_NAME), None)+ uri = getattr(row, "uri", None) if row is not None else None+ if uri:+ expanded = _expand_kicad_uri(str(uri), kicad_info)+ if expanded:+ lib_path = Path(expanded)+ lib_path.parent.mkdir(parents=True, exist_ok=True)+ return table_path, lib_path+ except Exception:+ pass sym_dir = Path(user_dir) / "symbols" sym_dir.mkdir(parents=True, exist_ok=True) lib_path = sym_dir / ADOM_LIB_FILE return table_path, lib_path +def _expand_kicad_uri(uri: str, kicad_info: dict) -> str | None:+ """Expand the path variables a sym-lib-table uri may carry. Only the ones+ the bridge itself would have written or KiCad seeds by default."""+ import os as _os+ ver = str(kicad_info.get("version") or "")+ major = ver.split(".")[0] if ver else ""+ subs = {}+ if kicad_info.get("user_dir"):+ subs[f"KICAD{major}_SYMBOL_DIR"] = str(Path(kicad_info["user_dir"]) / "symbols")+ subs["KIPRJMOD"] = None # project-relative: not a global library, refuse+ out = uri.replace("\\", "/")+ for k, v in subs.items():+ token = "${" + k + "}"+ if token in out:+ if v is None:+ return None+ out = out.replace(token, v.replace("\\", "/"))+ if "${" in out:+ return None # an unknown variable: do not guess at KiCad's expansion+ return _os.path.normpath(out).replace("\\", "/")+++def library_split_report(kicad_info: dict) -> dict:+ """Which Adom.kicad_sym does KiCad READ, which would the bridge WRITE by its+ default rule, and do they agree? Surfaced by adom_library_status so a split+ can never again hide behind a confident registered:true / exists:true."""+ paths = _get_paths(kicad_info)+ if not paths:+ return {"split": None}+ _table, registered = paths+ default = None+ if kicad_info.get("user_dir"):+ default = Path(kicad_info["user_dir"]) / "symbols" / ADOM_LIB_FILE+ def count(p):+ try:+ return len(list_symbols(p)) if p and Path(p).exists() else None+ except Exception:+ return None+ rep = {"registeredPath": str(registered).replace("\\", "/"),+ "registeredSymbols": count(registered),+ "bridgeDefaultPath": str(default).replace("\\", "/") if default else None,+ "bridgeDefaultSymbols": count(default) if default else None}+ rep["split"] = bool(default) and Path(default).resolve() != Path(registered).resolve()+ if rep["split"] and (rep["bridgeDefaultSymbols"] or 0) > (rep["registeredSymbols"] or 0):+ rep["orphanedLibrary"] = rep["bridgeDefaultPath"]+ return rep++ def ensure_adom_library(kicad_info: dict) -> dict: """Make sure Adom.kicad_sym exists and is registered.
bridge.json+1−1@@ -2,7 +2,7 @@ "manifest_version": 1, "name": "kicad", "displayName": "KiCad EDA",- "version": "0.9.273",+ "version": "0.9.274", "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/install_footprint.py+14@@ -122,6 +122,20 @@ def handle_install_footprint(kicad_info: dict, args: dict) -> dict: fp_dir.mkdir(parents=True, exist_ok=True) pretty_dir = fp_dir / ADOM_FP_DIR_NAME+ # ROW FIRST (wiki #71, same defect as the symbol side). If fp-lib-table+ # already registers an Adom footprint library, KiCad reads THAT directory and+ # nothing else, so that is where a new footprint has to go - not wherever+ # user_dir happens to resolve on this box. On a OneDrive-redirected Documents+ # the two differ, and writing to the user_dir one produced footprints KiCad+ # could never see while every verb reported ok.+ try:+ _t = LibTable.parse_file(str(Path(config_dir) / "fp-lib-table"))+ _row = next((e for e in _t.entries if e.name == ADOM_FP_LIB_NAME), None)+ if _row is not None and _row.uri and "${" not in str(_row.uri):+ pretty_dir = Path(str(_row.uri).replace("\\", "/"))+ pretty_dir.parent.mkdir(parents=True, exist_ok=True)+ except Exception:+ pass pretty_dir.mkdir(parents=True, exist_ok=True) # --- Step 2b: optional 3D model (install + wire into the footprint) ---
handlers/open_symbol_editor.py+53−12@@ -174,6 +174,49 @@ def _symbol_on_disk_but_not_in_editor(kicad_info: dict, name: str) -> bool: return False ++def _stale_explanation(kicad_info: dict, name: str) -> dict:+ """error/_hint for library_cache_stale, built from the measured split report+ instead of an assumed cache model."""+ try:+ import adom_library+ rep = adom_library.library_split_report(kicad_info)+ except Exception: # pylint: disable=broad-except+ rep = {}+ title = _get_symbol_editor_title() or "unknown"+ if rep.get("split"):+ return {+ "errorCode": "library_split",+ "registeredPath": rep.get("registeredPath"),+ "bridgeDefaultPath": rep.get("bridgeDefaultPath"),+ "registeredSymbols": rep.get("registeredSymbols"),+ "bridgeDefaultSymbols": rep.get("bridgeDefaultSymbols"),+ "error": (f"'{name}' is in an Adom.kicad_sym that KiCad does NOT read. sym-lib-table "+ f"registers {rep.get('registeredPath')} ({rep.get('registeredSymbols')} symbol(s)); "+ f"the bridge had been writing {rep.get('bridgeDefaultPath')} "+ f"({rep.get('bridgeDefaultSymbols')} symbol(s)). Different files, so no "+ f"restart or rescan can help (title: {title})"),+ "_hint": ("This is the OneDrive-redirected-Documents split: KiCad resolves its user "+ "folder through the shell Documents known folder, the bridge used the "+ "naive %USERPROFILE%/Documents. From 0.9.274 installs write to the "+ "registered file. Re-run kicad_install_symbol and the part lands where "+ "KiCad reads; kicad_adom_library_status shows both paths."),+ }+ return {+ "error": (f"'{name}' is in the Adom library KiCad reads ({rep.get('registeredPath') or 'registered path'}), "+ f"but the open Symbol Editor did not load it in the background "+ f"(title: {title})"),+ "_hint": ("The file and the table agree, so this is not a path problem. What is NOT "+ "known is why the editor did not load it: the bridge has no measurement "+ "that separates 'the editor has not indexed this file yet' from 'the "+ "background navigation missed'. kicad_rescan_libraries force:true restarts "+ "KiCad (reported by one user as NOT sufficient on 10.0 for symbols, wiki #71); "+ "kicad_screenshot_all shows what the tree actually lists. If you can "+ "reproduce with force:true failing while paths agree, report it - that is "+ "the unmeasured case."),+ }++ def _close_symbol_editor(hwnd: int) -> bool: """Ask an open Symbol Editor to close, so the next open re-indexes the libraries. WM_CLOSE, not a kill: KiCad gets to run its own shutdown (and to@@ -1043,18 +1086,16 @@ def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict: # Symbol Editor inside the same kicad.exe re-uses the cached # table, so a fresh window is not a fresh index. Only a process # restart rebuilds it (kicad_rescan_libraries force:true).- "error": f"'{search_text}' is in the Adom library on disk, but this running "- f"KiCad cannot see it: KiCad caches library indexes PROCESS-WIDE, so "- f"a symbol added after kicad.exe started stays invisible until the "- f"process re-indexes "- f"(title: {_get_symbol_editor_title() or 'unknown'})",- "_hint": ("Retrying will not fix it, and neither will closing and reopening the "- "Symbol Editor WINDOW - the cache belongs to the KiCad process, not "- "the window (wiki #71 proved a fresh editor still cannot see it). "- "Call kicad_rescan_libraries force:true for the process restart that "- "does rebuild the index (~1 min, wiki #35/#51), or pass rescan:true "- "here to have the bridge do it. The bridge will not restart a KiCad a "- "human launched: close it first and the restart is free."),+ # NO MORE CACHE STORIES (wiki #71, Ray, 2026-09-03). Two explanations+ # were attached to this verdict in two days - "the editor indexed+ # before the add", then "KiCad caches process-wide" - and Ray+ # disproved both with new hwnds after a real restart. The verdict+ # itself is only "navigation failed while the symbol is in the+ # Adom file". The one thing that actually distinguishes the cases+ # is WHICH file: the sym-lib-table row is what KiCad reads, and on+ # a OneDrive-redirected box the bridge had been writing elsewhere.+ # So report the paths, measured, and say only what they show.+ **_stale_explanation(kicad_info, search_text), } return { "success": False, "editorOpened": True, "symbolLoaded": False,
handlers/pcm.py+1−25@@ -46,31 +46,7 @@ _MAX_ZIP = 512 * 1024 * 1024 _UA = "adom-kicad-bridge pcm" -def _shell_documents_dir() -> Path | None:- """The Windows *shell* Documents folder (FOLDERID_Documents), which is what- KiCad's PATHS::GetDefault3rdPartyPath() uses via wxStandardPaths. This is- NOT always %USERPROFILE%/Documents: with OneDrive Known Folder Redirection- it is %USERPROFILE%/OneDrive/Documents. Caught live 2026-09-01: the bridge- installed a package under Documents/KiCad/10.0/3rdparty while KiCad was- scanning OneDrive/Documents/KiCad/10.0/3rdparty, so PCM showed the package- as Installed and no library ever appeared."""- try:- import ctypes- from ctypes import wintypes- FOLDERID_Documents = ctypes.c_char_p( # {FDD39AD0-238F-46AF-ADB4-6C85480369C7}- b"\xd0\x9a\xd3\xfd\x8f\x23\xaf\x46\xad\xb4\x6c\x85\x48\x03\x69\xc7")- class GUID(ctypes.Structure):- _fields_ = [("Data1", wintypes.DWORD), ("Data2", wintypes.WORD),- ("Data3", wintypes.WORD), ("Data4", wintypes.BYTE * 8)]- guid = GUID(0xFDD39AD0, 0x238F, 0x46AF, (wintypes.BYTE * 8)(0xAD, 0xB4, 0x6C, 0x85, 0x48, 0x03, 0x69, 0xC7))- out = ctypes.c_wchar_p()- if ctypes.windll.shell32.SHGetKnownFolderPath(ctypes.byref(guid), 0, None, ctypes.byref(out)) == 0:- p = Path(out.value)- ctypes.windll.ole32.CoTaskMemFree(out)- return p- except Exception: # pylint: disable=broad-except- pass- return None+from handlers.win_paths import shell_documents_dir as _shell_documents_dir def _third_party_dir(kicad_info: dict) -> tuple[Path | None, str]:
handlers/win_paths.pyadded+53@@ -0,0 +1,53 @@+"""Windows shell folders, resolved the way KiCad resolves them.++WHY (wiki #71, Ray, 2026-09-03; and the PCM bug the day before). KiCad finds the+user's KiCad folder through the SHELL Documents known folder+(wxStandardPaths::GetDocumentsDir -> FOLDERID_Documents). With OneDrive Known+Folder Redirection that is %USERPROFILE%\\OneDrive\\Documents, NOT+%USERPROFILE%\\Documents. This bridge used the naive path, so on a redirected+box it created and wrote a SECOND, empty Adom library that KiCad never reads,+while KiCad's real one sat under OneDrive with every symbol in it. Every+"installed but invisible" report on such a box traces back to this.+"""++from __future__ import annotations++import os+from pathlib import Path+++def shell_documents_dir() -> Path | None:+ """FOLDERID_Documents via SHGetKnownFolderPath, or None off-Windows."""+ try:+ import ctypes+ from ctypes import wintypes++ class GUID(ctypes.Structure):+ _fields_ = [("Data1", wintypes.DWORD), ("Data2", wintypes.WORD),+ ("Data3", wintypes.WORD), ("Data4", wintypes.BYTE * 8)]+ guid = GUID(0xFDD39AD0, 0x238F, 0x46AF,+ (wintypes.BYTE * 8)(0xAD, 0xB4, 0x6C, 0x85, 0x48, 0x03, 0x69, 0xC7))+ out = ctypes.c_wchar_p()+ if ctypes.windll.shell32.SHGetKnownFolderPath(ctypes.byref(guid), 0, None, ctypes.byref(out)) == 0:+ p = Path(out.value)+ ctypes.windll.ole32.CoTaskMemFree(out)+ return p+ except Exception: # pylint: disable=broad-except+ pass+ return None+++def documents_candidates() -> list[Path]:+ """Shell Documents FIRST (what KiCad uses), naive %USERPROFILE%\\Documents+ second (what this bridge used to assume). Both, de-duplicated, in that order,+ so a caller can prefer KiCad's answer and still find a legacy tree."""+ out: list[Path] = []+ s = shell_documents_dir()+ if s:+ out.append(s)+ up = os.environ.get("USERPROFILE")+ if up:+ n = Path(up) / "Documents"+ if not out or n.resolve() != out[0].resolve():+ out.append(n)+ return out