Merge pull request #68 from jflaflamme/feature/freerouting-autoroute

Add Freerouting autoroute integration
This commit is contained in:
mixelpixx
2026-03-20 09:11:57 -04:00
committed by GitHub
8 changed files with 1324 additions and 0 deletions

View File

@@ -599,6 +599,51 @@ 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.
```
### Autoroute with Freerouting
Automatically route all unconnected nets using the [Freerouting](https://github.com/freerouting/freerouting) autorouter.
**Setup (one-time):**
```bash
# 1. Download the Freerouting JAR
mkdir -p ~/.kicad-mcp
curl -L -o ~/.kicad-mcp/freerouting.jar \
https://github.com/freerouting/freerouting/releases/download/v2.0.1/freerouting-2.0.1-executable.jar
# 2. Runtime — pick ONE:
# Option A: Docker (recommended, no Java install needed)
docker pull eclipse-temurin:21-jre
# Option B: Install Java 21+ locally
# (Ubuntu/Debian) sudo apt install openjdk-21-jre
```
The autorouter auto-detects which runtime is available (Java 21+ direct, or Docker/Podman fallback).
```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
```text

View File

@@ -0,0 +1,593 @@
"""
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.
Supports two execution modes:
- Direct: java -jar freerouting.jar (requires Java 21+)
- Docker: docker run eclipse-temurin:21-jre (requires Docker)
"""
import os
import subprocess
import shutil
import time
import logging
from pathlib import Path
from typing import Dict, Any, Optional, List
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"
),
)
DOCKER_IMAGE = "eclipse-temurin:21-jre"
def _find_java() -> Optional[str]:
"""Find java executable on the system."""
java = shutil.which("java")
if java:
return java
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
def _find_docker() -> Optional[str]:
"""Find docker executable on the system."""
return shutil.which("docker") or shutil.which("podman")
def _docker_available() -> bool:
"""Check if Docker/Podman is available and running."""
docker = _find_docker()
if not docker:
return False
try:
proc = subprocess.run(
[docker, "info"],
capture_output=True,
timeout=10,
)
return proc.returncode == 0
except Exception:
return False
def _java_version_ok(java_exe: str) -> bool:
"""Check if local Java is version 21+."""
try:
proc = subprocess.run(
[java_exe, "-version"],
capture_output=True,
text=True,
timeout=10,
)
output = proc.stderr or proc.stdout
# Parse version like: openjdk version "17.0.18"
for line in output.split("\n"):
if "version" in line:
ver = line.split('"')[1] if '"' in line else ""
major = int(ver.split(".")[0])
return major >= 21
except Exception:
pass
return False
def _build_freerouting_cmd(
jar_path: str,
dsn_path: str,
ses_path: str,
passes: int,
use_docker: bool,
) -> List[str]:
"""Build the command to run Freerouting."""
if use_docker:
docker_exe = _find_docker()
board_dir = os.path.dirname(dsn_path)
dsn_name = os.path.basename(dsn_path)
ses_name = os.path.basename(ses_path)
jar_name = os.path.basename(jar_path)
return [
docker_exe, "run", "--rm",
"-v", f"{jar_path}:/app/{jar_name}:ro",
"-v", f"{board_dir}:/work",
DOCKER_IMAGE,
"java", "-jar", f"/app/{jar_name}",
"-de", f"/work/{dsn_name}",
"-do", f"/work/{ses_name}",
"-mp", str(passes),
]
else:
java_exe = _find_java()
return [
java_exe, "-jar", jar_path,
"-de", dsn_path, "-do", ses_path,
"-mp", str(passes),
]
class FreeroutingCommands:
"""Handles Freerouting autoroute operations."""
def __init__(self, board=None):
self.board = board
def _resolve_execution_mode(
self, jar_path: str
) -> Dict[str, Any]:
"""Determine how to run Freerouting: direct or docker.
Returns dict with 'mode', 'use_docker', or 'error'.
"""
java_exe = _find_java()
if java_exe and _java_version_ok(java_exe):
return {"mode": "direct", "use_docker": False}
if _docker_available():
return {"mode": "docker", "use_docker": True}
if java_exe:
return {
"mode": "error",
"error": (
f"Java found at {java_exe} but version < 21. "
"Freerouting 2.x requires Java 21+. "
"Install Java 21+ or Docker."
),
}
return {
"mode": "error",
"error": (
"Neither Java 21+ nor Docker found. "
"Install one of them to use Freerouting."
),
}
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", 20)
# 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."
),
}
# Determine execution mode
exec_mode = self._resolve_execution_mode(jar_path)
if exec_mode["mode"] == "error":
return {
"success": False,
"message": "No suitable Java runtime",
"errorDetails": exec_mode["error"],
}
use_docker = exec_mode["use_docker"]
# 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 = _build_freerouting_cmd(
jar_path, dsn_path, ses_path, passes, use_docker
)
mode_label = "docker" if use_docker else "direct"
logger.info(
f"Running Freerouting ({mode_label}): {' '.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 "
f"{proc.returncode}"
),
"errorDetails": proc.stderr or proc.stdout,
"elapsed_seconds": elapsed,
"mode": mode_label,
}
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}. "
f"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",
"mode": mode_label,
"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."""
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 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),
}
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/Docker are available."""
jar_path = params.get(
"freeroutingJar", DEFAULT_FREEROUTING_JAR
)
# Check local Java
java_exe = _find_java()
java_version = None
java_21_ok = False
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]
)
java_21_ok = _java_version_ok(java_exe)
except Exception:
pass
# Check Docker/Podman
docker_exe = _find_docker()
has_docker = _docker_available()
jar_exists = os.path.isfile(jar_path)
ready = jar_exists and (java_21_ok or has_docker)
mode = "none"
if java_21_ok:
mode = "direct"
elif has_docker:
mode = "docker"
return {
"success": True,
"message": "Freerouting dependency check",
"java": {
"found": java_exe is not None,
"path": java_exe,
"version": java_version,
"java_21_ok": java_21_ok,
},
"docker": {
"available": has_docker,
"path": docker_exe,
"image": DOCKER_IMAGE,
},
"freerouting": {
"jar_found": jar_exists,
"jar_path": jar_path,
},
"execution_mode": mode,
"ready": ready,
}

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,541 @@
"""
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, direct + docker)
- Error handling: missing board, no runtime, missing JAR, timeouts
- _find_java, _docker_available, _java_version_ok helpers
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from commands.freerouting import (
FreeroutingCommands,
_find_java,
_find_docker,
_docker_available,
_java_version_ok,
)
# pcbnew mock from conftest
pcbnew_mock = sys.modules["pcbnew"]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_pcbnew_mock():
"""Reset pcbnew mock before each test."""
pcbnew_mock.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():
board = MagicMock()
board.GetFileName.return_value = (
"/tmp/test_project/test.kicad_pcb"
)
board.GetTracks.return_value = []
return board
@pytest.fixture
def cmds(mock_board):
return FreeroutingCommands(board=mock_board)
@pytest.fixture
def cmds_no_board():
return FreeroutingCommands(board=None)
def _patch_direct_java():
"""Patch to simulate Java 21+ available locally."""
return patch.object(
FreeroutingCommands,
"_resolve_execution_mode",
return_value={"mode": "direct", "use_docker": False},
)
def _patch_docker_mode():
"""Patch to simulate Docker execution mode."""
return patch.object(
FreeroutingCommands,
"_resolve_execution_mode",
return_value={"mode": "docker", "use_docker": True},
)
def _patch_no_runtime():
"""Patch to simulate no Java and no Docker."""
return patch.object(
FreeroutingCommands,
"_resolve_execution_mode",
return_value={
"mode": "error",
"error": "Neither Java 21+ nor Docker found.",
},
)
# ---------------------------------------------------------------------------
# check_freerouting
# ---------------------------------------------------------------------------
class TestCheckFreerouting:
def test_no_java_no_docker(self, cmds):
with patch(
"commands.freerouting._find_java", return_value=None
), patch(
"commands.freerouting._docker_available",
return_value=False,
):
result = cmds.check_freerouting(
{"freeroutingJar": "/nonexistent.jar"}
)
assert result["success"] is True
assert result["java"]["found"] is False
assert result["docker"]["available"] is False
assert result["ready"] is False
assert result["execution_mode"] == "none"
def test_java_too_old_docker_available(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._java_version_ok",
return_value=False,
), patch(
"commands.freerouting._docker_available",
return_value=True,
), 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
assert result["execution_mode"] == "docker"
def test_java_21_direct(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._java_version_ok",
return_value=True,
), patch(
"commands.freerouting._docker_available",
return_value=False,
), patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "21.0.1"', stdout=""
)
result = cmds.check_freerouting(
{"freeroutingJar": str(jar)}
)
assert result["ready"] is True
assert result["execution_mode"] == "direct"
# ---------------------------------------------------------------------------
# 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
Path(dsn_path).write_text("(pcb test)")
result = cmds.export_dsn({})
assert result["success"] is True
assert result["path"] == 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
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_runtime(self, cmds, tmp_path):
jar = tmp_path / "freerouting.jar"
jar.touch()
with _patch_no_runtime():
result = cmds.autoroute(
{"freeroutingJar": str(jar)}
)
assert result["success"] is False
assert "No suitable Java runtime" in result["message"]
def test_no_jar(self, cmds):
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_direct_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_direct_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_direct(
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)
pcbnew_mock.ExportSpecctraDSN.side_effect = (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
)
mock_run.return_value = MagicMock(
returncode=0, stdout="Routing completed", stderr=""
)
ses_file.write_text("(session)")
pcbnew_mock.ImportSpecctraSES.return_value = True
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_direct_java():
result = cmds.autoroute(
{"freeroutingJar": str(jar)}
)
assert result["success"] is True
assert result["mode"] == "direct"
assert result["board_stats"]["tracks"] == 2
assert result["board_stats"]["vias"] == 1
assert "elapsed_seconds" in result
@patch("commands.freerouting.subprocess.run")
def test_full_success_docker(
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)
pcbnew_mock.ExportSpecctraDSN.side_effect = (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
)
mock_run.return_value = MagicMock(
returncode=0, stdout="Routing completed", stderr=""
)
ses_file.write_text("(session)")
pcbnew_mock.ImportSpecctraSES.return_value = True
cmds.board.GetTracks.return_value = [MagicMock()]
with _patch_docker_mode(), patch(
"commands.freerouting._find_docker",
return_value="/usr/bin/docker",
):
result = cmds.autoroute(
{"freeroutingJar": str(jar)}
)
assert result["success"] is True
assert result["mode"] == "docker"
# Verify a container runtime was invoked
call_args = mock_run.call_args[0][0]
assert "run" in call_args
assert "--rm" in call_args
@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_direct_java():
result = cmds.autoroute(
{"freeroutingJar": str(jar)}
)
assert result["success"] is False
assert "exited with code 1" in result["message"]
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
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
class TestFindDocker:
def test_finds_docker(self):
with patch(
"commands.freerouting.shutil.which",
side_effect=lambda x: "/usr/bin/docker"
if x == "docker"
else None,
):
assert _find_docker() == "/usr/bin/docker"
def test_finds_podman(self):
with patch(
"commands.freerouting.shutil.which",
side_effect=lambda x: "/usr/bin/podman"
if x == "podman"
else None,
):
assert _find_docker() == "/usr/bin/podman"
def test_none_when_not_found(self):
with patch(
"commands.freerouting.shutil.which",
return_value=None,
):
assert _find_docker() is None
class TestDockerAvailable:
def test_docker_found(self):
with patch(
"commands.freerouting._find_docker",
return_value="/usr/bin/docker",
), patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(returncode=0)
assert _docker_available() is True
def test_docker_not_installed(self):
with patch(
"commands.freerouting._find_docker",
return_value=None,
):
assert _docker_available() is False
def test_docker_not_running(self):
with patch(
"commands.freerouting._find_docker",
return_value="/usr/bin/docker",
), patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(returncode=1)
assert _docker_available() is False
class TestJavaVersionOk:
def test_java_21(self):
with patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "21.0.1"', stdout=""
)
assert _java_version_ok("/usr/bin/java") is True
def test_java_17(self):
with patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "17.0.18"', stdout=""
)
assert _java_version_ok("/usr/bin/java") is False
def test_java_error(self):
with patch(
"commands.freerouting.subprocess.run",
side_effect=Exception("not found"),
):
assert _java_version_ok("/usr/bin/java") is False

View File

@@ -25,6 +25,7 @@ import { registerDatasheetTools } from "./tools/datasheet.js";
import { registerFootprintTools } from "./tools/footprint.js";
import { registerSymbolCreatorTools } from "./tools/symbol-creator.js";
import { registerUITools } from "./tools/ui.js";
import { registerFreeroutingTools } from "./tools/freerouting.js";
import { registerRouterTools } from "./tools/router.js";
// Import resource registration functions
@@ -251,6 +252,7 @@ export class KiCADMcpServer {
registerFootprintTools(this.server, this.callKicadScript.bind(this));
registerSymbolCreatorTools(this.server, this.callKicadScript.bind(this));
registerUITools(this.server, this.callKicadScript.bind(this));
registerFreeroutingTools(this.server, this.callKicadScript.bind(this));
// Register all resources
registerProjectResources(this.server, this.callKicadScript.bind(this));

124
src/tools/freerouting.ts Normal file
View 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),
},
],
};
},
);
}

View File

@@ -16,3 +16,4 @@ export { registerUITools } from "./ui.js";
export { registerDatasheetTools } from "./datasheet.js";
export { registerFootprintTools } from "./footprint.js";
export { registerSymbolCreatorTools } from "./symbol-creator.js";
export { registerFreeroutingTools } from "./freerouting.js";

View File

@@ -126,6 +126,16 @@ export const toolCategories: ToolCategory[] = [
"add_via",
"add_copper_pour"
]
},
{
name: "autoroute",
description: "Freerouting autorouter: automatic PCB routing via Specctra DSN/SES",
tools: [
"autoroute",
"export_dsn",
"import_ses",
"check_freerouting"
]
}
];