feat: extend KiCad MCP server capabilities
This commit is contained in:
+119
-16
@@ -4,15 +4,18 @@ Board view command implementations for KiCAD interface
|
||||
|
||||
import os
|
||||
import pcbnew
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from PIL import Image
|
||||
import io
|
||||
import base64
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from PIL import Image
|
||||
import io
|
||||
import base64
|
||||
import tempfile
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
logger = logging.getLogger('kicad_interface')
|
||||
|
||||
class BoardViewCommands:
|
||||
class BoardViewCommands:
|
||||
"""Handles board viewing operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
@@ -166,16 +169,16 @@ class BoardViewCommands:
|
||||
"errorDetails": str(e)
|
||||
}
|
||||
|
||||
def _get_layer_type_name(self, type_id: int) -> str:
|
||||
"""Convert KiCAD layer type constant to name"""
|
||||
type_map = {
|
||||
pcbnew.LT_SIGNAL: "signal",
|
||||
pcbnew.LT_POWER: "power",
|
||||
pcbnew.LT_MIXED: "mixed",
|
||||
pcbnew.LT_JUMPER: "jumper"
|
||||
}
|
||||
# Note: LT_USER was removed in KiCAD 9.0
|
||||
return type_map.get(type_id, "unknown")
|
||||
def _get_layer_type_name(self, type_id: int) -> str:
|
||||
"""Convert KiCAD layer type constant to name"""
|
||||
type_map = {
|
||||
pcbnew.LT_SIGNAL: "signal",
|
||||
pcbnew.LT_POWER: "power",
|
||||
pcbnew.LT_MIXED: "mixed",
|
||||
pcbnew.LT_JUMPER: "jumper"
|
||||
}
|
||||
# Note: LT_USER was removed in KiCAD 9.0
|
||||
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"""
|
||||
@@ -230,3 +233,103 @@ class BoardViewCommands:
|
||||
"message": "Failed to get board extents",
|
||||
"errorDetails": str(e)
|
||||
}
|
||||
|
||||
def _find_kicad_cli(self) -> Optional[str]:
|
||||
return shutil.which("kicad-cli")
|
||||
|
||||
def get_board_3d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Render a 3D board preview using kicad-cli."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
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": "Save the board before requesting a 3D preview",
|
||||
}
|
||||
|
||||
kicad_cli = self._find_kicad_cli()
|
||||
if not kicad_cli:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "kicad-cli not found",
|
||||
"errorDetails": "KiCad CLI is required for 3D renders",
|
||||
}
|
||||
|
||||
angle = str(params.get("angle", "isometric")).lower()
|
||||
width = int(params.get("width", 1600))
|
||||
height = int(params.get("height", 900))
|
||||
|
||||
render_args = []
|
||||
if angle == "isometric":
|
||||
render_args.extend(["--side", "top", "--rotate", "-45,0,45", "--perspective"])
|
||||
elif angle in {"top", "bottom", "left", "right", "front", "back"}:
|
||||
render_args.extend(["--side", angle])
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Unsupported angle",
|
||||
"errorDetails": f"Angle '{angle}' is not supported",
|
||||
}
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmp:
|
||||
output_path = tmp.name
|
||||
|
||||
try:
|
||||
cmd = [
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"render",
|
||||
"--output",
|
||||
output_path,
|
||||
"--width",
|
||||
str(width),
|
||||
"--height",
|
||||
str(height),
|
||||
"--background",
|
||||
"transparent",
|
||||
"--quality",
|
||||
"high",
|
||||
*render_args,
|
||||
board_file,
|
||||
]
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=180,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "3D render failed",
|
||||
"errorDetails": result.stderr.strip() or result.stdout.strip(),
|
||||
}
|
||||
|
||||
with open(output_path, "rb") as f:
|
||||
image_bytes = f.read()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"imageData": base64.b64encode(image_bytes).decode("utf-8"),
|
||||
"format": "png",
|
||||
"angle": angle,
|
||||
}
|
||||
finally:
|
||||
if os.path.exists(output_path):
|
||||
os.remove(output_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board 3D view: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board 3D view",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
+506
-12
@@ -4,15 +4,17 @@ Component-related command implementations for KiCAD interface
|
||||
|
||||
import os
|
||||
import pcbnew
|
||||
import logging
|
||||
import math
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
import base64
|
||||
from commands.library import LibraryManager
|
||||
import logging
|
||||
import math
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
import base64
|
||||
import io
|
||||
from commands.library import LibraryManager
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
logger = logging.getLogger('kicad_interface')
|
||||
|
||||
class ComponentCommands:
|
||||
class ComponentCommands:
|
||||
"""Handles component-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None):
|
||||
@@ -914,7 +916,7 @@ class ComponentCommands:
|
||||
"errorDetails": str(e)
|
||||
}
|
||||
|
||||
def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Duplicate an existing component"""
|
||||
try:
|
||||
if not self.board:
|
||||
@@ -1010,11 +1012,503 @@ class ComponentCommands:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error duplicating component: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to duplicate component",
|
||||
"errorDetails": str(e)
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to duplicate component",
|
||||
"errorDetails": str(e)
|
||||
}
|
||||
|
||||
def add_component_annotation(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a text annotation close to a component footprint."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
reference = params.get("reference")
|
||||
annotation = params.get("annotation")
|
||||
visible = params.get("visible", True)
|
||||
|
||||
if not reference or not annotation:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "reference and annotation are required",
|
||||
}
|
||||
|
||||
module = self.board.FindFootprintByReference(reference)
|
||||
if not module:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Component not found",
|
||||
"errorDetails": f"Could not find component: {reference}",
|
||||
}
|
||||
|
||||
bbox = module.GetBoundingBox()
|
||||
layer_name = "B.SilkS" if self.board.GetLayerName(module.GetLayer()).startswith("B.") else "F.SilkS"
|
||||
layer_id = self.board.GetLayerID(layer_name)
|
||||
|
||||
text_item = pcbnew.PCB_TEXT(self.board)
|
||||
text_item.SetText(annotation)
|
||||
text_item.SetLayer(layer_id)
|
||||
text_item.SetPosition(
|
||||
pcbnew.VECTOR2I(
|
||||
bbox.GetRight() + 800000,
|
||||
bbox.GetTop() - 800000,
|
||||
)
|
||||
)
|
||||
text_item.SetTextSize(pcbnew.VECTOR2I(1000000, 1000000))
|
||||
text_item.SetTextThickness(150000)
|
||||
if hasattr(text_item, "SetVisible"):
|
||||
text_item.SetVisible(bool(visible))
|
||||
|
||||
self.board.Add(text_item)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Added annotation to {reference}",
|
||||
"annotation": {
|
||||
"reference": reference,
|
||||
"text": annotation,
|
||||
"layer": layer_name,
|
||||
"visible": bool(visible),
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding component annotation: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add component annotation",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def group_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create or extend a real KiCad PCB group with the selected components."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
references = params.get("references", [])
|
||||
group_name = params.get("groupName")
|
||||
if not references or not group_name:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "references and groupName are required",
|
||||
}
|
||||
|
||||
modules = []
|
||||
missing = []
|
||||
for reference in references:
|
||||
module = self.board.FindFootprintByReference(reference)
|
||||
if not module:
|
||||
missing.append(reference)
|
||||
continue
|
||||
modules.append(module)
|
||||
|
||||
if not modules:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No valid components found",
|
||||
"errorDetails": f"Missing references: {', '.join(missing)}",
|
||||
}
|
||||
|
||||
group = None
|
||||
if hasattr(self.board, "Groups"):
|
||||
for existing in self.board.Groups():
|
||||
name = existing.GetName() if hasattr(existing, "GetName") else ""
|
||||
if name == group_name:
|
||||
group = existing
|
||||
break
|
||||
|
||||
if group is None:
|
||||
group = pcbnew.PCB_GROUP(self.board)
|
||||
if hasattr(group, "SetName"):
|
||||
group.SetName(group_name)
|
||||
self.board.Add(group)
|
||||
|
||||
for module in modules:
|
||||
group.AddItem(module)
|
||||
if hasattr(module, "SetParentGroup"):
|
||||
module.SetParentGroup(group)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Grouped {len(modules)} component(s) into {group_name}",
|
||||
"group": {
|
||||
"name": group_name,
|
||||
"references": [module.GetReference() for module in modules],
|
||||
"missing": missing,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error grouping components: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to group components",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def replace_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Replace a component footprint while preserving placement and reference."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
reference = params.get("reference")
|
||||
new_component_id = params.get("newComponentId")
|
||||
new_footprint = params.get("newFootprint")
|
||||
new_value = params.get("newValue")
|
||||
|
||||
if not reference or not new_component_id:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "reference and newComponentId are required",
|
||||
}
|
||||
|
||||
module = self.board.FindFootprintByReference(reference)
|
||||
if not module:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Component not found",
|
||||
"errorDetails": f"Could not find component: {reference}",
|
||||
}
|
||||
|
||||
pos = module.GetPosition()
|
||||
layer = self.board.GetLayerName(module.GetLayer())
|
||||
rotation = module.GetOrientation().AsDegrees()
|
||||
value = new_value or module.GetValue()
|
||||
temp_reference = f"{reference}_TMP"
|
||||
replacement_spec = new_footprint or new_component_id
|
||||
|
||||
place_result = self.place_component(
|
||||
{
|
||||
"componentId": new_component_id,
|
||||
"position": {
|
||||
"x": pos.x / 1000000,
|
||||
"y": pos.y / 1000000,
|
||||
"unit": "mm",
|
||||
},
|
||||
"reference": temp_reference,
|
||||
"value": value,
|
||||
"footprint": replacement_spec,
|
||||
"rotation": rotation,
|
||||
"layer": layer,
|
||||
}
|
||||
)
|
||||
if not place_result.get("success"):
|
||||
return place_result
|
||||
|
||||
delete_result = self.delete_component({"reference": reference})
|
||||
if not delete_result.get("success"):
|
||||
return delete_result
|
||||
|
||||
rename_result = self.edit_component(
|
||||
{
|
||||
"reference": temp_reference,
|
||||
"newReference": reference,
|
||||
"value": value,
|
||||
"footprint": replacement_spec,
|
||||
}
|
||||
)
|
||||
if not rename_result.get("success"):
|
||||
return rename_result
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Replaced component {reference}",
|
||||
"component": {
|
||||
"reference": reference,
|
||||
"newComponentId": new_component_id,
|
||||
"footprint": replacement_spec,
|
||||
"value": value,
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error replacing component: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to replace component",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_component_connections(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get pad and net connectivity for a component."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
reference = params.get("reference")
|
||||
if not reference:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing reference",
|
||||
"errorDetails": "reference parameter is required",
|
||||
}
|
||||
|
||||
module = self.board.FindFootprintByReference(reference)
|
||||
if not module:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Component not found",
|
||||
"errorDetails": f"Could not find component: {reference}",
|
||||
}
|
||||
|
||||
nets = {}
|
||||
pads = []
|
||||
for pad in module.Pads():
|
||||
net_name = pad.GetNetname() or ""
|
||||
pad_entry = {
|
||||
"pad": pad.GetNumber(),
|
||||
"net": net_name,
|
||||
"position": {
|
||||
"x": pad.GetPosition().x / 1000000,
|
||||
"y": pad.GetPosition().y / 1000000,
|
||||
"unit": "mm",
|
||||
},
|
||||
}
|
||||
pads.append(pad_entry)
|
||||
if net_name:
|
||||
nets.setdefault(net_name, []).append(pad.GetNumber())
|
||||
|
||||
related_components = {}
|
||||
for other in self.board.GetFootprints():
|
||||
other_ref = other.GetReference()
|
||||
if other_ref == reference:
|
||||
continue
|
||||
for pad in other.Pads():
|
||||
net_name = pad.GetNetname() or ""
|
||||
if net_name and net_name in nets:
|
||||
related_components.setdefault(net_name, set()).add(other_ref)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"reference": reference,
|
||||
"pads": pads,
|
||||
"connections": [
|
||||
{
|
||||
"net": net_name,
|
||||
"pads": pad_numbers,
|
||||
"connectedReferences": sorted(related_components.get(net_name, set())),
|
||||
}
|
||||
for net_name, pad_numbers in sorted(nets.items())
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting component connections: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get component connections",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_component_placement(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get placement data for all components on the board."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
placements = []
|
||||
for module in self.board.GetFootprints():
|
||||
bbox = module.GetBoundingBox()
|
||||
placements.append(
|
||||
{
|
||||
"reference": module.GetReference(),
|
||||
"value": module.GetValue(),
|
||||
"footprint": module.GetFPIDAsString(),
|
||||
"layer": self.board.GetLayerName(module.GetLayer()),
|
||||
"position": {
|
||||
"x": module.GetPosition().x / 1000000,
|
||||
"y": module.GetPosition().y / 1000000,
|
||||
"unit": "mm",
|
||||
},
|
||||
"rotation": module.GetOrientation().AsDegrees(),
|
||||
"bounds": {
|
||||
"left": bbox.GetLeft() / 1000000,
|
||||
"top": bbox.GetTop() / 1000000,
|
||||
"right": bbox.GetRight() / 1000000,
|
||||
"bottom": bbox.GetBottom() / 1000000,
|
||||
"unit": "mm",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"placements": placements,
|
||||
"count": len(placements),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting component placement: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get component placement",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_component_groups(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get explicit KiCad groups, or derived prefix groups when none exist."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
grouped = {}
|
||||
derived = {}
|
||||
for module in self.board.GetFootprints():
|
||||
reference = module.GetReference()
|
||||
parent_group = module.GetParentGroup() if hasattr(module, "GetParentGroup") else None
|
||||
if parent_group:
|
||||
name = ""
|
||||
if hasattr(parent_group, "GetName"):
|
||||
name = parent_group.GetName()
|
||||
if not name and hasattr(parent_group, "GetFriendlyName"):
|
||||
name = parent_group.GetFriendlyName()
|
||||
name = name or "unnamed-group"
|
||||
grouped.setdefault(name, {"name": name, "source": "board", "references": []})
|
||||
grouped[name]["references"].append(reference)
|
||||
else:
|
||||
prefix = "".join(ch for ch in reference if ch.isalpha()) or "misc"
|
||||
derived.setdefault(prefix, {"name": prefix, "source": "derived-prefix", "references": []})
|
||||
derived[prefix]["references"].append(reference)
|
||||
|
||||
groups = list(grouped.values()) if grouped else list(derived.values())
|
||||
for group in groups:
|
||||
group["references"].sort()
|
||||
group["count"] = len(group["references"])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"groups": sorted(groups, key=lambda group: group["name"]),
|
||||
"count": len(groups),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting component groups: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get component groups",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_component_visualization(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Render a lightweight top-down PNG preview for a component footprint."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
reference = params.get("reference")
|
||||
if not reference:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing reference",
|
||||
"errorDetails": "reference parameter is required",
|
||||
}
|
||||
|
||||
module = self.board.FindFootprintByReference(reference)
|
||||
if not module:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Component not found",
|
||||
"errorDetails": f"Could not find component: {reference}",
|
||||
}
|
||||
|
||||
bbox = module.GetBoundingBox()
|
||||
margin_nm = 500000
|
||||
min_x = bbox.GetLeft() - margin_nm
|
||||
min_y = bbox.GetTop() - margin_nm
|
||||
max_x = bbox.GetRight() + margin_nm
|
||||
max_y = bbox.GetBottom() + margin_nm
|
||||
|
||||
width_nm = max(max_x - min_x, 1000000)
|
||||
height_nm = max(max_y - min_y, 1000000)
|
||||
canvas_size = 320
|
||||
padding = 20
|
||||
scale = min(
|
||||
(canvas_size - 2 * padding) / width_nm,
|
||||
(canvas_size - 2 * padding) / height_nm,
|
||||
)
|
||||
|
||||
def to_px(x_nm: int, y_nm: int) -> Tuple[int, int]:
|
||||
x = padding + int((x_nm - min_x) * scale)
|
||||
y = padding + int((y_nm - min_y) * scale)
|
||||
return x, y
|
||||
|
||||
image = Image.new("RGBA", (canvas_size, canvas_size), (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
body_left, body_top = to_px(bbox.GetLeft(), bbox.GetTop())
|
||||
body_right, body_bottom = to_px(bbox.GetRight(), bbox.GetBottom())
|
||||
draw.rounded_rectangle(
|
||||
[body_left, body_top, body_right, body_bottom],
|
||||
radius=10,
|
||||
outline=(36, 74, 124, 255),
|
||||
width=3,
|
||||
fill=(230, 238, 250, 180),
|
||||
)
|
||||
|
||||
for pad in module.Pads():
|
||||
pad_box = pad.GetBoundingBox()
|
||||
x0, y0 = to_px(pad_box.GetLeft(), pad_box.GetTop())
|
||||
x1, y1 = to_px(pad_box.GetRight(), pad_box.GetBottom())
|
||||
shape = pad.GetShape()
|
||||
circle_shapes = {
|
||||
getattr(pcbnew, "PAD_SHAPE_CIRCLE", object()),
|
||||
getattr(pcbnew, "PAD_SHAPE_OVAL", object()),
|
||||
}
|
||||
if shape in circle_shapes:
|
||||
draw.ellipse([x0, y0, x1, y1], fill=(201, 96, 74, 255), outline=(122, 41, 27, 255))
|
||||
else:
|
||||
draw.rounded_rectangle(
|
||||
[x0, y0, x1, y1],
|
||||
radius=4,
|
||||
fill=(201, 96, 74, 255),
|
||||
outline=(122, 41, 27, 255),
|
||||
)
|
||||
|
||||
draw.text((16, 12), reference, fill=(22, 22, 22, 255))
|
||||
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return {
|
||||
"success": True,
|
||||
"imageData": base64.b64encode(buffer.getvalue()).decode("utf-8"),
|
||||
"format": "png",
|
||||
"reference": reference,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting component visualization: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get component visualization",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _place_grid_array(self, component_id: str, start_position: Dict[str, Any],
|
||||
rows: int, columns: int, spacing_x: float, spacing_y: float,
|
||||
|
||||
+193
-11
@@ -2,15 +2,16 @@
|
||||
Design rules command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import os
|
||||
import pcbnew
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
import os
|
||||
import pcbnew
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
import json
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class DesignRuleCommands:
|
||||
class DesignRuleCommands:
|
||||
"""Handles design rule checking and configuration"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
@@ -410,7 +411,7 @@ class DesignRuleCommands:
|
||||
|
||||
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
|
||||
|
||||
@@ -467,8 +468,189 @@ class DesignRuleCommands:
|
||||
|
||||
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),
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get DRC violations",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _get_project_file(self) -> Optional[str]:
|
||||
if not self.board:
|
||||
return None
|
||||
board_file = self.board.GetFileName()
|
||||
if not board_file:
|
||||
return None
|
||||
return os.path.splitext(board_file)[0] + ".kicad_pro"
|
||||
|
||||
def assign_net_to_class(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Assign an existing net to an existing net class."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
net_name = params.get("net")
|
||||
net_class_name = params.get("netClass")
|
||||
if not net_name or not net_class_name:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "net and netClass are required",
|
||||
}
|
||||
|
||||
net_classes = self.board.GetNetClasses()
|
||||
net_class = net_classes.Find(net_class_name)
|
||||
if not net_class:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Net class not found",
|
||||
"errorDetails": f"Net class '{net_class_name}' does not exist",
|
||||
}
|
||||
|
||||
nets_map = self.board.GetNetInfo().NetsByName()
|
||||
if not nets_map.has_key(net_name):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Net not found",
|
||||
"errorDetails": f"Net '{net_name}' does not exist",
|
||||
}
|
||||
|
||||
net = nets_map[net_name]
|
||||
net.SetClass(net_class)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Assigned net {net_name} to class {net_class_name}",
|
||||
"assignment": {"net": net_name, "netClass": net_class_name},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error assigning net to class: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to assign net to class",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def set_layer_constraints(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Persist layer-specific constraints in the project file under kicad_mcp."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
layer = params.get("layer")
|
||||
if not layer:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing layer parameter",
|
||||
"errorDetails": "layer is required",
|
||||
}
|
||||
|
||||
project_file = self._get_project_file()
|
||||
if not project_file:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Project file unavailable",
|
||||
"errorDetails": "Save the board before setting layer constraints",
|
||||
}
|
||||
|
||||
payload = {}
|
||||
if os.path.exists(project_file):
|
||||
with open(project_file, "r", encoding="utf-8") as f:
|
||||
try:
|
||||
payload = json.load(f)
|
||||
except json.JSONDecodeError:
|
||||
payload = {}
|
||||
|
||||
constraints = {
|
||||
key: value
|
||||
for key, value in {
|
||||
"minTrackWidth": params.get("minTrackWidth"),
|
||||
"minClearance": params.get("minClearance"),
|
||||
"minViaDiameter": params.get("minViaDiameter"),
|
||||
"minViaDrill": params.get("minViaDrill"),
|
||||
}.items()
|
||||
if value is not None
|
||||
}
|
||||
|
||||
payload.setdefault("kicad_mcp", {}).setdefault("layer_constraints", {})[layer] = constraints
|
||||
with open(project_file, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, indent=2)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Stored layer constraints for {layer}",
|
||||
"layer": layer,
|
||||
"constraints": constraints,
|
||||
"projectFile": project_file,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting layer constraints: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to set layer constraints",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def check_clearance(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Approximate clearance check using item positions and bounding boxes."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
scale = 1000000
|
||||
min_clearance = self.board.GetDesignSettings().m_MinClearance / scale
|
||||
|
||||
def _resolve_point(item: Dict[str, Any]) -> Optional[Tuple[float, float]]:
|
||||
if item.get("position"):
|
||||
pos = item["position"]
|
||||
unit_scale = 1.0 if pos.get("unit", "mm") == "mm" else 25.4
|
||||
return (float(pos.get("x", 0)) * unit_scale, float(pos.get("y", 0)) * unit_scale)
|
||||
|
||||
if item.get("reference"):
|
||||
module = self.board.FindFootprintByReference(item["reference"])
|
||||
if module:
|
||||
pos = module.GetPosition()
|
||||
return (pos.x / scale, pos.y / scale)
|
||||
|
||||
return None
|
||||
|
||||
point1 = _resolve_point(params.get("item1", {}))
|
||||
point2 = _resolve_point(params.get("item2", {}))
|
||||
if not point1 or not point2:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Unable to resolve both items",
|
||||
"errorDetails": "Provide either positions or component references",
|
||||
}
|
||||
|
||||
dx = point1[0] - point2[0]
|
||||
dy = point1[1] - point2[1]
|
||||
distance = (dx * dx + dy * dy) ** 0.5
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"clearance": {
|
||||
"actual": distance,
|
||||
"required": min_clearance,
|
||||
"passes": distance >= min_clearance,
|
||||
"unit": "mm",
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking clearance: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to check clearance",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
+188
-14
@@ -2,16 +2,17 @@
|
||||
Export command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import os
|
||||
import pcbnew
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
import base64
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
import os
|
||||
import pcbnew
|
||||
import logging
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
import base64
|
||||
import subprocess
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class ExportCommands:
|
||||
class ExportCommands:
|
||||
"""Handles export-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
@@ -462,7 +463,7 @@ class ExportCommands:
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def export_bom(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def export_bom(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Export Bill of Materials"""
|
||||
try:
|
||||
if not self.board:
|
||||
@@ -550,11 +551,184 @@ class ExportCommands:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting BOM: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to export BOM",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to export BOM",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def export_netlist(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Export the current project's schematic netlist using kicad-cli."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
output_path = params.get("outputPath")
|
||||
if not output_path:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing output path",
|
||||
"errorDetails": "outputPath parameter is required",
|
||||
}
|
||||
|
||||
board_file = self.board.GetFileName()
|
||||
if not board_file:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Board file not found",
|
||||
"errorDetails": "Save the board before exporting a netlist",
|
||||
}
|
||||
|
||||
schematic_path = os.path.splitext(board_file)[0] + ".kicad_sch"
|
||||
if not os.path.exists(schematic_path):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Schematic file not found",
|
||||
"errorDetails": f"No schematic found at {schematic_path}",
|
||||
}
|
||||
|
||||
output_path = os.path.abspath(os.path.expanduser(output_path))
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
format_map = {
|
||||
"kicad": "kicadsexpr",
|
||||
"spice": "spice",
|
||||
"cadstar": "cadstar",
|
||||
"orcadpcb2": "orcadpcb2",
|
||||
}
|
||||
format_name = str(params.get("format", "KiCad")).lower()
|
||||
cli_format = format_map.get(format_name, "kicadsexpr")
|
||||
|
||||
kicad_cli = self._find_kicad_cli()
|
||||
if not kicad_cli:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "kicad-cli not found",
|
||||
"errorDetails": "KiCad CLI is required for netlist export",
|
||||
}
|
||||
|
||||
cmd = [
|
||||
kicad_cli,
|
||||
"sch",
|
||||
"export",
|
||||
"netlist",
|
||||
"--output",
|
||||
output_path,
|
||||
"--format",
|
||||
cli_format,
|
||||
schematic_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Netlist export failed",
|
||||
"errorDetails": result.stderr.strip() or result.stdout.strip(),
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Exported netlist",
|
||||
"file": {"path": output_path, "format": cli_format, "schematicPath": schematic_path},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting netlist: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to export netlist",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def export_position_file(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Export a pick-and-place position file using kicad-cli."""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
output_path = params.get("outputPath")
|
||||
if not output_path:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing output path",
|
||||
"errorDetails": "outputPath parameter is required",
|
||||
}
|
||||
|
||||
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": "Save the board before exporting a position file",
|
||||
}
|
||||
|
||||
output_path = os.path.abspath(os.path.expanduser(output_path))
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
|
||||
side_map = {"top": "front", "bottom": "back", "both": "both"}
|
||||
format_map = {"csv": "csv", "ascii": "ascii"}
|
||||
units_map = {"mm": "mm", "inch": "in"}
|
||||
|
||||
side = side_map.get(str(params.get("side", "both")).lower(), "both")
|
||||
format_name = format_map.get(str(params.get("format", "ASCII")).lower(), "ascii")
|
||||
units = units_map.get(str(params.get("units", "mm")).lower(), "mm")
|
||||
|
||||
kicad_cli = self._find_kicad_cli()
|
||||
if not kicad_cli:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "kicad-cli not found",
|
||||
"errorDetails": "KiCad CLI is required for position export",
|
||||
}
|
||||
|
||||
cmd = [
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"export",
|
||||
"pos",
|
||||
"--output",
|
||||
output_path,
|
||||
"--side",
|
||||
side,
|
||||
"--format",
|
||||
format_name,
|
||||
"--units",
|
||||
units,
|
||||
board_file,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
|
||||
if result.returncode != 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Position export failed",
|
||||
"errorDetails": result.stderr.strip() or result.stdout.strip(),
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Exported position file",
|
||||
"file": {"path": output_path, "format": format_name, "units": units, "side": side},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error exporting position file: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to export position file",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def export_vrml(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Export VRML via the generic 3D export path."""
|
||||
bridged = dict(params)
|
||||
bridged["format"] = "VRML"
|
||||
return self.export_3d(bridged)
|
||||
|
||||
def _export_bom_csv(self, path: str, components: List[Dict[str, Any]]) -> None:
|
||||
"""Export BOM to CSV format"""
|
||||
|
||||
@@ -40,12 +40,12 @@ class JLCPCBClient:
|
||||
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
|
||||
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
|
||||
"""
|
||||
self.app_id = app_id or os.getenv('JLCPCB_APP_ID')
|
||||
self.access_key = access_key or os.getenv('JLCPCB_API_KEY')
|
||||
self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET')
|
||||
|
||||
if not self.app_id or not self.access_key or not self.secret_key:
|
||||
logger.warning("JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.")
|
||||
self.app_id = app_id or os.getenv('JLCPCB_APP_ID')
|
||||
self.access_key = access_key or os.getenv('JLCPCB_API_KEY')
|
||||
self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET')
|
||||
|
||||
if not self.app_id or not self.access_key or not self.secret_key:
|
||||
logger.info("JLCPCB API credentials not found. JLCPCB API features stay disabled until credentials are configured.")
|
||||
|
||||
@staticmethod
|
||||
def _generate_nonce() -> str:
|
||||
|
||||
@@ -40,10 +40,10 @@ class LibraryManager:
|
||||
# Load global libraries
|
||||
global_table = self._get_global_fp_lib_table()
|
||||
if global_table and global_table.exists():
|
||||
logger.info(f"Loading global fp-lib-table from: {global_table}")
|
||||
self._parse_fp_lib_table(global_table)
|
||||
else:
|
||||
logger.warning(f"Global fp-lib-table not found at: {global_table}")
|
||||
logger.info(f"Loading global fp-lib-table from: {global_table}")
|
||||
self._parse_fp_lib_table(global_table)
|
||||
else:
|
||||
logger.info(f"Global fp-lib-table not found at: {global_table}")
|
||||
|
||||
# Load project-specific libraries if project path provided
|
||||
if self.project_path:
|
||||
@@ -312,8 +312,8 @@ class LibraryManager:
|
||||
logger.info(f"Found KiCad 3rd party directory: {candidate}")
|
||||
return str(candidate)
|
||||
|
||||
logger.warning("Could not find KiCad 3rd party directory")
|
||||
return None
|
||||
logger.info("Could not find KiCad 3rd party directory")
|
||||
return None
|
||||
|
||||
def list_libraries(self) -> List[str]:
|
||||
"""Get list of available library nicknames"""
|
||||
|
||||
@@ -62,7 +62,7 @@ class SymbolLibraryManager:
|
||||
logger.info(f"Loading global sym-lib-table from: {global_table}")
|
||||
self._parse_sym_lib_table(global_table)
|
||||
else:
|
||||
logger.warning(f"Global sym-lib-table not found at: {global_table}")
|
||||
logger.info(f"Global sym-lib-table not found at: {global_table}")
|
||||
|
||||
# Load project-specific libraries if project path provided
|
||||
if self.project_path:
|
||||
|
||||
+199
-18
@@ -2,21 +2,42 @@
|
||||
Project-related command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import os
|
||||
import pcbnew # type: ignore
|
||||
import logging
|
||||
import shutil
|
||||
from typing import Dict, Any, Optional
|
||||
import os
|
||||
import pcbnew # type: ignore
|
||||
import logging
|
||||
import shutil
|
||||
from typing import Dict, Any, Optional
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class ProjectCommands:
|
||||
class ProjectCommands:
|
||||
"""Handles project-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def _get_project_context(self) -> Optional[Dict[str, str]]:
|
||||
if not self.board:
|
||||
return None
|
||||
|
||||
board_path = self.board.GetFileName()
|
||||
if not board_path:
|
||||
return None
|
||||
|
||||
board_path = os.path.abspath(os.path.expanduser(board_path))
|
||||
project_root, board_name = os.path.split(board_path)
|
||||
project_stem, _ = os.path.splitext(board_name)
|
||||
|
||||
return {
|
||||
"board_path": board_path,
|
||||
"project_root": project_root,
|
||||
"project_name": project_stem,
|
||||
"project_file": os.path.join(project_root, f"{project_stem}.kicad_pro"),
|
||||
"schematic_file": os.path.join(project_root, f"{project_stem}.kicad_sch"),
|
||||
}
|
||||
|
||||
def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new KiCAD project"""
|
||||
@@ -222,10 +243,10 @@ class ProjectCommands:
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current project"""
|
||||
try:
|
||||
if not self.board:
|
||||
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current project"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
@@ -253,8 +274,168 @@ class ProjectCommands:
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting project info: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get project information",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get project information",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_project_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get resolved project file paths and title block metadata."""
|
||||
try:
|
||||
context = self._get_project_context()
|
||||
if not context:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
title_block = self.board.GetTitleBlock()
|
||||
project_file = context["project_file"]
|
||||
board_path = context["board_path"]
|
||||
schematic_file = context["schematic_file"]
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"properties": {
|
||||
"projectName": context["project_name"],
|
||||
"projectRoot": context["project_root"],
|
||||
"projectFile": project_file,
|
||||
"boardFile": board_path,
|
||||
"schematicFile": schematic_file,
|
||||
"exists": {
|
||||
"projectFile": os.path.exists(project_file),
|
||||
"boardFile": os.path.exists(board_path),
|
||||
"schematicFile": os.path.exists(schematic_file),
|
||||
},
|
||||
"titleBlock": {
|
||||
"title": title_block.GetTitle(),
|
||||
"date": title_block.GetDate(),
|
||||
"revision": title_block.GetRevision(),
|
||||
"company": title_block.GetCompany(),
|
||||
"comment1": title_block.GetComment(0),
|
||||
"comment2": title_block.GetComment(1),
|
||||
"comment3": title_block.GetComment(2),
|
||||
"comment4": title_block.GetComment(3),
|
||||
},
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting project properties: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get project properties",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_project_files(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List the files that make up the current KiCad project directory."""
|
||||
try:
|
||||
context = self._get_project_context()
|
||||
if not context:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
project_root = context["project_root"]
|
||||
project_name = context["project_name"]
|
||||
files = []
|
||||
|
||||
for entry in sorted(os.listdir(project_root)):
|
||||
path = os.path.join(project_root, entry)
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
|
||||
if not (
|
||||
entry.startswith(project_name)
|
||||
or entry in {"fp-lib-table", "sym-lib-table"}
|
||||
or entry.endswith(
|
||||
(
|
||||
".kicad_pro",
|
||||
".kicad_prl",
|
||||
".kicad_pcb",
|
||||
".kicad_sch",
|
||||
".dru",
|
||||
".json",
|
||||
".csv",
|
||||
".txt",
|
||||
)
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
ext = os.path.splitext(entry)[1].lower()
|
||||
file_type = {
|
||||
".kicad_pro": "project",
|
||||
".kicad_prl": "local-settings",
|
||||
".kicad_pcb": "board",
|
||||
".kicad_sch": "schematic",
|
||||
".kicad_dru": "design-rules",
|
||||
".csv": "report",
|
||||
".json": "report",
|
||||
".txt": "report",
|
||||
}.get(ext, "auxiliary")
|
||||
|
||||
stat = os.stat(path)
|
||||
files.append(
|
||||
{
|
||||
"name": entry,
|
||||
"path": path,
|
||||
"type": file_type,
|
||||
"sizeBytes": stat.st_size,
|
||||
"modifiedAt": datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"projectRoot": project_root,
|
||||
"files": files,
|
||||
"count": len(files),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting project files: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get project files",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_project_status(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the runtime status of the current project."""
|
||||
try:
|
||||
context = self._get_project_context()
|
||||
if not context:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
files_result = self.get_project_files({})
|
||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"status": {
|
||||
"projectName": context["project_name"],
|
||||
"projectRoot": context["project_root"],
|
||||
"boardLoaded": self.board is not None,
|
||||
"boardSaved": bool(context["board_path"]) and os.path.exists(context["board_path"]),
|
||||
"projectFileExists": os.path.exists(context["project_file"]),
|
||||
"schematicFileExists": os.path.exists(context["schematic_file"]),
|
||||
"boardOutlinePresent": board_box.GetWidth() > 0 and board_box.GetHeight() > 0,
|
||||
"componentCount": len(list(self.board.GetFootprints())),
|
||||
"fileCount": files_result.get("count", 0),
|
||||
},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting project status: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get project status",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
+31
-32
@@ -1006,15 +1006,14 @@ class RoutingCommands:
|
||||
"errorDetails": "name parameter is required",
|
||||
}
|
||||
|
||||
# Get net classes
|
||||
net_classes = self.board.GetNetClasses()
|
||||
|
||||
# Create new net class if it doesn't exist
|
||||
if not net_classes.Find(name):
|
||||
netclass = pcbnew.NETCLASS(name)
|
||||
net_classes.Add(netclass)
|
||||
else:
|
||||
netclass = net_classes.Find(name)
|
||||
# KiCad 10 exposes netclasses_map with lowercase helpers and item assignment.
|
||||
net_classes = self.board.GetNetClasses()
|
||||
|
||||
if net_classes.has_key(name):
|
||||
netclass = net_classes[name]
|
||||
else:
|
||||
netclass = pcbnew.NETCLASS(name)
|
||||
net_classes[name] = netclass
|
||||
|
||||
# Set properties
|
||||
scale = 1000000 # mm to nm
|
||||
@@ -1022,18 +1021,18 @@ class RoutingCommands:
|
||||
netclass.SetClearance(int(clearance * scale))
|
||||
if track_width is not None:
|
||||
netclass.SetTrackWidth(int(track_width * scale))
|
||||
if via_diameter is not None:
|
||||
netclass.SetViaDiameter(int(via_diameter * scale))
|
||||
if via_drill is not None:
|
||||
netclass.SetViaDrill(int(via_drill * scale))
|
||||
if uvia_diameter is not None:
|
||||
netclass.SetMicroViaDiameter(int(uvia_diameter * scale))
|
||||
if uvia_drill is not None:
|
||||
netclass.SetMicroViaDrill(int(uvia_drill * scale))
|
||||
if diff_pair_width is not None:
|
||||
netclass.SetDiffPairWidth(int(diff_pair_width * scale))
|
||||
if diff_pair_gap is not None:
|
||||
netclass.SetDiffPairGap(int(diff_pair_gap * scale))
|
||||
if via_diameter is not None:
|
||||
netclass.SetViaDiameter(int(via_diameter * scale))
|
||||
if via_drill is not None:
|
||||
netclass.SetViaDrill(int(via_drill * scale))
|
||||
if uvia_diameter is not None:
|
||||
netclass.SetuViaDiameter(int(uvia_diameter * scale))
|
||||
if uvia_drill is not None:
|
||||
netclass.SetuViaDrill(int(uvia_drill * scale))
|
||||
if diff_pair_width is not None:
|
||||
netclass.SetDiffPairWidth(int(diff_pair_width * scale))
|
||||
if diff_pair_gap is not None:
|
||||
netclass.SetDiffPairGap(int(diff_pair_gap * scale))
|
||||
|
||||
# Add nets to net class
|
||||
netinfo = self.board.GetNetInfo()
|
||||
@@ -1047,17 +1046,17 @@ class RoutingCommands:
|
||||
"success": True,
|
||||
"message": f"Created net class: {name}",
|
||||
"netClass": {
|
||||
"name": name,
|
||||
"clearance": netclass.GetClearance() / scale,
|
||||
"trackWidth": netclass.GetTrackWidth() / scale,
|
||||
"viaDiameter": netclass.GetViaDiameter() / scale,
|
||||
"viaDrill": netclass.GetViaDrill() / scale,
|
||||
"uviaDiameter": netclass.GetMicroViaDiameter() / scale,
|
||||
"uviaDrill": netclass.GetMicroViaDrill() / scale,
|
||||
"diffPairWidth": netclass.GetDiffPairWidth() / scale,
|
||||
"diffPairGap": netclass.GetDiffPairGap() / scale,
|
||||
"nets": nets,
|
||||
},
|
||||
"name": name,
|
||||
"clearance": netclass.GetClearance() / scale,
|
||||
"trackWidth": netclass.GetTrackWidth() / scale,
|
||||
"viaDiameter": netclass.GetViaDiameter() / scale,
|
||||
"viaDrill": netclass.GetViaDrill() / scale,
|
||||
"uviaDiameter": netclass.GetuViaDiameter() / scale,
|
||||
"uviaDrill": netclass.GetuViaDrill() / scale,
|
||||
"diffPairWidth": netclass.GetDiffPairWidth() / scale,
|
||||
"diffPairGap": netclass.GetDiffPairGap() / scale,
|
||||
"nets": nets,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
|
||||
+995
-18
File diff suppressed because it is too large
Load Diff
+18
-5
@@ -16,6 +16,15 @@ const __dirname = dirname(__filename);
|
||||
// Default config location
|
||||
const DEFAULT_CONFIG_PATH = join(dirname(__dirname), 'config', 'default-config.json');
|
||||
|
||||
const LOG_LEVELS = ['error', 'warn', 'info', 'debug'] as const;
|
||||
|
||||
function resolveLogLevel(): (typeof LOG_LEVELS)[number] {
|
||||
const value = process.env.KICAD_MCP_LOG_LEVEL;
|
||||
return LOG_LEVELS.includes(value as (typeof LOG_LEVELS)[number])
|
||||
? (value as (typeof LOG_LEVELS)[number])
|
||||
: 'warn';
|
||||
}
|
||||
|
||||
/**
|
||||
* Server configuration schema
|
||||
*/
|
||||
@@ -25,7 +34,7 @@ const ConfigSchema = z.object({
|
||||
description: z.string().default('MCP server for KiCAD PCB design operations'),
|
||||
pythonPath: z.string().optional(),
|
||||
kicadPath: z.string().optional(),
|
||||
logLevel: z.enum(['error', 'warn', 'info', 'debug']).default('info'),
|
||||
logLevel: z.enum(LOG_LEVELS).default(resolveLogLevel()),
|
||||
logDir: z.string().optional()
|
||||
});
|
||||
|
||||
@@ -41,14 +50,18 @@ export type Config = z.infer<typeof ConfigSchema>;
|
||||
* @returns Loaded and validated configuration
|
||||
*/
|
||||
export async function loadConfig(configPath?: string): Promise<Config> {
|
||||
const envOverrides = {
|
||||
logLevel: resolveLogLevel(),
|
||||
};
|
||||
|
||||
try {
|
||||
// Determine which config file to load
|
||||
const filePath = configPath || DEFAULT_CONFIG_PATH;
|
||||
|
||||
// Check if file exists
|
||||
if (!existsSync(filePath)) {
|
||||
logger.warn(`Configuration file not found: ${filePath}, using defaults`);
|
||||
return ConfigSchema.parse({});
|
||||
logger.debug(`Configuration file not found: ${filePath}, using defaults`);
|
||||
return ConfigSchema.parse(envOverrides);
|
||||
}
|
||||
|
||||
// Read and parse configuration
|
||||
@@ -56,11 +69,11 @@ export async function loadConfig(configPath?: string): Promise<Config> {
|
||||
const config = JSON.parse(configData);
|
||||
|
||||
// Validate configuration
|
||||
return ConfigSchema.parse(config);
|
||||
return ConfigSchema.parse({ ...config, ...envOverrides });
|
||||
} catch (error) {
|
||||
logger.error(`Error loading configuration: ${error}`);
|
||||
|
||||
// Return default configuration
|
||||
return ConfigSchema.parse({});
|
||||
return ConfigSchema.parse(envOverrides);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@ async function main() {
|
||||
// Create the server
|
||||
const server = new KiCADMcpServer(
|
||||
kicadScriptPath,
|
||||
config.logLevel
|
||||
config.logLevel,
|
||||
);
|
||||
|
||||
// Start the server
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ const DEFAULT_LOG_DIR = join(os.homedir(), '.kicad-mcp', 'logs');
|
||||
* Logger class for KiCAD MCP server
|
||||
*/
|
||||
class Logger {
|
||||
private logLevel: LogLevel = 'info';
|
||||
private logLevel: LogLevel = 'warn';
|
||||
private logDir: string = DEFAULT_LOG_DIR;
|
||||
|
||||
/**
|
||||
|
||||
+52
-17
@@ -220,6 +220,7 @@ export class KiCADMcpServer {
|
||||
reject: Function;
|
||||
timeoutHandle: NodeJS.Timeout;
|
||||
} | null = null;
|
||||
private pythonStderrBuffer = "";
|
||||
|
||||
private buildPythonEnv(): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env };
|
||||
@@ -240,7 +241,7 @@ export class KiCADMcpServer {
|
||||
*/
|
||||
constructor(
|
||||
kicadScriptPath: string,
|
||||
logLevel: "error" | "warn" | "info" | "debug" = "info",
|
||||
logLevel: "error" | "warn" | "info" | "debug" = "warn",
|
||||
) {
|
||||
// Set up the logger
|
||||
logger.setLogLevel(logLevel);
|
||||
@@ -272,12 +273,8 @@ export class KiCADMcpServer {
|
||||
* Register all tools, resources, and prompts
|
||||
*/
|
||||
private registerAll(): void {
|
||||
logger.info("Registering KiCAD tools, resources, and prompts...");
|
||||
logger.info("Registering KiCAD MCP runtime surface");
|
||||
|
||||
// Register router tools FIRST (for tool discovery and execution)
|
||||
registerRouterTools(this.server, this.callKicadScript.bind(this));
|
||||
|
||||
// Register all tools
|
||||
registerProjectTools(this.server, this.callKicadScript.bind(this));
|
||||
registerBoardTools(this.server, this.callKicadScript.bind(this));
|
||||
registerComponentTools(this.server, this.callKicadScript.bind(this));
|
||||
@@ -287,28 +284,65 @@ export class KiCADMcpServer {
|
||||
registerSchematicTools(this.server, this.callKicadScript.bind(this));
|
||||
registerLibraryTools(this.server, this.callKicadScript.bind(this));
|
||||
registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this));
|
||||
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
|
||||
registerDatasheetTools(this.server, this.callKicadScript.bind(this));
|
||||
registerRouterTools(this.server, this.callKicadScript.bind(this));
|
||||
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
|
||||
registerFootprintTools(this.server, this.callKicadScript.bind(this));
|
||||
registerSymbolCreatorTools(this.server, this.callKicadScript.bind(this));
|
||||
registerUITools(this.server, this.callKicadScript.bind(this));
|
||||
|
||||
// Register all resources
|
||||
registerProjectResources(this.server, this.callKicadScript.bind(this));
|
||||
registerBoardResources(this.server, this.callKicadScript.bind(this));
|
||||
registerComponentResources(this.server, this.callKicadScript.bind(this));
|
||||
registerLibraryResources(this.server, this.callKicadScript.bind(this));
|
||||
|
||||
// Register all prompts
|
||||
registerComponentPrompts(this.server);
|
||||
registerRoutingPrompts(this.server);
|
||||
registerDesignPrompts(this.server);
|
||||
registerFootprintPrompts(this.server);
|
||||
|
||||
logger.info("All KiCAD tools, resources, and prompts registered");
|
||||
logger.info(
|
||||
"Router pattern enabled: 4 router tools + direct tools for discovery",
|
||||
);
|
||||
logger.info("KiCAD MCP runtime registration complete");
|
||||
}
|
||||
|
||||
private logPythonStderrLine(rawLine: string): void {
|
||||
const line = rawLine.trim();
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
|
||||
const severityMatch = line.match(/\[(DEBUG|INFO|WARNING|ERROR|CRITICAL)\]/i);
|
||||
const message = `python ${line}`;
|
||||
|
||||
switch (severityMatch?.[1]?.toUpperCase()) {
|
||||
case "DEBUG":
|
||||
logger.debug(message);
|
||||
return;
|
||||
case "INFO":
|
||||
logger.info(message);
|
||||
return;
|
||||
case "WARNING":
|
||||
logger.warn(message);
|
||||
return;
|
||||
case "ERROR":
|
||||
case "CRITICAL":
|
||||
logger.error(message);
|
||||
return;
|
||||
default:
|
||||
if (/(traceback|exception|error)/i.test(line)) {
|
||||
logger.error(message);
|
||||
return;
|
||||
}
|
||||
logger.debug(message);
|
||||
}
|
||||
}
|
||||
|
||||
private handlePythonStderr(data: Buffer): void {
|
||||
this.pythonStderrBuffer += data.toString();
|
||||
const lines = this.pythonStderrBuffer.split(/\r?\n/);
|
||||
this.pythonStderrBuffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
this.logPythonStderrLine(line);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -520,6 +554,10 @@ export class KiCADMcpServer {
|
||||
|
||||
// Listen for process exit
|
||||
this.pythonProcess.on("exit", (code, signal) => {
|
||||
if (this.pythonStderrBuffer.trim()) {
|
||||
this.logPythonStderrLine(this.pythonStderrBuffer);
|
||||
this.pythonStderrBuffer = "";
|
||||
}
|
||||
logger.warn(
|
||||
`Python process exited with code ${code} and signal ${signal}`,
|
||||
);
|
||||
@@ -534,7 +572,7 @@ export class KiCADMcpServer {
|
||||
// Set up error logging for stderr
|
||||
if (this.pythonProcess.stderr) {
|
||||
this.pythonProcess.stderr.on("data", (data: Buffer) => {
|
||||
logger.error(`Python stderr: ${data.toString()}`);
|
||||
this.handlePythonStderr(data);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -555,9 +593,6 @@ export class KiCADMcpServer {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Write a ready message to stderr (for debugging)
|
||||
process.stderr.write("KiCAD MCP SERVER READY\n");
|
||||
|
||||
logger.info("KiCAD MCP server started and ready");
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start KiCAD MCP server: ${error}`);
|
||||
|
||||
Reference in New Issue
Block a user