From 53a8b3ff3ed0988d297cf8aad4a97792d535a011 Mon Sep 17 00:00:00 2001 From: Jeff Laflamme Date: Fri, 20 Mar 2026 11:33:37 +0700 Subject: [PATCH] Add Freerouting autoroute integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 new MCP tools: autoroute (full DSN→Freerouting→SES pipeline), export_dsn, import_ses, check_freerouting. Requires Java 11+ and freerouting.jar. Includes 21 test cases and README usage examples. --- README.md | 29 +++ python/commands/freerouting.py | 400 +++++++++++++++++++++++++++++++ python/kicad_interface.py | 8 + python/tests/test_freerouting.py | 367 ++++++++++++++++++++++++++++ src/server.ts | 2 + src/tools/freerouting.ts | 124 ++++++++++ src/tools/index.ts | 1 + src/tools/registry.ts | 10 + 8 files changed, 941 insertions(+) create mode 100644 python/commands/freerouting.py create mode 100644 python/tests/test_freerouting.py create mode 100644 src/tools/freerouting.ts diff --git a/README.md b/README.md index 7d9e161..53ea8b2 100644 --- a/README.md +++ b/README.md @@ -599,6 +599,35 @@ 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. + +**Prerequisites:** Java 11+ and `freerouting.jar` (place at `~/.kicad-mcp/freerouting.jar` or set `FREEROUTING_JAR` env var). + +```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..079cf96 --- /dev/null +++ b/python/commands/freerouting.py @@ -0,0 +1,400 @@ +""" +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. +""" + +import os +import subprocess +import shutil +import time +import logging +from pathlib import Path +from typing import Dict, Any, 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"), +) + + +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", + os.path.expandvars("$JAVA_HOME/bin/java"), + ]: + if os.path.isfile(candidate): + return candidate + return None + + +class FreeroutingCommands: + """Handles Freerouting autoroute operations.""" + + def __init__(self, board=None): + self.board = board + + 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") + + # 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." + ), + } + + # 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." + ), + } + + # 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 = [ + java_exe, "-jar", jar_path, + "-de", dsn_path, "-do", ses_path, + "-mp", str(passes or 20), + ] + + logger.info(f"Running Freerouting: {' '.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 {proc.returncode}", + "errorDetails": proc.stderr or proc.stdout, + "elapsed_seconds": elapsed, + } + 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}. 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", + "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 (no routing).""" + 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 file 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), + } + + # Save after import + 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 are available.""" + jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR) + + java_exe = _find_java() + java_version = None + 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] + except Exception: + pass + + jar_exists = os.path.isfile(jar_path) + + return { + "success": True, + "message": "Freerouting dependency check", + "java": { + "found": java_exe is not None, + "path": java_exe, + "version": java_version, + }, + "freerouting": { + "jar_found": jar_exists, + "jar_path": jar_path, + }, + "ready": java_exe is not None and jar_exists, + } 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..110d338 --- /dev/null +++ b/python/tests/test_freerouting.py @@ -0,0 +1,367 @@ +""" +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) + - Error handling: missing board, missing java, missing JAR, timeouts +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from commands.freerouting import FreeroutingCommands, _find_java + +# Ensure the pcbnew mock from conftest is available at module level +# so methods that do `import pcbnew` get the mock. +pcbnew_mock = sys.modules["pcbnew"] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def reset_pcbnew_mock(): + """Reset pcbnew mock before each test.""" + pcbnew_mock.reset_mock() + # Clear side_effects that persist through 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(): + """Create a mock pcbnew.BOARD with minimal interface.""" + board = MagicMock() + board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb" + board.GetTracks.return_value = [] + return board + + +@pytest.fixture +def cmds(mock_board): + """FreeroutingCommands with a mock board.""" + return FreeroutingCommands(board=mock_board) + + +@pytest.fixture +def cmds_no_board(): + """FreeroutingCommands without a board.""" + return FreeroutingCommands(board=None) + + +# --------------------------------------------------------------------------- +# check_freerouting +# --------------------------------------------------------------------------- + + +class TestCheckFreerouting: + def test_java_not_found(self, cmds): + with patch( + "commands.freerouting._find_java", return_value=None + ): + result = cmds.check_freerouting( + {"freeroutingJar": "/nonexistent.jar"} + ) + assert result["success"] is True + assert result["java"]["found"] is False + assert result["freerouting"]["jar_found"] is False + assert result["ready"] is False + + def test_java_found_no_jar(self, cmds): + with patch( + "commands.freerouting._find_java", return_value="/usr/bin/java" + ), 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": "/nonexistent.jar"} + ) + assert result["java"]["found"] is True + assert result["java"]["path"] == "/usr/bin/java" + assert result["freerouting"]["jar_found"] is False + assert result["ready"] is False + + def test_all_ready(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.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 + + +# --------------------------------------------------------------------------- +# 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 + # Simulate DSN file creation + Path(dsn_path).write_text("(pcb test)") + + result = cmds.export_dsn({}) + assert result["success"] is True + assert result["path"] == dsn_path + pcbnew_mock.ExportSpecctraDSN.assert_called_once_with( + cmds.board, 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 + pcbnew_mock.ImportSpecctraSES.assert_called_once_with( + cmds.board, str(ses_file) + ) + + 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_java(self, cmds): + with patch( + "commands.freerouting._find_java", return_value=None + ): + result = cmds.autoroute({}) + assert result["success"] is False + assert "Java not found" in result["message"] + + def test_no_jar(self, cmds): + with patch( + "commands.freerouting._find_java", return_value="/usr/bin/java" + ): + 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( + "commands.freerouting._find_java", return_value="/usr/bin/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( + "commands.freerouting._find_java", return_value="/usr/bin/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(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) + + # DSN export creates file + pcbnew_mock.ExportSpecctraDSN.side_effect = ( + lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] + ) + # Freerouting creates SES file + mock_run.return_value = MagicMock( + returncode=0, stdout="Routing completed", stderr="" + ) + ses_file.write_text("(session)") + + # SES import succeeds + pcbnew_mock.ImportSpecctraSES.return_value = True + + # Board tracks after import + 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( + "commands.freerouting._find_java", return_value="/usr/bin/java" + ): + result = cmds.autoroute({"freeroutingJar": str(jar)}) + + assert result["success"] is True + assert result["board_stats"]["tracks"] == 2 + assert result["board_stats"]["vias"] == 1 + assert "elapsed_seconds" in result + pcbnew_mock.ExportSpecctraDSN.assert_called_once() + pcbnew_mock.ImportSpecctraSES.assert_called_once() + + @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( + "commands.freerouting._find_java", return_value="/usr/bin/java" + ): + result = cmds.autoroute({"freeroutingJar": str(jar)}) + + assert result["success"] is False + assert "exited with code 1" in result["message"] + + +# --------------------------------------------------------------------------- +# _find_java helper +# --------------------------------------------------------------------------- + + +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 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" + ] } ];