The existing single-shot autoroute leaves 1-7 nets unrouted on dense
boards in my testing. Best-of-N drives that to 0 most of the time by
running Freerouting a few times with varied --max-passes and keeping
the SES with the best routing score.
New optional parameters (all backward-compatible):
attempts: int, default 1 (unchanged behaviour). When > 1, run
Freerouting N times and pick the highest-scoring SES.
targetNets: list of critical net names. An attempt that routes all
of them earns a 50,000-point scoring bonus.
passSchedule: list of --max-passes values to cycle through across
attempts. Default: [50, 60, 65, 70, 75, 80, 85, 90, 55,
95] (wraps if attempts > len). Ignored when attempts=1
(legacy maxPasses still used).
Scoring contract (pinned by tests):
score = nets_routed * 1000 + segments
if targetNets and all routed: score += 50_000
- +1 net always beats any segment-count delta (1000 pt step).
- Segments break ties at equal net count.
- Target bonus dominates net-count gains from unrelated nets.
## Implementation notes
- When attempts > 1, each attempt runs with `-mt 1` (single-thread
optimisation). Freerouting 2.x's multi-threaded optimiser is
documented to introduce clearance violations, so forcing
single-thread during scoring keeps the comparison apples-to-apples.
- One failed attempt does not abort the whole best-of-N run. The
failure is recorded in the response under attempts[] with ok=False,
and the remaining attempts compete for best. If every attempt fails
the response surfaces a clear error.
- The winning SES is preserved as <stem>_best.ses next to the
canonical <stem>.ses so the caller can inspect it after the run.
- Response shape:
attempts == 1: unchanged (no attempts/best_attempt fields)
attempts > 1: adds attempts[], best_attempt, best_score,
best_ses_path
## Attribution
Scoring approach and default pass schedule ported from
morningfire-pcb-automation
(https://github.com/NiNjA-CodE/morningfire-pcb-automation,
scripts/routing/freeroute_runner.py). Credited in the function
docstring, the TypeScript wrapper comment, the tool description (visible
to MCP clients), and the CHANGELOG entry.
The MCP version adds: cleaner per-attempt result reporting, automatic
single-thread optimisation, graceful degradation on partial failure,
and explicit validation that surfaces clean error payloads for invalid
attempts values.
## Tests
tests/test_autoroute_score.py 8 cases, scoring contract
tests/test_autoroute_best_of_n.py 6 cases, orchestration logic
All 14 passing. Tests are pure-Python: subprocess is mocked so the
suite runs in any environment (no Java / Freerouting / KiCad required).
- Single-attempt response shape unchanged
- Best-of-three picks the highest-scoring SES
- One nonzero exit attempt doesn't abort the run
- passSchedule wraps when attempts exceeds len
- targetNets bonus wins over higher raw net count
- attempts=0 rejected with clean error before DSN export
- +1 net (1000 pts) dominates any segment delta
- Segments tiebreak at equal net count
- Quoted net names in SES are normalised vs unquoted targets
TypeScript builds clean.
759 lines
26 KiB
Python
759 lines
26 KiB
Python
"""
|
||
Freerouting autoroute integration for KiCAD MCP Server.
|
||
|
||
Exports the board to Specctra DSN format, runs Freerouting CLI,
|
||
and imports the routed SES file back into the board.
|
||
|
||
Supports two execution modes:
|
||
- Direct: java -jar freerouting.jar (requires Java 21+)
|
||
- Docker: docker run eclipse-temurin:21-jre (requires Docker)
|
||
"""
|
||
|
||
import logging
|
||
import os
|
||
import 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,
|
||
}
|