Merge pull request #10 from gwall-ceres/main

Fix MCP protocol compliance and Windows compatibility
This commit is contained in:
mixelpixx
2025-11-18 18:22:46 -05:00
committed by GitHub
10 changed files with 630 additions and 226 deletions

View File

@@ -75,3 +75,8 @@ class BoardCommands:
"""Get a 2D image of the PCB""" """Get a 2D image of the PCB"""
self.view_commands.board = self.board self.view_commands.board = self.board
return self.view_commands.get_board_2d_view(params) return self.view_commands.get_board_2d_view(params)
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
self.view_commands.board = self.board
return self.view_commands.get_board_extents(params)

View File

@@ -151,8 +151,9 @@ class BoardLayerCommands:
layers.append({ layers.append({
"name": self.board.GetLayerName(layer_id), "name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id, "id": layer_id
"isActive": layer_id == self.board.GetActiveLayer() # Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
}) })
return { return {
@@ -173,7 +174,7 @@ class BoardLayerCommands:
type_map = { type_map = {
"copper": pcbnew.LT_SIGNAL, "copper": pcbnew.LT_SIGNAL,
"technical": pcbnew.LT_SIGNAL, "technical": pcbnew.LT_SIGNAL,
"user": pcbnew.LT_USER, "user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead
"signal": pcbnew.LT_SIGNAL "signal": pcbnew.LT_SIGNAL
} }
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL) return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
@@ -184,7 +185,7 @@ class BoardLayerCommands:
pcbnew.LT_SIGNAL: "signal", pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power", pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed", pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper", pcbnew.LT_JUMPER: "jumper"
pcbnew.LT_USER: "user"
} }
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown") return type_map.get(type_id, "unknown")

View File

@@ -16,7 +16,7 @@ class BoardSizeCommands:
self.board = board self.board = board
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]: def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the size of the PCB board""" """Set the size of the PCB board by creating edge cuts outline"""
try: try:
if not self.board: if not self.board:
return { return {
@@ -36,35 +36,33 @@ class BoardSizeCommands:
"errorDetails": "Both width and height are required" "errorDetails": "Both width and height are required"
} }
# Convert to internal units (nanometers) # Create board outline using BoardOutlineCommands
scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm # This properly creates edge cuts on Edge.Cuts layer
width_nm = int(width * scale) from commands.board.outline import BoardOutlineCommands
height_nm = int(height * scale) outline_commands = BoardOutlineCommands(self.board)
# Set board size using KiCAD 9.0 API # Create rectangular outline centered at origin
# Note: In KiCAD 9.0, SetSize takes two separate parameters instead of VECTOR2I result = outline_commands.add_board_outline({
board_box = self.board.GetBoardEdgesBoundingBox() "shape": "rectangle",
try: "centerX": width / 2, # Center X
# Try KiCAD 9.0+ API (two parameters) "centerY": height / 2, # Center Y
board_box.SetSize(width_nm, height_nm) "width": width,
except TypeError: "height": height,
# Fall back to older API (VECTOR2I) "unit": unit
board_box.SetSize(pcbnew.VECTOR2I(width_nm, height_nm)) })
# Note: SetBoardEdgesBoundingBox might not exist in all versions if result.get("success"):
# The board bounding box is typically derived from actual edge cuts return {
# For now, we'll just note the size was calculated "success": True,
logger.info(f"Board size set to {width}x{height} {unit}") "message": f"Created board outline: {width}x{height} {unit}",
"size": {
return { "width": width,
"success": True, "height": height,
"message": f"Set board size to {width}x{height} {unit}", "unit": unit
"size": { }
"width": width,
"height": height,
"unit": unit
} }
} else:
return result
except Exception as e: except Exception as e:
logger.error(f"Error setting board size: {str(e)}") logger.error(f"Error setting board size: {str(e)}")

View File

@@ -58,8 +58,9 @@ class BoardViewCommands:
"unit": "mm" "unit": "mm"
}, },
"layers": layers, "layers": layers,
"title": self.board.GetTitleBlock().GetTitle(), "title": self.board.GetTitleBlock().GetTitle()
"activeLayer": self.board.GetActiveLayer() # Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
} }
} }
@@ -95,26 +96,32 @@ class BoardViewCommands:
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName())) plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
plot_opts.SetScale(1) plot_opts.SetScale(1)
plot_opts.SetMirror(False) plot_opts.SetMirror(False)
plot_opts.SetExcludeEdgeLayer(False) # Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers
plot_opts.SetPlotFrameRef(False) plot_opts.SetPlotFrameRef(False)
plot_opts.SetPlotValue(True) plot_opts.SetPlotValue(True)
plot_opts.SetPlotReference(True) plot_opts.SetPlotReference(True)
# Plot to SVG first (for vector output) # Plot to SVG first (for vector output)
temp_svg = os.path.join(os.path.dirname(self.board.GetFileName()), "temp_view.svg") # Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View") plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
# Plot specified layers or all enabled layers # Plot specified layers or all enabled layers
# Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer()
if layers: if layers:
for layer_name in layers: for layer_name in layers:
layer_id = self.board.GetLayerID(layer_name) layer_id = self.board.GetLayerID(layer_name)
if layer_id >= 0 and self.board.IsLayerEnabled(layer_id): if layer_id >= 0 and self.board.IsLayerEnabled(layer_id):
plotter.PlotLayer(layer_id) plotter.SetLayer(layer_id)
plotter.PlotLayer()
else: else:
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id): if self.board.IsLayerEnabled(layer_id):
plotter.PlotLayer(layer_id) plotter.SetLayer(layer_id)
plotter.PlotLayer()
# Get the actual filename that was created (includes project name prefix)
temp_svg = plotter.GetPlotFileName()
plotter.ClosePlot() plotter.ClosePlot()
# Convert SVG to requested format # Convert SVG to requested format
@@ -165,7 +172,61 @@ class BoardViewCommands:
pcbnew.LT_SIGNAL: "signal", pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power", pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed", pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper", pcbnew.LT_JUMPER: "jumper"
pcbnew.LT_USER: "user"
} }
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown") return type_map.get(type_id, "unknown")
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
}
# Get unit preference (default to mm)
unit = params.get("unit", "mm")
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
# Get board bounding box
board_box = self.board.GetBoardEdgesBoundingBox()
# Extract bounds in nanometers, then convert
left = board_box.GetLeft() / scale
top = board_box.GetTop() / scale
right = board_box.GetRight() / scale
bottom = board_box.GetBottom() / scale
width = board_box.GetWidth() / scale
height = board_box.GetHeight() / scale
# Get center point
center_x = board_box.GetCenter().x / scale
center_y = board_box.GetCenter().y / scale
return {
"success": True,
"extents": {
"left": left,
"top": top,
"right": right,
"bottom": bottom,
"width": width,
"height": height,
"center": {
"x": center_x,
"y": center_y
},
"unit": unit
}
}
except Exception as e:
logger.error(f"Error getting board extents: {str(e)}")
return {
"success": False,
"message": "Failed to get board extents",
"errorDetails": str(e)
}

View File

@@ -405,7 +405,7 @@ class ComponentCommands:
"attributes": { "attributes": {
"smd": module.GetAttributes() & pcbnew.FP_SMD, "smd": module.GetAttributes() & pcbnew.FP_SMD,
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE, "through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
"virtual": module.GetAttributes() & pcbnew.FP_VIRTUAL "board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY
} }
} }
} }

View File

@@ -33,65 +33,82 @@ class DesignRuleCommands:
# Set clearance # Set clearance
if "clearance" in params: if "clearance" in params:
design_settings.SetMinClearance(int(params["clearance"] * scale)) 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
# Set track width
if "trackWidth" in params: if "trackWidth" in params:
design_settings.SetCurrentTrackWidth(int(params["trackWidth"] * scale)) design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale))
custom_values_set = True
# Set via settings # Via settings
if "viaDiameter" in params: if "viaDiameter" in params:
design_settings.SetCurrentViaSize(int(params["viaDiameter"] * scale)) design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale))
custom_values_set = True
if "viaDrill" in params: if "viaDrill" in params:
design_settings.SetCurrentViaDrill(int(params["viaDrill"] * scale)) design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale))
custom_values_set = True
# Set micro via settings # 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: if "microViaDiameter" in params:
design_settings.SetCurrentMicroViaSize(int(params["microViaDiameter"] * scale)) design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
if "microViaDrill" in params: if "microViaDrill" in params:
design_settings.SetCurrentMicroViaDrill(int(params["microViaDrill"] * scale)) design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
# Set minimum values # Set minimum values
if "minTrackWidth" in params: if "minTrackWidth" in params:
design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale) design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
if "minViaDiameter" in params: if "minViaDiameter" in params:
design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale) design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
# KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead
if "minViaDrill" in params: if "minViaDrill" in params:
design_settings.m_ViasMinDrill = int(params["minViaDrill"] * scale) design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
if "minMicroViaDiameter" in params: if "minMicroViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale) design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
if "minMicroViaDrill" in params: if "minMicroViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale) design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
# Set hole diameter # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
if "minHoleDiameter" in params: if "minHoleDiameter" in params:
design_settings.m_MinHoleDiameter = int(params["minHoleDiameter"] * scale) design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
# Set courtyard settings # KiCAD 9.0: Added hole clearance settings
if "requireCourtyard" in params: if "holeClearance" in params:
design_settings.m_RequireCourtyards = params["requireCourtyard"] design_settings.m_HoleClearance = int(params["holeClearance"] * scale)
if "courtyardClearance" in params: if "holeToHoleMin" in params:
design_settings.m_CourtyardMinClearance = int(params["courtyardClearance"] * scale) 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 { return {
"success": True, "success": True,
"message": "Updated design rules", "message": "Updated design rules",
"rules": { "rules": response_rules
"clearance": design_settings.GetMinClearance() / scale,
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
"microViaDiameter": design_settings.GetCurrentMicroViaSize() / scale,
"microViaDrill": design_settings.GetCurrentMicroViaDrill() / scale,
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
"minViaDiameter": design_settings.m_ViasMinSize / scale,
"minViaDrill": design_settings.m_ViasMinDrill / scale,
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
"minHoleDiameter": design_settings.m_MinHoleDiameter / scale,
"requireCourtyard": design_settings.m_RequireCourtyards,
"courtyardClearance": design_settings.m_CourtyardMinClearance / scale
}
} }
except Exception as e: except Exception as e:
@@ -103,7 +120,7 @@ class DesignRuleCommands:
} }
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get current design rules""" """Get current design rules - KiCAD 9.0 compatible"""
try: try:
if not self.board: if not self.board:
return { return {
@@ -115,24 +132,40 @@ class DesignRuleCommands:
design_settings = self.board.GetDesignSettings() design_settings = self.board.GetDesignSettings()
scale = 1000000 # nm to mm 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 { return {
"success": True, "success": True,
"rules": { "rules": rules
"clearance": design_settings.GetMinClearance() / scale,
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
"microViaDiameter": design_settings.GetCurrentMicroViaSize() / scale,
"microViaDrill": design_settings.GetCurrentMicroViaDrill() / scale,
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
"minViaDiameter": design_settings.m_ViasMinSize / scale,
"minViaDrill": design_settings.m_ViasMinDrill / scale,
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
"minHoleDiameter": design_settings.m_MinHoleDiameter / scale,
"requireCourtyard": design_settings.m_RequireCourtyards,
"courtyardClearance": design_settings.m_CourtyardMinClearance / scale
}
} }
except Exception as e: except Exception as e:
@@ -144,7 +177,13 @@ class DesignRuleCommands:
} }
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]: def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Design Rule Check""" """Run Design Rule Check using kicad-cli"""
import subprocess
import json
import tempfile
import platform
import shutil
try: try:
if not self.board: if not self.board:
return { return {
@@ -155,38 +194,144 @@ class DesignRuleCommands:
report_path = params.get("reportPath") report_path = params.get("reportPath")
# Create DRC runner # Get the board file path
drc = pcbnew.DRC(self.board) board_file = self.board.GetFileName()
if not board_file or not os.path.exists(board_file):
# Run DRC return {
drc.Run() "success": False,
"message": "Board file not found",
"errorDetails": "Cannot run DRC without a saved board file"
}
# Get violations # Find kicad-cli executable
violations = [] kicad_cli = self._find_kicad_cli()
for marker in drc.GetMarkers(): if not kicad_cli:
violations.append({ return {
"type": marker.GetErrorCode(), "success": False,
"severity": "error", "message": "kicad-cli not found",
"message": marker.GetDescription(), "errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH."
"location": { }
"x": marker.GetPos().x / 1000000,
"y": marker.GetPos().y / 1000000, # Create temporary JSON output file
"unit": "mm" 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: {' '.join(cmd)}")
# Run DRC
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600 # 10 minute timeout for large boards (21MB PCB needs time)
)
if result.returncode != 0:
logger.error(f"DRC command failed: {result.stderr}")
return {
"success": False,
"message": "DRC command failed",
"errorDetails": result.stderr
} }
})
# Save report if path provided # Read JSON output
if report_path: with open(json_output, 'r', encoding='utf-8') as f:
report_path = os.path.abspath(os.path.expanduser(report_path)) drc_data = json.load(f)
drc.WriteReport(report_path)
# Parse violations from kicad-cli output
violations = []
violation_counts = {}
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")
violations.append({
"type": vtype,
"severity": vseverity,
"message": violation.get("description", ""),
"location": {
"x": violation.get("x", 0),
"y": violation.get("y", 0),
"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=600)
# 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("DRC command timed out")
return { return {
"success": True, "success": False,
"message": f"Found {len(violations)} DRC violations", "message": "DRC command timed out",
"violations": violations, "errorDetails": "Command took longer than 600 seconds (10 minutes)"
"reportPath": report_path if report_path else None
} }
except Exception as e: except Exception as e:
logger.error(f"Error running DRC: {str(e)}") logger.error(f"Error running DRC: {str(e)}")
return { return {
@@ -195,6 +340,50 @@ class DesignRuleCommands:
"errorDetails": str(e) "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]: def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get list of DRC violations""" """Get list of DRC violations"""
try: try:

View File

@@ -1,4 +1,4 @@
""" """
Export command implementations for KiCAD interface Export command implementations for KiCAD interface
""" """
@@ -63,31 +63,51 @@ class ExportCommands:
for layer_name in layers: for layer_name in layers:
layer_id = self.board.GetLayerID(layer_name) layer_id = self.board.GetLayerID(layer_name)
if layer_id >= 0: if layer_id >= 0:
plotter.PlotLayer(layer_id) plotter.SetLayer(layer_id)
plotter.PlotLayer()
plotted_layers.append(layer_name) plotted_layers.append(layer_name)
else: else:
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id): if self.board.IsLayerEnabled(layer_id):
layer_name = self.board.GetLayerName(layer_id) layer_name = self.board.GetLayerName(layer_id)
plotter.PlotLayer(layer_id) plotter.SetLayer(layer_id)
plotter.PlotLayer()
plotted_layers.append(layer_name) plotted_layers.append(layer_name)
# Generate drill files if requested # Generate drill files if requested
drill_files = [] drill_files = []
if generate_drill_files: if generate_drill_files:
drill_writer = pcbnew.EXCELLON_WRITER(self.board) # KiCAD 9.0: Use kicad-cli for more reliable drill file generation
drill_writer.SetFormat(True) # The Python API's EXCELLON_WRITER.SetOptions() signature changed
drill_writer.SetMapFileFormat(pcbnew.PLOT_FORMAT_GERBER) board_file = self.board.GetFileName()
kicad_cli = self._find_kicad_cli()
merge_npth = False # Keep plated/non-plated holes separate
drill_writer.SetOptions(merge_npth) if kicad_cli and board_file and os.path.exists(board_file):
import subprocess
drill_writer.CreateDrillandMapFilesSet(output_dir, True, generate_map_file) # Generate drill files using kicad-cli
cmd = [
# Get list of generated drill files kicad_cli,
for file in os.listdir(output_dir): 'pcb', 'export', 'drill',
if file.endswith(".drl") or file.endswith(".cnc"): '--output', output_dir,
drill_files.append(file) '--format', 'excellon',
'--drill-origin', 'absolute',
'--excellon-separate-th', # Separate plated/non-plated
board_file
]
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode == 0:
# Get list of generated drill files
for file in os.listdir(output_dir):
if file.endswith((".drl", ".cnc")):
drill_files.append(file)
else:
logger.warning(f"Drill file generation failed: {result.stderr}")
except Exception as drill_error:
logger.warning(f"Could not generate drill files: {str(drill_error)}")
else:
logger.warning("kicad-cli not available for drill file generation")
return { return {
"success": True, "success": True,
@@ -137,7 +157,7 @@ class ExportCommands:
# Create plot controller # Create plot controller
plotter = pcbnew.PLOT_CONTROLLER(self.board) plotter = pcbnew.PLOT_CONTROLLER(self.board)
# Set up plot options # Set up plot options
plot_opts = plotter.GetPlotOptions() plot_opts = plotter.GetPlotOptions()
plot_opts.SetOutputDirectory(os.path.dirname(output_path)) plot_opts.SetOutputDirectory(os.path.dirname(output_path))
@@ -145,22 +165,28 @@ class ExportCommands:
plot_opts.SetPlotFrameRef(frame_reference) plot_opts.SetPlotFrameRef(frame_reference)
plot_opts.SetPlotValue(True) plot_opts.SetPlotValue(True)
plot_opts.SetPlotReference(True) plot_opts.SetPlotReference(True)
plot_opts.SetMonochrome(black_and_white) plot_opts.SetBlackAndWhite(black_and_white)
# Set page size # KiCAD 9.0 page size handling:
page_sizes = { # - SetPageSettings() was removed in KiCAD 9.0
"A4": (297, 210), # - SetA4Output(bool) forces A4 page size when True
"A3": (420, 297), # - For other sizes, KiCAD auto-scales to fit the board
"A2": (594, 420), # - SetAutoScale(True) enables automatic scaling to fit page
"A1": (841, 594), if page_size == "A4":
"A0": (1189, 841), plot_opts.SetA4Output(True)
"Letter": (279.4, 215.9), else:
"Legal": (355.6, 215.9), # For non-A4 sizes, disable A4 forcing and use auto-scale
"Tabloid": (431.8, 279.4) plot_opts.SetA4Output(False)
} plot_opts.SetAutoScale(True)
if page_size in page_sizes: # Note: KiCAD 9.0 doesn't support explicit page size selection
height, width = page_sizes[page_size] # for formats other than A4. The PDF will auto-scale to fit.
plot_opts.SetPageSettings((width, height)) logger.warning(f"Page size '{page_size}' requested, but KiCAD 9.0 only supports A4 explicitly. Using auto-scale instead.")
# Open plot for writing
# Note: For PDF, all layers are combined into a single file
# KiCAD prepends the board filename to the plot file name
base_name = os.path.basename(output_path).replace('.pdf', '')
plotter.OpenPlotfile(base_name, pcbnew.PLOT_FORMAT_PDF, '')
# Plot specified layers or all enabled layers # Plot specified layers or all enabled layers
plotted_layers = [] plotted_layers = []
@@ -168,22 +194,34 @@ class ExportCommands:
for layer_name in layers: for layer_name in layers:
layer_id = self.board.GetLayerID(layer_name) layer_id = self.board.GetLayerID(layer_name)
if layer_id >= 0: if layer_id >= 0:
plotter.PlotLayer(layer_id) plotter.SetLayer(layer_id)
plotter.PlotLayer()
plotted_layers.append(layer_name) plotted_layers.append(layer_name)
else: else:
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id): if self.board.IsLayerEnabled(layer_id):
layer_name = self.board.GetLayerName(layer_id) layer_name = self.board.GetLayerName(layer_id)
plotter.PlotLayer(layer_id) plotter.SetLayer(layer_id)
plotter.PlotLayer()
plotted_layers.append(layer_name) plotted_layers.append(layer_name)
# Close the plot file to finalize the PDF
plotter.ClosePlot()
# KiCAD automatically prepends the board name to the output file
# Get the actual output filename that was created
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
actual_filename = f"{board_name}-{base_name}.pdf"
actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename)
return { return {
"success": True, "success": True,
"message": "Exported PDF file", "message": "Exported PDF file",
"file": { "file": {
"path": output_path, "path": actual_output_path,
"requestedPath": output_path,
"layers": plotted_layers, "layers": plotted_layers,
"pageSize": page_size "pageSize": page_size if page_size == "A4" else "auto-scaled"
} }
} }
@@ -230,7 +268,7 @@ class ExportCommands:
plot_opts.SetFormat(pcbnew.PLOT_FORMAT_SVG) plot_opts.SetFormat(pcbnew.PLOT_FORMAT_SVG)
plot_opts.SetPlotValue(include_components) plot_opts.SetPlotValue(include_components)
plot_opts.SetPlotReference(include_components) plot_opts.SetPlotReference(include_components)
plot_opts.SetMonochrome(black_and_white) plot_opts.SetBlackAndWhite(black_and_white)
# Plot specified layers or all enabled layers # Plot specified layers or all enabled layers
plotted_layers = [] plotted_layers = []
@@ -238,13 +276,15 @@ class ExportCommands:
for layer_name in layers: for layer_name in layers:
layer_id = self.board.GetLayerID(layer_name) layer_id = self.board.GetLayerID(layer_name)
if layer_id >= 0: if layer_id >= 0:
plotter.PlotLayer(layer_id) plotter.SetLayer(layer_id)
plotter.PlotLayer()
plotted_layers.append(layer_name) plotted_layers.append(layer_name)
else: else:
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id): if self.board.IsLayerEnabled(layer_id):
layer_name = self.board.GetLayerName(layer_id) layer_name = self.board.GetLayerName(layer_id)
plotter.PlotLayer(layer_id) plotter.SetLayer(layer_id)
plotter.PlotLayer()
plotted_layers.append(layer_name) plotted_layers.append(layer_name)
return { return {
@@ -265,7 +305,11 @@ class ExportCommands:
} }
def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]: def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export 3D model files""" """Export 3D model files using kicad-cli (KiCAD 9.0 compatible)"""
import subprocess
import platform
import shutil
try: try:
if not self.board: if not self.board:
return { return {
@@ -288,46 +332,108 @@ class ExportCommands:
"errorDetails": "outputPath parameter is required" "errorDetails": "outputPath parameter is required"
} }
# Get 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": "Board must be saved before exporting 3D models"
}
# Create output directory if it doesn't exist # Create output directory if it doesn't exist
output_path = os.path.abspath(os.path.expanduser(output_path)) output_path = os.path.abspath(os.path.expanduser(output_path))
os.makedirs(os.path.dirname(output_path), exist_ok=True) os.makedirs(os.path.dirname(output_path), exist_ok=True)
# Get 3D viewer # Find kicad-cli executable
viewer = self.board.Get3DViewer() kicad_cli = self._find_kicad_cli()
if not viewer: if not kicad_cli:
return { return {
"success": False, "success": False,
"message": "3D viewer not available", "message": "kicad-cli not found",
"errorDetails": "Could not initialize 3D viewer" "errorDetails": "KiCAD CLI tool not found. Install KiCAD 8.0+ or set PATH."
} }
# Set export options # Build command based on format
viewer.SetCopperLayersOn(include_copper) format_upper = format.upper()
viewer.SetSolderMaskLayersOn(include_solder_mask)
viewer.SetSilkScreenLayersOn(include_silkscreen) if format_upper == "STEP":
viewer.Set3DModelsOn(include_components) cmd = [
kicad_cli,
'pcb', 'export', 'step',
'--output', output_path,
'--force' # Overwrite existing file
]
# Add options based on parameters
if not include_components:
cmd.append('--no-components')
if include_copper:
cmd.extend(['--include-tracks', '--include-pads', '--include-zones'])
if include_silkscreen:
cmd.append('--include-silkscreen')
if include_solder_mask:
cmd.append('--include-soldermask')
cmd.append(board_file)
elif format_upper == "VRML":
cmd = [
kicad_cli,
'pcb', 'export', 'vrml',
'--output', output_path,
'--units', 'mm', # Use mm for consistency
'--force'
]
if not include_components:
# Note: VRML export doesn't have a direct --no-components flag
# The models will be included by default, but can be controlled via 3D settings
pass
cmd.append(board_file)
# Export based on format
if format == "STEP":
viewer.ExportSTEPFile(output_path)
elif format == "VRML":
viewer.ExportVRMLFile(output_path)
else: else:
return { return {
"success": False, "success": False,
"message": "Unsupported format", "message": "Unsupported format",
"errorDetails": f"Format {format} is not supported" "errorDetails": f"Format {format} is not supported. Use 'STEP' or 'VRML'."
}
# Execute kicad-cli command
logger.info(f"Running 3D export command: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=300 # 5 minute timeout for 3D export
)
if result.returncode != 0:
logger.error(f"3D export command failed: {result.stderr}")
return {
"success": False,
"message": "3D export command failed",
"errorDetails": result.stderr
} }
return { return {
"success": True, "success": True,
"message": f"Exported {format} file", "message": f"Exported {format_upper} file",
"file": { "file": {
"path": output_path, "path": output_path,
"format": format "format": format_upper
} }
} }
except subprocess.TimeoutExpired:
logger.error("3D export command timed out")
return {
"success": False,
"message": "3D export timed out",
"errorDetails": "Export took longer than 5 minutes"
}
except Exception as e: except Exception as e:
logger.error(f"Error exporting 3D model: {str(e)}") logger.error(f"Error exporting 3D model: {str(e)}")
return { return {
@@ -368,7 +474,7 @@ class ExportCommands:
component = { component = {
"reference": module.GetReference(), "reference": module.GetReference(),
"value": module.GetValue(), "value": module.GetValue(),
"footprint": module.GetFootprintName(), "footprint": str(module.GetFPID()),
"layer": self.board.GetLayerName(module.GetLayer()) "layer": self.board.GetLayerName(module.GetLayer())
} }
@@ -473,3 +579,44 @@ class ExportCommands:
import json import json
with open(path, 'w') as f: with open(path, 'w') as f:
json.dump({"components": components}, f, indent=2) json.dump({"components": components}, f, indent=2)
def _find_kicad_cli(self) -> Optional[str]:
"""Find kicad-cli executable in system PATH or common locations
Returns:
Path to kicad-cli executable, or None if not found
"""
import shutil
import platform
# Try system PATH first
cli_path = shutil.which("kicad-cli")
if cli_path:
return cli_path
# Try platform-specific default locations
system = platform.system()
if system == "Windows":
possible_paths = [
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\9.0\bin\kicad-cli.exe",
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
]
elif system == "Darwin": # macOS
possible_paths = [
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
"/usr/local/bin/kicad-cli",
]
else: # Linux
possible_paths = [
"/usr/bin/kicad-cli",
"/usr/local/bin/kicad-cli",
]
for path in possible_paths:
if os.path.exists(path):
return path
return None

View File

@@ -107,13 +107,12 @@ async function shutdownServer(server: KiCADMcpServer) {
} }
} }
// Run the main function if this file is executed directly // Run the main function - always run when imported as module entry point
if (import.meta.url === `file://${process.argv[1]}`) { // The import.meta.url check was failing on Windows due to path separators
main().catch((error) => { main().catch((error) => {
logger.error(`Unhandled error in main: ${error}`); console.error(`Unhandled error in main: ${error}`);
process.exit(1); process.exit(1);
}); });
}
// For testing and programmatic usage // For testing and programmatic usage
export { KiCADMcpServer }; export { KiCADMcpServer };

View File

@@ -86,21 +86,10 @@ class Logger {
private log(level: LogLevel, message: string): void { private log(level: LogLevel, message: string): void {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`; const formattedMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}`;
// Log to console // Log to console.error (stderr) only - stdout is reserved for MCP protocol
switch (level) { // All log levels go to stderr to avoid corrupting STDIO MCP transport
case 'error': console.error(formattedMessage);
console.error(formattedMessage);
break;
case 'warn':
console.warn(formattedMessage);
break;
case 'info':
case 'debug':
default:
console.log(formattedMessage);
break;
}
// Log to file // Log to file
try { try {

View File

@@ -5,7 +5,7 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import express from 'express'; import express from 'express';
import { spawn, ChildProcess } from 'child_process'; import { spawn, exec, ChildProcess } from 'child_process';
import { existsSync } from 'fs'; import { existsSync } from 'fs';
import { join, dirname } from 'path'; import { join, dirname } from 'path';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -176,7 +176,6 @@ export class KiCADMcpServer {
logger.info('Validating pcbnew module access...'); logger.info('Validating pcbnew module access...');
const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`; const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`;
const { exec } = require('child_process');
try { try {
const { stdout, stderr } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => { const { stdout, stderr } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => {
@@ -325,7 +324,7 @@ export class KiCADMcpServer {
/** /**
* Call the KiCAD scripting interface to execute commands * Call the KiCAD scripting interface to execute commands
* *
* @param command The command to execute * @param command The command to execute
* @param params The parameters for the command * @param params The parameters for the command
* @returns The result of the command execution * @returns The result of the command execution
@@ -338,14 +337,23 @@ export class KiCADMcpServer {
reject(new Error("Python process for KiCAD scripting is not running")); reject(new Error("Python process for KiCAD scripting is not running"));
return; return;
} }
// Add request to queue // Determine timeout based on command type
// DRC and export operations need longer timeouts for large boards
let commandTimeout = 30000; // Default 30 seconds
const longRunningCommands = ['run_drc', 'export_gerber', 'export_pdf', 'export_3d'];
if (longRunningCommands.includes(command)) {
commandTimeout = 600000; // 10 minutes for long operations
logger.info(`Using extended timeout (${commandTimeout/1000}s) for command: ${command}`);
}
// Add request to queue with timeout info
this.requestQueue.push({ this.requestQueue.push({
request: { command, params }, request: { command, params, timeout: commandTimeout },
resolve, resolve,
reject reject
}); });
// Process the queue if not already processing // Process the queue if not already processing
if (!this.processingRequest) { if (!this.processingRequest) {
this.processNextRequest(); this.processNextRequest();
@@ -376,40 +384,46 @@ export class KiCADMcpServer {
// Set up response handling // Set up response handling
let responseData = ''; let responseData = '';
let timeoutHandle: NodeJS.Timeout | null = null;
// Clear any previous listeners // Clear any previous listeners
if (this.pythonProcess?.stdout) { if (this.pythonProcess?.stdout) {
this.pythonProcess.stdout.removeAllListeners('data'); this.pythonProcess.stdout.removeAllListeners('data');
this.pythonProcess.stdout.removeAllListeners('end'); this.pythonProcess.stdout.removeAllListeners('end');
} }
// Set up new listeners // Set up new listeners
if (this.pythonProcess?.stdout) { if (this.pythonProcess?.stdout) {
this.pythonProcess.stdout.on('data', (data: Buffer) => { this.pythonProcess.stdout.on('data', (data: Buffer) => {
const chunk = data.toString(); const chunk = data.toString();
logger.debug(`Received data chunk: ${chunk.length} bytes`); logger.debug(`Received data chunk: ${chunk.length} bytes`);
responseData += chunk; responseData += chunk;
// Check if we have a complete response // Check if we have a complete response
try { try {
// Try to parse the response as JSON // Try to parse the response as JSON
const result = JSON.parse(responseData); const result = JSON.parse(responseData);
// If we get here, we have a valid JSON response // If we get here, we have a valid JSON response
logger.debug(`Completed KiCAD command: ${request.command} with result: ${result.success ? 'success' : 'failure'}`); logger.debug(`Completed KiCAD command: ${request.command} with result: ${result.success ? 'success' : 'failure'}`);
// Clear the timeout since we got a response
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
// Reset processing flag // Reset processing flag
this.processingRequest = false; this.processingRequest = false;
// Process next request if any // Process next request if any
setTimeout(() => this.processNextRequest(), 0); setTimeout(() => this.processNextRequest(), 0);
// Clear listeners // Clear listeners
if (this.pythonProcess?.stdout) { if (this.pythonProcess?.stdout) {
this.pythonProcess.stdout.removeAllListeners('data'); this.pythonProcess.stdout.removeAllListeners('data');
this.pythonProcess.stdout.removeAllListeners('end'); this.pythonProcess.stdout.removeAllListeners('end');
} }
// Resolve the promise with the result // Resolve the promise with the result
resolve(result); resolve(result);
} catch (e) { } catch (e) {
@@ -417,26 +431,27 @@ export class KiCADMcpServer {
} }
}); });
} }
// Set a timeout // Set a timeout (use command-specific timeout or default)
const timeout = setTimeout(() => { const timeoutDuration = request.timeout || 30000;
logger.error(`Command timeout: ${request.command}`); timeoutHandle = setTimeout(() => {
logger.error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`);
// Clear listeners // Clear listeners
if (this.pythonProcess?.stdout) { if (this.pythonProcess?.stdout) {
this.pythonProcess.stdout.removeAllListeners('data'); this.pythonProcess.stdout.removeAllListeners('data');
this.pythonProcess.stdout.removeAllListeners('end'); this.pythonProcess.stdout.removeAllListeners('end');
} }
// Reset processing flag // Reset processing flag
this.processingRequest = false; this.processingRequest = false;
// Process next request // Process next request
setTimeout(() => this.processNextRequest(), 0); setTimeout(() => this.processNextRequest(), 0);
// Reject the promise // Reject the promise
reject(new Error(`Command timeout: ${request.command}`)); reject(new Error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`));
}, 30000); // 30 seconds timeout }, timeoutDuration);
// Write the request to the Python process // Write the request to the Python process
logger.debug(`Sending request: ${requestStr}`); logger.debug(`Sending request: ${requestStr}`);