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.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934
#!/usr/bin/env python3
"""Adom KiCad Bridge Server — localhost HTTP server for KiCad integration.
Receives commands from the Adom Desktop (Tauri app) and controls
KiCad via kicad-cli, direct executable launch, and library table manipulation.
Usage:
python server.py
python server.py --port 8772
"""
import json
import os
import re
import subprocess
from proc import run as _safe_run
import sys
import traceback
from http.server import HTTPServer, ThreadingHTTPServer, BaseHTTPRequestHandler
from pathlib import Path
# Platform detection for v1.7.4+ KiCad-v2 enriched helpers (runningInstances etc.)
IS_WINDOWS = sys.platform == "win32"
# Ensure the plugin root is on the path
sys.path.insert(0, str(Path(__file__).parent))
from kicad_detect import detect_kicad, detect_all_kicad_versions
from capabilities import build_capability_report
from handlers.open_files import handle_open_board, handle_open_schematic
from handlers.open_symbol_editor import handle_open_symbol_editor
from handlers.open_footprint_editor import handle_open_footprint_editor, handle_open_3d_viewer
from handlers.software_opengl import handle_enable_software_opengl
from handlers.diagnostics import handle_diagnostics
from handlers.close_windows import (
handle_close_symbol_editor, handle_close_footprint_editor,
handle_close_3d_viewer, handle_close_kicad, handle_window_info, handle_dismiss_dialogs,
)
from handlers.install_library import handle_install_library
from handlers.install_symbol import handle_install_symbol
from handlers.install_footprint import handle_install_footprint
from handlers.place_footprint import handle_place_footprint
from handlers.run_drc import handle_run_drc
from handlers.fix_keyboard import handle_fix_keyboard
from handlers.kicad_ui import handle_screenshot_all, handle_send_key, handle_click
from handlers.bridge_client import handle_bridge_status, handle_bridge_call, handle_open_editors
from handlers.kicad_cli_lint import (
handle_run_erc, handle_lint_board, handle_lint_schematic,
handle_lint_library, handle_format_upgrade,
)
# v0.7.1+: export handlers (Gerber/PDF/SVG/STEP/BOM_CSV) via kicad-cli. The
# functions have existed in handlers/export.py for a while but were never wired
# into COMMAND_HANDLERS — kicad_export_gerbers/pdf/svg/step/bom_csv all returned
# "Unknown command" at runtime. Discovered while building the KiCad tour skill.
from handlers.export import (
handle_export_gerber, handle_export_pdf, handle_export_svg,
handle_export_step, handle_export_bom_csv,
)
# v0.9.61+: one-shot desktop half of the Adom molecule pipeline (STEP + silk
# renders + sibling sch/pro discovery + container next-steps manifest).
from handlers.export_molecule import handle_export_molecule
# v0.9.63+: first-class showcase tour (AD demos this bridge during HD install).
from handlers.demo import handle_demo
# v0.9.10+: check/upgrade KiCad via the OFFICIAL downloads.kicad.org installer
# (NOT winget, which lags the real release). check_for_updates is read-only;
# upgrade silent-installs the official signed NSIS build.
from handlers.upgrade import handle_check_for_updates, handle_upgrade
from adom_library import ensure_adom_library
import plugin_install
DEFAULT_PORT = 8772
def _read_bridge_version() -> str:
"""Our own bridge version, from the BRIDGE_VERSION file beside server.py.
Reported in /health so AD can detect a STALE bridge process (one that
survived an AD update) and restart it — the "not running latest bridge"
footgun. Empty string if the file is missing (treated as stale by AD)."""
try:
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "BRIDGE_VERSION")
with open(p, encoding="utf-8") as fh:
return fh.read().strip()
except OSError:
return ""
BRIDGE_VERSION = _read_bridge_version()
# Populated at startup
kicad_info = None # default kicad_info (newest installed)
all_kicad_versions: list = [] # every installed version, newest first
def _handle_adom_library_status(kicad_info: dict, args: dict) -> dict:
"""Return the status of the Adom symbol library."""
result = ensure_adom_library(kicad_info)
return {"success": True, **result}
def _handle_list_versions(kicad_info: dict, args: dict) -> dict:
"""List every installed KiCad version on this machine.
Returns the full enriched per-version info from kicad_detect.detect_all_*
plus a `default` flag per entry. v1.7.4+ (KiCad v2 Phase 1): broader
surface — pythonVersion, pluginDir, adomPlugin{Installed,Version},
ipcApiAvailable, ipcApiScope, binaries{}, libraryTablePaths{} all
included per the integration plan. Back-compat: the legacy fields
(version, base_dir, kicad_exe, default) are all still present.
Returns: {success, versions:[full info dict + default flag], default,
activeVersion, count, runningInstances, _hint}
"""
if not all_kicad_versions:
return {
"success": True,
"versions": [],
"default": None,
"activeVersion": None,
"count": 0,
"runningInstances": _detect_running_kicad_instances(),
"_hint": "No KiCad installs found. KiCad is the user's host app, so AD won't silently auto-install it as a dependency/prewarm (v1.9.47) — but DON'T make the user install it by hand. Offer to do it for them, then run `kicad_upgrade '{}'`: the agent installs the latest stable from the OFFICIAL downloads.kicad.org installer (silent /S; may raise a UAC prompt to approve). kicad_upgrade installs from scratch, not just upgrades. Windows only.",
"errorCode": "host_app_not_installed",
"errorCodeLegacy": "kicad_not_installed",
"hostApp": "KiCad",
}
default_version = all_kicad_versions[0]["version"]
# Surface every field detect_all_kicad_versions returns + a `default`
# flag. We deliberately don't hand-pick fields here — callers expecting
# the v1.7.3 shape (version/base_dir/kicad_exe/default) still get them;
# callers using the v1.7.4 enriched shape get everything additional.
versions = []
for v in all_kicad_versions:
entry = dict(v) # shallow copy so we don't mutate the cached info
entry["default"] = (v["version"] == default_version)
versions.append(entry)
return {
"success": True,
"versions": versions,
"default": default_version, # legacy field — newest version
"activeVersion": default_version, # v1.7.4 alias for newest installed
"count": len(all_kicad_versions),
"runningInstances": _detect_running_kicad_instances(),
"_hint": (
f"Pass kicadVersion to any kicad_open_* command to pick a specific version "
f"(e.g. {{\"kicadVersion\":\"{versions[-1]['version']}\"}}). "
f"Omit to use the newest ({default_version}). "
f"v1.7.4+: each entry now includes pythonVersion, pluginDir, adomPluginInstalled, "
f"ipcApiAvailable, binaries{{}}, libraryTablePaths{{}} — see the integration plan."
),
}
def _handle_readiness(kicad_info: dict, args: dict) -> dict:
"""Read-only readiness probe — 'is the KiCad host app present?'
AD v1.9.47+: KiCad is the user's HOST APP. AD detects it, it never
installs it. This verb answers that detection question with zero side
effects: it reads the cached `all_kicad_versions` computed once at
startup, does NOT install the reverse-bridge plugin, does NOT spawn
subprocesses, and works even when KiCad is absent (reports
detected:false). For full per-version detail use kicad_list_versions;
for "what EDA tools do I have" across all bridges use the AD-level
`bridge_readiness`.
"""
detected = bool(all_kicad_versions)
default_version = all_kicad_versions[0]["version"] if detected else None
versions = [
{
"version": v["version"],
"exePath": v.get("kicad_exe"),
"default": (v["version"] == default_version),
}
for v in all_kicad_versions
]
return {
"success": True,
"hostApp": "KiCad",
"detected": detected,
"ready": detected,
"versionCount": len(all_kicad_versions),
"defaultVersion": default_version,
"versions": versions,
"_hint": (
"Read-only host-app detection (no install, no plugin load, no subprocess). "
"AD won't silently auto-install KiCad as a dependency/prewarm (v1.9.47), but the agent "
"still installs it FOR the user on request — never hand the user a manual download. "
+ ("KiCad is installed and ready." if detected
else "KiCad not detected — offer to install it, then run `kicad_upgrade '{}'` (installs the "
"official build from scratch, silent /S, Windows only; may raise a UAC prompt).")
),
}
# Public verb catalog for kicad_describe (Bridge SDK Verb contract + discussion
# #14 amendment). Keyed by the INTERNAL command name; the kicad_ prefix is added
# on output. Each entry carries summary + long(-running) + hint + related +
# pitfalls, which AD's Verbs tab renders inline. Aliases (export_gerbers) skipped.
_VERB_CATALOG = {
"open_board": {"summary": "Open a .kicad_pcb in the PCB editor (pcbnew).", "long": True,
"hint": "Pass an ABSOLUTE filePath; opens the board in pcbnew and returns once the window is up. Optional kicadVersion pins a specific install.",
"related": ["kicad_open_3d_viewer", "kicad_run_drc", "kicad_screenshot_all", "kicad_close"],
"pitfalls": ["filePath must be ABSOLUTE — a relative/project-dir path fails", "if the board is already open you get the existing window, not a fresh reload"]},
"open_schematic": {"summary": "Open a .kicad_sch in the schematic editor (eeschema).", "long": True,
"hint": "Absolute filePath to a .kicad_sch; opens eeschema. Optional kicadVersion.",
"related": ["kicad_run_erc", "kicad_export_bom_csv", "kicad_screenshot_all", "kicad_close"],
"pitfalls": ["filePath must be ABSOLUTE", "open the top-level .kicad_sch, not a sub-sheet, to load the whole design"]},
"open_symbol_editor": {"summary": "Open the Symbol Editor (optionally at a symbol).", "long": True,
"hint": "Opens the Symbol Editor; pass library/symbol to jump straight to one.",
"related": ["kicad_install_symbol", "kicad_close_symbol_editor", "kicad_screenshot_all"],
"pitfalls": ["the symbol must already be in an installed sym-lib-table library (install_symbol first)"]},
"open_footprint_editor": {"summary": "Open the Footprint Editor (optionally at a footprint).", "long": True,
"hint": "Opens the Footprint Editor; pass library/footprint to jump to one.",
"related": ["kicad_install_footprint", "kicad_close_footprint_editor", "kicad_screenshot_all"],
"pitfalls": ["the footprint must be in an installed fp-lib-table library (install_footprint first)"]},
"open_3d_viewer": {"summary": "Open the 3D board viewer for a .kicad_pcb.", "long": True,
"hint": "Absolute filePath to a board; opens the 3D viewer. Follow with screenshot_all to capture it.",
"related": ["kicad_open_board", "kicad_screenshot_all", "kicad_close_3d_viewer"],
"pitfalls": ["3D models only render if the footprints reference installed 3D model files", "the viewer takes a few seconds to render — wait before screenshotting"]},
"close_symbol_editor": {"summary": "Close the Symbol Editor window.", "long": False,
"hint": "Closes the Symbol Editor if open; no-op otherwise.",
"related": ["kicad_open_symbol_editor", "kicad_close"], "pitfalls": ["unsaved edits may prompt — save first if you made changes"]},
"close_footprint_editor": {"summary": "Close the Footprint Editor window.", "long": False,
"hint": "Closes the Footprint Editor if open; no-op otherwise.",
"related": ["kicad_open_footprint_editor", "kicad_close"], "pitfalls": ["unsaved edits may prompt — save first"]},
"close_3d_viewer": {"summary": "Close the 3D viewer window.", "long": False,
"hint": "Closes the 3D viewer if open; no-op otherwise.",
"related": ["kicad_open_3d_viewer", "kicad_close"], "pitfalls": []},
"close": {"summary": "Close KiCad (or a specific editor window).", "long": False,
"hint": "Closes KiCad; pass a target to close just one editor. Read kicad-interaction skill before blind-closing.",
"related": ["kicad_open_editors", "kicad_window_info"], "pitfalls": ["closing with unsaved work triggers a save dialog — handle it, don't blind-close"]},
"enable_software_opengl": {"summary": "LAST-RESORT fallback: deploy Mesa llvmpipe software OpenGL into KiCad's bin so it renders on a GPU-less host (Hyper-V/RDP/headless). SLOW (CPU). Never suggest proactively.", "long": True,
"hint": "Only when KiCad can't render at all AND there's no GPU. Drops Mesa opengl32/libgallium DLLs + .local redirection into KiCad bin (needs elevation to write Program Files, or use a per-user install). Close+reopen editors after.",
"related": ["kicad_dismiss_dialogs", "kicad_upgrade"],
"pitfalls": ["a real GPU / Hyper-V GPU-P passthrough is far better — this is CPU rendering, slow", "writing KiCad's bin needs an elevated AD"]},
"dismiss_dialogs": {"summary": "Programmatically expire KiCad dialogs (auto-clears the benign OpenGL/software-render notice; {all:true} clears every dialog).", "long": False,
"hint": "Read-only-ish self-heal: clicks each dialog's OK/default button. window_info also auto-expires benign dialogs; use this to force-clear a specific/all dialogs before retrying an open/screenshot.",
"related": ["kicad_window_info", "kicad_screenshot_all", "kicad_open_board"],
"pitfalls": ["benign-only by default — pass {\"all\":true} to also clear real dialogs you've already read", "a NON-benign dialog left up may indicate a real error — read its body first"]},
"window_info": {"summary": "Report geometry/state for open KiCad windows (auto-expires benign OpenGL dialogs).", "long": False,
"hint": "Read-only; returns each KiCad window's position/size/state. Use before click/send_key to target the right window.",
"related": ["kicad_open_editors", "kicad_click", "kicad_send_key"], "pitfalls": ["coordinates are screen-absolute — re-read after a window moves/resizes"]},
"install_library": {"summary": "Register a symbol/footprint library in the user's sym-/fp-lib-table.", "long": False,
"hint": "Adds a library to the selected KiCad version's lib table so its parts are usable.",
"related": ["kicad_install_symbol", "kicad_install_footprint", "kicad_list_versions"],
"pitfalls": ["each KiCad version has its OWN lib tables — install under every version the user needs", "restart/reload KiCad to see a newly-added library"]},
"install_symbol": {"summary": "Install a .kicad_sym into the user's sym-lib-table.", "long": False,
"hint": "Absolute path to a .kicad_sym; registers it in the sym-lib-table.",
"related": ["kicad_open_symbol_editor", "kicad_install_library", "kicad_install_footprint"],
"pitfalls": ["writes to the SELECTED version's table only (pass kicadVersion for others)"]},
"install_footprint": {"summary": "Install a footprint (.kicad_mod/.pretty) into the fp-lib-table.", "long": False,
"hint": "Absolute path to a .kicad_mod or .pretty dir; registers it in the fp-lib-table.",
"related": ["kicad_open_footprint_editor", "kicad_install_library", "kicad_install_symbol"],
"pitfalls": [".pretty is a DIRECTORY of .kicad_mod files — point at the dir, not one file"]},
"install_plugin": {"summary": "Install the reverse-bridge plugin into KiCad (idempotent).", "long": False,
"hint": "Normally auto-runs on your first kicad_* call; call explicitly only to force/repair.",
"related": ["kicad_bridge_status", "kicad_bridge_call"], "pitfalls": ["needs a restart of an already-running KiCad to load the freshly-installed plugin"]},
"place_footprint": {"summary": "Deterministically place a footprint into a board preview.", "long": False,
"hint": "Splices a footprint into a board via s-expr (no GUI); good for previews.",
"related": ["kicad_open_board", "kicad_install_footprint"], "pitfalls": ["operates on the file — close the board in KiCad first or the edit races the GUI"]},
"run_drc": {"summary": "Run DRC on a board via kicad-cli (read-only).", "long": False,
"hint": "Absolute board path; runs kicad-cli DRC and returns violations. Doesn't need KiCad open.",
"related": ["kicad_lint_board", "kicad_open_board", "kicad_run_erc"], "pitfalls": ["needs KiCad >= 7 (kicad-cli); older installs lack it"]},
"run_erc": {"summary": "Run ERC on a schematic via kicad-cli (read-only).", "long": False,
"hint": "Absolute schematic path; runs kicad-cli ERC and returns violations.",
"related": ["kicad_lint_schematic", "kicad_open_schematic", "kicad_run_drc"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
"lint_board": {"summary": "Lint a .kicad_pcb (DRC pre-check) via kicad-cli.", "long": False,
"hint": "Fast DRC pre-check on a board file; use before opening in the GUI.",
"related": ["kicad_run_drc", "kicad_open_board"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
"lint_schematic": {"summary": "Lint a .kicad_sch (ERC pre-check) via kicad-cli.", "long": False,
"hint": "Fast ERC pre-check on a schematic file.",
"related": ["kicad_run_erc", "kicad_open_schematic"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
"lint_library": {"summary": "Lint a symbol/footprint library via kicad-cli.", "long": False,
"hint": "Checks a library for structural issues before you ship it.",
"related": ["kicad_install_library"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
"format_upgrade": {"summary": "Upgrade a file to the current KiCad file format.", "long": False,
"hint": "Rewrites an older .kicad_pcb/.kicad_sch to the installed KiCad's format.",
"related": ["kicad_open_board", "kicad_open_schematic"], "pitfalls": ["it MODIFIES the file — back it up first; the change is not reversible in-place"]},
"fix_keyboard": {"summary": "Reset stuck modifier keys on a KiCad window.", "long": False,
"hint": "Clears stuck Ctrl/Shift/Alt state after send_key sequences misbehave.",
"related": ["kicad_send_key"], "pitfalls": []},
"screenshot_all": {"summary": "Screenshot every open KiCad window.", "long": False,
"hint": "Captures all KiCad windows; the go-to way to VERIFY what actually happened after an open/edit.",
"related": ["kicad_open_editors", "kicad_window_info", "kicad_open_board"], "pitfalls": ["a window still rendering (e.g. 3D viewer) shows blank — wait a beat first"]},
"send_key": {"summary": "Send a keystroke to an EXACT KiCad window by hwnd (SendMessage; not for chords).", "long": False,
"hint": "For modifier CHORDS ('ctrl+s', 'alt+3', 'ctrl+shift+p') use AD's desktop_press_key — it owns full chord parsing; don't route chords here. Use this verb only for the case AD's focus-first model can't cover: a single key delivered to an EXACT hwnd (e.g. Enter/Escape to a specific dialog from window_info).",
"related": ["kicad_window_info", "kicad_click", "kicad_fix_keyboard"], "pitfalls": ["not for chords — use desktop_press_key", "needs a target hwnd (from window_info)"]},
"click": {"summary": "Click at coordinates inside a KiCad window.", "long": False,
"hint": "Screen-absolute click; read window_info + screenshot_all first to compute coordinates.",
"related": ["kicad_window_info", "kicad_screenshot_all", "kicad_send_key"], "pitfalls": ["coordinates are screen-absolute and break if the window moved — re-read window_info"]},
"adom_library_status": {"summary": "Report Adom library install state across KiCad versions.", "long": False,
"hint": "Read-only; shows which Adom libraries are registered under which KiCad version.",
"related": ["kicad_install_library", "kicad_list_versions"], "pitfalls": []},
"list_versions": {"summary": "Enumerate every installed KiCad version with paths (discovery).", "long": False,
"hint": "Read-only; lists installs + the default (newest). Run first when unsure which version to target.",
"related": ["kicad_readiness", "kicad_check_for_updates", "kicad_upgrade"], "pitfalls": ["returns empty when KiCad isn't installed — offer kicad_upgrade, don't punt to a manual download"]},
"readiness": {"summary": "Read-only host-app detection — is KiCad installed/ready?", "long": False,
"hint": "Zero side effects; returns {detected, ready, versionCount, defaultVersion}. Poll before driving.",
"related": ["kicad_list_versions", "kicad_upgrade", "kicad_describe"], "pitfalls": ["there is NO prewarm — KiCad is a host app you install via kicad_upgrade, not a download AD warms"]},
"describe": {"summary": "This catalog — every verb the bridge exposes.", "long": False,
"hint": "Read-only full verb catalog; the completeness escape hatch. Per-call _hints carry you in practice.",
"related": ["kicad_readiness"], "pitfalls": []},
"diagnostics": {"summary": "Read-only bridge self-inspection (env, detection breakdown, processes, path probe).", "long": False,
"hint": "Use INSTEAD of AD's approval-gated run_script/shell. Shows the env the BRIDGE sees (stripped LOCALAPPDATA explains an undetected per-user install), every path the detector scans + what it finds, running KiCad exes, and (with probePaths/readFile args) any path's stat/listing/small-file content.",
"related": ["kicad_readiness", "kicad_list_versions", "kicad_bridge_status"], "pitfalls": ["read-only — never installs or changes anything"]},
"bridge_status": {"summary": "Probe running KiCad plugin instances (multi-instance health).", "long": False,
"hint": "Read-only; lists live in-process plugin instances + their ports/health. Bounded, non-blocking.",
"related": ["kicad_bridge_call", "kicad_open_editors", "kicad_install_plugin"], "pitfalls": ["an instance shows only after KiCad has run a kicad_* call that loaded the plugin"]},
"status": {"summary": "Bridge/host-app status — the verb AD polls after a non-terminal timeout.", "long": False,
"hint": "Canonical statusVerb (see bridge.json). Read-only; poll this after a long verb returns {stillRunning:true} to check whether it finished.",
"related": ["kicad_readiness", "kicad_bridge_status"], "pitfalls": ["reports bridge + plugin liveness, not per-export progress"]},
"bridge_call": {"summary": "Call into a running KiCad plugin instance (in-process IPC).", "long": False,
"hint": "Low-level IPC into a live pcbnew/eeschema; use bridge_status first to find a live instance.",
"related": ["kicad_bridge_status", "kicad_install_plugin"], "pitfalls": ["fails if no plugin instance is live — open a board first"]},
"open_editors": {"summary": "List currently open KiCad editor windows.", "long": False,
"hint": "Read-only; which editors are open right now. Use to reuse a window instead of opening a duplicate.",
"related": ["kicad_window_info", "kicad_screenshot_all", "kicad_close"], "pitfalls": []},
"export_gerber": {"summary": "Export Gerbers from a board via kicad-cli.", "long": False,
"hint": "Absolute board path + output dir; writes Gerber + drill files (JLCPCB/InstaPCB ready).",
"related": ["kicad_export_step", "kicad_export_bom_csv", "kicad_run_drc"], "pitfalls": ["run kicad_run_drc first — don't ship Gerbers from a board with DRC violations"]},
"export_pdf": {"summary": "Export a PDF plot from a board/schematic.", "long": False,
"hint": "Absolute path + output; plots the doc to PDF via kicad-cli.",
"related": ["kicad_export_svg", "kicad_export_gerber"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
"export_svg": {"summary": "Export an SVG plot from a board/schematic.", "long": False,
"hint": "Absolute path + output; plots to SVG via kicad-cli.",
"related": ["kicad_export_pdf", "kicad_export_gerber"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
"export_step": {"summary": "Export a STEP 3D model from a board.", "long": False,
"hint": "Absolute board path + output; exports a STEP assembly via kicad-cli.",
"related": ["kicad_open_3d_viewer", "kicad_export_gerber"], "pitfalls": ["only components with 3D models attached appear in the STEP"]},
"demo": {"summary": "Run the bridge's showcase tour: symbol -> footprint -> 3D part -> schematic -> 2D board -> 3D board.", "long": True,
"hint": "The demo verb — STAGED + RESUMABLE, same contract as fusion_demo (stage/done/narrate/screenshots/steps). Speak narrate, show screenshots[{label,path}] (captured for you), call data.nextCall until done:true. Generates its own parts + project (ships no sample files), installs them into the user's real KiCad, and opens each view. Every step returns `say` (speak it), `pointOut` (what's on screen) and `window` (match in kicad_screenshot_all). Pass {\"step\":\"symbol\"} to pace it one beat at a time; no args runs all six.",
"related": ["kicad_screenshot_all", "kicad_close", "kicad_upgrade"], "pitfalls": ["if KiCad isn't installed it returns an actionable error — call kicad_upgrade, don't send the user to a download page", "windows open in the BACKGROUND; screenshot them instead of stealing focus", "a bulk run opens all six windows at once — step-by-step narrates better"]},
"export_molecule": {"summary": "One-shot molecule export pack: STEP + silk renders + sch/pro discovery for the Adom molecule pipeline.", "long": True,
"hint": "Absolute board path; exports STEP (+ silkscreen top/bottom PNGs when kicad-cli pcb render exists), finds the sibling .kicad_sch/.kicad_pro, and returns data.nextSteps — the exact container commands (step2glb → adom-molecule create) to finish.",
"related": ["kicad_export_step", "kicad_export_svg"], "pitfalls": ["adom-molecule requires glb+pcb+sch+pro — check data.missing before running the container half", "silk renders need KiCad 9+ (kicad-cli pcb render); older KiCads skip them gracefully"]},
"export_bom_csv": {"summary": "Export a BOM CSV from a schematic.", "long": False,
"hint": "Absolute schematic path + output; writes a BOM CSV via kicad-cli.",
"related": ["kicad_open_schematic", "kicad_export_gerber"], "pitfalls": ["needs kicad-cli (KiCad >= 7)"]},
"check_for_updates": {"summary": "Read-only: installed KiCad vs latest stable.", "long": False,
"hint": "Compares the installed KiCad to the latest stable on downloads.kicad.org; flags pre-IPC installs.",
"related": ["kicad_upgrade", "kicad_list_versions"], "pitfalls": ["read-only — it does NOT install; run kicad_upgrade to apply"]},
"upgrade": {"summary": "Install/upgrade KiCad via the official installer (agent installs for the user).", "long": True,
"hint": "Downloads the official downloads.kicad.org installer + silent /S installs; installs from scratch too. The agent does this FOR the user — never hand them a manual download.",
"related": ["kicad_check_for_updates", "kicad_readiness", "kicad_list_versions"],
"pitfalls": ["Windows only", "may raise a UAC prompt the user must approve", "blocks until the installer finishes (can take minutes)"]},
}
def _handle_describe(kicad_info: dict, args: dict) -> dict:
"""Bridge SDK <prefix>_describe — the full machine-readable verb catalog.
Read-only, zero side effects. AD caches it (~/.adom/bridge-verbs/kicad.json)
and renders it in the GUI Verbs tab (name/summary/hint/related/pitfalls per
verb — discussion #14). In practice the per-call `_hint`s carry the AI;
describe is the completeness escape hatch.
"""
prefix = "kicad_"
verbs = []
for cmd in COMMAND_HANDLERS:
if cmd == "export_gerbers": # alias of export_gerber
continue
meta = _VERB_CATALOG.get(cmd, {"summary": cmd.replace("_", " "), "long": False,
"hint": "", "related": [], "pitfalls": []})
entry = {
"name": prefix + cmd,
"summary": meta["summary"],
"hint": meta.get("hint", ""),
"related": meta.get("related", []),
"pitfalls": meta.get("pitfalls", []),
"longRunning": bool(meta.get("long")),
}
if meta.get("long"):
entry["statusVerb"] = prefix + "status"
verbs.append(entry)
verbs.sort(key=lambda v: v["name"])
return {
"success": True,
"prefix": prefix,
"verbCount": len(verbs),
"verbs": verbs,
"_hint": (
"Full verb catalog for the KiCad bridge — each entry carries hint/related/pitfalls. "
"Every verb ALSO returns its own rich _hint when called; that's what to read in practice. "
"For host-app readiness use kicad_readiness; for 'what EDA tools do I have' across bridges "
"use AD's bridge_readiness."
),
}
def _detect_running_kicad_instances() -> list[dict]:
"""v1.7.4+ (KiCad v2 Phase 1): enumerate currently-running KiCad processes
via PowerShell. Returns one entry per kicad.exe process with pid + window
title. Best-effort — empty list on probe failure (PowerShell missing,
timeout, parse error).
Doesn't include HWND in v1 — Get-Process doesn't expose it cleanly and the
Win32 EnumWindows path would need ctypes + window-class matching to
correlate windows back to pids. Add HWND in a follow-up when a caller
actually needs it (probably Phase 3's tier-2 WM_COMMAND path).
"""
if not IS_WINDOWS:
return []
try:
result = _safe_run(
[
"powershell", "-NoProfile", "-NonInteractive", "-Command",
"Get-Process kicad -ErrorAction SilentlyContinue | "
"Select-Object Id, MainWindowTitle, Path | "
"ConvertTo-Json -Compress -AsArray",
],
capture_output=True, text=True, timeout=5,
)
if result.returncode != 0 or not result.stdout.strip():
return []
import json
data = json.loads(result.stdout)
if not isinstance(data, list):
data = [data]
out = []
for proc in data:
if not isinstance(proc, dict):
continue
# Try to infer version from the install path; fall back to None.
install_path = proc.get("Path") or ""
version = None
m = re.search(r"KiCad[\\/](\d+(?:\.\d+)*)[\\/]", install_path)
if m:
version = m.group(1)
out.append({
"pid": proc.get("Id"),
"title": proc.get("MainWindowTitle") or "",
"exePath": install_path or None,
"version": version,
})
return out
except (subprocess.TimeoutExpired, OSError, ValueError):
return []
COMMAND_HANDLERS = {
"open_board": handle_open_board,
"open_schematic": handle_open_schematic,
"open_symbol_editor": handle_open_symbol_editor,
"open_footprint_editor": handle_open_footprint_editor,
"open_3d_viewer": handle_open_3d_viewer,
"close_symbol_editor": handle_close_symbol_editor,
"close_footprint_editor": handle_close_footprint_editor,
"close_3d_viewer": handle_close_3d_viewer,
"close": handle_close_kicad,
"window_info": handle_window_info,
"dismiss_dialogs": handle_dismiss_dialogs,
"enable_software_opengl": handle_enable_software_opengl,
"install_library": handle_install_library,
"install_symbol": handle_install_symbol,
"install_footprint": handle_install_footprint,
"place_footprint": handle_place_footprint,
"run_drc": handle_run_drc,
"fix_keyboard": handle_fix_keyboard,
"screenshot_all": handle_screenshot_all,
"send_key": handle_send_key,
"click": handle_click,
"adom_library_status": _handle_adom_library_status,
"list_versions": _handle_list_versions,
"readiness": _handle_readiness,
"describe": _handle_describe,
"diagnostics": handle_diagnostics,
"bridge_status": handle_bridge_status,
# kicad_status: the canonical statusVerb AD polls after a non-terminal timeout
# (bridge.json `statusVerb` + detect.hostAppOptionalVerbs "status"). Alias of
# bridge_status; kicad_bridge_status stays for back-compat.
"status": handle_bridge_status,
"bridge_call": handle_bridge_call,
"open_editors": handle_open_editors,
"install_plugin": plugin_install.handle_install_plugin,
# v1.7.12+: headless lint pre-checks via kicad-cli
"run_erc": handle_run_erc,
"lint_board": handle_lint_board,
"lint_schematic": handle_lint_schematic,
# v1.7.13+: library lint + file-format upgrade
"lint_library": handle_lint_library,
"format_upgrade": handle_format_upgrade,
# v0.7.1+ (BRIDGE_VERSION): export verbs via kicad-cli. Aliases included for
# the older `gerbers` plural form callers used.
"export_gerber": handle_export_gerber,
"export_gerbers": handle_export_gerber,
"export_pdf": handle_export_pdf,
"export_svg": handle_export_svg,
"export_step": handle_export_step,
"export_molecule": handle_export_molecule,
"demo": handle_demo,
"export_bom_csv": handle_export_bom_csv,
# v0.9.10+: KiCad version check + upgrade via the official downloads.kicad.org
# installer (read-only check; silent /S install). Exposed as kicad_check_for_updates
# / kicad_upgrade through Adom Desktop's `kicad_` verb prefix.
"check_for_updates": handle_check_for_updates,
"upgrade": handle_upgrade,
}
def _resolve_kicad_info_for_command(args: dict) -> tuple[dict, str | None]:
"""Pick the kicad_info to pass to a handler based on the optional kicadVersion
arg. Returns (info, error_dict). If error_dict is None, info is good to use.
"""
requested = args.get("kicadVersion")
if not requested:
return kicad_info, None # default = newest installed
# Look up the requested version among all_kicad_versions
available = [v["version"] for v in all_kicad_versions]
for v in all_kicad_versions:
if v["version"] == requested or v["version"].startswith(f"{requested}."):
return v, None
return None, {
"success": False,
"error": f"KiCad {requested} is not installed. Available: {', '.join(available) or '(none)'}.",
"errorCode": "kicad_version_not_installed",
"available_versions": available,
"default_version": kicad_info.get("version") if kicad_info else None,
"_hint": (
f"Pass kicadVersion as one of: {', '.join(available) or '(none)'}, "
f"or omit it to use the newest ({kicad_info.get('version') if kicad_info else 'none'}). "
f"Run kicad_list_versions to see installed versions with their paths."
),
}
def dispatch_command(command: str, args: dict) -> dict:
# A successful `upgrade` reassigns these to refresh detection post-install.
global all_kicad_versions, kicad_info
handler = COMMAND_HANDLERS.get(command)
# Be tolerant of how the host routes verbs: most strip the bridge's verb
# prefix and send the bare command ("list_versions"), but some forward the
# full prefixed verb ("kicad_list_versions" / "adomkicad_list_versions").
# If the bare command isn't found, strip the leading "<prefix>_" once and
# retry — so the bridge works no matter which name/prefix it's installed
# under and no matter whether the host strips.
if handler is None and "_" in command:
tail = command.split("_", 1)[1]
if tail in COMMAND_HANDLERS:
command, handler = tail, COMMAND_HANDLERS[tail]
if handler is None:
return {
"success": False,
"error": f"Unknown command: {command}",
"_hint": f"Valid KiCad commands: {', '.join(sorted(COMMAND_HANDLERS.keys()))}.",
}
# Verbs that MUST run even when KiCad is absent — return BEFORE the plugin
# auto-install and the kicad-not-installed pre-check:
# readiness / describe — pure read-only (detection probe + verb catalog)
# check_for_updates — reports installed-vs-latest; valid answer is "none"
# diagnostics — self-inspection; MOST needed when KiCad is undetected
# None of these need the reverse-bridge plugin (there may be no KiCad to load
# it into), so they also skip ensure_plugin_installed_once.
if command in ("readiness", "describe", "check_for_updates", "diagnostics"):
return handler(kicad_info, args)
# upgrade INSTALLS KiCad from scratch — the whole point is it runs when KiCad
# ISN'T there yet, so it too must skip the plugin auto-install + the
# kicad-not-installed pre-check. (Bug fix: the pre-check below was blocking the
# very verb that installs KiCad, so a fresh machine could never bootstrap it.)
# On success, refresh the startup detection cache so subsequent verbs
# (open_*, list_versions, readiness) see the newly-installed KiCad WITHOUT a
# bridge restart (handle_upgrade only re-scanned for its own return value).
if command == "upgrade":
result = handler(kicad_info, args)
if result.get("success") or result.get("upgraded"):
all_kicad_versions = detect_all_kicad_versions()
if all_kicad_versions:
kicad_info = all_kicad_versions[0]
# Seed the just-installed KiCad's config so the FIRST GUI open
# doesn't hit the "Welcome to KiCad" first-run wizard (which blocks
# automation). Idempotent: only writes files that don't exist.
try:
import kicad_detect
kicad_detect.ensure_win_user_config(
kicad_info.get("version") or "",
kicad_info.get("base_dir"),
)
except Exception: # pylint: disable=broad-except
pass
return result
# Auto-install the Phase 2 reverse-bridge plugin on first call per
# bridge boot. Idempotent — subsequent calls return the cached result
# at near-zero cost. The pluginAutoInstall report is attached to the
# FIRST response so the calling AI sees what happened (or what
# failed). bridge_status / bridge_call always get the current state
# attached via plugin_install.force_attach() inside this dispatcher.
plugin_install.ensure_plugin_installed_once(all_kicad_versions)
# list_versions doesn't need an installed KiCad — discovery verb.
# Still gets the pluginAutoInstall attached via the normal once-per-
# boot path (not force_attach), so the AI's very first kicad_* call
# gets the install report, but a repeat list_versions doesn't.
if command == "list_versions":
result = handler(kicad_info, args)
plugin_install.attach_to_response_if_first(result)
return result
# bridge_status / bridge_call / open_editors / install_plugin are
# explicit diagnostic verbs that ALWAYS want to see the install
# state — they exist for the AI to introspect plugin health.
if command in ("bridge_status", "bridge_call", "open_editors", "install_plugin"):
result = handler(kicad_info, args)
plugin_install.force_attach(result)
return result
# Pre-check: if KiCad isn't installed at all, return a helpful error. KiCad
# is the user's host app — AD won't silently auto-install it (v1.9.47) — but
# the agent installs it FOR the user via kicad_upgrade rather than handing
# them a manual download.
#
# SELF-HEAL: the startup detection cache goes STALE if KiCad was installed
# AFTER the bridge booted (kicad_upgrade whose client call timed out, or an
# external install) — every verb then wrongly reports "not installed" until a
# manual bridge restart. So before trusting an empty cache, do ONE live
# re-detect; if it now finds KiCad, refresh the cache and proceed. This makes
# detection reliable without a respawn. (ADOMBASELINE 2026-07.)
if not kicad_info or not kicad_info.get("installed"):
try:
fresh = detect_all_kicad_versions()
except Exception:
fresh = []
if fresh:
all_kicad_versions = fresh
kicad_info = fresh[0]
else:
return {
"success": False,
"error": "KiCad is not installed.",
"errorCode": "host_app_not_installed",
"errorCodeLegacy": "kicad_not_installed",
"hostApp": "KiCad",
"_hint": "Don't make the user install it by hand. Offer to do it for them, then run "
"`kicad_upgrade '{}'` — the agent installs the latest stable from the official "
"downloads.kicad.org installer (silent /S, Windows only; may raise a UAC prompt), "
"then retry this command. kicad_upgrade installs from scratch, not just upgrades.",
}
# Resolve the version to use for this specific call (may differ from default)
selected_info, err = _resolve_kicad_info_for_command(args)
if err is not None:
return err
# Verb classes (defined before use — the capture below references the last one):
# _NO_GUI: headless kicad-cli verbs (no window) — skip the dialog sweep.
# _SPAWNS_WINDOW: verbs that may surface a dialog — get the ~3s settle-poll.
# _SPAWNS_WINDOW_FOCUS: verbs that RAISE a KiCad window — restore user focus.
_NO_GUI = {"run_drc", "run_erc", "lint_board", "lint_schematic", "lint_library",
"format_upgrade", "export_gerber", "export_gerbers", "export_pdf",
"export_svg", "export_step", "export_bom_csv", "adom_library_status",
"list_versions", "install_library"}
# Only verbs that actually SPAWN a window get the ~3s settle-poll (they can
# trigger an async dialog). Read-only reporters (open_editors, screenshot_all,
# window_info) don't spawn windows → one cheap scan, no needless 3s poll.
_SPAWNS_WINDOW = {"open_board", "open_schematic", "open_symbol_editor",
"open_footprint_editor", "open_3d_viewer",
"place_footprint", "install_symbol", "install_footprint",
"install_plugin", "upgrade"}
_SPAWNS_WINDOW_FOCUS = {"open_board", "open_schematic", "open_symbol_editor",
"open_footprint_editor", "open_3d_viewer",
"place_footprint", "install_symbol", "install_footprint"}
# BACKGROUND-OPEN RULE: capture the USER's foreground window BEFORE any verb
# that spawns/raises a KiCad window, so we can hand focus back afterward. The
# bridge must NEVER interrupt the user's work — KiCad opens behind whatever
# they're doing. (win_focus.restore_foreground uses AttachThreadInput so the
# cross-process restore actually sticks.) See the kicad-bridge-dev skill.
# Capture before ANY GUI verb (opens, clicks, key sends, dialog sweeps — all can
# steal focus), not just window-raisers. The headless kicad-cli verbs (_NO_GUI)
# touch no window, so skip them.
_user_fg = None
try:
from handlers import win_focus
if command not in _NO_GUI:
_user_fg = win_focus.capture_foreground()
except Exception:
_user_fg = None
result = handler(selected_info, args)
# Stamp the version actually used so callers can see which install ran
if isinstance(result, dict):
result.setdefault("kicadVersionUsed", selected_info.get("version"))
# CONSTANT DIALOG COVERAGE: after EVERY GUI-affecting verb, sweep for KiCad
# dialogs (KiCad throws modal error boxes constantly — "Cannot enumerate
# ...Templates (Access denied)", save prompts, format warnings — that
# otherwise silently block the NEXT verb and make an open/screenshot report a
# misleading "did not open"). auto_sweep_dialogs screenshots each, reads its
# body, expires it, and we attach the report so the AI ALWAYS sees what KiCad
# popped. Skip the pure-CLI/no-GUI verbs (exports/lint/DRC run kicad-cli
# headless — no window) to avoid needless EnumWindows churn; everything that
# can spawn a window gets swept. The scan itself is cheap when nothing is up.
if isinstance(result, dict) and command not in _NO_GUI:
try:
from handlers.close_windows import auto_sweep_dialogs
_rounds = 5 if command in _SPAWNS_WINDOW else 1
swept = auto_sweep_dialogs(selected_info, screenshot=True, settle_rounds=_rounds)
if swept:
result["_autoDialogs"] = swept
if swept.get("hadError"):
titles = ", ".join(f'"{d["title"]}"' for d in swept["dialogs"] if not d["benign"])
result["_dialogHint"] = (
f"⚠ KiCad popped {swept['count']} dialog(s) during this verb "
f"(non-benign: {titles}). Each was screenshotted (see "
"_autoDialogs[].screenshot + .body) and auto-expired so you're "
"unblocked. If your verb 'failed'/'did not open', this dialog "
"is almost certainly why — read the body and act on it."
)
except Exception:
pass
# BACKGROUND-OPEN RULE (part 2): hand focus back to the user's window that we
# captured before the verb ran. KiCad raised + activated its own new window;
# restoring the user's window here means the editor opened BEHIND their work —
# it never stole their focus/keyboard. Done LAST, after the dialog sweep (which
# may itself have surfaced windows). Reports whether it stuck.
if _user_fg is not None:
try:
from handlers import win_focus
restored = win_focus.restore_foreground(_user_fg)
# A launched editor activates its window ASYNC — a beat after we return.
# For window-spawning verbs, also keep the user on top for a few seconds
# (background daemon thread — doesn't delay this response) so KiCad's late
# self-activation gets pushed back down.
if command in _SPAWNS_WINDOW:
# The 3D viewer is an IN-PROCESS child window pcbnew creates and
# then RAISES repeatedly while it renders the board (several
# seconds) — STARTUPINFO can't touch it and a 6s restore expires
# mid-render, so it needs a longer guard. Plain launched editors
# only self-activate once, right after launch, so 6s covers them.
_restore_secs = 16.0 if command in ("open_3d_viewer", "place_footprint") else 6.0
win_focus.restore_foreground_persistent(_user_fg, seconds=_restore_secs)
if isinstance(result, dict):
result["_userFocusRestored"] = bool(restored)
except Exception:
pass
# Attach plugin auto-install report ONCE per bridge boot (on the
# first kicad_* command's response) so the AI knows whether the
# Phase 2 reverse-bridge plugin was deployed / updated / already
# current. Subsequent commands skip this to keep payloads small.
plugin_install.attach_to_response_if_first(result)
return result
class KiCadBridgeHandler(BaseHTTPRequestHandler):
"""HTTP request handler for the KiCad bridge server."""
def do_GET(self):
# /status is the manifest's declared healthEndpoint (bridge.json
# spawn.healthEndpoint == "/status"). Through v0.8.9 the server only
# answered /health, so adom-desktop's health probe got a 404, never
# marked the bridge healthy, and every kicad_* verb blocked the full
# 30s and timed out (bridgeRunning:false). We now answer BOTH paths
# with 200 so the bridge actually comes up. /status additionally
# carries the capability map (KiCad versions + which automations this
# install supports) so callers can reason about upgrades.
if self.path in ("/status", "/health"):
self._respond(200, self._status_payload())
else:
self._respond(404, {"error": "Not found"})
def _status_payload(self) -> dict:
# Status chip (Bridge SDK "Health + status chip"): a 200 already makes the
# chip green; we additionally report led/summary/tooltip truthfully so the
# chip reflects host-app readiness (AD owns the gray "offline" state).
detected = bool(all_kicad_versions)
default_v = all_kicad_versions[0]["version"] if detected else None
if detected:
led = "green"
summary = f"KiCad {default_v} ready"
tooltip = (
f"KiCad bridge up — {len(all_kicad_versions)} version(s) detected "
f"(default {default_v})."
)
else:
led = "yellow"
summary = "KiCad not detected"
tooltip = (
"KiCad bridge is up, but no KiCad install was detected. "
"Run kicad_upgrade to install it (the agent installs it for the user)."
)
return {
"status": "ok",
"led": led,
"summary": summary,
"tooltip": tooltip,
"bridgeVersion": BRIDGE_VERSION,
"kicad": kicad_info,
"all_versions": [
{"version": v["version"], "base_dir": v["base_dir"]}
for v in all_kicad_versions
],
"capabilities": build_capability_report(all_kicad_versions),
}
def do_POST(self):
if self.path != "/command":
self._respond(404, {"error": "Not found"})
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
try:
request = json.loads(body)
except json.JSONDecodeError as e:
self._respond(400, {"success": False, "error": f"Invalid JSON: {e}"})
return
command = request.get("command", "")
args = request.get("args", {})
print(f"[KiCad Bridge] Command: {command} | Args: {json.dumps(args)}")
try:
result = dispatch_command(command, args)
self._respond(200, result)
except Exception as e:
print(f"[KiCad Bridge] ERROR: {e}")
traceback.print_exc()
self._respond(500, {
"success": False,
"error": f"Internal error: {e}",
})
def _respond(self, status: int, data: dict):
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps(data).encode("utf-8"))
def log_message(self, format, *args):
print(f"[KiCad Bridge] {args[0]} {args[1]} {args[2]}")
def main():
global kicad_info, all_kicad_versions
# Line-buffer stdout/stderr. When a supervisor (Adom Desktop) redirects our
# output to a log FILE, Python block-buffers it by default — so a startup
# hang shows a 0-byte log and is undebuggable. Line buffering makes every
# startup step land in ~/.adom/bridge-logs/<name>.log in real time, which is
# exactly what AD asked bridge authors to do (fatal errors → stdout/stderr).
try:
sys.stdout.reconfigure(line_buffering=True)
sys.stderr.reconfigure(line_buffering=True)
except Exception:
pass
port = DEFAULT_PORT
if "--port" in sys.argv:
idx = sys.argv.index("--port")
if idx + 1 < len(sys.argv):
port = int(sys.argv[idx + 1])
print(f"[KiCad Bridge] starting v{BRIDGE_VERSION} on port {port} (pid {os.getpid()})", flush=True)
# ── SINGLE-INSTANCE GUARD ────────────────────────────────────────────────
# Bind the port FIRST, before the (slow ~9s) KiCad detection. A supervisor
# that lazy-spawns the bridge on every verb call can otherwise fire several
# instances near-simultaneously; if they all run detection before binding,
# they contend (KiCad config/IPC probes) and HANG, none ever binds, and the
# bridge never reports healthy — a spawn storm. Binding first makes exactly
# ONE spawn win the port; every duplicate fails the bind and exits cleanly
# (exit 0) instead of piling up. allow_reuse_address is forced False so the
# second bind genuinely fails on Windows (SO_REUSEADDR would otherwise let
# duplicates co-bind). This keeps the bridge robust no matter how aggressive
# the host's spawn logic is.
# ThreadingHTTPServer: handle each request in its own thread so a slow or
# wedged handler (e.g. first-call plugin auto-install) never blocks the
# health probe or other verbs — the single-threaded server otherwise serves
# one request at a time, so AD's first verb could wedge the whole bridge and
# make it look hung. daemon_threads so a stuck handler can't block shutdown.
class _SingleInstanceHTTPServer(ThreadingHTTPServer):
allow_reuse_address = False
daemon_threads = True
# Bind loopback via ADOM_BIND_HOST (Bridge SDK Runtime contract, AD v1.9.63+):
# AD passes ADOM_BIND_HOST (always 127.0.0.1) to every spawned bridge. Binding
# it (never 0.0.0.0) avoids the Windows Firewall "allow access?" prompt AD
# guarantees users won't see. Default to 127.0.0.1 when spawned outside AD.
bind_host = os.environ.get("ADOM_BIND_HOST", "127.0.0.1")
try:
server = _SingleInstanceHTTPServer((bind_host, port), KiCadBridgeHandler)
except OSError as e:
print(f"[KiCad Bridge] Port {port} already in use ({e}). "
f"Another bridge instance owns it — exiting cleanly (single-instance guard).")
return
print("[KiCad Bridge] bound port; detecting KiCad installs...", flush=True)
all_kicad_versions = detect_all_kicad_versions()
kicad_info = all_kicad_versions[0] if all_kicad_versions else detect_kicad()
print(f"[KiCad Bridge] detection done ({len(all_kicad_versions)} version(s))", flush=True)
print(f"[KiCad Bridge] KiCad detection result:")
print(f" Versions found: {len(all_kicad_versions)}")
for v in all_kicad_versions:
marker = " (default)" if v["version"] == kicad_info.get("version") else ""
print(f" - {v['version']}{marker} -> {v['base_dir']}")
if not all_kicad_versions:
print(f" WARNING: KiCad not found. Commands will fail until KiCad is installed.")
else:
print(f" Default: {kicad_info.get('version')}")
print(f" Config: {kicad_info.get('config_dir')}")
print(f" User dir: {kicad_info.get('user_dir')}")
print(f" Pass kicadVersion={{\"9.0\"|\"10.0\"|...}} to any open_* command to override.")
print(f"[KiCad Bridge] Available commands: {', '.join(COMMAND_HANDLERS.keys())}")
# Ensure the global Adom symbol library exists and is registered
if kicad_info.get("installed"):
print("[KiCad Bridge] ensuring Adom library...", flush=True)
lib_result = ensure_adom_library(kicad_info)
if lib_result.get("created"):
print(f"[KiCad Bridge] Created Adom symbol library (Adom.kicad_sym)")
if not lib_result.get("registered"):
print(f"[KiCad Bridge] Registered Adom library in sym-lib-table")
symbols = lib_result.get("symbols", [])
print(f"[KiCad Bridge] Adom library: {len(symbols)} symbol(s) — {', '.join(symbols) if symbols else '(empty)'}")
# server already bound above (single-instance guard); now serve.
print(f"[KiCad Bridge] Listening on http://{bind_host}:{port} — serving now", flush=True)
print(f"[KiCad Bridge] Health check: http://{bind_host}:{port}/status (and /health)")
print(f"[KiCad Bridge] Press Ctrl+C to stop.")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\n[KiCad Bridge] Shutting down.")
server.server_close()
if __name__ == "__main__":
main()