← Commit history

v0.9.163: deploy the plugin loader to the interpreter's REAL user-site too (10.0.5 reports the default %APPDATA%/Python/Python311 path, so everything in the Documents user-site was invisible) + dashboard gains ab's 8-surface per-part ladder with taskbar progress

John Lauer ·53eeaff091 ·1mo ago ·parent 86b8b93
7 files changed +302−7
BRIDGE_VERSION+1−1
@@ -1 +1 @@-0.9.162+0.9.163
adom-bridge-kicad-manifest.json+5−5
@@ -1,15 +1,15 @@ {   "manifest_version": 1,   "name": "kicad",-  "version": "0.9.162",-  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.162/adom-bridge-kicad-v0.9.162.zip",-  "sha256": "fc5dfed12f05c15b1ac6b88f4febca9631f4c7b153f3e2dfaee9f67fe75fc334",-  "size": 442541,+  "version": "0.9.163",+  "url": "https://wiki.adom.inc/download/adom/kicad-bridge/0.9.163/adom-bridge-kicad-v0.9.163.zip",+  "sha256": "40e6868e0f9f247c4006bcd72ce7d519a22d7005e7660c000addb2340ecc7c7c",+  "size": 443052,   "verbPrefixes": [     "kicad_"   ],   "healthEndpoint": "/status",-  "released_at": "2026-08-18T11:04:56Z",+  "released_at": "2026-08-18T11:17:43Z",   "hero": "https://wiki.adom.inc/blob/app/kicad-bridge/kicad-hero.png",   "languages": [     "Python",
adom-bridge-kicad-v0.9.163.zipadded
⋯ 1 unchanged line ⋯
bridge.json+1−1
@@ -2,7 +2,7 @@   "manifest_version": 1,   "name": "kicad",   "displayName": "KiCad EDA",-  "version": "0.9.162",+  "version": "0.9.163",   "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.",
dashboard/kicad_dashboard.py+203
@@ -371,6 +371,198 @@ def part_tour(target, args):         shutil.rmtree(tmp, ignore_errors=True)  ++# ---------------------------------------------------------------- surfaces+# The per-part surface ladder, ported from ab's web-control demo+# (adom/adom-bridge web-control-demo.html): ONE real wiki part, eight surfaces,+# each fetching the part's files from its wiki page, installing them into the+# user's real KiCad, opening JUST that surface in the background, and rendering+# it from kicad_state. The whole circle: wiki component -> the app the EE+# designs in.+PART = {"page": "aravk/in-s42atr", "name": "IN-S42ATR", "lib": "Adom"}++SURFACES = [+    ("symbol", "Symbol", "kicad_show_symbol", "part", "symbol editor",+     "installs the part from its wiki page, then shows JUST the symbol"),+    ("footprint", "Footprint", "kicad_show_footprint", "part", "footprint editor",+     "just the land pattern: pads + courtyard"),+    ("3d-chip", "3D chip", "kicad_show_3d_chip", "part", "3d viewer",+     "the part's STEP model on its footprint"),+    ("library", "Library", "kicad_show_library", "part", "symbol editor",+     "the library holding the symbol, footprint and 3D model"),+    ("schematic", "Schematic", "kicad_show_schematic", "project", "schematic editor",+     "the generated single-component schematic"),+    ("2d-board", "2D board", "kicad_show_2d_board", "project", "pcb editor",+     "the part-dominant board, tight edge cuts"),+    ("3d-board", "3D board", "kicad_show_3d_board", "project", "3d viewer",+     "the populated board in 3D"),+    ("project", "Project", "kicad_show_project", "project", "kicad 10",+     "the Project Manager: schematic + board hang off it"),+]++_stage_lock = threading.Lock()+_staged = {"part": None, "project": None}   # per-server-run cache, like ab's+++def _kicad_hwnd(target):+    """Main KiCad window hwnd, for taskbar progress."""+    try:+        st = ab(target, "kicad_state", {"includeImages": False}, timeout=60)+        for w in ((st.get("kicadState") or st).get("windows") or []):+            if w.get("hwnd"):+                return int(w["hwnd"])+    except Exception:+        pass+    return None+++def taskbar_progress(target, value=None, state="normal", hwnd=None):+    """John, 2026-08-18: "when you automate kicad and you know something is going+    to take a while, use ab's progress bar indicator in the windows taskbar".+    Paints on the KiCad button itself, so the wait is legible without the user+    watching this page: indeterminate while staging, a real percentage as steps+    land, and cleared (or red) when the job ends."""+    h = hwnd or _kicad_hwnd(target)+    if not h:+        return None+    prog = {"state": state}+    if value is not None:+        prog["value"] = int(value)+    ab(target, "desktop_taskbar", {"hwnd": h, "progress": prog}, timeout=30)+    return h+++def _emit(surface, **kw):+    """Broadcast to the page AND record in /console: an AI driving this app reads+    the console back, so surface progress has to land in both places."""+    if kw.get("log"):+        log(f"[{surface}] {kw['log']}")+    HUB.broadcast("surface", {"surface": surface, **kw})+++def stage_part(target, surface):+    """Fetch the part's CAD files from its wiki page and install them."""+    with _stage_lock:+        if _staged["part"]:+            _emit(surface, log="part already staged this session (reusing)")+            return _staged["part"]+        _emit(surface, log=f"staging {PART['name']} from {PART['page']}", percent=10)+        tmp = tempfile.mkdtemp(prefix="kdash-surface-")+        try:+            p = subprocess.run(["adom-wiki", "repo", "clone", PART["page"], "--dir", tmp],+                               capture_output=True, text=True, timeout=180)+            if p.returncode != 0:+                raise RuntimeError(f"wiki clone failed: {p.stderr[:150]}")+            found = {}+            for root, _d, names in os.walk(tmp):+                for n in names:+                    for ext in (".kicad_sym", ".kicad_mod", ".step"):+                        if n.lower().endswith(ext) and ext not in found:+                            found[ext] = os.path.join(root, n)+            if ".kicad_sym" not in found or ".kicad_mod" not in found:+                raise RuntimeError("the wiki page ships no .kicad_sym / .kicad_mod")+            _emit(surface, log=f"sending {len(found)} file(s) to the box", percent=25)+            sent = ab(target, "send_files", {"filePaths": list(found.values())}, timeout=180)+            dest = {os.path.splitext(d)[1].lower(): d for d in (sent.get("destinationPaths") or [])}+            if not dest:+                raise RuntimeError(f"send_files failed: {str(sent.get('error'))[:120]}")+            _emit(surface, log="installing the symbol", percent=40)+            r = ab(target, "kicad_install_symbol",+                   {"filePath": dest[".kicad_sym"], "quietInstall": True}, timeout=180)+            if r.get("libraryName"):+                PART["lib"] = r["libraryName"]+            _emit(surface, log=f"installing the footprint (+3D model) into '{PART['lib']}'", percent=55)+            fargs = {"filePath": dest[".kicad_mod"], "quietInstall": True}+            if dest.get(".step"):+                fargs["model"] = {"filePath": dest[".step"]}+            ab(target, "kicad_install_footprint", fargs, timeout=180)+            _staged["part"] = {"lib": PART["lib"]}+            return _staged["part"]+        finally:+            shutil.rmtree(tmp, ignore_errors=True)+++def stage_project(target, surface):+    """The part, plus the generated single-component project."""+    stage_part(target, surface)+    with _stage_lock:+        if _staged["project"]:+            return _staged["project"]+        _emit(surface, log="generating the part's demo project", percent=70)+        r = ab(target, "kicad_make_part_project", {"partName": PART["name"]}, timeout=240)+        if r.get("success") is False:+            raise RuntimeError(f"kicad_make_part_project: {str(r.get('error'))[:120]}")+        _staged["project"] = {"pro": r.get("project"), "sch": r.get("schematic"),+                              "pcb": r.get("board")}+        return _staged["project"]+++def _surface_args(key, staged_project):+    name, lib = PART["name"], PART["lib"]+    pj = staged_project or {}+    return {+        "symbol": {"symbolName": name, "libraryName": lib},+        "footprint": {"footprintName": name, "library": lib},+        "3d-chip": {"footprintName": name, "library": lib},+        "library": {"libraryName": lib},+        "schematic": {"filePath": pj.get("sch")},+        "2d-board": {"filePath": pj.get("pcb")},+        "3d-board": {"filePath": pj.get("pcb")},+        "project": {"filePath": pj.get("pro")},+    }[key]+++def run_surface(target, key):+    """Stage what this surface needs, open it in the BACKGROUND, then render it+    from kicad_state. Streams progress over SSE and onto the taskbar button."""+    spec = next((s for s in SURFACES if s[0] == key), None)+    if key == "state":+        spec = ("state", "KiCad state", "kicad_state", None, None,+                "captures every KiCad window as a screenshot")+    if not spec:+        _emit(key, status="error", log=f"unknown surface {key}")+        return+    _key, label, verb, need, pick, note = spec+    hwnd = None+    try:+        _emit(key, status="running", log=note, percent=5)+        hwnd = taskbar_progress(target, None, "indeterminate")+        project = None+        if need == "part":+            stage_part(target, key)+        elif need == "project":+            project = stage_project(target, key)+        taskbar_progress(target, 80, "normal", hwnd)+        _emit(key, log=f"opening the {label.lower()} in the background", percent=80)+        args = _surface_args(key, project) if need else {"maxWidth": 900}+        r = ab(target, verb, args, timeout=300)+        if r.get("success") is False:+            raise RuntimeError(str(r.get("error") or r.get("errorCode"))[:160])+        _emit(key, log="rendering it from kicad_state", percent=92)+        shot, title = _render(target, pick)+        taskbar_progress(target, 100, "none", hwnd)+        _emit(key, status="done", percent=100, image=shot, title=title,+              log=f"{label} shown in the background" + (f" ({title})" if title else ""))+    except Exception as e:+        if hwnd:+            taskbar_progress(target, 100, "error", hwnd)+        _emit(key, status="error", log=str(e)[:200])+++def _render(target, pick):+    """Pull this surface's window out of kicad_state as a base64 PNG."""+    st = ab(target, "kicad_state", {"maxWidth": 900}, timeout=240)+    wins = (st.get("kicadState") or st).get("windows") or []+    chosen = None+    if pick:+        chosen = next((w for w in wins if pick in (w.get("title") or "").lower()), None)+    chosen = chosen or (wins[-1] if wins else None)+    if not chosen:+        return None, None+    img = chosen.get("image") or chosen.get("screenshot")+    return img, chosen.get("title")++ def do_action(target, name, args):     args = args or {}     if name == "launch":@@ -390,6 +582,17 @@ def do_action(target, name, args):                   {"overlayBadges": bool(args.get("overlayBadges", True))}, timeout=60)     if name == "part_tour":         return part_tour(target, args)+    if name == "surface":+        key = (args.get("surface") or "").strip()+        threading.Thread(target=run_surface, args=(target, key), daemon=True,+                         name=f"surface-{key}").start()+        return {"success": True, "started": key,+                "_hint": "Progress streams on /events as `surface` events (status, log, "+                         "percent, image) and on the KiCad taskbar button's progress bar."}+    if name == "surfaces":+        return {"success": True, "part": PART,+                "surfaces": [{"key": k, "label": lbl, "note": note}+                             for k, lbl, _v, _n, _p, note in SURFACES]}     return {"error": f"unknown action {name}",             "_hint": "Actions: launch, close, demo, demo_progress, upgrade, install_plugin, "                      "set_overlay_badges, part_tour."}
dashboard/ui/index.html+57
@@ -56,6 +56,14 @@ input:focus-visible{outline:none;border-color:var(--accent) !important} .bar{height:8px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;overflow:hidden;margin-top:10px;display:none} .bar>div{height:100%;width:0;background:var(--accent);transition:width .5s ease} .barlabel{font-size:12.5px;color:var(--text-2);margin-top:6px}+.scard{display:flex;flex-direction:column;gap:8px}+.scard .btn{width:100%;justify-content:center;text-align:center}+.slog{font:12px 'JetBrains Mono',monospace;color:var(--text-2);min-height:16px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}+.sbar{height:6px;background:var(--elevated);border:1px solid var(--border);border-radius:999px;overflow:hidden;display:none}+.sbar>div{height:100%;width:0;background:var(--accent);transition:width .4s ease}+.sshot{border:1px solid var(--border);border-radius:8px;overflow:hidden;display:none}+.sshot img{width:100%;display:block}+.scard.err .slog{color:var(--red)} .shots{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:12px} .shot{background:var(--elevated);border:1px solid var(--border);border-radius:8px;overflow:hidden} .shot img{width:100%;display:block}@@ -136,6 +144,10 @@ dialog::backdrop{background:rgba(0,0,0,.55)}     <div class="barlabel" id="partLabel"></div>   </div> +  <h2>KiCad surfaces</h2>+  <div class="note" id="surfaceIntro">A real wiki part, end to end. Each button fetches the part's files from its wiki page, installs them into your real KiCad, opens JUST that surface in the background, and renders it from kicad_state. This is the whole circle: a wiki component to the app the EE designs in.</div>+  <div class="grid" id="surfaceGrid"></div>+   <h2>Copy-paste prompts for your AI</h2>   <div id="prompts"></div> </main>@@ -293,6 +305,49 @@ const PROMPTS=[ ]; $('#prompts').innerHTML=PROMPTS.map(p=>`<div class="prompt"><code>${p}</code><button class="btn copy" onclick="navigator.clipboard.writeText('${p.replace(/'/g,"\\'")}');toast('copied','ok')">Copy</button></div>`).join(''); +// KiCad surfaces: ab's per-part ladder, driven through /api/action {surface}.+// Each card owns its own log line, progress bar and rendered screenshot; the+// server streams `surface` SSE events as it stages, opens and renders.+const SURF_CARDS={};+async function buildSurfaces(){+  const r=await act('surfaces');const d=r.data||r;+  const g=$('#surfaceGrid');g.innerHTML='';+  const part=d.part||{};+  $('#surfaceIntro').innerHTML="A real wiki part, end to end: <a href='https://wiki.adom.inc/"++    (part.page||'')+"' target='_blank' rel='noopener'>"+(part.name||'the part')+"</a>. "++    "Each button fetches the part's files from its wiki page, installs them into your real KiCad, "++    "opens JUST that surface in the background, and renders it from kicad_state. This is the whole "++    "circle: a wiki component to the app the EE designs in.";+  const rows=(d.surfaces||[]).concat([{key:'state',label:'Show KiCad state',note:'captures every KiCad window as a screenshot'}]);+  for(const s of rows){+    const el=document.createElement('div');el.className='card scard';+    el.innerHTML=`<button class="btn" data-tip="${s.note}">${s.label}${s.key!=='state'?' ('+(part.name||'')+')':''}</button>`++                 `<div class="sbar"><div></div></div><div class="slog">not run yet</div>`++                 `<div class="sshot"><img alt="${s.label}"></div>`;+    el.querySelector('button').onclick=()=>{+      el.classList.remove('err');+      el.querySelector('.slog').textContent='starting...';+      el.querySelector('.sbar').style.display='block';+      el.querySelector('.sbar>div').style.width='5%';+      act('surface',{surface:s.key});+    };+    SURF_CARDS[s.key]=el;g.appendChild(el);+  }+}+function onSurfaceEvent(e){+  const el=SURF_CARDS[e.surface];if(!el)return;+  if(e.log)el.querySelector('.slog').textContent=e.log;+  if(typeof e.percent==='number'){el.querySelector('.sbar').style.display='block';el.querySelector('.sbar>div').style.width=e.percent+'%';}+  if(e.status==='error'){el.classList.add('err');el.querySelector('.sbar').style.display='none';toast(e.surface+': '+e.log,'err');}+  if(e.status==='done'){+    el.classList.remove('err');+    setTimeout(()=>{el.querySelector('.sbar').style.display='none';},800);+    if(e.image){const box=el.querySelector('.sshot');box.style.display='block';+      box.querySelector('img').src='data:image/png;base64,'+e.image;}+    toast(e.log,'ok');+  }+}+ // Live channel (SSE): state pushes, AI toasts, CLI click directives. const CLICK_TARGETS={setup:'#btnSetup',launch:'#btnLaunch',close:'#btnClose',demo:'#btnDemo','part-tour':'#btnPartTour',settings:'#btnSettings',refresh:'#btnRefresh'}; function connectSSE(){@@ -300,6 +355,7 @@ function connectSSE(){   es.addEventListener('hello',()=>{console.log('sse connected');});   es.addEventListener('state',e=>{try{const st=JSON.parse(e.data);if(!curTarget||st.target===curTarget){lastStatus=st;renderLeds(st);}}catch(_){}});   es.addEventListener('toast',e=>{try{const t=JSON.parse(e.data);toast(t.message,t.kind==='info'?'':t.kind);}catch(_){}});+  es.addEventListener('surface',e=>{try{onSurfaceEvent(JSON.parse(e.data));}catch(_){}});   es.addEventListener('click',e=>{try{     const {target}=JSON.parse(e.data);const sel=CLICK_TARGETS[target];     if(sel&&$(sel)){$(sel).click();}else{toast('unknown click target: '+target,'err');}@@ -346,6 +402,7 @@ brand.addEventListener('mouseleave',()=>{clearTimeout(aiShow);aiHide=setTimeout( ai.addEventListener('mouseenter',()=>clearTimeout(aiHide)); ai.addEventListener('mouseleave',()=>{aiHide=setTimeout(()=>ai.classList.remove('show'),220);}); +buildSurfaces(); loadTargets().then(()=>refresh());setInterval(()=>refresh(),15000);setInterval(loadTargets,60000); </script> </body>
plugin_install.py+35
@@ -139,6 +139,19 @@ except Exception: """  +def _std_user_site():+    """The interpreter's DEFAULT user-site for KiCad's Python 3.11 on Windows:+    %APPDATA%\\Python\\Python311\\site-packages. KiCad 10.0.5 reports this as+    site.USER_SITE (its own sitecustomize redirect no longer applies), so this+    is where a loader must live to be picked up."""+    import os as _os+    from pathlib import Path as _P+    appdata = _os.environ.get("APPDATA")+    if not appdata:+        return None+    return _P(appdata) / "Python" / "Python311" / "site-packages"++ def _file_state(dest: Path) -> dict:     """Inspect a destination file and return state for the feedback report. @@ -325,6 +338,28 @@ def _install_one_version(version: str, version_info: dict) -> dict:     except OSError as e:         report["pthLoaderError"] = str(e) +    # 0.9.163 - the SECOND deploy target, and the one that actually works on+    # 10.0.5. Asking KiCad's own interpreter shows ENABLE_USER_SITE=True with+    # USER_SITE at the INTERPRETER DEFAULT (%APPDATA%\Python\Python311\+    # site-packages), NOT the Documents path KiCad's sitecustomize is supposed+    # to install - i.e. on 10.0.5 that sitecustomize no longer takes effect, so+    # everything deployed to the Documents user-site is invisible. Deploying the+    # same payload to the interpreter's real user-site makes the loader fire+    # again; the boot guard keeps it inert in every non-KiCad Python.+    try:+        std_site = _std_user_site()+        if std_site and std_site != user_site:+            std_site.mkdir(parents=True, exist_ok=True)+            shutil.copy2(src_bridge, std_site / "adom_bridge.py")+            shutil.copy2(src_loader, std_site / "usercustomize.py")+            (std_site / "adom_bridge_boot.py").write_text(_BOOT_PY, encoding="utf-8")+            (std_site / "adom_bridge_boot.pth").write_text("import adom_bridge_boot\n",+                                                          encoding="utf-8")+            report["stdUserSitePath"] = str(std_site)+            written.append({"name": "std-user-site payload", "dest": str(std_site)})+    except Exception as e:+        report["stdUserSiteError"] = str(e)+     report["action"] = "installed" if not pre_state["adom_bridge.py"]["exists"] else "updated"     report["payloadVersion"] = PAYLOAD_VERSION     report["filesWritten"] = written