← 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).

John Lauer ·d9e363d859 ·1mo ago ·parent c0c2864
7 files changed +190−39
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.273\ No newline at end of file+0.9.274\ No newline at end of file
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