From 53a8b3ff3ed0988d297cf8aad4a97792d535a011 Mon Sep 17 00:00:00 2001 From: Jeff Laflamme Date: Fri, 20 Mar 2026 11:33:37 +0700 Subject: [PATCH 1/2] 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" + ] } ]; From 7f1b072d00620ef34c43643b94fd1be56a13b814 Mon Sep 17 00:00:00 2001 From: Jeff Laflamme Date: Fri, 20 Mar 2026 12:01:19 +0700 Subject: [PATCH 2/2] 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. --- README.md | 18 +- python/commands/freerouting.py | 301 +++++++++++++++++++++++----- python/tests/test_freerouting.py | 334 +++++++++++++++++++++++-------- 3 files changed, 518 insertions(+), 135 deletions(-) diff --git a/README.md b/README.md index 53ea8b2..6c5fb35 100644 --- a/README.md +++ b/README.md @@ -603,7 +603,23 @@ Create a differential pair for USB_P and USB_N with 0.2mm width and 0.15mm gap. 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). +**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. diff --git a/python/commands/freerouting.py b/python/commands/freerouting.py index 079cf96..a3b2268 100644 --- a/python/commands/freerouting.py +++ b/python/commands/freerouting.py @@ -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, } diff --git a/python/tests/test_freerouting.py b/python/tests/test_freerouting.py index 110d338..c338c80 100644 --- a/python/tests/test_freerouting.py +++ b/python/tests/test_freerouting.py @@ -5,8 +5,9 @@ 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 + - 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 @@ -15,10 +16,15 @@ from unittest.mock import MagicMock, patch import pytest -from commands.freerouting import FreeroutingCommands, _find_java +from commands.freerouting import ( + FreeroutingCommands, + _find_java, + _find_docker, + _docker_available, + _java_version_ok, +) -# Ensure the pcbnew mock from conftest is available at module level -# so methods that do `import pcbnew` get the mock. +# pcbnew mock from conftest pcbnew_mock = sys.modules["pcbnew"] @@ -31,7 +37,6 @@ pcbnew_mock = sys.modules["pcbnew"] 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 @@ -41,65 +46,88 @@ def reset_pcbnew_mock(): @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.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) +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_java_not_found(self, cmds): + 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["freerouting"]["jar_found"] is False + assert result["docker"]["available"] is False assert result["ready"] is False + assert result["execution_mode"] == "none" - 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): + 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" + "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: @@ -110,6 +138,31 @@ class TestCheckFreerouting: {"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" # --------------------------------------------------------------------------- @@ -129,15 +182,11 @@ class TestExportDsn: 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") @@ -149,7 +198,9 @@ class TestExportDsn: assert result["path"] == output def test_export_failure(self, cmds): - pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error") + pcbnew_mock.ExportSpecctraDSN.side_effect = Exception( + "DSN error" + ) result = cmds.export_dsn({}) assert result["success"] is False assert "DSN error" in result["errorDetails"] @@ -162,7 +213,9 @@ class TestExportDsn: class TestImportSes: def test_no_board(self, cmds_no_board): - result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"}) + result = cmds_no_board.import_ses( + {"sesPath": "/tmp/test.ses"} + ) assert result["success"] is False assert "No board" in result["message"] @@ -172,7 +225,9 @@ class TestImportSes: assert "Missing sesPath" in result["message"] def test_ses_file_not_found(self, cmds): - result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"}) + result = cmds.import_ses( + {"sesPath": "/nonexistent/test.ses"} + ) assert result["success"] is False assert "not found" in result["message"] @@ -185,15 +240,14 @@ class TestImportSes: 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") + 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"] @@ -210,21 +264,20 @@ class TestAutoroute: 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({}) + 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 "Java not found" in result["message"] + assert "No suitable Java runtime" 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"} - ) + result = cmds.autoroute( + {"freeroutingJar": "/nonexistent/freerouting.jar"} + ) assert result["success"] is False assert "JAR not found" in result["message"] @@ -237,15 +290,17 @@ class TestAutoroute: "export fail" ) - with patch( - "commands.freerouting._find_java", return_value="/usr/bin/java" - ): - result = cmds.autoroute({"freeroutingJar": str(jar)}) + 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): + def test_freerouting_timeout( + self, mock_run, cmds, tmp_path + ): import subprocess jar = tmp_path / "freerouting.jar" @@ -264,9 +319,7 @@ class TestAutoroute: cmd="", timeout=10 ) - with patch( - "commands.freerouting._find_java", return_value="/usr/bin/java" - ): + with _patch_direct_java(): result = cmds.autoroute( {"freeroutingJar": str(jar), "timeout": 10} ) @@ -274,7 +327,9 @@ class TestAutoroute: assert "timed out" in result["message"] @patch("commands.freerouting.subprocess.run") - def test_full_success(self, mock_run, cmds, tmp_path): + def test_full_success_direct( + self, mock_run, cmds, tmp_path + ): jar = tmp_path / "freerouting.jar" jar.touch() board_dir = tmp_path / "project" @@ -286,37 +341,72 @@ class TestAutoroute: 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)}) + 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 - pcbnew_mock.ExportSpecctraDSN.assert_called_once() - pcbnew_mock.ImportSpecctraSES.assert_called_once() + + @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( @@ -338,17 +428,17 @@ class TestAutoroute: returncode=1, stdout="", stderr="OutOfMemoryError" ) - with patch( - "commands.freerouting._find_java", return_value="/usr/bin/java" - ): - result = cmds.autoroute({"freeroutingJar": str(jar)}) + with _patch_direct_java(): + result = cmds.autoroute( + {"freeroutingJar": str(jar)} + ) assert result["success"] is False assert "exited with code 1" in result["message"] # --------------------------------------------------------------------------- -# _find_java helper +# Helper functions # --------------------------------------------------------------------------- @@ -362,6 +452,90 @@ class TestFindJava: def test_none_when_not_found(self): with patch( - "commands.freerouting.shutil.which", return_value=None + "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