fix: reconnect IPC backend after KiCad starts (#140)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
23
CHANGELOG.md
23
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
|
`.kicad_sch` path) for stateless callers, so project libraries can be
|
||||||
resolved without first calling `open_project`.
|
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`,
|
- **Schematic symbol lookup**: `get_schematic_component`,
|
||||||
`edit_schematic_component`, `set_schematic_component_property`,
|
`edit_schematic_component`, `set_schematic_component_property`,
|
||||||
`remove_schematic_component_property`, and `delete_schematic_component`
|
`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,
|
this out explicitly so agents know they can use it to inspect MPN,
|
||||||
Manufacturer, Distributor PN and other BOM fields without a separate call.
|
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
|
### New MCP Prompt
|
||||||
|
|
||||||
- `component_sourcing_properties` — Guides the LLM through attaching BOM and
|
- `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
|
no-op removal, special-character escaping, UUID preservation, and the two
|
||||||
new convenience tools.
|
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
|
### Removed
|
||||||
|
|
||||||
- `add_schematic_junction` MCP tool has been removed. Junctions are now
|
- `add_schematic_junction` MCP tool has been removed. Junctions are now
|
||||||
|
|||||||
18
README.md
18
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
|
- Changes made via MCP tools appear immediately in the KiCAD UI
|
||||||
- No manual reload required when IPC is active
|
- No manual reload required when IPC is active
|
||||||
- Hybrid backend: uses IPC when available, falls back to SWIG API
|
- 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
|
- 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.
|
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
|
### Required Software
|
||||||
|
|
||||||
**KiCAD 9.0 or Higher**
|
**KiCAD 9.0 or higher**
|
||||||
|
|
||||||
- Download from [kicad.org/download](https://www.kicad.org/download/)
|
- Download from [kicad.org/download](https://www.kicad.org/download/)
|
||||||
- Must include Python module (pcbnew)
|
- Must include Python module (pcbnew)
|
||||||
@@ -463,7 +466,7 @@ Choose one:
|
|||||||
### Linux (Ubuntu/Debian)
|
### Linux (Ubuntu/Debian)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install KiCAD 9.0
|
# Install KiCAD 9.0 or higher
|
||||||
sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases
|
sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases
|
||||||
sudo apt-get update
|
sudo apt-get update
|
||||||
sudo apt-get install -y kicad kicad-libraries
|
sudo apt-get install -y kicad kicad-libraries
|
||||||
@@ -495,7 +498,9 @@ cd KiCAD-MCP-Server
|
|||||||
|
|
||||||
The script will:
|
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
|
- Verify prerequisites
|
||||||
- Install dependencies
|
- Install dependencies
|
||||||
- Build project
|
- Build project
|
||||||
@@ -693,7 +698,8 @@ Edit configuration file:
|
|||||||
**Platform-specific PYTHONPATH:**
|
**Platform-specific PYTHONPATH:**
|
||||||
|
|
||||||
- **Linux:** `/usr/lib/kicad/lib/python3/dist-packages`
|
- **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`
|
- **macOS:** `/Applications/KiCad/KiCad.app/Contents/Frameworks/Python.framework/Versions/3.9/lib/python3.9/site-packages`
|
||||||
|
|
||||||
#### Linux Python Detection
|
#### Linux Python Detection
|
||||||
@@ -1076,7 +1082,7 @@ npm run format
|
|||||||
**Solutions:**
|
**Solutions:**
|
||||||
|
|
||||||
1. Run automated diagnostics: `.\setup-windows.ps1`
|
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
|
3. Check Windows Event Viewer for Node.js errors
|
||||||
4. See [Windows Troubleshooting Guide](docs/WINDOWS_TROUBLESHOOTING.md)
|
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):**
|
**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
|
- 21 IPC-enabled commands with automatic SWIG fallback
|
||||||
- Hybrid footprint loading (SWIG for library access, IPC for placement)
|
- Hybrid footprint loading (SWIG for library access, IPC for placement)
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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
|
## Key Differences
|
||||||
|
|
||||||
@@ -33,6 +39,7 @@ The following MCP commands have IPC handlers:
|
|||||||
| `add_via` | `_ipc_add_via` | Implemented |
|
| `add_via` | `_ipc_add_via` | Implemented |
|
||||||
| `add_net` | `_ipc_add_net` | Implemented |
|
| `add_net` | `_ipc_add_net` | Implemented |
|
||||||
| `delete_trace` | `_ipc_delete_trace` | Falls back to SWIG |
|
| `delete_trace` | `_ipc_delete_trace` | Falls back to SWIG |
|
||||||
|
| `query_traces` | `_ipc_query_traces` | Implemented |
|
||||||
| `get_nets_list` | `_ipc_get_nets_list` | Implemented |
|
| `get_nets_list` | `_ipc_get_nets_list` | Implemented |
|
||||||
| `add_copper_pour` | `_ipc_add_copper_pour` | Implemented |
|
| `add_copper_pour` | `_ipc_add_copper_pour` | Implemented |
|
||||||
| `refill_zones` | `_ipc_refill_zones` | 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`)
|
- Auto-detect socket path (`/tmp/kicad/api.sock`)
|
||||||
- Version checking and validation
|
- Version checking and validation
|
||||||
- Auto-fallback to SWIG when IPC unavailable
|
- Auto-fallback to SWIG when IPC unavailable
|
||||||
|
- Runtime reconnect from SWIG to IPC when KiCAD starts after MCP
|
||||||
- Change notification callbacks
|
- Change notification callbacks
|
||||||
|
|
||||||
**Board Operations:**
|
**Board Operations:**
|
||||||
@@ -86,6 +94,7 @@ The following MCP commands have IPC handlers:
|
|||||||
- Get all tracks
|
- Get all tracks
|
||||||
- Get all vias
|
- Get all vias
|
||||||
- Get all nets
|
- Get all nets
|
||||||
|
- Query traces from the live board with net, layer, and bounding-box filters
|
||||||
|
|
||||||
**Zone Operations:**
|
**Zone Operations:**
|
||||||
|
|
||||||
@@ -119,6 +128,30 @@ The following MCP commands have IPC handlers:
|
|||||||
pip install kicad-python
|
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
|
### Testing
|
||||||
|
|
||||||
Run the test script to verify IPC functionality:
|
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
|
2. **Project creation**: Not supported via IPC, uses file system
|
||||||
3. **Footprint library access**: Uses hybrid approach (SWIG loads from library, IPC places)
|
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)
|
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
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -173,6 +208,8 @@ Run the test script to verify IPC functionality:
|
|||||||
- Ensure KiCAD is running
|
- Ensure KiCAD is running
|
||||||
- Enable IPC API: `Preferences > Plugins > Enable IPC API Server`
|
- Enable IPC API: `Preferences > Plugins > Enable IPC API Server`
|
||||||
- Check if a board is open
|
- 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"
|
### "kicad-python not found"
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,10 @@ Check if KiCAD is currently running.
|
|||||||
"command": "/usr/bin/pcbnew /tmp/project.kicad_pcb"
|
"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,
|
"launched": true,
|
||||||
"message": "KiCAD launched successfully",
|
"message": "KiCAD launched successfully",
|
||||||
"project": "/tmp/mcp_demo/New_Project.kicad_pcb",
|
"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
|
## 🔄 Workflow Examples
|
||||||
@@ -383,16 +402,15 @@ await callKicadScript("launch_kicad_ui", {
|
|||||||
|
|
||||||
- **Window Management:** Bring KiCAD to front, minimize/maximize
|
- **Window Management:** Bring KiCAD to front, minimize/maximize
|
||||||
- **Multi-Instance:** Handle multiple KiCAD instances
|
- **Multi-Instance:** Handle multiple KiCAD instances
|
||||||
- **IPC Integration:** Seamless integration with IPC backend
|
|
||||||
- **Status Notifications:** Push notifications when KiCAD state changes
|
- **Status Notifications:** Push notifications when KiCAD state changes
|
||||||
- **Auto-Close:** Option to close KiCAD after operations complete
|
- **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.
|
No file reloading needed! Changes appear instantly.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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)
|
**Active Backend:** Hybrid SWIG/IPC
|
||||||
**Real-time Updates:** Not available yet
|
|
||||||
**IPC Backend:** Skeleton implemented, operations coming in Weeks 2-3
|
**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
|
### 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.
|
- Example: Add board outline, mounting holes, etc.
|
||||||
- Each operation saves the file automatically
|
- Each operation saves the file automatically
|
||||||
|
|
||||||
3. **Reload in KiCAD UI**
|
3. **Check whether reload is needed**
|
||||||
- **Option A (Automatic):** KiCAD 8.0+ detects file changes and shows a reload prompt
|
- If the MCP response reports `_backend: "ipc"` and `_realtime: true`, the change should appear in KiCAD immediately.
|
||||||
- **Option B (Manual):** File → Revert to reload from disk
|
- If the MCP response reports `_backend: "swig"` or `_realtime: false`, reload the board from disk.
|
||||||
- **Keyboard shortcut:** None by default (but you can assign one)
|
- **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
|
### 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
|
||||||
|
|
||||||
```
|
```text
|
||||||
Claude MCP → IPC Socket → Running KiCAD → Instant UI Update
|
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**
|
1. Enable IPC in KiCAD:
|
||||||
- Preferences → Advanced Preferences
|
- Preferences → Plugins → Enable IPC API Server
|
||||||
- Search for "IPC"
|
- Restart KiCAD if required
|
||||||
- Enable: "Enable IPC API Server"
|
|
||||||
- Restart KiCAD
|
|
||||||
|
|
||||||
2. **Install kicad-python** (Already installed ✓)
|
2. Install `kicad-python`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install kicad-python
|
pip install kicad-python
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Configure MCP Server**
|
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
|
```json
|
||||||
{
|
{
|
||||||
@@ -94,20 +116,23 @@ Claude MCP → IPC Socket → Running KiCAD → Instant UI Update
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **Start KiCAD first, then use MCP**
|
4. Start KiCAD and open a board, or use `launch_kicad_ui`.
|
||||||
- Changes will appear in real-time
|
|
||||||
- No manual reloading needed
|
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
|
### Current IPC Status
|
||||||
|
|
||||||
| Feature | Status |
|
| Feature | Status |
|
||||||
| -------------------- | ----------- |
|
| ------------------------ | ------------------------------------------------------------- |
|
||||||
| Connection to KiCAD | ✅ Working |
|
| Connection to KiCAD | Working when KiCAD IPC is enabled |
|
||||||
| Version checking | ✅ Working |
|
| Board operations | Partially implemented via IPC |
|
||||||
| Project operations | ⏳ Week 2-3 |
|
| Component operations | Partially implemented / hybrid |
|
||||||
| Board operations | ⏳ Week 2-3 |
|
| Routing operations | Partially implemented via IPC |
|
||||||
| Component operations | ⏳ Week 2-3 |
|
| SWIG fallback | Used automatically in `auto` mode when IPC is unavailable |
|
||||||
| Routing operations | ⏳ Week 2-3 |
|
| 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.):
|
For complex changes (multiple components, routing, etc.):
|
||||||
|
|
||||||
1. Make the change
|
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
|
3. Verify it looks correct
|
||||||
4. Proceed with next change
|
4. Proceed with next change
|
||||||
|
|
||||||
@@ -171,6 +196,15 @@ For complex changes (multiple components, routing, etc.):
|
|||||||
**Cause:** MCP operation may have failed
|
**Cause:** MCP operation may have failed
|
||||||
**Solution:** Check the MCP response for success: true
|
**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
|
### File is Locked
|
||||||
|
|
||||||
**Cause:** KiCAD has the file open exclusively
|
**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
|
**Last Updated:** 2025-10-26
|
||||||
**Version:** 2.0.0-alpha.1
|
**Version:** 2.0.0-alpha.1
|
||||||
|
|||||||
@@ -45,10 +45,12 @@ If the automated setup fails, continue with the manual troubleshooting below.
|
|||||||
2. **Test pcbnew import manually:**
|
2. **Test pcbnew import manually:**
|
||||||
|
|
||||||
```powershell
|
```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:**
|
**If it fails:**
|
||||||
- KiCAD's Python module isn't installed
|
- KiCAD's Python module isn't installed
|
||||||
@@ -61,7 +63,7 @@ If the automated setup fails, continue with the manual troubleshooting below.
|
|||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"kicad": {
|
"kicad": {
|
||||||
"env": {
|
"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"
|
**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:**
|
**Solution:**
|
||||||
|
|
||||||
1. **Check if KiCAD is installed:**
|
1. **Check if KiCAD is installed:**
|
||||||
|
|
||||||
```powershell
|
```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:**
|
2. **If KiCAD is installed elsewhere:**
|
||||||
- Find your KiCAD installation directory
|
- Find your KiCAD installation directory
|
||||||
- Update PYTHONPATH in config to match your installation
|
- Set `KICAD_PYTHON` to the bundled `python.exe`
|
||||||
- Example for version 8.0:
|
- 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:**
|
3. **If KiCAD is not installed:**
|
||||||
- Download from https://www.kicad.org/download/windows/
|
- Download from https://www.kicad.org/download/windows/
|
||||||
- Install version 9.0 or higher
|
- Install KiCAD 9.0 or higher
|
||||||
- Use default installation path
|
- 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:**
|
1. **Install with KiCAD's Python:**
|
||||||
|
|
||||||
```powershell
|
```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:**
|
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
|
Invoke-WebRequest -Uri https://bootstrap.pypa.io/get-pip.py -OutFile get-pip.py
|
||||||
|
|
||||||
# Install pip
|
# 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
|
# 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:**
|
**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:**
|
**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": {
|
"mcpServers": {
|
||||||
"kicad": {
|
"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"]
|
"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",
|
"command": "node",
|
||||||
"args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"],
|
"args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"],
|
||||||
"env": {
|
"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",
|
"NODE_ENV": "production",
|
||||||
"LOG_LEVEL": "info"
|
"LOG_LEVEL": "info"
|
||||||
}
|
}
|
||||||
@@ -284,7 +299,7 @@ Config location: `%APPDATA%\Code\User\globalStorage\saoudrizwan.claude-dev\setti
|
|||||||
"command": "node",
|
"command": "node",
|
||||||
"args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"],
|
"args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\dist\\index.js"],
|
||||||
"env": {
|
"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"
|
"description": "KiCAD PCB Design Assistant"
|
||||||
}
|
}
|
||||||
@@ -300,10 +315,10 @@ If Node.js issues persist, run Python directly:
|
|||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"kicad": {
|
"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"],
|
"args": ["C:\\Users\\YourName\\KiCAD-MCP-Server\\python\\kicad_interface.py"],
|
||||||
"env": {
|
"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
|
### Test 1: Verify KiCAD Python
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
& "C:\Program Files\KiCad\9.0\bin\python.exe" -c @"
|
& "C:\Program Files\KiCad\10.0\bin\python.exe" -c @"
|
||||||
import sys
|
import sys
|
||||||
print(f'Python version: {sys.version}')
|
print(f'Python version: {sys.version}')
|
||||||
import pcbnew
|
import pcbnew
|
||||||
@@ -330,7 +345,7 @@ Expected output:
|
|||||||
|
|
||||||
```
|
```
|
||||||
Python version: 3.11.x ...
|
Python version: 3.11.x ...
|
||||||
pcbnew version: 9.0.0
|
pcbnew version: 10.0.0
|
||||||
SUCCESS!
|
SUCCESS!
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -353,7 +368,7 @@ Test-Path .\dist\index.js # Should output: True
|
|||||||
### Test 4: Run Server Manually
|
### Test 4: Run Server Manually
|
||||||
|
|
||||||
```powershell
|
```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
|
node .\dist\index.js
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -390,20 +405,20 @@ Add to your MCP config:
|
|||||||
### Check Python sys.path
|
### Check Python sys.path
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
& "C:\Program Files\KiCad\9.0\bin\python.exe" -c @"
|
& "C:\Program Files\KiCad\10.0\bin\python.exe" -c @"
|
||||||
import sys
|
import sys
|
||||||
for path in sys.path:
|
for path in sys.path:
|
||||||
print(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
|
### Test MCP Communication
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Start server
|
# 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
|
$process = Start-Process -FilePath "node" -ArgumentList ".\dist\index.js" -NoNewWindow -PassThru
|
||||||
|
|
||||||
# Wait 3 seconds
|
# Wait 3 seconds
|
||||||
@@ -474,7 +489,8 @@ If none of the above solutions work:
|
|||||||
|
|
||||||
When everything works, you should have:
|
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
|
- [ ] Node.js 18+ installed and in PATH
|
||||||
- [ ] Python can import pcbnew successfully
|
- [ ] Python can import pcbnew successfully
|
||||||
- [ ] `npm run build` completes without errors
|
- [ ] `npm run build` completes without errors
|
||||||
|
|||||||
@@ -371,10 +371,11 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
# Check if on Edge.Cuts layer
|
# Check if on Edge.Cuts layer
|
||||||
bbox = board.get_item_bounding_box(shape)
|
bbox = board.get_item_bounding_box(shape)
|
||||||
if bbox:
|
if bbox:
|
||||||
min_x = min(min_x, bbox.min.x)
|
left, top, right, bottom = self._get_box2_extents(bbox)
|
||||||
min_y = min(min_y, bbox.min.y)
|
min_x = min(min_x, left)
|
||||||
max_x = max(max_x, bbox.max.x)
|
min_y = min(min_y, top)
|
||||||
max_y = max(max_y, bbox.max.y)
|
max_x = max(max_x, right)
|
||||||
|
max_y = max(max_y, bottom)
|
||||||
|
|
||||||
if min_x == float("inf"):
|
if min_x == float("inf"):
|
||||||
return {"width": 0, "height": 0, "unit": "mm"}
|
return {"width": 0, "height": 0, "unit": "mm"}
|
||||||
@@ -385,6 +386,21 @@ class IPCBoardAPI(BoardAPI):
|
|||||||
logger.error(f"Failed to get board size: {e}")
|
logger.error(f"Failed to get board size: {e}")
|
||||||
return {"width": 0, "height": 0, "unit": "mm", "error": str(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:
|
def add_layer(self, layer_name: str, layer_type: str) -> bool:
|
||||||
"""Add layer to the board (layers are typically predefined in KiCAD)."""
|
"""Add layer to the board (layers are typically predefined in KiCAD)."""
|
||||||
logger.warning("Layer management via IPC is limited - layers are predefined")
|
logger.warning("Layer management via IPC is limited - layers are predefined")
|
||||||
|
|||||||
@@ -496,6 +496,7 @@ class KiCADInterface:
|
|||||||
"add_via": "_ipc_add_via",
|
"add_via": "_ipc_add_via",
|
||||||
"add_net": "_ipc_add_net",
|
"add_net": "_ipc_add_net",
|
||||||
"delete_trace": "_ipc_delete_trace",
|
"delete_trace": "_ipc_delete_trace",
|
||||||
|
"query_traces": "_ipc_query_traces",
|
||||||
"get_nets_list": "_ipc_get_nets_list",
|
"get_nets_list": "_ipc_get_nets_list",
|
||||||
# Zone commands
|
# Zone commands
|
||||||
"add_copper_pour": "_ipc_add_copper_pour",
|
"add_copper_pour": "_ipc_add_copper_pour",
|
||||||
@@ -519,12 +520,99 @@ class KiCADInterface:
|
|||||||
"save_project": "_ipc_save_project",
|
"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]:
|
def handle_command(self, command: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Route command to appropriate handler, preferring IPC when available"""
|
"""Route command to appropriate handler, preferring IPC when available"""
|
||||||
logger.info(f"Handling command: {command}")
|
logger.info(f"Handling command: {command}")
|
||||||
logger.debug(f"Command parameters: {params}")
|
logger.debug(f"Command parameters: {params}")
|
||||||
|
|
||||||
try:
|
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)
|
# 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:
|
if self.use_ipc and self.ipc_board_api and command in self.IPC_CAPABLE_COMMANDS:
|
||||||
ipc_handler_name = self.IPC_CAPABLE_COMMANDS[command]
|
ipc_handler_name = self.IPC_CAPABLE_COMMANDS[command]
|
||||||
@@ -558,8 +646,11 @@ class KiCADInterface:
|
|||||||
|
|
||||||
# Add backend indicator
|
# Add backend indicator
|
||||||
if isinstance(result, dict):
|
if isinstance(result, dict):
|
||||||
result["_backend"] = "swig"
|
backend = self._result_backend_for_command(command, result)
|
||||||
result["_realtime"] = False
|
result["_backend"] = backend
|
||||||
|
result["_realtime"] = bool(
|
||||||
|
backend == "ipc" and result.get("realtime", self.use_ipc)
|
||||||
|
)
|
||||||
|
|
||||||
# Update board reference if command was successful
|
# Update board reference if command was successful
|
||||||
if result.get("success", False):
|
if result.get("success", False):
|
||||||
@@ -5018,14 +5109,21 @@ class KiCADInterface:
|
|||||||
logger.info("Checking if KiCAD UI is running")
|
logger.info("Checking if KiCAD UI is running")
|
||||||
try:
|
try:
|
||||||
manager = KiCADProcessManager()
|
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()
|
processes = manager.get_process_info()
|
||||||
is_running = len(processes) > 0
|
is_running = len(processes) > 0
|
||||||
|
if is_running:
|
||||||
|
self._try_enable_ipc_backend()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"running": is_running,
|
"running": is_running,
|
||||||
"processes": processes,
|
"processes": processes,
|
||||||
"message": "KiCAD is running" if is_running else "KiCAD is not running",
|
"message": "KiCAD is running" if is_running else "KiCAD is not running",
|
||||||
|
**self._backend_status(),
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error checking KiCAD UI status: {str(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
|
path_obj = Path(project_path) if project_path else None
|
||||||
|
|
||||||
result = check_and_launch_kicad(path_obj, auto_launch)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Error launching KiCAD UI: {str(e)}")
|
logger.error(f"Error launching KiCAD UI: {str(e)}")
|
||||||
return {"success": False, "message": 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)")
|
logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)")
|
||||||
return self.routing_commands.delete_trace(params)
|
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]:
|
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"""
|
"""IPC handler for get_nets_list - gets nets with real-time data"""
|
||||||
try:
|
try:
|
||||||
@@ -5777,15 +5956,17 @@ print("ok")
|
|||||||
|
|
||||||
def _handle_get_backend_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def _handle_get_backend_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Get information about the current backend"""
|
"""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 {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"backend": "ipc" if self.use_ipc else "swig",
|
**status,
|
||||||
"realtime_sync": self.use_ipc,
|
"version": ipc_backend.get_version() if ipc_backend else "N/A",
|
||||||
"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",
|
|
||||||
"message": (
|
"message": (
|
||||||
"Using IPC backend with real-time UI sync"
|
"Using IPC backend with real-time UI sync"
|
||||||
if self.use_ipc
|
if status["backend"] == "ipc"
|
||||||
else "Using SWIG backend (requires manual reload)"
|
else "Using SWIG backend (requires manual reload)"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|||||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { spawn, exec, execSync, ChildProcess } from "child_process";
|
import { spawn, exec, execSync, ChildProcess } from "child_process";
|
||||||
import { existsSync } from "fs";
|
import { existsSync, readdirSync } from "fs";
|
||||||
import { join, dirname } from "path";
|
import { join, dirname } from "path";
|
||||||
import { logger } from "./logger.js";
|
import { logger } from "./logger.js";
|
||||||
|
|
||||||
@@ -40,9 +40,41 @@ import { registerRoutingPrompts } from "./prompts/routing.js";
|
|||||||
import { registerDesignPrompts } from "./prompts/design.js";
|
import { registerDesignPrompts } from "./prompts/design.js";
|
||||||
import { registerFootprintPrompts } from "./prompts/footprint.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
|
* Find the Python executable to use.
|
||||||
* Prioritizes virtual environment if available, falls back to system Python
|
* Prioritizes project venvs, then explicit overrides, then KiCAD-bundled Python
|
||||||
|
* before falling back to system Python.
|
||||||
*/
|
*/
|
||||||
function findPythonExecutable(scriptPath: string): string {
|
function findPythonExecutable(scriptPath: string): string {
|
||||||
const isWindows = process.platform === "win32";
|
const isWindows = process.platform === "win32";
|
||||||
@@ -73,12 +105,13 @@ function findPythonExecutable(scriptPath: string): string {
|
|||||||
|
|
||||||
// Platform-specific KiCAD bundled Python detection
|
// Platform-specific KiCAD bundled Python detection
|
||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
// Windows: Always prefer KiCAD's bundled Python (pcbnew.pyd is compiled for it)
|
// Windows: Always prefer KiCAD's bundled Python (pcbnew.pyd is compiled for it).
|
||||||
const kicadPython = "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe";
|
for (const kicadPython of getWindowsKiCadPythonCandidates()) {
|
||||||
if (existsSync(kicadPython)) {
|
if (existsSync(kicadPython)) {
|
||||||
logger.info(`Found KiCAD bundled Python at: ${kicadPython}`);
|
logger.info(`Found KiCAD bundled Python at: ${kicadPython}`);
|
||||||
return kicadPython;
|
return kicadPython;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (isMac) {
|
} else if (isMac) {
|
||||||
// macOS: Try KiCAD's bundled Python (check multiple versions and locations)
|
// macOS: Try KiCAD's bundled Python (check multiple versions and locations)
|
||||||
const kicadPythonVersions = ["3.9", "3.10", "3.11", "3.12", "3.13"];
|
const kicadPythonVersions = ["3.9", "3.10", "3.11", "3.12", "3.13"];
|
||||||
@@ -259,10 +292,12 @@ export class KiCADMcpServer {
|
|||||||
// Check if Python executable exists (for absolute paths) or is executable (for commands)
|
// Check if Python executable exists (for absolute paths) or is executable (for commands)
|
||||||
const isAbsolutePath =
|
const isAbsolutePath =
|
||||||
pythonExe.startsWith("/") || pythonExe.startsWith("C:") || pythonExe.startsWith("\\");
|
pythonExe.startsWith("/") || pythonExe.startsWith("C:") || pythonExe.startsWith("\\");
|
||||||
|
let pythonExecutableAvailable = true;
|
||||||
|
|
||||||
if (isAbsolutePath) {
|
if (isAbsolutePath) {
|
||||||
// Absolute path: use existsSync
|
// Absolute path: use existsSync
|
||||||
if (!existsSync(pythonExe)) {
|
if (!existsSync(pythonExe)) {
|
||||||
|
pythonExecutableAvailable = false;
|
||||||
errors.push(`Python executable not found: ${pythonExe}`);
|
errors.push(`Python executable not found: ${pythonExe}`);
|
||||||
|
|
||||||
if (isWindows) {
|
if (isWindows) {
|
||||||
@@ -299,6 +334,7 @@ export class KiCADMcpServer {
|
|||||||
|
|
||||||
logger.info(`Python version check passed: ${stdout.trim()}`);
|
logger.info(`Python version check passed: ${stdout.trim()}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
pythonExecutableAvailable = false;
|
||||||
errors.push(`Python executable not found in PATH: ${pythonExe}`);
|
errors.push(`Python executable not found in PATH: ${pythonExe}`);
|
||||||
errors.push(`Error: ${error.message}`);
|
errors.push(`Error: ${error.message}`);
|
||||||
errors.push("Set KICAD_PYTHON environment variable to specify full path");
|
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)
|
// Try to test pcbnew import (quick validation)
|
||||||
if (existsSync(pythonExe) && existsSync(this.kicadScriptPath)) {
|
if (pythonExecutableAvailable && existsSync(this.kicadScriptPath)) {
|
||||||
logger.info("Validating pcbnew module access...");
|
logger.info("Validating pcbnew module access...");
|
||||||
|
|
||||||
const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`;
|
const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`;
|
||||||
|
|||||||
466
tests/test_backend_metadata.py
Normal file
466
tests/test_backend_metadata.py
Normal file
@@ -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"}
|
||||||
Reference in New Issue
Block a user