Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
670d2a9c4This file is pinned to the selected release. Download the original to open it in your design tool.
"""GUI-driver MCP tools — the second channel to KiCad (chrome, not design).
Thin socket clients for the in-KiCad helper plugin (``gui_driver_plugin/`` in
this repo; installs to ``3rdparty/plugins/<id>/``). The helper listens on
``127.0.0.1:$KICAD_GUI_DRIVER_PORT`` (default 8770), JSON lines: one request
object per line in, one ``{"ok": bool, ...}`` response line back. All wx work
happens on KiCad's UI thread inside the helper; this side is pure sockets.
Surface (locked decisions; design notes in docs/GUI_DRIVER_SPEC.md):
* GENERIC scriptable surface — no curated allow-list. Agents look with
``kicad_gui_tree`` and act with ``kicad_gui_click`` by name.
* Destructive flagging is ADVISORY ONLY: tree entries matching the seed list
get ``destructive: true`` and a ``"⚠ "`` name prefix; ``kicad_gui_click``
executes without any confirm/gate.
* Playbooks (``kicad_pcb_snapshot``, ``kicad_reload_and_open_plugin``,
``kicad_run_drc``) stay thin conveniences over the generic tools.
Backend B (Linux AT-SPI, zero-in-KiCad) rides along as
``kicad_gui_tree_atspi`` / ``kicad_gui_click_atspi``; needs
``gsettings set org.gnome.desktop.interface toolkit-accessibility true`` and
KiCad launched with ``GTK_MODULES=gail:atk-bridge``.
Boundary: this module never imports kipy/pcbnew — kipy stays design-only.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import socket
import sys
import time
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
DEFAULT_PORT = 8770
DESTRUCTIVE_PREFIX = "⚠ "
# -- session token -----------------------------------------------------------
#
# The helper's control channel is token-gated: it mints a per-session secret and
# writes it to a mode-0600 file (see gui_driver_plugin/plugins/listener.py). We
# read that file and echo the token on every request; without it the helper
# answers "unauthorized". This MUST stay in lock-step with listener._token_path.
def _token_path() -> Path:
"""Path of the mode-0600 session-token file the helper wrote (shared contract)."""
override = os.environ.get("KICAD_GUI_DRIVER_TOKEN_FILE")
if override:
return Path(override)
port = int(os.environ.get("KICAD_GUI_DRIVER_PORT", DEFAULT_PORT))
plat = sys.platform
home = Path.home()
if plat.startswith("win"):
appdata = os.environ.get("APPDATA")
base = Path(appdata) / "kicad" if appdata else home / ".kicad"
elif plat == "darwin":
base = home / "Library" / "Preferences" / "kicad"
else:
base = home / ".local" / "share" / "kicad"
return base / f"gui_driver_{port}.token"
def _read_token() -> Optional[str]:
"""Read the session token the helper wrote, or None if it isn't there yet."""
try:
tok = _token_path().read_text(encoding="utf-8").strip()
return tok or None
except OSError:
return None
# -- helper self-install -----------------------------------------------------
#
# The MCP bundles the in-KiCad helper (gui_driver_plugin/plugins/ in this repo)
# and self-deploys it into KiCad's 3rdparty plugin tree on connect failure —
# no separate installer. Bump HELPER_VERSION whenever the bundled helper
# changes; ensure_helper_installed() re-deploys on a marker mismatch, so
# helper updates ship with the MCP with zero user steps.
# 0.0.2: run_plugin triggers async (no UI-thread block/timeout)
# 0.0.3: request lines capped before auth; screenshot paths validated
HELPER_VERSION = "0.0.3"
HELPER_IDENTIFIER = "com_github_rossvonfange_kicad-gui-driver"
HELPER_VERSION_MARKER = "HELPER_VERSION"
def _bundled_helper_dir() -> Path:
"""The helper sources shipped with this repo/wheel (root-flatten source)."""
return Path(__file__).resolve().parents[2] / "gui_driver_plugin" / "plugins"
def kicad_plugin_dirs(
platform: Optional[str] = None,
environ: Optional[Dict[str, str]] = None,
home: Optional[Path] = None,
) -> List[Path]:
"""Per-OS ``<kicad-data>/<ver>/3rdparty/plugins`` dirs, one per KiCad version.
Resolution (args are injectable for tests; defaults are the live OS):
* Linux ``~/.local/share/kicad/<ver>/3rdparty/plugins``
* Windows ``%APPDATA%\\kicad\\<ver>\\3rdparty\\plugins``
* macOS ``~/Library/Preferences/kicad/<ver>/3rdparty/plugins``
The ``<ver>`` dir is globbed (e.g. ``9.0``); only versions the user has
actually run KiCad with exist, and those are the ones returned.
"""
platform = platform if platform is not None else sys.platform
environ = environ if environ is not None else dict(os.environ)
home = home if home is not None else Path.home()
if platform.startswith("win"):
appdata = environ.get("APPDATA")
base = Path(appdata) / "kicad" if appdata else None
elif platform == "darwin":
base = home / "Library" / "Preferences" / "kicad"
else:
base = home / ".local" / "share" / "kicad"
if base is None or not base.is_dir():
return []
return sorted(
ver / "3rdparty" / "plugins" for ver in base.glob("[0-9]*.[0-9]*") if ver.is_dir()
)
def ensure_helper_installed(
plugin_dirs: Optional[List[Path]] = None,
bundled: Optional[Path] = None,
) -> Dict[str, Any]:
"""Self-deploy the bundled helper into every KiCad version's plugin tree.
ROOT-FLATTEN layout (the packaging bug we fixed once): the bundled
``plugins/*`` files land directly at ``<plugins-dir>/<identifier>/`` —
NOT nested another level down — matching how PCM installs the zip's
``plugins/`` contents. Idempotent: a dir is (re)written only when the
``HELPER_VERSION`` marker file is missing or differs from this MCP's
:data:`HELPER_VERSION`.
Returns ``{"installed": [dirs written], "current": [dirs already OK]}``.
"""
bundled = bundled if bundled is not None else _bundled_helper_dir()
if plugin_dirs is None:
plugin_dirs = kicad_plugin_dirs()
installed: List[str] = []
current: List[str] = []
if not bundled.is_dir():
return {"installed": installed, "current": current}
for plugins_dir in plugin_dirs:
target = plugins_dir / HELPER_IDENTIFIER
marker = target / HELPER_VERSION_MARKER
try:
if marker.is_file() and marker.read_text(encoding="utf-8").strip() == HELPER_VERSION:
current.append(str(target))
continue
target.mkdir(parents=True, exist_ok=True)
for src in bundled.iterdir():
if src.name == "__pycache__":
continue
if src.is_dir():
shutil.copytree(src, target / src.name, dirs_exist_ok=True)
else:
shutil.copy2(src, target / src.name)
marker.write_text(HELPER_VERSION + "\n", encoding="utf-8")
installed.append(str(target))
except OSError as exc: # never let self-install break the graceful path
logger.warning("gui-driver helper install into %s failed: %s", target, exc)
return {"installed": installed, "current": current}
# Seed list of destructive menu labels (docs/gui_destructive_seed.txt,
# regex-derived from the live pcbnew 215-item enumeration; embedded here so the
# installed wheel doesn't depend on docs/). Matching is on NORMALIZED labels —
# see _normalize_label — so "Update PCB from Schematic…" and
# "Update PCB from Schematic..." both hit.
DESTRUCTIVE_SEED_LABELS = [
"Append Board...",
"Cleanup Graphics...",
"Cleanup Tracks & Vias...",
"Clear Recent Files",
"Delete",
"Import Non-KiCad Board File…",
"Interactive Delete Tool",
"New...",
"Remove Unused Pads...",
"Rescue",
"Reset Drill Origin",
"Reset Grid Origin",
"Revert",
"Update PCB from Schematic…",
"Update Schematic from PCB...",
]
# Preferred matching path: resolved KiCad TOOL_ACTION names (survive label
# renames). wx menu items expose no public action mapping today, so the helper
# reports action=None and this set is empty — it is consulted first whenever a
# tree entry does carry an "action" so the upgrade is a data change, not code.
DESTRUCTIVE_ACTIONS: frozenset = frozenset()
def _normalize_label(label: str) -> str:
"""Casefolded label; mnemonic '&', accel suffix and trailing ellipsis stripped."""
text = label.split("\t", 1)[0].replace("&", "").strip()
while text.endswith(("...", "…")):
text = text[:-3] if text.endswith("...") else text[:-1]
text = text.strip()
return " ".join(text.split()).casefold()
_DESTRUCTIVE_NORMALIZED = {_normalize_label(label) for label in DESTRUCTIVE_SEED_LABELS}
def is_destructive(entry: Dict[str, Any]) -> bool:
"""Advisory destructive classification for one tree entry.
Prefers a resolved KiCad action name when the entry has one; falls back to
the normalized label against the seed list.
"""
action = entry.get("action")
if action and action in DESTRUCTIVE_ACTIONS:
return True
return _normalize_label(entry.get("name", "")) in _DESTRUCTIVE_NORMALIZED
def flag_destructive_tree(tree: Dict[str, Any]) -> Dict[str, Any]:
"""Mutate a helper `tree` result in place: prefix + flag destructive menu items."""
def _walk(entries: List[Dict[str, Any]]) -> None:
for entry in entries:
if is_destructive(entry):
entry["destructive"] = True
if not entry["name"].startswith(DESTRUCTIVE_PREFIX):
entry["name"] = DESTRUCTIVE_PREFIX + entry["name"]
_walk(entry.get("children", []))
for menu in tree.get("menus", []):
_walk(menu.get("children", []))
return tree
def resolve_name(tree: Dict[str, Any], name: str) -> Optional[Tuple[int, str]]:
"""Resolve a human name to ``(id, kind)`` against a captured tree.
Menus first (clean labels — the reliable path), then AUI toolbar tooltips.
The ``⚠ `` advisory prefix is stripped before matching so names copied from
``kicad_gui_tree`` output resolve as-is.
"""
wanted = _normalize_label(name.removeprefix(DESTRUCTIVE_PREFIX))
def _walk(entries: List[Dict[str, Any]]) -> Optional[int]:
for entry in entries:
if _normalize_label(entry.get("name", "").removeprefix(DESTRUCTIVE_PREFIX)) == wanted:
return entry["id"]
hit = _walk(entry.get("children", []))
if hit is not None:
return hit
return None
for menu in tree.get("menus", []):
hit = _walk(menu.get("children", []))
if hit is not None:
return (hit, "menu")
for bar in tree.get("toolbars", []):
for tool in bar.get("tools", []):
if (
_normalize_label(tool.get("tooltip") or "") == wanted
or _normalize_label(tool.get("label") or "") == wanted
):
return (tool["id"], "tool")
return None
class GuiDriverCommands:
"""MCP command handlers for the GUI-driver channel (helper socket client)."""
def __init__(self, interface: Any = None) -> None:
self._interface = interface # unused today; kept for parity with peers
# -- socket plumbing -----------------------------------------------------
@property
def port(self) -> int:
return int(os.environ.get("KICAD_GUI_DRIVER_PORT", DEFAULT_PORT))
def _send(self, request: Dict[str, Any], timeout: float = 30.0) -> Dict[str, Any]:
"""One request → one response over a fresh connection. Raises on I/O.
Echoes the session token (from the helper's 0600 token file) on every
request; the helper rejects requests that don't carry it.
"""
payload = dict(request)
token = _read_token()
if token is not None:
payload["token"] = token
with socket.create_connection(("127.0.0.1", self.port), timeout=timeout) as conn:
conn.sendall((json.dumps(payload) + "\n").encode("utf-8"))
reader = conn.makefile("r", encoding="utf-8")
line = reader.readline()
if not line:
raise ConnectionError("helper closed the connection without a response")
return json.loads(line)
def _call(self, request: Dict[str, Any], timeout: float = 30.0) -> Dict[str, Any]:
"""`_send` wrapped into the backend's {"success": ...} envelope."""
try:
response = self._send(request, timeout=timeout)
except (OSError, ConnectionError) as exc:
return {"success": False, "error": self._unreachable_error(exc)}
except json.JSONDecodeError as exc:
return {"success": False, "error": f"malformed helper response: {exc}"}
if not response.get("ok"):
return {"success": False, "error": response.get("error", "helper error")}
return {"success": True, "result": response.get("result")}
def _unreachable_error(self, exc: Exception) -> str:
"""Not-reachable message. Installing the helper into the user's KiCad is
OPT-IN — it is a change to their config, not a side effect of a failed
connection. Auto-install runs only when ``KICAD_GUI_DRIVER_AUTOINSTALL``
is truthy; otherwise we point at the explicit ``install_gui_driver`` tool.
Graceful degradation is the REQUIRED contract: this always returns a
string for a ``success: False`` envelope — never a raise, never a hang.
"""
base = f"GUI-driver helper not reachable on 127.0.0.1:{self.port} ({exc}). "
autoinstall = os.environ.get("KICAD_GUI_DRIVER_AUTOINSTALL", "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
if not autoinstall:
return base + (
"The helper is not installed/running. Run the `install_gui_driver` tool "
"to deploy it into KiCad (opt-in), set KICAD_GUI_DRIVER_ENABLE=1 in KiCad's "
"environment, and restart KiCad (or Tools > External Plugins > Refresh Plugins). "
"To auto-install on failure instead, set KICAD_GUI_DRIVER_AUTOINSTALL=1."
)
try:
deployed = ensure_helper_installed()
except Exception as install_exc: # noqa: BLE001 — never break graceful-fail
logger.warning("gui-driver helper self-install failed: %s", install_exc)
deployed = {"installed": [], "current": []}
if deployed["installed"]:
where = ", ".join(deployed["installed"])
if self._try_refresh_plugins_atspi():
return base + (
f"Helper self-installed to {where} and Refresh Plugins was "
"triggered via AT-SPI — retry the call."
)
return base + (
f"Helper self-installed to {where} — in KiCad run Tools > "
"External Plugins > Refresh Plugins (or restart KiCad), then retry."
)
return base + (
"Is KiCad running with the kicad-gui-driver plugin installed? "
"Install: copy gui_driver_plugin/plugins/ to "
"<kicad-data>/3rdparty/plugins/com_github_rossvonfange_kicad-gui-driver/ "
"and restart KiCad (or Tools > External Plugins > Refresh Plugins)."
)
def _try_refresh_plugins_atspi(self) -> bool:
"""Linux nicety: click Tools > External Plugins > Refresh Plugins via
AT-SPI so a just-installed helper loads without a restart. Best-effort
only — any failure (non-Linux, no a11y bus, no KiCad) is silent and
the caller falls back to the refresh/restart prompt.
"""
if not sys.platform.startswith("linux"):
return False
try:
out = self.kicad_gui_click_atspi({"name": "Refresh Plugins"})
return bool(out.get("success"))
except Exception: # noqa: BLE001 — nicety, never surfaces
return False
def install_gui_driver(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Explicitly deploy the in-KiCad helper into the user's KiCad plugin tree.
Opt-in: the connection path no longer installs the helper as a side effect
of a failed connect. Returns which version dirs were written vs already
current, plus the steps to open the (token-gated) channel.
"""
try:
deployed = ensure_helper_installed()
except Exception as exc: # noqa: BLE001 — surface as a failed envelope, never raise
return {"success": False, "error": f"gui-driver helper install failed: {exc}"}
refreshed = self._try_refresh_plugins_atspi() if deployed["installed"] else False
return {
"success": True,
"installed": deployed["installed"],
"current": deployed["current"],
"refreshed_via_atspi": refreshed,
"next": (
"Set KICAD_GUI_DRIVER_ENABLE=1 in KiCad's environment, then restart KiCad "
"(or Tools > External Plugins > Refresh Plugins) to open the token-gated channel."
),
}
# -- generic tools -------------------------------------------------------
def kicad_gui_tree(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Enumerate the live GUI: menus/submenus (name+id) + AUI toolbars (id+tooltip).
Destructive items carry `destructive: true` and a leading `⚠ ` — an
ADVISORY marker only; nothing is gated.
"""
out = self._call({"cmd": "tree", "frame": params.get("frame")})
if not out["success"]:
return out
tree = flag_destructive_tree(out["result"])
return {"success": True, "tree": tree}
def kicad_gui_click(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Activate a menu item or toolbar tool by name (or explicit id).
Resolution: fetch the live tree, resolve name→(id, kind) client-side
(menus by label, toolbar tools by tooltip), then inject the wx event.
Falls back to helper-side resolution if the client miss was transient.
"""
frame = params.get("frame")
item_id = params.get("id")
kind = params.get("kind", "menu")
name = params.get("name")
if item_id is None:
if not name:
return {"success": False, "error": "kicad_gui_click needs `name` or `id`"}
tree_out = self._call({"cmd": "tree", "frame": frame})
if tree_out["success"]:
resolved = resolve_name(tree_out["result"], name)
if resolved is not None:
item_id, kind = resolved
request: Dict[str, Any] = {"cmd": "click", "kind": kind, "frame": frame}
if item_id is not None:
request["id"] = item_id
else:
request["name"] = name # helper-side fallback resolution
out = self._call(request)
if not out["success"]:
return out
return {"success": True, **out["result"], "requested": name or item_id}
def kicad_run_action_plugin(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Trigger an External-Plugins entry (an ActionPlugin) by name."""
name = params.get("name")
if not name:
return {"success": False, "error": "kicad_run_action_plugin needs `name`"}
out = self._call({"cmd": "run_plugin", "name": name, "frame": params.get("frame")})
if not out["success"]:
return out
return {"success": True, **out["result"], "plugin": name}
def kicad_gui_wait_for(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Wait until a top-level window whose title contains `title` is shown."""
title = params.get("title")
if not title:
return {"success": False, "error": "kicad_gui_wait_for needs `title`"}
timeout = float(params.get("timeout", 10.0))
out = self._call(
{"cmd": "wait_for", "title": title, "timeout": timeout},
timeout=timeout + 10.0,
)
if not out["success"]:
return out
return {"success": True, **out["result"]}
def kicad_gui_screenshot(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Capture the driven frame's screen rect to a PNG; returns its path."""
out = self._call(
{"cmd": "screenshot", "path": params.get("path"), "frame": params.get("frame")}
)
if not out["success"]:
return out
return {"success": True, **out["result"]}
# -- playbooks (thin wrappers, deliberately not a framework) -------------
def kicad_pcb_snapshot(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Playbook: Zoom to Fit, then screenshot — visual board verification."""
zoom = self.kicad_gui_click({"name": "Zoom to Fit", "frame": params.get("frame")})
if not zoom.get("success"):
return {"success": False, "error": "Zoom to Fit failed", "detail": zoom}
time.sleep(float(params.get("settle", 0.5))) # let the canvas repaint
shot = self.kicad_gui_screenshot(params)
if not shot.get("success"):
return shot
return {
"success": True,
"zoomed": True,
**{k: v for k, v in shot.items() if k != "success"},
}
def kicad_reload_and_open_plugin(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Playbook: Refresh Plugins, then trigger the named External-Plugins item.
The plugin dev/test loop: re-scan the 3rdparty tree, launch the plugin.
"""
name = params.get("name")
if not name:
return {"success": False, "error": "kicad_reload_and_open_plugin needs `name`"}
refresh = self.kicad_gui_click({"name": "Refresh Plugins", "frame": params.get("frame")})
if not refresh.get("success"):
return {"success": False, "error": "Refresh Plugins failed", "detail": refresh}
time.sleep(float(params.get("settle", 1.0))) # let the re-scan rebuild the menu
return self.kicad_run_action_plugin(params)
def kicad_run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Playbook: open the DRC dialog, click Run, scrape the violations grid.
Exercises the dialog-driving path (wait_for + child enumeration +
button click). Grid scraping is best-effort: wx exposes ListCtrl rows
directly; the DataViewCtrl KiCad uses for violations may only expose a
row count — the raw widget scrape is returned either way.
"""
frame = params.get("frame")
opened = self.kicad_gui_click({"name": "Design Rules Checker", "frame": frame})
if not opened.get("success"):
return {"success": False, "error": "could not open the DRC dialog", "detail": opened}
dialog_title = params.get("dialogTitle", "DRC")
appeared = self.kicad_gui_wait_for(
{"title": dialog_title, "timeout": float(params.get("timeout", 15.0))}
)
if not appeared.get("success") or not appeared.get("found"):
return {
"success": False,
"error": f"DRC dialog (~{dialog_title!r}) did not appear",
"detail": appeared,
}
title = appeared["title"]
ran = self._call({"cmd": "click_button", "title": title, "label": "Run DRC"})
if not ran["success"]:
return {"success": False, "error": "could not click Run DRC", "detail": ran}
# Poll the dialog until rows (or a row count) show up, then scrape.
deadline = time.monotonic() + float(params.get("runTimeout", 60.0))
scraped: Dict[str, Any] = {}
while time.monotonic() < deadline:
time.sleep(1.0)
out = self._call({"cmd": "scrape", "title": title})
if not out["success"]:
continue
scraped = out["result"]
if self._collect_rows(scraped):
break
violations = self._collect_rows(scraped)
return {
"success": True,
"dialog": title,
"violations": violations,
"violationCount": (
sum(len(g["rows"]) for g in violations)
if violations
else self._collect_row_counts(scraped)
),
"rawScrape": scraped,
}
@staticmethod
def _collect_rows(widget: Dict[str, Any]) -> List[Dict[str, Any]]:
"""All list-like widgets in a scrape that yielded actual text rows."""
found: List[Dict[str, Any]] = []
def _walk(entry: Dict[str, Any]) -> None:
rows = entry.get("rows")
if rows:
found.append({"widget": entry.get("class"), "rows": rows})
for child in entry.get("children", []):
_walk(child)
if widget:
_walk(widget)
return found
@staticmethod
def _collect_row_counts(widget: Dict[str, Any]) -> Optional[int]:
counts: List[int] = []
def _walk(entry: Dict[str, Any]) -> None:
if "row_count" in entry:
counts.append(entry["row_count"])
for child in entry.get("children", []):
_walk(child)
if widget:
_walk(widget)
return max(counts) if counts else None
# -- Backend B: AT-SPI (Linux, zero-in-KiCad) ----------------------------
@staticmethod
def _atspi():
"""Import gi/Atspi or explain how to enable it (returns (module, error))."""
try:
import gi # type: ignore[import-not-found]
gi.require_version("Atspi", "2.0")
from gi.repository import Atspi # type: ignore[import-not-found]
return Atspi, None
except Exception as exc: # noqa: BLE001
return None, (
f"AT-SPI unavailable ({exc}). Backend B needs the OS gi bindings "
"(no pip), accessibility enabled once via `gsettings set "
"org.gnome.desktop.interface toolkit-accessibility true`, and "
"KiCad launched with GTK_MODULES=gail:atk-bridge."
)
@staticmethod
def _atspi_find_apps(Atspi, app_match: str) -> List[Any]:
desktop = Atspi.get_desktop(0)
wanted = app_match.casefold()
apps = []
for i in range(desktop.get_child_count()):
app = desktop.get_child_at_index(i)
if app is not None and wanted in (app.get_name() or "").casefold():
apps.append(app)
return apps
def kicad_gui_tree_atspi(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Linux fast-path: dump KiCad's accessible widget tree (role + name)."""
Atspi, err = self._atspi()
if Atspi is None:
return {"success": False, "error": err}
Atspi.init()
app_match = params.get("app", "kicad")
max_depth = int(params.get("maxDepth", 12))
apps = self._atspi_find_apps(Atspi, app_match)
if not apps:
return {
"success": False,
"error": f"no accessible application matching {app_match!r} on the a11y bus",
}
def _node(obj, depth: int) -> Dict[str, Any]:
entry: Dict[str, Any] = {
"role": obj.get_role_name(),
"name": obj.get_name() or "",
}
if depth < max_depth:
children = []
for i in range(obj.get_child_count()):
child = obj.get_child_at_index(i)
if child is not None:
children.append(_node(child, depth + 1))
if children:
entry["children"] = children
return entry
return {"success": True, "apps": [_node(app, 0) for app in apps]}
def kicad_gui_click_atspi(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Linux fast-path: activate the first accessible node matching name (+role)."""
name = params.get("name")
if not name:
return {"success": False, "error": "kicad_gui_click_atspi needs `name`"}
Atspi, err = self._atspi()
if Atspi is None:
return {"success": False, "error": err}
Atspi.init()
wanted = _normalize_label(name)
role_filter = (params.get("role") or "").casefold() or None
apps = self._atspi_find_apps(Atspi, params.get("app", "kicad"))
if not apps:
return {"success": False, "error": "no KiCad application on the a11y bus"}
def _find(obj):
node_name = _normalize_label(obj.get_name() or "")
role = obj.get_role_name()
if node_name == wanted and (role_filter is None or role_filter in role):
return obj
for i in range(obj.get_child_count()):
child = obj.get_child_at_index(i)
if child is not None:
hit = _find(child)
if hit is not None:
return hit
return None
for app in apps:
node = _find(app)
if node is not None:
node.do_action(0) # press / activate
return {
"success": True,
"clicked": node.get_name(),
"role": node.get_role_name(),
}
return {"success": False, "error": f"no accessible node named {name!r}"}