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:
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user