app
Adom Desktop - 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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
"""Handler for open_symbol_editor command.
Opens the KiCad Symbol Editor and optionally loads a specific symbol.
Windows: two-phase Win32 approach —
1. Send WM_COMMAND 20012 to the KiCad project manager to open the Symbol Editor
2. If a symbolName is specified, call navigate_symbol.ps1 which clicks the
search box, types the symbol name via SendKeys, and sends Ctrl+Shift+E.
macOS: launches the Symbol Editor app directly via `open -a`. Symbol-name
navigation requires Accessibility-framework permission for UI scripting,
which we don't request — handlers report this limitation in the response.
"""
import ctypes
import ctypes.wintypes
import subprocess
import sys
import time
from pathlib import Path
IS_WINDOWS = sys.platform == "win32"
IS_MACOS = sys.platform == "darwin"
# Win32 constants — defined cross-platform so module-level references don't
# error on macOS. The Win32 helpers below only use them through user32/kernel32,
# which are None on macOS; handlers branch on platform before calling helpers.
WM_COMMAND = 0x0111
# v1.7.6 fix (KiCad v2 Phase 4 menu dump audit): KiCad 10's Project
# Manager Tools menu IDs are: 20010=Schematic Editor, 20011=Symbol Editor,
# 20012=PCB Editor, 20013=Footprint Editor, 20014=Gerber Viewer.
# Previous value of 20012 sent the wrong command (PCB Editor) so this
# handler silently failed for KiCad 10 users — Symbol Editor never opened,
# PCB Editor sometimes flashed instead. Caught when Phase 3 tier-1 routing
# also sent 20012 to the bridge and got identical "did not appear" failure.
KICAD_SYMBOL_EDITOR_CMD_ID = 20011 # Project Manager > Tools > Symbol Editor
SW_RESTORE = 9
GW_OWNER = 4
if IS_WINDOWS:
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
else:
user32 = None
kernel32 = None
# Keep callback references alive to prevent GC
_callbacks = []
# Path to the navigate_symbol.ps1 script (same directory as the plugin root)
_SCRIPT_DIR = Path(__file__).resolve().parent.parent
_NAVIGATE_SCRIPT = _SCRIPT_DIR / "navigate_symbol.ps1"
def _find_windows_by_title(substring: str) -> list:
"""Find all visible windows whose title contains the given substring."""
results = []
WNDENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
)
def callback(hwnd, _lparam):
if user32.IsWindowVisible(hwnd):
length = user32.GetWindowTextLengthW(hwnd)
if length > 0:
buf = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, buf, length + 1)
if substring in buf.value:
owner = user32.GetWindow(hwnd, GW_OWNER)
results.append((hwnd, buf.value, owner))
return True
cb = WNDENUMPROC(callback)
_callbacks.append(cb)
user32.EnumWindows(cb, 0)
_callbacks.remove(cb)
return results
def _find_kicad_manager() -> int | None:
"""Find the KiCad project manager window handle."""
windows = _find_windows_by_title("KiCad")
for hwnd, title, owner in windows:
lower = title.lower()
if ("editor" not in lower
and "viewer" not in lower
and "calculator" not in lower
and owner == 0):
return hwnd
return None
def _find_symbol_editor() -> int | None:
"""Find an open Symbol Editor window."""
windows = _find_windows_by_title("Symbol Editor")
for hwnd, title, _owner in windows:
if "symbol editor" in title.lower():
return hwnd
return None
def _find_child_by_class_and_parent(parent_hwnd: int, child_class: str, parent_text: str) -> int | None:
"""Find a child window by class name and parent window text."""
result = [None]
WNDENUMPROC = ctypes.WINFUNCTYPE(
ctypes.c_bool, ctypes.wintypes.HWND, ctypes.wintypes.LPARAM
)
def callback(hwnd, _lparam):
cls_buf = ctypes.create_unicode_buffer(256)
user32.GetClassNameW(hwnd, cls_buf, 256)
if cls_buf.value == child_class:
parent = user32.GetParent(hwnd)
p_buf = ctypes.create_unicode_buffer(256)
user32.GetWindowTextW(parent, p_buf, 256)
if p_buf.value == parent_text:
result[0] = hwnd
return False
return True
cb = WNDENUMPROC(callback)
_callbacks.append(cb)
user32.EnumChildWindows(parent_hwnd, cb, 0)
_callbacks.remove(cb)
return result[0]
def _force_foreground(hwnd: int):
"""Bring a window to the foreground."""
user32.ShowWindow(hwnd, SW_RESTORE)
user32.SetForegroundWindow(hwnd)
user32.BringWindowToTop(hwnd)
def _navigate_to_symbol(sym_hwnd: int, symbol_name: str) -> dict:
"""Call the PowerShell script to navigate to a specific symbol.
Finds the search Edit control HWND from Python (reliable cross-process),
then passes it to the PowerShell script which handles focus, typing, and shortcuts.
Returns a dict with 'status' ('ok', 'partial', 'noload', 'error') and 'title' or 'error'.
"""
if not _NAVIGATE_SCRIPT.exists():
return {"status": "error", "error": f"Script not found: {_NAVIGATE_SCRIPT}"}
# Find the search Edit control from Python (more reliable than C# cross-process)
search_edit = _find_child_by_class_and_parent(sym_hwnd, "Edit", "searchCtrl")
if not search_edit:
return {"status": "error", "error": "Search edit control not found in Symbol Editor"}
try:
subprocess.run(
[
"powershell", "-ExecutionPolicy", "Bypass",
"-File", str(_NAVIGATE_SCRIPT),
"-SymEditorHwnd", str(sym_hwnd),
"-SearchEditHwnd", str(search_edit),
"-SymbolName", symbol_name,
],
capture_output=True, text=True, timeout=20,
)
except subprocess.TimeoutExpired:
pass # Script timed out, but actions may have been performed
except Exception:
pass # Script may crash during .NET cleanup but actions still succeed
# Verify result from Python by checking the window title directly.
# The PowerShell script often crashes during .NET cleanup (0xC0000409)
# but the actual Win32 actions (focus, click, type, shortcut) succeed.
time.sleep(0.5)
# Re-find the Symbol Editor (HWND may have changed if editor was reopened)
sym_hwnd_check = _find_symbol_editor()
if not sym_hwnd_check:
return {"status": "error", "error": "Symbol Editor closed unexpectedly"}
windows = _find_windows_by_title("Symbol Editor")
for _, title, _ in windows:
if symbol_name in title:
return {"status": "ok", "title": title}
if "symbol editor" in title.lower() and "[no symbol loaded]" not in title:
return {"status": "partial", "title": title}
return {"status": "noload", "title": "[no symbol loaded]"}
def _mac_open_symbol_editor(kicad_info: dict, args: dict) -> dict:
"""macOS: launch the Symbol Editor app directly. Symbol/library navigation
after launch is not supported (would require Accessibility permission)."""
parent = Path(kicad_info.get("base_dir", "")).parent
sym_app = parent / "Symbol Editor.app"
if not sym_app.exists():
return {
"success": False,
"error": f"Symbol Editor.app not found at {sym_app}",
"_hint": "Reinstall KiCad — the Symbol Editor app bundle is missing.",
}
try:
subprocess.Popen(
["open", "-a", str(sym_app)],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
except Exception as e:
return {"success": False, "error": f"Failed to launch Symbol Editor: {e}"}
symbol_name = args.get("symbolName", "")
library_name = args.get("libraryName", "")
if symbol_name or library_name:
return {
"success": True,
"output": "Symbol Editor opened. Navigation to a specific symbol is not implemented on macOS.",
"_hint": ("On macOS, ask the user to type the symbol name in the editor's "
"search box manually. UI automation requires Accessibility permission."),
}
return {"success": True, "output": "Symbol Editor opened."}
def handle_open_symbol_editor(kicad_info: dict, args: dict) -> dict:
"""Open the KiCad Symbol Editor and optionally load a specific symbol.
Args:
kicad_info: KiCad detection info dict.
args: Optional dict with:
- symbolName: Name of symbol to load (e.g. "ADOM_MCU").
- libraryName: Name of the library containing the symbol.
"""
if not kicad_info.get("installed"):
return {
"success": False,
"error": "KiCad not installed",
"_hint": "Report to the user and ask them to install KiCad from https://www.kicad.org/download/, then retry.",
}
if IS_MACOS:
return _mac_open_symbol_editor(kicad_info, args or {})
if not IS_WINDOWS:
return {"success": False, "error": f"open_symbol_editor not implemented on {sys.platform!r}"}
symbol_name = args.get("symbolName", "")
library_name = args.get("libraryName", "")
# Check if the symbol is already loaded in the Symbol Editor
if symbol_name:
sym_hwnd = _find_symbol_editor()
if sym_hwnd:
windows = _find_windows_by_title("Symbol Editor")
for hwnd, title, _ in windows:
if symbol_name in title:
_force_foreground(hwnd)
return {
"success": True,
"output": f"Symbol '{symbol_name}' is already loaded in the Symbol Editor.",
}
# --- Phase 1: Ensure Symbol Editor is open ---
sym_hwnd = _find_symbol_editor()
pathways_tried: list[str] = []
if not sym_hwnd:
# Phase 3 Tier 1: try the reverse bridge first. Two sub-paths
# because Symbol Editor is hosted INSIDE eeschema.exe (not as a
# separate process):
# 1a) If eeschema is already running, send menu_id 20390 to
# its Schematic Editor frame — proven path from Phase 2
# MVP testing. Opens Symbol Editor as a sub-window in the
# existing eeschema process.
# 1b) Else, send menu_id 20011 to kicad.exe Project Manager's
# Tools menu — this spawns a NEW eeschema.exe with Symbol
# Editor as its top-level window. Slower (new process) but
# works from a cold start.
try:
from handlers.bridge_client import try_wm_command_via_bridge
# 1a: prefer eeschema if alive
tier1 = try_wm_command_via_bridge(
exe_name="eeschema", menu_id=20390, frame_index=0,
)
tier1_source = "eeschema-20390"
if not tier1.get("success"):
# 1b: fall through to kicad project manager
tier1_pm = try_wm_command_via_bridge(
exe_name="kicad", menu_id=KICAD_SYMBOL_EDITOR_CMD_ID, frame_index=0,
)
if tier1_pm.get("success"):
tier1 = tier1_pm
tier1_source = "kicad-20011"
pathways_tried.append(
f"plugin-{tier1_source}" if tier1.get("success")
else f"plugin-failed:{tier1.get('errorCode','')}"
)
if tier1.get("success"):
for _ in range(20):
time.sleep(0.5)
sym_hwnd = _find_symbol_editor()
if sym_hwnd:
break
if sym_hwnd:
_force_foreground(sym_hwnd)
if not symbol_name and not library_name:
return {
"success": True,
"output": f"Symbol Editor opened successfully via bridge ({tier1_source}).",
"pathway": "plugin",
"pathwaysTried": pathways_tried,
"bridgePid": tier1.get("pid"),
}
# If symbol_name/library_name was requested, fall
# through to the navigate step below (uses legacy
# PowerShell SendKeys path).
except Exception as e: # pylint: disable=broad-except
# Bridge import failed (unlikely) — record and fall through.
pathways_tried.append(f"plugin-import-error:{type(e).__name__}")
if not sym_hwnd:
# Tier 2: legacy Win32 PostMessage from outside the KiCad process.
# Find or launch KiCad project manager
kicad_hwnd = _find_kicad_manager()
if not kicad_hwnd:
kicad_exe = kicad_info.get("kicad_exe")
if not kicad_exe or not Path(kicad_exe).exists():
return {
"success": False,
"error": "KiCad executable not found",
"_hint": "Report to the user and ask them to reinstall KiCad — the installation appears corrupted.",
}
try:
subprocess.Popen(
[kicad_exe],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
except Exception as e:
return {
"success": False,
"error": f"Failed to launch KiCad: {e}",
"_hint": "Call close_kicad with {\"force\": true} to clear stuck processes, then retry.",
}
for _ in range(20):
time.sleep(0.5)
kicad_hwnd = _find_kicad_manager()
if kicad_hwnd:
break
if not kicad_hwnd:
return {
"success": False,
"error": "KiCad launched but project manager window not found after 10s",
"_hint": "Call screenshot_all to see KiCad's current state, or call close_kicad with {\"force\": true} and retry.",
}
time.sleep(2.0)
# Open Symbol Editor via WM_COMMAND
user32.PostMessageW(kicad_hwnd, WM_COMMAND, KICAD_SYMBOL_EDITOR_CMD_ID, 0)
pathways_tried.append("wm_command")
for _ in range(20):
time.sleep(0.5)
sym_hwnd = _find_symbol_editor()
if sym_hwnd:
break
if not sym_hwnd:
return {
"success": False,
"error": "WM_COMMAND sent to KiCad but Symbol Editor did not appear within 10s.",
"pathwaysTried": pathways_tried,
"_hint": "Call screenshot_all to check for a modal dialog blocking KiCad, dismiss with send_key escape, then retry.",
}
# Wait for Symbol Editor to fully initialize
time.sleep(1.5)
# --- Phase 2: Navigate to specific symbol (if requested) ---
if not symbol_name and not library_name:
_force_foreground(sym_hwnd)
return {"success": True, "output": "Symbol Editor opened successfully.",
"pathwaysTried": pathways_tried}
search_text = symbol_name or library_name
# NOTE: Phase 2.aa shipped a `select_and_load_symbol` bridge RPC that
# would normally replace the legacy PowerShell SendKeys path here.
# However, the v0.5.0 implementation segfaults eeschema when walking
# the wx.dataview.DataViewModel — needs a different approach
# (likely keystroke simulation: WXK_DOWN + WXK_RETURN to the tree).
# Until v2.ab fixes that, we deliberately DO NOT auto-route through
# the bridge here. The bridge RPC remains callable manually for
# users who want to experiment, but the default kicad_open_symbol_editor
# flow keeps using the proven PowerShell path.
nav_result = _navigate_to_symbol(sym_hwnd, search_text)
if nav_result["status"] == "ok":
return {
"success": True,
"output": f"Symbol '{search_text}' loaded in the Symbol Editor: {nav_result['title']}",
}
elif nav_result["status"] == "partial":
return {
"success": True,
"output": f"Symbol Editor loaded: {nav_result['title']}",
}
elif nav_result["status"] == "noload":
# The search typed but Ctrl+Shift+E didn't load anything.
# This can happen if the tree didn't filter in time.
# Try once more with a longer wait.
time.sleep(0.5)
nav_result2 = _navigate_to_symbol(sym_hwnd, search_text)
if nav_result2["status"] == "ok":
return {
"success": True,
"output": f"Symbol '{search_text}' loaded in the Symbol Editor: {nav_result2['title']}",
}
elif nav_result2["status"] == "partial":
return {
"success": True,
"output": f"Symbol Editor loaded: {nav_result2['title']}",
}
else:
return {
"success": True,
"output": f"Symbol Editor opened and searched for '{search_text}'. "
f"Use Ctrl+Shift+E to load the symbol if it didn't auto-load.",
}
else:
# Error from the script
_force_foreground(sym_hwnd)
return {
"success": True,
"output": f"Symbol Editor opened but navigation failed: {nav_result.get('error', 'unknown')}. "
f"Search for '{search_text}' manually.",
}