diff --git a/CHANGELOG.md b/CHANGELOG.md index 9410de4..9775479 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,22 @@ All notable changes to the KiCAD MCP Server project are documented here. `.kicad_sch` path) for stateless callers, so project libraries can be resolved without first calling `open_project`. +- **IPC backend runtime reconnect**: MCP no longer stays on SWIG for the + entire process when it starts before KiCAD. IPC-capable board tools now retry + the IPC connection when KiCAD is running, refresh the live board API when a + board becomes available, and report `_backend: "ipc"` when they actually use + the IPC path. `check_kicad_ui`, `launch_kicad_ui`, and `get_backend_info` + now include live backend status instead of only reflecting startup state. + +- **Windows KiCAD Python discovery**: Windows startup now scans per-user KiCAD + installs under `%LOCALAPPDATA%\Programs\KiCad` in addition to machine-wide + installs under `C:\Program Files\KiCad` and `C:\Program Files (x86)\KiCad`, + so user-scope installs no longer require a manual `KICAD_PYTHON` override. + +- **IPC board size on KiCAD 10**: `get_board_info` now handles KiCAD 10 IPC + `Box2` objects that expose `pos` / `size` instead of `min` / `max`, avoiding + a zero-size board result with an attribute error. + - **Schematic symbol lookup**: `get_schematic_component`, `edit_schematic_component`, `set_schematic_component_property`, `remove_schematic_component_property`, and `delete_schematic_component` @@ -157,6 +173,9 @@ All notable changes to the KiCAD MCP Server project are documented here. this out explicitly so agents know they can use it to inspect MPN, Manufacturer, Distributor PN and other BOM fields without a separate call. +- `query_traces`: added to the IPC-capable board command path so trace reads + can use live KiCAD board data when IPC is connected. + ### New MCP Prompt - `component_sourcing_properties` — Guides the LLM through attaching BOM and @@ -175,6 +194,10 @@ All notable changes to the KiCAD MCP Server project are documented here. no-op removal, special-character escaping, UUID preservation, and the two new convenience tools. +- `tests/test_backend_metadata.py`: regression coverage for backend metadata, + runtime IPC reconnect after KiCAD starts, IPC-backed `query_traces`, and + KiCAD 10 IPC `Box2` board-size compatibility. + ### Removed - `add_schematic_junction` MCP tool has been removed. Junctions are now diff --git a/README.md b/README.md index 4799bc3..e957761 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,9 @@ We are currently implementing and testing the KiCAD 9.0 IPC API for real-time UI - Changes made via MCP tools appear immediately in the KiCAD UI - No manual reload required when IPC is active - Hybrid backend: uses IPC when available, falls back to SWIG API +- IPC runtime reconnect: if MCP has fallen back to SWIG, IPC-capable board + tools retry IPC after KiCAD launches instead of staying on SWIG for the entire + session - 20+ commands now support IPC including routing, component placement, and zone operations Note: IPC features are under active development and testing. Enable IPC in KiCAD via Preferences > Plugins > Enable IPC API Server. @@ -418,7 +421,7 @@ See [Freerouting Guide](docs/FREEROUTING_GUIDE.md) for setup and usage. ### Required Software -**KiCAD 9.0 or Higher** +**KiCAD 9.0 or higher** - Download from [kicad.org/download](https://www.kicad.org/download/) - Must include Python module (pcbnew) @@ -463,7 +466,7 @@ Choose one: ### Linux (Ubuntu/Debian) ```bash -# Install KiCAD 9.0 +# Install KiCAD 9.0 or higher sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases sudo apt-get update sudo apt-get install -y kicad kicad-libraries @@ -495,7 +498,9 @@ cd KiCAD-MCP-Server The script will: -- Detect KiCAD installation +- Detect KiCAD installations, including both machine-wide installs under + `C:\Program Files\KiCad` and per-user installs under + `%LOCALAPPDATA%\Programs\KiCad` - Verify prerequisites - Install dependencies - Build project @@ -693,7 +698,8 @@ Edit configuration file: **Platform-specific PYTHONPATH:** - **Linux:** `/usr/lib/kicad/lib/python3/dist-packages` -- **Windows:** `C:\Program Files\KiCad\9.0\lib\python3\dist-packages` +- **Windows:** `C:\Program Files\KiCad\10.0\lib\python3\dist-packages` or + `%LOCALAPPDATA%\Programs\KiCad\10.0\lib\python3\dist-packages` - **macOS:** `/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages` #### Linux Python Detection @@ -1076,7 +1082,7 @@ npm run format **Solutions:** 1. Run automated diagnostics: `.\setup-windows.ps1` -2. Verify Python path uses double backslashes: `C:\\Program Files\\KiCad\\9.0` +2. Verify Python path uses double backslashes: `C:\\Program Files\\KiCad\\10.0` 3. Check Windows Event Viewer for Node.js errors 4. See [Windows Troubleshooting Guide](docs/WINDOWS_TROUBLESHOOTING.md) @@ -1118,7 +1124,7 @@ See [STATUS_SUMMARY.md](docs/STATUS_SUMMARY.md) for the complete status matrix a **IPC Backend (Experimental):** -- Real-time UI synchronization via KiCAD 9.0 IPC API +- Real-time UI synchronization via the KiCAD IPC API - 21 IPC-enabled commands with automatic SWIG fallback - Hybrid footprint loading (SWIG for library access, IPC for placement) diff --git a/docs/IPC_BACKEND_STATUS.md b/docs/IPC_BACKEND_STATUS.md index 5f70387..6e21985 100644 --- a/docs/IPC_BACKEND_STATUS.md +++ b/docs/IPC_BACKEND_STATUS.md @@ -11,7 +11,13 @@ The IPC backend provides real-time UI synchronization with KiCAD 9.0+ via the official IPC API. When KiCAD is running with IPC enabled, commands can update the KiCAD UI immediately without requiring manual reload. -This feature is experimental and under active testing. The server uses a hybrid approach: IPC when available, automatic fallback to SWIG when IPC is not connected. +This feature is experimental and under active testing. The server uses a hybrid +approach: IPC when available, automatic fallback to SWIG when IPC is not +connected, and runtime reconnect when KiCAD becomes available after the MCP +server has already started. Tools with IPC implementations recheck IPC +availability at runtime and reconnect if KiCAD is available, which prevents the +MCP from staying in SWIG for the entire session after an initial failure to +connect to IPC. ## Key Differences @@ -33,6 +39,7 @@ The following MCP commands have IPC handlers: | `add_via` | `_ipc_add_via` | Implemented | | `add_net` | `_ipc_add_net` | Implemented | | `delete_trace` | `_ipc_delete_trace` | Falls back to SWIG | +| `query_traces` | `_ipc_query_traces` | Implemented | | `get_nets_list` | `_ipc_get_nets_list` | Implemented | | `add_copper_pour` | `_ipc_add_copper_pour` | Implemented | | `refill_zones` | `_ipc_refill_zones` | Implemented | @@ -59,6 +66,7 @@ The following MCP commands have IPC handlers: - Auto-detect socket path (`/tmp/kicad/api.sock`) - Version checking and validation - Auto-fallback to SWIG when IPC unavailable +- Runtime reconnect from SWIG to IPC when KiCAD starts after MCP - Change notification callbacks **Board Operations:** @@ -86,6 +94,7 @@ The following MCP commands have IPC handlers: - Get all tracks - Get all vias - Get all nets +- Query traces from the live board with net, layer, and bounding-box filters **Zone Operations:** @@ -119,6 +128,30 @@ The following MCP commands have IPC handlers: pip install kicad-python ``` +### Runtime Backend Selection + +At startup, the server attempts IPC first when `KICAD_BACKEND` is `auto` or +`ipc`. If KiCAD is not running yet, `auto` mode falls back to SWIG so file-based +operations can still work. + +IPC-capable board tools now retry the IPC connection at runtime. This means the +following workflow can switch from SWIG to IPC without restarting the MCP +server: + +1. Start the MCP server before KiCAD is running. +2. Launch KiCAD manually or with `launch_kicad_ui`. +3. Open a board with IPC enabled in KiCAD. +4. Call a normal IPC-capable board tool such as `get_board_info`, + `get_layer_list`, `get_component_list`, `get_nets_list`, or `query_traces`. + +When the reconnect succeeds, tool responses include `_backend: "ipc"` and +`_realtime: true`. If IPC is still unavailable or no board API can be opened, +the server continues to use SWIG and reports `_backend: "swig"` for the result. + +Use `check_kicad_ui`, `launch_kicad_ui`, or `get_backend_info` to inspect the +current live backend status. These responses include `backend`, +`realtime_sync`, and `ipc_connected`. + ### Testing Run the test script to verify IPC functionality: @@ -164,7 +197,9 @@ Run the test script to verify IPC functionality: 2. **Project creation**: Not supported via IPC, uses file system 3. **Footprint library access**: Uses hybrid approach (SWIG loads from library, IPC places) 4. **Delete trace**: Falls back to SWIG (IPC API doesn't support direct deletion) -5. **Some operations may not work as expected**: This is experimental code +5. **Reconnect still requires IPC prerequisites**: Runtime reconnect only + succeeds when KiCAD is running with IPC enabled and a board is available +6. **Some operations may not work as expected**: This is experimental code ## Troubleshooting @@ -173,6 +208,8 @@ Run the test script to verify IPC functionality: - Ensure KiCAD is running - Enable IPC API: `Preferences > Plugins > Enable IPC API Server` - Check if a board is open +- If MCP started before KiCAD, call `check_kicad_ui` or retry an IPC-capable + board tool after KiCAD and the board are ready ### "kicad-python not found" diff --git a/docs/UI_AUTO_LAUNCH.md b/docs/UI_AUTO_LAUNCH.md index 05f2315..296e5bb 100644 --- a/docs/UI_AUTO_LAUNCH.md +++ b/docs/UI_AUTO_LAUNCH.md @@ -71,7 +71,10 @@ Check if KiCAD is currently running. "command": "/usr/bin/pcbnew /tmp/project.kicad_pcb" } ], - "message": "KiCAD is running" + "message": "KiCAD is running", + "backend": "ipc", + "realtime_sync": true, + "ipc_connected": true } ``` @@ -104,10 +107,26 @@ Launch KiCAD UI, optionally with a project file. "launched": true, "message": "KiCAD launched successfully", "project": "/tmp/mcp_demo/New_Project.kicad_pcb", - "processes": [...] + "processes": [...], + "backend": "ipc", + "realtime_sync": true, + "ipc_connected": true } ``` +If IPC is not enabled, no board is open, or the IPC connection is otherwise not +available yet, these status fields report `backend: "swig"`, +`realtime_sync: false`, and `ipc_connected: false`. + +### IPC Reconnect After Launch + +When the MCP server starts before KiCAD, it may initially fall back to the SWIG +backend. `launch_kicad_ui` and `check_kicad_ui` now refresh the live backend +status after KiCAD is running, and normal IPC-capable board tools retry IPC on +their next call. Agents do not need to switch to special `ipc_*` tools; they can +retry standard tools such as `get_board_info`, `get_layer_list`, +`get_component_list`, `get_nets_list`, or `query_traces`. + --- ## 🔄 Workflow Examples @@ -383,16 +402,15 @@ await callKicadScript("launch_kicad_ui", { - **Window Management:** Bring KiCAD to front, minimize/maximize - **Multi-Instance:** Handle multiple KiCAD instances -- **IPC Integration:** Seamless integration with IPC backend - **Status Notifications:** Push notifications when KiCAD state changes - **Auto-Close:** Option to close KiCAD after operations complete -### IPC Mode (Coming Weeks 2-3) +### IPC Mode -When IPC backend is fully implemented: +When KiCAD is running with IPC enabled: ``` -KiCAD runs in background → MCP connects via IPC → Real-time updates +KiCAD runs in background → MCP connects or reconnects via IPC → Real-time updates No file reloading needed! Changes appear instantly. ``` diff --git a/docs/VISUAL_FEEDBACK.md b/docs/VISUAL_FEEDBACK.md index 92d7612..752acb2 100644 --- a/docs/VISUAL_FEEDBACK.md +++ b/docs/VISUAL_FEEDBACK.md @@ -2,15 +2,19 @@ This document explains how to see changes made by the MCP server in the KiCAD UI in real-time or near-real-time. -## Current Status (Week 1 - SWIG Backend) +## Current Status -**Active Backend:** SWIG (legacy pcbnew Python API) -**Real-time Updates:** Not available yet -**IPC Backend:** Skeleton implemented, operations coming in Weeks 2-3 +**Active Backend:** Hybrid SWIG/IPC + +**Real-time Updates:** Available for IPC-backed commands when KiCAD IPC is connected + +**SWIG Fallback:** File-based commands still require KiCAD to reload from disk + +**IPC Re-detect:** IPC-enabled tools detect at runtime if IPC is available again and switch from SWIG to IPC. --- -## 🎯 Best Current Workflow (SWIG + Manual Reload) +## 🎯 Best Current Workflow (Hybrid IPC + SWIG) ### Setup @@ -24,10 +28,11 @@ This document explains how to see changes made by the MCP server in the KiCAD UI - Example: Add board outline, mounting holes, etc. - Each operation saves the file automatically -3. **Reload in KiCAD UI** - - **Option A (Automatic):** KiCAD 8.0+ detects file changes and shows a reload prompt - - **Option B (Manual):** File → Revert to reload from disk - - **Keyboard shortcut:** None by default (but you can assign one) +3. **Check whether reload is needed** + - If the MCP response reports `_backend: "ipc"` and `_realtime: true`, the change should appear in KiCAD immediately. + - If the MCP response reports `_backend: "swig"` or `_realtime: false`, reload the board from disk. + - **Option A (Automatic):** KiCAD 8.0+ detects file changes and shows a reload prompt. + - **Option B (Manual):** File → Revert to reload from disk. ### Workflow Example @@ -57,34 +62,51 @@ This document explains how to see changes made by the MCP server in the KiCAD UI --- -## 🔮 Future: IPC Backend (Weeks 2-3) +## IPC Backend: Real-Time Updates (Experimental) -When fully implemented, the IPC backend will provide **true real-time updates**: +When KiCAD is running with the IPC API enabled, supported MCP board tools can +use the IPC backend for **true real-time UI updates** instead of relying on +file save/reload. -### How It Will Work +### How It Works -``` -Claude MCP → IPC Socket → Running KiCAD → Instant UI Update +```text +Agent → MCP → IPC connection → Running KiCAD → Instant UI Update ``` -**No file reloading required** - changes appear as you make them! +**No file reloading is required** for commands that successfully use IPC. Tool +responses include `_backend: "ipc"` and `_realtime: true` when the IPC path was +used. If IPC is unavailable, the server falls back to SWIG and the manual reload +workflow still applies. If IPC becomes available during the session, +IPC-capable tools can reconnect and use IPC without restarting the MCP server. -### IPC Setup (When Available) +### IPC Setup -1. **Enable IPC in KiCAD** - - Preferences → Advanced Preferences - - Search for "IPC" - - Enable: "Enable IPC API Server" - - Restart KiCAD +1. Enable IPC in KiCAD: + - Preferences → Plugins → Enable IPC API Server + - Restart KiCAD if required -2. **Install kicad-python** (Already installed ✓) +2. Install `kicad-python`: ```bash pip install kicad-python ``` 3. **Configure MCP Server** - Add to your MCP config: + + The default `auto` backend mode is recommended when you want SWIG fallback + plus runtime reconnect. To make the setting explicit, add: + + ```json + { + "env": { + "KICAD_BACKEND": "auto" + } + } + ``` + + Use strict `ipc` mode only when you want startup to fail if IPC is not + available: ```json { @@ -94,20 +116,23 @@ Claude MCP → IPC Socket → Running KiCAD → Instant UI Update } ``` -4. **Start KiCAD first, then use MCP** - - Changes will appear in real-time - - No manual reloading needed +4. Start KiCAD and open a board, or use `launch_kicad_ui`. + +The MCP server **can** start before KiCAD. In `auto` backend mode, IPC-capable +board tools retry IPC at runtime after KiCAD is available, so agents can keep +using standard tools such as `get_board_info`, `get_layer_list`, +`get_component_list`, `get_nets_list`, and `query_traces`. ### Current IPC Status -| Feature | Status | -| -------------------- | ----------- | -| Connection to KiCAD | ✅ Working | -| Version checking | ✅ Working | -| Project operations | ⏳ Week 2-3 | -| Board operations | ⏳ Week 2-3 | -| Component operations | ⏳ Week 2-3 | -| Routing operations | ⏳ Week 2-3 | +| Feature | Status | +| ------------------------ | ------------------------------------------------------------- | +| Connection to KiCAD | Working when KiCAD IPC is enabled | +| Board operations | Partially implemented via IPC | +| Component operations | Partially implemented / hybrid | +| Routing operations | Partially implemented via IPC | +| SWIG fallback | Used automatically in `auto` mode when IPC is unavailable | +| Runtime reconnect to IPC | Used automatically in `auto` mode for IPC-capable board tools | --- @@ -153,7 +178,7 @@ The MCP server auto-saves after each operation, so changes are immediately avail For complex changes (multiple components, routing, etc.): 1. Make the change -2. Reload in KiCAD +2. Confirm the change in KiCAD; reload only if the response used SWIG 3. Verify it looks correct 4. Proceed with next change @@ -171,6 +196,15 @@ For complex changes (multiple components, routing, etc.): **Cause:** MCP operation may have failed **Solution:** Check the MCP response for success: true +### Changes Still Require Reload + +**Cause:** The tool response reported `_backend: "swig"` or `_realtime: false`. +SWIG-backed commands write files directly and still require KiCAD to reload the board from disk. + +**Solution:** Ensure KiCAD is running with IPC enabled and a board is open, then +retry the IPC-capable board tool. If IPC reconnect succeeds, the response will +report `_backend: "ipc"` and `_realtime: true`. + ### File is Locked **Cause:** KiCAD has the file open exclusively @@ -181,13 +215,5 @@ For complex changes (multiple components, routing, etc.): --- -## 📅 Roadmap - -**Current (Week 1):** SWIG backend with manual reload -**Week 2-3:** IPC backend implementation -**Week 4+:** Real-time collaboration features - ---- - **Last Updated:** 2025-10-26 **Version:** 2.0.0-alpha.1 diff --git a/docs/WINDOWS_TROUBLESHOOTING.md b/docs/WINDOWS_TROUBLESHOOTING.md index a4b9f0b..a33059d 100644 --- a/docs/WINDOWS_TROUBLESHOOTING.md +++ b/docs/WINDOWS_TROUBLESHOOTING.md @@ -45,10 +45,12 @@ If the automated setup fails, continue with the manual troubleshooting below. 2. **Test pcbnew import manually:** ```powershell - & "C:\Program Files\KiCad\9.0\bin\python.exe" -c "import pcbnew; print(pcbnew.GetBuildVersion())" + & "C:\Program Files\KiCad\10.0\bin\python.exe" -c "import pcbnew; print(pcbnew.GetBuildVersion())" ``` - **Expected:** Prints KiCAD version like `9.0.0` + Replace `10.0` with your installed KiCAD version if needed. + + **Expected:** Prints KiCAD version like `10.0.0` **If it fails:** - KiCAD's Python module isn't installed @@ -61,7 +63,7 @@ If the automated setup fails, continue with the manual troubleshooting below. "mcpServers": { "kicad": { "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" + "PYTHONPATH": "C:\\Program Files\\KiCad\\10.0\\lib\\python3\\dist-packages" } } } @@ -74,25 +76,37 @@ If the automated setup fails, continue with the manual troubleshooting below. **Symptom:** Log shows "No KiCAD installations found" +The server checks common Windows install locations, including both machine-wide +and per-user KiCAD installs: + +- `%LOCALAPPDATA%\Programs\KiCad` +- `C:\Program Files\KiCad` +- `C:\Program Files (x86)\KiCad` + **Solution:** 1. **Check if KiCAD is installed:** ```powershell - Test-Path "C:\Program Files\KiCad\9.0" + Test-Path "C:\Program Files\KiCad\10.0" + Test-Path "$env:LOCALAPPDATA\Programs\KiCad\10.0" ``` + Replace `10.0` with your installed KiCAD version if needed. + 2. **If KiCAD is installed elsewhere:** - Find your KiCAD installation directory - - Update PYTHONPATH in config to match your installation - - Example for version 8.0: + - Set `KICAD_PYTHON` to the bundled `python.exe` + - Update `PYTHONPATH` in config to match your installation if needed + - Example for a per-user 10.0 install: ``` - "PYTHONPATH": "C:\\Program Files\\KiCad\\8.0\\lib\\python3\\dist-packages" + "KICAD_PYTHON": "C:\\Users\\YourName\\AppData\\Local\\Programs\\KiCad\\10.0\\bin\\python.exe", + "PYTHONPATH": "C:\\Users\\YourName\\AppData\\Local\\Programs\\KiCad\\10.0\\lib\\python3\\dist-packages" ``` 3. **If KiCAD is not installed:** - Download from https://www.kicad.org/download/windows/ - - Install version 9.0 or higher + - Install KiCAD 9.0 or higher - Use default installation path --- @@ -162,7 +176,7 @@ If the automated setup fails, continue with the manual troubleshooting below. 1. **Install with KiCAD's Python:** ```powershell - & "C:\Program Files\KiCad\9.0\bin\python.exe" -m pip install -r requirements.txt + & "C:\Program Files\KiCad\10.0\bin\python.exe" -m pip install -r requirements.txt ``` 2. **If pip is not available:** @@ -172,10 +186,10 @@ If the automated setup fails, continue with the manual troubleshooting below. Invoke-WebRequest -Uri https://bootstrap.pypa.io/get-pip.py -OutFile get-pip.py # Install pip - & "C:\Program Files\KiCad\9.0\bin\python.exe" get-pip.py + & "C:\Program Files\KiCad\10.0\bin\python.exe" get-pip.py # Then install requirements - & "C:\Program Files\KiCad\9.0\bin\python.exe" -m pip install -r requirements.txt + & "C:\Program Files\KiCad\10.0\bin\python.exe" -m pip install -r requirements.txt ``` --- @@ -232,7 +246,8 @@ If the automated setup fails, continue with the manual troubleshooting below. **Solution:** -KiCAD MCP requires Python 3.10+. KiCAD 9.0 includes Python 3.11, which is perfect. +KiCAD MCP requires Python 3.10+. KiCAD 10.0 includes a compatible bundled Python, +and KiCAD 9.0+ is supported. **Always use KiCAD's bundled Python:** @@ -240,7 +255,7 @@ KiCAD MCP requires Python 3.10+. KiCAD 9.0 includes Python 3.11, which is perfec { "mcpServers": { "kicad": { - "command": "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe", + "command": "C:\\Program Files\\KiCad\\10.0\\bin\\python.exe", "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\python\\kicad_interface.py"] } } @@ -264,7 +279,7 @@ Config location: `%APPDATA%\Claude\claude_desktop_config.json` "command": "node", "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"], "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages", + "PYTHONPATH": "C:\\Program Files\\KiCad\\10.0\\lib\\python3\\dist-packages", "NODE_ENV": "production", "LOG_LEVEL": "info" } @@ -284,7 +299,7 @@ Config location: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\setti "command": "node", "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"], "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" + "PYTHONPATH": "C:\\Program Files\\KiCad\\10.0\\lib\\python3\\dist-packages" }, "description": "KiCAD PCB Design Assistant" } @@ -300,10 +315,10 @@ If Node.js issues persist, run Python directly: { "mcpServers": { "kicad": { - "command": "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe", + "command": "C:\\Program Files\\KiCad\\10.0\\bin\\python.exe", "args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\python\\kicad_interface.py"], "env": { - "PYTHONPATH": "C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages" + "PYTHONPATH": "C:\\Program Files\\KiCad\\10.0\\lib\\python3\\dist-packages" } } } @@ -317,7 +332,7 @@ If Node.js issues persist, run Python directly: ### Test 1: Verify KiCAD Python ```powershell -& "C:\Program Files\KiCad\9.0\bin\python.exe" -c @" +& "C:\Program Files\KiCad\10.0\bin\python.exe" -c @" import sys print(f'Python version: {sys.version}') import pcbnew @@ -330,7 +345,7 @@ Expected output: ``` Python version: 3.11.x ... -pcbnew version: 9.0.0 +pcbnew version: 10.0.0 SUCCESS! ``` @@ -353,7 +368,7 @@ Test-Path .\dist\index.js # Should output: True ### Test 4: Run Server Manually ```powershell -$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages" +$env:PYTHONPATH = "C:\Program Files\KiCad\10.0\lib\python3\dist-packages" node .\dist\index.js ``` @@ -390,20 +405,20 @@ Add to your MCP config: ### Check Python sys.path ```powershell -& "C:\Program Files\KiCad\9.0\bin\python.exe" -c @" +& "C:\Program Files\KiCad\10.0\bin\python.exe" -c @" import sys for path in sys.path: print(path) "@ ``` -Should include: `C:\Program Files\KiCad\9.0\lib\python3\dist-packages` +Should include: `C:\Program Files\KiCad\10.0\lib\python3\dist-packages` ### Test MCP Communication ```powershell # Start server -$env:PYTHONPATH = "C:\Program Files\KiCad\9.0\lib\python3\dist-packages" +$env:PYTHONPATH = "C:\Program Files\KiCad\10.0\lib\python3\dist-packages" $process = Start-Process -FilePath "node" -ArgumentList ".\dist\index.js" -NoNewWindow -PassThru # Wait 3 seconds @@ -474,7 +489,8 @@ If none of the above solutions work: When everything works, you should have: -- [ ] KiCAD 9.0+ installed at `C:\Program Files\KiCad\9.0` +- [ ] KiCAD 9.0 or higher installed under a versioned KiCAD directory such as + `C:\Program Files\KiCad\10.0` or `%LOCALAPPDATA%\Programs\KiCad\10.0` - [ ] Node.js 18+ installed and in PATH - [ ] Python can import pcbnew successfully - [ ] `npm run build` completes without errors diff --git a/python/kicad_api/ipc_backend.py b/python/kicad_api/ipc_backend.py index 490ca6e..3d787f3 100644 --- a/python/kicad_api/ipc_backend.py +++ b/python/kicad_api/ipc_backend.py @@ -371,10 +371,11 @@ class IPCBoardAPI(BoardAPI): # Check if on Edge.Cuts layer bbox = board.get_item_bounding_box(shape) if bbox: - min_x = min(min_x, bbox.min.x) - min_y = min(min_y, bbox.min.y) - max_x = max(max_x, bbox.max.x) - max_y = max(max_y, bbox.max.y) + left, top, right, bottom = self._get_box2_extents(bbox) + min_x = min(min_x, left) + min_y = min(min_y, top) + max_x = max(max_x, right) + max_y = max(max_y, bottom) if min_x == float("inf"): return {"width": 0, "height": 0, "unit": "mm"} @@ -385,6 +386,21 @@ class IPCBoardAPI(BoardAPI): logger.error(f"Failed to get board size: {e}") return {"width": 0, "height": 0, "unit": "mm", "error": str(e)} + @staticmethod + def _get_box2_extents(bbox: Any) -> tuple[float, float, float, float]: + """Return left/top/right/bottom for kipy Box2 wrappers across versions.""" + if hasattr(bbox, "min") and hasattr(bbox, "max"): + return bbox.min.x, bbox.min.y, bbox.max.x, bbox.max.y + + if hasattr(bbox, "pos") and hasattr(bbox, "size"): + x1 = bbox.pos.x + y1 = bbox.pos.y + x2 = x1 + bbox.size.x + y2 = y1 + bbox.size.y + return min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2) + + raise AttributeError("Unsupported Box2 shape: expected min/max or pos/size") + def add_layer(self, layer_name: str, layer_type: str) -> bool: """Add layer to the board (layers are typically predefined in KiCAD).""" logger.warning("Layer management via IPC is limited - layers are predefined") diff --git a/python/kicad_interface.py b/python/kicad_interface.py index b798672..a610186 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -496,6 +496,7 @@ class KiCADInterface: "add_via": "_ipc_add_via", "add_net": "_ipc_add_net", "delete_trace": "_ipc_delete_trace", + "query_traces": "_ipc_query_traces", "get_nets_list": "_ipc_get_nets_list", # Zone commands "add_copper_pour": "_ipc_add_copper_pour", @@ -519,12 +520,99 @@ class KiCADInterface: "save_project": "_ipc_save_project", } + # Commands that are implemented by the explicit IPC command handlers in + # command_routes, rather than by the generic IPC_CAPABLE_COMMANDS fast path. + IPC_DIRECT_COMMANDS = { + "ipc_add_track", + "ipc_add_via", + "ipc_add_text", + "ipc_list_components", + "ipc_get_tracks", + "ipc_get_vias", + "ipc_save_board", + } + + def _refresh_ipc_board_api(self) -> bool: + """Refresh the IPC board API after KiCAD or a board becomes available.""" + ipc_backend = getattr(self, "ipc_backend", None) + if not ipc_backend or not ipc_backend.is_connected(): + self.ipc_board_api = None + return False + + try: + self.ipc_board_api = ipc_backend.get_board() + return True + except Exception as e: + logger.warning(f"Connected to KiCAD IPC, but no board API is available yet: {e}") + self.ipc_board_api = None + return False + + def _try_enable_ipc_backend(self, force: bool = False) -> bool: + """Try to switch an already-running interface to IPC when KiCAD is available.""" + if KICAD_BACKEND == "swig": + return False + + ipc_backend = getattr(self, "ipc_backend", None) + if self.use_ipc and ipc_backend and ipc_backend.is_connected(): + self._refresh_ipc_board_api() + return True + + if not force and not KiCADProcessManager.is_running(): + return False + + try: + from kicad_api.ipc_backend import IPCBackend + + backend = ipc_backend or IPCBackend() + if not backend.is_connected(): + backend.connect() + + self.ipc_backend = backend + self.use_ipc = True + self._refresh_ipc_board_api() + logger.info("Switched to IPC backend after KiCAD became available") + return True + except Exception as e: + logger.info(f"Runtime IPC connection not available: {e}") + return False + + def _backend_status(self) -> Dict[str, Any]: + """Return backend status fields for command responses.""" + ipc_backend = getattr(self, "ipc_backend", None) + ipc_connected = ipc_backend.is_connected() if ipc_backend else False + return { + "backend": "ipc" if self.use_ipc and ipc_connected else "swig", + "realtime_sync": self.use_ipc and ipc_connected, + "ipc_connected": ipc_connected, + } + + @staticmethod + def _normalize_ipc_layer_name(layer: Any) -> str: + """Convert KiCad IPC layer enum strings to common layer names.""" + layer_name = str(layer) + if layer_name.startswith("BL_"): + return layer_name[3:].replace("_", ".") + return layer_name + + def _result_backend_for_command(self, command: str, result: Dict[str, Any]) -> str: + """Return the backend label for a command result.""" + if command in {"get_backend_info", "check_kicad_ui", "launch_kicad_ui"}: + return result.get("backend", "ipc" if self.use_ipc else "swig") + + if command in self.IPC_DIRECT_COMMANDS: + return "ipc" if self.use_ipc else "unavailable" + + return "swig" + def handle_command(self, command: str, params: Dict[str, Any]) -> Dict[str, Any]: """Route command to appropriate handler, preferring IPC when available""" logger.info(f"Handling command: {command}") logger.debug(f"Command parameters: {params}") try: + if command in self.IPC_CAPABLE_COMMANDS: + self._try_enable_ipc_backend() + # Check if we can use IPC for this command (real-time UI sync) if self.use_ipc and self.ipc_board_api and command in self.IPC_CAPABLE_COMMANDS: ipc_handler_name = self.IPC_CAPABLE_COMMANDS[command] @@ -558,8 +646,11 @@ class KiCADInterface: # Add backend indicator if isinstance(result, dict): - result["_backend"] = "swig" - result["_realtime"] = False + backend = self._result_backend_for_command(command, result) + result["_backend"] = backend + result["_realtime"] = bool( + backend == "ipc" and result.get("realtime", self.use_ipc) + ) # Update board reference if command was successful if result.get("success", False): @@ -5018,14 +5109,21 @@ class KiCADInterface: logger.info("Checking if KiCAD UI is running") try: manager = KiCADProcessManager() + # `processes` is the single source of truth (from #173) so + # `running` can't disagree with it; and if KiCAD is up, opportunistically + # (re)connect the IPC backend (#140) so a session that started before + # KiCAD launched can fall up from SWIG to IPC. processes = manager.get_process_info() is_running = len(processes) > 0 + if is_running: + self._try_enable_ipc_backend() return { "success": True, "running": is_running, "processes": processes, "message": "KiCAD is running" if is_running else "KiCAD is not running", + **self._backend_status(), } except Exception as e: logger.error(f"Error checking KiCAD UI status: {str(e)}") @@ -5044,8 +5142,10 @@ class KiCADInterface: path_obj = Path(project_path) if project_path else None result = check_and_launch_kicad(path_obj, auto_launch) + if result.get("running"): + self._try_enable_ipc_backend(force=True) - return {"success": True, **result} + return {"success": True, **result, **self._backend_status()} except Exception as e: logger.error(f"Error launching KiCAD UI: {str(e)}") return {"success": False, "message": str(e)} @@ -5581,6 +5681,85 @@ print("ok") logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)") return self.routing_commands.delete_trace(params) + def _ipc_query_traces(self, params: Dict[str, Any]) -> Dict[str, Any]: + """IPC handler for query_traces - reads traces from the live KiCAD board.""" + try: + net_name = params.get("net") + layer_filter = params.get("layer") + bbox = params.get("boundingBox") + include_vias = params.get("includeVias", False) + + def point_in_bbox(point: Dict[str, Any]) -> bool: + if not bbox: + return True + unit_scale = 25.4 if bbox.get("unit", "mm") == "inch" else 1.0 + x1 = bbox.get("x1", 0) * unit_scale + y1 = bbox.get("y1", 0) * unit_scale + x2 = bbox.get("x2", 0) * unit_scale + y2 = bbox.get("y2", 0) * unit_scale + low_x, high_x = sorted((x1, x2)) + low_y, high_y = sorted((y1, y2)) + return low_x <= point.get("x", 0) <= high_x and low_y <= point.get("y", 0) <= high_y + + traces = [] + for track in self.ipc_board_api.get_tracks(): + if net_name and track.get("net") != net_name: + continue + + layer = self._normalize_ipc_layer_name(track.get("layer", "")) + if layer_filter and layer != layer_filter: + continue + + start = track.get("start", {}) + end = track.get("end", {}) + if bbox and not (point_in_bbox(start) or point_in_bbox(end)): + continue + + start_with_unit = {**start, "unit": "mm"} + end_with_unit = {**end, "unit": "mm"} + dx = end.get("x", 0) - start.get("x", 0) + dy = end.get("y", 0) - start.get("y", 0) + traces.append( + { + "uuid": track.get("id", ""), + "net": track.get("net", ""), + "netCode": track.get("netCode", 0), + "layer": layer, + "width": track.get("width", 0), + "start": start_with_unit, + "end": end_with_unit, + "length": (dx**2 + dy**2) ** 0.5, + } + ) + + result = {"success": True, "traceCount": len(traces), "traces": traces} + + if include_vias: + vias = [] + for via in self.ipc_board_api.get_vias(): + if net_name and via.get("net") != net_name: + continue + position = via.get("position", {}) + if bbox and not point_in_bbox(position): + continue + vias.append( + { + "uuid": via.get("id", ""), + "position": {**position, "unit": "mm"}, + "net": via.get("net", ""), + "netCode": via.get("netCode", 0), + "diameter": via.get("diameter", 0), + "drill": via.get("drill", 0), + } + ) + result["viaCount"] = len(vias) + result["vias"] = vias + + return result + except Exception as e: + logger.error(f"IPC query_traces error: {e}") + return {"success": False, "message": str(e)} + def _ipc_get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]: """IPC handler for get_nets_list - gets nets with real-time data""" try: @@ -5777,15 +5956,17 @@ print("ok") def _handle_get_backend_info(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get information about the current backend""" + if KiCADProcessManager.is_running(): + self._try_enable_ipc_backend() + status = self._backend_status() + ipc_backend = getattr(self, "ipc_backend", None) return { "success": True, - "backend": "ipc" if self.use_ipc else "swig", - "realtime_sync": self.use_ipc, - "ipc_connected": (self.ipc_backend.is_connected() if self.ipc_backend else False), - "version": self.ipc_backend.get_version() if self.ipc_backend else "N/A", + **status, + "version": ipc_backend.get_version() if ipc_backend else "N/A", "message": ( "Using IPC backend with real-time UI sync" - if self.use_ipc + if status["backend"] == "ipc" else "Using SWIG backend (requires manual reload)" ), } diff --git a/src/server.ts b/src/server.ts index a3a4a6e..57ff3b5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,7 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import express from "express"; import { spawn, exec, execSync, ChildProcess } from "child_process"; -import { existsSync } from "fs"; +import { existsSync, readdirSync } from "fs"; import { join, dirname } from "path"; import { logger } from "./logger.js"; @@ -40,9 +40,41 @@ import { registerRoutingPrompts } from "./prompts/routing.js"; import { registerDesignPrompts } from "./prompts/design.js"; import { registerFootprintPrompts } from "./prompts/footprint.js"; +function getWindowsKiCadPythonCandidates(): string[] { + const roots = [ + process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "Programs", "KiCad") : undefined, + "C:\\Program Files\\KiCad", + "C:\\Program Files (x86)\\KiCad", + ].filter((root): root is string => Boolean(root)); + + const candidates: string[] = []; + + for (const root of roots) { + if (!existsSync(root)) { + continue; + } + + try { + const versionDirs = readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((a, b) => b.localeCompare(a, undefined, { numeric: true })); + + for (const versionDir of versionDirs) { + candidates.push(join(root, versionDir, "bin", "python.exe")); + } + } catch (error: any) { + logger.warn(`Failed to inspect KiCAD install directory ${root}: ${error.message}`); + } + } + + return [...new Set(candidates)]; +} + /** - * Find the Python executable to use - * Prioritizes virtual environment if available, falls back to system Python + * Find the Python executable to use. + * Prioritizes project venvs, then explicit overrides, then KiCAD-bundled Python + * before falling back to system Python. */ function findPythonExecutable(scriptPath: string): string { const isWindows = process.platform === "win32"; @@ -73,11 +105,12 @@ function findPythonExecutable(scriptPath: string): string { // Platform-specific KiCAD bundled Python detection if (isWindows) { - // Windows: Always prefer KiCAD's bundled Python (pcbnew.pyd is compiled for it) - const kicadPython = "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe"; - if (existsSync(kicadPython)) { - logger.info(`Found KiCAD bundled Python at: ${kicadPython}`); - return kicadPython; + // Windows: Always prefer KiCAD's bundled Python (pcbnew.pyd is compiled for it). + for (const kicadPython of getWindowsKiCadPythonCandidates()) { + if (existsSync(kicadPython)) { + logger.info(`Found KiCAD bundled Python at: ${kicadPython}`); + return kicadPython; + } } } else if (isMac) { // macOS: Try KiCAD's bundled Python (check multiple versions and locations) @@ -259,10 +292,12 @@ export class KiCADMcpServer { // Check if Python executable exists (for absolute paths) or is executable (for commands) const isAbsolutePath = pythonExe.startsWith("/") || pythonExe.startsWith("C:") || pythonExe.startsWith("\\"); + let pythonExecutableAvailable = true; if (isAbsolutePath) { // Absolute path: use existsSync if (!existsSync(pythonExe)) { + pythonExecutableAvailable = false; errors.push(`Python executable not found: ${pythonExe}`); if (isWindows) { @@ -299,6 +334,7 @@ export class KiCADMcpServer { logger.info(`Python version check passed: ${stdout.trim()}`); } catch (error: any) { + pythonExecutableAvailable = false; errors.push(`Python executable not found in PATH: ${pythonExe}`); errors.push(`Error: ${error.message}`); errors.push("Set KICAD_PYTHON environment variable to specify full path"); @@ -325,7 +361,7 @@ export class KiCADMcpServer { } // Try to test pcbnew import (quick validation) - if (existsSync(pythonExe) && existsSync(this.kicadScriptPath)) { + if (pythonExecutableAvailable && existsSync(this.kicadScriptPath)) { logger.info("Validating pcbnew module access..."); const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`; diff --git a/tests/test_backend_metadata.py b/tests/test_backend_metadata.py new file mode 100644 index 0000000..fc4bf33 --- /dev/null +++ b/tests/test_backend_metadata.py @@ -0,0 +1,466 @@ +"""Tests for backend metadata added by KiCADInterface.handle_command.""" + +import sys +import types +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + + +def _make_iface(command_routes, use_ipc=False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + iface.use_ipc = use_ipc + iface.ipc_backend = None + iface.ipc_board_api = None + iface.command_routes = command_routes + return iface + + +class _FakeIPCBoardAPI: + def get_size(self): + return {"width": 10, "height": 20, "unit": "mm"} + + def list_components(self): + return [] + + def get_tracks(self): + return [ + { + "id": "track-1", + "start": {"x": 0, "y": 0}, + "end": {"x": 3, "y": 4}, + "width": 0.25, + "layer": "BL_F_Cu", + "net": "N$1", + } + ] + + def get_vias(self): + return [] + + def get_nets(self): + return [{"name": "N$1", "code": 1}] + + def get_enabled_layers(self): + return ["F.Cu", "B.Cu"] + + +class _FakeIPCBackend: + def __init__(self): + self.connected = False + + def connect(self): + self.connected = True + return True + + def is_connected(self): + return self.connected + + def get_board(self): + return _FakeIPCBoardAPI() + + def get_version(self): + return "9.0-test" + + +class _ConnectShouldNotBeCalledIPCBackend(_FakeIPCBackend): + def connect(self): + raise AssertionError("IPC reconnect should not be attempted") + + +class _FailingConnectIPCBackend(_FakeIPCBackend): + def connect(self): + raise RuntimeError("IPC unavailable") + + +class _NoBoardIPCBackend(_FakeIPCBackend): + def get_board(self): + raise RuntimeError("No board open") + + +class _FilteringIPCBoardAPI(_FakeIPCBoardAPI): + def get_tracks(self): + return [ + { + "id": "track-1", + "start": {"x": 0, "y": 0}, + "end": {"x": 3, "y": 4}, + "width": 0.25, + "layer": "BL_F_Cu", + "net": "N$1", + "netCode": 1, + }, + { + "id": "track-2", + "start": {"x": 10, "y": 10}, + "end": {"x": 11, "y": 11}, + "width": 0.2, + "layer": "BL_B_Cu", + "net": "N$2", + "netCode": 2, + }, + ] + + def get_vias(self): + return [ + { + "id": "via-1", + "position": {"x": 0.5, "y": 0.5}, + "diameter": 0.8, + "drill": 0.4, + "net": "N$1", + "netCode": 1, + }, + { + "id": "via-2", + "position": {"x": 20, "y": 20}, + "diameter": 0.8, + "drill": 0.4, + "net": "N$2", + "netCode": 2, + }, + ] + + +class _Point: + def __init__(self, x, y): + self.x = x + self.y = y + + +class _BoxWithPosSize: + def __init__(self, x, y, width, height): + self.pos = _Point(x, y) + self.size = _Point(width, height) + + +class _BoxWithMinMax: + def __init__(self, min_x, min_y, max_x, max_y): + self.min = _Point(min_x, min_y) + self.max = _Point(max_x, max_y) + + +class _BoundingBoxBoard: + def __init__(self, boxes): + self._boxes = boxes + + def get_shapes(self): + return list(range(len(self._boxes))) + + def get_item_bounding_box(self, shape): + return self._boxes[shape] + + +def _stub_kipy_units(monkeypatch): + units_module = types.ModuleType("kipy.util.units") + units_module.to_mm = lambda nm: nm / 1_000_000 + monkeypatch.setitem(sys.modules, "kipy", types.ModuleType("kipy")) + monkeypatch.setitem(sys.modules, "kipy.util", types.ModuleType("kipy.util")) + monkeypatch.setitem(sys.modules, "kipy.util.units", units_module) + + +def test_generic_command_is_tagged_as_swig(): + iface = _make_iface( + { + "get_project_info": lambda params: { + "success": True, + "project": {"name": "demo"}, + } + }, + use_ipc=True, + ) + + result = iface.handle_command("get_project_info", {}) + + assert result["_backend"] == "swig" + assert result["_realtime"] is False + + +def test_explicit_ipc_command_is_tagged_as_ipc_when_ipc_is_active(): + iface = _make_iface( + { + "ipc_add_track": lambda params: { + "success": True, + "message": "Track added", + "realtime": True, + } + }, + use_ipc=True, + ) + + result = iface.handle_command("ipc_add_track", {}) + + assert result["_backend"] == "ipc" + assert result["_realtime"] is True + + +def test_backend_info_uses_reported_backend_for_metadata(): + iface = _make_iface( + { + "get_backend_info": lambda params: { + "success": True, + "backend": "ipc", + "realtime_sync": True, + } + }, + use_ipc=True, + ) + + result = iface.handle_command("get_backend_info", {}) + + assert result["_backend"] == "ipc" + assert result["_realtime"] is True + + +def test_ipc_capable_command_reconnects_when_kicad_is_running(monkeypatch): + import kicad_interface + + monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "auto") + monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: True) + monkeypatch.setitem( + sys.modules, + "kicad_api.ipc_backend", + types.SimpleNamespace(IPCBackend=_FakeIPCBackend), + ) + + iface = _make_iface({}, use_ipc=False) + + result = iface.handle_command("get_board_info", {}) + + assert result["success"] is True + assert result["_backend"] == "ipc" + assert result["_realtime"] is True + + +def test_ipc_capable_command_does_not_reconnect_in_strict_swig_mode(monkeypatch): + import kicad_interface + + monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "swig") + monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: True) + monkeypatch.setitem( + sys.modules, + "kicad_api.ipc_backend", + types.SimpleNamespace(IPCBackend=_ConnectShouldNotBeCalledIPCBackend), + ) + + iface = _make_iface( + { + "get_board_info": lambda params: { + "success": True, + "board": {"filename": "demo.kicad_pcb"}, + } + }, + use_ipc=False, + ) + + result = iface.handle_command("get_board_info", {}) + + assert result["success"] is True + assert result["_backend"] == "swig" + assert result["_realtime"] is False + assert iface.ipc_board_api is None + + +def test_ipc_reconnect_failure_falls_back_to_swig(monkeypatch): + import kicad_interface + + monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "auto") + monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: True) + monkeypatch.setitem( + sys.modules, + "kicad_api.ipc_backend", + types.SimpleNamespace(IPCBackend=_FailingConnectIPCBackend), + ) + + iface = _make_iface( + { + "get_board_info": lambda params: { + "success": True, + "board": {"filename": "demo.kicad_pcb"}, + } + }, + use_ipc=False, + ) + + result = iface.handle_command("get_board_info", {}) + + assert result["success"] is True + assert result["_backend"] == "swig" + assert result["_realtime"] is False + assert iface.use_ipc is False + assert iface.ipc_board_api is None + + +def test_connected_ipc_without_board_api_reports_status_but_board_tools_fallback(monkeypatch): + import kicad_interface + + monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "auto") + monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda: True) + monkeypatch.setitem( + sys.modules, + "kicad_api.ipc_backend", + types.SimpleNamespace(IPCBackend=_NoBoardIPCBackend), + ) + + iface = _make_iface( + { + "get_board_info": lambda params: { + "success": True, + "board": {"filename": "demo.kicad_pcb"}, + } + }, + use_ipc=False, + ) + iface.command_routes["get_backend_info"] = iface._handle_get_backend_info + + board_result = iface.handle_command("get_board_info", {}) + backend_result = iface.handle_command("get_backend_info", {}) + + assert board_result["success"] is True + assert board_result["_backend"] == "swig" + assert board_result["_realtime"] is False + assert iface.use_ipc is True + assert iface.ipc_board_api is None + + assert backend_result["success"] is True + assert backend_result["backend"] == "ipc" + assert backend_result["realtime_sync"] is True + assert backend_result["ipc_connected"] is True + assert backend_result["_backend"] == "ipc" + + +def test_ui_status_tools_report_live_ipc_backend_status(monkeypatch): + import kicad_interface + + monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "auto") + monkeypatch.setattr(kicad_interface.KiCADProcessManager, "is_running", lambda self=None: True) + monkeypatch.setattr( + kicad_interface.KiCADProcessManager, + "get_process_info", + lambda self: [{"pid": "1234", "name": "pcbnew.exe", "command": "pcbnew.exe"}], + ) + monkeypatch.setitem( + sys.modules, + "kicad_api.ipc_backend", + types.SimpleNamespace(IPCBackend=_FakeIPCBackend), + ) + + iface = _make_iface({}, use_ipc=False) + iface.ipc_backend = _FakeIPCBackend() + iface.command_routes = { + "check_kicad_ui": iface._handle_check_kicad_ui, + "launch_kicad_ui": iface._handle_launch_kicad_ui, + "get_backend_info": iface._handle_get_backend_info, + } + monkeypatch.setattr( + kicad_interface, + "check_and_launch_kicad", + lambda path_obj, auto_launch: { + "running": True, + "launched": True, + "processes": [{"pid": "1234", "name": "pcbnew.exe", "command": "pcbnew.exe"}], + "message": "KiCAD launched successfully", + "project": str(path_obj) if path_obj else None, + }, + ) + + for command in ("check_kicad_ui", "launch_kicad_ui", "get_backend_info"): + result = iface.handle_command(command, {}) + + assert result["success"] is True + assert result["backend"] == "ipc" + assert result["realtime_sync"] is True + assert result["ipc_connected"] is True + assert result["_backend"] == "ipc" + assert result["_realtime"] is True + + +def test_query_traces_can_use_ipc_backend(monkeypatch): + import kicad_interface + + monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "swig") + + iface = _make_iface({}, use_ipc=True) + iface.ipc_board_api = _FakeIPCBoardAPI() + + result = iface.handle_command( + "query_traces", + {"layer": "F.Cu", "boundingBox": {"x1": -1, "y1": -1, "x2": 1, "y2": 1}}, + ) + + assert result["success"] is True + assert result["traceCount"] == 1 + assert result["traces"][0]["layer"] == "F.Cu" + assert result["traces"][0]["length"] == 5 + assert result["_backend"] == "ipc" + + +def test_query_traces_ipc_filters_and_vias(monkeypatch): + import kicad_interface + + monkeypatch.setattr(kicad_interface, "KICAD_BACKEND", "swig") + + iface = _make_iface({}, use_ipc=True) + iface.ipc_board_api = _FilteringIPCBoardAPI() + + net_miss = iface.handle_command("query_traces", {"net": "NO_MATCH"}) + layer_match = iface.handle_command("query_traces", {"layer": "B.Cu"}) + reversed_bbox_with_vias = iface.handle_command( + "query_traces", + { + "net": "N$1", + "includeVias": True, + "boundingBox": {"x1": 1, "y1": 1, "x2": -1, "y2": -1}, + }, + ) + + assert net_miss["success"] is True + assert net_miss["traceCount"] == 0 + assert net_miss["_backend"] == "ipc" + + assert layer_match["success"] is True + assert layer_match["traceCount"] == 1 + assert layer_match["traces"][0]["uuid"] == "track-2" + assert layer_match["traces"][0]["layer"] == "B.Cu" + + assert reversed_bbox_with_vias["success"] is True + assert reversed_bbox_with_vias["traceCount"] == 1 + assert reversed_bbox_with_vias["traces"][0]["uuid"] == "track-1" + assert reversed_bbox_with_vias["viaCount"] == 1 + assert reversed_bbox_with_vias["vias"][0]["uuid"] == "via-1" + + +def test_ipc_board_size_supports_kicad_10_box2_pos_size(monkeypatch): + from kicad_api.ipc_backend import IPCBoardAPI + + _stub_kipy_units(monkeypatch) + board_api = IPCBoardAPI(None, lambda *_args: None) + board_api._board = _BoundingBoxBoard( + [ + _BoxWithPosSize(1_000_000, 2_000_000, 3_000_000, 4_000_000), + _BoxWithPosSize(0, 1_000_000, 2_000_000, 1_000_000), + ] + ) + + result = board_api.get_size() + + assert result == {"width": 4.0, "height": 5.0, "unit": "mm"} + + +def test_ipc_board_size_keeps_min_max_box2_compatibility(monkeypatch): + from kicad_api.ipc_backend import IPCBoardAPI + + _stub_kipy_units(monkeypatch) + board_api = IPCBoardAPI(None, lambda *_args: None) + board_api._board = _BoundingBoxBoard( + [ + _BoxWithMinMax(1_000_000, 2_000_000, 3_000_000, 4_000_000), + _BoxWithMinMax(0, 1_000_000, 2_000_000, 3_000_000), + ] + ) + + result = board_api.get_size() + + assert result == {"width": 3.0, "height": 3.0, "unit": "mm"}