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:
Jeff Laflamme
2026-03-20 11:33:37 +07:00
parent 5b380c0f17
commit 53a8b3ff3e
8 changed files with 941 additions and 0 deletions

View 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,
}

View File

@@ -226,6 +226,7 @@ try:
from commands.datasheet_manager import DatasheetManager
from commands.footprint import FootprintCreator
from commands.symbol_creator import SymbolCreator
from commands.freerouting import FreeroutingCommands
logger.info("Successfully imported all command handlers")
except ImportError as e:
@@ -270,6 +271,7 @@ class KiCADInterface:
self.board_commands = BoardCommands(self.board)
self.component_commands = ComponentCommands(self.board, self.footprint_library)
self.routing_commands = RoutingCommands(self.board)
self.freerouting_commands = FreeroutingCommands(self.board)
self.design_rule_commands = DesignRuleCommands(self.board)
self.export_commands = ExportCommands(self.board)
self.library_commands = LibraryCommands(self.footprint_library)
@@ -419,6 +421,11 @@ class KiCADInterface:
"delete_symbol": self._handle_delete_symbol,
"list_symbols_in_library": self._handle_list_symbols_in_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(
@@ -578,6 +585,7 @@ class KiCADInterface:
self.routing_commands.board = self.board
self.design_rule_commands.board = self.board
self.export_commands.board = self.board
self.freerouting_commands.board = self.board
# Schematic command handlers
def _handle_create_schematic(self, params):

View 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