diff --git a/README.md b/README.md index 7d9e161..6c5fb35 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/python/commands/freerouting.py b/python/commands/freerouting.py new file mode 100644 index 0000000..a3b2268 --- /dev/null +++ b/python/commands/freerouting.py @@ -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, + } diff --git a/python/kicad_interface.py b/python/kicad_interface.py index fcdeaa9..e99654b 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -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): diff --git a/python/tests/test_freerouting.py b/python/tests/test_freerouting.py new file mode 100644 index 0000000..c338c80 --- /dev/null +++ b/python/tests/test_freerouting.py @@ -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 diff --git a/src/server.ts b/src/server.ts index 4596693..4a24c4f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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)); diff --git a/src/tools/freerouting.ts b/src/tools/freerouting.ts new file mode 100644 index 0000000..f17d7ce --- /dev/null +++ b/src/tools/freerouting.ts @@ -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), + }, + ], + }; + }, + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 62b6d91..fae6416 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -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"; diff --git a/src/tools/registry.ts b/src/tools/registry.ts index ba5602d..bca2cf1 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -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" + ] } ];