KiCAD nightly builds occasionally return a SwigPyObject from pcbnew.LoadBoard, and SaveBoard can leave self.board with no method dispatch as a side-effect of certain sequences (delete_trace + auto-save is the one users have hit in the wild). Before this fix, open_project would keep reporting "Opened project: foo.kicad_pcb" while every subsequent board operation failed with AttributeError on GetDesignSettings / GetBoardEdgesBoundingBox / GetCurrentViaSize, with no path to recovery short of restarting the MCP server. Add two helpers in KiCADInterface: * _is_board_healthy(board=None) probes for stable BOARD methods (GetDesignSettings, GetBoardEdgesBoundingBox, GetFileName) — these are missing on a dehydrated SwigPyObject, so hasattr() catches the state without segfaulting. * _safe_load_board(path) wraps pcbnew.LoadBoard, checks health, and on dehydration reloads the pcbnew module via importlib.reload and retries once. Returns None when recovery is impossible so callers surface real failure rather than fake success. Wire the helpers in: * handle_command's open_project / create_project path validates the loaded board and either recovers (with a warnings[] entry) or returns success=False with an explicit "restart the MCP server" errorDetails — never claims success when the board is unusable. * _auto_save_board now detects dehydration introduced by SaveBoard itself and reloads from disk so the next command sees a usable proxy. This is the post-delete_trace failure mode users hit. * _handle_place_component, _handle_sync_schematic_to_board, _handle_import_svg_logo and _handle_refill_zones all go through _safe_load_board instead of bare LoadBoard, surfacing real errors consistently. Also fix two adjacent issues observed in the same incident: * _handle_check_kicad_ui used to call manager.is_running() and manager.get_process_info() separately, with different detection methods. They could disagree, producing the confusing running=True, processes=[] state users hit after manually quitting KiCAD. processes is now the single source of truth and running is derived from len(). * run_drc accepts a timeoutSec param (default 600s, clamped to [10, 1800]) so callers with smaller MCP transport budgets can bound the kicad-cli subprocess. Same timeout is applied to the optional report-generation subprocess. Error message names the actual timeout that fired. Tests: tests/test_swig_dehydration.py adds 17 unit tests covering detection, recovery, the open_project surfacing path, the auto-save post-recovery path, the check_kicad_ui consistency, and the run_drc timeout clamping. Full suite: same 12 pre-existing failures both before and after this change, +17 new tests passing. Note: the SWIG dehydration is fundamentally a pcbnew memory bug exposed by repeated LoadBoard calls in a single Python process; this PR is a defensive recovery layer, not a fix to the underlying binding. Complementary to PR #151 (auto-save-guard), which expands the LoadBoard call rate by refusing saves on external file change and forcing re-open_project cycles.
470 lines
19 KiB
Python
470 lines
19 KiB
Python
"""
|
|
Design rules command implementations for KiCAD interface
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
import pcbnew
|
|
|
|
logger = logging.getLogger("kicad_interface")
|
|
|
|
|
|
class DesignRuleCommands:
|
|
"""Handles design rule checking and configuration"""
|
|
|
|
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
|
"""Initialize with optional board instance"""
|
|
self.board = board
|
|
|
|
def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Set design rules for the PCB"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
design_settings = self.board.GetDesignSettings()
|
|
|
|
# Convert mm to nanometers for KiCAD internal units
|
|
scale = 1000000 # mm to nm
|
|
|
|
# Set clearance
|
|
if "clearance" in params:
|
|
design_settings.m_MinClearance = int(params["clearance"] * scale)
|
|
|
|
# KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed)
|
|
# Track if we set any custom track/via values
|
|
custom_values_set = False
|
|
|
|
if "trackWidth" in params:
|
|
design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale))
|
|
custom_values_set = True
|
|
|
|
# Via settings
|
|
if "viaDiameter" in params:
|
|
design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale))
|
|
custom_values_set = True
|
|
if "viaDrill" in params:
|
|
design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale))
|
|
custom_values_set = True
|
|
|
|
# KiCAD 9.0: Activate custom track/via values so they become the current values
|
|
if custom_values_set:
|
|
design_settings.UseCustomTrackViaSize(True)
|
|
|
|
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
|
|
if "microViaDiameter" in params:
|
|
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
|
|
if "microViaDrill" in params:
|
|
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
|
|
|
|
# Set minimum values
|
|
if "minTrackWidth" in params:
|
|
design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
|
|
if "minViaDiameter" in params:
|
|
design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
|
|
|
|
# KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead
|
|
if "minViaDrill" in params:
|
|
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
|
|
|
|
if "minMicroViaDiameter" in params:
|
|
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
|
|
if "minMicroViaDrill" in params:
|
|
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
|
|
|
|
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
|
|
if "minHoleDiameter" in params:
|
|
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
|
|
|
|
# KiCAD 9.0: Added hole clearance settings
|
|
if "holeClearance" in params:
|
|
design_settings.m_HoleClearance = int(params["holeClearance"] * scale)
|
|
if "holeToHoleMin" in params:
|
|
design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale)
|
|
|
|
# Build response with KiCAD 9.0 compatible properties
|
|
# After UseCustomTrackViaSize(True), GetCurrent* returns the custom values
|
|
response_rules = {
|
|
"clearance": design_settings.m_MinClearance / scale,
|
|
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
|
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
|
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
|
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
|
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
|
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
|
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
|
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
|
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
|
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
|
"holeClearance": design_settings.m_HoleClearance / scale,
|
|
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
|
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"message": "Updated design rules",
|
|
"rules": response_rules,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error setting design rules: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to set design rules",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Get current design rules - KiCAD 9.0 compatible"""
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
design_settings = self.board.GetDesignSettings()
|
|
scale = 1000000 # nm to mm
|
|
|
|
# Build rules dict with KiCAD 9.0 compatible properties
|
|
rules = {
|
|
# Core clearance and track settings
|
|
"clearance": design_settings.m_MinClearance / scale,
|
|
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
|
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
|
# Via settings (current values from methods)
|
|
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
|
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
|
# Via minimum values
|
|
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
|
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
|
# Micro via settings
|
|
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
|
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
|
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
|
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
|
# KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
|
|
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
|
"holeClearance": design_settings.m_HoleClearance / scale,
|
|
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
|
# Other constraints
|
|
"copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
|
|
"silkClearance": design_settings.m_SilkClearance / scale,
|
|
}
|
|
|
|
return {"success": True, "rules": rules}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting design rules: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to get design rules",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Run Design Rule Check using kicad-cli"""
|
|
import json
|
|
import platform
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
report_path = params.get("reportPath")
|
|
# Caller-overridable timeout (seconds). Defaults to 600s for big boards
|
|
# but smaller MCP transport budgets (e.g. 120s) can lower it explicitly.
|
|
try:
|
|
timeout_sec = int(params.get("timeoutSec", 600))
|
|
except (TypeError, ValueError):
|
|
timeout_sec = 600
|
|
timeout_sec = max(10, min(timeout_sec, 1800)) # clamp to [10, 1800]
|
|
|
|
# Get the board file path
|
|
board_file = self.board.GetFileName()
|
|
if not board_file or not os.path.exists(board_file):
|
|
return {
|
|
"success": False,
|
|
"message": "Board file not found",
|
|
"errorDetails": "Cannot run DRC without a saved board file",
|
|
}
|
|
|
|
# Find kicad-cli executable
|
|
kicad_cli = self._find_kicad_cli()
|
|
if not kicad_cli:
|
|
return {
|
|
"success": False,
|
|
"message": "kicad-cli not found",
|
|
"errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.",
|
|
}
|
|
|
|
# Create temporary JSON output file
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
|
json_output = tmp.name
|
|
|
|
try:
|
|
# Build command
|
|
cmd = [
|
|
kicad_cli,
|
|
"pcb",
|
|
"drc",
|
|
"--format",
|
|
"json",
|
|
"--output",
|
|
json_output,
|
|
"--units",
|
|
"mm",
|
|
board_file,
|
|
]
|
|
|
|
logger.info(f"Running DRC command (timeout={timeout_sec}s): {' '.join(cmd)}")
|
|
|
|
# Run DRC. subprocess.run kills the child on TimeoutExpired.
|
|
result = subprocess.run(
|
|
cmd,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout_sec,
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
logger.error(f"DRC command failed: {result.stderr}")
|
|
return {
|
|
"success": False,
|
|
"message": "DRC command failed",
|
|
"errorDetails": result.stderr,
|
|
}
|
|
|
|
# Read JSON output
|
|
with open(json_output, "r", encoding="utf-8") as f:
|
|
drc_data = json.load(f)
|
|
|
|
# Parse violations from kicad-cli output
|
|
violations = []
|
|
violation_counts: dict[str, int] = {}
|
|
severity_counts = {"error": 0, "warning": 0, "info": 0}
|
|
|
|
for violation in drc_data.get("violations", []):
|
|
vtype = violation.get("type", "unknown")
|
|
vseverity = violation.get("severity", "error")
|
|
|
|
# Extract location from first item's pos (kicad-cli JSON format)
|
|
items = violation.get("items", [])
|
|
loc_x, loc_y = 0, 0
|
|
if items and "pos" in items[0]:
|
|
loc_x = items[0]["pos"].get("x", 0)
|
|
loc_y = items[0]["pos"].get("y", 0)
|
|
|
|
violations.append(
|
|
{
|
|
"type": vtype,
|
|
"severity": vseverity,
|
|
"message": violation.get("description", ""),
|
|
"location": {
|
|
"x": loc_x,
|
|
"y": loc_y,
|
|
"unit": "mm",
|
|
},
|
|
}
|
|
)
|
|
|
|
# Count violations by type
|
|
violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
|
|
|
|
# Count by severity
|
|
if vseverity in severity_counts:
|
|
severity_counts[vseverity] += 1
|
|
|
|
# Determine where to save the violations file
|
|
board_dir = os.path.dirname(board_file)
|
|
board_name = os.path.splitext(os.path.basename(board_file))[0]
|
|
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
|
|
|
|
# Always save violations to JSON file (for large result sets)
|
|
with open(violations_file, "w", encoding="utf-8") as f:
|
|
json.dump(
|
|
{
|
|
"board": board_file,
|
|
"timestamp": drc_data.get("date", "unknown"),
|
|
"total_violations": len(violations),
|
|
"violation_counts": violation_counts,
|
|
"severity_counts": severity_counts,
|
|
"violations": violations,
|
|
},
|
|
f,
|
|
indent=2,
|
|
)
|
|
|
|
# Save text report if requested
|
|
if report_path:
|
|
report_path = os.path.abspath(os.path.expanduser(report_path))
|
|
cmd_report = [
|
|
kicad_cli,
|
|
"pcb",
|
|
"drc",
|
|
"--format",
|
|
"report",
|
|
"--output",
|
|
report_path,
|
|
"--units",
|
|
"mm",
|
|
board_file,
|
|
]
|
|
subprocess.run(cmd_report, capture_output=True, timeout=timeout_sec)
|
|
|
|
# Return summary only (not full violations list)
|
|
return {
|
|
"success": True,
|
|
"message": f"Found {len(violations)} DRC violations",
|
|
"summary": {
|
|
"total": len(violations),
|
|
"by_severity": severity_counts,
|
|
"by_type": violation_counts,
|
|
},
|
|
"violationsFile": violations_file,
|
|
"reportPath": report_path if report_path else None,
|
|
}
|
|
|
|
finally:
|
|
# Clean up temp JSON file
|
|
if os.path.exists(json_output):
|
|
os.unlink(json_output)
|
|
|
|
except subprocess.TimeoutExpired:
|
|
logger.error(f"DRC command timed out after {timeout_sec}s")
|
|
return {
|
|
"success": False,
|
|
"message": "DRC command timed out",
|
|
"errorDetails": (
|
|
f"Command took longer than {timeout_sec} seconds; "
|
|
"raise timeoutSec param for very large boards"
|
|
),
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error running DRC: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to run DRC",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def _find_kicad_cli(self) -> Optional[str]:
|
|
"""Find kicad-cli executable"""
|
|
import platform
|
|
import shutil
|
|
|
|
# Try system PATH first
|
|
cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli"
|
|
cli_path = shutil.which(cli_name)
|
|
if cli_path:
|
|
return cli_path
|
|
|
|
# Try common installation paths (version-specific)
|
|
if platform.system() == "Windows":
|
|
common_paths = [
|
|
r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe",
|
|
r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
|
|
r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
|
|
r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe",
|
|
r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
|
|
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
|
|
r"C:\Program Files\KiCad\bin\kicad-cli.exe",
|
|
]
|
|
for path in common_paths:
|
|
if os.path.exists(path):
|
|
return path
|
|
elif platform.system() == "Darwin": # macOS
|
|
common_paths = [
|
|
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
|
|
"/usr/local/bin/kicad-cli",
|
|
]
|
|
for path in common_paths:
|
|
if os.path.exists(path):
|
|
return path
|
|
else: # Linux
|
|
common_paths = [
|
|
"/usr/bin/kicad-cli",
|
|
"/usr/local/bin/kicad-cli",
|
|
]
|
|
for path in common_paths:
|
|
if os.path.exists(path):
|
|
return path
|
|
|
|
return None
|
|
|
|
def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Get list of DRC violations
|
|
|
|
Note: This command internally uses run_drc() which calls kicad-cli.
|
|
The old BOARD.GetDRCMarkers() API was removed in KiCAD 9.0.
|
|
This implementation provides backward compatibility by parsing kicad-cli output.
|
|
"""
|
|
import json
|
|
|
|
try:
|
|
if not self.board:
|
|
return {
|
|
"success": False,
|
|
"message": "No board is loaded",
|
|
"errorDetails": "Load or create a board first",
|
|
}
|
|
|
|
severity = params.get("severity", "all")
|
|
|
|
# Run DRC using kicad-cli (this saves violations to JSON file)
|
|
drc_result = self.run_drc({})
|
|
|
|
if not drc_result.get("success"):
|
|
return drc_result # Return the error from run_drc
|
|
|
|
# Read violations from the saved JSON file
|
|
violations_file = drc_result.get("violationsFile")
|
|
if not violations_file or not os.path.exists(violations_file):
|
|
return {
|
|
"success": False,
|
|
"message": "Violations file not found",
|
|
"errorDetails": "run_drc did not create violations file",
|
|
}
|
|
|
|
# Load violations from file
|
|
with open(violations_file, "r", encoding="utf-8") as f:
|
|
data = json.load(f)
|
|
|
|
all_violations = data.get("violations", [])
|
|
|
|
# Filter by severity if specified
|
|
if severity != "all":
|
|
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
|
|
else:
|
|
filtered_violations = all_violations
|
|
|
|
return {
|
|
"success": True,
|
|
"violations": filtered_violations,
|
|
"violationsFile": violations_file, # Include file path for reference
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting DRC violations: {str(e)}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to get DRC violations",
|
|
"errorDetails": str(e),
|
|
}
|