""" Freerouting autoroute integration for KiCAD MCP Server. Exports the board to Specctra DSN format, runs Freerouting CLI, and imports the routed SES file back into the board. Supports two execution modes: - Direct: java -jar freerouting.jar (requires Java 21+) - Docker: docker run eclipse-temurin:21-jre (requires Docker) """ import logging import os import re import shutil import subprocess import time from pathlib import Path from typing import Any, Dict, Iterable, List, Optional logger = logging.getLogger("kicad_interface") # Default Freerouting JAR location DEFAULT_FREEROUTING_JAR = os.environ.get( "FREEROUTING_JAR", os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"), ) DOCKER_IMAGE = "eclipse-temurin:21-jre" # Default schedule of `-mp` (max passes) values used when ``attempts`` > 1. # Cycles through a range that empirically produces enough variation between # runs to surface a better result than any single fixed value. Ported from # morningfire-pcb-automation/scripts/routing/freeroute_runner.py. DEFAULT_PASS_SCHEDULE = [50, 60, 65, 70, 75, 80, 85, 90, 55, 95] 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, single_thread: bool = False, ) -> List[str]: """Build the command to run Freerouting. ``single_thread`` forces ``-mt 1`` (single-threaded optimisation). Freerouting 2.x's multi-threaded optimiser is documented to produce clearance violations in some cases (the runtime even prints a warning); best-of-N callers should pass this so each attempt's score reflects a valid routed board, not an artefact of MT optimisation. """ extra = ["-mt", "1"] if single_thread else [] if use_docker: docker_exe = _find_docker() if docker_exe is None: raise RuntimeError("Docker/Podman executable not found") board_dir = os.path.dirname(dsn_path) dsn_name = os.path.basename(dsn_path) ses_name = os.path.basename(ses_path) jar_name = os.path.basename(jar_path) return [ docker_exe, "run", "--rm", "-v", f"{jar_path}:/app/{jar_name}:ro", "-v", f"{board_dir}:/work", DOCKER_IMAGE, "java", "-jar", f"/app/{jar_name}", "-de", f"/work/{dsn_name}", "-do", f"/work/{ses_name}", "-mp", str(passes), *extra, ] else: java_exe = _find_java() if java_exe is None: raise RuntimeError("Java executable not found") return [ java_exe, "-jar", jar_path, "-de", dsn_path, "-do", ses_path, "-mp", str(passes), *extra, ] # --------------------------------------------------------------------------- # Best-of-N scoring helpers (ported from morningfire-pcb-automation) # --------------------------------------------------------------------------- # # Approach lifted from # https://github.com/NiNjA-CodE/morningfire-pcb-automation # scripts/routing/freeroute_runner.py::score_ses # # Single-shot Freerouting on dense boards routinely leaves 1–7 nets # unrouted. Re-running with varied --max-passes values surfaces a better # solution most of the time; the scoring function below picks the best # SES across attempts. # --------------------------------------------------------------------------- _SES_NET_RE = re.compile(r'\(net\s+(\S+)\s*\n\s*\(wire') def _score_ses(ses_text: str, target_nets: Iterable[str]) -> Dict[str, Any]: """Score a Specctra SES file by routing completeness. Score = (nets_routed * 1000) + segments + 50000_if_all_targets_routed The ``nets_routed * 1000`` term dominates segment count so an attempt that routes one more net always beats an attempt with marginally more segments. The target-net bonus is huge so any attempt that routes all critical nets wins, regardless of segment count. Returns: ``{"score": int, "nets": int, "segments": int, "vias": int, "targets_found": [...], "targets_missing": [...]}`` """ nets = set(_SES_NET_RE.findall(ses_text)) # Strip wrapping quotes if Freerouting emits them. clean_nets = {n.strip('"') for n in nets} segments = len(re.findall(r'\(wire', ses_text)) vias = len(re.findall(r'\(via ', ses_text)) targets = set(target_nets) if target_nets else set() found = sorted(targets & clean_nets) missing = sorted(targets - clean_nets) score = len(clean_nets) * 1000 + segments if targets and not missing: score += 50_000 return { "score": score, "nets": len(clean_nets), "segments": segments, "vias": vias, "targets_found": found, "targets_missing": missing, } class FreeroutingCommands: """Handles Freerouting autoroute operations.""" def __init__(self, board: Any = None) -> None: self.board = board def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]: """Determine how to run Freerouting: direct or docker. Returns dict with 'mode', 'use_docker', or 'error'. """ java_exe = _find_java() if java_exe and _java_version_ok(java_exe): return {"mode": "direct", "use_docker": False} if _docker_available(): return {"mode": "docker", "use_docker": True} if java_exe: return { "mode": "error", "error": ( f"Java found at {java_exe} but version < 21. " "Freerouting 2.x requires Java 21+. " "Install Java 21+ or Docker." ), } return { "mode": "error", "error": ( "Neither Java 21+ nor Docker found. " "Install one of them to use Freerouting." ), } def autoroute(self, params: Dict[str, Any]) -> Dict[str, Any]: """Run Freerouting autorouter on the current board. Single-attempt flow (default): 1. Export board to Specctra DSN 2. Run Freerouting CLI on DSN -> SES (one pass with ``maxPasses``) 3. Import SES back into the board 4. Save the board Best-of-N flow (``attempts > 1``): 1. Export DSN once 2. Run Freerouting ``attempts`` times, varying ``--max-passes`` per the ``passSchedule`` (defaults to a built-in schedule of 10 spread-out values). 3. Score each SES by (nets_routed * 1000) + segments, plus a 50,000-point bonus when every ``targetNets`` entry routed. 4. Keep the highest-scoring SES; import that one into the board. Single-attempt behaviour is unchanged when ``attempts`` is omitted or set to 1, so existing callers do not need updates. The best-of-N scoring approach is ported from morningfire-pcb-automation (https://github.com/NiNjA-CodE/morningfire-pcb-automation, scripts/routing/freeroute_runner.py). On dense boards a single run regularly leaves 1–7 nets unrouted; cycling through a few ``-mp`` values typically gets the count to zero. """ 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) # Best-of-N parameters attempts_raw = params.get("attempts", 1) try: attempts = int(attempts_raw) if attempts_raw is not None else 1 except (TypeError, ValueError): return { "success": False, "message": "Invalid attempts value", "errorDetails": f"attempts must be a positive integer; got {attempts_raw!r}", } if attempts < 1: return { "success": False, "message": "Invalid attempts value", "errorDetails": "attempts must be >= 1", } target_nets = list(params.get("targetNets") or []) pass_schedule = list(params.get("passSchedule") or DEFAULT_PASS_SCHEDULE) if not pass_schedule: pass_schedule = [passes] # 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") best_ses_path = os.path.join(board_dir, f"{board_stem}_best.ses") # Step 1: Export DSN (once, regardless of attempt count) 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 (single or multiple attempts) mode_label = "docker" if use_docker else "direct" total_start = time.time() attempt_results: List[Dict[str, Any]] = [] best_score = -1 best_attempt_idx = -1 best_proc_stdout = "" # If only one attempt, use the legacy maxPasses value (preserves # exact backward-compatible behaviour). Otherwise cycle through # passSchedule. Always run single-threaded when scoring multiple # attempts so the optimiser doesn't introduce clearance violations # that would distort the comparison. for idx in range(attempts): if attempts == 1: attempt_passes = passes single_thread = False else: attempt_passes = pass_schedule[idx % len(pass_schedule)] single_thread = True cmd = _build_freerouting_cmd( jar_path, dsn_path, ses_path, attempt_passes, use_docker, single_thread=single_thread, ) logger.info( f"Freerouting attempt {idx + 1}/{attempts} " f"(mp={attempt_passes}, mode={mode_label})" ) attempt_start = time.time() try: proc = subprocess.run( cmd, capture_output=True, text=True, timeout=timeout, cwd=board_dir, ) attempt_elapsed = round(time.time() - attempt_start, 1) except subprocess.TimeoutExpired: return { "success": False, "message": f"Freerouting timed out after {timeout}s", "errorDetails": "Increase timeout or reduce board complexity", "attempts_completed": idx, } except Exception as e: return { "success": False, "message": "Failed to run Freerouting", "errorDetails": str(e), "attempts_completed": idx, } if proc.returncode != 0: # Don't abort the whole best-of-N just because one attempt # exits nonzero — record it and move on. attempt_results.append({ "attempt": idx + 1, "max_passes": attempt_passes, "elapsed_seconds": attempt_elapsed, "ok": False, "exit_code": proc.returncode, "stderr": (proc.stderr or "")[:200], }) if attempts == 1: return { "success": False, "message": f"Freerouting exited with code {proc.returncode}", "errorDetails": proc.stderr or proc.stdout, "elapsed_seconds": attempt_elapsed, "mode": mode_label, } continue if not os.path.isfile(ses_path): attempt_results.append({ "attempt": idx + 1, "max_passes": attempt_passes, "elapsed_seconds": attempt_elapsed, "ok": False, "error": "no SES produced", }) if attempts == 1: return { "success": False, "message": "Freerouting did not produce SES output", "errorDetails": ( f"Expected at: {ses_path}. Stdout: {proc.stdout[:500]}" ), "elapsed_seconds": attempt_elapsed, } continue # Score this attempt with open(ses_path, "r", encoding="utf-8", errors="replace") as fh: ses_text = fh.read() score_info = _score_ses(ses_text, target_nets) score = score_info["score"] attempt_results.append({ "attempt": idx + 1, "max_passes": attempt_passes, "elapsed_seconds": attempt_elapsed, "ok": True, **score_info, }) logger.info( f" attempt {idx + 1}: score={score} " f"({score_info['nets']} nets, {score_info['segments']} segs, " f"{score_info['vias']} vias)" ) if score > best_score: best_score = score best_attempt_idx = idx best_proc_stdout = proc.stdout or "" # Snapshot the SES that produced this score so later # attempts (which overwrite ses_path) don't clobber it. shutil.copy2(ses_path, best_ses_path) elapsed = round(time.time() - total_start, 1) if best_attempt_idx == -1: return { "success": False, "message": "All Freerouting attempts failed", "errorDetails": "No attempt produced a usable SES file", "elapsed_seconds": elapsed, "attempts": attempt_results, } # Restore the winning SES as the canonical output file if attempts > 1: shutil.copy2(best_ses_path, ses_path) ses_size = os.path.getsize(ses_path) logger.info( f"Best SES: attempt {best_attempt_idx + 1}, score={best_score}, " f"{ses_size} bytes (total {elapsed}s)" ) # Step 3: Import the winning 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, "attempts": attempt_results, } except Exception as e: return { "success": False, "message": "SES import failed", "errorDetails": str(e), "elapsed_seconds": elapsed, "attempts": attempt_results, } # 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 response: Dict[str, Any] = { "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": best_proc_stdout[:1000], } if attempts > 1: response["attempts"] = attempt_results response["best_attempt"] = best_attempt_idx + 1 response["best_score"] = best_score response["best_ses_path"] = best_ses_path return response 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, }