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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006
"""adom_bridge.py - Phase 2 reverse bridge for adom-desktop's KiCad v2.
Runs INSIDE every KiCad GUI process (kicad.exe, pcbnew.exe, eeschema.exe,
kicad-cli.exe) thanks to the usercustomize.py loader at KiCad USER_SITE.
What this does
==============
1. Starts a tiny HTTP server on 127.0.0.1:<port> where port is
8870 + exe_offset (kicad=0, pcbnew=1, eeschema=2, kicad-cli=3).
2. Writes a discovery JSON to %TEMP%\\adom-kicad-bridge-<exe>.json so
adom-desktop's Python kicad-bridge can find which port to talk to
for each KiCad process.
3. Accepts JSON-RPC-style requests over POST /rpc and dispatches
them onto KiCad's main UI thread via `wx.CallAfter`. Results return
in the HTTP response body.
Why HTTP instead of WebSocket
=============================
KiCad 10's bundled Python doesn't ship `websockets`, `aiohttp`, or
`tornado`. Plain `http.server` is in stdlib though. For MVP we don't
need server-push - every command is request-response. If a future
phase needs streaming (e.g. DRC progress events), we can add Server-
Sent Events on a side endpoint, still stdlib-only.
MVP methods (this iteration):
* ping - returns version + pid + exe + uptime_ms.
* list_frames - returns wx.GetTopLevelWindows() summary
(frame class, title, hwnd, menubarItemCount).
* get_menu_ids - returns the full menu walk for every frame.
* wm_command - posts a wx.EVT_MENU to a target frame so menu
commands fire on the UI thread.
* shutdown - stops accepting connections (does NOT kill KiCad).
Threading model
===============
The HTTP server runs on a dedicated daemon thread. Each incoming
request is handled in its own thread (`ThreadingHTTPServer`). When a
request needs wx state, it submits a callable to wx's main thread via
`wx.CallAfter` and waits on a `threading.Event` for the result.
Safety
======
* Listens on 127.0.0.1 only - never bound to a public interface.
* Honors ADOM_KICAD_BRIDGE_DISABLE=1 env var as kill-switch.
* Wraps EVERYTHING in try/except - a bridge failure cannot crash KiCad.
* Daemon thread - dies with the host process, no leaked sockets.
"""
from __future__ import annotations
import json
import os
import socket
import sys
import threading
import time
import traceback
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any, Callable, Optional
__version__ = "0.8.0"
# Exe-name -> port offset. The KiCad bridge in adom-desktop knows this
# table too. New exes get appended; never renumber existing entries or
# the kicad-bridge's per-exe routing breaks.
_EXE_PORT_OFFSET = {
"kicad": 0,
"pcbnew": 1,
"eeschema": 2,
"kicad-cli": 3,
"python": 9, # bundled python.exe (rare, for headless tests)
}
_BASE_PORT = 8870
_OUT_DIR = Path(os.environ.get("TEMP") or os.environ.get("TMP") or "/tmp")
_STARTED_AT_MS = int(time.time() * 1000)
# Module-level mutable state. The HTTP handler reads these.
_state: dict[str, Any] = {
"exe_name": (Path(sys.executable).stem or "unknown").lower(),
"pid": os.getpid(),
"port": None,
"running": False,
"request_count": 0,
}
# --- discovery breadcrumb ---------------------------------------------------
def _discovery_path() -> Path:
"""Per-PID discovery file (Phase 2.x).
Naming: adom-kicad-bridge-<exe>-<pid>.json so multiple instances of
the same exe can run side-by-side (e.g. two pcbnew windows on two
different boards) without overwriting each other. The kicad-bridge
enumerates all matching files and picks the right plugin for the
caller. v1.7.5 used <exe>.json which lost multi-instance support."""
return _OUT_DIR / f"adom-kicad-bridge-{_state['exe_name']}-{_state['pid']}.json"
def _write_discovery():
try:
_discovery_path().write_text(json.dumps({
"version": __version__,
"exeName": _state["exe_name"],
"pid": _state["pid"],
"port": _state["port"],
"startedAt": datetime.utcfromtimestamp(_STARTED_AT_MS / 1000).isoformat() + "Z",
"host": "127.0.0.1",
"endpoint": f"http://127.0.0.1:{_state['port']}/rpc",
"transport": "http",
}, indent=2), encoding="utf-8")
except OSError:
pass
def _remove_discovery():
try:
p = _discovery_path()
if p.exists():
p.unlink()
# Also clean any v1.7.5-style exe-only path this process may have
# written (the legacy bridge_client may write to both paths during
# transition). Belt-and-suspenders cleanup.
legacy = _OUT_DIR / f"adom-kicad-bridge-{_state['exe_name']}.json"
if legacy.exists():
try:
payload = json.loads(legacy.read_text(encoding="utf-8"))
if payload.get("pid") == _state["pid"]:
legacy.unlink()
except (OSError, ValueError):
pass
except OSError:
pass
# --- wx marshalling helpers -------------------------------------------------
def _call_on_ui_thread(fn: Callable[[], Any], timeout: float = 5.0) -> Any:
"""Run `fn` on wx's UI thread; return its result. Re-raises exceptions.
The HTTP handler runs on a worker thread. wx state can only be
touched from the main thread that owns the wx.App. wx.CallAfter
posts onto that thread's idle queue. We hand-roll a result-shuttle
using a threading.Event so the worker can wait for the UI return.
"""
import wx # type: ignore[import-not-found]
holder: dict[str, Any] = {"ok": False, "result": None, "err": None}
event = threading.Event()
def _runner():
try:
holder["result"] = fn()
holder["ok"] = True
except Exception as e: # pylint: disable=broad-except
holder["err"] = (type(e).__name__, str(e), traceback.format_exc())
finally:
event.set()
wx.CallAfter(_runner)
if not event.wait(timeout):
raise TimeoutError(f"wx UI thread did not respond within {timeout:.1f}s")
if not holder["ok"]:
kind, msg, tb = holder["err"]
raise RuntimeError(f"UI-thread error: {kind}: {msg}\n{tb}")
return holder["result"]
# --- frame + menu introspection (run on UI thread) -------------------------
def _ui_list_frames() -> list[dict]:
"""Return wx.GetTopLevelWindows() summary. Must run on UI thread."""
import wx # type: ignore[import-not-found]
out = []
for win in wx.GetTopLevelWindows():
try:
mb = win.GetMenuBar() if hasattr(win, "GetMenuBar") else None
mb_count = 0
if mb is not None:
try:
mb_count = sum(
mb.GetMenu(i).GetMenuItemCount()
for i in range(mb.GetMenuCount())
)
except Exception: # pylint: disable=broad-except
mb_count = -1
out.append({
"frameClass": type(win).__name__,
"frameTitle": win.GetTitle() if hasattr(win, "GetTitle") else "",
"hwnd": int(win.GetHandle()) if hasattr(win, "GetHandle") else None,
"menubarTopCount": mb.GetMenuCount() if mb is not None else 0,
"menubarTotalItems": mb_count,
"isShown": bool(win.IsShown()) if hasattr(win, "IsShown") else None,
})
except Exception as e: # pylint: disable=broad-except
out.append({"_frameError": f"{type(e).__name__}: {e}"})
return out
def _walk_menu(menu, top_label: str, out: list[dict]):
for item in menu.GetMenuItems():
try:
out.append({
"topMenu": top_label,
"label": item.GetItemLabelText(),
"id": item.GetId(),
"kind": str(item.GetKind()),
"accelerator": item.GetItemLabel(),
"helpString": item.GetHelp() or "",
})
if item.IsSubMenu():
sub = item.GetSubMenu()
if sub is not None:
_walk_menu(sub, f"{top_label} > {item.GetItemLabelText()}", out)
except Exception as e: # pylint: disable=broad-except
out.append({"topMenu": top_label, "_itemError": f"{type(e).__name__}: {e}"})
def _ui_get_menu_ids() -> list[dict]:
import wx # type: ignore[import-not-found]
out = []
for win in wx.GetTopLevelWindows():
try:
mb = win.GetMenuBar() if hasattr(win, "GetMenuBar") else None
menus: list[dict] = []
if mb is not None:
for i in range(mb.GetMenuCount()):
_walk_menu(mb.GetMenu(i), mb.GetMenuLabel(i), menus)
out.append({
"frameClass": type(win).__name__,
"frameTitle": win.GetTitle() if hasattr(win, "GetTitle") else "",
"menuItems": menus,
})
except Exception as e: # pylint: disable=broad-except
out.append({"_frameError": f"{type(e).__name__}: {e}"})
return out
_BOARD_TITLE_NEEDLES = ("pcb editor", "footprint editor")
_SCHEMATIC_TITLE_NEEDLES = ("schematic editor", "symbol editor")
def _ui_get_board_info() -> dict:
"""Return PCB board statistics. Only meaningful inside pcbnew.exe
(the only KiCad GUI exe that exposes a usable `pcbnew` Python
module). Returns hasBoard:False if called from a different process
or if pcbnew has no board loaded.
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable (wrong process — must be inside pcbnew.exe)"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
bb = board.GetBoundingBox()
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"trackCount": len(board.GetTracks()),
"footprintCount": len(board.GetFootprints()),
"drawingsCount": len(board.GetDrawings()),
"netCount": board.GetNetCount(),
"copperLayerCount": board.GetCopperLayerCount(),
"designSettingsLayerCount": board.GetCopperLayerCount(),
"boundingBoxMm": {
"widthMm": bb.GetWidth() / 1_000_000,
"heightMm": bb.GetHeight() / 1_000_000,
"centerXMm": bb.GetCenter().x / 1_000_000,
"centerYMm": bb.GetCenter().y / 1_000_000,
},
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_schematic_info() -> dict:
"""Return schematic info — eeschema doesn't expose its sheet/symbol
state as a Python module like pcbnew does, so this is a best-effort
introspection via wx frame walk.
Returns the schematic editor's wx frame title (which encodes the
filename + dirty state, e.g. "design.kicad_sch — Schematic Editor"
or "untitled [Unsaved] — Schematic Editor"). Phase 2.y will replace
this with a real eeschema Python binding once KiCad 11 ships its
IPC API (`kipy`).
"""
try:
import wx # type: ignore[import-not-found]
except ImportError:
return {"available": False, "_error": "wx not loaded — must be inside eeschema.exe"}
try:
frames = []
for win in wx.GetTopLevelWindows():
title = ""
if hasattr(win, "GetTitle"):
try:
title = win.GetTitle()
except Exception: # pylint: disable=broad-except
pass
tl = title.lower()
if any(n in tl for n in _SCHEMATIC_TITLE_NEEDLES):
# Parse filename from title. Format examples:
# "untitled [Unsaved] — Schematic Editor"
# "design [Unsaved] — Schematic Editor"
# "C:\\path\\design.kicad_sch — Schematic Editor"
# "design.kicad_sch [Unsaved] — Schematic Editor"
hwnd = int(win.GetHandle()) if hasattr(win, "GetHandle") else None
fr = {
"frameClass": type(win).__name__,
"frameTitle": title,
"hwnd": hwnd,
"isShown": bool(win.IsShown()) if hasattr(win, "IsShown") else None,
}
# Decode the title shape
parts = title.rsplit("—", 1)
if len(parts) == 2:
file_part = parts[0].strip()
editor_part = parts[1].strip()
fr["editorKind"] = editor_part # "Schematic Editor" / "Symbol Editor"
# Strip trailing " [Unsaved]" if present
dirty = "[Unsaved]" in file_part
file_part = file_part.replace("[Unsaved]", "").strip()
fr["fileName"] = file_part or None
fr["isUnsaved"] = dirty
fr["isUntitled"] = file_part.lower() in ("untitled", "")
frames.append(fr)
return {
"available": True,
"frames": frames,
"frameCount": len(frames),
}
except Exception as e: # pylint: disable=broad-except
return {"available": False, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_open_editors() -> dict:
"""Cross-process best-effort: list every wx top-level window in THIS
process and classify it.
Each bridge process only sees its own frames. The kicad-bridge
(server.py) calls this on every plugin instance to build a
cross-process picture — see `handle_open_editors` in bridge_client.py.
"""
try:
import wx # type: ignore[import-not-found]
except ImportError:
return {"available": False, "_error": "wx not loaded"}
try:
editors: list[dict] = []
for win in wx.GetTopLevelWindows():
title = ""
if hasattr(win, "GetTitle"):
try:
title = win.GetTitle()
except Exception: # pylint: disable=broad-except
pass
cls = type(win).__name__
hwnd = int(win.GetHandle()) if hasattr(win, "GetHandle") else None
tl = title.lower()
kind = "unknown"
if "schematic editor" in tl:
kind = "schematic_editor"
elif "symbol editor" in tl:
kind = "symbol_editor"
elif "pcb editor" in tl:
kind = "pcb_editor"
elif "footprint editor" in tl:
kind = "footprint_editor"
elif "kicad" in tl and "editor" not in tl:
kind = "project_manager"
editors.append({
"kind": kind,
"frameClass": cls,
"frameTitle": title,
"hwnd": hwnd,
"isShown": bool(win.IsShown()) if hasattr(win, "IsShown") else None,
})
return {
"available": True,
"exeName": _state["exe_name"],
"pid": _state["pid"],
"editors": editors,
"editorCount": len(editors),
}
except Exception as e: # pylint: disable=broad-except
return {"available": False, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_pcb_footprints() -> dict:
"""Return every footprint on the board with position, rotation, layer,
reference, value, and footprint library ID. Only meaningful inside
pcbnew.exe with a board loaded.
Coordinates are in millimeters (KiCad internally uses nm; we divide
by 1_000_000). Rotation is in degrees.
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable (wrong process)"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
footprints = []
for fp in board.GetFootprints():
try:
pos = fp.GetPosition()
fpid = fp.GetFPID()
# FPID is "library:name" — split for clarity
lib_name = ""
item_name = ""
try:
lib_name = fpid.GetLibNickname().wx_str()
item_name = fpid.GetLibItemName().wx_str()
except Exception: # pylint: disable=broad-except
pass
# Bounding box (excludes courtyard, includes drawn shapes)
bb = fp.GetBoundingBox()
pad_count = fp.GetPadCount() if hasattr(fp, "GetPadCount") else len(fp.Pads())
footprints.append({
"ref": fp.GetReference(),
"value": fp.GetValue(),
"libNickname": lib_name,
"itemName": item_name,
"fpid": f"{lib_name}:{item_name}" if lib_name else item_name,
"x_mm": pos.x / 1_000_000,
"y_mm": pos.y / 1_000_000,
"rotationDeg": fp.GetOrientation().AsDegrees(),
"layer": fp.GetLayerName(),
"isFlipped": bool(fp.IsFlipped()),
"isLocked": bool(fp.IsLocked()),
"padCount": pad_count,
"boundingBoxMm": {
"minXMm": bb.GetX() / 1_000_000,
"minYMm": bb.GetY() / 1_000_000,
"widthMm": bb.GetWidth() / 1_000_000,
"heightMm": bb.GetHeight() / 1_000_000,
},
})
except Exception as e: # pylint: disable=broad-except
footprints.append({"_error": f"{type(e).__name__}: {e}"})
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"footprintCount": len(footprints),
"footprints": footprints,
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_pcb_layers() -> dict:
"""Return the active layer stackup: every enabled copper + technical
layer with its name, layer ID, and type (copper / dielectric / mask /
silk / paste / fab / other). pcbnew only.
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
# Enumerate every layer the board enables. Layer IDs are in
# PCB_LAYER_ID enum but we'll skip naming the enum and use the
# board's own canonical names.
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if not board.IsLayerEnabled(layer_id):
continue
name = board.GetLayerName(layer_id)
# Classify. `pcbnew.IsCopperLayer(id)` is the authoritative
# source for copper (works across KiCad 9/10 layer-ID
# rearrangements). Fall back to name-pattern classification
# for the rest. The first iteration of this code used
# `layer_id < 32` which over-counted because KiCad interleaves
# mask/silk into the low-ID range.
kind = "other"
lname = name.lower()
is_copper = False
try:
is_copper = bool(pcbnew.IsCopperLayer(layer_id))
except Exception: # pylint: disable=broad-except
pass
if is_copper:
kind = "copper"
elif "mask" in lname:
kind = "mask"
elif "silkscreen" in lname or "silk" in lname:
kind = "silk"
elif "paste" in lname:
kind = "paste"
elif "courtyard" in lname:
kind = "courtyard"
elif "adhesive" in lname or "adhes" in lname:
kind = "adhesive"
elif "edge" in lname:
kind = "edge"
elif "fab" in lname:
kind = "fab"
elif "comments" in lname or "comment" in lname:
kind = "comments"
elif "user" in lname or "drawings" in lname:
kind = "user"
elif "margin" in lname:
kind = "margin"
layers.append({
"id": layer_id,
"name": name,
"kind": kind,
"isCopper": is_copper,
})
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"copperLayerCount": board.GetCopperLayerCount(),
"totalEnabledLayers": len(layers),
"layers": layers,
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_pcb_nets() -> dict:
"""Return every net on the board with name, code, pad count, and
total track length (in mm). pcbnew only."""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
# Walk all tracks once, building a per-net length map.
# GetLength() is in nm.
net_lengths_nm: dict[int, int] = {}
for tr in board.GetTracks():
try:
netcode = tr.GetNetCode()
length = tr.GetLength() if hasattr(tr, "GetLength") else 0
net_lengths_nm[netcode] = net_lengths_nm.get(netcode, 0) + length
except Exception: # pylint: disable=broad-except
pass
# Walk pads to count pads per net.
net_pad_counts: dict[int, int] = {}
for fp in board.GetFootprints():
for pad in fp.Pads():
try:
netcode = pad.GetNetCode()
net_pad_counts[netcode] = net_pad_counts.get(netcode, 0) + 1
except Exception: # pylint: disable=broad-except
pass
# Get nets from board.GetNetInfo().
nets = []
try:
netinfo = board.GetNetInfo()
for netcode in range(board.GetNetCount()):
try:
net = netinfo.GetNetItem(netcode)
if net is None:
continue
name = net.GetNetname() if hasattr(net, "GetNetname") else ""
nets.append({
"code": netcode,
"name": name,
"padCount": net_pad_counts.get(netcode, 0),
"trackLengthMm": net_lengths_nm.get(netcode, 0) / 1_000_000,
})
except Exception as e: # pylint: disable=broad-except
nets.append({"code": netcode, "_error": f"{type(e).__name__}: {e}"})
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"netCount": len(nets),
"totalTrackLengthMm": sum(n.get("trackLengthMm", 0) for n in nets if isinstance(n.get("trackLengthMm"), (int, float))),
"nets": nets,
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_pcb_design_rules() -> dict:
"""Return the board's design rules: minimum track width, via dimensions,
clearances. pcbnew only.
These come from board.GetDesignSettings() and BOARD_DESIGN_SETTINGS.
Units in millimeters."""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
ds = board.GetDesignSettings()
# Best-effort field extraction — different KiCad versions expose
# different attrs. Wrap each in its own try/except.
rules: dict = {"hasBoard": True, "fileName": board.GetFileName()}
def _safe_attr(name, getter, divisor=1_000_000):
try:
val = getter()
rules[name] = val / divisor if divisor else val
except Exception as e: # pylint: disable=broad-except
rules[name] = None
rules.setdefault("_errors", {})[name] = f"{type(e).__name__}: {e}"
# Track widths (the first entry is the "default" / "current" track width)
try:
tw = list(ds.m_TrackWidthList) if hasattr(ds, "m_TrackWidthList") else []
rules["trackWidthsMm"] = [w / 1_000_000 for w in tw]
except Exception as e: # pylint: disable=broad-except
rules["trackWidthsMm"] = None
rules.setdefault("_errors", {})["trackWidthsMm"] = f"{type(e).__name__}: {e}"
# Via dimensions
try:
vds = list(ds.m_ViasDimensionsList) if hasattr(ds, "m_ViasDimensionsList") else []
rules["viaDimensions"] = [
{"diameterMm": v.m_Diameter / 1_000_000, "drillMm": v.m_Drill / 1_000_000}
for v in vds
]
except Exception as e: # pylint: disable=broad-except
rules["viaDimensions"] = None
rules.setdefault("_errors", {})["viaDimensions"] = f"{type(e).__name__}: {e}"
# Min clearance
_safe_attr("minClearanceMm", lambda: ds.GetSmallestClearanceValue() if hasattr(ds, "GetSmallestClearanceValue") else ds.m_MinClearance)
# Hole sizes
_safe_attr("minThroughHoleMm", lambda: ds.m_MinThroughDrill if hasattr(ds, "m_MinThroughDrill") else 0)
_safe_attr("minViaDiameterMm", lambda: ds.m_ViasMinSize)
return rules
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
# ── Track / pad / via / drawing introspection ─────────────────────────
def _ui_get_pcb_tracks() -> dict:
"""Return every track (segment, arc, or via) with position, layer,
width, and net info. pcbnew only.
PCB_TRACK covers normal segment tracks; PCB_ARC tracks; PCB_VIA via
objects. We classify each by type so callers can filter."""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
tracks: list[dict] = []
# Build a netcode → netname map so each track row carries the name
try:
netinfo = board.GetNetInfo()
netname_for: dict[int, str] = {}
for code in range(board.GetNetCount()):
try:
n = netinfo.GetNetItem(code)
if n is not None and hasattr(n, "GetNetname"):
netname_for[code] = n.GetNetname()
except Exception: # pylint: disable=broad-except
pass
except Exception: # pylint: disable=broad-except
netname_for = {}
for tr in board.GetTracks():
try:
cls = type(tr).__name__
start = tr.GetStart() if hasattr(tr, "GetStart") else None
end = tr.GetEnd() if hasattr(tr, "GetEnd") else None
width = tr.GetWidth() if hasattr(tr, "GetWidth") else 0
netcode = tr.GetNetCode() if hasattr(tr, "GetNetCode") else 0
layer_id = tr.GetLayer() if hasattr(tr, "GetLayer") else -1
row = {
"type": cls,
"layer": layer_id,
"layerName": board.GetLayerName(layer_id) if layer_id >= 0 else None,
"widthMm": width / 1_000_000 if width else None,
"netCode": netcode,
"netName": netname_for.get(netcode, ""),
"lengthMm": (tr.GetLength() / 1_000_000) if hasattr(tr, "GetLength") else None,
}
if start is not None:
row["startMm"] = {"x": start.x / 1_000_000, "y": start.y / 1_000_000}
if end is not None:
row["endMm"] = {"x": end.x / 1_000_000, "y": end.y / 1_000_000}
tracks.append(row)
except Exception as e: # pylint: disable=broad-except
tracks.append({"_error": f"{type(e).__name__}: {e}"})
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"trackCount": len(tracks),
"tracks": tracks,
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_pcb_vias() -> dict:
"""Return every via (PCB_VIA in pcbnew's track list).
Differentiates through-hole / blind / buried via type. pcbnew only.
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
# Net name lookup (same pattern as get_pcb_tracks)
try:
netinfo = board.GetNetInfo()
netname_for: dict[int, str] = {}
for code in range(board.GetNetCount()):
n = netinfo.GetNetItem(code)
if n is not None and hasattr(n, "GetNetname"):
netname_for[code] = n.GetNetname()
except Exception: # pylint: disable=broad-except
netname_for = {}
# Map via-type enum → string. KiCad pcbnew.VIATYPE_THROUGH etc.
type_names = {}
for attr in ("VIATYPE_THROUGH", "VIATYPE_BLIND_BURIED", "VIATYPE_MICROVIA"):
try:
type_names[getattr(pcbnew, attr)] = attr.replace("VIATYPE_", "").lower()
except AttributeError:
pass
vias: list[dict] = []
for tr in board.GetTracks():
if type(tr).__name__ != "PCB_VIA":
continue
try:
pos = tr.GetPosition()
width = tr.GetWidth()
drill = tr.GetDrillValue() if hasattr(tr, "GetDrillValue") else 0
vtype = tr.GetViaType() if hasattr(tr, "GetViaType") else None
netcode = tr.GetNetCode()
top_layer = tr.TopLayer() if hasattr(tr, "TopLayer") else None
bottom_layer = tr.BottomLayer() if hasattr(tr, "BottomLayer") else None
vias.append({
"positionMm": {"x": pos.x / 1_000_000, "y": pos.y / 1_000_000},
"diameterMm": width / 1_000_000,
"drillMm": drill / 1_000_000,
"viaType": type_names.get(vtype, str(vtype)) if vtype is not None else None,
"topLayer": top_layer,
"topLayerName": board.GetLayerName(top_layer) if top_layer is not None else None,
"bottomLayer": bottom_layer,
"bottomLayerName": board.GetLayerName(bottom_layer) if bottom_layer is not None else None,
"netCode": netcode,
"netName": netname_for.get(netcode, ""),
})
except Exception as e: # pylint: disable=broad-except
vias.append({"_error": f"{type(e).__name__}: {e}"})
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"viaCount": len(vias),
"vias": vias,
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_pcb_pads() -> dict:
"""Return every pad across every footprint. pcbnew only.
Includes pad name (e.g. "1", "GND"), parent footprint reference,
absolute position (post-rotation), drill (if THT), layers mask.
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
# Net name lookup
try:
netinfo = board.GetNetInfo()
netname_for: dict[int, str] = {}
for code in range(board.GetNetCount()):
n = netinfo.GetNetItem(code)
if n is not None and hasattr(n, "GetNetname"):
netname_for[code] = n.GetNetname()
except Exception: # pylint: disable=broad-except
netname_for = {}
pads: list[dict] = []
for fp in board.GetFootprints():
try:
fp_ref = fp.GetReference()
for pad in fp.Pads():
try:
pos = pad.GetPosition()
size = pad.GetSize() if hasattr(pad, "GetSize") else None
drill = pad.GetDrillSize() if hasattr(pad, "GetDrillSize") else None
netcode = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0
# GetShape returns an enum; stringify
try:
shape_id = pad.GetShape()
except Exception: # pylint: disable=broad-except
shape_id = None
row = {
"footprintRef": fp_ref,
"padName": pad.GetName() if hasattr(pad, "GetName") else "",
"positionMm": {"x": pos.x / 1_000_000, "y": pos.y / 1_000_000},
"shapeId": shape_id,
"netCode": netcode,
"netName": netname_for.get(netcode, ""),
}
if size is not None:
try:
row["sizeMm"] = {"x": size.x / 1_000_000, "y": size.y / 1_000_000}
except Exception: # pylint: disable=broad-except
pass
if drill is not None:
try:
row["drillMm"] = {"x": drill.x / 1_000_000, "y": drill.y / 1_000_000}
except Exception: # pylint: disable=broad-except
pass
# Pad-stack attribute: THT, SMD, or NPTH
try:
row["attribute"] = pad.GetAttribute()
except Exception: # pylint: disable=broad-except
pass
pads.append(row)
except Exception as e: # pylint: disable=broad-except
pads.append({"footprintRef": fp_ref, "_error": f"{type(e).__name__}: {e}"})
except Exception as e: # pylint: disable=broad-except
pads.append({"_error": f"footprint walk: {type(e).__name__}: {e}"})
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"padCount": len(pads),
"pads": pads,
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_pcb_drawings() -> dict:
"""Return every drawing on the board (silk text, edge cuts, dimensions,
user-drawn lines). pcbnew only.
Includes type, layer, position. For text, includes the text content.
For shapes, includes start/end/center/radius as applicable."""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
drawings: list[dict] = []
for dr in board.GetDrawings():
try:
cls = type(dr).__name__
layer_id = dr.GetLayer() if hasattr(dr, "GetLayer") else -1
row = {
"type": cls,
"layer": layer_id,
"layerName": board.GetLayerName(layer_id) if layer_id >= 0 else None,
}
# Text drawings expose GetText() / GetTextHeight() / GetTextWidth()
if hasattr(dr, "GetText"):
try:
row["text"] = dr.GetText()
except Exception: # pylint: disable=broad-except
pass
# Position (works for text + most shapes)
if hasattr(dr, "GetPosition"):
try:
pos = dr.GetPosition()
row["positionMm"] = {"x": pos.x / 1_000_000, "y": pos.y / 1_000_000}
except Exception: # pylint: disable=broad-except
pass
# Shape start/end (lines, arcs, rects)
for name, getter in (("startMm", "GetStart"), ("endMm", "GetEnd"), ("centerMm", "GetCenter")):
if hasattr(dr, getter):
try:
pt = getattr(dr, getter)()
row[name] = {"x": pt.x / 1_000_000, "y": pt.y / 1_000_000}
except Exception: # pylint: disable=broad-except
pass
# Arc radius / shape kind
if hasattr(dr, "GetRadius"):
try:
r = dr.GetRadius()
row["radiusMm"] = r / 1_000_000
except Exception: # pylint: disable=broad-except
pass
drawings.append(row)
except Exception as e: # pylint: disable=broad-except
drawings.append({"_error": f"{type(e).__name__}: {e}"})
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"drawingCount": len(drawings),
"drawings": drawings,
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_net_topology(net_name: str) -> dict:
"""Graph traversal from a named net outward through the components
that pin it. pcbnew only. Phase 2.ac+.
Returns the full footprint-level topology for one net:
- Direct pads (footprint refs + pad names on this net)
- Reachable components (every footprint that has a pad on this
net) with their OTHER nets — i.e. "if I trace VCC, what
components touch it, and what other signals do they bridge?"
Example output for the demo board's VCC net:
{
"netName": "VCC",
"padCount": 6, # 6 pads share this net
"componentCount": 3, # spread across 3 footprints
"components": [
{"ref": "R1", "value": "10k", "padsOnNet": ["1"],
"otherPads": [{"padName": "2", "netName": "OUT"}]},
...
],
"bridgedNets": ["OUT", "GND"] # nets reachable in 1 hop
}
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"found": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"found": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"found": False, "_error": "no board loaded"}
try:
# Net code lookup
target_netcode = None
netname_for: dict[int, str] = {}
netinfo = board.GetNetInfo()
for code in range(board.GetNetCount()):
n = netinfo.GetNetItem(code)
if n is not None and hasattr(n, "GetNetname"):
nm = n.GetNetname()
netname_for[code] = nm
if nm == net_name:
target_netcode = code
if target_netcode is None:
return {
"found": False,
"netName": net_name,
"_error": f"net {net_name!r} not found",
"_hint": "Call get_pcb_nets to see the list of net names on this board. Net names are case-sensitive.",
}
# Walk every footprint's pads. For each pad on the target net,
# record the parent footprint. For each footprint that has at
# least one pad on the target net, also list its OTHER pads
# (so the caller can see what other signals that component
# bridges).
components: dict[str, dict] = {} # ref → {value, padsOnNet, otherPads}
bridged_netcodes: set[int] = set()
for fp in board.GetFootprints():
try:
fp_ref = fp.GetReference()
pads_on_net = []
other_pads = []
for pad in fp.Pads():
nc = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0
pad_name = pad.GetName() if hasattr(pad, "GetName") else ""
if nc == target_netcode:
pads_on_net.append(pad_name)
elif nc != 0:
other_pads.append({
"padName": pad_name,
"netCode": nc,
"netName": netname_for.get(nc, ""),
})
if pads_on_net:
components[fp_ref] = {
"ref": fp_ref,
"value": fp.GetValue() if hasattr(fp, "GetValue") else "",
"footprintId": str(fp.GetFPID().GetLibItemName().wx_str()) if hasattr(fp, "GetFPID") else "",
"padsOnNet": pads_on_net,
"otherPads": other_pads,
}
for op in other_pads:
bridged_netcodes.add(op["netCode"])
except Exception as e: # pylint: disable=broad-except
components[f"_error_{len(components)}"] = {"_error": f"{type(e).__name__}: {e}"}
# Total pad count (sum across components)
pad_count = sum(len(c.get("padsOnNet", [])) for c in components.values())
# Bridged net names (the ones component-co-located with our target net)
bridged_nets = sorted({netname_for.get(nc, "") for nc in bridged_netcodes if netname_for.get(nc)})
return {
"found": True,
"netName": net_name,
"netCode": target_netcode,
"padCount": pad_count,
"componentCount": len(components),
"components": sorted(components.values(), key=lambda c: c.get("ref", "")),
"bridgedNets": bridged_nets,
"_hint": (
f"Net {net_name!r} reaches {pad_count} pad(s) across {len(components)} "
f"component(s). Components co-located with this net bridge to: "
f"{', '.join(bridged_nets) if bridged_nets else '(no other nets — net is isolated)'}. "
"For length-matching on each branch, call get_track_paths_by_net "
f"{{\"netName\":{net_name!r}}}. For pad-level details (positions, sizes), "
"filter get_pcb_pads to this footprintRef list."
),
}
except Exception as e: # pylint: disable=broad-except
return {"found": False, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_pcb_zones() -> dict:
"""Return every copper zone / keepout on the board.
Each zone: {netCode, netName, layer, layerName, priority, isFilled,
isKeepout, clearanceMm, minWidthMm, hatchStyle, polygonVertexCount,
boundingBoxMm}.
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
# Net-name lookup
try:
netinfo = board.GetNetInfo()
netname_for: dict[int, str] = {}
for code in range(board.GetNetCount()):
n = netinfo.GetNetItem(code)
if n is not None and hasattr(n, "GetNetname"):
netname_for[code] = n.GetNetname()
except Exception: # pylint: disable=broad-except
netname_for = {}
zones: list[dict] = []
# board.Zones() returns ZONE objects (PCB_ZONE). Each can span
# multiple layers in KiCad 7+ but we report per-layer where useful.
try:
zone_iter = list(board.Zones())
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"board.Zones() failed: {type(e).__name__}: {e}"}
for z in zone_iter:
try:
# Layer set — KiCad 7+ zones can be multi-layer
layer_ids: list[int] = []
try:
lset = z.GetLayerSet()
# LayerSet has Seq() returning vector of layer IDs
for lid in lset.Seq():
layer_ids.append(int(lid))
except Exception: # pylint: disable=broad-except
# Fallback to single-layer
try:
layer_ids = [z.GetLayer()]
except Exception: # pylint: disable=broad-except
pass
netcode = z.GetNetCode() if hasattr(z, "GetNetCode") else 0
# Bounding box
try:
bb = z.GetBoundingBox()
bbox_mm = {
"minXMm": bb.GetX() / 1_000_000,
"minYMm": bb.GetY() / 1_000_000,
"widthMm": bb.GetWidth() / 1_000_000,
"heightMm": bb.GetHeight() / 1_000_000,
}
except Exception: # pylint: disable=broad-except
bbox_mm = None
# Polygon vertex count (one polygon per outline)
vertex_count = 0
try:
# GetOutline() returns SHAPE_POLY_SET
poly = z.Outline()
vertex_count = poly.FullPointCount() if hasattr(poly, "FullPointCount") else 0
except Exception: # pylint: disable=broad-except
pass
zones.append({
"netCode": netcode,
"netName": netname_for.get(netcode, ""),
"layers": layer_ids,
"layerNames": [board.GetLayerName(lid) for lid in layer_ids],
"priority": z.GetAssignedPriority() if hasattr(z, "GetAssignedPriority") else None,
"isFilled": bool(z.IsFilled()) if hasattr(z, "IsFilled") else None,
"isKeepout": bool(z.GetIsRuleArea()) if hasattr(z, "GetIsRuleArea") else None,
"clearanceMm": (z.GetLocalClearance() / 1_000_000) if hasattr(z, "GetLocalClearance") else None,
"minWidthMm": (z.GetMinThickness() / 1_000_000) if hasattr(z, "GetMinThickness") else None,
"polygonVertexCount": vertex_count,
"boundingBoxMm": bbox_mm,
})
except Exception as e: # pylint: disable=broad-except
zones.append({"_error": f"{type(e).__name__}: {e}"})
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"zoneCount": len(zones),
"zones": zones,
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_footprint_connections(ref: str) -> dict:
"""For a given footprint reference (e.g. "U1"), return its pin/pad-level
connectivity. Each pad with a non-empty net reports which OTHER pads
(on other footprints) share that net.
Returns: {found, ref, pads:[{padName, netCode, netName,
connectedTo:[{ref, padName}]}]}
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"found": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"found": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"found": False, "_error": "no board loaded"}
try:
# Build the per-net pad index once: netcode → [(ref, padName)]
per_net: dict[int, list[tuple[str, str]]] = {}
target_fp = None
for fp in board.GetFootprints():
try:
fp_ref = fp.GetReference()
if fp_ref == ref:
target_fp = fp
for pad in fp.Pads():
nc = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0
nm = pad.GetName() if hasattr(pad, "GetName") else ""
if nc:
per_net.setdefault(nc, []).append((fp_ref, nm))
except Exception: # pylint: disable=broad-except
continue
if target_fp is None:
return {
"found": False,
"ref": ref,
"error": f"footprint with reference {ref!r} not found",
"_hint": "Call get_pcb_footprints to see the full list of refs on the board.",
}
# Net name lookup
try:
netinfo = board.GetNetInfo()
netname_for: dict[int, str] = {}
for code in range(board.GetNetCount()):
n = netinfo.GetNetItem(code)
if n is not None and hasattr(n, "GetNetname"):
netname_for[code] = n.GetNetname()
except Exception: # pylint: disable=broad-except
netname_for = {}
pads_out: list[dict] = []
for pad in target_fp.Pads():
try:
nc = pad.GetNetCode() if hasattr(pad, "GetNetCode") else 0
pad_name = pad.GetName() if hasattr(pad, "GetName") else ""
row: dict = {
"padName": pad_name,
"netCode": nc,
"netName": netname_for.get(nc, ""),
"connectedTo": [],
}
if nc:
# Other pads on the same net (excluding self)
for (other_ref, other_pad) in per_net.get(nc, []):
if other_ref == ref and other_pad == pad_name:
continue
row["connectedTo"].append({"ref": other_ref, "padName": other_pad})
pads_out.append(row)
except Exception as e: # pylint: disable=broad-except
pads_out.append({"_error": f"{type(e).__name__}: {e}"})
return {
"found": True,
"ref": ref,
"value": target_fp.GetValue() if hasattr(target_fp, "GetValue") else "",
"padCount": len(pads_out),
"pads": pads_out,
}
except Exception as e: # pylint: disable=broad-except
return {"found": False, "_error": f"{type(e).__name__}: {e}"}
def _ui_run_drc_inprocess() -> dict:
"""Trigger DRC inside the running pcbnew via the DRC menu item, wait
a short while, then read get_drc_markers. Replaces shelling out to
`kicad-cli pcb drc` (which takes ~3-5s subprocess startup).
NOTE: pcbnew's full DRC_ENGINE isn't exposed in KiCad 10's SWIG
bindings (DRC_ENGINE.RunTests is C++-only). The cleanest available
path is to POST the DRC menu command (menu_id 20088 on the PCB
Editor frame), which opens the DRC dialog and triggers a run. We
then poll get_drc_markers until markers appear or timeout.
Trade-off: this opens the DRC dialog visually (the user sees it
flash). True invisible DRC needs KiCad 11's kipy / a future
DRC_ENGINE Python binding.
Returns: {hasBoard, dialogTriggered, markers:[{...}],
markerCount, bySeverityCounts, _hint}.
"""
try:
import pcbnew # type: ignore[import-not-found]
import wx # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew/wx not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False, "_error": "no board loaded"}
# Find PCB Editor frame
target_frame = None
for win in wx.GetTopLevelWindows():
try:
title = win.GetTitle() if hasattr(win, "GetTitle") else ""
except Exception: # pylint: disable=broad-except
title = ""
if "pcb editor" in title.lower():
target_frame = win
break
if target_frame is None:
return {
"hasBoard": True,
"dialogTriggered": False,
"_error": "PCB Editor frame not found",
"_hint": "Open pcbnew with a board first via kicad_open_board.",
}
# Post WM_COMMAND 20088 (DRC) via Win32 PostMessage — proven path
# for posting menu commands to KiCad. wx.PostEvent fires the event
# but KiCad's TOOL_DISPATCHER doesn't pick wx events up; only Win32
# WM_COMMAND.
try:
import ctypes
import ctypes.wintypes as wt
WM_COMMAND = 0x0111
DRC_MENU_ID = 20088
hwnd = int(target_frame.GetHandle())
user32 = ctypes.windll.user32
PostMessageW = user32.PostMessageW
PostMessageW.argtypes = [wt.HWND, ctypes.c_uint, wt.WPARAM, wt.LPARAM]
PostMessageW.restype = wt.BOOL
ok = PostMessageW(hwnd, WM_COMMAND, DRC_MENU_ID, 0)
except Exception as e: # pylint: disable=broad-except
return {
"hasBoard": True,
"dialogTriggered": False,
"_error": f"PostMessage failed: {type(e).__name__}: {e}",
}
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"dialogTriggered": bool(ok),
"menuIdPosted": DRC_MENU_ID,
"_hint": (
"DRC menu command posted to pcbnew. The DRC dialog will appear "
"and KiCad will run the rule check. Wait ~2-5s (depending on "
"board complexity), then call get_drc_markers to read the "
"results. Note: this opens the DRC dialog visually — the user "
"sees it flash. For invisible DRC, fall back to "
"`kicad-cli pcb drc --output drc.json` (current behavior of "
"kicad_run_drc) until KiCad 11's kipy IPC API ships a true "
"in-process trigger."
),
}
def _ui_get_track_paths_by_net(net_name: str) -> dict:
"""Group all tracks belonging to a net into connected polyline paths.
A "path" is a chain of track segments where consecutive segments share
an endpoint. Returns one entry per path with start/end + total length.
Useful for length-matching analysis (DDR data lines, RF traces, etc.)
where you want each branch of the net measured independently.
Returns: {hasBoard, netName, pathCount, paths:[{
segmentCount, totalLengthMm,
startMm:{x,y}, endMm:{x,y},
layers:[layerName],
segments:[{startMm, endMm, widthMm, layer}]
}]}
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
# Resolve netcode for the requested net
netcode = None
try:
netinfo = board.GetNetInfo()
for code in range(board.GetNetCount()):
n = netinfo.GetNetItem(code)
if n is not None and hasattr(n, "GetNetname"):
if n.GetNetname() == net_name:
netcode = code
break
except Exception: # pylint: disable=broad-except
pass
if netcode is None:
return {
"hasBoard": True,
"found": False,
"netName": net_name,
"_error": f"net {net_name!r} not found",
"_hint": "Call get_pcb_nets to see the list of net names on this board.",
}
# Collect every segment-like track on this net. Skip vias (they're
# zero-length points; user can correlate via get_pcb_vias on the
# same net).
segs = []
for tr in board.GetTracks():
try:
if tr.GetNetCode() != netcode:
continue
cls = type(tr).__name__
if cls == "PCB_VIA":
continue
start = tr.GetStart()
end = tr.GetEnd()
width = tr.GetWidth()
layer_id = tr.GetLayer()
segs.append({
"startMm": (start.x / 1_000_000, start.y / 1_000_000),
"endMm": (end.x / 1_000_000, end.y / 1_000_000),
"widthMm": width / 1_000_000,
"layer": layer_id,
"layerName": board.GetLayerName(layer_id),
"lengthMm": (tr.GetLength() / 1_000_000) if hasattr(tr, "GetLength") else 0,
})
except Exception: # pylint: disable=broad-except
continue
if not segs:
return {
"hasBoard": True,
"found": True,
"netName": net_name,
"pathCount": 0,
"paths": [],
"_hint": f"Net {net_name!r} exists but has 0 routed track segments. "
"It's still defined (pads on it) but unrouted.",
}
# Build adjacency by endpoint match. Two segments share an endpoint
# when the (rounded-to-µm) coordinates match. Use 0.0001mm = 100nm
# tolerance to handle floating-point quirks.
def _key(pt):
return (round(pt[0], 4), round(pt[1], 4))
# endpoint -> set of segment indices touching it
endpoint_to_segs: dict = {}
for i, s in enumerate(segs):
for ep in (_key(s["startMm"]), _key(s["endMm"])):
endpoint_to_segs.setdefault(ep, []).append(i)
# Union-find on segment indices: two segs are in the same component
# if they share any endpoint.
parent = list(range(len(segs)))
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for seg_list in endpoint_to_segs.values():
for j in range(1, len(seg_list)):
union(seg_list[0], seg_list[j])
# Group segs by component root
components: dict = {}
for i in range(len(segs)):
components.setdefault(find(i), []).append(i)
paths = []
for _root, seg_ids in components.items():
comp_segs = [segs[i] for i in seg_ids]
total_length = sum(s["lengthMm"] for s in comp_segs)
# Pick "start" / "end" as the two endpoints that appear in only
# ONE segment of this component (the dangling ends). If the
# component is a loop, just pick the first segment's endpoints.
endpoint_count: dict = {}
for s in comp_segs:
for ep in (_key(s["startMm"]), _key(s["endMm"])):
endpoint_count[ep] = endpoint_count.get(ep, 0) + 1
ends = [ep for ep, c in endpoint_count.items() if c == 1]
if len(ends) >= 2:
start_ep = {"x": ends[0][0], "y": ends[0][1]}
end_ep = {"x": ends[1][0], "y": ends[1][1]}
else:
start_ep = {"x": comp_segs[0]["startMm"][0], "y": comp_segs[0]["startMm"][1]}
end_ep = {"x": comp_segs[0]["endMm"][0], "y": comp_segs[0]["endMm"][1]}
layers = sorted(set(s["layerName"] for s in comp_segs))
paths.append({
"segmentCount": len(comp_segs),
"totalLengthMm": total_length,
"startMm": start_ep,
"endMm": end_ep,
"layers": layers,
"segments": [
{
"startMm": {"x": s["startMm"][0], "y": s["startMm"][1]},
"endMm": {"x": s["endMm"][0], "y": s["endMm"][1]},
"widthMm": s["widthMm"],
"layer": s["layer"],
"layerName": s["layerName"],
"lengthMm": s["lengthMm"],
}
for s in comp_segs
],
})
# Sort paths longest-first (typical use is "which is the longest
# branch on this DDR data line?")
paths.sort(key=lambda p: p["totalLengthMm"], reverse=True)
return {
"hasBoard": True,
"found": True,
"netName": net_name,
"pathCount": len(paths),
"paths": paths,
"_hint": (
"Each `path` is a connected chain of track segments sharing "
"endpoints. Paths are sorted longest-first. For length-"
"matching (DDR/RF), compare `totalLengthMm` across nets — "
"e.g. fetch the same path-count for each DDR data line and "
"diff their totals. Vias are excluded (they have zero "
"horizontal length); for via inventory call get_pcb_vias "
"filtered to this net's code."
),
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
def _ui_get_drc_markers() -> dict:
"""Read existing DRC markers from the board.
Markers are produced by the most recent DRC run (manual or kicad-cli).
This method does NOT trigger DRC; for that, use the legacy kicad_run_drc
verb (which shells out to kicad-cli). A future 2.ab method will trigger
in-process via pcbnew's DRC_ENGINE.
Returns: {hasBoard, markerCount, markers:[{severity, type, message,
positionMm, layer?, ...}]}
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False}
try:
# Markers live in board.GetItems() but we want only PCB_MARKER_T.
# GetMarkers() returns a vector<PCB_MARKER*>.
try:
marker_iter = list(board.Markers()) if hasattr(board, "Markers") else []
except Exception: # pylint: disable=broad-except
marker_iter = []
# Fallback: walk board.GetItems(PCB_MARKER_T) — older SWIG bindings
if not marker_iter and hasattr(pcbnew, "PCB_MARKER_T"):
try:
# Some SWIG bindings expose this; many don't. Best-effort.
pass
except Exception: # pylint: disable=broad-except
pass
# Build severity / type enum lookup
severity_names: dict[int, str] = {}
for attr, label in (
("SEVERITY_ERROR", "error"),
("SEVERITY_WARNING", "warning"),
("SEVERITY_ACTION", "action"),
("SEVERITY_INFO", "info"),
("SEVERITY_EXCLUSION", "exclusion"),
("SEVERITY_IGNORE", "ignore"),
):
try:
severity_names[getattr(pcbnew, attr)] = label
except AttributeError:
pass
markers: list[dict] = []
for m in marker_iter:
try:
row: dict = {}
# Severity
if hasattr(m, "GetSeverity"):
try:
sev = m.GetSeverity()
row["severity"] = severity_names.get(sev, str(sev))
row["severityCode"] = sev
except Exception: # pylint: disable=broad-except
pass
# The reporter holds the message
if hasattr(m, "GetReporter"):
try:
rep = m.GetReporter()
if rep is not None:
if hasattr(rep, "GetErrorMessage"):
row["message"] = rep.GetErrorMessage()
if hasattr(rep, "GetErrorText"):
row["errorText"] = rep.GetErrorText()
except Exception as e: # pylint: disable=broad-except
row.setdefault("_errors", {})["reporter"] = f"{type(e).__name__}: {e}"
# Layer
if hasattr(m, "GetLayer"):
try:
lid = m.GetLayer()
row["layer"] = lid
row["layerName"] = board.GetLayerName(lid) if lid >= 0 else None
except Exception: # pylint: disable=broad-except
pass
# Position
if hasattr(m, "GetPosition"):
try:
p = m.GetPosition()
row["positionMm"] = {"x": p.x / 1_000_000, "y": p.y / 1_000_000}
except Exception: # pylint: disable=broad-except
pass
# Marker class
row["markerClass"] = type(m).__name__
markers.append(row)
except Exception as e: # pylint: disable=broad-except
markers.append({"_error": f"{type(e).__name__}: {e}"})
# Group by severity for the AI's summary
by_severity: dict[str, int] = {}
for r in markers:
by_severity[r.get("severity", "unknown")] = by_severity.get(r.get("severity", "unknown"), 0) + 1
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"markerCount": len(markers),
"bySeverityCounts": by_severity,
"markers": markers,
"_note": "Markers reflect the LAST DRC run (manual or kicad-cli). To trigger a fresh DRC, use kicad_run_drc; this method only READS existing markers.",
}
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": True, "_error": f"{type(e).__name__}: {e}"}
# ── PCB export (in-process via pcbnew.PLOT_CONTROLLER) ────────────────
def _ui_export_pcb_plot(fmt_label: str, params: dict) -> dict:
"""Export PCB to SVG or PDF using pcbnew.PLOT_CONTROLLER. In-process —
no kicad-cli subprocess startup cost (~3-5s per call savings).
fmt_label: "svg" or "pdf"
params (all optional):
outputDir: destination dir (default: %TEMP%/adom-kicad-export/)
layers: list of layer names to plot (default: copper+silk)
accepts canonical names like "F.Cu", "B.SilkS", "Edge.Cuts"
mirror: bool, mirror the plot (default false)
plotFrameRef:bool, plot the page border (default false)
useAuxOrigin:bool, use aux origin instead of page origin (default false)
subtractMaskFromSilk: bool (default false)
drillMarks: one of "none", "small", "full" (default "small")
filenamePrefix: stem prepended to each layer's filename (default: board basename)
"""
try:
import pcbnew # type: ignore[import-not-found]
except ImportError:
return {"hasBoard": False, "_error": "pcbnew not importable"}
try:
board = pcbnew.GetBoard()
except Exception as e: # pylint: disable=broad-except
return {"hasBoard": False, "_error": f"{type(e).__name__}: {e}"}
if board is None:
return {"hasBoard": False, "_error": "no board loaded"}
# Resolve format
fmt = fmt_label.lower()
if fmt == "svg":
plot_format = pcbnew.PLOT_FORMAT_SVG
elif fmt == "pdf":
plot_format = pcbnew.PLOT_FORMAT_PDF
else:
return {"hasBoard": True, "_error": f"unsupported export format: {fmt_label}"}
# Output directory
try:
out_dir_str = params.get("outputDir") or os.path.join(
os.environ.get("TEMP") or "/tmp",
"adom-kicad-export",
)
out_dir = Path(out_dir_str)
out_dir.mkdir(parents=True, exist_ok=True)
except OSError as e:
return {"hasBoard": True, "_error": f"could not create output dir: {e}"}
# Layers to plot
requested_layer_names = params.get("layers")
layer_ids: list[int] = []
if requested_layer_names:
# User-specified list
for name in requested_layer_names:
for lid in range(pcbnew.PCB_LAYER_ID_COUNT):
if board.IsLayerEnabled(lid) and board.GetLayerName(lid) == name:
layer_ids.append(lid)
break
else:
# Couldn't match — record but continue
pass
else:
# Default: every enabled copper layer + F.SilkS + B.SilkS + Edge.Cuts
for lid in range(pcbnew.PCB_LAYER_ID_COUNT):
if not board.IsLayerEnabled(lid):
continue
name = board.GetLayerName(lid)
try:
is_copper = bool(pcbnew.IsCopperLayer(lid))
except Exception: # pylint: disable=broad-except
is_copper = False
if is_copper or name in ("F.Silkscreen", "B.Silkscreen", "F.SilkS", "B.SilkS", "Edge.Cuts"):
layer_ids.append(lid)
# Configure plot controller
try:
plot_controller = pcbnew.PLOT_CONTROLLER(board)
except AttributeError:
try:
plot_controller = pcbnew.PCB_PLOT_CONTROLLER(board)
except AttributeError:
return {"hasBoard": True, "_error": "PLOT_CONTROLLER not available in this pcbnew SWIG binding"}
plot_options = plot_controller.GetPlotOptions()
plot_options.SetFormat(plot_format)
plot_options.SetOutputDirectory(str(out_dir))
plot_options.SetPlotFrameRef(bool(params.get("plotFrameRef", False)))
plot_options.SetMirror(bool(params.get("mirror", False)))
plot_options.SetUseAuxOrigin(bool(params.get("useAuxOrigin", False)))
try:
plot_options.SetSubtractMaskFromSilk(bool(params.get("subtractMaskFromSilk", False)))
except Exception: # pylint: disable=broad-except
pass
# Drill marks
drill_marks = (params.get("drillMarks") or "small").lower()
try:
# KiCad's PCB_PLOT_PARAMS exposes the enum as nested constants
drill_enum = {
"none": pcbnew.DRILL_MARKS_NO_DRILL_SHAPE,
"small": pcbnew.DRILL_MARKS_SMALL_DRILL_SHAPE,
"full": pcbnew.DRILL_MARKS_FULL_DRILL_SHAPE,
}.get(drill_marks)
if drill_enum is not None and hasattr(plot_options, "SetDrillMarksType"):
plot_options.SetDrillMarksType(drill_enum)
except Exception: # pylint: disable=broad-except
pass
# Filename prefix. KiCad's PLOT_CONTROLLER always prepends the board
# basename to the OpenPlotfile sheet name. So:
# - Default (no prefix): sheet_name=<layer> → "<basename>-<layer>.svg"
# - With prefix: sheet_name=<prefix>-<layer> → "<basename>-<prefix>-<layer>.svg"
# The previous default used basename as the prefix, producing the
# ugly "<basename>-<basename>-<layer>.svg" doubling.
board_path = board.GetFileName()
board_basename = Path(board_path).stem if board_path else "board" # noqa: F841 — kept for caller refs
prefix = params.get("filenamePrefix")
# Plot each layer
written: list[dict] = []
plotted_count = 0
try:
for lid in layer_ids:
try:
plot_controller.SetLayer(lid)
layer_name = board.GetLayerName(lid)
# OpenPlotfile takes (sheetName, format, sheetDescription).
# For multi-layer SVG/PDF this opens a new file per layer.
# KiCad always prepends the board basename to the output
# filename, so the actual path is:
# <output_dir>/<board_basename>-<sheetName>.<ext>
sheet_name = f"{prefix}-{layer_name}" if prefix else layer_name
plot_controller.OpenPlotfile(sheet_name, plot_format, "")
plot_controller.PlotLayer()
plot_controller.ClosePlot()
plotted_count += 1
# Compute the path KiCad wrote to. Different KiCad versions
# name it slightly differently; we search the output dir
# after the fact and capture whatever .svg/.pdf was just
# created.
# First attempt: predictable naming.
ext = "svg" if fmt == "svg" else "pdf"
# KiCad's output filename is usually <prefix>-<safe_layer_name>.<ext>
# where safe_layer_name replaces dots with underscores.
# Just sample the dir for the most recently modified matching file.
candidates = sorted(out_dir.glob(f"*-{layer_name.replace('.', '_')}.{ext}"),
key=lambda p: p.stat().st_mtime, reverse=True)
if not candidates:
candidates = sorted(out_dir.glob(f"*.{ext}"),
key=lambda p: p.stat().st_mtime, reverse=True)
actual_path = str(candidates[0]) if candidates else None
written.append({
"layerId": lid,
"layerName": layer_name,
"filePath": actual_path,
"fileSize": candidates[0].stat().st_size if candidates else None,
})
except Exception as e: # pylint: disable=broad-except
written.append({
"layerId": lid,
"layerName": board.GetLayerName(lid) if lid >= 0 else None,
"_error": f"{type(e).__name__}: {e}",
})
finally:
try:
plot_controller.ClosePlot()
except Exception: # pylint: disable=broad-except
pass
return {
"hasBoard": True,
"fileName": board.GetFileName(),
"format": fmt,
"outputDir": str(out_dir),
"plottedLayerCount": plotted_count,
"files": written,
}
# ── Library navigation (Symbol Editor / Footprint Editor search-box) ──
def _find_search_ctrl_recursive(window) -> object:
"""Recursively find a wx.SearchCtrl (or wx.TextCtrl with placeholder
containing 'search') under `window`. Returns the control or None.
wx widget trees are arbitrary deep — Symbol Editor's library panel
has the search box nested inside a wxAuiManager pane inside a
wxSplitter inside the main frame. Walk every descendant."""
try:
import wx # type: ignore[import-not-found]
except ImportError:
return None
try:
# SearchCtrl is the obvious target
if isinstance(window, wx.SearchCtrl):
return window
# KiCad sometimes uses a plain TextCtrl with a search hint
if isinstance(window, wx.TextCtrl):
try:
hint = window.GetHint() if hasattr(window, "GetHint") else ""
if hint and "search" in hint.lower():
return window
except Exception: # pylint: disable=broad-except
pass
for child in window.GetChildren():
found = _find_search_ctrl_recursive(child)
if found is not None:
return found
except Exception: # pylint: disable=broad-except
pass
return None
def _ui_navigate_in_editor(editor_kind: str, query: str) -> dict:
"""Generic library-search navigation. Finds the editor's wx frame,
locates its search control, sets the value, and posts a text-changed
event so the live filter activates.
editor_kind: "symbol" or "footprint" (matched against frame title)
query: text to type into the search box (substring match in KiCad's
library filter)
Returns {found, searched, frameTitle, searchCtrlClass, error?}.
NOTE: This is a NON-DESTRUCTIVE filter. It does NOT click "load symbol"
or open the editor with the result selected — it just sets the search
text so the user (or a follow-up wm_command) can act on the filtered list.
"""
try:
import wx # type: ignore[import-not-found]
except ImportError:
return {"found": False, "_error": "wx not loaded"}
needle = f"{editor_kind} editor".lower()
target_frame = None
for win in wx.GetTopLevelWindows():
try:
title = win.GetTitle() if hasattr(win, "GetTitle") else ""
except Exception: # pylint: disable=broad-except
title = ""
if needle in title.lower():
target_frame = win
break
if target_frame is None:
return {
"found": False,
"error": f"no '{editor_kind} editor' frame is open",
"_hint": f"call kicad_open_{editor_kind}_editor first to launch the editor.",
}
sctrl = _find_search_ctrl_recursive(target_frame)
if sctrl is None:
return {
"found": False,
"frameTitle": target_frame.GetTitle(),
"error": "could not locate search control inside the editor frame",
"_hint": "the wx widget hierarchy may have changed in this KiCad version. "
"Report the KiCad version and try the legacy PowerShell SendKeys path.",
}
try:
sctrl.SetValue(query)
# Post EVT_TEXT so the live filter activates
try:
evt = wx.CommandEvent(wx.EVT_TEXT.typeId, sctrl.GetId())
evt.SetEventObject(sctrl)
evt.SetString(query)
wx.PostEvent(sctrl, evt)
except Exception: # pylint: disable=broad-except
pass
return {
"found": True,
"frameTitle": target_frame.GetTitle(),
"searchCtrlClass": type(sctrl).__name__,
"searched": query,
"_hint": (
f"Set the library search filter to {query!r}. "
f"This is FILTER-ONLY — it does NOT load the {editor_kind}. "
f"The user can now SEE the matching entries in the library "
f"tree. To filter AND load the first match in one call, use "
f"select_and_load_{editor_kind} instead (v0.6.0+, "
f"keystroke-based, no longer segfaults). To revert the "
f"filter, call navigate_{editor_kind} with an empty query."
),
}
except Exception as e: # pylint: disable=broad-except
return {
"found": False,
"frameTitle": target_frame.GetTitle(),
"error": f"failed to set search value: {type(e).__name__}: {e}",
}
def _ui_navigate_symbol(symbol_name: str) -> dict:
return _ui_navigate_in_editor("symbol", symbol_name)
def _ui_navigate_footprint(footprint_name: str) -> dict:
return _ui_navigate_in_editor("footprint", footprint_name)
def _find_dataview_ctrl_recursive(window) -> object:
"""Recursively find the wx.dataview.DataViewCtrl that holds the
library tree in Symbol Editor / Footprint Editor. Returns the
control or None."""
try:
import wx # type: ignore[import-not-found]
import wx.dataview # type: ignore[import-not-found]
except ImportError:
return None
try:
if isinstance(window, wx.dataview.DataViewCtrl):
return window
for child in window.GetChildren():
found = _find_dataview_ctrl_recursive(child)
if found is not None:
return found
except Exception: # pylint: disable=broad-except
pass
return None
def _find_treectrl_recursive(window) -> object:
"""Older KiCad versions or alternative panels may use a plain TreeCtrl.
Fallback when DataViewCtrl isn't found."""
try:
import wx # type: ignore[import-not-found]
except ImportError:
return None
try:
if isinstance(window, wx.TreeCtrl):
return window
for child in window.GetChildren():
found = _find_treectrl_recursive(child)
if found is not None:
return found
except Exception: # pylint: disable=broad-except
pass
return None
def _ui_select_and_load(editor_kind: str, name: str, use_send_input: bool = False) -> dict:
"""Filter the editor's library tree by `name`, then KEYSTROKE-SIMULATE
"first match selected + activated" to load it.
v0.8.0 (Phase 2.ad): with `use_send_input=True`, foreground
acquisition uses `_ui_robust_focus` — the AttachThreadInput dance
PLUS a SendInput LEFT-CLICK at the search ctrl's screen center.
Previously v0.7.0 used a naive `SetForegroundWindow` which Windows
frequently downgraded to a taskbar flash, causing SendInput
keystrokes to land in the terminal/VS Code instead of KiCad. Now
the click guarantees foreground + child focus; cursor pos is
snapshotted + restored.
v0.7.0 (Phase 2.ac): added `use_send_input` opt-in. When True,
after setting the search filter we focus the editor frame +
inject real Win32 keystrokes via SendInput (vs the default
wx.PostEvent which may not propagate to KiCad's
TOOL_DISPATCHER). SendInput is more reliable but briefly steals
foreground focus.
v0.6.0 rewrite: previous impl walked the wx.dataview.DataViewModel
to find the first leaf, then posted EVT_DATAVIEW_ITEM_ACTIVATED on
the picked item. That worked on small libraries but SEGFAULTED
eeschema on real KiCad symbol libraries.
Returns: {found, loaded?, frameTitle, searchedFor, transport,
keystrokeMode, error?, _hint?}.
"""
try:
import wx # type: ignore[import-not-found]
except ImportError:
return {"found": False, "_error": "wx not loaded"}
# Step 1+2: navigate (sets the search filter via _ui_navigate_in_editor)
nav = _ui_navigate_in_editor(editor_kind, name)
if not nav.get("found"):
return {**nav, "loaded": False}
# Step 3: re-resolve the editor frame and the search control to send
# keystrokes to.
needle = f"{editor_kind} editor".lower()
target_frame = None
for win in wx.GetTopLevelWindows():
try:
title = win.GetTitle() if hasattr(win, "GetTitle") else ""
except Exception: # pylint: disable=broad-except
title = ""
if needle in title.lower():
target_frame = win
break
if target_frame is None:
return {"found": True, "loaded": False, "searchedFor": name,
"error": "editor frame disappeared between navigate and activate"}
sctrl = _find_search_ctrl_recursive(target_frame)
if sctrl is None:
return {
"found": True,
"loaded": False,
"searchedFor": name,
"frameTitle": target_frame.GetTitle(),
"error": "search control not found",
"_hint": "navigate_in_editor returned found=True but the search ctrl "
"vanished. Likely a wx widget hierarchy change in this "
"KiCad version. Fall back to the legacy PowerShell SendKeys "
"path via kicad_open_symbol_editor.",
}
# Step 4: dispatch the Down + Enter keystrokes.
#
# Two transports:
# - wx.PostEvent (default): enqueues wx.KeyEvent on wx's event
# loop. Doesn't steal foreground. BUT often doesn't propagate
# to KiCad's native Win32 / TOOL_DISPATCHER handler.
# - SendInput (opt-in via use_send_input): real Win32 keyboard
# events. Always reaches KiCad. BUT briefly steals foreground.
keystroke_mode = "send_input" if use_send_input else "wx_post_event"
try:
# Focus the search ctrl regardless of transport — SendInput
# needs the right window focused, wx.PostEvent benefits from it.
try:
sctrl.SetFocus()
except Exception: # pylint: disable=broad-except
pass
# Give wx one cycle to actually move focus (otherwise SendInput
# injects into the previous foreground window — typically the
# CLI terminal or VS Code).
try:
wx.YieldIfNeeded()
except Exception: # pylint: disable=broad-except
pass
if use_send_input:
# v0.8.0 (Phase 2.ad): robust foreground acquisition before
# the SendInput keystrokes. v0.7.0 used a naive
# `SetForegroundWindow(hwnd)` + 150ms sleep, which Windows
# frequently downgraded to a taskbar flash because the
# bridge thread didn't have recent user-input context —
# SendInput keystrokes then landed in whatever window WAS
# foreground (terminal, VS Code, etc.) instead of KiCad,
# and verify_loaded always reported hasLoaded:false.
#
# _ui_robust_focus does TWO things together:
# 1. The same keybd_event(0,0) + AttachThreadInput +
# BringWindowToTop + SetForegroundWindow dance Tauri's
# screenshot.rs uses — documented Win32 pattern.
# 2. A SendInput LEFT-CLICK at the center of the search
# ctrl's screen rect — mouse clicks bypass the
# foreground restriction unconditionally AND set
# keyboard focus inside the child control. Matches
# the legacy navigate_symbol.ps1 mouse_event path.
# 3. Cursor position is snapshotted + restored so the
# user's mouse doesn't end up in a surprising spot.
try:
hwnd = int(target_frame.GetHandle())
except Exception: # pylint: disable=broad-except
hwnd = 0
# Compute the search ctrl's screen center.
click_x: int | None = None
click_y: int | None = None
try:
screen_rect = sctrl.GetScreenRect()
click_x = screen_rect.x + screen_rect.width // 2
click_y = screen_rect.y + screen_rect.height // 2
except Exception: # pylint: disable=broad-except
pass
focus_detail = _ui_robust_focus(hwnd, click_x, click_y)
# Send the PROVEN keystroke sequence that the legacy
# navigate_symbol.ps1 / navigate_footprint.ps1 PowerShell
# path uses: ENTER to commit the search filter, then
# CTRL+SHIFT+E to load the highlighted entry. WXK_DOWN
# alone isn't enough — KiCad's library tree needs the
# explicit "load symbol" shortcut.
VK_RETURN = 0x0D
VK_CONTROL = 0x11
VK_SHIFT = 0x10
VK_E = 0x45
si = _ui_send_input_keystrokes([
VK_RETURN, # commit search
(VK_CONTROL, VK_SHIFT, VK_E), # Ctrl+Shift+E
])
keys_sent = ["SendInput VK_RETURN", "SendInput Ctrl+Shift+E"] if si.get("ok") else [f"SendInput failed: {si}"]
transport_detail = {"sendInput": si, "robustFocus": focus_detail}
else:
keys_sent = []
for keycode, label in ((wx.WXK_DOWN, "WXK_DOWN"), (wx.WXK_RETURN, "WXK_RETURN")):
try:
key_down = wx.KeyEvent(wx.wxEVT_KEY_DOWN)
key_down.SetEventObject(sctrl)
key_down.SetKeyCode(keycode)
wx.PostEvent(sctrl, key_down)
key_up = wx.KeyEvent(wx.wxEVT_KEY_UP)
key_up.SetEventObject(sctrl)
key_up.SetKeyCode(keycode)
wx.PostEvent(sctrl, key_up)
keys_sent.append(label)
except Exception as e: # pylint: disable=broad-except
keys_sent.append(f"{label}:error={type(e).__name__}")
transport_detail = None
return {
"found": True,
# "unverified" — caller must verify_loaded to confirm. SendInput
# is more reliable but neither is guaranteed; the editor frame
# title is the ground truth.
"loaded": "unverified",
"searchedFor": name,
"frameTitle": target_frame.GetTitle(),
"searchCtrlClass": type(sctrl).__name__,
"keystrokeMode": keystroke_mode,
"keysSent": keys_sent,
"transportDetail": transport_detail,
"_hint": (
"Keystrokes sent. To CONFIRM the symbol/footprint actually "
"loaded, call `kicad_bridge_call verify_loaded "
f"{{\"kind\":\"{editor_kind}\",\"expectedName\":{name!r}}}` after "
"~500ms. If verify_loaded reports hasLoaded:false, the keystroke "
"didn't dispatch — retry with useSendInput:true (real Win32 "
"keyboard input, briefly steals foreground; more reliable but "
"noisier) or fall back to the legacy path via "
f"`kicad_open_{editor_kind}_editor '{{\"{editor_kind}Name\":\"...\"}}'`."
if not use_send_input else
"Keystrokes sent via Win32 SendInput (real keyboard input). "
f"Foreground was briefly stolen to {target_frame.GetTitle()!r}. "
"Call `kicad_bridge_call verify_loaded "
f"{{\"kind\":\"{editor_kind}\",\"expectedName\":{name!r}}}` to "
"confirm. If still hasLoaded:false, the search returned zero "
"matches — refine the query."
),
}
except Exception as e: # pylint: disable=broad-except
return {
"found": True,
"loaded": False,
"searchedFor": name,
"frameTitle": target_frame.GetTitle(),
"_error": f"{type(e).__name__}: {e}",
}
def _ui_dataview_activate_first(dvc, frame, searched_for: str) -> dict:
"""Walk a wx.dataview.DataViewCtrl's model, find the first leaf,
select it, and post EVT_DATAVIEW_ITEM_ACTIVATED to trigger load."""
try:
import wx # type: ignore[import-not-found]
import wx.dataview # type: ignore[import-not-found]
except ImportError:
return {"loaded": False, "_error": "wx.dataview unavailable"}
try:
model = dvc.GetModel()
if model is None:
return {
"found": True, "loaded": False, "searchedFor": searched_for,
"frameTitle": frame.GetTitle(),
"error": "DataViewCtrl has no model",
}
# Walk the model: roots → children → leaves. KiCad's library tree
# model has libraries at the root, symbols/footprints as children.
# After the filter, hidden items aren't in IsEnabled but are still
# walkable via GetChildren. We want the first item that IS rendered
# (i.e. matches the filter).
#
# wxPython's DataViewItemArray is list-like — use len() + [i],
# NOT the C++ GetCount()/Item() which aren't exposed in SWIG.
children_root = wx.dataview.DataViewItemArray()
model.GetChildren(wx.dataview.DataViewItem(0), children_root)
first_leaf = None
# Recurse: for each root, expand and look for leaf children.
def _walk(item, depth=0):
nonlocal first_leaf
if first_leaf is not None or depth > 8:
return
kids = wx.dataview.DataViewItemArray()
model.GetChildren(item, kids)
if len(kids) == 0:
# This is a leaf — pick it
first_leaf = item
return
for i in range(len(kids)):
_walk(kids[i], depth + 1)
# Walk each top-level (library) until we find a leaf
for i in range(len(children_root)):
_walk(children_root[i])
if first_leaf is not None:
break
if first_leaf is None:
return {
"found": True, "loaded": False, "searchedFor": searched_for,
"frameTitle": frame.GetTitle(),
"error": "no leaf items in (filtered) library tree",
"_hint": "The search filter may have hidden all entries. Refine the query or check spelling.",
}
# Select + ensure visible
try:
dvc.UnselectAll()
dvc.Select(first_leaf)
dvc.EnsureVisible(first_leaf)
except Exception: # pylint: disable=broad-except
pass
# Get a human label for the activated item
activated_label = ""
try:
value = model.GetValue(first_leaf, 0)
activated_label = str(value) if value is not None else ""
except Exception: # pylint: disable=broad-except
pass
# Post EVT_DATAVIEW_ITEM_ACTIVATED — same effect as double-clicking
try:
evt_type = wx.dataview.EVT_DATAVIEW_ITEM_ACTIVATED.typeId
evt = wx.dataview.DataViewEvent(evt_type, dvc, first_leaf)
evt.SetEventObject(dvc)
wx.PostEvent(dvc, evt)
posted = True
except Exception as e: # pylint: disable=broad-except
posted = False
post_err = f"{type(e).__name__}: {e}"
result = {
"found": True,
"loaded": posted,
"searchedFor": searched_for,
"frameTitle": frame.GetTitle(),
"activatedItem": activated_label,
"treeControlClass": "DataViewCtrl",
}
if not posted:
result["error"] = "PostEvent failed for EVT_DATAVIEW_ITEM_ACTIVATED"
result["_postError"] = post_err
return result
except Exception as e: # pylint: disable=broad-except
return {
"found": True, "loaded": False, "searchedFor": searched_for,
"frameTitle": frame.GetTitle(),
"_error": f"{type(e).__name__}: {e}",
}
def _ui_treectrl_activate_first(tree, frame, searched_for: str) -> dict:
"""Fallback: classic wx.TreeCtrl. Walk to first leaf + post
EVT_TREE_ITEM_ACTIVATED."""
try:
import wx # type: ignore[import-not-found]
except ImportError:
return {"loaded": False, "_error": "wx unavailable"}
try:
root = tree.GetRootItem()
if not root.IsOk():
return {
"found": True, "loaded": False, "searchedFor": searched_for,
"frameTitle": frame.GetTitle(),
"error": "TreeCtrl has no root",
}
def _walk_tree(item):
# If item has no children, it's a leaf.
cookie = 0
try:
child, cookie = tree.GetFirstChild(item)
except Exception: # pylint: disable=broad-except
child = None
if not child or not child.IsOk():
return item
while child and child.IsOk():
found = _walk_tree(child)
if found is not None:
return found
try:
child, cookie = tree.GetNextChild(item, cookie)
except Exception: # pylint: disable=broad-except
break
return None
leaf = None
# If the root is hidden, walk its children
if tree.HasFlag(wx.TR_HIDE_ROOT):
cookie = 0
try:
child, cookie = tree.GetFirstChild(root)
except Exception: # pylint: disable=broad-except
child = None
while child and child.IsOk() and leaf is None:
leaf = _walk_tree(child)
try:
child, cookie = tree.GetNextChild(root, cookie)
except Exception: # pylint: disable=broad-except
break
else:
leaf = _walk_tree(root)
if leaf is None:
return {
"found": True, "loaded": False, "searchedFor": searched_for,
"frameTitle": frame.GetTitle(),
"error": "no leaf in (filtered) TreeCtrl",
}
tree.SelectItem(leaf)
tree.EnsureVisible(leaf)
label = tree.GetItemText(leaf)
# Post EVT_TREE_ITEM_ACTIVATED
try:
evt = wx.TreeEvent(wx.EVT_TREE_ITEM_ACTIVATED.typeId, tree, leaf)
evt.SetEventObject(tree)
wx.PostEvent(tree, evt)
posted = True
except Exception: # pylint: disable=broad-except
posted = False
return {
"found": True,
"loaded": posted,
"searchedFor": searched_for,
"frameTitle": frame.GetTitle(),
"activatedItem": label,
"treeControlClass": "TreeCtrl",
}
except Exception as e: # pylint: disable=broad-except
return {"found": True, "loaded": False, "searchedFor": searched_for,
"frameTitle": frame.GetTitle(),
"_error": f"{type(e).__name__}: {e}"}
def _ui_select_and_load_symbol(symbol_name: str, use_send_input: bool = False) -> dict:
return _ui_select_and_load("symbol", symbol_name, use_send_input=use_send_input)
def _ui_select_and_load_footprint(footprint_name: str, use_send_input: bool = False) -> dict:
return _ui_select_and_load("footprint", footprint_name, use_send_input=use_send_input)
# ── verify_loaded + SendInput fallback (Phase 2.ac) ──────────────────
# KiCad's editor frame titles when nothing is loaded. Used by both
# verify_loaded and select_and_load to confirm load success.
_NO_LOAD_MARKERS = {
"symbol": ("[no symbol loaded]",),
"footprint": ("[no footprint loaded]",),
}
def _ui_verify_loaded(editor_kind: str, expected_name: str = "") -> dict:
"""Read the editor frame title and report whether the editor has a
symbol/footprint loaded. Phase 2.ac+.
Args:
editor_kind: "symbol" or "footprint"
expected_name: optional — if non-empty, check the title CONTAINS
this substring. Use to confirm a specific symbol/
footprint was loaded by a prior select_and_load_*.
Returns:
{found: bool, — was the editor frame found at all?
frameTitle: str,
hasLoaded: bool, — is something loaded? (title doesn't contain
"[no <kind> loaded]" marker)
loadedName: str?, — best-effort: extracted symbol/footprint name
matchesExpected: bool? — only present if expected_name was passed
_hint}
"""
try:
import wx # type: ignore[import-not-found]
except ImportError:
return {"found": False, "_error": "wx not loaded"}
needle = f"{editor_kind} editor".lower()
target_frame = None
for win in wx.GetTopLevelWindows():
try:
title = win.GetTitle() if hasattr(win, "GetTitle") else ""
except Exception: # pylint: disable=broad-except
title = ""
if needle in title.lower():
target_frame = win
break
if target_frame is None:
return {
"found": False,
"_hint": f"No '{editor_kind} editor' frame is currently open. "
f"Call kicad_open_{editor_kind}_editor first, then retry.",
}
title = target_frame.GetTitle()
no_load_markers = _NO_LOAD_MARKERS.get(editor_kind, ())
has_loaded = not any(marker in title for marker in no_load_markers)
# Extract the loaded name from the title. KiCad's editor title format:
# "Library:Name — Symbol Editor" (saved/loaded item, no edits)
# "Library:Name * — Symbol Editor" (loaded but modified)
# "[no symbol loaded] — Symbol Editor" (nothing loaded)
# The "— Symbol Editor" / "— Footprint Editor" suffix is constant; we
# split on it to isolate the prefix.
loaded_name = None
if has_loaded:
# Try em-dash separator first (KiCad uses U+2014)
for sep in ("—", "-"):
if sep in title:
prefix = title.split(sep, 1)[0].strip()
# Strip trailing modification marker
prefix = prefix.rstrip(" *")
if prefix:
loaded_name = prefix
break
result: dict = {
"found": True,
"frameTitle": title,
"hasLoaded": has_loaded,
"loadedName": loaded_name,
}
if expected_name:
if has_loaded and loaded_name:
# Allow substring match — "R_US" matches "Device:R_US",
# "RP2040" matches "Adom:RP2040", etc.
result["matchesExpected"] = expected_name.lower() in loaded_name.lower()
else:
result["matchesExpected"] = False
result["expectedName"] = expected_name
if not has_loaded:
result["_hint"] = (
f"The {editor_kind} editor is OPEN but nothing is loaded yet. "
f"If you just called select_and_load_{editor_kind}, the keystroke "
f"may not have dispatched (wx KeyEvent doesn't always reach KiCad's "
f"TOOL_DISPATCHER). Retry with useSendInput:true for real Win32 "
f"keyboard input, OR fall back to "
f"kicad_open_{editor_kind}_editor '{{\"{editor_kind}Name\":\"...\"}}' "
f"which uses the proven PowerShell SendKeys path."
)
elif expected_name and not result["matchesExpected"]:
result["_hint"] = (
f"The {editor_kind} editor has {loaded_name!r} loaded, NOT "
f"{expected_name!r}. The search filter may have matched a different "
f"item first. Refine your query (e.g. include the library prefix: "
f"\"Device:R_US\" instead of just \"R\"), or use "
f"kicad_open_{editor_kind}_editor with the exact name."
)
else:
# Loaded + (no expected or matches)
result["_hint"] = (
f"✅ {editor_kind} editor has {loaded_name!r} loaded. "
f"Safe to drive further edits via the editor's GUI (or wait for "
f"future Phase 2.ad bridge methods to manipulate the loaded item in-place)."
)
return result
def _ui_send_input_keystrokes(keys) -> dict:
"""Inject real Win32 keystrokes via SendInput. Each item in `keys` is
either:
- a virtual key code (int) for a tap (down + up), e.g. VK_RETURN
- a tuple of VK ints to chord (e.g. (VK_CONTROL, VK_SHIFT, 0x45)
for Ctrl+Shift+E — held down in order, then released in reverse).
Trade-off vs wx.PostEvent: SendInput sends keystrokes at the OS
level — they reach whatever window has foreground focus. So:
1. The caller must ensure the right window has focus FIRST
(we do via SetForegroundWindow on the editor frame).
2. SendInput briefly STEALS foreground from whatever was active.
Returns: {ok: bool, totalEvents: int, _error?}.
"""
try:
import ctypes
import ctypes.wintypes as wt
except ImportError:
return {"ok": False, "_error": "ctypes not available"}
INPUT_KEYBOARD = 1
KEYEVENTF_KEYUP = 0x0002
class KEYBDINPUT(ctypes.Structure):
_fields_ = [
("wVk", wt.WORD),
("wScan", wt.WORD),
("dwFlags", wt.DWORD),
("time", wt.DWORD),
("dwExtraInfo", ctypes.POINTER(wt.ULONG)),
]
class _INPUT_UNION(ctypes.Union):
_fields_ = [("ki", KEYBDINPUT), ("padding", ctypes.c_byte * 32)]
class INPUT(ctypes.Structure):
_anonymous_ = ("u",)
_fields_ = [("type", wt.DWORD), ("u", _INPUT_UNION)]
SendInput = ctypes.windll.user32.SendInput
SendInput.argtypes = [wt.UINT, ctypes.POINTER(INPUT), ctypes.c_int]
SendInput.restype = wt.UINT
def _add_event(inputs_list, vk, key_up):
inp = INPUT()
inp.type = INPUT_KEYBOARD
inp.ki = KEYBDINPUT(
wVk=vk,
wScan=0,
dwFlags=KEYEVENTF_KEYUP if key_up else 0,
time=0,
dwExtraInfo=ctypes.cast(ctypes.pointer(wt.ULONG(0)),
ctypes.POINTER(wt.ULONG)),
)
inputs_list.append(inp)
inputs: list[INPUT] = []
for item in keys:
if isinstance(item, (tuple, list)):
# Chord: press each VK down in order, then release in reverse
for vk in item:
_add_event(inputs, vk, False)
for vk in reversed(item):
_add_event(inputs, vk, True)
else:
# Tap: down + up
_add_event(inputs, item, False)
_add_event(inputs, item, True)
arr_type = INPUT * len(inputs)
arr = arr_type(*inputs)
sent = SendInput(len(inputs), arr, ctypes.sizeof(INPUT))
return {"ok": sent == len(inputs), "totalEvents": len(inputs)}
def _ui_robust_focus(hwnd: int,
click_x: int | None = None,
click_y: int | None = None,
restore_cursor: bool = True) -> dict:
"""Reliably bring a Win32 window to foreground AND set keyboard focus
inside it.
The naive `SetForegroundWindow(hwnd)` is unreliable on modern Windows:
Microsoft restricts foreground-switching to processes that recently
handled user input, so a bare call often just flashes the taskbar
button instead of actually granting foreground. v0.7.0's
`_ui_select_and_load(use_send_input=True)` did exactly the naive call
and SendInput keystrokes frequently landed in the previously-focused
window (terminal, VS Code, etc.) instead of KiCad.
v0.8.0 (Phase 2.ad): two complementary techniques run together:
1. The same `force_foreground` dance Tauri's screenshot.rs uses —
send a phantom keybd_event to mark "this thread had user input,"
AttachThreadInput to share the foreground thread's input queue,
then BringWindowToTop + SetForegroundWindow. Documented Win32
pattern; works reliably across UAC/elevation boundaries.
2. If `click_x`/`click_y` are provided, a SendInput LEFT-CLICK at
those screen coordinates. Mouse clicks are unconditionally
treated as user input by Windows — they bring the owning window
to foreground AND, for clicks inside an edit control, set
keyboard focus there. This matches the legacy PowerShell
`navigate_symbol.ps1` `mouse_event` path that historically
worked for KiCad's symbol-chooser search ctrl. `restore_cursor`
(default True) snapshots cursor pos before the click and
restores it after — avoids surprising the user with a cursor
jump on a different monitor.
Returns: {ok, hwnd, foregroundOk, clicked, restoreCursorOk, _error?}.
"""
try:
import ctypes
import ctypes.wintypes as wt
import time
except ImportError:
return {"ok": False, "_error": "ctypes not available"}
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
# ── Step 1: AttachThreadInput dance to acquire real foreground. ──
foreground_ok = False
try:
# Phantom keybd_event(0, 0, 0, 0): VK=0 is invalid, no real
# keystroke fires, but Windows flips an internal bit saying
# "this thread just had user input" — which is the gate that
# allows SetForegroundWindow to actually grant foreground.
user32.keybd_event(0, 0, 0, 0)
user32.keybd_event(0, 0, 0x0002, 0) # KEYEVENTF_KEYUP
target_tid = user32.GetWindowThreadProcessId(hwnd, None)
our_tid = kernel32.GetCurrentThreadId()
attached = False
if target_tid != 0 and target_tid != our_tid:
attached = bool(user32.AttachThreadInput(our_tid, target_tid, True))
# Un-iconify if minimized.
if user32.IsIconic(hwnd):
user32.ShowWindow(hwnd, 9) # SW_RESTORE
user32.BringWindowToTop(hwnd)
foreground_ok = bool(user32.SetForegroundWindow(hwnd))
if attached:
user32.AttachThreadInput(our_tid, target_tid, False)
except Exception as e: # pylint: disable=broad-except
return {"ok": False, "hwnd": hwnd,
"_error": f"foreground dance failed: {type(e).__name__}: {e}"}
# ── Step 2: optional left-click at (click_x, click_y). ──
clicked = False
restore_cursor_ok = False
saved_pt = None
if click_x is not None and click_y is not None:
try:
if restore_cursor:
class POINT(ctypes.Structure):
_fields_ = [("x", wt.LONG), ("y", wt.LONG)]
saved_pt = POINT()
if user32.GetCursorPos(ctypes.byref(saved_pt)):
pass
else:
saved_pt = None
# Move cursor to target coords (absolute pixels).
user32.SetCursorPos(int(click_x), int(click_y))
# Brief settle — too fast and Windows may merge events.
time.sleep(0.05)
# SendInput left-click. We use mouse_event for simplicity
# (it's still supported on modern Windows; the SendInput
# path requires building a MOUSEINPUT structure and we'd
# otherwise share no code with the keyboard helper).
MOUSEEVENTF_LEFTDOWN = 0x0002
MOUSEEVENTF_LEFTUP = 0x0004
user32.mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0)
time.sleep(0.03)
user32.mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0)
clicked = True
time.sleep(0.08) # let the click propagate (focus change)
# Restore cursor to where the user had it.
if restore_cursor and saved_pt is not None:
if user32.SetCursorPos(saved_pt.x, saved_pt.y):
restore_cursor_ok = True
except Exception as e: # pylint: disable=broad-except
return {
"ok": foreground_ok, # foreground may have succeeded
"hwnd": hwnd,
"foregroundOk": foreground_ok,
"clicked": clicked,
"restoreCursorOk": restore_cursor_ok,
"_error": f"mouse-click step failed: {type(e).__name__}: {e}",
}
return {
"ok": foreground_ok,
"hwnd": hwnd,
"foregroundOk": foreground_ok,
"clicked": clicked,
"restoreCursorOk": restore_cursor_ok,
}
def _ui_wm_command(frame_index: int, menu_id: int) -> dict:
"""Send a Win32 WM_COMMAND to the given top-level frame's HWND.
NOTE: Initial impl used wx.PostEvent with EVT_MENU — events fired but
KiCad's menu-handler chain (which lives in C++ on TOOL_ACTIONs) did
NOT pick them up. The proven path is Win32 PostMessageW(WM_COMMAND),
which is how the external Phase 4 probe-drive.py opened sub-editors
successfully. The native menu-bar dispatch routes through KiCad's
TOOL_DISPATCHER which DOES respond to Win32 messages.
Trade-off: only works on Windows. Cross-platform port (Phase 2.x)
would need to find the wx handler that KiCad bound at C++ level —
likely needs a small C++ shim or a call into KiCad's TOOL_MANAGER
via the pcbnew swig binding.
"""
import wx # type: ignore[import-not-found]
import ctypes
import ctypes.wintypes as wt
windows = list(wx.GetTopLevelWindows())
if not (0 <= frame_index < len(windows)):
raise IndexError(f"frame_index {frame_index} out of range (have {len(windows)} frames)")
win = windows[frame_index]
hwnd = int(win.GetHandle()) if hasattr(win, "GetHandle") else 0
if not hwnd:
raise RuntimeError(f"frame[{frame_index}] has no HWND")
WM_COMMAND = 0x0111
user32 = ctypes.windll.user32
PostMessageW = user32.PostMessageW
PostMessageW.argtypes = [wt.HWND, ctypes.c_uint, wt.WPARAM, wt.LPARAM]
PostMessageW.restype = wt.BOOL
ok = PostMessageW(hwnd, WM_COMMAND, menu_id, 0)
return {
"frameClass": type(win).__name__,
"frameTitle": win.GetTitle() if hasattr(win, "GetTitle") else "",
"hwnd": hwnd,
"menuId": menu_id,
"transport": "win32-postmessage-wm-command",
"posted": bool(ok),
}
# --- request dispatcher ----------------------------------------------------
def _dispatch(method: str, params: dict) -> Any:
if method == "ping":
return {
"version": __version__,
"exeName": _state["exe_name"],
"pid": _state["pid"],
"uptimeMs": int(time.time() * 1000) - _STARTED_AT_MS,
"requestCount": _state["request_count"],
}
if method == "list_frames":
return {"frames": _call_on_ui_thread(_ui_list_frames)}
if method == "get_menu_ids":
return {"frames": _call_on_ui_thread(_ui_get_menu_ids)}
if method == "wm_command":
frame_index = int(params.get("frame_index", 0))
menu_id = int(params["menu_id"])
return _call_on_ui_thread(lambda: _ui_wm_command(frame_index, menu_id))
if method == "get_board_info":
return _call_on_ui_thread(_ui_get_board_info)
if method == "get_schematic_info":
return _call_on_ui_thread(_ui_get_schematic_info)
if method == "get_open_editors":
return _call_on_ui_thread(_ui_get_open_editors)
if method == "get_pcb_footprints":
return _call_on_ui_thread(_ui_get_pcb_footprints)
if method == "get_pcb_layers":
return _call_on_ui_thread(_ui_get_pcb_layers)
if method == "get_pcb_nets":
return _call_on_ui_thread(_ui_get_pcb_nets)
if method == "get_pcb_design_rules":
return _call_on_ui_thread(_ui_get_pcb_design_rules)
if method == "navigate_symbol":
query = params.get("symbolName") or params.get("query") or ""
return _call_on_ui_thread(lambda: _ui_navigate_symbol(query))
if method == "navigate_footprint":
query = params.get("footprintName") or params.get("query") or ""
return _call_on_ui_thread(lambda: _ui_navigate_footprint(query))
if method == "get_pcb_tracks":
return _call_on_ui_thread(_ui_get_pcb_tracks)
if method == "get_pcb_vias":
return _call_on_ui_thread(_ui_get_pcb_vias)
if method == "get_pcb_pads":
return _call_on_ui_thread(_ui_get_pcb_pads)
if method == "get_pcb_drawings":
return _call_on_ui_thread(_ui_get_pcb_drawings)
if method == "export_svg":
return _call_on_ui_thread(lambda: _ui_export_pcb_plot("svg", params), timeout=60.0)
if method == "export_pdf":
return _call_on_ui_thread(lambda: _ui_export_pcb_plot("pdf", params), timeout=60.0)
if method == "get_pcb_zones":
return _call_on_ui_thread(_ui_get_pcb_zones)
if method == "get_footprint_connections":
ref = params.get("ref") or ""
if not ref:
raise ValueError("get_footprint_connections requires params.ref (e.g. 'U1')")
return _call_on_ui_thread(lambda: _ui_get_footprint_connections(ref))
if method == "get_drc_markers":
return _call_on_ui_thread(_ui_get_drc_markers)
if method == "run_drc_inprocess":
return _call_on_ui_thread(_ui_run_drc_inprocess, timeout=15.0)
if method == "get_track_paths_by_net":
net = params.get("netName") or params.get("net") or ""
if not net:
raise ValueError("get_track_paths_by_net requires params.netName (e.g. 'VCC')")
return _call_on_ui_thread(lambda: _ui_get_track_paths_by_net(net))
# Phase 2.ac (v0.7.0): keystroke-based, no more segfault. Optional
# useSendInput:true escalates from wx.PostEvent (safe, may not
# propagate) to real Win32 SendInput (reliable, briefly steals
# foreground). Always pair with verify_loaded for confirmation.
if method == "select_and_load_symbol":
name = params.get("symbolName") or params.get("name") or ""
use_si = bool(params.get("useSendInput"))
return _call_on_ui_thread(lambda: _ui_select_and_load_symbol(name, use_si), timeout=30.0)
if method == "select_and_load_footprint":
name = params.get("footprintName") or params.get("name") or ""
use_si = bool(params.get("useSendInput"))
return _call_on_ui_thread(lambda: _ui_select_and_load_footprint(name, use_si), timeout=30.0)
if method == "verify_loaded":
kind = params.get("kind") or ""
if kind not in ("symbol", "footprint"):
raise ValueError("verify_loaded requires params.kind = 'symbol' or 'footprint'")
expected = params.get("expectedName", "")
return _call_on_ui_thread(lambda: _ui_verify_loaded(kind, expected))
if method == "get_net_topology":
net = params.get("netName") or params.get("net") or ""
if not net:
raise ValueError("get_net_topology requires params.netName (e.g. 'VCC')")
return _call_on_ui_thread(lambda: _ui_get_net_topology(net))
raise ValueError(f"unknown method: {method}")
# --- HTTP server -----------------------------------------------------------
class _RpcHandler(BaseHTTPRequestHandler):
# Silence the default stderr request logging - it spams the KiCad
# console window. If we ever need to debug, set
# ADOM_KICAD_BRIDGE_VERBOSE=1 in the environment.
def log_message(self, format, *args): # noqa: A002 - matches stdlib sig
if os.environ.get("ADOM_KICAD_BRIDGE_VERBOSE") == "1":
super().log_message(format, *args)
def _send_json(self, status: int, payload: dict):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def do_GET(self): # noqa: N802 - stdlib name
if self.path == "/" or self.path == "/status":
self._send_json(200, {
"ok": True,
"version": __version__,
"exeName": _state["exe_name"],
"pid": _state["pid"],
"port": _state["port"],
"requestCount": _state["request_count"],
"uptimeMs": int(time.time() * 1000) - _STARTED_AT_MS,
})
return
self._send_json(404, {"ok": False, "error": "not found"})
def do_POST(self): # noqa: N802 - stdlib name
if self.path != "/rpc":
self._send_json(404, {"ok": False, "error": "post to /rpc"})
return
_state["request_count"] += 1
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
length = 0
try:
raw = self.rfile.read(length) if length > 0 else b""
req = json.loads(raw.decode("utf-8")) if raw else {}
except json.JSONDecodeError as e:
self._send_json(400, {"id": None, "error": {"code": -32700, "message": f"parse error: {e}"}})
return
req_id = req.get("id")
method = req.get("method")
params = req.get("params") or {}
if not method:
self._send_json(400, {"id": req_id, "error": {"code": -32600, "message": "missing 'method'"}})
return
try:
result = _dispatch(method, params)
self._send_json(200, {"id": req_id, "result": result})
except (ValueError, KeyError, IndexError) as e:
self._send_json(400, {"id": req_id, "error": {"code": -32602, "message": str(e)}})
except TimeoutError as e:
self._send_json(504, {"id": req_id, "error": {"code": -32001, "message": f"ui timeout: {e}"}})
except Exception as e: # pylint: disable=broad-except
self._send_json(500, {"id": req_id, "error": {
"code": -32000,
"message": f"{type(e).__name__}: {e}",
"data": traceback.format_exc(),
}})
def _run_server(host: str, port: int):
"""Entry point for the bridge thread."""
try:
server = ThreadingHTTPServer((host, port), _RpcHandler)
server.daemon_threads = True
_state["running"] = True
_state["port"] = port
_write_discovery()
try:
server.serve_forever(poll_interval=0.5)
finally:
_state["running"] = False
_remove_discovery()
except Exception: # pylint: disable=broad-except
try:
(_OUT_DIR / f"adom-kicad-bridge-{_state['exe_name']}.error.log").write_text(
traceback.format_exc(), encoding="utf-8"
)
except OSError:
pass
def _pick_port() -> Optional[int]:
"""Return the port this exe should listen on, or None if every
attempt is taken."""
offset = _EXE_PORT_OFFSET.get(_state["exe_name"])
if offset is None:
offset = max(_EXE_PORT_OFFSET.values()) + 1
base = _BASE_PORT + offset
for attempt in range(11):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("127.0.0.1", base + attempt))
return base + attempt
except OSError:
continue
return None
_bridge_thread: Optional[threading.Thread] = None
def start():
"""Public entry point - called from usercustomize.py.
Idempotent: a second call does nothing. Honors the kill-switch env
var. Returns the port the bridge is listening on, or None if it
didn't start.
"""
global _bridge_thread # pylint: disable=global-statement
if os.environ.get("ADOM_KICAD_BRIDGE_DISABLE") == "1":
return None
if _bridge_thread is not None and _bridge_thread.is_alive():
return _state.get("port")
# Only start if wx is actually loaded. Headless python.exe
# invocations have nothing to bridge.
try:
import wx # type: ignore[import-not-found, unused-import] # noqa: F401
except ImportError:
return None
port = _pick_port()
if port is None:
return None
_bridge_thread = threading.Thread(
target=_run_server,
args=("127.0.0.1", port),
name=f"adom-kicad-bridge-{_state['exe_name']}",
daemon=True,
)
_bridge_thread.start()
# Give the HTTP server a beat to bind. If it failed, the discovery
# file won't appear and the orchestrator will know.
time.sleep(0.1)
return port
def status() -> dict:
return dict(_state)