Add type annotations to all previously untyped functions and remove 9 suppressed error codes (call-arg, assignment, return-value, operator, has-type, dict-item, misc, list-item, annotation-unchecked) by fixing the underlying type issues. Add [[tool.mypy.overrides]] with ignore_missing_imports for KiCAD-specific modules (pcbnew, sexpdata, skip, cairosvg, kipy, PIL) so the pre-commit mypy hook passes in its isolated venv. Add types-requests and pytest to additional_dependencies in .pre-commit-config.yaml. Also fixes several real bugs uncovered by stricter checks: incorrect static calls to instance methods in swig_backend, wrong return type on get_size, missing value param in BoardAPI.place_component, variable shadowing in kicad_process.py, unqualified LibraryManager reference in kicad_interface, and missing top-level Path import. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
546 lines
17 KiB
Python
546 lines
17 KiB
Python
"""
|
|
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 logging
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, 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"),
|
|
)
|
|
|
|
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()
|
|
if docker_exe is None:
|
|
raise RuntimeError("Docker/Podman executable not found")
|
|
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()
|
|
if java_exe is None:
|
|
raise RuntimeError("Java executable not found")
|
|
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: Any = None) -> 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,
|
|
}
|