Add Freerouting autoroute integration
4 new MCP tools: autoroute (full DSN→Freerouting→SES pipeline), export_dsn, import_ses, check_freerouting. Requires Java 11+ and freerouting.jar. Includes 21 test cases and README usage examples.
This commit is contained in:
29
README.md
29
README.md
@@ -599,6 +599,35 @@ Add a copper pour for GND on the bottom layer covering the entire board.
|
|||||||
Create a differential pair for USB_P and USB_N with 0.2mm width and 0.15mm gap.
|
Create a differential pair for USB_P and USB_N with 0.2mm width and 0.15mm gap.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Autoroute with Freerouting
|
||||||
|
|
||||||
|
Automatically route all unconnected nets using the [Freerouting](https://github.com/freerouting/freerouting) autorouter.
|
||||||
|
|
||||||
|
**Prerequisites:** Java 11+ and `freerouting.jar` (place at `~/.kicad-mcp/freerouting.jar` or set `FREEROUTING_JAR` env var).
|
||||||
|
|
||||||
|
```text
|
||||||
|
Check if Freerouting is ready on my system.
|
||||||
|
Autoroute the current board using Freerouting with a 5-minute timeout.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step-by-step workflow:**
|
||||||
|
|
||||||
|
```text
|
||||||
|
1. Open the project at ~/Projects/LEDBoard/LEDBoard.kicad_pcb
|
||||||
|
2. Check Freerouting dependencies are installed
|
||||||
|
3. Run autoroute with max 10 passes
|
||||||
|
4. Run DRC to verify the autorouted result
|
||||||
|
5. Export Gerbers to the fabrication folder
|
||||||
|
```
|
||||||
|
|
||||||
|
**Manual DSN/SES workflow** (for advanced users or external autorouters):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Export the board to Specctra DSN format.
|
||||||
|
# ... run Freerouting GUI or another autorouter externally ...
|
||||||
|
Import the routed SES file from ~/Projects/LEDBoard/LEDBoard.ses
|
||||||
|
```
|
||||||
|
|
||||||
### Design Verification
|
### Design Verification
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
400
python/commands/freerouting.py
Normal file
400
python/commands/freerouting.py
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
"""
|
||||||
|
Freerouting autoroute integration for KiCAD MCP Server.
|
||||||
|
|
||||||
|
Exports the board to Specctra DSN format, runs Freerouting CLI,
|
||||||
|
and imports the routed SES file back into the board.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger("kicad_interface")
|
||||||
|
|
||||||
|
# Default Freerouting JAR location
|
||||||
|
DEFAULT_FREEROUTING_JAR = os.environ.get(
|
||||||
|
"FREEROUTING_JAR",
|
||||||
|
os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_java() -> Optional[str]:
|
||||||
|
"""Find java executable on the system."""
|
||||||
|
java = shutil.which("java")
|
||||||
|
if java:
|
||||||
|
return java
|
||||||
|
# Check common locations
|
||||||
|
for candidate in [
|
||||||
|
"/usr/bin/java",
|
||||||
|
"/usr/local/bin/java",
|
||||||
|
os.path.expandvars("$JAVA_HOME/bin/java"),
|
||||||
|
]:
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class FreeroutingCommands:
|
||||||
|
"""Handles Freerouting autoroute operations."""
|
||||||
|
|
||||||
|
def __init__(self, board=None):
|
||||||
|
self.board = board
|
||||||
|
|
||||||
|
def autoroute(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Run Freerouting autorouter on the current board.
|
||||||
|
|
||||||
|
Flow:
|
||||||
|
1. Export board to Specctra DSN
|
||||||
|
2. Run Freerouting CLI on DSN -> SES
|
||||||
|
3. Import SES back into the board
|
||||||
|
4. Save the board
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import pcbnew
|
||||||
|
except ImportError:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "pcbnew not available",
|
||||||
|
"errorDetails": "KiCAD Python API is required",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not self.board:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No board is loaded",
|
||||||
|
"errorDetails": "Load or create a board first",
|
||||||
|
}
|
||||||
|
|
||||||
|
board_path = params.get("boardPath")
|
||||||
|
if not board_path:
|
||||||
|
board_path = self.board.GetFileName()
|
||||||
|
|
||||||
|
if not board_path:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No board file path available",
|
||||||
|
"errorDetails": "Provide boardPath or open a project first",
|
||||||
|
}
|
||||||
|
|
||||||
|
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
|
||||||
|
timeout = params.get("timeout", 300)
|
||||||
|
passes = params.get("maxPasses")
|
||||||
|
|
||||||
|
# Validate java
|
||||||
|
java_exe = _find_java()
|
||||||
|
if not java_exe:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Java not found",
|
||||||
|
"errorDetails": (
|
||||||
|
"Freerouting requires Java. Install Java 11+ or set "
|
||||||
|
"JAVA_HOME environment variable."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate Freerouting JAR
|
||||||
|
if not os.path.isfile(jar_path):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Freerouting JAR not found",
|
||||||
|
"errorDetails": (
|
||||||
|
f"Expected at: {jar_path}. Download from "
|
||||||
|
"https://github.com/freerouting/freerouting/releases "
|
||||||
|
"or set FREEROUTING_JAR env var."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Set up file paths
|
||||||
|
board_dir = os.path.dirname(board_path)
|
||||||
|
board_stem = Path(board_path).stem
|
||||||
|
dsn_path = os.path.join(board_dir, f"{board_stem}.dsn")
|
||||||
|
ses_path = os.path.join(board_dir, f"{board_stem}.ses")
|
||||||
|
|
||||||
|
# Step 1: Export DSN
|
||||||
|
logger.info(f"Exporting DSN to {dsn_path}")
|
||||||
|
try:
|
||||||
|
result = pcbnew.ExportSpecctraDSN(self.board, dsn_path)
|
||||||
|
if result is not True and result != 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "DSN export failed",
|
||||||
|
"errorDetails": f"ExportSpecctraDSN returned: {result}",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "DSN export failed",
|
||||||
|
"errorDetails": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not os.path.isfile(dsn_path):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "DSN file was not created",
|
||||||
|
"errorDetails": f"Expected at: {dsn_path}",
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn_size = os.path.getsize(dsn_path)
|
||||||
|
logger.info(f"DSN exported: {dsn_size} bytes")
|
||||||
|
|
||||||
|
# Step 2: Run Freerouting
|
||||||
|
cmd = [
|
||||||
|
java_exe, "-jar", jar_path,
|
||||||
|
"-de", dsn_path, "-do", ses_path,
|
||||||
|
"-mp", str(passes or 20),
|
||||||
|
]
|
||||||
|
|
||||||
|
logger.info(f"Running Freerouting: {' '.join(cmd)}")
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
cwd=board_dir,
|
||||||
|
)
|
||||||
|
elapsed = round(time.time() - start_time, 1)
|
||||||
|
|
||||||
|
if proc.returncode != 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Freerouting exited with code {proc.returncode}",
|
||||||
|
"errorDetails": proc.stderr or proc.stdout,
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
}
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Freerouting timed out after {timeout}s",
|
||||||
|
"errorDetails": "Increase timeout or reduce board complexity",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Failed to run Freerouting",
|
||||||
|
"errorDetails": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check SES output
|
||||||
|
if not os.path.isfile(ses_path):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Freerouting did not produce SES output",
|
||||||
|
"errorDetails": f"Expected at: {ses_path}. Stdout: {proc.stdout[:500]}",
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
}
|
||||||
|
|
||||||
|
ses_size = os.path.getsize(ses_path)
|
||||||
|
logger.info(f"SES produced: {ses_size} bytes in {elapsed}s")
|
||||||
|
|
||||||
|
# Step 3: Import SES
|
||||||
|
logger.info(f"Importing SES from {ses_path}")
|
||||||
|
try:
|
||||||
|
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
|
||||||
|
if result is not True and result != 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "SES import failed",
|
||||||
|
"errorDetails": f"ImportSpecctraSES returned: {result}",
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "SES import failed",
|
||||||
|
"errorDetails": str(e),
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 4: Save board
|
||||||
|
try:
|
||||||
|
self.board.Save(board_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Board save after autoroute failed: {e}")
|
||||||
|
|
||||||
|
# Collect stats
|
||||||
|
tracks = self.board.GetTracks()
|
||||||
|
track_count = 0
|
||||||
|
via_count = 0
|
||||||
|
for t in tracks:
|
||||||
|
if t.GetClass() == "PCB_VIA":
|
||||||
|
via_count += 1
|
||||||
|
else:
|
||||||
|
track_count += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Autoroute completed in {elapsed}s",
|
||||||
|
"dsn_path": dsn_path,
|
||||||
|
"ses_path": ses_path,
|
||||||
|
"elapsed_seconds": elapsed,
|
||||||
|
"board_stats": {
|
||||||
|
"tracks": track_count,
|
||||||
|
"vias": via_count,
|
||||||
|
},
|
||||||
|
"freerouting_stdout": proc.stdout[:1000] if proc.stdout else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Export the board to Specctra DSN format only (no routing)."""
|
||||||
|
try:
|
||||||
|
import pcbnew
|
||||||
|
except ImportError:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "pcbnew not available",
|
||||||
|
"errorDetails": "KiCAD Python API is required",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not self.board:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No board is loaded",
|
||||||
|
"errorDetails": "Load or create a board first",
|
||||||
|
}
|
||||||
|
|
||||||
|
board_path = params.get("boardPath") or self.board.GetFileName()
|
||||||
|
output_path = params.get("outputPath")
|
||||||
|
|
||||||
|
if not output_path:
|
||||||
|
if board_path:
|
||||||
|
output_path = os.path.splitext(board_path)[0] + ".dsn"
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No output path",
|
||||||
|
"errorDetails": "Provide outputPath or have a board file open",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = pcbnew.ExportSpecctraDSN(self.board, output_path)
|
||||||
|
if result is not True and result != 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "DSN export failed",
|
||||||
|
"errorDetails": f"ExportSpecctraDSN returned: {result}",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "DSN export failed",
|
||||||
|
"errorDetails": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Exported DSN to {output_path}",
|
||||||
|
"path": output_path,
|
||||||
|
"size_bytes": file_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
def import_ses(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Import a Specctra SES file into the board."""
|
||||||
|
try:
|
||||||
|
import pcbnew
|
||||||
|
except ImportError:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "pcbnew not available",
|
||||||
|
"errorDetails": "KiCAD Python API is required",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not self.board:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No board is loaded",
|
||||||
|
"errorDetails": "Load or create a board first",
|
||||||
|
}
|
||||||
|
|
||||||
|
ses_path = params.get("sesPath")
|
||||||
|
if not ses_path:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Missing sesPath parameter",
|
||||||
|
"errorDetails": "Provide the path to the .ses file",
|
||||||
|
}
|
||||||
|
|
||||||
|
if not os.path.isfile(ses_path):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "SES file not found",
|
||||||
|
"errorDetails": f"File not found: {ses_path}",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
|
||||||
|
if result is not True and result != 0:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "SES import failed",
|
||||||
|
"errorDetails": f"ImportSpecctraSES returned: {result}",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "SES import failed",
|
||||||
|
"errorDetails": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save after import
|
||||||
|
board_path = params.get("boardPath") or self.board.GetFileName()
|
||||||
|
if board_path:
|
||||||
|
try:
|
||||||
|
self.board.Save(board_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Board save after SES import failed: {e}")
|
||||||
|
|
||||||
|
tracks = self.board.GetTracks()
|
||||||
|
track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA")
|
||||||
|
via_count = sum(1 for t in tracks if t.GetClass() == "PCB_VIA")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Imported SES from {ses_path}",
|
||||||
|
"board_stats": {
|
||||||
|
"tracks": track_count,
|
||||||
|
"vias": via_count,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Check if Freerouting and Java are available."""
|
||||||
|
jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
|
||||||
|
|
||||||
|
java_exe = _find_java()
|
||||||
|
java_version = None
|
||||||
|
if java_exe:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
[java_exe, "-version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
java_version = (proc.stderr or proc.stdout).strip().split("\n")[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
jar_exists = os.path.isfile(jar_path)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Freerouting dependency check",
|
||||||
|
"java": {
|
||||||
|
"found": java_exe is not None,
|
||||||
|
"path": java_exe,
|
||||||
|
"version": java_version,
|
||||||
|
},
|
||||||
|
"freerouting": {
|
||||||
|
"jar_found": jar_exists,
|
||||||
|
"jar_path": jar_path,
|
||||||
|
},
|
||||||
|
"ready": java_exe is not None and jar_exists,
|
||||||
|
}
|
||||||
@@ -226,6 +226,7 @@ try:
|
|||||||
from commands.datasheet_manager import DatasheetManager
|
from commands.datasheet_manager import DatasheetManager
|
||||||
from commands.footprint import FootprintCreator
|
from commands.footprint import FootprintCreator
|
||||||
from commands.symbol_creator import SymbolCreator
|
from commands.symbol_creator import SymbolCreator
|
||||||
|
from commands.freerouting import FreeroutingCommands
|
||||||
|
|
||||||
logger.info("Successfully imported all command handlers")
|
logger.info("Successfully imported all command handlers")
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
@@ -270,6 +271,7 @@ class KiCADInterface:
|
|||||||
self.board_commands = BoardCommands(self.board)
|
self.board_commands = BoardCommands(self.board)
|
||||||
self.component_commands = ComponentCommands(self.board, self.footprint_library)
|
self.component_commands = ComponentCommands(self.board, self.footprint_library)
|
||||||
self.routing_commands = RoutingCommands(self.board)
|
self.routing_commands = RoutingCommands(self.board)
|
||||||
|
self.freerouting_commands = FreeroutingCommands(self.board)
|
||||||
self.design_rule_commands = DesignRuleCommands(self.board)
|
self.design_rule_commands = DesignRuleCommands(self.board)
|
||||||
self.export_commands = ExportCommands(self.board)
|
self.export_commands = ExportCommands(self.board)
|
||||||
self.library_commands = LibraryCommands(self.footprint_library)
|
self.library_commands = LibraryCommands(self.footprint_library)
|
||||||
@@ -419,6 +421,11 @@ class KiCADInterface:
|
|||||||
"delete_symbol": self._handle_delete_symbol,
|
"delete_symbol": self._handle_delete_symbol,
|
||||||
"list_symbols_in_library": self._handle_list_symbols_in_library,
|
"list_symbols_in_library": self._handle_list_symbols_in_library,
|
||||||
"register_symbol_library": self._handle_register_symbol_library,
|
"register_symbol_library": self._handle_register_symbol_library,
|
||||||
|
# Freerouting autoroute commands
|
||||||
|
"autoroute": self.freerouting_commands.autoroute,
|
||||||
|
"export_dsn": self.freerouting_commands.export_dsn,
|
||||||
|
"import_ses": self.freerouting_commands.import_ses,
|
||||||
|
"check_freerouting": self.freerouting_commands.check_freerouting,
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -578,6 +585,7 @@ class KiCADInterface:
|
|||||||
self.routing_commands.board = self.board
|
self.routing_commands.board = self.board
|
||||||
self.design_rule_commands.board = self.board
|
self.design_rule_commands.board = self.board
|
||||||
self.export_commands.board = self.board
|
self.export_commands.board = self.board
|
||||||
|
self.freerouting_commands.board = self.board
|
||||||
|
|
||||||
# Schematic command handlers
|
# Schematic command handlers
|
||||||
def _handle_create_schematic(self, params):
|
def _handle_create_schematic(self, params):
|
||||||
|
|||||||
367
python/tests/test_freerouting.py
Normal file
367
python/tests/test_freerouting.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Freerouting autoroute integration.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- FreeroutingCommands.check_freerouting (dependency detection)
|
||||||
|
- FreeroutingCommands.export_dsn (DSN export via pcbnew)
|
||||||
|
- FreeroutingCommands.import_ses (SES import via pcbnew)
|
||||||
|
- FreeroutingCommands.autoroute (full pipeline)
|
||||||
|
- Error handling: missing board, missing java, missing JAR, timeouts
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from commands.freerouting import FreeroutingCommands, _find_java
|
||||||
|
|
||||||
|
# Ensure the pcbnew mock from conftest is available at module level
|
||||||
|
# so methods that do `import pcbnew` get the mock.
|
||||||
|
pcbnew_mock = sys.modules["pcbnew"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Fixtures
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def reset_pcbnew_mock():
|
||||||
|
"""Reset pcbnew mock before each test."""
|
||||||
|
pcbnew_mock.reset_mock()
|
||||||
|
# Clear side_effects that persist through reset_mock
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.side_effect = None
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.return_value = MagicMock()
|
||||||
|
pcbnew_mock.ImportSpecctraSES.side_effect = None
|
||||||
|
pcbnew_mock.ImportSpecctraSES.return_value = MagicMock()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_board():
|
||||||
|
"""Create a mock pcbnew.BOARD with minimal interface."""
|
||||||
|
board = MagicMock()
|
||||||
|
board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
|
||||||
|
board.GetTracks.return_value = []
|
||||||
|
return board
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cmds(mock_board):
|
||||||
|
"""FreeroutingCommands with a mock board."""
|
||||||
|
return FreeroutingCommands(board=mock_board)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def cmds_no_board():
|
||||||
|
"""FreeroutingCommands without a board."""
|
||||||
|
return FreeroutingCommands(board=None)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# check_freerouting
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckFreerouting:
|
||||||
|
def test_java_not_found(self, cmds):
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting._find_java", return_value=None
|
||||||
|
):
|
||||||
|
result = cmds.check_freerouting(
|
||||||
|
{"freeroutingJar": "/nonexistent.jar"}
|
||||||
|
)
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["java"]["found"] is False
|
||||||
|
assert result["freerouting"]["jar_found"] is False
|
||||||
|
assert result["ready"] is False
|
||||||
|
|
||||||
|
def test_java_found_no_jar(self, cmds):
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
||||||
|
), patch(
|
||||||
|
"commands.freerouting.subprocess.run"
|
||||||
|
) as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
stderr='openjdk version "17.0.1"', stdout=""
|
||||||
|
)
|
||||||
|
result = cmds.check_freerouting(
|
||||||
|
{"freeroutingJar": "/nonexistent.jar"}
|
||||||
|
)
|
||||||
|
assert result["java"]["found"] is True
|
||||||
|
assert result["java"]["path"] == "/usr/bin/java"
|
||||||
|
assert result["freerouting"]["jar_found"] is False
|
||||||
|
assert result["ready"] is False
|
||||||
|
|
||||||
|
def test_all_ready(self, cmds, tmp_path):
|
||||||
|
jar = tmp_path / "freerouting.jar"
|
||||||
|
jar.touch()
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
||||||
|
), patch(
|
||||||
|
"commands.freerouting.subprocess.run"
|
||||||
|
) as mock_run:
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
stderr='openjdk version "17.0.1"', stdout=""
|
||||||
|
)
|
||||||
|
result = cmds.check_freerouting(
|
||||||
|
{"freeroutingJar": str(jar)}
|
||||||
|
)
|
||||||
|
assert result["ready"] is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# export_dsn
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestExportDsn:
|
||||||
|
def test_no_board(self, cmds_no_board):
|
||||||
|
result = cmds_no_board.export_dsn({})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "No board" in result["message"]
|
||||||
|
|
||||||
|
def test_export_success(self, cmds, tmp_path):
|
||||||
|
board_path = str(tmp_path / "test.kicad_pcb")
|
||||||
|
dsn_path = str(tmp_path / "test.dsn")
|
||||||
|
cmds.board.GetFileName.return_value = board_path
|
||||||
|
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.return_value = True
|
||||||
|
# Simulate DSN file creation
|
||||||
|
Path(dsn_path).write_text("(pcb test)")
|
||||||
|
|
||||||
|
result = cmds.export_dsn({})
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["path"] == dsn_path
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.assert_called_once_with(
|
||||||
|
cmds.board, dsn_path
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_export_custom_path(self, cmds, tmp_path):
|
||||||
|
output = str(tmp_path / "custom.dsn")
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.return_value = True
|
||||||
|
Path(output).write_text("(pcb test)")
|
||||||
|
|
||||||
|
result = cmds.export_dsn({"outputPath": output})
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["path"] == output
|
||||||
|
|
||||||
|
def test_export_failure(self, cmds):
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error")
|
||||||
|
result = cmds.export_dsn({})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "DSN error" in result["errorDetails"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# import_ses
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestImportSes:
|
||||||
|
def test_no_board(self, cmds_no_board):
|
||||||
|
result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "No board" in result["message"]
|
||||||
|
|
||||||
|
def test_missing_ses_path(self, cmds):
|
||||||
|
result = cmds.import_ses({})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "Missing sesPath" in result["message"]
|
||||||
|
|
||||||
|
def test_ses_file_not_found(self, cmds):
|
||||||
|
result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "not found" in result["message"]
|
||||||
|
|
||||||
|
def test_import_success(self, cmds, tmp_path):
|
||||||
|
ses_file = tmp_path / "test.ses"
|
||||||
|
ses_file.write_text("(session test)")
|
||||||
|
|
||||||
|
pcbnew_mock.ImportSpecctraSES.return_value = True
|
||||||
|
cmds.board.GetTracks.return_value = []
|
||||||
|
|
||||||
|
result = cmds.import_ses({"sesPath": str(ses_file)})
|
||||||
|
assert result["success"] is True
|
||||||
|
pcbnew_mock.ImportSpecctraSES.assert_called_once_with(
|
||||||
|
cmds.board, str(ses_file)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_import_failure(self, cmds, tmp_path):
|
||||||
|
ses_file = tmp_path / "test.ses"
|
||||||
|
ses_file.write_text("(session test)")
|
||||||
|
|
||||||
|
pcbnew_mock.ImportSpecctraSES.side_effect = Exception("SES error")
|
||||||
|
result = cmds.import_ses({"sesPath": str(ses_file)})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "SES error" in result["errorDetails"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# autoroute (full pipeline)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestAutoroute:
|
||||||
|
def test_no_board(self, cmds_no_board):
|
||||||
|
result = cmds_no_board.autoroute({})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "No board" in result["message"]
|
||||||
|
|
||||||
|
def test_no_java(self, cmds):
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting._find_java", return_value=None
|
||||||
|
):
|
||||||
|
result = cmds.autoroute({})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "Java not found" in result["message"]
|
||||||
|
|
||||||
|
def test_no_jar(self, cmds):
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
||||||
|
):
|
||||||
|
result = cmds.autoroute(
|
||||||
|
{"freeroutingJar": "/nonexistent/freerouting.jar"}
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "JAR not found" in result["message"]
|
||||||
|
|
||||||
|
@patch("commands.freerouting.subprocess.run")
|
||||||
|
def test_dsn_export_fails(self, mock_run, cmds, tmp_path):
|
||||||
|
jar = tmp_path / "freerouting.jar"
|
||||||
|
jar.touch()
|
||||||
|
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception(
|
||||||
|
"export fail"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
||||||
|
):
|
||||||
|
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "DSN export failed" in result["message"]
|
||||||
|
|
||||||
|
@patch("commands.freerouting.subprocess.run")
|
||||||
|
def test_freerouting_timeout(self, mock_run, cmds, tmp_path):
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
jar = tmp_path / "freerouting.jar"
|
||||||
|
jar.touch()
|
||||||
|
board_dir = tmp_path / "project"
|
||||||
|
board_dir.mkdir()
|
||||||
|
board_file = board_dir / "test.kicad_pcb"
|
||||||
|
board_file.touch()
|
||||||
|
dsn_file = board_dir / "test.dsn"
|
||||||
|
|
||||||
|
cmds.board.GetFileName.return_value = str(board_file)
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
||||||
|
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
||||||
|
)
|
||||||
|
mock_run.side_effect = subprocess.TimeoutExpired(
|
||||||
|
cmd="", timeout=10
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
||||||
|
):
|
||||||
|
result = cmds.autoroute(
|
||||||
|
{"freeroutingJar": str(jar), "timeout": 10}
|
||||||
|
)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "timed out" in result["message"]
|
||||||
|
|
||||||
|
@patch("commands.freerouting.subprocess.run")
|
||||||
|
def test_full_success(self, mock_run, cmds, tmp_path):
|
||||||
|
jar = tmp_path / "freerouting.jar"
|
||||||
|
jar.touch()
|
||||||
|
board_dir = tmp_path / "project"
|
||||||
|
board_dir.mkdir()
|
||||||
|
board_file = board_dir / "test.kicad_pcb"
|
||||||
|
board_file.touch()
|
||||||
|
dsn_file = board_dir / "test.dsn"
|
||||||
|
ses_file = board_dir / "test.ses"
|
||||||
|
|
||||||
|
cmds.board.GetFileName.return_value = str(board_file)
|
||||||
|
|
||||||
|
# DSN export creates file
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
||||||
|
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
||||||
|
)
|
||||||
|
# Freerouting creates SES file
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=0, stdout="Routing completed", stderr=""
|
||||||
|
)
|
||||||
|
ses_file.write_text("(session)")
|
||||||
|
|
||||||
|
# SES import succeeds
|
||||||
|
pcbnew_mock.ImportSpecctraSES.return_value = True
|
||||||
|
|
||||||
|
# Board tracks after import
|
||||||
|
track = MagicMock()
|
||||||
|
track.GetClass.return_value = "PCB_TRACK"
|
||||||
|
via = MagicMock()
|
||||||
|
via.GetClass.return_value = "PCB_VIA"
|
||||||
|
cmds.board.GetTracks.return_value = [track, track, via]
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
||||||
|
):
|
||||||
|
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["board_stats"]["tracks"] == 2
|
||||||
|
assert result["board_stats"]["vias"] == 1
|
||||||
|
assert "elapsed_seconds" in result
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.assert_called_once()
|
||||||
|
pcbnew_mock.ImportSpecctraSES.assert_called_once()
|
||||||
|
|
||||||
|
@patch("commands.freerouting.subprocess.run")
|
||||||
|
def test_freerouting_nonzero_exit(
|
||||||
|
self, mock_run, cmds, tmp_path
|
||||||
|
):
|
||||||
|
jar = tmp_path / "freerouting.jar"
|
||||||
|
jar.touch()
|
||||||
|
board_dir = tmp_path / "project"
|
||||||
|
board_dir.mkdir()
|
||||||
|
board_file = board_dir / "test.kicad_pcb"
|
||||||
|
board_file.touch()
|
||||||
|
dsn_file = board_dir / "test.dsn"
|
||||||
|
|
||||||
|
cmds.board.GetFileName.return_value = str(board_file)
|
||||||
|
pcbnew_mock.ExportSpecctraDSN.side_effect = (
|
||||||
|
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
|
||||||
|
)
|
||||||
|
mock_run.return_value = MagicMock(
|
||||||
|
returncode=1, stdout="", stderr="OutOfMemoryError"
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting._find_java", return_value="/usr/bin/java"
|
||||||
|
):
|
||||||
|
result = cmds.autoroute({"freeroutingJar": str(jar)})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "exited with code 1" in result["message"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _find_java helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindJava:
|
||||||
|
def test_finds_via_which(self):
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting.shutil.which",
|
||||||
|
return_value="/usr/bin/java",
|
||||||
|
):
|
||||||
|
assert _find_java() == "/usr/bin/java"
|
||||||
|
|
||||||
|
def test_none_when_not_found(self):
|
||||||
|
with patch(
|
||||||
|
"commands.freerouting.shutil.which", return_value=None
|
||||||
|
), patch("os.path.isfile", return_value=False):
|
||||||
|
assert _find_java() is None
|
||||||
@@ -25,6 +25,7 @@ import { registerDatasheetTools } from "./tools/datasheet.js";
|
|||||||
import { registerFootprintTools } from "./tools/footprint.js";
|
import { registerFootprintTools } from "./tools/footprint.js";
|
||||||
import { registerSymbolCreatorTools } from "./tools/symbol-creator.js";
|
import { registerSymbolCreatorTools } from "./tools/symbol-creator.js";
|
||||||
import { registerUITools } from "./tools/ui.js";
|
import { registerUITools } from "./tools/ui.js";
|
||||||
|
import { registerFreeroutingTools } from "./tools/freerouting.js";
|
||||||
import { registerRouterTools } from "./tools/router.js";
|
import { registerRouterTools } from "./tools/router.js";
|
||||||
|
|
||||||
// Import resource registration functions
|
// Import resource registration functions
|
||||||
@@ -251,6 +252,7 @@ export class KiCADMcpServer {
|
|||||||
registerFootprintTools(this.server, this.callKicadScript.bind(this));
|
registerFootprintTools(this.server, this.callKicadScript.bind(this));
|
||||||
registerSymbolCreatorTools(this.server, this.callKicadScript.bind(this));
|
registerSymbolCreatorTools(this.server, this.callKicadScript.bind(this));
|
||||||
registerUITools(this.server, this.callKicadScript.bind(this));
|
registerUITools(this.server, this.callKicadScript.bind(this));
|
||||||
|
registerFreeroutingTools(this.server, this.callKicadScript.bind(this));
|
||||||
|
|
||||||
// Register all resources
|
// Register all resources
|
||||||
registerProjectResources(this.server, this.callKicadScript.bind(this));
|
registerProjectResources(this.server, this.callKicadScript.bind(this));
|
||||||
|
|||||||
124
src/tools/freerouting.ts
Normal file
124
src/tools/freerouting.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Freerouting autoroute tools for KiCAD MCP server
|
||||||
|
*
|
||||||
|
* Provides autorouting via Freerouting (Specctra DSN/SES workflow).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export function registerFreeroutingTools(
|
||||||
|
server: McpServer,
|
||||||
|
callKicadScript: Function,
|
||||||
|
) {
|
||||||
|
// Full autoroute: export DSN -> run Freerouting -> import SES
|
||||||
|
server.tool(
|
||||||
|
"autoroute",
|
||||||
|
"Run Freerouting autorouter on the current PCB. Exports to Specctra DSN, runs Freerouting CLI, and imports the routed SES result. Requires Java 11+ and freerouting.jar (see check_freerouting).",
|
||||||
|
{
|
||||||
|
boardPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Path to .kicad_pcb file (default: current board)"),
|
||||||
|
freeroutingJar: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"Path to freerouting.jar (default: ~/.kicad-mcp/freerouting.jar or FREEROUTING_JAR env)",
|
||||||
|
),
|
||||||
|
maxPasses: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe("Maximum routing passes (default: 20)"),
|
||||||
|
timeout: z
|
||||||
|
.number()
|
||||||
|
.optional()
|
||||||
|
.describe("Timeout in seconds (default: 300)"),
|
||||||
|
},
|
||||||
|
async (args: any) => {
|
||||||
|
const result = await callKicadScript("autoroute", args);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Export DSN only
|
||||||
|
server.tool(
|
||||||
|
"export_dsn",
|
||||||
|
"Export the current PCB to Specctra DSN format. Useful for manual Freerouting workflow or external autorouters.",
|
||||||
|
{
|
||||||
|
boardPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Path to .kicad_pcb file (default: current board)"),
|
||||||
|
outputPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Output DSN file path (default: same dir as board)"),
|
||||||
|
},
|
||||||
|
async (args: any) => {
|
||||||
|
const result = await callKicadScript("export_dsn", args);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Import SES
|
||||||
|
server.tool(
|
||||||
|
"import_ses",
|
||||||
|
"Import a Specctra SES (session) file into the current PCB. Use after running Freerouting externally.",
|
||||||
|
{
|
||||||
|
sesPath: z.string().describe("Path to the .ses file to import"),
|
||||||
|
boardPath: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Path to .kicad_pcb file (default: current board)"),
|
||||||
|
},
|
||||||
|
async (args: any) => {
|
||||||
|
const result = await callKicadScript("import_ses", args);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check Freerouting dependencies
|
||||||
|
server.tool(
|
||||||
|
"check_freerouting",
|
||||||
|
"Check if Java and Freerouting JAR are available on the system. Run this before autoroute to verify prerequisites.",
|
||||||
|
{
|
||||||
|
freeroutingJar: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe("Path to freerouting.jar to check"),
|
||||||
|
},
|
||||||
|
async (args: any) => {
|
||||||
|
const result = await callKicadScript("check_freerouting", args);
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result, null, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,3 +16,4 @@ export { registerUITools } from "./ui.js";
|
|||||||
export { registerDatasheetTools } from "./datasheet.js";
|
export { registerDatasheetTools } from "./datasheet.js";
|
||||||
export { registerFootprintTools } from "./footprint.js";
|
export { registerFootprintTools } from "./footprint.js";
|
||||||
export { registerSymbolCreatorTools } from "./symbol-creator.js";
|
export { registerSymbolCreatorTools } from "./symbol-creator.js";
|
||||||
|
export { registerFreeroutingTools } from "./freerouting.js";
|
||||||
|
|||||||
@@ -126,6 +126,16 @@ export const toolCategories: ToolCategory[] = [
|
|||||||
"add_via",
|
"add_via",
|
||||||
"add_copper_pour"
|
"add_copper_pour"
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "autoroute",
|
||||||
|
description: "Freerouting autorouter: automatic PCB routing via Specctra DSN/SES",
|
||||||
|
tools: [
|
||||||
|
"autoroute",
|
||||||
|
"export_dsn",
|
||||||
|
"import_ses",
|
||||||
|
"check_freerouting"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user