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)
13 files changed
+782−73
handlers/settings.py+35−1handlers/show.py+48−4handlers/win_focus.py+110handlers/win_taskbar.py+435−68plugin_payload/adom_bridge.py+67resources/adom-overlay-32.pngresources/demo-audio/board.mp3resources/demo-audio/board3d.mp3resources/demo-audio/footprint.mp3resources/demo-audio/prepare.mp3resources/demo-audio/schematic.mp3resources/demo-audio/symbol.mp3server.py+87handlers/settings.py+35−1@@ -19,6 +19,13 @@ from pathlib import Path _DEFAULTS = { "overlayBadges": True,+ # How the taskbar shows "Adom is driving KiCad" (John, 2026-08-22, adopted+ # from the fusion bridge): "progress" paints a progress bar on the KiCad+ # button from the measured verb timings and suppresses the OS orange flash;+ # "flash" leaves Windows' attention flash alone and paints no bar; "both";+ # "none". Default progress: a flash nags the user to click it away, while a+ # bar ends by itself exactly when Adom stops driving.+ "activityIndicator": "progress", } @@ -50,12 +57,39 @@ def handle_get_settings(kicad_info: dict, args: dict) -> dict: } +_CHOICES = {"activityIndicator": ("progress", "flash", "both", "none")}+++def _coerce(key, raw):+ """Type the value by its DEFAULT's type. The first cut bool()'d everything,+ so activityIndicator:"flash" persisted as True and the indicator silently+ ran its fallback branch (caught 2026-08-23 rebuilding the settings modal)."""+ if key in _CHOICES:+ v = str(raw).strip().lower()+ if v not in _CHOICES[key]:+ raise ValueError(f"{key} must be one of {', '.join(_CHOICES[key])}")+ return v+ if isinstance(_DEFAULTS[key], bool):+ if isinstance(raw, str):+ return raw.strip().lower() in ("1", "true", "on", "yes")+ return bool(raw)+ return raw++ def handle_set_settings(kicad_info: dict, args: dict) -> dict: s = get_settings() changed = {}+ if args.get("restoreDefaults"):+ for k, v in _DEFAULTS.items():+ if s.get(k) != v:+ changed[k] = v+ s[k] = v for k in _DEFAULTS: if k in args:- v = bool(args[k])+ try:+ v = _coerce(k, args[k])+ except ValueError as e:+ return {"success": False, "error": str(e)} if s.get(k) != v: changed[k] = v s[k] = v
handlers/show.py+48−4@@ -76,6 +76,14 @@ def handle_show_symbol(kicad_info: dict, args: dict) -> dict: "symbol editor", a.get("symbolName") or "", a.get("libraryName") or a.get("library") or "") if already:+ # #49 ask 2: the reuse fast path returned no resolution fields, so the+ # most common success on a warm editor was also the least inspectable.+ try:+ from handlers.open_symbol_editor import _resolved_fields+ _res = _resolved_fields(already.get("title") or "",+ a.get("libraryName") or a.get("library") or "")+ except Exception:+ _res = {} _ev = _capture_evidence(already, "symbol-editor-reused") _sh = _build_shot(already, "symbol-editor-reused", a.get("captureMaxWidth", _SHOT_DEFAULT_MAX)) if a.get("capture") else None@@ -239,7 +247,24 @@ def _build_shot(win, label: str, max_width: int) -> dict | None: mw = max(_SHOT_MIN_MAX, min(_SHOT_MAX_MAX, mw)) from handlers.kicad_ui import _screenshot_hwnd- shot = _screenshot_hwnd(hwnd, label=f"shot-{label}", max_dim=mw)+ # Wiki #52 (John): 4 of 30 symbol shots came back 100%-uniform white -+ # the editor answers "ready" before its GL canvas first paints, and the+ # blanks cluster on FAST parts precisely because less time passes+ # before the capture. The evidence path has polled for paint since+ # 0.9.202; this path - the one web control actually consumes - captured+ # once and shipped whatever it got. Own the settle here: retry while+ # the canvas region is unrendered, up to 10s, and REPORT the verdict on+ # the shot either way so a blank can never pose as proof.+ shot = None+ waited = 0+ for waited in range(11):+ shot = _screenshot_hwnd(hwnd, label=f"shot-{label}", max_dim=mw)+ c = (shot or {}).get("canvas") or {}+ if not shot or not shot.get("success"):+ return None+ if not c.get("checked") or c.get("rendered"):+ break+ time.sleep(1.0) path = (shot or {}).get("fullPath") or (shot or {}).get("savedTo") if not path: return None@@ -251,7 +276,8 @@ def _build_shot(win, label: str, max_width: int) -> dict | None: # what we actually wrote rather than assuming PNG. ext = str(path).lower().rsplit(".", 1)[-1] mime = {"png": "image/png", "bmp": "image/bmp", "webp": "image/webp"}.get(ext, "image/png")- return {+ _c = (shot or {}).get("canvas") or {}+ out = { "title": title, "image": b64, "thumbnailDataUrl": f"data:{mime};base64,{b64}",@@ -262,6 +288,14 @@ def _build_shot(win, label: str, max_width: int) -> dict | None: "auto-pulls. Request it with capture:true; captureMaxWidth defaults to " f"{_SHOT_DEFAULT_MAX} and clamps to {_SHOT_MIN_MAX}-{_SHOT_MAX_MAX}."), }+ if _c.get("checked"):+ out["canvasRendered"] = bool(_c.get("rendered"))+ out["canvasWaitedSec"] = waited+ if not _c.get("rendered"):+ out["_canvasHint"] = ("The canvas region is still UNRENDERED after 10s of "+ "polling - treat this frame as proof of the window, "+ "not the part, and recapture rather than render it.")+ return out except Exception: return None @@ -516,10 +550,20 @@ def handle_show_library(kicad_info: dict, args: dict) -> dict: return {"success": False, "error": "libraryName is required", "_hint": "kicad_show_library {\"libraryName\":\"Device\"} — add kind:\"footprint\" for the footprint side."} kind = (a.get("kind") or "symbol").lower()+ # #49 follow-up: symbolName/footprintName were DROPPED here, and the+ # library-browse response then said "No symbol was requested" - which was+ # untrue when the caller had passed one. Silently discarding an argument and+ # then denying it was given is worse than ignoring it.+ want_sym = a.get("symbolName") or ""+ want_fp = a.get("footprintName") or "" if kind in ("footprint", "fp"):- r = handle_open_footprint_editor(kicad_info, {"library": lib, "timeout": a.get("timeout")})+ r = handle_open_footprint_editor(kicad_info, {+ "library": lib, "timeout": a.get("timeout"),+ **({"footprintName": want_fp} if want_fp else {})}) else:- r = handle_open_symbol_editor(kicad_info, {"libraryName": lib, "timeout": a.get("timeout")})+ r = handle_open_symbol_editor(kicad_info, {+ "libraryName": lib, "timeout": a.get("timeout"),+ **({"symbolName": want_sym} if want_sym else {})}) if isinstance(r, dict): r.setdefault("surface", "library") return r
handlers/win_focus.py+110@@ -12,7 +12,12 @@ documented AttachThreadInput technique (attach our thread to both the current-fg and target threads) so the restore reliably works cross-process. """ +import ctypes+import ctypes.wintypes+import json+import os import sys+import time IS_WINDOWS = sys.platform == "win32" @@ -1528,3 +1533,108 @@ def announce_foreground(action: str, seconds: int = 3) -> None: _t.sleep(1.2) # give the toast a beat to render BEFORE the grab except Exception: pass+++# ── PLACEMENT MEMORY (wiki #48, Colby/Caleb, 2026-08-20) ────────────────────+# A bridge-initiated restart used to lose where the user had put their windows.+# Caleb drags KiCad to his external monitor, the bridge restarts it for the+# footprint-cache remedy, and Windows places the fresh process on the PRIMARY+# display: "it keeps moving and annoying." Restoring placement is not focus+# steal, so it belongs inside the background-first contract, not outside it.+_PLACEMENT_FILE = os.path.join(+ os.environ.get("USERPROFILE") or os.path.expanduser("~"),+ ".adom", "kicad-bridge-placement.json")+++def capture_placement() -> dict:+ """Where every KiCad window is RIGHT NOW, keyed by window kind.++ Keyed by kind ("symbol editor", "pcb editor", ...) rather than hwnd,+ because the whole point is to survive a restart, and hwnds do not."""+ out = {}+ try:+ for h in _enum_kicad_hwnds():+ try:+ if h in _parked:+ continue # never memorise the park position+ _b = ctypes.create_unicode_buffer(160)+ _user32.GetWindowTextW(h, _b, 160)+ title = (_b.value or "").lower()+ kind = next((k for k in ("symbol editor", "footprint editor", "pcb editor",+ "schematic editor", "3d viewer", "kicad")+ if k in title), None)+ if not kind:+ continue+ r = ctypes.wintypes.RECT()+ _user32.GetWindowRect(h, ctypes.byref(r))+ if r.right - r.left < 200 or r.bottom - r.top < 150:+ continue # a minimised/parked stub is not a placement+ out[kind] = {"x": r.left, "y": r.top,+ "w": r.right - r.left, "h": r.bottom - r.top,+ "maximized": bool(_user32.IsZoomed(h))}+ except Exception:+ continue+ except Exception:+ pass+ if out:+ try:+ os.makedirs(os.path.dirname(_PLACEMENT_FILE), exist_ok=True)+ prev = {}+ try:+ with open(_PLACEMENT_FILE) as f:+ prev = json.load(f)+ except Exception:+ prev = {}+ prev.update(out)+ with open(_PLACEMENT_FILE, "w") as f:+ json.dump(prev, f, indent=2)+ except Exception:+ pass+ return out+++def restore_placement(saved: dict = None, waits: int = 40) -> dict:+ """Put fresh KiCad windows back where the user had them.++ Uses the on-disk memory when no live capture was taken, so even a COLD+ spawn lands on the monitor they last used rather than the primary."""+ if saved is None:+ try:+ with open(_PLACEMENT_FILE) as f:+ saved = json.load(f)+ except Exception:+ saved = {}+ if not saved:+ return {"restored": 0, "reason": "no remembered placement"}++ SWP_NOACTIVATE, SWP_NOZORDER = 0x0010, 0x0004+ SW_SHOWNOACTIVATE, SW_SHOWMAXIMIZED = 4, 3+ done, seen = 0, set()+ for _ in range(waits):+ for h in _enum_kicad_hwnds():+ try:+ if h in seen:+ continue+ _b = ctypes.create_unicode_buffer(160)+ _user32.GetWindowTextW(h, _b, 160)+ title = (_b.value or "").lower()+ kind = next((k for k in saved if k in title), None)+ if not kind:+ continue+ p = saved[kind]+ # NEVER leave it minimised: a parked window blanks PrintWindow,+ # which silently breaks the evidence/shot pipeline (#48 ask 4).+ _user32.ShowWindow(h, SW_SHOWMAXIMIZED if p.get("maximized")+ else SW_SHOWNOACTIVATE)+ if not p.get("maximized"):+ _user32.SetWindowPos(h, 0, int(p["x"]), int(p["y"]),+ int(p["w"]), int(p["h"]),+ SWP_NOACTIVATE | SWP_NOZORDER)+ seen.add(h)+ done += 1+ except Exception:+ continue+ if done >= len(saved):+ break+ time.sleep(0.5)+ return {"restored": done, "kinds": sorted(saved.keys())}
handlers/win_taskbar.py+435−68@@ -10,8 +10,11 @@ handlers/taskbar_overlay.py in fusion): an in-bridge ITaskbarList3::SetOverlayIc is IN-PROCESS-ONLY. The COM call returns S_OK cross-process and the shell silently ignores it (verified live here 2026-08-17: badges never rendered on kicad.exe's buttons). The working path is AD-core's `desktop_taskbar` verb: hand it the badge-PNG as base64 (resources/adom-overlay-32.png — fusion's exact asset, mark anchored-bottom-right of a transparent 32px canvas so Windows renders it at the right size)+PNG as base64 (resources/adom-overlay-32.png — mark anchored TOP-RIGHT of a+transparent 32px canvas, matching how pup places its favicon badge; John,+2026-08-21: "put the favicon in the upper right corner instead of the lower+right". Windows renders the overlay from that canvas, so the anchor in the+asset is what decides the corner) and AD paints the overlay natively, cross-process. set: desktop_taskbar {hwnd, overlay: {icon: <b64 png>, tooltip}}@@ -28,7 +31,6 @@ iconPath is a real .ico extracted from the exe itself — a fresh identity gets fresh icon lookup. kicad.exe's cache entry is healthy; its windows stay native. """ import os-import subprocess import sys import threading from pathlib import Path@@ -36,18 +38,27 @@ from pathlib import Path IS_WINDOWS = sys.platform == "win32" _BADGE_PATH = str(Path(__file__).resolve().parent.parent / "resources" / "adom-overlay-32.png")-_ICON_DIR = os.path.join(os.environ.get("USERPROFILE") or str(Path.home()), ".adom", "kicad-icons") -# exe basename (lower) -> (appId, displayName). Only the exes whose shell icon-# cache is known-poisoned; kicad.exe renders fine natively.-_IDENTITY_EXES = {- "eeschema.exe": ("Adom.KiCad.Eeschema", "KiCad Schematic Editor"),- "pcbnew.exe": ("Adom.KiCad.Pcbnew", "KiCad PCB Editor"),-}+# ⛔ THIS BRIDGE STAMPS NO AUMIDs. EVER. (John, 2026-08-23: "i just want+# default kicad behavior.")+#+# History, so nobody rebuilds this: weeks ago an AI stamped per-window AUMIDs+# on eeschema/pcbnew windows and at least one stamp ran before icon extraction+# succeeded - Windows caches the icon PER AUMID STRING, so those buttons became+# a white generic box that survived KiCad reinstalls. The first "fix" (.v2+# suffix, 0.9.240-242) replaced the poison with Adom-flavoured identity, which+# was still an override of default KiCad behaviour. The real fix is REMOVAL:+# `_identity_repair()` below clears the per-window AppUserModelID property on+# every eeschema/pcbnew window and deletes our Adom.KiCad.* registry+# registrations, so buttons fall back to the exes' own native icons.+#+# The overlay badge and the progress bar DO NOT need an AUMID - they ride+# ITaskbarList3 (SetOverlayIcon/SetProgressValue) against the hwnd's button+# directly. Activity indication keeps working on fully native buttons.+_ONCE_STAMPED_EXES = {"eeschema.exe", "pcbnew.exe"}+_AUMID_PREFIX = "Adom.KiCad." _badged: set = set()-_identified: set = set()-_icons_extracted = False _B64 = None _LOCK = threading.Lock() @@ -84,68 +95,209 @@ def _hwnd_exe_base(hwnd) -> str: return "" -def _ensure_exe_icons() -> None:- """Extract eeschema/pcbnew .ico files from the installed exes (once). Runs a- tiny PowerShell because a stdlib-only HICON→.ico writer isn't worth owning."""- global _icons_extracted- if _icons_extracted or not IS_WINDOWS:- return- if all(os.path.isfile(os.path.join(_ICON_DIR, e.replace(".exe", ".ico")))- for e in _IDENTITY_EXES):- _icons_extracted = True- return+def _clear_window_aumid(hwnd) -> bool:+ """Blank the per-window PKEY_AppUserModel_ID (VT_EMPTY) so the button+ falls back to the exe's own identity. ctypes COM because the bridge has+ no pywin32; AD's desktop_set_window_identity has no clear path (an empty+ appId no-ops, verified live)."""+ import ctypes+ from ctypes import wintypes++ class GUID(ctypes.Structure):+ _fields_ = [("d1", ctypes.c_uint32), ("d2", ctypes.c_uint16),+ ("d3", ctypes.c_uint16), ("d4", ctypes.c_ubyte * 8)]++ class PROPERTYKEY(ctypes.Structure):+ _fields_ = [("fmtid", GUID), ("pid", ctypes.c_uint32)]++ class PROPVARIANT(ctypes.Structure):+ _fields_ = [("vt", ctypes.c_uint16), ("r1", ctypes.c_uint16),+ ("r2", ctypes.c_uint16), ("r3", ctypes.c_uint16),+ ("data", ctypes.c_byte * 16)]++ try:+ iid_ips = GUID(0x886D8EEB, 0x8CF2, 0x4446, (ctypes.c_ubyte * 8)(+ 0x8D, 0x02, 0xCD, 0xBA, 0x1D, 0xBD, 0xCF, 0x99))+ pkey = PROPERTYKEY(GUID(0x9F4C2855, 0x9F79, 0x4B39, (ctypes.c_ubyte * 8)(+ 0xA8, 0xD0, 0xE1, 0xD4, 0x2D, 0xE1, 0xD5, 0xF3)), 5)+ ps = ctypes.c_void_p()+ hr = ctypes.windll.shell32.SHGetPropertyStoreForWindow(+ wintypes.HWND(hwnd), ctypes.byref(iid_ips), ctypes.byref(ps))+ if hr != 0 or not ps.value:+ return False+ # IPropertyStore vtable: QI(0) AddRef(1) Release(2) GetCount(3)+ # GetAt(4) GetValue(5) SetValue(6) Commit(7)+ vtbl = ctypes.cast(ps.value,+ ctypes.POINTER(ctypes.POINTER(ctypes.c_void_p))).contents+ set_value = ctypes.WINFUNCTYPE(+ ctypes.c_long, ctypes.c_void_p, ctypes.POINTER(PROPERTYKEY),+ ctypes.POINTER(PROPVARIANT))(vtbl[6])+ commit = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_void_p)(vtbl[7])+ release = ctypes.WINFUNCTYPE(ctypes.c_long, ctypes.c_void_p)(vtbl[2])+ empty = PROPVARIANT() # vt == VT_EMPTY+ ok = set_value(ps, ctypes.byref(pkey), ctypes.byref(empty)) == 0+ ok = (commit(ps) == 0) and ok+ release(ps)+ return ok+ except Exception:+ return False+++def _delete_adom_aumid_registrations() -> int:+ """Delete every HKCU AppUserModelId registration we ever created+ (Adom.KiCad.*): icon paths, display names, the poisoned strings, all of+ it. Best-effort; returns how many keys went."""+ n = 0+ try:+ import winreg+ root = r"Software\Classes\AppUserModelId"+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, root) as k:+ subs, i = [], 0+ while True:+ try:+ subs.append(winreg.EnumKey(k, i))+ i += 1+ except OSError:+ break+ for s in subs:+ if s.startswith(_AUMID_PREFIX):+ try:+ winreg.DeleteKey(winreg.HKEY_CURRENT_USER, root + "\\" + s)+ n += 1+ except OSError:+ pass+ except Exception:+ pass+ return n+++def _rebuild_button(hwnd) -> None:+ """Win11 bakes the button's tile at creation; a cleared AUMID only shows+ after the button is recreated. Brief hide + re-show (no activation) forces+ that - except on the foreground window, which we never blink.""" try:+ import ctypes+ u = ctypes.windll.user32+ if u.GetForegroundWindow() == hwnd:+ return+ if u.IsIconic(hwnd):+ u.ShowWindow(hwnd, 0) # SW_HIDE+ u.ShowWindow(hwnd, 7) # SW_SHOWMINNOACTIVE+ else:+ u.ShowWindow(hwnd, 0)+ u.ShowWindow(hwnd, 8) # SW_SHOWNA+ except Exception:+ pass+++def _identity_repair() -> None:+ """One-shot per boot: remove every taskbar-identity override this bridge+ (or any earlier AI experiment) ever applied, restoring default KiCad+ taskbar behaviour. Idempotent - a clean box is a fast no-op."""+ cleared = _delete_adom_aumid_registrations()+ fixed = 0+ for h in _enum_all_kicad_hwnds():+ if _hwnd_exe_base(h) not in _ONCE_STAMPED_EXES:+ continue+ if _clear_window_aumid(h):+ _rebuild_button(h)+ fixed += 1+ if cleared or fixed:+ print("[taskbar] identity repair: %d registry key(s) removed, "+ "%d window(s) restored to native identity" % (cleared, fixed))+ _repair_kicad_shortcut_aumids()+++def _repair_kicad_shortcut_aumids() -> None:+ """Fill the AppUserModelID field KiCad's OWN installer forgot (solved live+ on John's taskbar, 2026-08-23).++ Standalone editor processes present AUMIDs like Kicad.Kicad.pcbnew.10.0+ (built at runtime; the pattern is confirmed by reading the taskbar buttons'+ AutomationIds). The installer writes that AUMID onto the MAIN shortcut only+ and leaves it blank on the standalone editor shortcuts, so the shell has no+ shortcut to resolve the editor group's icon from - on some machines it+ falls back to the window's icon (ConfRoomROG), on others it paints the+ generic white page (AdomLapper; why the fallback differs is unsolved).+ Writing KiCad's own AUMID onto KiCad's own user-level shortcut makes the+ icon resolution deterministic - the exact mechanism that keeps the main+ KiCad button correct. NO Adom identity involved.++ The mapping takes effect when explorer next (re)builds its Start Menu+ AUMID index (logon / explorer restart); we never restart explorer+ ourselves. Idempotent: shortcuts already carrying an AUMID are skipped."""+ try:+ import subprocess from kicad_detect import detect_kicad info = detect_kicad() or {}- bin_dir = os.path.dirname(info.get("kicad_cli_exe") or info.get("kicadCli") or "")- if not bin_dir:+ ver = str(info.get("version") or info.get("kicadVersion") or "")+ ver = ".".join(ver.split(".")[:2]) if ver else ""+ if not ver: return ps = r'''-Add-Type -AssemblyName System.Drawing-$sig = '[DllImport("shell32.dll", CharSet=CharSet.Unicode)] public static extern uint ExtractIconExW(string f, int i, IntPtr[] big, IntPtr[] small, uint n);'-Add-Type -MemberDefinition $sig -Name IX -Namespace W-New-Item -ItemType Directory -Force -Path "%OUT%" | Out-Null-foreach ($exe in @("eeschema","pcbnew")) {- $big = New-Object IntPtr[] 1; $small = New-Object IntPtr[] 1- [W.IX]::ExtractIconExW("%BIN%\$exe.exe", 0, $big, $small, 1) | Out-Null- if ($big[0] -ne [IntPtr]::Zero) {- $icon = [System.Drawing.Icon]::FromHandle($big[0])- $fs = [System.IO.File]::Create((Join-Path "%OUT%" ($exe + ".ico")))- $icon.Save($fs); $fs.Close()+$cs = @"+using System;+using System.Runtime.InteropServices;+using System.Runtime.InteropServices.ComTypes;+public class LnkAumid {+ [StructLayout(LayoutKind.Sequential, Pack=4)] public struct PROPERTYKEY { public Guid fmtid; public uint pid; }+ [StructLayout(LayoutKind.Explicit)] public struct PropVariant {+ [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr p;+ }+ [ComImport, Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]+ public interface IPropertyStore {+ int GetCount(out uint c); int GetAt(uint i, out PROPERTYKEY k);+ int GetValue(ref PROPERTYKEY k, out PropVariant v);+ int SetValue(ref PROPERTYKEY k, ref PropVariant v); int Commit(); }+ [ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink {}+ public static bool Set(string path, string aumid) {+ var link = (object)new CShellLink();+ var pf = (IPersistFile)link;+ pf.Load(path, 2);+ var ps = (IPropertyStore)link;+ var k = new PROPERTYKEY { fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5 };+ PropVariant cur; ps.GetValue(ref k, out cur);+ if (cur.vt == 31 && Marshal.PtrToStringUni(cur.p) != "") return false;+ var v = new PropVariant { vt = 31, p = Marshal.StringToCoTaskMemUni(aumid) };+ bool ok = ps.SetValue(ref k, ref v) == 0 && ps.Commit() == 0;+ pf.Save(path, true);+ Marshal.FreeCoTaskMem(v.p);+ return ok;+ }+}+"@+Add-Type -TypeDefinition $cs+$v = "%VER%"+$map = @{+ ("PCB Editor " + $v + " (standalone).lnk") = ("Kicad.Kicad.pcbnew." + $v)+ ("Schematic Editor " + $v + " (standalone).lnk") = ("Kicad.Kicad.eeschema." + $v)+ ("Gerber Viewer " + $v + ".lnk") = ("Kicad.Kicad.gerbview." + $v)+}+$n = 0+$root = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\KiCad " + $v+foreach ($name in $map.Keys) {+ $p = Join-Path $root $name+ if (Test-Path $p) { try { if ([LnkAumid]::Set($p, $map[$name])) { $n++ } } catch {} } }-'''.replace("%OUT%", _ICON_DIR).replace("%BIN%", bin_dir)+Write-Output $n+'''.replace("%VER%", ver) cnw = getattr(subprocess, "CREATE_NO_WINDOW", 0)- subprocess.run(["powershell", "-NoProfile", "-Command", ps],- capture_output=True, timeout=30, creationflags=cnw)- _icons_extracted = True+ r = subprocess.run(["powershell", "-NoProfile", "-Command", ps],+ capture_output=True, timeout=60, creationflags=cnw)+ n = (r.stdout or b"").decode(errors="replace").strip()+ if n and n != "0":+ print("[taskbar] wrote KiCad's own AUMID onto %s standalone "+ "shortcut(s) (installer gap); effective at next explorer "+ "restart/logon" % n) except Exception: pass -def _ensure_identity(hwnd) -> None:- """Bypass the poisoned per-exe shell icon cache: stamp eeschema/pcbnew- windows with a per-exe AUMID + real iconPath (idempotent per hwnd)."""- if hwnd in _identified:- return- exe = _hwnd_exe_base(hwnd)- ident = _IDENTITY_EXES.get(exe)- if not ident:- _identified.add(hwnd) # kicad.exe & friends: nothing to fix- return- _ensure_exe_icons()- app_id, display = ident- ico = os.path.join(_ICON_DIR, exe.replace(".exe", ".ico"))- args = {"hwnd": int(hwnd), "appId": app_id, "displayName": display}- if os.path.isfile(ico):- args["iconPath"] = ico- if _ad("desktop_set_window_identity", args):- _identified.add(hwnd)-- def apply_adom_badge(hwnd) -> bool:- """Repair the button's base icon (identity) then stamp the Adom overlay- (idempotent per hwnd). Best-effort: never raises, never blocks a verb."""+ """Stamp the Adom overlay on the window's taskbar button (idempotent per+ hwnd). ITaskbarList3 by hwnd - touches no AUMID, no base icon. Best-effort:+ never raises, never blocks a verb.""" try: from handlers.settings import get_settings if not get_settings().get("overlayBadges", True):@@ -155,7 +307,6 @@ def apply_adom_badge(hwnd) -> bool: if hwnd in _badged: return True with _LOCK:- _ensure_identity(hwnd) icon = _badge_b64() if not icon: return False@@ -215,13 +366,229 @@ def badge_existing_on_boot() -> None: return import time - def _later():- time.sleep(5)+ # INVERTED (2026-08-22, twice in one day and each move follows from the+ # badge's one-sentence meaning). First: boot re-BADGING was retired - a+ # freshly booted bridge is driving nothing, so stamping at boot lied.+ # Then the live idle-clear proof caught the mirror-image gap: a badge+ # stamped by the PREVIOUS bridge process survived the restart, because the+ # fresh process had no _badged memory and an unarmed watchdog - overlays+ # outlived a deploy by design. Same sentence, same conclusion: at boot,+ # Adom is driving nothing, so any overlay found on a KiCad window is stale.+ # CLEAR them, off the boot path.+ def _boot_clear():+ import time as _t+ _t.sleep(6)+ try:+ for h in _enum_all_kicad_hwnds():+ _ad("desktop_taskbar", {"hwnd": int(h), "overlay": {"badge": "none"}})+ _paint_progress(h, None) # a stale bar is the same lie+ except Exception:+ pass+ threading.Thread(target=_boot_clear, daemon=True,+ name="kicad-boot-badge-clear").start()++ def _repair():+ import time as _t+ _t.sleep(8) # off the boot path try:- from handlers.settings import get_settings- if get_settings().get("overlayBadges", True):- rebadge_all(True)+ _identity_repair() except Exception: pass+ threading.Thread(target=_repair, daemon=True,+ name="kicad-identity-repair").start()+ return+++# ── ACTIVITY-DRIVEN overlay (John, 2026-08-22) ──────────────────────────────+# THE HIGH-LEVEL GOAL of the Adom overlay: tell the user "Adom is driving+# KiCad for you, right now". Which means it must track ACTIVITY, not ownership:+#+# * John drove his own KiCad via web control and saw NO badge, because the+# old code stamped only windows the bridge had itself spawned. But "who+# launched it" is not what the badge claims - "who is driving it" is, and+# a verb navigating the user's own KiCad is Adom driving it.+# * The badge must also COME OFF when the driving stops, or it decays into+# wallpaper. Five minutes with no KiCad verb -> cleared -> "it's yours".+#+# The 0.9.154 lesson still holds in its correct scope: never badge a user's+# KiCad that Adom is NOT touching (boot-time re-badging of enumerated windows+# was wrong). Activity semantics satisfy both: badge while verbs run against+# KiCad, whoever launched it; clear on idle.++_IDLE_CLEAR_SEC = 300 # "adom is no longer driving kicad, it's yours"+_last_drive = 0.0+_watchdog_on = False+# Verbs currently IN FLIGHT. The first ticker keyed "driving" off the+# note_driving timestamp alone, which is stamped once at dispatch START - so a+# 23s verb had its bar cleared at ~4s, mid-work (caught on a live screenshot:+# the direct paint showed, the ticker's did not). A counter cannot lie about+# in-flight the way a timestamp does.+_inflight = 0+_inflight_lock = threading.Lock()+++def _enum_all_kicad_hwnds() -> set:+ """EVERY live KiCad window, not just bridge-owned - during activity the+ badge belongs on whatever Adom is actually driving."""+ try:+ from handlers.win_focus import _enum_kicad_hwnds+ return _enum_kicad_hwnds()+ except Exception:+ return set()+++def note_driving() -> None:+ """A KiCad verb is running: stamp the overlay everywhere and reset the+ idle clock. Called from the dispatch chokepoint; best-effort, never+ blocks a verb."""+ global _last_drive, _watchdog_on, _inflight+ import time as _t+ _last_drive = _t.time()+ with _inflight_lock:+ _inflight += 1+ try:+ from handlers.settings import get_settings+ if not get_settings().get("overlayBadges", True):+ return+ except Exception:+ pass+ try:+ for h in _enum_all_kicad_hwnds():+ apply_adom_badge(h)+ except Exception:+ pass+ if not _watchdog_on:+ _watchdog_on = True+ threading.Thread(target=_idle_watchdog, daemon=True,+ name="kicad-badge-idle").start()+ global _prog_ticker_on+ if not _prog_ticker_on:+ _prog_ticker_on = True+ print("[taskbar] progress ticker starting")+ threading.Thread(target=_progress_ticker, daemon=True,+ name="kicad-taskbar-progress").start()+++def note_done() -> None:+ """The verb finished (success, failure, or raise): its driving is over.+ Paired with note_driving at the dispatch chokepoint's finally."""+ global _inflight, _last_drive+ import time as _t+ with _inflight_lock:+ _inflight = max(0, _inflight - 1)+ _last_drive = _t.time() # idle clock starts when the LAST verb ends - threading.Thread(target=_later, daemon=True, name="kicad-boot-badge").start()++def _idle_watchdog() -> None:+ """Clear the overlay after _IDLE_CLEAR_SEC with no KiCad verb activity.+ Checks every 30s; a new verb simply re-stamps, so flapping costs nothing."""+ import time as _t+ while True:+ _t.sleep(30)+ try:+ if _last_drive and (_t.time() - _last_drive) >= _IDLE_CLEAR_SEC:+ # Sweep every enumerated window, not just _badged: process+ # restarts (deploys, the footprint-cache remedy) orphan badges+ # this set has never heard of.+ for h in (_enum_all_kicad_hwnds() | set(_badged)):+ clear_adom_badge(h) if h in _badged else _ad(+ "desktop_taskbar", {"hwnd": int(h), "overlay": {"badge": "none"}})+ except Exception:+ pass+++# ── Taskbar PROGRESS instead of the orange flash (John, 2026-08-22) ────────+# The orange flash was never ours: this bridge calls FlashWindowEx nowhere.+# Windows itself flashes the button when a backgrounded KiCad's self-raise is+# denied by the focus sentinel - a side effect of the background-first+# contract. And a flash NAGS: it sits there until the user clicks it away, so+# it converts "Adom is working" into "you must do something". A progress bar+# says the same thing and then removes itself, which is the difference between+# informing and demanding. Adopted from the fusion bridge, toggle+# `activityIndicator` (progress|flash|both|none, default progress).++_FLASHW_STOP = 0+++def _flash_stop(hwnd) -> None:+ """Cancel an active OS attention-flash on a window (FLASHW_STOP)."""+ try:+ import ctypes+ class FLASHWINFO(ctypes.Structure):+ _fields_ = [("cbSize", ctypes.c_uint), ("hwnd", ctypes.c_void_p),+ ("dwFlags", ctypes.c_uint), ("uCount", ctypes.c_uint),+ ("dwTimeout", ctypes.c_uint)]+ fi = FLASHWINFO(ctypes.sizeof(FLASHWINFO), int(hwnd), _FLASHW_STOP, 0, 0)+ ctypes.windll.user32.FlashWindowEx(ctypes.byref(fi))+ except Exception:+ pass+++def _paint_progress(hwnd, percent) -> None:+ """Paint (or clear) the taskbar progress bar on one KiCad button."""+ if percent is None:+ _ad("desktop_taskbar", {"hwnd": int(hwnd), "progress": {"state": "none"}})+ elif percent < 0:+ _ad("desktop_taskbar", {"hwnd": int(hwnd),+ "progress": {"state": "indeterminate"}})+ else:+ _ad("desktop_taskbar", {"hwnd": int(hwnd), "progress": {+ "state": "normal", "value": max(1, min(100, int(percent)))}})+++_prog_ticker_on = False+++def _progress_ticker() -> None:+ """While verbs run, mirror the bridge's MEASURED live progress frames onto+ every KiCad taskbar button; clear the bars the moment activity stops.++ 2s cadence with a cached hwnd set - deliberately NOT per-paint enumeration+ at 0.7s, which the fusion code review measured at ~780 subprocess spawns+ per long verb."""+ import time as _t+ global _prog_ticker_on+ idle_polls = 0+ hwnds = set()+ try:+ while True:+ _t.sleep(2)+ mode = "progress"+ try:+ from handlers.settings import get_settings+ mode = get_settings().get("activityIndicator", "progress")+ except Exception:+ pass+ with _inflight_lock:+ _busy = _inflight > 0+ driving = _busy or (_last_drive and (_t.time() - _last_drive) < 3.5)+ if driving:+ idle_polls = 0+ hwnds = _enum_all_kicad_hwnds() or hwnds+ pct = None+ try:+ from handlers import progress as _pg+ frames = _pg.live(mine_only=False)+ if frames:+ pct = min((f.get("percent") for f in frames+ if f.get("percent") is not None), default=-1)+ pct = -1 if pct is None else pct+ except Exception:+ pct = -1+ for h in hwnds:+ if mode in ("progress", "both"):+ _paint_progress(h, pct if pct is not None else -1)+ if mode in ("progress", "none"):+ _flash_stop(h) # the OS flash contradicts the bar+ else:+ idle_polls += 1+ if hwnds:+ for h in hwnds:+ _paint_progress(h, None) # bars vanish WITH the work+ hwnds = set()+ if idle_polls > 5:+ _prog_ticker_on = False+ return # nothing to do; next verb restarts us+ except Exception as e:+ print(f"[taskbar] ticker DIED: {type(e).__name__}: {e}")+ _prog_ticker_on = False
plugin_payload/adom_bridge.py+67@@ -2903,6 +2903,67 @@ class _ModuleFpTable: raise _ProbeUnavailable("pcbnew has no FootprintLoad on this build") return fn(library, footprint) ++def _ui_probe_fp_io() -> dict:+ """What footprint FILE-IO capability does this KiCad's Python expose?++ Wiki #51 (John: "try FootprintLibCreate and friends"). The module-level+ probe (#44) found no FootprintEnumerate/FootprintLoad on `pcbnew` itself -+ but KiCad has historically exposed them as METHODS of an IO/plugin class+ (PCB_IO in 6/7, PCB_IO_KICAD_SEXPR in 8+), which the earlier inventory's+ name filter would not have surfaced. Report what exists, so the next step+ is informed rather than another guess."""+ import pcbnew+ out = {}+ out["moduleAttrs"] = sorted(n for n in dir(pcbnew)+ if "io" in n.lower() and not n.startswith("_"))[:25]+ for cls in ("PCB_IO_KICAD_SEXPR", "PCB_IO", "IO_MGR", "PCB_IO_MGR", "PLUGIN"):+ c = getattr(pcbnew, cls, None)+ if c is None:+ out[cls] = "absent"+ continue+ try:+ methods = [m for m in dir(c) if "ootprint" in m]+ out[cls] = methods or "(class exists, no footprint methods)"+ except Exception as e:+ out[cls] = f"introspect failed: {type(e).__name__}"+ return out+++def _ui_save_footprint_via_api(src_pretty: str, src_name: str,+ dst_pretty: str, dst_name: str) -> dict:+ """THE EXPERIMENT: write a footprint into a library THROUGH KiCad's own IO,+ from inside the running process - then the caller checks whether the+ Footprint Editor can see it WITHOUT a restart. If yes, installs stop+ needing the ~150s restart entirely; if no, the cache ignores even KiCad's+ own writes and the avenue is closed with evidence."""+ import pcbnew+ io = None+ for cls in ("PCB_IO_KICAD_SEXPR", "PCB_IO"):+ c = getattr(pcbnew, cls, None)+ if c is not None:+ try:+ io = c()+ break+ except Exception:+ continue+ if io is None:+ return {"ok": False, "error": "no PCB_IO class constructible in this build"}+ try:+ fp = io.FootprintLoad(src_pretty, src_name)+ if fp is None:+ return {"ok": False, "error": f"FootprintLoad returned None for {src_name}"}+ except Exception as e:+ return {"ok": False, "error": f"load: {type(e).__name__}: {e}"}+ try:+ if dst_name and dst_name != src_name:+ fp.SetFPID(pcbnew.LIB_ID("", dst_name))+ io.FootprintSave(dst_pretty, fp)+ return {"ok": True, "saved": dst_name or src_name, "into": dst_pretty,+ "via": type(io).__name__}+ except Exception as e:+ return {"ok": False, "error": f"save: {type(e).__name__}: {e}"}+ def _ui_probe_footprint(library: str, footprint: str) -> dict: """Ask KICAD ITSELF whether it can see and load this footprint. @@ -3057,6 +3118,12 @@ def _dispatch(method: str, params: dict) -> Any: return {"payloadVersion": __version__, "marker": "reload-probe-1", "pid": _state["pid"], "exeName": _state["exe_name"], "uptimeMs": int(time.time() * 1000) - _STARTED_AT_MS}+ if method == "probe_fp_io":+ return _call_on_ui_thread(_ui_probe_fp_io)+ if method == "save_footprint_via_api":+ return _call_on_ui_thread(lambda: _ui_save_footprint_via_api(+ str(params.get("srcPretty") or ""), str(params.get("srcName") or ""),+ str(params.get("dstPretty") or ""), str(params.get("dstName") or ""))) if method == "probe_footprint": return _call_on_ui_thread(lambda: _ui_probe_footprint( str(params.get("library") or ""), str(params.get("footprint") or "")))
resources/adom-overlay-32.png⋯ 1 unchanged line ⋯
resources/demo-audio/board.mp3added⋯ 1 unchanged line ⋯
resources/demo-audio/board3d.mp3added⋯ 1 unchanged line ⋯
resources/demo-audio/footprint.mp3added⋯ 1 unchanged line ⋯
resources/demo-audio/prepare.mp3added⋯ 1 unchanged line ⋯
resources/demo-audio/schematic.mp3added⋯ 1 unchanged line ⋯
resources/demo-audio/symbol.mp3added⋯ 1 unchanged line ⋯
server.py+87@@ -606,6 +606,65 @@ def _detect_running_kicad_instances() -> list[dict]: ++def _handle_rescan_libraries(kicad_info: dict, args: dict) -> dict:+ """kicad_rescan_libraries - make newly installed parts visible, ONCE.++ Wiki #51 (John): per-part cache restarts cost ~150s each and dominated a+ 17-part run at 75% of total time. The batch fix: install N parts, call this+ once, then show them all - one rescan instead of N.++ Today the rescan IS a KiCad process restart, because KiCad 10 caches+ footprint libraries process-wide and its Python API exposes no table+ reload (measured on #44/#47: a frame bounce does not clear it, a process+ restart does; pcbnew has no FootprintEnumerate/FootprintLoad to drive).+ The verb owns that cost explicitly so no later show-verb pays it by+ surprise. If a future KiCad exposes an in-process reload, this verb keeps+ its name and simply gets fast."""+ # First resort (#51 discovery): reload every open editor's library tree in+ # place via its Refresh menu action - seconds, no restart, nothing closed.+ if not args.get("force"):+ try:+ from handlers.open_footprint_editor import _tree_refresh, _find_footprint_editor+ from handlers.open_symbol_editor import _find_symbol_editor+ refreshed = []+ for h in (_find_footprint_editor(), _find_symbol_editor()):+ if h and _tree_refresh(h):+ refreshed.append(int(h))+ if refreshed:+ return {"success": True, "method": "tree-refresh",+ "refreshedEditors": refreshed,+ "_hint": ("Library trees reloaded in place - no restart. Parts "+ "installed before this call are now visible. If a show "+ "verb still reports library_cache_stale afterwards, call "+ "again with force:true for the full restart.")}+ except Exception:+ pass+ from handlers.open_footprint_editor import _restart_kicad_for_cache+ r = _restart_kicad_for_cache(kicad_info)+ if not r.get("restarted"):+ return {"success": False, "errorCode": "rescan_refused",+ "reason": r.get("reason"), "userWindows": r.get("userWindows"),+ "_hint": ("The bridge will not restart a KiCad a human launched. Ask the "+ "user to close it, then call again.")}+ # This verb's whole job is the rescan, so unlike the mid-show path it DOES+ # wait for the fresh instance - the caller asked for exactly this cost.+ from handlers.kicad_ui import _find_all_kicad_windows+ import time as _t+ for _ in range(80):+ _t.sleep(0.75)+ try:+ if _find_all_kicad_windows():+ return {"success": True, "restarted": True, "ready": True,+ "_hint": ("Library caches rebuilt. Every part installed before this "+ "call is now visible; show verbs will not trigger another "+ "restart until the next install-while-open.")}+ except Exception:+ pass+ return {"success": False, "restarted": True, "ready": False,+ "errorCode": "kicad_restarting", "statusVerb": "kicad_status",+ "error": "KiCad is restarting but did not answer within 60s; poll statusVerb."}+ def _handle_verb_times(kicad_info: dict, args: dict) -> dict: """kicad_verb_times - measured run times per verb ON THIS BOX. @@ -764,6 +823,7 @@ COMMAND_HANDLERS = { "list_footprints": handle_list_footprints, "progress": _handle_progress, "verb_times": _handle_verb_times,+ "rescan_libraries": _handle_rescan_libraries, # v1.7.12+: headless lint pre-checks via kicad-cli "run_erc": handle_run_erc, "lint_board": handle_lint_board,@@ -950,6 +1010,11 @@ def dispatch_command(command: str, args: dict) -> dict: # _NO_GUI: headless kicad-cli verbs (no window) — skip the dialog sweep. # _SPAWNS_WINDOW: verbs that may surface a dialog — get the ~3s settle-poll. # _SPAWNS_WINDOW_FOCUS: verbs that RAISE a KiCad window — restore user focus.+ # Read-only verbs: they observe KiCad, they do not drive it. They still get+ # the GUI-verb dialog sweep, but never the activity indicator.+ _PASSIVE_VERBS = {"status", "bridge_status", "state", "readiness", "window_info",+ "open_editors", "screenshot_all", "plugin_diagnose", "describe",+ "get_settings", "diagnostics", "check_for_updates"} _NO_GUI = {"run_drc", "run_erc", "lint_board", "lint_schematic", "lint_library", "format_upgrade", "export_gerber", "export_gerbers", "export_pdf", "export_svg", "export_step", "export_bom_csv", "adom_library_status",@@ -1016,6 +1081,20 @@ def dispatch_command(command: str, args: dict) -> dict: if command not in _NO_GUI: from handlers import win_focus as _wfg _wfg.begin_gui_verb()+ # The Adom overlay's whole meaning is "Adom is driving KiCad right+ # now" (John, 2026-08-22) - stamped on ACTIVITY at the chokepoint,+ # cleared by the idle watchdog. But OBSERVATION IS NOT DRIVING:+ # status/state/readiness are polled every 15s by the dashboard, and+ # counting those as activity meant the badge could never clear on+ # any box running it - permanent wallpaper through a side door.+ # (Caught live: a kicad_status call re-stamped freshly cleared+ # badges.) Only verbs that ACT on KiCad mark driving.+ if command not in _PASSIVE_VERBS:+ try:+ from handlers.win_taskbar import note_driving+ note_driving()+ except Exception:+ pass # 0.9.156 (John's catch): a window CLOSED while parked off-screen # makes KiCad persist the off-screen geometry, so the user's next # normal launch opens invisibly. Restore true positions before any@@ -1050,6 +1129,14 @@ def dispatch_command(command: str, args: dict) -> dict: _pgT.log_run(command, time.time() - _prog_started, ok=bool(_ok)) except Exception: pass+ # The taskbar activity indicator pairs with note_driving: the bar must+ # track the verb exactly, so mark done in the same always-runs finally.+ try:+ if command not in _NO_GUI and command not in _PASSIVE_VERBS:+ from handlers.win_taskbar import note_done+ note_done()+ except Exception:+ pass # Retire the live progress frame HERE, in the one block that runs even # when the handler raises. Anywhere later is unreachable on the error # path, and a leaked frame shows a poller a bar that never completes for