Add Docker/Podman support for Freerouting autorouter

Auto-detects runtime: Java 21+ direct or Docker/Podman fallback.
Discovers docker/podman via PATH instead of hardcoding.
README includes one-time setup with JAR download + Docker pull.
31 tests covering both execution modes.
This commit is contained in:
Jeff Laflamme
2026-03-20 12:01:19 +07:00
parent 53a8b3ff3e
commit 7f1b072d00
3 changed files with 518 additions and 135 deletions

View File

@@ -3,6 +3,10 @@ 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
@@ -11,23 +15,26 @@ import shutil
import time
import logging
from pathlib import Path
from typing import Dict, Any, Optional
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"),
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
# Check common locations
for candidate in [
"/usr/bin/java",
"/usr/local/bin/java",
@@ -38,12 +45,118 @@ def _find_java() -> Optional[str]:
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.
@@ -77,25 +190,17 @@ class FreeroutingCommands:
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."
"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 {
@@ -103,11 +208,22 @@ class FreeroutingCommands:
"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."
"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
@@ -122,7 +238,9 @@ class FreeroutingCommands:
return {
"success": False,
"message": "DSN export failed",
"errorDetails": f"ExportSpecctraDSN returned: {result}",
"errorDetails": (
f"ExportSpecctraDSN returned: {result}"
),
}
except Exception as e:
return {
@@ -142,13 +260,14 @@ class FreeroutingCommands:
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),
]
cmd = _build_freerouting_cmd(
jar_path, dsn_path, ses_path, passes, use_docker
)
logger.info(f"Running Freerouting: {' '.join(cmd)}")
mode_label = "docker" if use_docker else "direct"
logger.info(
f"Running Freerouting ({mode_label}): {' '.join(cmd)}"
)
start_time = time.time()
try:
@@ -164,15 +283,23 @@ class FreeroutingCommands:
if proc.returncode != 0:
return {
"success": False,
"message": f"Freerouting exited with code {proc.returncode}",
"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",
"message": (
f"Freerouting timed out after {timeout}s"
),
"errorDetails": (
"Increase timeout or reduce board complexity"
),
}
except Exception as e:
return {
@@ -186,7 +313,10 @@ class FreeroutingCommands:
return {
"success": False,
"message": "Freerouting did not produce SES output",
"errorDetails": f"Expected at: {ses_path}. Stdout: {proc.stdout[:500]}",
"errorDetails": (
f"Expected at: {ses_path}. "
f"Stdout: {proc.stdout[:500]}"
),
"elapsed_seconds": elapsed,
}
@@ -201,7 +331,9 @@ class FreeroutingCommands:
return {
"success": False,
"message": "SES import failed",
"errorDetails": f"ImportSpecctraSES returned: {result}",
"errorDetails": (
f"ImportSpecctraSES returned: {result}"
),
"elapsed_seconds": elapsed,
}
except Exception as e:
@@ -216,7 +348,9 @@ class FreeroutingCommands:
try:
self.board.Save(board_path)
except Exception as e:
logger.warning(f"Board save after autoroute failed: {e}")
logger.warning(
f"Board save after autoroute failed: {e}"
)
# Collect stats
tracks = self.board.GetTracks()
@@ -231,6 +365,7 @@ class FreeroutingCommands:
return {
"success": True,
"message": f"Autoroute completed in {elapsed}s",
"mode": mode_label,
"dsn_path": dsn_path,
"ses_path": ses_path,
"elapsed_seconds": elapsed,
@@ -238,11 +373,13 @@ class FreeroutingCommands:
"tracks": track_count,
"vias": via_count,
},
"freerouting_stdout": proc.stdout[:1000] if proc.stdout else "",
"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)."""
"""Export the board to Specctra DSN format only."""
try:
import pcbnew
except ImportError:
@@ -259,26 +396,36 @@ class FreeroutingCommands:
"errorDetails": "Load or create a board first",
}
board_path = params.get("boardPath") or self.board.GetFileName()
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"
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",
"errorDetails": (
"Provide outputPath or have a board open"
),
}
try:
result = pcbnew.ExportSpecctraDSN(self.board, output_path)
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}",
"errorDetails": (
f"ExportSpecctraDSN returned: {result}"
),
}
except Exception as e:
return {
@@ -287,7 +434,11 @@ class FreeroutingCommands:
"errorDetails": str(e),
}
file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0
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}",
@@ -318,7 +469,9 @@ class FreeroutingCommands:
return {
"success": False,
"message": "Missing sesPath parameter",
"errorDetails": "Provide the path to the .ses file",
"errorDetails": (
"Provide the path to the .ses file"
),
}
if not os.path.isfile(ses_path):
@@ -329,12 +482,16 @@ class FreeroutingCommands:
}
try:
result = pcbnew.ImportSpecctraSES(self.board, ses_path)
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}",
"errorDetails": (
f"ImportSpecctraSES returned: {result}"
),
}
except Exception as e:
return {
@@ -343,17 +500,24 @@ class FreeroutingCommands:
"errorDetails": str(e),
}
# Save after import
board_path = params.get("boardPath") or self.board.GetFileName()
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}")
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")
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,
@@ -364,12 +528,18 @@ class FreeroutingCommands:
},
}
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)
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(
@@ -378,11 +548,27 @@ class FreeroutingCommands:
text=True,
timeout=10,
)
java_version = (proc.stderr or proc.stdout).strip().split("\n")[0]
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,
@@ -391,10 +577,17 @@ class FreeroutingCommands:
"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,
},
"ready": java_exe is not None and jar_exists,
"execution_mode": mode,
"ready": ready,
}