diff --git a/python/commands/board/view.py b/python/commands/board/view.py index aaad991..b54a5c3 100644 --- a/python/commands/board/view.py +++ b/python/commands/board/view.py @@ -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), + } diff --git a/python/commands/component.py b/python/commands/component.py index 9489060..870eb8e 100644 --- a/python/commands/component.py +++ b/python/commands/component.py @@ -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, diff --git a/python/commands/design_rules.py b/python/commands/design_rules.py index 71eb7aa..2a88f81 100644 --- a/python/commands/design_rules.py +++ b/python/commands/design_rules.py @@ -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), + } diff --git a/python/commands/export.py b/python/commands/export.py index 5c74a66..698ab84 100644 --- a/python/commands/export.py +++ b/python/commands/export.py @@ -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""" diff --git a/python/commands/jlcpcb.py b/python/commands/jlcpcb.py index f93e40a..8598250 100644 --- a/python/commands/jlcpcb.py +++ b/python/commands/jlcpcb.py @@ -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: diff --git a/python/commands/library.py b/python/commands/library.py index d56a791..8209fd0 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -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""" diff --git a/python/commands/library_symbol.py b/python/commands/library_symbol.py index 1c14bb5..47fed8d 100644 --- a/python/commands/library_symbol.py +++ b/python/commands/library_symbol.py @@ -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: diff --git a/python/commands/project.py b/python/commands/project.py index 46872cf..82edf4b 100644 --- a/python/commands/project.py +++ b/python/commands/project.py @@ -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), + } diff --git a/python/commands/routing.py b/python/commands/routing.py index b84f4c1..ee6b911 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -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: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 891a6d1..52a788e 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -12,8 +12,17 @@ import json import traceback import logging import os +import base64 +import io +import re +import subprocess +import tempfile +from dataclasses import asdict +from pathlib import Path from typing import Dict, Any, Optional +from PIL import Image, ImageDraw + # Import tool schemas and resource definitions from schemas.tool_schemas import TOOL_SCHEMAS from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read @@ -23,11 +32,33 @@ log_dir = os.path.join(os.path.expanduser("~"), ".kicad-mcp", "logs") os.makedirs(log_dir, exist_ok=True) log_file = os.path.join(log_dir, "kicad_interface.log") -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s [%(levelname)s] %(message)s", - handlers=[logging.FileHandler(log_file), logging.StreamHandler(sys.stderr)], +def _resolve_log_level(env_name: str, default: str) -> int: + value = os.environ.get(env_name, default).upper() + return getattr(logging, value, getattr(logging, default.upper(), logging.INFO)) + + +root_logger = logging.getLogger() +root_logger.handlers.clear() +root_logger.setLevel(logging.DEBUG) + +formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s") + +file_handler = logging.FileHandler(log_file) +file_handler.setLevel(_resolve_log_level("KICAD_PYTHON_FILE_LOG_LEVEL", "INFO")) +file_handler.setFormatter(formatter) + +stderr_handler = logging.StreamHandler(sys.stderr) +stderr_handler.setLevel( + _resolve_log_level( + "KICAD_PYTHON_STDERR_LOG_LEVEL", + os.environ.get("KICAD_PYTHON_LOG_LEVEL", "WARNING"), + ) ) +stderr_handler.setFormatter(formatter) + +root_logger.addHandler(file_handler) +root_logger.addHandler(stderr_handler) + logger = logging.getLogger("kicad_interface") # Log Python environment details @@ -106,7 +137,7 @@ if AUTO_LAUNCH_KICAD: # Check which backend to use # KICAD_BACKEND can be: 'auto', 'ipc', or 'swig' KICAD_BACKEND = os.environ.get("KICAD_BACKEND", "auto").lower() -logger.info(f"KiCAD backend preference: {KICAD_BACKEND}") +logger.debug(f"KiCAD backend preference: {KICAD_BACKEND}") # Try to use IPC backend first if available and preferred USE_IPC_BACKEND = False @@ -129,7 +160,7 @@ if KICAD_BACKEND in ("auto", "ipc"): except ImportError: logger.info("IPC backend not available (kicad-python not installed)") except Exception as e: - logger.info(f"IPC backend connection failed: {e}") + logger.debug(f"IPC backend connection failed: {e}") ipc_backend = None # Fall back to SWIG backend if IPC not available @@ -141,7 +172,7 @@ if not USE_IPC_BACKEND and KICAD_BACKEND != "ipc": logger.info(f"Successfully imported pcbnew module from: {pcbnew.__file__}") logger.info(f"pcbnew version: {pcbnew.GetBuildVersion()}") - logger.warning("Using SWIG backend - changes require manual reload in KiCAD UI") + logger.info("Using SWIG backend - changes require manual reload in KiCAD UI") except ImportError as e: logger.error(f"Failed to import pcbnew module: {e}") logger.error(f"Current sys.path: {sys.path}") @@ -295,6 +326,9 @@ class KiCADInterface: "open_project": self.project_commands.open_project, "save_project": self.project_commands.save_project, "get_project_info": self.project_commands.get_project_info, + "get_project_properties": self._handle_get_project_properties, + "get_project_files": self._handle_get_project_files, + "get_project_status": self._handle_get_project_status, # Board commands "set_board_size": self.board_commands.set_board_size, "add_layer": self.board_commands.add_layer, @@ -302,6 +336,7 @@ class KiCADInterface: "get_board_info": self.board_commands.get_board_info, "get_layer_list": self.board_commands.get_layer_list, "get_board_2d_view": self.board_commands.get_board_2d_view, + "get_board_3d_view": self._handle_get_board_3d_view, "get_board_extents": self.board_commands.get_board_extents, "add_board_outline": self.board_commands.add_board_outline, "add_mounting_hole": self.board_commands.add_mounting_hole, @@ -314,9 +349,16 @@ class KiCADInterface: "rotate_component": self.component_commands.rotate_component, "delete_component": self.component_commands.delete_component, "edit_component": self.component_commands.edit_component, + "add_component_annotation": self.component_commands.add_component_annotation, "get_component_properties": self.component_commands.get_component_properties, "get_component_list": self.component_commands.get_component_list, + "get_component_connections": self.component_commands.get_component_connections, + "get_component_placement": self.component_commands.get_component_placement, + "get_component_groups": self.component_commands.get_component_groups, + "get_component_visualization": self.component_commands.get_component_visualization, "find_component": self.component_commands.find_component, + "group_components": self.component_commands.group_components, + "replace_component": self.component_commands.replace_component, "get_component_pads": self.component_commands.get_component_pads, "get_pad_position": self.component_commands.get_pad_position, "place_component_array": self.component_commands.place_component_array, @@ -333,12 +375,17 @@ class KiCADInterface: "get_nets_list": self.routing_commands.get_nets_list, "create_netclass": self.routing_commands.create_netclass, "add_copper_pour": self.routing_commands.add_copper_pour, + "add_zone": self.routing_commands.add_copper_pour, "route_differential_pair": self.routing_commands.route_differential_pair, "refill_zones": self._handle_refill_zones, # Design rule commands "set_design_rules": self.design_rule_commands.set_design_rules, "get_design_rules": self.design_rule_commands.get_design_rules, "run_drc": self.design_rule_commands.run_drc, + "add_net_class": self._handle_add_net_class, + "assign_net_to_class": self.design_rule_commands.assign_net_to_class, + "set_layer_constraints": self.design_rule_commands.set_layer_constraints, + "check_clearance": self.design_rule_commands.check_clearance, "get_drc_violations": self.design_rule_commands.get_drc_violations, # Export commands "export_gerber": self.export_commands.export_gerber, @@ -346,11 +393,20 @@ class KiCADInterface: "export_svg": self.export_commands.export_svg, "export_3d": self.export_commands.export_3d, "export_bom": self.export_commands.export_bom, + "export_netlist": self.export_commands.export_netlist, + "export_position_file": self.export_commands.export_position_file, + "export_vrml": self.export_commands.export_vrml, # Library commands (footprint management) "list_libraries": self.library_commands.list_libraries, "search_footprints": self.library_commands.search_footprints, "list_library_footprints": self.library_commands.list_library_footprints, "get_footprint_info": self.library_commands.get_footprint_info, + "get_component_library": self._handle_get_component_library, + "get_library_list": self._handle_get_library_list, + "get_component_details": self._handle_get_component_details, + "get_component_footprint": self._handle_get_component_footprint, + "get_component_symbol": self._handle_get_component_symbol, + "get_component_3d_model": self._handle_get_component_3d_model, # Symbol library commands (local KiCad symbol library search) "list_symbol_libraries": self.symbol_library_commands.list_symbol_libraries, "search_symbols": self.symbol_library_commands.search_symbols, @@ -371,6 +427,7 @@ class KiCADInterface: "add_schematic_component": self._handle_add_schematic_component, "delete_schematic_component": self._handle_delete_schematic_component, "edit_schematic_component": self._handle_edit_schematic_component, + "add_wire": self._handle_add_wire, "add_schematic_wire": self._handle_add_schematic_wire, "add_schematic_connection": self._handle_add_schematic_connection, "add_schematic_net_label": self._handle_add_schematic_net_label, @@ -485,10 +542,22 @@ class KiCADInterface: # Update board reference if command was successful if result.get("success", False): - if command == "create_project" or command == "open_project": + if ( + command == "create_project" + or command == "open_project" + or command == "save_project" + ): logger.info("Updating board reference...") # Get board from the project commands handler self.board = self.project_commands.board + project_info = result.get("project", {}) + self._set_project_context_from_paths( + project_info.get("boardPath") + or project_info.get("path") + or self.board.GetFileName() + if self.board + else None + ) self._update_command_handlers() return result @@ -520,6 +589,923 @@ class KiCADInterface: self.design_rule_commands.board = self.board self.export_commands.board = self.board + def _set_project_context_from_paths(self, path_value): + """Refresh project-local library managers from a project or board path.""" + if not path_value: + return + + candidate = Path(os.path.abspath(os.path.expanduser(str(path_value)))) + if candidate.suffix in {".kicad_pcb", ".kicad_pro", ".kicad_sch"}: + project_path = candidate.parent + else: + project_path = candidate if candidate.is_dir() else candidate.parent + + if project_path == getattr(self, "_current_project_path", None): + return + + self._current_project_path = project_path + self.footprint_library = FootprintLibraryManager(project_path=project_path) + self.component_commands.library_manager = self.footprint_library + self.library_commands.library_manager = self.footprint_library + self.symbol_library_commands.library_manager = SymbolLibraryManager( + project_path=project_path + ) + logger.info(f"Updated project-local library context: {project_path}") + + def _get_project_root(self) -> Optional[Path]: + """Resolve the current project directory from tracked state.""" + if getattr(self, "_current_project_path", None): + return self._current_project_path + + if self.board and self.board.GetFileName(): + board_path = Path(self.board.GetFileName()) + if board_path.parent: + return board_path.parent + + return None + + def _get_board_file_path(self) -> Optional[Path]: + """Return the current board file path if one is available.""" + if not self.board: + return None + + filename = self.board.GetFileName() + if not filename: + return None + + return Path(filename) + + def _get_project_basename(self) -> Optional[str]: + """Return the inferred KiCad project basename.""" + board_path = self._get_board_file_path() + if board_path: + return board_path.stem + + project_root = self._get_project_root() + if project_root: + return project_root.name + + return None + + def _get_symbol_manager(self): + return self.symbol_library_commands.library_manager + + def _get_footprint_manager(self): + return self.library_commands.library_manager + + def _resolve_symbol_info( + self, component_id: Optional[str], library: Optional[str] = None + ): + """Resolve a symbol from explicit library/id hints.""" + if not component_id: + return None + + symbol_manager = self._get_symbol_manager() + symbol_name = component_id + + if ":" in component_id and not library: + return symbol_manager.find_symbol(component_id) + + if ":" in component_id: + _, symbol_name = component_id.split(":", 1) + + if library: + return symbol_manager.get_symbol_info(library, symbol_name) + + symbol = symbol_manager.find_symbol(symbol_name) + if symbol: + return symbol + + matches = symbol_manager.search_symbols(symbol_name, limit=1) + return matches[0] if matches else None + + def _load_footprint_object(self, footprint_spec: Optional[str]): + """Load a footprint object from a library spec.""" + if not footprint_spec: + return None + + footprint_manager = self._get_footprint_manager() + located = footprint_manager.find_footprint(footprint_spec) + if not located: + return None + + library_path, footprint_name = located + footprint = pcbnew.FootprintLoad(library_path, footprint_name) + if not footprint: + return None + + library_name = None + for nickname, path in footprint_manager.libraries.items(): + if path == library_path: + library_name = nickname + break + + return { + "footprint": footprint, + "library_path": library_path, + "library_name": library_name, + "footprint_name": footprint_name, + "full_name": ( + f"{library_name}:{footprint_name}" + if library_name + else footprint_name + ), + } + + def _resolve_model_path(self, raw_path: str, library_path: Optional[str] = None) -> str: + """Best-effort resolution of KiCad 3D model paths with env vars.""" + if not raw_path: + return "" + + expanded = os.path.expandvars(raw_path) + if os.path.isabs(expanded): + return expanded + + if library_path: + candidate = os.path.join(library_path, expanded) + if os.path.exists(candidate): + return candidate + + project_root = self._get_project_root() + if project_root: + candidate = str(project_root / expanded) + if os.path.exists(candidate): + return candidate + + return expanded + + def _get_current_schematic_path(self) -> Optional[Path]: + """Infer the current schematic path from the loaded project context.""" + board_path = self._get_board_file_path() + if board_path: + candidate = board_path.with_suffix(".kicad_sch") + if candidate.exists(): + return candidate + + project_root = self._get_project_root() + if project_root: + for candidate in sorted(project_root.glob("*.kicad_sch")): + return candidate + + return None + + def _handle_add_net_class(self, params): + """Bridge legacy TypeScript field names to the routing netclass command.""" + bridged = dict(params) + rename_map = { + "uvia_diameter": "uviaDiameter", + "uvia_drill": "uviaDrill", + "diff_pair_width": "diffPairWidth", + "diff_pair_gap": "diffPairGap", + } + for source, target in rename_map.items(): + if source in bridged and target not in bridged: + bridged[target] = bridged.pop(source) + return self.routing_commands.create_netclass(bridged) + + def _handle_add_wire(self, params): + """Bridge the generic add_wire tool to the schematic wire backend.""" + start = params.get("startPoint") or params.get("start") + end = params.get("endPoint") or params.get("end") + schematic_path = params.get("schematicPath") + if not schematic_path: + resolved = self._get_current_schematic_path() + schematic_path = str(resolved) if resolved else None + + def _normalize_point(point): + if isinstance(point, dict): + return [point.get("x", 0), point.get("y", 0)] + return point + + return self._handle_add_schematic_wire( + { + "schematicPath": schematic_path, + "startPoint": _normalize_point(start), + "endPoint": _normalize_point(end), + "properties": params.get("properties", {}), + } + ) + + def _handle_get_project_properties(self, params): + """Return normalized project properties for the current board.""" + project_result = self.project_commands.get_project_info({}) + if not project_result.get("success"): + return project_result + + project = project_result.get("project", {}) + properties = { + "title": project.get("title", ""), + "date": project.get("date", ""), + "revision": project.get("revision", ""), + "company": project.get("company", ""), + "comments": [ + project.get("comment1", ""), + project.get("comment2", ""), + project.get("comment3", ""), + project.get("comment4", ""), + ], + } + + return {"success": True, "project": project, "properties": properties} + + def _handle_get_project_files(self, params): + """List the primary files associated with the current project.""" + project_root = self._get_project_root() + if not project_root: + return { + "success": False, + "message": "No project is loaded", + "errorDetails": "Load or create a project first", + } + + project_name = self._get_project_basename() or project_root.name + candidates = [ + project_root / f"{project_name}.kicad_pro", + project_root / f"{project_name}.kicad_pcb", + project_root / f"{project_name}.kicad_sch", + project_root / f"{project_name}.kicad_prl", + project_root / "fp-lib-table", + project_root / "sym-lib-table", + ] + + files = [] + seen_paths = set() + for candidate in candidates: + candidate_str = str(candidate) + if candidate_str in seen_paths or not candidate.exists(): + continue + seen_paths.add(candidate_str) + stat = candidate.stat() + files.append( + { + "name": candidate.name, + "path": candidate_str, + "type": candidate.suffix or candidate.name, + "sizeBytes": stat.st_size, + "modifiedAt": stat.st_mtime, + } + ) + + return { + "success": True, + "projectRoot": str(project_root), + "projectName": project_name, + "files": files, + "count": len(files), + } + + def _handle_get_project_status(self, params): + """Return a concise status snapshot for the current project.""" + project_root = self._get_project_root() + board_path = self._get_board_file_path() + project_files = self._handle_get_project_files({}) + components = self.component_commands.get_component_list({}) + nets = self.routing_commands.get_nets_list({}) + + project_file_types = { + Path(entry["path"]).suffix or Path(entry["path"]).name + for entry in project_files.get("files", []) + } + + return { + "success": True, + "status": { + "boardLoaded": self.board is not None, + "projectRoot": str(project_root) if project_root else None, + "boardPath": str(board_path) if board_path else None, + "hasProjectFile": ".kicad_pro" in project_file_types, + "hasBoardFile": ".kicad_pcb" in project_file_types, + "hasSchematicFile": ".kicad_sch" in project_file_types, + "componentCount": len(components.get("components", [])) + if components.get("success") + else 0, + "netCount": len(nets.get("nets", [])) + if nets.get("success") + else 0, + "libraryTables": sorted( + entry["name"] + for entry in project_files.get("files", []) + if entry["name"] in {"fp-lib-table", "sym-lib-table"} + ), + }, + } + + def _handle_get_board_3d_view(self, params): + """Render a board 3D preview using kicad-cli.""" + board_path = self._get_board_file_path() + if not self.board or not board_path or not board_path.exists(): + return { + "success": False, + "message": "No saved board is loaded", + "errorDetails": "Load or create a project and save the board first", + } + + kicad_cli = self.export_commands._find_kicad_cli() + if not kicad_cli: + return { + "success": False, + "message": "kicad-cli not found", + "errorDetails": "KiCad CLI is required to render board previews", + } + + angle = str(params.get("angle", "isometric")).lower() + width = int(params.get("width", 1600)) + height = int(params.get("height", 900)) + image_format = str(params.get("format", "png")).lower() + image_format = "jpg" if image_format == "jpeg" else image_format + suffix = ".jpg" if image_format == "jpg" else ".png" + + render_args = [] + if angle == "isometric": + render_args.extend(["--side", "top", "--rotate", "-45,0,45"]) + elif angle in {"top", "bottom", "left", "right", "front", "back"}: + render_args.extend(["--side", angle]) + else: + return { + "success": False, + "message": "Unsupported angle", + "errorDetails": f"Unsupported 3D render angle: {angle}", + } + + data_dir = os.environ.get("KICAD_MCP_DATA_DIR") + temp_dir = data_dir if data_dir and os.path.isdir(data_dir) else None + with tempfile.NamedTemporaryFile( + suffix=suffix, + delete=False, + dir=temp_dir, + ) as tmp_file: + output_path = tmp_file.name + + cmd = [ + kicad_cli, + "pcb", + "render", + "--output", + output_path, + "--width", + str(width), + "--height", + str(height), + "--quality", + "basic", + "--background", + "transparent" if image_format == "png" else "opaque", + *render_args, + str(board_path), + ] + + try: + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=180, + ) + if result.returncode != 0 or not os.path.exists(output_path): + return { + "success": False, + "message": "3D render failed", + "errorDetails": result.stderr or "render output was not created", + } + + with open(output_path, "rb") as image_file: + encoded = base64.b64encode(image_file.read()).decode("ascii") + + return { + "success": True, + "imageData": encoded, + "format": image_format, + "angle": angle, + "size": {"width": width, "height": height}, + } + finally: + try: + os.unlink(output_path) + except OSError: + pass + + def _handle_get_component_connections(self, params): + """Return the net connections for a placed component.""" + reference = params.get("reference") + pads_result = self.component_commands.get_component_pads({"reference": reference}) + if not pads_result.get("success"): + return pads_result + + connections = [] + for pad in pads_result.get("pads", []): + if pad.get("net"): + connections.append( + { + "pad": pad.get("number") or pad.get("name"), + "net": pad.get("net"), + "position": pad.get("position"), + } + ) + + return { + "success": True, + "reference": reference, + "connections": connections, + "count": len(connections), + } + + def _handle_get_component_placement(self, params): + """Return placement information for all placed components.""" + components_result = self.component_commands.get_component_list({}) + if not components_result.get("success"): + return components_result + + by_layer = {} + for component in components_result.get("components", []): + by_layer[component.get("layer", "unknown")] = ( + by_layer.get(component.get("layer", "unknown"), 0) + 1 + ) + + return { + "success": True, + "components": components_result.get("components", []), + "count": len(components_result.get("components", [])), + "byLayer": by_layer, + } + + def _handle_get_component_groups(self, params): + """Group components by reference prefix.""" + components_result = self.component_commands.get_component_list({}) + if not components_result.get("success"): + return components_result + + grouped = {} + for component in components_result.get("components", []): + reference = component.get("reference", "") + match = re.match(r"[A-Za-z]+", reference) + key = match.group(0) if match else "Other" + grouped.setdefault( + key, + { + "group": key, + "count": 0, + "references": [], + "values": set(), + }, + ) + grouped[key]["count"] += 1 + grouped[key]["references"].append(reference) + value = component.get("value") + if value: + grouped[key]["values"].add(value) + + groups = [] + for key in sorted(grouped): + group = grouped[key] + groups.append( + { + "group": group["group"], + "count": group["count"], + "references": sorted(group["references"]), + "values": sorted(group["values"]), + } + ) + + return {"success": True, "groups": groups, "count": len(groups)} + + def _handle_get_component_visualization(self, params): + """Generate a simple footprint-centric PNG preview for a placed component.""" + 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}", + } + + pads = list(module.Pads()) + if not pads: + return { + "success": False, + "message": "Component has no pads", + "errorDetails": f"Component {reference} has no drawable pads", + } + + module_pos = module.GetPosition() + pad_entries = [] + min_x = min_y = float("inf") + max_x = max_y = float("-inf") + + for pad in pads: + pad_pos = pad.GetPosition() + pad_size = pad.GetSize() + x_mm = (pad_pos.x - module_pos.x) / 1000000.0 + y_mm = (pad_pos.y - module_pos.y) / 1000000.0 + w_mm = pad_size.x / 1000000.0 + h_mm = pad_size.y / 1000000.0 + + min_x = min(min_x, x_mm - w_mm / 2.0) + max_x = max(max_x, x_mm + w_mm / 2.0) + min_y = min(min_y, y_mm - h_mm / 2.0) + max_y = max(max_y, y_mm + h_mm / 2.0) + + pad_entries.append( + { + "x": x_mm, + "y": y_mm, + "w": w_mm, + "h": h_mm, + "shape": pad.GetShape(), + "label": pad.GetNumber() or pad.GetName(), + } + ) + + width_px = 420 + height_px = 420 + padding_px = 40 + span_x = max(max_x - min_x, 1.0) + span_y = max(max_y - min_y, 1.0) + scale = min( + (width_px - 2 * padding_px) / span_x, + (height_px - 2 * padding_px) / span_y, + ) + + image = Image.new("RGBA", (width_px, height_px), (250, 252, 255, 255)) + draw = ImageDraw.Draw(image) + draw.rounded_rectangle( + [(10, 10), (width_px - 10, height_px - 10)], + radius=18, + outline=(30, 41, 59, 255), + width=2, + fill=(255, 255, 255, 255), + ) + + body_left = padding_px + body_top = padding_px + body_right = width_px - padding_px + body_bottom = height_px - padding_px + draw.rounded_rectangle( + [(body_left, body_top), (body_right, body_bottom)], + radius=20, + outline=(99, 102, 241, 255), + width=3, + fill=(238, 242, 255, 255), + ) + + def to_canvas(x_mm, y_mm): + x_px = padding_px + (x_mm - min_x) * scale + y_px = height_px - padding_px - (y_mm - min_y) * scale + return x_px, y_px + + for pad in pad_entries: + x_px, y_px = to_canvas(pad["x"], pad["y"]) + half_w = (pad["w"] * scale) / 2.0 + half_h = (pad["h"] * scale) / 2.0 + box = [ + x_px - half_w, + y_px - half_h, + x_px + half_w, + y_px + half_h, + ] + + if pad["shape"] in { + pcbnew.PAD_SHAPE_CIRCLE, + pcbnew.PAD_SHAPE_OVAL, + pcbnew.PAD_SHAPE_ROUNDRECT, + }: + draw.ellipse(box, fill=(15, 118, 110, 255), outline=(15, 23, 42, 255)) + else: + draw.rounded_rectangle( + box, + radius=6, + fill=(15, 118, 110, 255), + outline=(15, 23, 42, 255), + ) + draw.text((box[0], box[1] - 14), str(pad["label"]), fill=(15, 23, 42, 255)) + + draw.text((20, 18), f"{reference} {module.GetValue()}", fill=(15, 23, 42, 255)) + + buffer = io.BytesIO() + image.save(buffer, format="PNG") + return { + "success": True, + "reference": reference, + "format": "png", + "imageData": base64.b64encode(buffer.getvalue()).decode("ascii"), + } + + def _handle_get_component_library(self, params): + """Return normalized symbol and footprint matches for a component query.""" + filter_value = str(params.get("filter", "") or "").strip() + library_filter = str(params.get("library", "") or "").strip() or None + limit = int(params.get("limit", 25) or 25) + symbol_manager = self._get_symbol_manager() + footprint_manager = self._get_footprint_manager() + components = [] + + if filter_value: + symbols = symbol_manager.search_symbols(filter_value, limit, library_filter) + for symbol in symbols: + components.append( + { + "id": symbol.full_ref, + "name": symbol.name, + "library": symbol.library, + "kind": "symbol", + "description": symbol.description, + "footprint": symbol.footprint, + "datasheet": symbol.datasheet, + } + ) + + footprint_pattern = f"*{filter_value}*" + footprint_result = self.library_commands.search_footprints( + {"pattern": footprint_pattern, "limit": limit, "library": library_filter} + ) + for footprint in footprint_result.get("footprints", []): + components.append( + { + "id": footprint["full_name"], + "name": footprint["footprint"], + "library": footprint["library"], + "kind": "footprint", + "path": footprint_manager.get_library_path(footprint["library"]), + } + ) + else: + symbol_libraries = [library_filter] if library_filter else symbol_manager.list_libraries() + for library_name in symbol_libraries: + for symbol in symbol_manager.list_symbols(library_name)[: max(limit // 2, 1)]: + components.append( + { + "id": symbol.full_ref, + "name": symbol.name, + "library": symbol.library, + "kind": "symbol", + "description": symbol.description, + "footprint": symbol.footprint, + "datasheet": symbol.datasheet, + } + ) + if len(components) >= limit: + break + if len(components) >= limit: + break + + if len(components) < limit: + footprint_libraries = [library_filter] if library_filter else footprint_manager.list_libraries() + for library_name in footprint_libraries: + footprint_result = self.library_commands.list_library_footprints( + {"library": library_name} + ) + for footprint_name in footprint_result.get("footprints", [])[: max(limit // 2, 1)]: + components.append( + { + "id": f"{library_name}:{footprint_name}", + "name": footprint_name, + "library": library_name, + "kind": "footprint", + "path": footprint_manager.get_library_path(library_name), + } + ) + if len(components) >= limit: + break + if len(components) >= limit: + break + + return { + "success": True, + "components": components[:limit], + "count": len(components[:limit]), + "filter": filter_value, + "library": library_filter, + } + + def _handle_get_library_list(self, params): + """Return both footprint and symbol library inventories.""" + footprint_libraries = self.library_commands.list_libraries({}) + symbol_libraries = self.symbol_library_commands.list_symbol_libraries({}) + + libraries = [ + {"name": name, "type": "footprint"} + for name in footprint_libraries.get("libraries", []) + ] + [ + {"name": name, "type": "symbol"} + for name in symbol_libraries.get("libraries", []) + ] + libraries.sort(key=lambda entry: (entry["name"], entry["type"])) + + return { + "success": True, + "libraries": libraries, + "counts": { + "footprint": footprint_libraries.get("count", 0), + "symbol": symbol_libraries.get("count", 0), + }, + } + + def _handle_get_component_details(self, params): + """Return symbol- and footprint-centric component metadata.""" + component_id = params.get("componentId") + library = params.get("library") + symbol = self._resolve_symbol_info(component_id, library) + footprint_spec = params.get("footprint") + if not footprint_spec and symbol and symbol.footprint: + footprint_spec = symbol.footprint + if not footprint_spec and component_id: + footprint_spec = component_id + + footprint_result = None + if footprint_spec: + footprint_result = self.library_commands.get_footprint_info({"footprint": footprint_spec}) + + if not symbol and not (footprint_result and footprint_result.get("success")): + return { + "success": False, + "message": "Component not found", + "errorDetails": f"Could not resolve component: {component_id}", + } + + return { + "success": True, + "component": asdict(symbol) if symbol else None, + "footprint": footprint_result.get("footprint_info") + if footprint_result and footprint_result.get("success") + else None, + } + + def _handle_get_component_footprint(self, params): + """Resolve the footprint associated with a library component.""" + component_id = params.get("componentId") + footprint_spec = params.get("footprint") + library = params.get("library") + + symbol = None + if not footprint_spec: + symbol = self._resolve_symbol_info(component_id, library) + if symbol and symbol.footprint: + footprint_spec = symbol.footprint + + if not footprint_spec: + return { + "success": False, + "message": "Footprint not found", + "errorDetails": f"No footprint is associated with component: {component_id}", + } + + footprint_info = self.library_commands.get_footprint_info( + {"footprint": footprint_spec} + ) + if not footprint_info.get("success"): + search_term = component_id.split(":", 1)[-1] if component_id else footprint_spec + suggestions = self.library_commands.search_footprints( + {"pattern": f"*{search_term}*", "limit": 10} + ) + return { + "success": False, + "message": "Footprint not found", + "errorDetails": footprint_info.get("errorDetails") + or footprint_info.get("message"), + "candidates": suggestions.get("footprints", []), + } + + loaded = self._load_footprint_object(footprint_spec) + footprint_payload = footprint_info.get("footprint_info", {}).copy() + if loaded: + footprint_obj = loaded["footprint"] + footprint_payload.update( + { + "padCount": len(list(footprint_obj.Pads())), + "libraryPath": loaded["library_path"], + "fullName": loaded["full_name"], + } + ) + + return { + "success": True, + "componentId": component_id, + "footprint": footprint_payload, + "sourceComponent": asdict(symbol) if symbol else None, + } + + def _handle_get_component_symbol(self, params): + """Resolve a symbol entry and return its metadata plus a lightweight SVG card.""" + component_id = params.get("componentId") + library = params.get("library") + symbol = self._resolve_symbol_info(component_id, library) + if not symbol: + return { + "success": False, + "message": "Symbol not found", + "errorDetails": f"Could not resolve symbol for component: {component_id}", + } + + symbol_manager = self._get_symbol_manager() + description = (symbol.description or "No description").replace("&", "&").replace("<", "<").replace(">", ">") + footprint = (symbol.footprint or "No linked footprint").replace("&", "&").replace("<", "<").replace(">", ">") + datasheet = (symbol.datasheet or "No datasheet").replace("&", "&").replace("<", "<").replace(">", ">") + full_ref = symbol.full_ref.replace("&", "&").replace("<", "<").replace(">", ">") + svg = f""" + + + + + + + + + + {full_ref} + {description} + Footprint: {footprint} + Datasheet: {datasheet} + +""".strip() + + return { + "success": True, + "symbol": asdict(symbol), + "libraryPath": symbol_manager.get_library_path(symbol.library), + "svgData": svg, + } + + def _handle_get_component_3d_model(self, params): + """Return the 3D model references attached to a footprint.""" + component_id = params.get("componentId") + footprint_spec = params.get("footprint") + footprint_obj = None + library_path = None + resolved_footprint = None + + if self.board and component_id: + placed_component = self.board.FindFootprintByReference(component_id) + if placed_component: + footprint_obj = placed_component + resolved_footprint = placed_component.GetFPIDAsString() + + if footprint_obj is None: + if not footprint_spec: + symbol = self._resolve_symbol_info(component_id, params.get("library")) + if symbol and symbol.footprint: + footprint_spec = symbol.footprint + loaded = self._load_footprint_object(footprint_spec) + if not loaded: + return { + "success": False, + "message": "3D model not found", + "errorDetails": f"Could not resolve footprint for component: {component_id}", + } + footprint_obj = loaded["footprint"] + library_path = loaded["library_path"] + resolved_footprint = loaded["full_name"] + + models = [] + for model in footprint_obj.Models(): + raw_path = str(model.m_Filename) + resolved_path = self._resolve_model_path(raw_path, library_path) + models.append( + { + "path": raw_path, + "resolvedPath": resolved_path, + "exists": bool(resolved_path) and os.path.exists(resolved_path), + "scale": { + "x": model.m_Scale.x, + "y": model.m_Scale.y, + "z": model.m_Scale.z, + }, + "rotation": { + "x": model.m_Rotation.x, + "y": model.m_Rotation.y, + "z": model.m_Rotation.z, + }, + "offset": { + "x": model.m_Offset.x, + "y": model.m_Offset.y, + "z": model.m_Offset.z, + }, + "visible": bool(model.m_Show), + "opacity": model.m_Opacity, + } + ) + + return { + "success": True, + "componentId": component_id, + "footprint": resolved_footprint, + "models": models, + "count": len(models), + } + # Schematic command handlers def _handle_create_schematic(self, params): """Create a new schematic""" @@ -583,18 +1569,9 @@ class KiCADInterface: def _handle_place_component(self, params): """Place a component on the PCB, with project-local fp-lib-table support.""" - from pathlib import Path - board_path = params.get("boardPath") if board_path: - project_path = Path(board_path).parent - if project_path != getattr(self, "_current_project_path", None): - self._current_project_path = project_path - local_lib = FootprintLibraryManager(project_path=project_path) - self.component_commands = ComponentCommands(self.board, local_lib) - logger.info( - f"Reloaded FootprintLibraryManager with project_path={project_path}" - ) + self._set_project_context_from_paths(board_path) return self.component_commands.place_component(params) diff --git a/src/config.ts b/src/config.ts index 7148515..67b7f21 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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; * @returns Loaded and validated configuration */ export async function loadConfig(configPath?: string): Promise { + 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 { 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); } } diff --git a/src/index.ts b/src/index.ts index 6f31054..191c2c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -31,7 +31,7 @@ async function main() { // Create the server const server = new KiCADMcpServer( kicadScriptPath, - config.logLevel + config.logLevel, ); // Start the server diff --git a/src/logger.ts b/src/logger.ts index 6a22d8b..770af0e 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -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; /** diff --git a/src/server.ts b/src/server.ts index 4f1474b..81c0579 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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}`);