diff --git a/CHANGELOG.md b/CHANGELOG.md index 76370de..6c6cfe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,102 @@ All notable changes to the KiCAD MCP Server project are documented here. +## [2.2.0-alpha] - 2026-02-27 + +### New MCP Tools (TypeScript layer – previously Python-only) + +**Routing tools:** +- `delete_trace` - Delete traces by UUID, position or net name +- `query_traces` - Query/filter traces on the board +- `get_nets_list` - List all nets with net code and class +- `modify_trace` - Modify trace width or layer +- `create_netclass` - Create or update a net class +- `route_differential_pair` - Route a differential pair between two points +- `refill_zones` - Refill all copper zones ⚠️ SWIG segfault risk, prefer IPC/UI + +**Component tools:** +- `get_component_pads` - Get all pad data for a component +- `get_component_list` - List all components on the board +- `get_pad_position` - Get absolute position of a specific pad +- `place_component_array` - Place components in a grid array +- `align_components` - Align components along an axis +- `duplicate_component` - Duplicate a component with offset + +### Bug Fixes + +- `routing.py`: Fix SwigPyObject UUID comparison (`str()` → `m_Uuid.AsString()`) +- `routing.py`: Fix SWIG iterator invalidation after `board.Remove()` by snapshotting `list(board.Tracks())` +- `routing.py`: Add `board.SetModified()` + `track = None` after `Remove()` to prevent dangling SWIG pointer crashes +- `routing.py`: Per-track `try/except` in `query_traces()` to skip invalid objects after bulk delete +- `routing.py`: Add missing return statement (mypy) +- `library.py`: Fix `search_footprints` parameter mapping (`search_term` → `pattern`) +- `library.py`: Fix field access (`fp.name` → `fp.full_name`) +- `library.py`: Accept both `pattern` and `search_term` parameter names +- `library.py`: Fix loop variable shadowing `Path` object (mypy) +- `design_rules.py`: Add type annotation for `violation_counts` (mypy) + +### Pending additions (not yet committed) + +**Datasheet tools:** +- `get_datasheet_url` - Return LCSC datasheet PDF URL and product page URL for a given + LCSC number (e.g. `C179739` → `https://www.lcsc.com/datasheet/C179739.pdf`). + No API key required – URL is constructed directly from the LCSC number. +- `enrich_datasheets` - Scan a `.kicad_sch` file and write LCSC datasheet URLs into + every symbol that has an `LCSC` property but an empty `Datasheet` field. After + enrichment the URL appears natively in KiCAD's symbol properties, footprint browser + and any other tool that reads the standard KiCAD `Datasheet` field. + Supports `dry_run=true` for preview without writing. + Implementation: `python/commands/datasheet_manager.py` (text-based, no `skip` writes) + +**Schematic tools:** +- `delete_schematic_component` - Remove a placed symbol from a `.kicad_sch` file by + reference designator (e.g. `R1`, `U3`). + +### Bug Fixes (pending) + +- `schematic.ts` / `kicad_interface.py`: Fix missing `delete_schematic_component` MCP tool. + + **Root cause (two separate issues):** + 1. No MCP tool named `delete_schematic_component` existed. Claude had no way to call + it, so any "delete schematic component" request fell through to the PCB-only + `delete_component` tool, which searches `pcbnew.BOARD` and always returned + "Component not found" for schematic symbols. + 2. `component_schematic.py::remove_component()` still used `skip` for writes. + PR #40 rewrote `DynamicSymbolLoader` (add path) to avoid `skip`-induced schematic + corruption, but `remove_component` (delete path) was not touched by that PR. + + **Fix:** + - Added `delete_schematic_component` to the TypeScript tool layer (`schematic.ts`) + with clear docstring distinguishing it from the PCB `delete_component`. + - Implemented `_handle_delete_schematic_component` in `kicad_interface.py` using + direct text manipulation (parenthesis-depth tracking, same approach as PR #40). + Does not call `component_schematic.py::remove_component()` at all. + - Error message explicitly guides the user when the wrong tool is used: + *"note: this tool removes schematic symbols, use delete_component for PCB footprints"* + +### Pending fixes (not yet committed) + +- `connection_schematic.py` / `kicad_interface.py`: Fix `generate_netlist` missing + `schematic_path` parameter – without it `get_net_connections` always fell back to + proximity matching which only returns one connection per component (first wire hit, + then `break`). PinLocator was never invoked. Fix: added `schematic_path: Optional[Path]` + to `generate_netlist` signature and threaded it through to `get_net_connections`, + and updated `_handle_generate_netlist` in `kicad_interface.py` to pass `schematic_path`. +- `server.ts`: Fix KiCAD bundled Python (3.11.5) not being selected on Windows – the + detection condition `process.env.PYTHONPATH?.includes("KiCad")` was fragile and failed + in some environments, causing System Python 3.12 to be used instead. Since `pcbnew.pyd` + is compiled for KiCAD's Python 3.11.5, this resulted in `No module named 'pcbnew'`. + Fix: removed the condition, KiCAD bundled Python is now always preferred on Windows + when it exists at `C:\Program Files\KiCad\9.0\bin\python.exe`. + Also added `KICAD_PYTHON` to `claude_desktop_config.json` as explicit override. +- `pin_locator.py`: Fix `generate_netlist` timeout – `get_pin_location` and + `get_all_symbol_pins` called `Schematic(schematic_path)` on every single pin lookup, + causing O(nets × components × pins) schematic file loads (e.g. 400+ loads for a + medium schematic). Fix: added `_schematic_cache` dict to `PinLocator.__init__`, + schematic is now loaded once per path and reused. + +--- + ## [2.1.0-alpha] - 2026-01-10 ### Phase 1: Intelligent Schematic Wiring System - Core Infrastructure diff --git a/python/commands/connection_schematic.py b/python/commands/connection_schematic.py index 15b552d..286b67f 100644 --- a/python/commands/connection_schematic.py +++ b/python/commands/connection_schematic.py @@ -10,11 +10,13 @@ logger = logging.getLogger(__name__) try: from commands.wire_manager import WireManager from commands.pin_locator import PinLocator + WIRE_MANAGER_AVAILABLE = True except ImportError: logger.warning("WireManager/PinLocator not available") WIRE_MANAGER_AVAILABLE = False + class ConnectionManager: """Manage connections between components in schematics""" @@ -29,7 +31,12 @@ class ConnectionManager: return cls._pin_locator @staticmethod - def add_wire(schematic_path: Path, start_point: list, end_point: list, properties: dict = None): + def add_wire( + schematic_path: Path, + start_point: list, + end_point: list, + properties: dict = None, + ): """ Add a wire between two points using WireManager @@ -47,11 +54,18 @@ class ConnectionManager: logger.error("WireManager not available") return False - stroke_width = properties.get('stroke_width', 0) if properties else 0 - stroke_type = properties.get('stroke_type', 'default') if properties else 'default' + stroke_width = properties.get("stroke_width", 0) if properties else 0 + stroke_type = ( + properties.get("stroke_type", "default") if properties else "default" + ) - success = WireManager.add_wire(schematic_path, start_point, end_point, - stroke_width=stroke_width, stroke_type=stroke_type) + success = WireManager.add_wire( + schematic_path, + start_point, + end_point, + stroke_width=stroke_width, + stroke_type=stroke_type, + ) return success except Exception as e: logger.error(f"Error adding wire: {e}") @@ -70,7 +84,7 @@ class ConnectionManager: [x, y] coordinates or None if pin not found """ try: - if not hasattr(symbol, 'pin'): + if not hasattr(symbol, "pin"): logger.warning(f"Symbol {symbol.property.Reference.value} has no pins") return None @@ -82,7 +96,9 @@ class ConnectionManager: break if not target_pin: - logger.warning(f"Pin '{pin_name}' not found on {symbol.property.Reference.value}") + logger.warning( + f"Pin '{pin_name}' not found on {symbol.property.Reference.value}" + ) return None # Get pin location relative to symbol @@ -101,8 +117,14 @@ class ConnectionManager: return None @staticmethod - def add_connection(schematic_path: Path, source_ref: str, source_pin: str, - target_ref: str, target_pin: str, routing: str = 'direct'): + def add_connection( + schematic_path: Path, + source_ref: str, + source_pin: str, + target_ref: str, + target_pin: str, + routing: str = "direct", + ): """ Add a wire connection between two component pins @@ -128,31 +150,41 @@ class ConnectionManager: return False # Get pin locations - source_loc = locator.get_pin_location(schematic_path, source_ref, source_pin) - target_loc = locator.get_pin_location(schematic_path, target_ref, target_pin) + source_loc = locator.get_pin_location( + schematic_path, source_ref, source_pin + ) + target_loc = locator.get_pin_location( + schematic_path, target_ref, target_pin + ) if not source_loc or not target_loc: logger.error("Could not determine pin locations") return False # Create wire based on routing style - if routing == 'direct': + if routing == "direct": # Simple direct wire success = WireManager.add_wire(schematic_path, source_loc, target_loc) - elif routing == 'orthogonal_h': + elif routing == "orthogonal_h": # Orthogonal routing (horizontal first) - path = WireManager.create_orthogonal_path(source_loc, target_loc, prefer_horizontal_first=True) + path = WireManager.create_orthogonal_path( + source_loc, target_loc, prefer_horizontal_first=True + ) success = WireManager.add_polyline_wire(schematic_path, path) - elif routing == 'orthogonal_v': + elif routing == "orthogonal_v": # Orthogonal routing (vertical first) - path = WireManager.create_orthogonal_path(source_loc, target_loc, prefer_horizontal_first=False) + path = WireManager.create_orthogonal_path( + source_loc, target_loc, prefer_horizontal_first=False + ) success = WireManager.add_polyline_wire(schematic_path, path) else: logger.error(f"Unknown routing style: {routing}") return False if success: - logger.info(f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})") + logger.info( + f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})" + ) return True else: return False @@ -160,6 +192,7 @@ class ConnectionManager: except Exception as e: logger.error(f"Error adding connection: {e}") import traceback + logger.error(traceback.format_exc()) return False @@ -177,13 +210,12 @@ class ConnectionManager: Label object or None on error """ try: - if not hasattr(schematic, 'label'): + if not hasattr(schematic, "label"): logger.error("Schematic does not have label collection") return None label = schematic.label.append( - text=net_name, - at={'x': position[0], 'y': position[1]} + text=net_name, at={"x": position[0], "y": position[1]} ) logger.info(f"Added net label '{net_name}' at {position}") return label @@ -192,7 +224,9 @@ class ConnectionManager: return None @staticmethod - def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str): + def connect_to_net( + schematic_path: Path, component_ref: str, pin_name: str, net_name: str + ): """ Connect a component pin to a named net using a wire stub and label @@ -231,7 +265,9 @@ class ConnectionManager: return False # Add label at the end of the stub using WireManager - label_success = WireManager.add_label(schematic_path, net_name, stub_end, label_type='label') + label_success = WireManager.add_label( + schematic_path, net_name, stub_end, label_type="label" + ) if not label_success: logger.error(f"Failed to add net label '{net_name}'") return False @@ -242,11 +278,14 @@ class ConnectionManager: except Exception as e: logger.error(f"Error connecting to net: {e}") import traceback + logger.error(traceback.format_exc()) return False @staticmethod - def get_net_connections(schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None): + def get_net_connections( + schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None + ): """ Get all connections for a named net using wire graph analysis @@ -273,14 +312,14 @@ class ConnectionManager: return dx < tolerance and dy < tolerance # 1. Find all labels with this net name - if not hasattr(schematic, 'label'): + if not hasattr(schematic, "label"): logger.warning("Schematic has no labels") return connections net_label_positions = [] for label in schematic.label: - if hasattr(label, 'value') and label.value == net_name: - if hasattr(label, 'at') and hasattr(label.at, 'value'): + if hasattr(label, "value") and label.value == net_name: + if hasattr(label, "at") and hasattr(label.at, "value"): pos = label.at.value net_label_positions.append([float(pos[0]), float(pos[1])]) @@ -288,21 +327,25 @@ class ConnectionManager: logger.info(f"No labels found for net '{net_name}'") return connections - logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'") + logger.debug( + f"Found {len(net_label_positions)} labels for net '{net_name}'" + ) # 2. Find all wires connected to these label positions - if not hasattr(schematic, 'wire'): + if not hasattr(schematic, "wire"): logger.warning("Schematic has no wires") return connections connected_wire_points = set() for wire in schematic.wire: - if hasattr(wire, 'pts') and hasattr(wire.pts, 'xy'): + if hasattr(wire, "pts") and hasattr(wire.pts, "xy"): # Get all points in this wire (polyline) wire_points = [] for point in wire.pts.xy: - if hasattr(point, 'value'): - wire_points.append([float(point.value[0]), float(point.value[1])]) + if hasattr(point, "value"): + wire_points.append( + [float(point.value[0]), float(point.value[1])] + ) # Check if any wire point touches a label wire_connected = False @@ -323,10 +366,12 @@ class ConnectionManager: logger.debug(f"No wires connected to net '{net_name}' labels") return connections - logger.debug(f"Found {len(connected_wire_points)} wire connection points for net '{net_name}'") + logger.debug( + f"Found {len(connected_wire_points)} wire connection points for net '{net_name}'" + ) # 3. Find component pins at wire endpoints - if not hasattr(schematic, 'symbol'): + if not hasattr(schematic, "symbol"): logger.warning("Schematic has no symbols") return connections @@ -337,15 +382,15 @@ class ConnectionManager: for symbol in schematic.symbol: # Skip template symbols - if not hasattr(symbol.property, 'Reference'): + if not hasattr(symbol.property, "Reference"): continue ref = symbol.property.Reference.value - if ref.startswith('_TEMPLATE'): + if ref.startswith("_TEMPLATE"): continue # Get lib_id for pin location lookup - lib_id = symbol.lib_id.value if hasattr(symbol, 'lib_id') else None + lib_id = symbol.lib_id.value if hasattr(symbol, "lib_id") else None if not lib_id: continue @@ -360,17 +405,18 @@ class ConnectionManager: # Check each pin for pin_num, pin_data in pins.items(): # Get pin location - pin_loc = locator.get_pin_location(schematic_path, ref, pin_num) + pin_loc = locator.get_pin_location( + schematic_path, ref, pin_num + ) if not pin_loc: continue # Check if pin coincides with any wire point for wire_pt in connected_wire_points: if points_coincide(pin_loc, list(wire_pt)): - connections.append({ - "component": ref, - "pin": pin_num - }) + connections.append( + {"component": ref, "pin": pin_num} + ) break # Pin found, no need to check more wire points except Exception as e: @@ -380,7 +426,7 @@ class ConnectionManager: # Fallback: proximity-based matching if no PinLocator if not locator or not schematic_path: - symbol_pos = symbol.at.value if hasattr(symbol, 'at') else None + symbol_pos = symbol.at.value if hasattr(symbol, "at") else None if not symbol_pos: continue @@ -389,12 +435,11 @@ class ConnectionManager: # Check if symbol is near any wire point (within 10mm) for wire_pt in connected_wire_points: - dist = ((symbol_x - wire_pt[0])**2 + (symbol_y - wire_pt[1])**2)**0.5 + dist = ( + (symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2 + ) ** 0.5 if dist < 10.0: # 10mm proximity threshold - connections.append({ - "component": ref, - "pin": "unknown" - }) + connections.append({"component": ref, "pin": "unknown"}) break # Only add once per component logger.info(f"Found {len(connections)} connections for net '{net_name}'") @@ -403,14 +448,20 @@ class ConnectionManager: except Exception as e: logger.error(f"Error getting net connections: {e}") import traceback + logger.error(traceback.format_exc()) return [] @staticmethod - def generate_netlist(schematic: Schematic): + def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None): """ Generate a netlist from the schematic + Args: + schematic: Schematic object + schematic_path: Optional path to schematic file (enables accurate pin matching + via PinLocator; without it, only one connection per component is found) + Returns: Dictionary with net information: { @@ -431,47 +482,58 @@ class ConnectionManager: } """ try: - netlist = { - "nets": [], - "components": [] - } + netlist = {"nets": [], "components": []} # Gather all components - if hasattr(schematic, 'symbol'): + if hasattr(schematic, "symbol"): for symbol in schematic.symbol: component_info = { "reference": symbol.property.Reference.value, - "value": symbol.property.Value.value if hasattr(symbol.property, 'Value') else "", - "footprint": symbol.property.Footprint.value if hasattr(symbol.property, 'Footprint') else "" + "value": ( + symbol.property.Value.value + if hasattr(symbol.property, "Value") + else "" + ), + "footprint": ( + symbol.property.Footprint.value + if hasattr(symbol.property, "Footprint") + else "" + ), } netlist["components"].append(component_info) # Gather all nets from labels - if hasattr(schematic, 'label'): + if hasattr(schematic, "label"): net_names = set() for label in schematic.label: - if hasattr(label, 'value'): + if hasattr(label, "value"): net_names.add(label.value) # For each net, get connections for net_name in net_names: - connections = ConnectionManager.get_net_connections(schematic, net_name) + connections = ConnectionManager.get_net_connections( + schematic, net_name, schematic_path + ) if connections: - netlist["nets"].append({ - "name": net_name, - "connections": connections - }) + netlist["nets"].append( + {"name": net_name, "connections": connections} + ) - logger.info(f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components") + logger.info( + f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components" + ) return netlist except Exception as e: logger.error(f"Error generating netlist: {e}") return {"nets": [], "components": []} -if __name__ == '__main__': + +if __name__ == "__main__": # Example Usage (for testing) - from schematic import SchematicManager # Assuming schematic.py is in the same directory + from schematic import ( + SchematicManager, + ) # Assuming schematic.py is in the same directory # Create a new schematic test_sch = SchematicManager.create_schematic("ConnectionTestSchematic") diff --git a/python/commands/datasheet_manager.py b/python/commands/datasheet_manager.py new file mode 100644 index 0000000..5fa051f --- /dev/null +++ b/python/commands/datasheet_manager.py @@ -0,0 +1,289 @@ +""" +Datasheet Manager for KiCAD MCP Server + +Enriches KiCAD schematic symbols with datasheet URLs derived from LCSC part numbers. +Uses direct text manipulation (like dynamic_symbol_loader.py) to avoid +skip-library-induced schematic corruption. + +URL schema: https://www.lcsc.com/datasheet/{LCSC#}.pdf +No API key required. +""" + +import re +import logging +from pathlib import Path +from typing import Dict, List, Optional + +logger = logging.getLogger("kicad_interface") + +LCSC_DATASHEET_URL = "https://www.lcsc.com/datasheet/{lcsc}.pdf" +LCSC_PRODUCT_URL = "https://www.lcsc.com/product-detail/{lcsc}.html" + +# Values treated as "empty" datasheet +EMPTY_DATASHEET_VALUES = {"~", "", "~{DATASHEET}"} + + +class DatasheetManager: + """ + Enriches KiCAD schematics with LCSC datasheet URLs. + + Reads .kicad_sch files, finds symbol instances that have an LCSC property + but an empty Datasheet property, and fills in the LCSC datasheet URL. + """ + + @staticmethod + def _normalize_lcsc(lcsc: str) -> Optional[str]: + """ + Normalize LCSC number to standard format 'C123456'. + + Accepts: 'C123456', '123456', 'c123456' + Returns: 'C123456' or None if invalid + """ + lcsc = lcsc.strip() + if not lcsc: + return None + # Remove leading C/c + without_prefix = lcsc.lstrip("Cc") + if without_prefix.isdigit(): + return f"C{without_prefix}" + return None + + @staticmethod + def _find_lib_symbols_range(lines: List[str]): + """ + Find the line range of the (lib_symbols ...) section. + Returns (start, end) line indices or (None, None) if not found. + These lines must be excluded from symbol-instance processing. + """ + lib_sym_start = None + lib_sym_end = None + depth = 0 + + for i, line in enumerate(lines): + if "(lib_symbols" in line and lib_sym_start is None: + lib_sym_start = i + depth = 0 + for ch in line: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + elif lib_sym_start is not None and lib_sym_end is None: + for ch in line: + if ch == "(": + depth += 1 + elif ch == ")": + depth -= 1 + if depth == 0: + lib_sym_end = i + break + + return lib_sym_start, lib_sym_end + + @staticmethod + def _process_symbol_block( + lines: List[str], block_start: int, block_end: int + ) -> Optional[Dict]: + """ + Extract LCSC and Datasheet info from a placed symbol block. + + Returns dict with: + - lcsc: normalized LCSC number or None + - datasheet_line: line index of Datasheet property or None + - datasheet_value: current Datasheet value or None + """ + lcsc_value = None + datasheet_line_idx = None + datasheet_current = None + + for k in range(block_start, block_end + 1): + line = lines[k] + + lcsc_match = re.search(r'\(property\s+"LCSC"\s+"([^"]*)"', line) + if lcsc_match: + lcsc_value = lcsc_match.group(1) + + ds_match = re.search(r'\(property\s+"Datasheet"\s+"([^"]*)"', line) + if ds_match: + datasheet_line_idx = k + datasheet_current = ds_match.group(1) + + return { + "lcsc": lcsc_value, + "datasheet_line": datasheet_line_idx, + "datasheet_value": datasheet_current, + } + + def enrich_schematic( + self, schematic_path: Path, dry_run: bool = False + ) -> Dict: + """ + Scan a .kicad_sch file and fill in missing LCSC datasheet URLs. + + For each placed symbol that has: + - (property "LCSC" "C123456") set + - (property "Datasheet" "~") or empty + + Sets: + - (property "Datasheet" "https://www.lcsc.com/datasheet/C123456.pdf") + + Args: + schematic_path: Path to .kicad_sch file + dry_run: If True, return what would be changed without writing + + Returns: + { + "success": True, + "updated": , + "already_set": , + "no_lcsc": , + "no_datasheet_field": , + "details": [{"reference": "...", "lcsc": "...", "url": "..."}] + } + """ + schematic_path = Path(schematic_path) + if not schematic_path.exists(): + return { + "success": False, + "message": f"Schematic not found: {schematic_path}", + } + + with open(schematic_path, "r", encoding="utf-8") as f: + content = f.read() + + lines = content.split("\n") + new_lines = list(lines) + + lib_sym_start, lib_sym_end = self._find_lib_symbols_range(lines) + + updated = 0 + already_set = 0 + no_lcsc = 0 + no_datasheet_field = 0 + details = [] + + i = 0 + while i < len(new_lines): + line = new_lines[i] + + # Skip lib_symbols section + if lib_sym_start is not None and lib_sym_end is not None: + if lib_sym_start <= i <= lib_sym_end: + i += 1 + continue + + # Detect placed symbol: (symbol (lib_id "...") + if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", line): + block_start = i + block_depth = 0 + for ch in line: + if ch == "(": + block_depth += 1 + elif ch == ")": + block_depth -= 1 + + j = i + 1 + while j < len(new_lines) and block_depth > 0: + for ch in new_lines[j]: + if ch == "(": + block_depth += 1 + elif ch == ")": + block_depth -= 1 + if block_depth > 0: + j += 1 + else: + break + + block_end = j + info = self._process_symbol_block(new_lines, block_start, block_end) + + raw_lcsc = info["lcsc"] + ds_line = info["datasheet_line"] + ds_value = info["datasheet_value"] + + # Extract reference for reporting + ref_match = None + for k in range(block_start, block_end + 1): + m = re.search(r'\(property\s+"Reference"\s+"([^"]+)"', new_lines[k]) + if m: + ref_match = m.group(1) + break + reference = ref_match or "?" + + if not raw_lcsc: + no_lcsc += 1 + elif ds_line is None: + no_datasheet_field += 1 + logger.warning( + f"Symbol {reference} has LCSC={raw_lcsc} but no Datasheet property" + ) + else: + lcsc_norm = self._normalize_lcsc(raw_lcsc) + if not lcsc_norm: + no_lcsc += 1 + elif ds_value not in EMPTY_DATASHEET_VALUES: + already_set += 1 + logger.debug( + f"Symbol {reference}: Datasheet already set to {ds_value!r}" + ) + else: + url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm) + if not dry_run: + new_lines[ds_line] = re.sub( + r'(property\s+"Datasheet"\s+)"[^"]*"', + f'\\1"{url}"', + new_lines[ds_line], + ) + updated += 1 + details.append( + { + "reference": reference, + "lcsc": lcsc_norm, + "url": url, + "dry_run": dry_run, + } + ) + logger.info( + f"{'[DRY RUN] ' if dry_run else ''}Set Datasheet for " + f"{reference} ({lcsc_norm}): {url}" + ) + + i = block_end + 1 + continue + + i += 1 + + if not dry_run and updated > 0: + with open(schematic_path, "w", encoding="utf-8") as f: + f.write("\n".join(new_lines)) + logger.info( + f"Saved {schematic_path.name}: {updated} datasheet URLs written" + ) + + return { + "success": True, + "updated": updated, + "already_set": already_set, + "no_lcsc": no_lcsc, + "no_datasheet_field": no_datasheet_field, + "dry_run": dry_run, + "details": details, + "schematic": str(schematic_path), + } + + def get_datasheet_url(self, lcsc: str) -> Optional[str]: + """ + Return the LCSC datasheet URL for a given LCSC number. + No network request – pure URL construction. + """ + norm = self._normalize_lcsc(lcsc) + if norm: + return LCSC_DATASHEET_URL.format(lcsc=norm) + return None + + def get_product_url(self, lcsc: str) -> Optional[str]: + """Return the LCSC product page URL.""" + norm = self._normalize_lcsc(lcsc) + if norm: + return LCSC_PRODUCT_URL.format(lcsc=norm) + return None diff --git a/python/commands/design_rules.py b/python/commands/design_rules.py index 65bd27c..04984c8 100644 --- a/python/commands/design_rules.py +++ b/python/commands/design_rules.py @@ -7,7 +7,8 @@ import pcbnew import logging from typing import Dict, Any, Optional, List, Tuple -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class DesignRuleCommands: """Handles design rule checking and configuration""" @@ -23,7 +24,7 @@ class DesignRuleCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } design_settings = self.board.GetDesignSettings() @@ -57,9 +58,13 @@ class DesignRuleCommands: # Set micro via settings (use properties - methods removed in KiCAD 9.0) if "microViaDiameter" in params: - design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale) + design_settings.m_MicroViasMinSize = int( + params["microViaDiameter"] * scale + ) if "microViaDrill" in params: - design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale) + design_settings.m_MicroViasMinDrill = int( + params["microViaDrill"] * scale + ) # Set minimum values if "minTrackWidth" in params: @@ -72,13 +77,19 @@ class DesignRuleCommands: design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale) if "minMicroViaDiameter" in params: - design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale) + design_settings.m_MicroViasMinSize = int( + params["minMicroViaDiameter"] * scale + ) if "minMicroViaDrill" in params: - design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale) + design_settings.m_MicroViasMinDrill = int( + params["minMicroViaDrill"] * scale + ) # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill if "minHoleDiameter" in params: - design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale) + design_settings.m_MinThroughDrill = int( + params["minHoleDiameter"] * scale + ) # KiCAD 9.0: Added hole clearance settings if "holeClearance" in params: @@ -102,13 +113,13 @@ class DesignRuleCommands: "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, "holeClearance": design_settings.m_HoleClearance / scale, "holeToHoleMin": design_settings.m_HoleToHoleMin / scale, - "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale + "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale, } return { "success": True, "message": "Updated design rules", - "rules": response_rules + "rules": response_rules, } except Exception as e: @@ -116,7 +127,7 @@ class DesignRuleCommands: return { "success": False, "message": "Failed to set design rules", - "errorDetails": str(e) + "errorDetails": str(e), } def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -126,7 +137,7 @@ class DesignRuleCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } design_settings = self.board.GetDesignSettings() @@ -138,42 +149,34 @@ class DesignRuleCommands: "clearance": design_settings.m_MinClearance / scale, "trackWidth": design_settings.GetCurrentTrackWidth() / scale, "minTrackWidth": design_settings.m_TrackMinWidth / scale, - # Via settings (current values from methods) "viaDiameter": design_settings.GetCurrentViaSize() / scale, "viaDrill": design_settings.GetCurrentViaDrill() / scale, - # Via minimum values "minViaDiameter": design_settings.m_ViasMinSize / scale, "viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale, - # Micro via settings "microViaDiameter": design_settings.m_MicroViasMinSize / scale, "microViaDrill": design_settings.m_MicroViasMinDrill / scale, "minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale, "minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale, - # KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter) "minThroughDrill": design_settings.m_MinThroughDrill / scale, "holeClearance": design_settings.m_HoleClearance / scale, "holeToHoleMin": design_settings.m_HoleToHoleMin / scale, - # Other constraints "copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale, "silkClearance": design_settings.m_SilkClearance / scale, } - return { - "success": True, - "rules": rules - } + return {"success": True, "rules": rules} except Exception as e: logger.error(f"Error getting design rules: {str(e)}") return { "success": False, "message": "Failed to get design rules", - "errorDetails": str(e) + "errorDetails": str(e), } def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -189,7 +192,7 @@ class DesignRuleCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } report_path = params.get("reportPath") @@ -200,7 +203,7 @@ class DesignRuleCommands: return { "success": False, "message": "Board file not found", - "errorDetails": "Cannot run DRC without a saved board file" + "errorDetails": "Cannot run DRC without a saved board file", } # Find kicad-cli executable @@ -209,23 +212,28 @@ class DesignRuleCommands: return { "success": False, "message": "kicad-cli not found", - "errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH." + "errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.", } # Create temporary JSON output file - with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp: + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as tmp: json_output = tmp.name try: # Build command cmd = [ kicad_cli, - 'pcb', - 'drc', - '--format', 'json', - '--output', json_output, - '--units', 'mm', - board_file + "pcb", + "drc", + "--format", + "json", + "--output", + json_output, + "--units", + "mm", + board_file, ] logger.info(f"Running DRC command: {' '.join(cmd)}") @@ -235,7 +243,7 @@ class DesignRuleCommands: cmd, capture_output=True, text=True, - timeout=600 # 10 minute timeout for large boards (21MB PCB needs time) + timeout=600, # 10 minute timeout for large boards (21MB PCB needs time) ) if result.returncode != 0: @@ -243,32 +251,34 @@ class DesignRuleCommands: return { "success": False, "message": "DRC command failed", - "errorDetails": result.stderr + "errorDetails": result.stderr, } # Read JSON output - with open(json_output, 'r', encoding='utf-8') as f: + with open(json_output, "r", encoding="utf-8") as f: drc_data = json.load(f) # Parse violations from kicad-cli output violations = [] - violation_counts = {} + violation_counts: dict[str, int] = {} severity_counts = {"error": 0, "warning": 0, "info": 0} - for violation in drc_data.get('violations', []): + for violation in drc_data.get("violations", []): vtype = violation.get("type", "unknown") vseverity = violation.get("severity", "error") - violations.append({ - "type": vtype, - "severity": vseverity, - "message": violation.get("description", ""), - "location": { - "x": violation.get("x", 0), - "y": violation.get("y", 0), - "unit": "mm" + violations.append( + { + "type": vtype, + "severity": vseverity, + "message": violation.get("description", ""), + "location": { + "x": violation.get("x", 0), + "y": violation.get("y", 0), + "unit": "mm", + }, } - }) + ) # Count violations by type violation_counts[vtype] = violation_counts.get(vtype, 0) + 1 @@ -280,30 +290,39 @@ class DesignRuleCommands: # Determine where to save the violations file board_dir = os.path.dirname(board_file) board_name = os.path.splitext(os.path.basename(board_file))[0] - violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json") + violations_file = os.path.join( + board_dir, f"{board_name}_drc_violations.json" + ) # Always save violations to JSON file (for large result sets) - with open(violations_file, 'w', encoding='utf-8') as f: - json.dump({ - "board": board_file, - "timestamp": drc_data.get("date", "unknown"), - "total_violations": len(violations), - "violation_counts": violation_counts, - "severity_counts": severity_counts, - "violations": violations - }, f, indent=2) + with open(violations_file, "w", encoding="utf-8") as f: + json.dump( + { + "board": board_file, + "timestamp": drc_data.get("date", "unknown"), + "total_violations": len(violations), + "violation_counts": violation_counts, + "severity_counts": severity_counts, + "violations": violations, + }, + f, + indent=2, + ) # Save text report if requested if report_path: report_path = os.path.abspath(os.path.expanduser(report_path)) cmd_report = [ kicad_cli, - 'pcb', - 'drc', - '--format', 'report', - '--output', report_path, - '--units', 'mm', - board_file + "pcb", + "drc", + "--format", + "report", + "--output", + report_path, + "--units", + "mm", + board_file, ] subprocess.run(cmd_report, capture_output=True, timeout=600) @@ -314,10 +333,10 @@ class DesignRuleCommands: "summary": { "total": len(violations), "by_severity": severity_counts, - "by_type": violation_counts + "by_type": violation_counts, }, "violationsFile": violations_file, - "reportPath": report_path if report_path else None + "reportPath": report_path if report_path else None, } finally: @@ -330,14 +349,14 @@ class DesignRuleCommands: return { "success": False, "message": "DRC command timed out", - "errorDetails": "Command took longer than 600 seconds (10 minutes)" + "errorDetails": "Command took longer than 600 seconds (10 minutes)", } except Exception as e: logger.error(f"Error running DRC: {str(e)}") return { "success": False, "message": "Failed to run DRC", - "errorDetails": str(e) + "errorDetails": str(e), } def _find_kicad_cli(self) -> Optional[str]: @@ -399,7 +418,7 @@ class DesignRuleCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } severity = params.get("severity", "all") @@ -416,25 +435,27 @@ class DesignRuleCommands: return { "success": False, "message": "Violations file not found", - "errorDetails": "run_drc did not create violations file" + "errorDetails": "run_drc did not create violations file", } # Load violations from file - with open(violations_file, 'r', encoding='utf-8') as f: + with open(violations_file, "r", encoding="utf-8") as f: data = json.load(f) all_violations = data.get("violations", []) # Filter by severity if specified if severity != "all": - filtered_violations = [v for v in all_violations if v.get("severity") == severity] + filtered_violations = [ + v for v in all_violations if v.get("severity") == severity + ] else: filtered_violations = all_violations return { "success": True, "violations": filtered_violations, - "violationsFile": violations_file # Include file path for reference + "violationsFile": violations_file, # Include file path for reference } except Exception as e: @@ -442,5 +463,5 @@ class DesignRuleCommands: return { "success": False, "message": "Failed to get DRC violations", - "errorDetails": str(e) + "errorDetails": str(e), } diff --git a/python/commands/library.py b/python/commands/library.py index 7a168af..765552f 100644 --- a/python/commands/library.py +++ b/python/commands/library.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple import glob -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") class LibraryManager: @@ -85,7 +85,7 @@ class LibraryManager: ) """ try: - with open(table_path, 'r') as f: + with open(table_path, "r") as f: content = f.read() # Simple regex-based parser for lib entries @@ -103,7 +103,9 @@ class LibraryManager: self.libraries[nickname] = resolved_uri logger.debug(f" Found library: {nickname} -> {resolved_uri}") else: - logger.warning(f" Could not resolve URI for library {nickname}: {uri}") + logger.warning( + f" Could not resolve URI for library {nickname}: {uri}" + ) except Exception as e: logger.error(f"Error parsing fp-lib-table at {table_path}: {e}") @@ -124,23 +126,23 @@ class LibraryManager: # Common KiCAD environment variables env_vars = { - 'KICAD9_FOOTPRINT_DIR': self._find_kicad_footprint_dir(), - 'KICAD8_FOOTPRINT_DIR': self._find_kicad_footprint_dir(), - 'KICAD_FOOTPRINT_DIR': self._find_kicad_footprint_dir(), - 'KISYSMOD': self._find_kicad_footprint_dir(), - 'KICAD9_3RD_PARTY': self._find_kicad_3rdparty_dir(), - 'KICAD8_3RD_PARTY': self._find_kicad_3rdparty_dir(), + "KICAD9_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), + "KICAD8_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), + "KICAD_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), + "KISYSMOD": self._find_kicad_footprint_dir(), + "KICAD9_3RD_PARTY": self._find_kicad_3rdparty_dir(), + "KICAD8_3RD_PARTY": self._find_kicad_3rdparty_dir(), } # Project directory if self.project_path: - env_vars['KIPRJMOD'] = str(self.project_path) + env_vars["KIPRJMOD"] = str(self.project_path) # Replace environment variables for var, value in env_vars.items(): if value: - resolved = resolved.replace(f'${{{var}}}', value) - resolved = resolved.replace(f'${var}', value) + resolved = resolved.replace(f"${{{var}}}", value) + resolved = resolved.replace(f"${var}", value) # Expand ~ to home directory resolved = os.path.expanduser(resolved) @@ -167,10 +169,10 @@ class LibraryManager: ] # Also check environment variable - if 'KICAD9_FOOTPRINT_DIR' in os.environ: - possible_paths.insert(0, os.environ['KICAD9_FOOTPRINT_DIR']) - if 'KICAD8_FOOTPRINT_DIR' in os.environ: - possible_paths.insert(0, os.environ['KICAD8_FOOTPRINT_DIR']) + if "KICAD9_FOOTPRINT_DIR" in os.environ: + possible_paths.insert(0, os.environ["KICAD9_FOOTPRINT_DIR"]) + if "KICAD8_FOOTPRINT_DIR" in os.environ: + possible_paths.insert(0, os.environ["KICAD8_FOOTPRINT_DIR"]) for path in possible_paths: if os.path.isdir(path): @@ -190,26 +192,36 @@ class LibraryManager: import json # 1. Check shell environment variable first - if 'KICAD9_3RD_PARTY' in os.environ: - path = os.environ['KICAD9_3RD_PARTY'] + if "KICAD9_3RD_PARTY" in os.environ: + path = os.environ["KICAD9_3RD_PARTY"] if os.path.isdir(path): return path # 2. Check kicad_common.json for user-defined variables kicad_common_paths = [ - Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "kicad_common.json", # macOS + Path.home() + / "Library" + / "Preferences" + / "kicad" + / "9.0" + / "kicad_common.json", # macOS Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux - Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows + Path.home() + / "AppData" + / "Roaming" + / "kicad" + / "9.0" + / "kicad_common.json", # Windows ] for config_path in kicad_common_paths: if config_path.exists(): try: - with open(config_path, 'r') as f: + with open(config_path, "r") as f: config = json.load(f) - env_vars = config.get('environment', {}).get('vars', {}) - if env_vars and 'KICAD9_3RD_PARTY' in env_vars: - path = env_vars['KICAD9_3RD_PARTY'] + env_vars = config.get("environment", {}).get("vars", {}) + if env_vars and "KICAD9_3RD_PARTY" in env_vars: + path = env_vars["KICAD9_3RD_PARTY"] if os.path.isdir(path): return path except (json.JSONDecodeError, KeyError, TypeError): @@ -231,10 +243,10 @@ class LibraryManager: Path.home() / "Documents" / "KiCad" / version / "3rdparty", ] - for path in possible_paths: - if path.exists(): - logger.info(f"Found KiCad 3rd party directory: {path}") - return str(path) + for candidate in possible_paths: + if candidate.exists(): + logger.info(f"Found KiCad 3rd party directory: {candidate}") + return str(candidate) logger.warning("Could not find KiCad 3rd party directory") return None @@ -325,7 +337,9 @@ class LibraryManager: for library_nickname, library_path in self.libraries.items(): fp_file = Path(library_path) / f"{footprint_name}.kicad_mod" if fp_file.exists(): - logger.info(f"Found footprint {footprint_name} in library {library_nickname}") + logger.info( + f"Found footprint {footprint_name} in library {library_nickname}" + ) return (library_path, footprint_name) logger.warning(f"Footprint not found in any library: {footprint_name}") @@ -354,18 +368,22 @@ class LibraryManager: for footprint in footprints: if regex.search(footprint.lower()): - results.append({ - 'library': library_nickname, - 'footprint': footprint, - 'full_name': f"{library_nickname}:{footprint}" - }) + results.append( + { + "library": library_nickname, + "footprint": footprint, + "full_name": f"{library_nickname}:{footprint}", + } + ) if len(results) >= limit: return results return results - def get_footprint_info(self, library_nickname: str, footprint_name: str) -> Optional[Dict[str, str]]: + def get_footprint_info( + self, library_nickname: str, footprint_name: str + ) -> Optional[Dict[str, str]]: """ Get information about a specific footprint @@ -385,11 +403,11 @@ class LibraryManager: return None return { - 'library': library_nickname, - 'footprint': footprint_name, - 'full_name': f"{library_nickname}:{footprint_name}", - 'path': str(fp_file), - 'library_path': library_path + "library": library_nickname, + "footprint": footprint_name, + "full_name": f"{library_nickname}:{footprint_name}", + "path": str(fp_file), + "library_path": library_path, } @@ -404,39 +422,48 @@ class LibraryCommands: """List all available footprint libraries""" try: libraries = self.library_manager.list_libraries() - return { - "success": True, - "libraries": libraries, - "count": len(libraries) - } + return {"success": True, "libraries": libraries, "count": len(libraries)} except Exception as e: logger.error(f"Error listing libraries: {e}") return { "success": False, "message": "Failed to list libraries", - "errorDetails": str(e) + "errorDetails": str(e), } def search_footprints(self, params: Dict) -> Dict: """Search for footprints by pattern""" try: - pattern = params.get("pattern", "*") + # Support both 'pattern' and 'search_term' parameter names + pattern = params.get("pattern") or params.get("search_term", "*") limit = params.get("limit", 20) + library_filter = params.get("library") - results = self.library_manager.search_footprints(pattern, limit) + results = self.library_manager.search_footprints( + pattern, limit * 10 if library_filter else limit + ) + + # Filter by library if specified + if library_filter: + results = [ + r + for r in results + if r.get("library", "").lower() == library_filter.lower() + ] + results = results[:limit] return { "success": True, "footprints": results, "count": len(results), - "pattern": pattern + "pattern": pattern, } except Exception as e: logger.error(f"Error searching footprints: {e}") return { "success": False, "message": "Failed to search footprints", - "errorDetails": str(e) + "errorDetails": str(e), } def list_library_footprints(self, params: Dict) -> Dict: @@ -444,10 +471,7 @@ class LibraryCommands: try: library = params.get("library") if not library: - return { - "success": False, - "message": "Missing library parameter" - } + return {"success": False, "message": "Missing library parameter"} footprints = self.library_manager.list_footprints(library) @@ -455,14 +479,14 @@ class LibraryCommands: "success": True, "library": library, "footprints": footprints, - "count": len(footprints) + "count": len(footprints), } except Exception as e: logger.error(f"Error listing library footprints: {e}") return { "success": False, "message": "Failed to list library footprints", - "errorDetails": str(e) + "errorDetails": str(e), } def get_footprint_info(self, params: Dict) -> Dict: @@ -470,10 +494,7 @@ class LibraryCommands: try: footprint_spec = params.get("footprint") if not footprint_spec: - return { - "success": False, - "message": "Missing footprint parameter" - } + return {"success": False, "message": "Missing footprint parameter"} # Try to find the footprint result = self.library_manager.find_footprint(footprint_spec) @@ -491,17 +512,14 @@ class LibraryCommands: "library": library_nickname, "footprint": footprint_name, "full_name": f"{library_nickname}:{footprint_name}", - "library_path": library_path + "library_path": library_path, } - return { - "success": True, - "footprint_info": info - } + return {"success": True, "footprint_info": info} else: return { "success": False, - "message": f"Footprint not found: {footprint_spec}" + "message": f"Footprint not found: {footprint_spec}", } except Exception as e: @@ -509,5 +527,5 @@ class LibraryCommands: return { "success": False, "message": "Failed to get footprint info", - "errorDetails": str(e) + "errorDetails": str(e), } diff --git a/python/commands/pin_locator.py b/python/commands/pin_locator.py index 1e91bb0..bf4367d 100644 --- a/python/commands/pin_locator.py +++ b/python/commands/pin_locator.py @@ -14,7 +14,7 @@ import sexpdata from sexpdata import Symbol from skip import Schematic -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") class PinLocator: @@ -23,6 +23,7 @@ class PinLocator: def __init__(self): """Initialize pin locator with empty cache""" self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data + self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic @staticmethod def parse_symbol_definition(symbol_def: list) -> Dict[str, Dict]: @@ -47,39 +48,39 @@ class PinLocator: return # Check if this is a pin definition - if len(sexp) > 0 and sexp[0] == Symbol('pin'): + if len(sexp) > 0 and sexp[0] == Symbol("pin"): # Pin format: (pin type shape (at x y angle) (length len) (name "name") (number "num")) pin_data = { - 'x': 0, - 'y': 0, - 'angle': 0, - 'length': 0, - 'name': '', - 'number': '', - 'type': str(sexp[1]) if len(sexp) > 1 else 'passive' + "x": 0, + "y": 0, + "angle": 0, + "length": 0, + "name": "", + "number": "", + "type": str(sexp[1]) if len(sexp) > 1 else "passive", } # Extract pin attributes for item in sexp: if isinstance(item, list) and len(item) > 0: - if item[0] == Symbol('at') and len(item) >= 3: - pin_data['x'] = float(item[1]) - pin_data['y'] = float(item[2]) + if item[0] == Symbol("at") and len(item) >= 3: + pin_data["x"] = float(item[1]) + pin_data["y"] = float(item[2]) if len(item) >= 4: - pin_data['angle'] = float(item[3]) + pin_data["angle"] = float(item[3]) - elif item[0] == Symbol('length') and len(item) >= 2: - pin_data['length'] = float(item[1]) + elif item[0] == Symbol("length") and len(item) >= 2: + pin_data["length"] = float(item[1]) - elif item[0] == Symbol('name') and len(item) >= 2: - pin_data['name'] = str(item[1]).strip('"') + elif item[0] == Symbol("name") and len(item) >= 2: + pin_data["name"] = str(item[1]).strip('"') - elif item[0] == Symbol('number') and len(item) >= 2: - pin_data['number'] = str(item[1]).strip('"') + elif item[0] == Symbol("number") and len(item) >= 2: + pin_data["number"] = str(item[1]).strip('"') # Store by pin number - if pin_data['number']: - pins[pin_data['number']] = pin_data + if pin_data["number"]: + pins[pin_data["number"]] = pin_data # Recurse into sublists for item in sexp: @@ -108,7 +109,7 @@ class PinLocator: try: # Read schematic - with open(schematic_path, 'r', encoding='utf-8') as f: + with open(schematic_path, "r", encoding="utf-8") as f: sch_content = f.read() sch_data = sexpdata.loads(sch_content) @@ -116,7 +117,11 @@ class PinLocator: # Find lib_symbols section lib_symbols = None for item in sch_data: - if isinstance(item, list) and len(item) > 0 and item[0] == Symbol('lib_symbols'): + if ( + isinstance(item, list) + and len(item) > 0 + and item[0] == Symbol("lib_symbols") + ): lib_symbols = item break @@ -126,7 +131,11 @@ class PinLocator: # Find the specific symbol definition for item in lib_symbols[1:]: # Skip 'lib_symbols' itself - if isinstance(item, list) and len(item) > 1 and item[0] == Symbol('symbol'): + if ( + isinstance(item, list) + and len(item) > 1 + and item[0] == Symbol("symbol") + ): symbol_name = str(item[1]).strip('"') if symbol_name == lib_id: # Found the symbol, parse pins @@ -141,6 +150,7 @@ class PinLocator: except Exception as e: logger.error(f"Error getting symbol pins: {e}") import traceback + logger.error(traceback.format_exc()) return {} @@ -169,8 +179,9 @@ class PinLocator: return (rotated_x, rotated_y) - def get_pin_location(self, schematic_path: Path, symbol_reference: str, - pin_number: str) -> Optional[List[float]]: + def get_pin_location( + self, schematic_path: Path, symbol_reference: str, pin_number: str + ) -> Optional[List[float]]: """ Get the absolute location of a pin on a symbol instance @@ -184,7 +195,11 @@ class PinLocator: """ try: # Load schematic with kicad-skip to get symbol instance - sch = Schematic(str(schematic_path)) + # Use cache to avoid reloading the file for every pin lookup + sch_key = str(schematic_path) + if sch_key not in self._schematic_cache: + self._schematic_cache[sch_key] = Schematic(sch_key) + sch = self._schematic_cache[sch_key] # Find the symbol instance target_symbol = None @@ -205,12 +220,16 @@ class PinLocator: symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 # Get symbol lib_id - lib_id = target_symbol.lib_id.value if hasattr(target_symbol, 'lib_id') else None + lib_id = ( + target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None + ) if not lib_id: logger.error(f"Symbol {symbol_reference} has no lib_id") return None - logger.debug(f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, lib_id={lib_id}") + logger.debug( + f"Symbol {symbol_reference}: pos=({symbol_x}, {symbol_y}), rot={symbol_rotation}, lib_id={lib_id}" + ) # Get pin definitions for this symbol pins = self.get_symbol_pins(schematic_path, lib_id) @@ -220,36 +239,49 @@ class PinLocator: # Find the requested pin if pin_number not in pins: - logger.error(f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())}") + logger.error( + f"Pin {pin_number} not found on {symbol_reference}. Available pins: {list(pins.keys())}" + ) return None pin_data = pins[pin_number] # Get pin position relative to symbol origin - pin_rel_x = pin_data['x'] - pin_rel_y = pin_data['y'] + pin_rel_x = pin_data["x"] + pin_rel_y = pin_data["y"] - logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})") + logger.debug( + f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})" + ) # Apply symbol rotation to pin position if symbol_rotation != 0: - pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation) - logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})") + pin_rel_x, pin_rel_y = self.rotate_point( + pin_rel_x, pin_rel_y, symbol_rotation + ) + logger.debug( + f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})" + ) # Calculate absolute position abs_x = symbol_x + pin_rel_x abs_y = symbol_y + pin_rel_y - logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})") + logger.info( + f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})" + ) return [abs_x, abs_y] except Exception as e: logger.error(f"Error getting pin location: {e}") import traceback + logger.error(traceback.format_exc()) return None - def get_all_symbol_pins(self, schematic_path: Path, symbol_reference: str) -> Dict[str, List[float]]: + def get_all_symbol_pins( + self, schematic_path: Path, symbol_reference: str + ) -> Dict[str, List[float]]: """ Get locations of all pins on a symbol instance @@ -261,8 +293,11 @@ class PinLocator: Dictionary mapping pin number -> [x, y] coordinates """ try: - # Load schematic - sch = Schematic(str(schematic_path)) + # Load schematic (use cache) + sch_key = str(schematic_path) + if sch_key not in self._schematic_cache: + self._schematic_cache[sch_key] = Schematic(sch_key) + sch = self._schematic_cache[sch_key] # Find symbol target_symbol = None @@ -276,7 +311,9 @@ class PinLocator: return {} # Get lib_id - lib_id = target_symbol.lib_id.value if hasattr(target_symbol, 'lib_id') else None + lib_id = ( + target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None + ) if not lib_id: logger.error(f"Symbol {symbol_reference} has no lib_id") return {} @@ -289,7 +326,9 @@ class PinLocator: # Calculate location for each pin result = {} for pin_num in pins.keys(): - location = self.get_pin_location(schematic_path, symbol_reference, pin_num) + location = self.get_pin_location( + schematic_path, symbol_reference, pin_num + ) if location: result[pin_num] = location @@ -301,10 +340,11 @@ class PinLocator: return {} -if __name__ == '__main__': +if __name__ == "__main__": # Test pin location discovery import sys - sys.path.insert(0, '/home/chris/MCP/KiCAD-MCP-Server/python') + + sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python") from pathlib import Path from commands.component_schematic import ComponentManager @@ -316,8 +356,10 @@ if __name__ == '__main__': print("=" * 80) # Create test schematic with components (cross-platform temp directory) - test_path = Path(tempfile.gettempdir()) / 'test_pin_locator.kicad_sch' - template_path = Path('/home/chris/MCP/KiCAD-MCP-Server/python/templates/template_with_symbols_expanded.kicad_sch') + test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch" + template_path = Path( + "/home/chris/MCP/KiCAD-MCP-Server/python/templates/template_with_symbols_expanded.kicad_sch" + ) shutil.copy(template_path, test_path) print(f"\n✓ Created test schematic: {test_path}") @@ -327,11 +369,25 @@ if __name__ == '__main__': sch = SchematicManager.load_schematic(str(test_path)) # Add resistor at (100, 100), rotation 0 - r1_def = {'type': 'R', 'reference': 'R1', 'value': '10k', 'x': 100, 'y': 100, 'rotation': 0} + r1_def = { + "type": "R", + "reference": "R1", + "value": "10k", + "x": 100, + "y": 100, + "rotation": 0, + } ComponentManager.add_component(sch, r1_def, test_path) # Add capacitor at (150, 100), rotation 90 - c1_def = {'type': 'C', 'reference': 'C1', 'value': '100nF', 'x': 150, 'y': 100, 'rotation': 90} + c1_def = { + "type": "C", + "reference": "C1", + "value": "100nF", + "x": 150, + "y": 100, + "rotation": 90, + } ComponentManager.add_component(sch, c1_def, test_path) SchematicManager.save_schematic(sch, str(test_path)) diff --git a/python/commands/routing.py b/python/commands/routing.py index c3a6c54..7ac6ffc 100644 --- a/python/commands/routing.py +++ b/python/commands/routing.py @@ -8,7 +8,8 @@ import logging import math from typing import Dict, Any, Optional, List, Tuple -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") + class RoutingCommands: """Handles routing-related KiCAD operations""" @@ -24,7 +25,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } name = params.get("name") @@ -34,7 +35,7 @@ class RoutingCommands: return { "success": False, "message": "Missing net name", - "errorDetails": "name parameter is required" + "errorDetails": "name parameter is required", } # Create new net @@ -58,8 +59,8 @@ class RoutingCommands: "net": { "name": name, "class": net_class if net_class else "Default", - "netcode": net.GetNetCode() - } + "netcode": net.GetNetCode(), + }, } except Exception as e: @@ -67,7 +68,7 @@ class RoutingCommands: return { "success": False, "message": "Failed to add net", - "errorDetails": str(e) + "errorDetails": str(e), } def route_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -77,7 +78,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } start = params.get("start") @@ -91,7 +92,7 @@ class RoutingCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "start and end points are required" + "errorDetails": "start and end points are required", } # Get layer ID @@ -100,7 +101,7 @@ class RoutingCommands: return { "success": False, "message": "Invalid layer", - "errorDetails": f"Layer '{layer}' does not exist" + "errorDetails": f"Layer '{layer}' does not exist", } # Get start point @@ -133,14 +134,16 @@ class RoutingCommands: # Add via if requested and net is specified if via and net: via_point = end_point - self.add_via({ - "position": { - "x": via_point.x / 1000000, - "y": via_point.y / 1000000, - "unit": "mm" - }, - "net": net - }) + self.add_via( + { + "position": { + "x": via_point.x / 1000000, + "y": via_point.y / 1000000, + "unit": "mm", + }, + "net": net, + } + ) return { "success": True, @@ -149,17 +152,17 @@ class RoutingCommands: "start": { "x": start_point.x / 1000000, "y": start_point.y / 1000000, - "unit": "mm" + "unit": "mm", }, "end": { "x": end_point.x / 1000000, "y": end_point.y / 1000000, - "unit": "mm" + "unit": "mm", }, "layer": layer, "width": track.GetWidth() / 1000000, - "net": net - } + "net": net, + }, } except Exception as e: @@ -167,7 +170,7 @@ class RoutingCommands: return { "success": False, "message": "Failed to route trace", - "errorDetails": str(e) + "errorDetails": str(e), } def add_via(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -177,7 +180,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } position = params.get("position") @@ -191,22 +194,28 @@ class RoutingCommands: return { "success": False, "message": "Missing position", - "errorDetails": "position parameter is required" + "errorDetails": "position parameter is required", } # Create via via = pcbnew.PCB_VIA(self.board) - + # Set position - scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 if position["unit"] == "mm" else 25400000 + ) # mm or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) # Set size and drill (default to board's current via settings) design_settings = self.board.GetDesignSettings() - via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize()) - via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill()) + via.SetWidth( + int(size * 1000000) if size else design_settings.GetCurrentViaSize() + ) + via.SetDrill( + int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill() + ) # Set layers from_id = self.board.GetLayerID(from_layer) @@ -215,7 +224,7 @@ class RoutingCommands: return { "success": False, "message": "Invalid layer", - "errorDetails": "Specified layers do not exist" + "errorDetails": "Specified layers do not exist", } via.SetLayerPair(from_id, to_id) @@ -237,14 +246,14 @@ class RoutingCommands: "position": { "x": position["x"], "y": position["y"], - "unit": position["unit"] + "unit": position["unit"], }, "size": via.GetWidth() / 1000000, "drill": via.GetDrill() / 1000000, "from_layer": from_layer, "to_layer": to_layer, - "net": net - } + "net": net, + }, } except Exception as e: @@ -252,7 +261,7 @@ class RoutingCommands: return { "success": False, "message": "Failed to add via", - "errorDetails": str(e) + "errorDetails": str(e), } def delete_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -262,7 +271,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } trace_uuid = params.get("traceUuid") @@ -275,13 +284,13 @@ class RoutingCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "One of traceUuid, position, or net must be provided" + "errorDetails": "One of traceUuid, position, or net must be provided", } # Delete by net name (bulk delete) if net_name: tracks_to_remove = [] - for track in self.board.Tracks(): + for track in list(self.board.Tracks()): if track.GetNetname() != net_name: continue @@ -298,20 +307,23 @@ class RoutingCommands: tracks_to_remove.append(track) + deleted_count = len(tracks_to_remove) for track in tracks_to_remove: self.board.Remove(track) + tracks_to_remove.clear() + self.board.SetModified() return { "success": True, - "message": f"Deleted {len(tracks_to_remove)} traces on net '{net_name}'", - "deletedCount": len(tracks_to_remove) + "message": f"Deleted {deleted_count} traces on net '{net_name}'", + "deletedCount": deleted_count, } # Find track by UUID if trace_uuid: track = None - for item in self.board.Tracks(): - if str(item.m_Uuid) == trace_uuid: + for item in list(self.board.Tracks()): + if item.m_Uuid.AsString() == trace_uuid: track = item break @@ -319,26 +331,35 @@ class RoutingCommands: return { "success": False, "message": "Track not found", - "errorDetails": f"Could not find track with UUID: {trace_uuid}" + "errorDetails": f"Could not find track with UUID: {trace_uuid}", } self.board.Remove(track) + track = None + self.board.SetModified() + return {"success": True, "message": f"Deleted track: {trace_uuid}"} + + # No valid parameters provided + if not position: return { - "success": True, - "message": f"Deleted track: {trace_uuid}" + "success": False, + "message": "No valid search parameter provided", + "errorDetails": "Provide traceUuid, position, or net parameter", } # Find track by position if position: - scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm + scale = ( + 1000000 if position["unit"] == "mm" else 25400000 + ) # mm or inch to nm x_nm = int(position["x"] * scale) y_nm = int(position["y"] * scale) point = pcbnew.VECTOR2I(x_nm, y_nm) # Find closest track closest_track = None - min_distance = float('inf') - for track in self.board.Tracks(): + min_distance = float("inf") + for track in list(self.board.Tracks()): dist = self._point_to_track_distance(point, track) if dist < min_distance: min_distance = dist @@ -346,15 +367,17 @@ class RoutingCommands: if closest_track and min_distance < 1000000: # Within 1mm self.board.Remove(closest_track) + closest_track = None + self.board.SetModified() return { "success": True, - "message": "Deleted track at specified position" + "message": "Deleted track at specified position", } else: return { "success": False, "message": "No track found", - "errorDetails": "No track found near specified position" + "errorDetails": "No track found near specified position", } except Exception as e: @@ -362,8 +385,13 @@ class RoutingCommands: return { "success": False, "message": "Failed to delete trace", - "errorDetails": str(e) + "errorDetails": str(e), } + return { + "success": False, + "message": "No action taken", + "errorDetails": "No matching trace found for given parameters", + } def get_nets_list(self, params: Dict[str, Any]) -> Dict[str, Any]: """Get a list of all nets in the PCB""" @@ -372,7 +400,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } nets = [] @@ -380,23 +408,22 @@ class RoutingCommands: for net_code in range(netinfo.GetNetCount()): net = netinfo.GetNetItem(net_code) if net: - nets.append({ - "name": net.GetNetname(), - "code": net.GetNetCode(), - "class": net.GetClassName() - }) + nets.append( + { + "name": net.GetNetname(), + "code": net.GetNetCode(), + "class": net.GetClassName(), + } + ) - return { - "success": True, - "nets": nets - } + return {"success": True, "nets": nets} except Exception as e: logger.error(f"Error getting nets list: {str(e)}") return { "success": False, "message": "Failed to get nets list", - "errorDetails": str(e) + "errorDetails": str(e), } def query_traces(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -406,7 +433,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } # Get filter parameters @@ -420,86 +447,90 @@ class RoutingCommands: vias = [] # Process tracks - for track in self.board.Tracks(): - # Check if it's a via - is_via = track.Type() == pcbnew.PCB_VIA_T + for track in list(self.board.Tracks()): + try: + # Check if it's a via + is_via = track.Type() == pcbnew.PCB_VIA_T - if is_via and not include_vias: - continue - - # Filter by net - if net_name and track.GetNetname() != net_name: - continue - - # Filter by layer (only for tracks, not vias) - if layer and not is_via: - layer_id = self.board.GetLayerID(layer) - if track.GetLayer() != layer_id: + if is_via and not include_vias: continue - # Filter by bounding box - if bbox: - bbox_unit = bbox.get("unit", "mm") - bbox_scale = scale if bbox_unit == "mm" else 25400000 - x1 = int(bbox.get("x1", 0) * bbox_scale) - y1 = int(bbox.get("y1", 0) * bbox_scale) - x2 = int(bbox.get("x2", 0) * bbox_scale) - y2 = int(bbox.get("y2", 0) * bbox_scale) + # Filter by net + if net_name and track.GetNetname() != net_name: + continue + + # Filter by layer (only for tracks, not vias) + if layer and not is_via: + layer_id = self.board.GetLayerID(layer) + if track.GetLayer() != layer_id: + continue + + # Filter by bounding box + if bbox: + bbox_unit = bbox.get("unit", "mm") + bbox_scale = scale if bbox_unit == "mm" else 25400000 + x1 = int(bbox.get("x1", 0) * bbox_scale) + y1 = int(bbox.get("y1", 0) * bbox_scale) + x2 = int(bbox.get("x2", 0) * bbox_scale) + y2 = int(bbox.get("y2", 0) * bbox_scale) + + if is_via: + pos = track.GetPosition() + if not (x1 <= pos.x <= x2 and y1 <= pos.y <= y2): + continue + else: + start = track.GetStart() + end = track.GetEnd() + # Check if either endpoint is within bbox + start_in = x1 <= start.x <= x2 and y1 <= start.y <= y2 + end_in = x1 <= end.x <= x2 and y1 <= end.y <= y2 + if not (start_in or end_in): + continue if is_via: pos = track.GetPosition() - if not (x1 <= pos.x <= x2 and y1 <= pos.y <= y2): - continue + vias.append( + { + "uuid": track.m_Uuid.AsString(), + "position": { + "x": pos.x / scale, + "y": pos.y / scale, + "unit": "mm", + }, + "net": track.GetNetname(), + "netCode": track.GetNetCode(), + "diameter": track.GetWidth() / scale, + "drill": track.GetDrillValue() / scale, + } + ) else: start = track.GetStart() end = track.GetEnd() - # Check if either endpoint is within bbox - start_in = x1 <= start.x <= x2 and y1 <= start.y <= y2 - end_in = x1 <= end.x <= x2 and y1 <= end.y <= y2 - if not (start_in or end_in): - continue + traces.append( + { + "uuid": track.m_Uuid.AsString(), + "net": track.GetNetname(), + "netCode": track.GetNetCode(), + "layer": self.board.GetLayerName(track.GetLayer()), + "width": track.GetWidth() / scale, + "start": { + "x": start.x / scale, + "y": start.y / scale, + "unit": "mm", + }, + "end": { + "x": end.x / scale, + "y": end.y / scale, + "unit": "mm", + }, + "length": track.GetLength() / scale, + } + ) + except Exception as track_err: + logger.warning(f"Skipping invalid track object: {track_err}") + continue - if is_via: - pos = track.GetPosition() - vias.append({ - "uuid": str(track.m_Uuid), - "position": { - "x": pos.x / scale, - "y": pos.y / scale, - "unit": "mm" - }, - "net": track.GetNetname(), - "netCode": track.GetNetCode(), - "diameter": track.GetWidth() / scale, - "drill": track.GetDrillValue() / scale - }) - else: - start = track.GetStart() - end = track.GetEnd() - traces.append({ - "uuid": str(track.m_Uuid), - "net": track.GetNetname(), - "netCode": track.GetNetCode(), - "layer": self.board.GetLayerName(track.GetLayer()), - "width": track.GetWidth() / scale, - "start": { - "x": start.x / scale, - "y": start.y / scale, - "unit": "mm" - }, - "end": { - "x": end.x / scale, - "y": end.y / scale, - "unit": "mm" - }, - "length": track.GetLength() / scale - }) - - result = { - "success": True, - "traceCount": len(traces), - "traces": traces - } + result = {"success": True, "traceCount": len(traces), "traces": traces} if include_vias: result["viaCount"] = len(vias) @@ -512,7 +543,7 @@ class RoutingCommands: return { "success": False, "message": "Failed to query traces", - "errorDetails": str(e) + "errorDetails": str(e), } def modify_trace(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -526,7 +557,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } # Identification parameters @@ -542,7 +573,7 @@ class RoutingCommands: return { "success": False, "message": "Missing trace identifier", - "errorDetails": "Provide either 'uuid' or 'position' to identify the trace" + "errorDetails": "Provide either 'uuid' or 'position' to identify the trace", } scale = 1000000 # nm to mm conversion @@ -551,8 +582,8 @@ class RoutingCommands: track = None if trace_uuid: - for item in self.board.Tracks(): - if str(item.m_Uuid) == trace_uuid: + for item in list(self.board.Tracks()): + if item.m_Uuid.AsString() == trace_uuid: track = item break elif position: @@ -563,8 +594,8 @@ class RoutingCommands: point = pcbnew.VECTOR2I(x_nm, y_nm) # Find closest track - min_distance = float('inf') - for item in self.board.Tracks(): + min_distance = float("inf") + for item in list(self.board.Tracks()): dist = self._point_to_track_distance(point, item) if dist < min_distance: min_distance = dist @@ -578,7 +609,7 @@ class RoutingCommands: return { "success": False, "message": "Track not found", - "errorDetails": "Could not find track with specified identifier" + "errorDetails": "Could not find track with specified identifier", } # Check if it's a via (some modifications don't apply) @@ -597,7 +628,7 @@ class RoutingCommands: return { "success": False, "message": "Invalid layer", - "errorDetails": f"Layer '{new_layer}' not found" + "errorDetails": f"Layer '{new_layer}' not found", } track.SetLayer(layer_id) modifications.append(f"layer={new_layer}") @@ -609,7 +640,7 @@ class RoutingCommands: return { "success": False, "message": "Invalid net", - "errorDetails": f"Net '{new_net}' not found" + "errorDetails": f"Net '{new_net}' not found", } track.SetNet(net) modifications.append(f"net={new_net}") @@ -618,14 +649,14 @@ class RoutingCommands: return { "success": False, "message": "No modifications specified", - "errorDetails": "Provide at least one of: width, layer, net" + "errorDetails": "Provide at least one of: width, layer, net", } return { "success": True, "message": f"Modified trace: {', '.join(modifications)}", - "uuid": str(track.m_Uuid), - "modifications": modifications + "uuid": track.m_Uuid.AsString(), + "modifications": modifications, } except Exception as e: @@ -633,7 +664,7 @@ class RoutingCommands: return { "success": False, "message": "Failed to modify trace", - "errorDetails": str(e) + "errorDetails": str(e), } def copy_routing_pattern(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -648,7 +679,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } source_refs = params.get("sourceRefs", []) # e.g., ["U1", "U2", "U3"] @@ -660,14 +691,14 @@ class RoutingCommands: return { "success": False, "message": "Missing component references", - "errorDetails": "Provide both 'sourceRefs' and 'targetRefs' arrays" + "errorDetails": "Provide both 'sourceRefs' and 'targetRefs' arrays", } if len(source_refs) != len(target_refs): return { "success": False, "message": "Mismatched component counts", - "errorDetails": f"sourceRefs has {len(source_refs)} items, targetRefs has {len(target_refs)}" + "errorDetails": f"sourceRefs has {len(source_refs)} items, targetRefs has {len(target_refs)}", } scale = 1000000 # nm to mm conversion @@ -681,7 +712,7 @@ class RoutingCommands: return { "success": False, "message": "Component not found", - "errorDetails": f"Component '{ref}' not found on board" + "errorDetails": f"Component '{ref}' not found on board", } # Calculate offset from first source to first target component @@ -709,7 +740,7 @@ class RoutingCommands: traces_to_copy = [] vias_to_copy = [] - for track in self.board.Tracks(): + for track in list(self.board.Tracks()): if track.GetNetname() not in source_nets: continue @@ -731,7 +762,9 @@ class RoutingCommands: # Create new track new_track = pcbnew.PCB_TRACK(self.board) - new_track.SetStart(pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y)) + new_track.SetStart( + pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y) + ) new_track.SetEnd(pcbnew.VECTOR2I(end.x + offset_x, end.y + offset_y)) new_track.SetLayer(track.GetLayer()) @@ -763,15 +796,11 @@ class RoutingCommands: result = { "success": True, "message": f"Copied routing pattern: {created_traces} traces, {created_vias} vias", - "offset": { - "x": offset_x / scale, - "y": offset_y / scale, - "unit": "mm" - }, + "offset": {"x": offset_x / scale, "y": offset_y / scale, "unit": "mm"}, "createdTraces": created_traces, "createdVias": created_vias, "sourceComponents": source_refs, - "targetComponents": target_refs + "targetComponents": target_refs, } return result @@ -781,7 +810,7 @@ class RoutingCommands: return { "success": False, "message": "Failed to copy routing pattern", - "errorDetails": str(e) + "errorDetails": str(e), } def create_netclass(self, params: Dict[str, Any]) -> Dict[str, Any]: @@ -791,7 +820,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } name = params.get("name") @@ -809,12 +838,12 @@ class RoutingCommands: return { "success": False, "message": "Missing netclass name", - "errorDetails": "name parameter is required" + "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) @@ -862,8 +891,8 @@ class RoutingCommands: "uviaDrill": netclass.GetMicroViaDrill() / scale, "diffPairWidth": netclass.GetDiffPairWidth() / scale, "diffPairGap": netclass.GetDiffPairGap() / scale, - "nets": nets - } + "nets": nets, + }, } except Exception as e: @@ -871,9 +900,9 @@ class RoutingCommands: return { "success": False, "message": "Failed to create net class", - "errorDetails": str(e) + "errorDetails": str(e), } - + def add_copper_pour(self, params: Dict[str, Any]) -> Dict[str, Any]: """Add a copper pour (zone) to the PCB""" try: @@ -881,7 +910,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } layer = params.get("layer", "F.Cu") @@ -891,12 +920,12 @@ class RoutingCommands: points = params.get("points", []) priority = params.get("priority", 0) fill_type = params.get("fillType", "solid") # solid or hatched - + if not points or len(points) < 3: return { "success": False, "message": "Missing points", - "errorDetails": "At least 3 points are required for copper pour outline" + "errorDetails": "At least 3 points are required for copper pour outline", } # Get layer ID @@ -905,13 +934,13 @@ class RoutingCommands: return { "success": False, "message": "Invalid layer", - "errorDetails": f"Layer '{layer}' does not exist" + "errorDetails": f"Layer '{layer}' does not exist", } # Create zone zone = pcbnew.ZONE(self.board) zone.SetLayer(layer_id) - + # Set net if provided if net: netinfo = self.board.GetNetInfo() @@ -919,22 +948,22 @@ class RoutingCommands: if nets_map.has_key(net): net_obj = nets_map[net] zone.SetNet(net_obj) - + # Set zone properties scale = 1000000 # mm to nm zone.SetAssignedPriority(priority) - + if clearance is not None: zone.SetLocalClearance(int(clearance * scale)) - + zone.SetMinThickness(int(min_width * scale)) - + # Set fill type if fill_type == "hatched": zone.SetFillMode(pcbnew.ZONE_FILL_MODE_HATCH_PATTERN) else: zone.SetFillMode(pcbnew.ZONE_FILL_MODE_POLYGONS) - + # Create outline outline = zone.Outline() outline.NewOutline() # Create a new outline contour first @@ -945,7 +974,7 @@ class RoutingCommands: x_nm = int(point["x"] * scale) y_nm = int(point["y"] * scale) outline.Append(pcbnew.VECTOR2I(x_nm, y_nm)) # Add point to outline - + # Add zone to board self.board.Add(zone) @@ -965,8 +994,8 @@ class RoutingCommands: "minWidth": min_width, "priority": priority, "fillType": fill_type, - "pointCount": len(points) - } + "pointCount": len(points), + }, } except Exception as e: @@ -974,9 +1003,9 @@ class RoutingCommands: return { "success": False, "message": "Failed to add copper pour", - "errorDetails": str(e) + "errorDetails": str(e), } - + def route_differential_pair(self, params: Dict[str, Any]) -> Dict[str, Any]: """Route a differential pair between two sets of points or pads""" try: @@ -984,7 +1013,7 @@ class RoutingCommands: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } start_pos = params.get("startPos") @@ -999,7 +1028,7 @@ class RoutingCommands: return { "success": False, "message": "Missing parameters", - "errorDetails": "startPos, endPos, netPos, and netNeg are required" + "errorDetails": "startPos, endPos, netPos, and netNeg are required", } # Get layer ID @@ -1008,7 +1037,7 @@ class RoutingCommands: return { "success": False, "message": "Invalid layer", - "errorDetails": f"Layer '{layer}' does not exist" + "errorDetails": f"Layer '{layer}' does not exist", } # Get nets @@ -1022,65 +1051,73 @@ class RoutingCommands: return { "success": False, "message": "Nets not found", - "errorDetails": "One or both nets specified for the differential pair do not exist" + "errorDetails": "One or both nets specified for the differential pair do not exist", } # Get start and end points start_point = self._get_point(start_pos) end_point = self._get_point(end_pos) - + # Calculate offset vectors for the two traces # First, get the direction vector from start to end dx = end_point.x - start_point.x dy = end_point.y - start_point.y length = math.sqrt(dx * dx + dy * dy) - + if length <= 0: return { "success": False, "message": "Invalid points", - "errorDetails": "Start and end points must be different" + "errorDetails": "Start and end points must be different", } - + # Normalize direction vector dx /= length dy /= length - + # Get perpendicular vector px = -dy py = dx - + # Set default gap if not provided if gap is None: gap = 0.2 # mm - + # Convert to nm gap_nm = int(gap * 1000000) - + # Calculate offsets offset_x = int(px * gap_nm / 2) offset_y = int(py * gap_nm / 2) - + # Create positive and negative trace points - pos_start = pcbnew.VECTOR2I(int(start_point.x + offset_x), int(start_point.y + offset_y)) - pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y)) - neg_start = pcbnew.VECTOR2I(int(start_point.x - offset_x), int(start_point.y - offset_y)) - neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y)) - + pos_start = pcbnew.VECTOR2I( + int(start_point.x + offset_x), int(start_point.y + offset_y) + ) + pos_end = pcbnew.VECTOR2I( + int(end_point.x + offset_x), int(end_point.y + offset_y) + ) + neg_start = pcbnew.VECTOR2I( + int(start_point.x - offset_x), int(start_point.y - offset_y) + ) + neg_end = pcbnew.VECTOR2I( + int(end_point.x - offset_x), int(end_point.y - offset_y) + ) + # Create positive trace pos_track = pcbnew.PCB_TRACK(self.board) pos_track.SetStart(pos_start) pos_track.SetEnd(pos_end) pos_track.SetLayer(layer_id) pos_track.SetNet(net_pos_obj) - + # Create negative trace neg_track = pcbnew.PCB_TRACK(self.board) neg_track.SetStart(neg_start) neg_track.SetEnd(neg_end) neg_track.SetLayer(layer_id) neg_track.SetNet(net_neg_obj) - + # Set width if width: trace_width_nm = int(width * 1000000) @@ -1091,7 +1128,7 @@ class RoutingCommands: trace_width = self.board.GetDesignSettings().GetCurrentTrackWidth() pos_track.SetWidth(trace_width) neg_track.SetWidth(trace_width) - + # Add tracks to board self.board.Add(pos_track) self.board.Add(neg_track) @@ -1105,8 +1142,8 @@ class RoutingCommands: "layer": layer, "width": pos_track.GetWidth() / 1000000, "gap": gap, - "length": length / 1000000 - } + "length": length / 1000000, + }, } except Exception as e: @@ -1114,7 +1151,7 @@ class RoutingCommands: return { "success": False, "message": "Failed to route differential pair", - "errorDetails": str(e) + "errorDetails": str(e), } def _get_point(self, point_spec: Dict[str, Any]) -> pcbnew.VECTOR2I: @@ -1132,34 +1169,33 @@ class RoutingCommands: return pad.GetPosition() raise ValueError("Invalid point specification") - def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float: + def _point_to_track_distance( + self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK + ) -> float: """Calculate distance from point to track segment""" start = track.GetStart() end = track.GetEnd() - + # Vector from start to end v = pcbnew.VECTOR2I(end.x - start.x, end.y - start.y) # Vector from start to point w = pcbnew.VECTOR2I(point.x - start.x, point.y - start.y) - + # Length of track squared c1 = v.x * v.x + v.y * v.y if c1 == 0: return self._point_distance(point, start) - + # Projection coefficient c2 = float(w.x * v.x + w.y * v.y) / c1 - + if c2 < 0: return self._point_distance(point, start) elif c2 > 1: return self._point_distance(point, end) - + # Point on line - proj = pcbnew.VECTOR2I( - int(start.x + c2 * v.x), - int(start.y + c2 * v.y) - ) + proj = pcbnew.VECTOR2I(int(start.x + c2 * v.x), int(start.y + c2 * v.y)) return self._point_distance(point, proj) def _point_distance(self, p1: pcbnew.VECTOR2I, p2: pcbnew.VECTOR2I) -> float: diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 2beb33b..dcf1f54 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -19,19 +19,16 @@ from schemas.tool_schemas import TOOL_SCHEMAS from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read # Configure logging -log_dir = os.path.join(os.path.expanduser('~'), '.kicad-mcp', 'logs') +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') +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) - ] + format="%(asctime)s [%(levelname)s] %(message)s", + handlers=[logging.FileHandler(log_file), logging.StreamHandler(sys.stderr)], ) -logger = logging.getLogger('kicad_interface') +logger = logging.getLogger("kicad_interface") # Log Python environment details logger.info(f"Python version: {sys.version}") @@ -40,16 +37,13 @@ logger.info(f"Platform: {sys.platform}") logger.info(f"Working directory: {os.getcwd()}") # Windows-specific diagnostics -if sys.platform == 'win32': +if sys.platform == "win32": logger.info("=== Windows Environment Diagnostics ===") logger.info(f"PYTHONPATH: {os.environ.get('PYTHONPATH', 'NOT SET')}") logger.info(f"PATH: {os.environ.get('PATH', 'NOT SET')[:200]}...") # Truncate PATH # Check for common KiCAD installations - common_kicad_paths = [ - r"C:\Program Files\KiCad", - r"C:\Program Files (x86)\KiCad" - ] + common_kicad_paths = [r"C:\Program Files\KiCad", r"C:\Program Files (x86)\KiCad"] found_kicad = False for base_path in common_kicad_paths: @@ -57,10 +51,16 @@ if sys.platform == 'win32': logger.info(f"Found KiCAD installation at: {base_path}") # List versions try: - versions = [d for d in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, d))] + versions = [ + d + for d in os.listdir(base_path) + if os.path.isdir(os.path.join(base_path, d)) + ] logger.info(f" Versions found: {', '.join(versions)}") for version in versions: - python_path = os.path.join(base_path, version, 'lib', 'python3', 'dist-packages') + python_path = os.path.join( + base_path, version, "lib", "python3", "dist-packages" + ) if os.path.exists(python_path): logger.info(f" ✓ Python path exists: {python_path}") found_kicad = True @@ -71,7 +71,9 @@ if sys.platform == 'win32': if not found_kicad: logger.warning("No KiCAD installations found in standard locations!") - logger.warning("Please ensure KiCAD 9.0+ is installed from https://www.kicad.org/download/windows/") + logger.warning( + "Please ensure KiCAD 9.0+ is installed from https://www.kicad.org/download/windows/" + ) logger.info("========================================") @@ -90,7 +92,9 @@ paths_added = PlatformHelper.add_kicad_to_python_path() if paths_added: logger.info("Successfully added KiCAD Python paths to sys.path") else: - logger.warning("No KiCAD Python paths found - attempting to import pcbnew from system path") + logger.warning( + "No KiCAD Python paths found - attempting to import pcbnew from system path" + ) logger.info(f"Current Python path: {sys.path}") @@ -108,7 +112,7 @@ logger.info(f"KiCAD backend preference: {KICAD_BACKEND}") USE_IPC_BACKEND = False ipc_backend = None -if KICAD_BACKEND in ('auto', 'ipc'): +if KICAD_BACKEND in ("auto", "ipc"): try: logger.info("Checking IPC backend availability...") from kicad_api.ipc_backend import IPCBackend @@ -129,11 +133,12 @@ if KICAD_BACKEND in ('auto', 'ipc'): ipc_backend = None # Fall back to SWIG backend if IPC not available -if not USE_IPC_BACKEND and KICAD_BACKEND != 'ipc': +if not USE_IPC_BACKEND and KICAD_BACKEND != "ipc": # Import KiCAD's Python API (SWIG) try: logger.info("Attempting to import pcbnew module (SWIG backend)...") import pcbnew # type: ignore + 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") @@ -143,7 +148,7 @@ if not USE_IPC_BACKEND and KICAD_BACKEND != 'ipc': # Platform-specific help message help_message = "" - if sys.platform == 'win32': + if sys.platform == "win32": help_message = """ Windows Troubleshooting: 1. Verify KiCAD is installed: C:\\Program Files\\KiCad\\9.0 @@ -153,7 +158,7 @@ Windows Troubleshooting: 4. Log file location: %USERPROFILE%\\.kicad-mcp\\logs\\kicad_interface.log 5. Run setup-windows.ps1 for automatic configuration """ - elif sys.platform == 'darwin': + elif sys.platform == "darwin": help_message = """ macOS Troubleshooting: 1. Verify KiCAD is installed: /Applications/KiCad/KiCad.app @@ -173,7 +178,7 @@ Linux Troubleshooting: error_response = { "success": False, "message": "Failed to import pcbnew module - KiCAD Python API not found", - "errorDetails": f"Error: {str(e)}\n\n{help_message}\n\nPython sys.path:\n{chr(10).join(sys.path)}" + "errorDetails": f"Error: {str(e)}\n\n{help_message}\n\nPython sys.path:\n{chr(10).join(sys.path)}", } print(json.dumps(error_response)) sys.exit(1) @@ -183,17 +188,17 @@ Linux Troubleshooting: error_response = { "success": False, "message": "Error importing pcbnew module", - "errorDetails": str(e) + "errorDetails": str(e), } print(json.dumps(error_response)) sys.exit(1) # If IPC-only mode requested but not available, exit with error -elif KICAD_BACKEND == 'ipc' and not USE_IPC_BACKEND: +elif KICAD_BACKEND == "ipc" and not USE_IPC_BACKEND: error_response = { "success": False, "message": "IPC backend requested but not available", - "errorDetails": "KiCAD must be running with IPC API enabled. Enable at: Preferences > Plugins > Enable IPC API Server" + "errorDetails": "KiCAD must be running with IPC API enabled. Enable at: Preferences > Plugins > Enable IPC API Server", } print(json.dumps(error_response)) sys.exit(1) @@ -211,21 +216,27 @@ try: from commands.component_schematic import ComponentManager from commands.connection_schematic import ConnectionManager from commands.library_schematic import LibraryManager as SchematicLibraryManager - from commands.library import LibraryManager as FootprintLibraryManager, LibraryCommands + from commands.library import ( + LibraryManager as FootprintLibraryManager, + LibraryCommands, + ) from commands.library_symbol import SymbolLibraryManager, SymbolLibraryCommands from commands.jlcpcb import JLCPCBClient, test_jlcpcb_connection from commands.jlcpcb_parts import JLCPCBPartsManager + from commands.datasheet_manager import DatasheetManager + logger.info("Successfully imported all command handlers") except ImportError as e: logger.error(f"Failed to import command handlers: {e}") error_response = { "success": False, "message": "Failed to import command handlers", - "errorDetails": str(e) + "errorDetails": str(e), } print(json.dumps(error_response)) sys.exit(1) + class KiCADInterface: """Main interface class to handle KiCAD operations""" @@ -267,12 +278,13 @@ class KiCADInterface: # Initialize JLCPCB API integration self.jlcpcb_client = JLCPCBClient() # Official API (requires auth) from commands.jlcsearch import JLCSearchClient + self.jlcsearch_client = JLCSearchClient() # Public API (no auth required) self.jlcpcb_parts = JLCPCBPartsManager() # Schematic-related classes don't need board reference # as they operate directly on schematic files - + # Command routing dictionary self.command_routes = { # Project commands @@ -280,7 +292,6 @@ 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, - # Board commands "set_board_size": self.board_commands.set_board_size, "add_layer": self.board_commands.add_layer, @@ -293,7 +304,6 @@ class KiCADInterface: "add_mounting_hole": self.board_commands.add_mounting_hole, "add_text": self.board_commands.add_text, "add_board_text": self.board_commands.add_text, # Alias for TypeScript tool - # Component commands "place_component": self.component_commands.place_component, "move_component": self.component_commands.move_component, @@ -308,7 +318,6 @@ class KiCADInterface: "place_component_array": self.component_commands.place_component_array, "align_components": self.component_commands.align_components, "duplicate_component": self.component_commands.duplicate_component, - # Routing commands "add_net": self.routing_commands.add_net, "route_trace": self.routing_commands.route_trace, @@ -322,43 +331,41 @@ class KiCADInterface: "add_copper_pour": 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, "get_drc_violations": self.design_rule_commands.get_drc_violations, - # Export commands "export_gerber": self.export_commands.export_gerber, "export_pdf": self.export_commands.export_pdf, "export_svg": self.export_commands.export_svg, "export_3d": self.export_commands.export_3d, "export_bom": self.export_commands.export_bom, - # 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, - # 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, "list_library_symbols": self.symbol_library_commands.list_library_symbols, "get_symbol_info": self.symbol_library_commands.get_symbol_info, - # JLCPCB API commands (complete parts catalog via API) "download_jlcpcb_database": self._handle_download_jlcpcb_database, "search_jlcpcb_parts": self._handle_search_jlcpcb_parts, "get_jlcpcb_part": self._handle_get_jlcpcb_part, "get_jlcpcb_database_stats": self._handle_get_jlcpcb_database_stats, "suggest_jlcpcb_alternatives": self._handle_suggest_jlcpcb_alternatives, - + # Datasheet commands + "enrich_datasheets": self._handle_enrich_datasheets, + "get_datasheet_url": self._handle_get_datasheet_url, # Schematic commands "create_schematic": self._handle_create_schematic, "load_schematic": self._handle_load_schematic, "add_schematic_component": self._handle_add_schematic_component, + "delete_schematic_component": self._handle_delete_schematic_component, "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, @@ -367,11 +374,9 @@ class KiCADInterface: "generate_netlist": self._handle_generate_netlist, "list_schematic_libraries": self._handle_list_schematic_libraries, "export_schematic_pdf": self._handle_export_schematic_pdf, - # UI/Process management commands "check_kicad_ui": self._handle_check_kicad_ui, "launch_kicad_ui": self._handle_launch_kicad_ui, - # IPC-specific commands (real-time operations) "get_backend_info": self._handle_get_backend_info, "ipc_add_track": self._handle_ipc_add_track, @@ -380,10 +385,12 @@ class KiCADInterface: "ipc_list_components": self._handle_ipc_list_components, "ipc_get_tracks": self._handle_ipc_get_tracks, "ipc_get_vias": self._handle_ipc_get_vias, - "ipc_save_board": self._handle_ipc_save_board + "ipc_save_board": self._handle_ipc_save_board, } - logger.info(f"KiCAD interface initialized (backend: {'IPC' if self.use_ipc else 'SWIG'})") + logger.info( + f"KiCAD interface initialized (backend: {'IPC' if self.use_ipc else 'SWIG'})" + ) # Commands that can be handled via IPC for real-time updates IPC_CAPABLE_COMMANDS = { @@ -422,7 +429,11 @@ class KiCADInterface: try: # Check if we can use IPC for this command (real-time UI sync) - if self.use_ipc and self.ipc_board_api and command in self.IPC_CAPABLE_COMMANDS: + if ( + self.use_ipc + and self.ipc_board_api + and command in self.IPC_CAPABLE_COMMANDS + ): ipc_handler_name = self.IPC_CAPABLE_COMMANDS[command] ipc_handler = getattr(self, ipc_handler_name, None) @@ -440,7 +451,9 @@ class KiCADInterface: # Fall back to SWIG-based handler if self.use_ipc and command in self.IPC_CAPABLE_COMMANDS: - logger.warning(f"IPC handler not available for {command}, falling back to SWIG (deprecated)") + logger.warning( + f"IPC handler not available for {command}, falling back to SWIG (deprecated)" + ) # Get the handler for the command handler = self.command_routes.get(command) @@ -469,7 +482,7 @@ class KiCADInterface: return { "success": False, "message": f"Unknown command: {command}", - "errorDetails": "The specified command is not supported" + "errorDetails": "The specified command is not supported", } except Exception as e: @@ -479,7 +492,7 @@ class KiCADInterface: return { "success": False, "message": f"Error handling command: {command}", - "errorDetails": f"{str(e)}\n{traceback_str}" + "errorDetails": f"{str(e)}\n{traceback_str}", } def _update_command_handlers(self): @@ -491,7 +504,7 @@ class KiCADInterface: self.routing_commands.board = self.board self.design_rule_commands.board = self.board self.export_commands.board = self.board - + # Schematic command handlers def _handle_create_schematic(self, params): """Create a new schematic""" @@ -502,16 +515,14 @@ class KiCADInterface: # - Python schema uses: filename, title # - Legacy uses: projectName, path, metadata project_name = ( - params.get("projectName") or - params.get("name") or - params.get("title") + params.get("projectName") or params.get("name") or params.get("title") ) # Handle filename parameter - it may contain full path filename = params.get("filename") if filename: # If filename provided, extract name and path from it - if filename.endswith('.kicad_sch'): + if filename.endswith(".kicad_sch"): filename = filename[:-10] # Remove .kicad_sch extension path = os.path.dirname(filename) or "." project_name = project_name or os.path.basename(filename) @@ -522,7 +533,7 @@ class KiCADInterface: if not project_name: return { "success": False, - "message": "Schematic name is required. Provide 'name', 'projectName', or 'filename' parameter." + "message": "Schematic name is required. Provide 'name', 'projectName', or 'filename' parameter.", } schematic = SchematicManager.create_schematic(project_name, metadata) @@ -533,19 +544,19 @@ class KiCADInterface: except Exception as e: logger.error(f"Error creating schematic: {str(e)}") return {"success": False, "message": str(e)} - + def _handle_load_schematic(self, params): """Load an existing schematic""" logger.info("Loading schematic") try: filename = params.get("filename") - + if not filename: return {"success": False, "message": "Filename is required"} - + schematic = SchematicManager.load_schematic(filename) success = schematic is not None - + if success: metadata = SchematicManager.get_schematic_metadata(schematic) return {"success": success, "metadata": metadata} @@ -554,7 +565,7 @@ class KiCADInterface: except Exception as e: logger.error(f"Error loading schematic: {str(e)}") return {"success": False, "message": str(e)} - + def _handle_add_schematic_component(self, params): """Add a component to a schematic using text-based injection (no sexpdata)""" logger.info("Adding component to schematic") @@ -570,27 +581,124 @@ class KiCADInterface: if not component: return {"success": False, "message": "Component definition is required"} - comp_type = component.get('type', 'R') - library = component.get('library', 'Device') - reference = component.get('reference', 'X?') - value = component.get('value', comp_type) - x = component.get('x', 0) - y = component.get('y', 0) + comp_type = component.get("type", "R") + library = component.get("library", "Device") + reference = component.get("reference", "X?") + value = component.get("value", comp_type) + x = component.get("x", 0) + y = component.get("y", 0) loader = DynamicSymbolLoader() loader.add_component( - Path(schematic_path), library, comp_type, - reference=reference, value=value, x=x, y=y + Path(schematic_path), + library, + comp_type, + reference=reference, + value=value, + x=x, + y=y, ) return { "success": True, "component_reference": reference, - "symbol_source": f"{library}:{comp_type}" + "symbol_source": f"{library}:{comp_type}", } except Exception as e: logger.error(f"Error adding component to schematic: {str(e)}") import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + + def _handle_delete_schematic_component(self, params): + """Remove a placed symbol from a schematic using text-based manipulation (no skip writes)""" + logger.info("Deleting schematic component") + try: + from pathlib import Path + import re + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not reference: + return {"success": False, "message": "reference is required"} + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + + with open(sch_file, "r", encoding="utf-8") as f: + lines = f.read().split("\n") + + # Find lib_symbols range to skip it + lib_sym_start, lib_sym_end = None, None + depth = 0 + for i, line in enumerate(lines): + if "(lib_symbols" in line and lib_sym_start is None: + lib_sym_start = i + depth = sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")") + elif lib_sym_start is not None and lib_sym_end is None: + depth += sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")") + if depth == 0: + lib_sym_end = i + break + + # Find the placed symbol block matching the reference + block_start = None + block_end = None + i = 0 + while i < len(lines): + # Skip lib_symbols + if lib_sym_start is not None and lib_sym_end is not None: + if lib_sym_start <= i <= lib_sym_end: + i += 1 + continue + + if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", lines[i]): + b_start = i + b_depth = sum(1 for c in lines[i] if c == "(") - sum(1 for c in lines[i] if c == ")") + j = i + 1 + while j < len(lines) and b_depth > 0: + b_depth += sum(1 for c in lines[j] if c == "(") - sum(1 for c in lines[j] if c == ")") + j += 1 + b_end = j - 1 + + # Check if this block contains the target reference + block_text = "\n".join(lines[b_start:b_end + 1]) + # Match: (property "Reference" "R1" ... + if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): + block_start = b_start + block_end = b_end + break + + i = b_end + 1 + continue + + i += 1 + + if block_start is None: + return { + "success": False, + "message": f"Component '{reference}' not found in schematic (note: this tool removes schematic symbols, use delete_component for PCB footprints)", + } + + # Remove the block (and any trailing blank line) + del lines[block_start:block_end + 1] + if block_start < len(lines) and lines[block_start].strip() == "": + del lines[block_start] + + with open(sch_file, "w", encoding="utf-8") as f: + f.write("\n".join(lines)) + + logger.info(f"Deleted schematic component {reference} from {sch_file.name}") + return {"success": True, "reference": reference, "schematic": str(sch_file)} + + except Exception as e: + logger.error(f"Error deleting schematic component: {e}") + import traceback logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} @@ -609,11 +717,14 @@ class KiCADInterface: if not schematic_path: return {"success": False, "message": "Schematic path is required"} if not start_point or not end_point: - return {"success": False, "message": "Start and end points are required"} + return { + "success": False, + "message": "Start and end points are required", + } # Extract wire properties - stroke_width = properties.get('stroke_width', 0) - stroke_type = properties.get('stroke_type', 'default') + stroke_width = properties.get("stroke_width", 0) + stroke_type = properties.get("stroke_type", "default") # Use WireManager for S-expression manipulation success = WireManager.add_wire( @@ -621,7 +732,7 @@ class KiCADInterface: start_point, end_point, stroke_width=stroke_width, - stroke_type=stroke_type + stroke_type=stroke_type, ) if success: @@ -631,43 +742,57 @@ class KiCADInterface: except Exception as e: logger.error(f"Error adding wire to schematic: {str(e)}") import traceback + logger.error(traceback.format_exc()) - return {"success": False, "message": str(e), "errorDetails": traceback.format_exc()} - + return { + "success": False, + "message": str(e), + "errorDetails": traceback.format_exc(), + } + def _handle_list_schematic_libraries(self, params): """List available symbol libraries""" logger.info("Listing schematic libraries") try: search_paths = params.get("searchPaths") - + libraries = LibraryManager.list_available_libraries(search_paths) return {"success": True, "libraries": libraries} except Exception as e: logger.error(f"Error listing schematic libraries: {str(e)}") return {"success": False, "message": str(e)} - + def _handle_export_schematic_pdf(self, params): """Export schematic to PDF""" logger.info("Exporting schematic to PDF") try: schematic_path = params.get("schematicPath") output_path = params.get("outputPath") - + if not schematic_path: return {"success": False, "message": "Schematic path is required"} if not output_path: return {"success": False, "message": "Output path is required"} - + import subprocess + result = subprocess.run( - ["kicad-cli", "sch", "export", "pdf", "--output", output_path, schematic_path], - capture_output=True, - text=True + [ + "kicad-cli", + "sch", + "export", + "pdf", + "--output", + output_path, + schematic_path, + ], + capture_output=True, + text=True, ) - + success = result.returncode == 0 message = result.stderr if not success else "" - + return {"success": success, "message": message} except Exception as e: logger.error(f"Error exporting schematic to PDF: {str(e)}") @@ -684,9 +809,13 @@ class KiCADInterface: source_pin = params.get("sourcePin") target_ref = params.get("targetRef") target_pin = params.get("targetPin") - routing = params.get("routing", "direct") # 'direct', 'orthogonal_h', 'orthogonal_v' + routing = params.get( + "routing", "direct" + ) # 'direct', 'orthogonal_h', 'orthogonal_v' - if not all([schematic_path, source_ref, source_pin, target_ref, target_pin]): + if not all( + [schematic_path, source_ref, source_pin, target_ref, target_pin] + ): return {"success": False, "message": "Missing required parameters"} # Use ConnectionManager with new PinLocator and WireManager integration @@ -696,21 +825,26 @@ class KiCADInterface: source_pin, target_ref, target_pin, - routing=routing + routing=routing, ) if success: return { "success": True, - "message": f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})" + "message": f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})", } else: return {"success": False, "message": "Failed to add connection"} except Exception as e: logger.error(f"Error adding schematic connection: {str(e)}") import traceback + logger.error(traceback.format_exc()) - return {"success": False, "message": str(e), "errorDetails": traceback.format_exc()} + return { + "success": False, + "message": str(e), + "errorDetails": traceback.format_exc(), + } def _handle_add_schematic_net_label(self, params): """Add a net label to schematic using WireManager""" @@ -722,7 +856,9 @@ class KiCADInterface: schematic_path = params.get("schematicPath") net_name = params.get("netName") position = params.get("position") - label_type = params.get("labelType", "label") # 'label', 'global_label', 'hierarchical_label' + label_type = params.get( + "labelType", "label" + ) # 'label', 'global_label', 'hierarchical_label' orientation = params.get("orientation", 0) # 0, 90, 180, 270 if not all([schematic_path, net_name, position]): @@ -734,18 +870,26 @@ class KiCADInterface: net_name, position, label_type=label_type, - orientation=orientation + orientation=orientation, ) if success: - return {"success": True, "message": f"Added net label '{net_name}' at {position}"} + return { + "success": True, + "message": f"Added net label '{net_name}' at {position}", + } else: return {"success": False, "message": "Failed to add net label"} except Exception as e: logger.error(f"Error adding net label: {str(e)}") import traceback + logger.error(traceback.format_exc()) - return {"success": False, "message": str(e), "errorDetails": traceback.format_exc()} + return { + "success": False, + "message": str(e), + "errorDetails": traceback.format_exc(), + } def _handle_connect_to_net(self, params): """Connect a component pin to a named net using wire stub and label""" @@ -763,24 +907,26 @@ class KiCADInterface: # Use ConnectionManager with new WireManager integration success = ConnectionManager.connect_to_net( - Path(schematic_path), - component_ref, - pin_name, - net_name + Path(schematic_path), component_ref, pin_name, net_name ) if success: return { "success": True, - "message": f"Connected {component_ref}/{pin_name} to net '{net_name}'" + "message": f"Connected {component_ref}/{pin_name} to net '{net_name}'", } else: return {"success": False, "message": "Failed to connect to net"} except Exception as e: logger.error(f"Error connecting to net: {str(e)}") import traceback + logger.error(traceback.format_exc()) - return {"success": False, "message": str(e), "errorDetails": traceback.format_exc()} + return { + "success": False, + "message": str(e), + "errorDetails": traceback.format_exc(), + } def _handle_get_net_connections(self, params): """Get all connections for a named net""" @@ -815,7 +961,9 @@ class KiCADInterface: if not schematic: return {"success": False, "message": "Failed to load schematic"} - netlist = ConnectionManager.generate_netlist(schematic) + netlist = ConnectionManager.generate_netlist( + schematic, schematic_path=schematic_path + ) return {"success": True, "netlist": netlist} except Exception as e: logger.error(f"Error generating netlist: {str(e)}") @@ -833,7 +981,7 @@ class KiCADInterface: "success": True, "running": is_running, "processes": processes, - "message": "KiCAD is running" if is_running else "KiCAD is not running" + "message": "KiCAD is running" if is_running else "KiCAD is not running", } except Exception as e: logger.error(f"Error checking KiCAD UI status: {str(e)}") @@ -848,14 +996,12 @@ class KiCADInterface: # Convert project path to Path object if provided from pathlib import Path + path_obj = Path(project_path) if project_path else None result = check_and_launch_kicad(path_obj, auto_launch) - return { - "success": True, - **result - } + return {"success": True, **result} except Exception as e: logger.error(f"Error launching KiCAD UI: {str(e)}") return {"success": False, "message": str(e)} @@ -868,7 +1014,7 @@ class KiCADInterface: return { "success": False, "message": "No board is loaded", - "errorDetails": "Load or create a board first" + "errorDetails": "Load or create a board first", } # Use pcbnew's zone filler for SWIG backend @@ -879,7 +1025,9 @@ class KiCADInterface: return { "success": True, "message": "Zones refilled successfully", - "zoneCount": zones.size() if hasattr(zones, 'size') else len(list(zones)) + "zoneCount": ( + zones.size() if hasattr(zones, "size") else len(list(zones)) + ), } except Exception as e: logger.error(f"Error refilling zones: {str(e)}") @@ -901,8 +1049,16 @@ class KiCADInterface: net = params.get("net") # Handle both dict format and direct x/y - start_x = start.get("x", 0) if isinstance(start, dict) else params.get("startX", 0) - start_y = start.get("y", 0) if isinstance(start, dict) else params.get("startY", 0) + start_x = ( + start.get("x", 0) + if isinstance(start, dict) + else params.get("startX", 0) + ) + start_y = ( + start.get("y", 0) + if isinstance(start, dict) + else params.get("startY", 0) + ) end_x = end.get("x", 0) if isinstance(end, dict) else params.get("endX", 0) end_y = end.get("y", 0) if isinstance(end, dict) else params.get("endY", 0) @@ -913,19 +1069,23 @@ class KiCADInterface: end_y=end_y, width=width, layer=layer, - net_name=net + net_name=net, ) return { "success": success, - "message": "Added trace (visible in KiCAD UI)" if success else "Failed to add trace", + "message": ( + "Added trace (visible in KiCAD UI)" + if success + else "Failed to add trace" + ), "trace": { "start": {"x": start_x, "y": start_y, "unit": "mm"}, "end": {"x": end_x, "y": end_y, "unit": "mm"}, "layer": layer, "width": width, - "net": net - } + "net": net, + }, } except Exception as e: logger.error(f"IPC route_trace error: {e}") @@ -935,8 +1095,16 @@ class KiCADInterface: """IPC handler for add_via - adds via with real-time UI update""" try: position = params.get("position", {}) - x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) - y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) + x = ( + position.get("x", 0) + if isinstance(position, dict) + else params.get("x", 0) + ) + y = ( + position.get("y", 0) + if isinstance(position, dict) + else params.get("y", 0) + ) size = params.get("size", 0.8) drill = params.get("drill", 0.4) @@ -945,25 +1113,24 @@ class KiCADInterface: to_layer = params.get("to_layer", "B.Cu") success = self.ipc_board_api.add_via( - x=x, - y=y, - diameter=size, - drill=drill, - net_name=net, - via_type="through" + x=x, y=y, diameter=size, drill=drill, net_name=net, via_type="through" ) return { "success": success, - "message": "Added via (visible in KiCAD UI)" if success else "Failed to add via", + "message": ( + "Added via (visible in KiCAD UI)" + if success + else "Failed to add via" + ), "via": { "position": {"x": x, "y": y, "unit": "mm"}, "size": size, "drill": drill, "from_layer": from_layer, "to_layer": to_layer, - "net": net - } + "net": net, + }, } except Exception as e: logger.error(f"IPC add_via error: {e}") @@ -978,7 +1145,7 @@ class KiCADInterface: return { "success": True, "message": f"Net '{name}' will be created when components are connected", - "net": {"name": name} + "net": {"name": name}, } def _ipc_add_copper_pour(self, params): @@ -996,16 +1163,15 @@ class KiCADInterface: if not points or len(points) < 3: return { "success": False, - "message": "At least 3 points are required for copper pour outline" + "message": "At least 3 points are required for copper pour outline", } # Convert points format if needed (handle both {x, y} and {x, y, unit}) formatted_points = [] for point in points: - formatted_points.append({ - "x": point.get("x", 0), - "y": point.get("y", 0) - }) + formatted_points.append( + {"x": point.get("x", 0), "y": point.get("y", 0)} + ) success = self.ipc_board_api.add_zone( points=formatted_points, @@ -1015,12 +1181,16 @@ class KiCADInterface: min_thickness=min_width, priority=priority, fill_mode=fill_type, - name=name + name=name, ) return { "success": success, - "message": "Added copper pour (visible in KiCAD UI)" if success else "Failed to add copper pour", + "message": ( + "Added copper pour (visible in KiCAD UI)" + if success + else "Failed to add copper pour" + ), "pour": { "layer": layer, "net": net, @@ -1028,8 +1198,8 @@ class KiCADInterface: "minWidth": min_width, "priority": priority, "fillType": fill_type, - "pointCount": len(points) - } + "pointCount": len(points), + }, } except Exception as e: logger.error(f"IPC add_copper_pour error: {e}") @@ -1042,7 +1212,11 @@ class KiCADInterface: return { "success": success, - "message": "Zones refilled (visible in KiCAD UI)" if success else "Failed to refill zones" + "message": ( + "Zones refilled (visible in KiCAD UI)" + if success + else "Failed to refill zones" + ), } except Exception as e: logger.error(f"IPC refill_zones error: {e}") @@ -1053,24 +1227,31 @@ class KiCADInterface: try: text = params.get("text", "") position = params.get("position", {}) - x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) - y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) + x = ( + position.get("x", 0) + if isinstance(position, dict) + else params.get("x", 0) + ) + y = ( + position.get("y", 0) + if isinstance(position, dict) + else params.get("y", 0) + ) layer = params.get("layer", "F.SilkS") size = params.get("size", 1.0) rotation = params.get("rotation", 0) success = self.ipc_board_api.add_text( - text=text, - x=x, - y=y, - layer=layer, - size=size, - rotation=rotation + text=text, x=x, y=y, layer=layer, size=size, rotation=rotation ) return { "success": success, - "message": f"Added text '{text}' (visible in KiCAD UI)" if success else "Failed to add text" + "message": ( + f"Added text '{text}' (visible in KiCAD UI)" + if success + else "Failed to add text" + ), } except Exception as e: logger.error(f"IPC add_text error: {e}") @@ -1087,8 +1268,12 @@ class KiCADInterface: return { "success": success, - "message": f"Board size set to {width}x{height} {unit} (visible in KiCAD UI)" if success else "Failed to set board size", - "boardSize": {"width": width, "height": height, "unit": unit} + "message": ( + f"Board size set to {width}x{height} {unit} (visible in KiCAD UI)" + if success + else "Failed to set board size" + ), + "boardSize": {"width": width, "height": height, "unit": unit}, } except Exception as e: logger.error(f"IPC set_board_size error: {e}") @@ -1112,8 +1297,8 @@ class KiCADInterface: "viaCount": len(vias), "netCount": len(nets), "backend": "ipc", - "realtime": True - } + "realtime": True, + }, } except Exception as e: logger.error(f"IPC get_board_info error: {e}") @@ -1125,8 +1310,16 @@ class KiCADInterface: reference = params.get("reference", params.get("componentId", "")) footprint = params.get("footprint", "") position = params.get("position", {}) - x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) - y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) + x = ( + position.get("x", 0) + if isinstance(position, dict) + else params.get("x", 0) + ) + y = ( + position.get("y", 0) + if isinstance(position, dict) + else params.get("y", 0) + ) rotation = params.get("rotation", 0) layer = params.get("layer", "F.Cu") value = params.get("value", "") @@ -1138,19 +1331,23 @@ class KiCADInterface: y=y, rotation=rotation, layer=layer, - value=value + value=value, ) return { "success": success, - "message": f"Placed component {reference} (visible in KiCAD UI)" if success else "Failed to place component", + "message": ( + f"Placed component {reference} (visible in KiCAD UI)" + if success + else "Failed to place component" + ), "component": { "reference": reference, "footprint": footprint, "position": {"x": x, "y": y, "unit": "mm"}, "rotation": rotation, - "layer": layer - } + "layer": layer, + }, } except Exception as e: logger.error(f"IPC place_component error: {e}") @@ -1161,20 +1358,29 @@ class KiCADInterface: try: reference = params.get("reference", params.get("componentId", "")) position = params.get("position", {}) - x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0) - y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 0) + x = ( + position.get("x", 0) + if isinstance(position, dict) + else params.get("x", 0) + ) + y = ( + position.get("y", 0) + if isinstance(position, dict) + else params.get("y", 0) + ) rotation = params.get("rotation") success = self.ipc_board_api.move_component( - reference=reference, - x=x, - y=y, - rotation=rotation + reference=reference, x=x, y=y, rotation=rotation ) return { "success": success, - "message": f"Moved component {reference} (visible in KiCAD UI)" if success else "Failed to move component" + "message": ( + f"Moved component {reference} (visible in KiCAD UI)" + if success + else "Failed to move component" + ), } except Exception as e: logger.error(f"IPC move_component error: {e}") @@ -1189,7 +1395,11 @@ class KiCADInterface: return { "success": success, - "message": f"Deleted component {reference} (visible in KiCAD UI)" if success else "Failed to delete component" + "message": ( + f"Deleted component {reference} (visible in KiCAD UI)" + if success + else "Failed to delete component" + ), } except Exception as e: logger.error(f"IPC delete_component error: {e}") @@ -1200,11 +1410,7 @@ class KiCADInterface: try: components = self.ipc_board_api.list_components() - return { - "success": True, - "components": components, - "count": len(components) - } + return {"success": True, "components": components, "count": len(components)} except Exception as e: logger.error(f"IPC get_component_list error: {e}") return {"success": False, "message": str(e)} @@ -1216,7 +1422,7 @@ class KiCADInterface: return { "success": success, - "message": "Project saved" if success else "Failed to save project" + "message": "Project saved" if success else "Failed to save project", } except Exception as e: logger.error(f"IPC save_project error: {e}") @@ -1226,7 +1432,9 @@ class KiCADInterface: """IPC handler for delete_trace - Note: IPC doesn't support direct trace deletion yet""" # IPC API doesn't have a direct delete track method # Fall back to SWIG for this operation - logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)") + logger.info( + "delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)" + ) return self.routing_commands.delete_trace(params) def _ipc_get_nets_list(self, params): @@ -1234,11 +1442,7 @@ class KiCADInterface: try: nets = self.ipc_board_api.get_nets() - return { - "success": True, - "nets": nets, - "count": len(nets) - } + return {"success": True, "nets": nets, "count": len(nets)} except Exception as e: logger.error(f"IPC get_nets_list error: {e}") return {"success": False, "message": str(e)} @@ -1257,7 +1461,10 @@ class KiCADInterface: width = params.get("width", 0.1) if len(points) < 2: - return {"success": False, "message": "At least 2 points required for board outline"} + return { + "success": False, + "message": "At least 2 points required for board outline", + } commit = board.begin_commit() lines_created = 0 @@ -1268,8 +1475,12 @@ class KiCADInterface: end = points[(i + 1) % len(points)] # Wrap around to close the outline segment = BoardSegment() - segment.start = Vector2.from_xy(from_mm(start.get("x", 0)), from_mm(start.get("y", 0))) - segment.end = Vector2.from_xy(from_mm(end.get("x", 0)), from_mm(end.get("y", 0))) + segment.start = Vector2.from_xy( + from_mm(start.get("x", 0)), from_mm(start.get("y", 0)) + ) + segment.end = Vector2.from_xy( + from_mm(end.get("x", 0)), from_mm(end.get("y", 0)) + ) segment.layer = BoardLayer.BL_Edge_Cuts segment.attributes.stroke.width = from_mm(width) @@ -1281,7 +1492,7 @@ class KiCADInterface: return { "success": True, "message": f"Added board outline with {lines_created} segments (visible in KiCAD UI)", - "segments": lines_created + "segments": lines_created, } except Exception as e: logger.error(f"IPC add_board_outline error: {e}") @@ -1316,10 +1527,7 @@ class KiCADInterface: return { "success": True, "message": f"Added mounting hole at ({x}, {y}) mm (visible in KiCAD UI)", - "hole": { - "position": {"x": x, "y": y}, - "diameter": diameter - } + "hole": {"position": {"x": x, "y": y}, "diameter": diameter}, } except Exception as e: logger.error(f"IPC add_mounting_hole error: {e}") @@ -1330,11 +1538,7 @@ class KiCADInterface: try: layers = self.ipc_board_api.get_enabled_layers() - return { - "success": True, - "layers": layers, - "count": len(layers) - } + return {"success": True, "layers": layers, "count": len(layers)} except Exception as e: logger.error(f"IPC get_layer_list error: {e}") return {"success": False, "message": str(e)} @@ -1365,13 +1569,17 @@ class KiCADInterface: reference=reference, x=target.get("position", {}).get("x", 0), y=target.get("position", {}).get("y", 0), - rotation=new_rotation + rotation=new_rotation, ) return { "success": success, - "message": f"Rotated component {reference} by {angle}° (visible in KiCAD UI)" if success else "Failed to rotate component", - "newRotation": new_rotation + "message": ( + f"Rotated component {reference} by {angle}° (visible in KiCAD UI)" + if success + else "Failed to rotate component" + ), + "newRotation": new_rotation, } except Exception as e: logger.error(f"IPC rotate_component error: {e}") @@ -1392,10 +1600,7 @@ class KiCADInterface: if not target: return {"success": False, "message": f"Component {reference} not found"} - return { - "success": True, - "component": target - } + return {"success": True, "component": target} except Exception as e: logger.error(f"IPC get_component_properties error: {e}") return {"success": False, "message": str(e)} @@ -1410,9 +1615,15 @@ class KiCADInterface: "success": True, "backend": "ipc" if self.use_ipc else "swig", "realtime_sync": self.use_ipc, - "ipc_connected": self.ipc_backend.is_connected() if self.ipc_backend else False, + "ipc_connected": ( + self.ipc_backend.is_connected() if self.ipc_backend else False + ), "version": self.ipc_backend.get_version() if self.ipc_backend else "N/A", - "message": "Using IPC backend with real-time UI sync" if self.use_ipc else "Using SWIG backend (requires manual reload)" + "message": ( + "Using IPC backend with real-time UI sync" + if self.use_ipc + else "Using SWIG backend (requires manual reload)" + ), } def _handle_ipc_add_track(self, params): @@ -1428,12 +1639,16 @@ class KiCADInterface: end_y=params.get("endY", 0), width=params.get("width", 0.25), layer=params.get("layer", "F.Cu"), - net_name=params.get("net") + net_name=params.get("net"), ) return { "success": success, - "message": "Track added (visible in KiCAD UI)" if success else "Failed to add track", - "realtime": True + "message": ( + "Track added (visible in KiCAD UI)" + if success + else "Failed to add track" + ), + "realtime": True, } except Exception as e: logger.error(f"Error adding track via IPC: {e}") @@ -1451,12 +1666,16 @@ class KiCADInterface: diameter=params.get("diameter", 0.8), drill=params.get("drill", 0.4), net_name=params.get("net"), - via_type=params.get("type", "through") + via_type=params.get("type", "through"), ) return { "success": success, - "message": "Via added (visible in KiCAD UI)" if success else "Failed to add via", - "realtime": True + "message": ( + "Via added (visible in KiCAD UI)" + if success + else "Failed to add via" + ), + "realtime": True, } except Exception as e: logger.error(f"Error adding via via IPC: {e}") @@ -1474,12 +1693,16 @@ class KiCADInterface: y=params.get("y", 0), layer=params.get("layer", "F.SilkS"), size=params.get("size", 1.0), - rotation=params.get("rotation", 0) + rotation=params.get("rotation", 0), ) return { "success": success, - "message": "Text added (visible in KiCAD UI)" if success else "Failed to add text", - "realtime": True + "message": ( + "Text added (visible in KiCAD UI)" + if success + else "Failed to add text" + ), + "realtime": True, } except Exception as e: logger.error(f"Error adding text via IPC: {e}") @@ -1492,11 +1715,7 @@ class KiCADInterface: try: components = self.ipc_board_api.list_components() - return { - "success": True, - "components": components, - "count": len(components) - } + return {"success": True, "components": components, "count": len(components)} except Exception as e: logger.error(f"Error listing components via IPC: {e}") return {"success": False, "message": str(e)} @@ -1508,11 +1727,7 @@ class KiCADInterface: try: tracks = self.ipc_board_api.get_tracks() - return { - "success": True, - "tracks": tracks, - "count": len(tracks) - } + return {"success": True, "tracks": tracks, "count": len(tracks)} except Exception as e: logger.error(f"Error getting tracks via IPC: {e}") return {"success": False, "message": str(e)} @@ -1524,11 +1739,7 @@ class KiCADInterface: try: vias = self.ipc_board_api.get_vias() - return { - "success": True, - "vias": vias, - "count": len(vias) - } + return {"success": True, "vias": vias, "count": len(vias)} except Exception as e: logger.error(f"Error getting vias via IPC: {e}") return {"success": False, "message": str(e)} @@ -1542,7 +1753,7 @@ class KiCADInterface: success = self.ipc_board_api.save() return { "success": success, - "message": "Board saved" if success else "Failed to save board" + "message": "Board saved" if success else "Failed to save board", } except Exception as e: logger.error(f"Error saving board via IPC: {e}") @@ -1553,16 +1764,17 @@ class KiCADInterface: def _handle_download_jlcpcb_database(self, params): """Download JLCPCB parts database from JLCSearch API""" try: - force = params.get('force', False) + force = params.get("force", False) # Check if database exists import os + stats = self.jlcpcb_parts.get_database_stats() - if stats['total_parts'] > 0 and not force: + if stats["total_parts"] > 0 and not force: return { "success": False, "message": "Database already exists. Use force=true to re-download.", - "stats": stats + "stats": stats, } logger.info("Downloading JLCPCB parts database from JLCSearch...") @@ -1575,8 +1787,7 @@ class KiCADInterface: # Import into database logger.info(f"Importing {len(parts)} parts into database...") self.jlcpcb_parts.import_jlcsearch_parts( - parts, - progress_callback=lambda curr, total, msg: logger.info(msg) + parts, progress_callback=lambda curr, total, msg: logger.info(msg) ) # Get final stats @@ -1587,33 +1798,33 @@ class KiCADInterface: return { "success": True, - "total_parts": stats['total_parts'], - "basic_parts": stats['basic_parts'], - "extended_parts": stats['extended_parts'], + "total_parts": stats["total_parts"], + "basic_parts": stats["basic_parts"], + "extended_parts": stats["extended_parts"], "db_size_mb": round(db_size_mb, 2), - "db_path": stats['db_path'] + "db_path": stats["db_path"], } except Exception as e: logger.error(f"Error downloading JLCPCB database: {e}", exc_info=True) return { "success": False, - "message": f"Failed to download database: {str(e)}" + "message": f"Failed to download database: {str(e)}", } def _handle_search_jlcpcb_parts(self, params): """Search JLCPCB parts database""" try: - query = params.get('query') - category = params.get('category') - package = params.get('package') - library_type = params.get('library_type', 'All') - manufacturer = params.get('manufacturer') - in_stock = params.get('in_stock', True) - limit = params.get('limit', 20) + query = params.get("query") + category = params.get("category") + package = params.get("package") + library_type = params.get("library_type", "All") + manufacturer = params.get("manufacturer") + in_stock = params.get("in_stock", True) + limit = params.get("limit", 20) # Adjust library_type filter - if library_type == 'All': + if library_type == "All": library_type = None parts = self.jlcpcb_parts.search_parts( @@ -1623,97 +1834,72 @@ class KiCADInterface: library_type=library_type, manufacturer=manufacturer, in_stock=in_stock, - limit=limit + limit=limit, ) # Add price breaks and footprints to each part for part in parts: - if part.get('price_json'): + if part.get("price_json"): try: - part['price_breaks'] = json.loads(part['price_json']) + part["price_breaks"] = json.loads(part["price_json"]) except: - part['price_breaks'] = [] + part["price_breaks"] = [] - return { - "success": True, - "parts": parts, - "count": len(parts) - } + return {"success": True, "parts": parts, "count": len(parts)} except Exception as e: logger.error(f"Error searching JLCPCB parts: {e}", exc_info=True) - return { - "success": False, - "message": f"Search failed: {str(e)}" - } + return {"success": False, "message": f"Search failed: {str(e)}"} def _handle_get_jlcpcb_part(self, params): """Get detailed information for a specific JLCPCB part""" try: - lcsc_number = params.get('lcsc_number') + lcsc_number = params.get("lcsc_number") if not lcsc_number: - return { - "success": False, - "message": "Missing lcsc_number parameter" - } + return {"success": False, "message": "Missing lcsc_number parameter"} part = self.jlcpcb_parts.get_part_info(lcsc_number) if not part: - return { - "success": False, - "message": f"Part not found: {lcsc_number}" - } + return {"success": False, "message": f"Part not found: {lcsc_number}"} # Get suggested KiCAD footprints - footprints = self.jlcpcb_parts.map_package_to_footprint(part.get('package', '')) + footprints = self.jlcpcb_parts.map_package_to_footprint( + part.get("package", "") + ) - return { - "success": True, - "part": part, - "footprints": footprints - } + return {"success": True, "part": part, "footprints": footprints} except Exception as e: logger.error(f"Error getting JLCPCB part: {e}", exc_info=True) - return { - "success": False, - "message": f"Failed to get part info: {str(e)}" - } + return {"success": False, "message": f"Failed to get part info: {str(e)}"} def _handle_get_jlcpcb_database_stats(self, params): """Get statistics about JLCPCB database""" try: stats = self.jlcpcb_parts.get_database_stats() - return { - "success": True, - "stats": stats - } + return {"success": True, "stats": stats} except Exception as e: logger.error(f"Error getting database stats: {e}", exc_info=True) - return { - "success": False, - "message": f"Failed to get stats: {str(e)}" - } + return {"success": False, "message": f"Failed to get stats: {str(e)}"} def _handle_suggest_jlcpcb_alternatives(self, params): """Suggest alternative JLCPCB parts""" try: - lcsc_number = params.get('lcsc_number') - limit = params.get('limit', 5) + lcsc_number = params.get("lcsc_number") + limit = params.get("limit", 5) if not lcsc_number: - return { - "success": False, - "message": "Missing lcsc_number parameter" - } + return {"success": False, "message": "Missing lcsc_number parameter"} # Get original part for price comparison original_part = self.jlcpcb_parts.get_part_info(lcsc_number) reference_price = None - if original_part and original_part.get('price_breaks'): + if original_part and original_part.get("price_breaks"): try: - reference_price = float(original_part['price_breaks'][0].get('price', 0)) + reference_price = float( + original_part["price_breaks"][0].get("price", 0) + ) except: pass @@ -1721,25 +1907,62 @@ class KiCADInterface: # Add price breaks to alternatives for part in alternatives: - if part.get('price_json'): + if part.get("price_json"): try: - part['price_breaks'] = json.loads(part['price_json']) + part["price_breaks"] = json.loads(part["price_json"]) except: - part['price_breaks'] = [] + part["price_breaks"] = [] return { "success": True, "alternatives": alternatives, - "reference_price": reference_price + "reference_price": reference_price, } except Exception as e: logger.error(f"Error suggesting alternatives: {e}", exc_info=True) return { "success": False, - "message": f"Failed to suggest alternatives: {str(e)}" + "message": f"Failed to suggest alternatives: {str(e)}", } + def _handle_enrich_datasheets(self, params): + """Enrich schematic Datasheet fields from LCSC numbers""" + try: + from pathlib import Path + + schematic_path = params.get("schematic_path") + if not schematic_path: + return {"success": False, "message": "Missing schematic_path parameter"} + dry_run = params.get("dry_run", False) + manager = DatasheetManager() + return manager.enrich_schematic(Path(schematic_path), dry_run=dry_run) + except Exception as e: + logger.error(f"Error enriching datasheets: {e}", exc_info=True) + return {"success": False, "message": f"Failed to enrich datasheets: {str(e)}"} + + def _handle_get_datasheet_url(self, params): + """Return LCSC datasheet and product URLs for a part number""" + try: + lcsc = params.get("lcsc", "") + if not lcsc: + return {"success": False, "message": "Missing lcsc parameter"} + manager = DatasheetManager() + datasheet_url = manager.get_datasheet_url(lcsc) + product_url = manager.get_product_url(lcsc) + if not datasheet_url: + return {"success": False, "message": f"Invalid LCSC number: {lcsc}"} + norm = manager._normalize_lcsc(lcsc) + return { + "success": True, + "lcsc": norm, + "datasheet_url": datasheet_url, + "product_url": product_url, + } + except Exception as e: + logger.error(f"Error getting datasheet URL: {e}", exc_info=True) + return {"success": False, "message": f"Failed to get datasheet URL: {str(e)}"} + def main(): """Main entry point""" @@ -1756,38 +1979,36 @@ def main(): command_data = json.loads(line) # Check if this is JSON-RPC 2.0 format - if 'jsonrpc' in command_data and command_data['jsonrpc'] == '2.0': + if "jsonrpc" in command_data and command_data["jsonrpc"] == "2.0": logger.info("Detected JSON-RPC 2.0 format message") - method = command_data.get('method') - params = command_data.get('params', {}) - request_id = command_data.get('id') + method = command_data.get("method") + params = command_data.get("params", {}) + request_id = command_data.get("id") # Handle MCP protocol methods - if method == 'initialize': + if method == "initialize": logger.info("Handling MCP initialize") response = { - 'jsonrpc': '2.0', - 'id': request_id, - 'result': { - 'protocolVersion': '2025-06-18', - 'capabilities': { - 'tools': { - 'listChanged': True + "jsonrpc": "2.0", + "id": request_id, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": { + "tools": {"listChanged": True}, + "resources": { + "subscribe": False, + "listChanged": True, }, - 'resources': { - 'subscribe': False, - 'listChanged': True - } }, - 'serverInfo': { - 'name': 'kicad-mcp-server', - 'title': 'KiCAD PCB Design Assistant', - 'version': '2.1.0-alpha' + "serverInfo": { + "name": "kicad-mcp-server", + "title": "KiCAD PCB Design Assistant", + "version": "2.1.0-alpha", }, - 'instructions': 'AI-assisted PCB design with KiCAD. Use tools to create projects, design boards, place components, route traces, and export manufacturing files.' - } + "instructions": "AI-assisted PCB design with KiCAD. Use tools to create projects, design boards, place components, route traces, and export manufacturing files.", + }, } - elif method == 'tools/list': + elif method == "tools/list": logger.info("Handling MCP tools/list") # Return list of available tools with proper schemas tools = [] @@ -1798,85 +2019,84 @@ def main(): tools.append(tool_def) else: # Fallback for tools without schemas - logger.warning(f"No schema defined for tool: {cmd_name}") - tools.append({ - 'name': cmd_name, - 'description': f'KiCAD command: {cmd_name}', - 'inputSchema': { - 'type': 'object', - 'properties': {} + logger.warning( + f"No schema defined for tool: {cmd_name}" + ) + tools.append( + { + "name": cmd_name, + "description": f"KiCAD command: {cmd_name}", + "inputSchema": { + "type": "object", + "properties": {}, + }, } - }) + ) logger.info(f"Returning {len(tools)} tools") response = { - 'jsonrpc': '2.0', - 'id': request_id, - 'result': { - 'tools': tools - } + "jsonrpc": "2.0", + "id": request_id, + "result": {"tools": tools}, } - elif method == 'tools/call': + elif method == "tools/call": logger.info("Handling MCP tools/call") - tool_name = params.get('name') - tool_params = params.get('arguments', {}) + tool_name = params.get("name") + tool_params = params.get("arguments", {}) # Execute the command result = interface.handle_command(tool_name, tool_params) response = { - 'jsonrpc': '2.0', - 'id': request_id, - 'result': { - 'content': [ - { - 'type': 'text', - 'text': json.dumps(result) - } + "jsonrpc": "2.0", + "id": request_id, + "result": { + "content": [ + {"type": "text", "text": json.dumps(result)} ] - } + }, } - elif method == 'resources/list': + elif method == "resources/list": logger.info("Handling MCP resources/list") # Return list of available resources response = { - 'jsonrpc': '2.0', - 'id': request_id, - 'result': { - 'resources': RESOURCE_DEFINITIONS - } + "jsonrpc": "2.0", + "id": request_id, + "result": {"resources": RESOURCE_DEFINITIONS}, } - elif method == 'resources/read': + elif method == "resources/read": logger.info("Handling MCP resources/read") - resource_uri = params.get('uri') + resource_uri = params.get("uri") if not resource_uri: response = { - 'jsonrpc': '2.0', - 'id': request_id, - 'error': { - 'code': -32602, - 'message': 'Missing required parameter: uri' - } + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32602, + "message": "Missing required parameter: uri", + }, } else: # Read the resource - resource_data = handle_resource_read(resource_uri, interface) + resource_data = handle_resource_read( + resource_uri, interface + ) response = { - 'jsonrpc': '2.0', - 'id': request_id, - 'result': resource_data + "jsonrpc": "2.0", + "id": request_id, + "result": resource_data, } else: logger.error(f"Unknown JSON-RPC method: {method}") response = { - 'jsonrpc': '2.0', - 'id': request_id, - 'error': { - 'code': -32601, - 'message': f'Method not found: {method}' - } + "jsonrpc": "2.0", + "id": request_id, + "error": { + "code": -32601, + "message": f"Method not found: {method}", + }, } else: # Handle legacy custom format @@ -1889,7 +2109,7 @@ def main(): response = { "success": False, "message": "Missing command", - "errorDetails": "The command field is required" + "errorDetails": "The command field is required", } else: # Handle command @@ -1905,7 +2125,7 @@ def main(): response = { "success": False, "message": "Invalid JSON input", - "errorDetails": str(e) + "errorDetails": str(e), } print(json.dumps(response)) sys.stdout.flush() @@ -1918,5 +2138,6 @@ def main(): logger.error(f"Unexpected error: {str(e)}\n{traceback.format_exc()}") sys.exit(1) + if __name__ == "__main__": main() diff --git a/src/server.ts b/src/server.ts index af1db1b..e2e847d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,46 +2,47 @@ * KiCAD MCP Server implementation */ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; -import express from 'express'; -import { spawn, exec, execSync, ChildProcess } from 'child_process'; -import { existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { logger } from './logger.js'; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import express from "express"; +import { spawn, exec, execSync, ChildProcess } from "child_process"; +import { existsSync } from "fs"; +import { join, dirname } from "path"; +import { logger } from "./logger.js"; // Import tool registration functions -import { registerProjectTools } from './tools/project.js'; -import { registerBoardTools } from './tools/board.js'; -import { registerComponentTools } from './tools/component.js'; -import { registerRoutingTools } from './tools/routing.js'; -import { registerDesignRuleTools } from './tools/design-rules.js'; -import { registerExportTools } from './tools/export.js'; -import { registerSchematicTools } from './tools/schematic.js'; -import { registerLibraryTools } from './tools/library.js'; -import { registerSymbolLibraryTools } from './tools/library-symbol.js'; -import { registerJLCPCBApiTools } from './tools/jlcpcb-api.js'; -import { registerUITools } from './tools/ui.js'; -import { registerRouterTools } from './tools/router.js'; +import { registerProjectTools } from "./tools/project.js"; +import { registerBoardTools } from "./tools/board.js"; +import { registerComponentTools } from "./tools/component.js"; +import { registerRoutingTools } from "./tools/routing.js"; +import { registerDesignRuleTools } from "./tools/design-rules.js"; +import { registerExportTools } from "./tools/export.js"; +import { registerSchematicTools } from "./tools/schematic.js"; +import { registerLibraryTools } from "./tools/library.js"; +import { registerSymbolLibraryTools } from "./tools/library-symbol.js"; +import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js"; +import { registerDatasheetTools } from "./tools/datasheet.js"; +import { registerUITools } from "./tools/ui.js"; +import { registerRouterTools } from "./tools/router.js"; // Import resource registration functions -import { registerProjectResources } from './resources/project.js'; -import { registerBoardResources } from './resources/board.js'; -import { registerComponentResources } from './resources/component.js'; -import { registerLibraryResources } from './resources/library.js'; +import { registerProjectResources } from "./resources/project.js"; +import { registerBoardResources } from "./resources/board.js"; +import { registerComponentResources } from "./resources/component.js"; +import { registerLibraryResources } from "./resources/library.js"; // Import prompt registration functions -import { registerComponentPrompts } from './prompts/component.js'; -import { registerRoutingPrompts } from './prompts/routing.js'; -import { registerDesignPrompts } from './prompts/design.js'; +import { registerComponentPrompts } from "./prompts/component.js"; +import { registerRoutingPrompts } from "./prompts/routing.js"; +import { registerDesignPrompts } from "./prompts/design.js"; /** * Find the Python executable to use * Prioritizes virtual environment if available, falls back to system Python */ function findPythonExecutable(scriptPath: string): string { - const isWindows = process.platform === 'win32'; - const isMac = process.platform === 'darwin'; + const isWindows = process.platform === "win32"; + const isMac = process.platform === "darwin"; const isLinux = !isWindows && !isMac; // Get the project root (parent of the python/ directory) @@ -49,8 +50,18 @@ function findPythonExecutable(scriptPath: string): string { // Check for virtual environment const venvPaths = [ - join(projectRoot, 'venv', isWindows ? 'Scripts' : 'bin', isWindows ? 'python.exe' : 'python'), - join(projectRoot, '.venv', isWindows ? 'Scripts' : 'bin', isWindows ? 'python.exe' : 'python'), + join( + projectRoot, + "venv", + isWindows ? "Scripts" : "bin", + isWindows ? "python.exe" : "python", + ), + join( + projectRoot, + ".venv", + isWindows ? "Scripts" : "bin", + isWindows ? "python.exe" : "python", + ), ]; for (const venvPath of venvPaths) { @@ -62,27 +73,29 @@ function findPythonExecutable(scriptPath: string): string { // Allow override via KICAD_PYTHON environment variable (any platform) if (process.env.KICAD_PYTHON) { - logger.info(`Using KICAD_PYTHON environment variable: ${process.env.KICAD_PYTHON}`); + logger.info( + `Using KICAD_PYTHON environment variable: ${process.env.KICAD_PYTHON}`, + ); return process.env.KICAD_PYTHON; } // Platform-specific KiCAD bundled Python detection - if (isWindows && process.env.PYTHONPATH?.includes('KiCad')) { - // Windows: Try KiCAD's bundled Python - const kicadPython = 'C:\\Program Files\\KiCad\\9.0\\bin\\python.exe'; + if (isWindows) { + // Windows: Always prefer KiCAD's bundled Python (pcbnew.pyd is compiled for it) + const kicadPython = "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe"; if (existsSync(kicadPython)) { logger.info(`Found KiCAD bundled Python at: ${kicadPython}`); return kicadPython; } } else if (isMac) { // macOS: Try KiCAD's bundled Python (check multiple versions and locations) - const kicadPythonVersions = ['3.9', '3.10', '3.11', '3.12', '3.13']; + const kicadPythonVersions = ["3.9", "3.10", "3.11", "3.12", "3.13"]; // Standard KiCAD installation paths const kicadAppPaths = [ - '/Applications/KiCad/KiCad.app', - '/Applications/KiCAD/KiCad.app', // Alternative capitalization - `${process.env.HOME}/Applications/KiCad/KiCad.app`, // User Applications folder + "/Applications/KiCad/KiCad.app", + "/Applications/KiCAD/KiCad.app", // Alternative capitalization + `${process.env.HOME}/Applications/KiCad/KiCad.app`, // User Applications folder ]; // Check all KiCAD app locations with all Python versions @@ -98,24 +111,26 @@ function findPythonExecutable(scriptPath: string): string { // Fallback to Homebrew Python (if pcbnew is installed via pip) const homebrewPaths = [ - '/opt/homebrew/bin/python3', // Apple Silicon - '/usr/local/bin/python3', // Intel Mac - '/opt/homebrew/bin/python3.12', - '/opt/homebrew/bin/python3.11', + "/opt/homebrew/bin/python3", // Apple Silicon + "/usr/local/bin/python3", // Intel Mac + "/opt/homebrew/bin/python3.12", + "/opt/homebrew/bin/python3.11", ]; for (const path of homebrewPaths) { if (existsSync(path)) { - logger.info(`Found Homebrew Python at: ${path} (ensure pcbnew is importable)`); + logger.info( + `Found Homebrew Python at: ${path} (ensure pcbnew is importable)`, + ); return path; } } } else if (isLinux) { // Linux: Try KiCAD bundled Python locations first const linuxKicadPaths = [ - '/usr/lib/kicad/bin/python3', - '/usr/local/lib/kicad/bin/python3', - '/opt/kicad/bin/python3', + "/usr/lib/kicad/bin/python3", + "/usr/local/lib/kicad/bin/python3", + "/opt/kicad/bin/python3", ]; for (const path of linuxKicadPaths) { @@ -127,17 +142,17 @@ function findPythonExecutable(scriptPath: string): string { // Resolve system python3 to full path using 'which' try { - const result = execSync('which python3', { encoding: 'utf-8' }).trim(); + const result = execSync("which python3", { encoding: "utf-8" }).trim(); if (result && existsSync(result)) { logger.info(`Resolved system Python via which: ${result}`); return result; } } catch (e) { - logger.warn('Failed to resolve python3 via which command'); + logger.warn("Failed to resolve python3 via which command"); } // Fallback to common system paths - const systemPaths = ['/usr/bin/python3', '/bin/python3']; + const systemPaths = ["/usr/bin/python3", "/bin/python3"]; for (const path of systemPaths) { if (existsSync(path)) { logger.info(`Found system Python at: ${path}`); @@ -147,8 +162,8 @@ function findPythonExecutable(scriptPath: string): string { } // Default to system Python (last resort) - logger.info('Using system Python (no venv found)'); - return isWindows ? 'python.exe' : 'python3'; + logger.info("Using system Python (no venv found)"); + return isWindows ? "python.exe" : "python3"; } /** @@ -159,11 +174,19 @@ export class KiCADMcpServer { private pythonProcess: ChildProcess | null = null; private kicadScriptPath: string; private stdioTransport!: StdioServerTransport; - private requestQueue: Array<{ request: any, resolve: Function, reject: Function }> = []; + private requestQueue: Array<{ + request: any; + resolve: Function; + reject: Function; + }> = []; private processingRequest = false; - private responseBuffer: string = ''; - private currentRequestHandler: { resolve: Function, reject: Function, timeoutHandle: NodeJS.Timeout } | null = null; - + private responseBuffer: string = ""; + private currentRequestHandler: { + resolve: Function; + reject: Function; + timeoutHandle: NodeJS.Timeout; + } | null = null; + /** * Constructor for the KiCAD MCP Server * @param kicadScriptPath Path to the Python KiCAD interface script @@ -171,37 +194,39 @@ export class KiCADMcpServer { */ constructor( kicadScriptPath: string, - logLevel: 'error' | 'warn' | 'info' | 'debug' = 'info' + logLevel: "error" | "warn" | "info" | "debug" = "info", ) { // Set up the logger logger.setLogLevel(logLevel); - + // Check if KiCAD script exists this.kicadScriptPath = kicadScriptPath; if (!existsSync(this.kicadScriptPath)) { - throw new Error(`KiCAD interface script not found: ${this.kicadScriptPath}`); + throw new Error( + `KiCAD interface script not found: ${this.kicadScriptPath}`, + ); } - + // Initialize the MCP server this.server = new McpServer({ - name: 'kicad-mcp-server', - version: '1.0.0', - description: 'MCP server for KiCAD PCB design operations' + name: "kicad-mcp-server", + version: "1.0.0", + description: "MCP server for KiCAD PCB design operations", }); - + // Initialize STDIO transport this.stdioTransport = new StdioServerTransport(); - logger.info('Using STDIO transport for local communication'); - + logger.info("Using STDIO transport for local communication"); + // Register tools, resources, and prompts this.registerAll(); } - + /** * Register all tools, resources, and prompts */ private registerAll(): void { - logger.info('Registering KiCAD tools, resources, and prompts...'); + logger.info("Registering KiCAD tools, resources, and prompts..."); // Register router tools FIRST (for tool discovery and execution) registerRouterTools(this.server, this.callKicadScript.bind(this)); @@ -217,6 +242,7 @@ export class KiCADMcpServer { 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)); registerUITools(this.server, this.callKicadScript.bind(this)); // Register all resources @@ -230,20 +256,26 @@ export class KiCADMcpServer { registerRoutingPrompts(this.server); registerDesignPrompts(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("All KiCAD tools, resources, and prompts registered"); + logger.info( + "Router pattern enabled: 4 router tools + direct tools for discovery", + ); } - + /** * Validate prerequisites before starting the server */ private async validatePrerequisites(pythonExe: string): Promise { - const isWindows = process.platform === 'win32'; - const isLinux = process.platform !== 'win32' && process.platform !== 'darwin'; + const isWindows = process.platform === "win32"; + const isLinux = + process.platform !== "win32" && process.platform !== "darwin"; const errors: string[] = []; // Check if Python executable exists (for absolute paths) or is executable (for commands) - const isAbsolutePath = pythonExe.startsWith('/') || pythonExe.startsWith('C:') || pythonExe.startsWith('\\'); + const isAbsolutePath = + pythonExe.startsWith("/") || + pythonExe.startsWith("C:") || + pythonExe.startsWith("\\"); if (isAbsolutePath) { // Absolute path: use existsSync @@ -251,42 +283,61 @@ export class KiCADMcpServer { errors.push(`Python executable not found: ${pythonExe}`); if (isWindows) { - errors.push('Windows: Install KiCAD 9.0+ from https://www.kicad.org/download/windows/'); - errors.push('Or run: .\\setup-windows.ps1 for automatic configuration'); + errors.push( + "Windows: Install KiCAD 9.0+ from https://www.kicad.org/download/windows/", + ); + errors.push( + "Or run: .\\setup-windows.ps1 for automatic configuration", + ); } else if (isLinux) { - errors.push('Linux: Install KiCAD 9.0+ or set KICAD_PYTHON environment variable'); - errors.push('Set KICAD_PYTHON to specify a custom Python path'); + errors.push( + "Linux: Install KiCAD 9.0+ or set KICAD_PYTHON environment variable", + ); + errors.push("Set KICAD_PYTHON to specify a custom Python path"); } } } else { // Command name: verify it's executable via --version test logger.info(`Validating command-based Python executable: ${pythonExe}`); try { - const { stdout } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => { - exec(`"${pythonExe}" --version`, { - timeout: 3000, - env: { ...process.env } - }, (error: any, stdout: string, stderr: string) => { - if (error) { - reject(error); - } else { - resolve({ stdout, stderr }); - } - }); + const { stdout } = await new Promise<{ + stdout: string; + stderr: string; + }>((resolve, reject) => { + exec( + `"${pythonExe}" --version`, + { + timeout: 3000, + env: { ...process.env }, + }, + (error: any, stdout: string, stderr: string) => { + if (error) { + reject(error); + } else { + resolve({ stdout, stderr }); + } + }, + ); }); logger.info(`Python version check passed: ${stdout.trim()}`); } catch (error: any) { errors.push(`Python executable not found in PATH: ${pythonExe}`); errors.push(`Error: ${error.message}`); - errors.push('Set KICAD_PYTHON environment variable to specify full path'); + errors.push( + "Set KICAD_PYTHON environment variable to specify full path", + ); if (isLinux) { - errors.push(''); - errors.push('Linux troubleshooting:'); - errors.push('1. Check if python3 is installed: which python3'); - errors.push('2. Install KiCAD: sudo apt install kicad (Ubuntu/Debian)'); - errors.push('3. Set KICAD_PYTHON=/usr/bin/python3 in your MCP config'); + errors.push(""); + errors.push("Linux troubleshooting:"); + errors.push("1. Check if python3 is installed: which python3"); + errors.push( + "2. Install KiCAD: sudo apt install kicad (Ubuntu/Debian)", + ); + errors.push( + "3. Set KICAD_PYTHON=/usr/bin/python3 in your MCP config", + ); } } } @@ -297,76 +348,91 @@ export class KiCADMcpServer { } // Check if dist/index.js exists (if running from compiled code) - const distPath = join(dirname(dirname(this.kicadScriptPath)), 'dist', 'index.js'); + const distPath = join( + dirname(dirname(this.kicadScriptPath)), + "dist", + "index.js", + ); if (!existsSync(distPath)) { - errors.push('Project not built. Run: npm run build'); + errors.push("Project not built. Run: npm run build"); } // Try to test pcbnew import (quick validation) if (existsSync(pythonExe) && existsSync(this.kicadScriptPath)) { - logger.info('Validating pcbnew module access...'); + logger.info("Validating pcbnew module access..."); const testCommand = `"${pythonExe}" -c "import pcbnew; print('OK')"`; try { - const { stdout, stderr } = await new Promise<{stdout: string, stderr: string}>((resolve, reject) => { - exec(testCommand, { - timeout: 5000, - env: { ...process.env } - }, (error: any, stdout: string, stderr: string) => { - if (error) { - reject(error); - } else { - resolve({ stdout, stderr }); - } - }); + const { stdout, stderr } = await new Promise<{ + stdout: string; + stderr: string; + }>((resolve, reject) => { + exec( + testCommand, + { + timeout: 5000, + env: { ...process.env }, + }, + (error: any, stdout: string, stderr: string) => { + if (error) { + reject(error); + } else { + resolve({ stdout, stderr }); + } + }, + ); }); - if (!stdout.includes('OK')) { - errors.push('pcbnew module import test failed'); + if (!stdout.includes("OK")) { + errors.push("pcbnew module import test failed"); errors.push(`Output: ${stdout}`); errors.push(`Errors: ${stderr}`); if (isWindows) { - errors.push(''); - errors.push('Windows troubleshooting:'); - errors.push('1. Set PYTHONPATH=C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages'); - errors.push('2. Test: "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe" -c "import pcbnew"'); - errors.push('3. Run: .\\setup-windows.ps1 for automatic fix'); - errors.push('4. See: docs/WINDOWS_TROUBLESHOOTING.md'); + errors.push(""); + errors.push("Windows troubleshooting:"); + errors.push( + "1. Set PYTHONPATH=C:\\Program Files\\KiCad\\9.0\\lib\\python3\\dist-packages", + ); + errors.push( + '2. Test: "C:\\Program Files\\KiCad\\9.0\\bin\\python.exe" -c "import pcbnew"', + ); + errors.push("3. Run: .\\setup-windows.ps1 for automatic fix"); + errors.push("4. See: docs/WINDOWS_TROUBLESHOOTING.md"); } } else { - logger.info('✓ pcbnew module validated successfully'); + logger.info("✓ pcbnew module validated successfully"); } } catch (error: any) { errors.push(`pcbnew validation failed: ${error.message}`); if (isWindows) { - errors.push(''); - errors.push('This usually means:'); - errors.push('- KiCAD is not installed'); - errors.push('- PYTHONPATH is incorrect'); - errors.push('- Python cannot find pcbnew module'); - errors.push(''); - errors.push('Quick fix: Run .\\setup-windows.ps1'); + errors.push(""); + errors.push("This usually means:"); + errors.push("- KiCAD is not installed"); + errors.push("- PYTHONPATH is incorrect"); + errors.push("- Python cannot find pcbnew module"); + errors.push(""); + errors.push("Quick fix: Run .\\setup-windows.ps1"); } } } // Log all errors if (errors.length > 0) { - logger.error('='.repeat(70)); - logger.error('STARTUP VALIDATION FAILED'); - logger.error('='.repeat(70)); - errors.forEach(err => logger.error(err)); - logger.error('='.repeat(70)); + logger.error("=".repeat(70)); + logger.error("STARTUP VALIDATION FAILED"); + logger.error("=".repeat(70)); + errors.forEach((err) => logger.error(err)); + logger.error("=".repeat(70)); // Also write to stderr for Claude Desktop to capture - process.stderr.write('\n' + '='.repeat(70) + '\n'); - process.stderr.write('KiCAD MCP Server - Startup Validation Failed\n'); - process.stderr.write('='.repeat(70) + '\n'); - errors.forEach(err => process.stderr.write(err + '\n')); - process.stderr.write('='.repeat(70) + '\n\n'); + process.stderr.write("\n" + "=".repeat(70) + "\n"); + process.stderr.write("KiCAD MCP Server - Startup Validation Failed\n"); + process.stderr.write("=".repeat(70) + "\n"); + errors.forEach((err) => process.stderr.write(err + "\n")); + process.stderr.write("=".repeat(70) + "\n\n"); return false; } @@ -379,10 +445,12 @@ export class KiCADMcpServer { */ async start(): Promise { try { - logger.info('Starting KiCAD MCP server...'); + logger.info("Starting KiCAD MCP server..."); // Start the Python process for KiCAD scripting - logger.info(`Starting Python process with script: ${this.kicadScriptPath}`); + logger.info( + `Starting Python process with script: ${this.kicadScriptPath}`, + ); const pythonExe = findPythonExecutable(this.kicadScriptPath); logger.info(`Using Python executable: ${pythonExe}`); @@ -390,76 +458,82 @@ export class KiCADMcpServer { // Validate prerequisites const isValid = await this.validatePrerequisites(pythonExe); if (!isValid) { - throw new Error('Prerequisites validation failed. See logs above for details.'); + throw new Error( + "Prerequisites validation failed. See logs above for details.", + ); } this.pythonProcess = spawn(pythonExe, [this.kicadScriptPath], { - stdio: ['pipe', 'pipe', 'pipe'], + stdio: ["pipe", "pipe", "pipe"], env: { ...process.env, - PYTHONPATH: process.env.PYTHONPATH || 'C:/Program Files/KiCad/9.0/lib/python3/dist-packages' - } + PYTHONPATH: + process.env.PYTHONPATH || + "C:/Program Files/KiCad/9.0/lib/python3/dist-packages", + }, }); - + // Listen for process exit - this.pythonProcess.on('exit', (code, signal) => { - logger.warn(`Python process exited with code ${code} and signal ${signal}`); + this.pythonProcess.on("exit", (code, signal) => { + logger.warn( + `Python process exited with code ${code} and signal ${signal}`, + ); this.pythonProcess = null; }); - + // Listen for process errors - this.pythonProcess.on('error', (err) => { + this.pythonProcess.on("error", (err) => { logger.error(`Python process error: ${err.message}`); }); - + // Set up error logging for stderr if (this.pythonProcess.stderr) { - this.pythonProcess.stderr.on('data', (data: Buffer) => { + this.pythonProcess.stderr.on("data", (data: Buffer) => { logger.error(`Python stderr: ${data.toString()}`); }); } // Set up persistent stdout handler (instead of adding/removing per request) if (this.pythonProcess.stdout) { - this.pythonProcess.stdout.on('data', (data: Buffer) => { + this.pythonProcess.stdout.on("data", (data: Buffer) => { this.handlePythonResponse(data); }); } // Connect server to STDIO transport - logger.info('Connecting MCP server to STDIO transport...'); + logger.info("Connecting MCP server to STDIO transport..."); try { await this.server.connect(this.stdioTransport); - logger.info('Successfully connected to STDIO transport'); + logger.info("Successfully connected to STDIO transport"); } catch (error) { logger.error(`Failed to connect to STDIO transport: ${error}`); 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'); + 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}`); throw error; } } - + /** * Stop the MCP server and clean up resources */ async stop(): Promise { - logger.info('Stopping KiCAD MCP server...'); - + logger.info("Stopping KiCAD MCP server..."); + // Kill the Python process if it's running if (this.pythonProcess) { this.pythonProcess.kill(); this.pythonProcess = null; } - - logger.info('KiCAD MCP server stopped'); + + logger.info("KiCAD MCP server stopped"); } - + /** * Call the KiCAD scripting interface to execute commands * @@ -471,7 +545,7 @@ export class KiCADMcpServer { return new Promise((resolve, reject) => { // Check if Python process is running if (!this.pythonProcess) { - logger.error('Python process is not running'); + logger.error("Python process is not running"); reject(new Error("Python process for KiCAD scripting is not running")); return; } @@ -479,17 +553,24 @@ export class KiCADMcpServer { // Determine timeout based on command type // DRC and export operations need longer timeouts for large boards let commandTimeout = 30000; // Default 30 seconds - const longRunningCommands = ['run_drc', 'export_gerber', 'export_pdf', 'export_3d']; + const longRunningCommands = [ + "run_drc", + "export_gerber", + "export_pdf", + "export_3d", + ]; if (longRunningCommands.includes(command)) { commandTimeout = 600000; // 10 minutes for long operations - logger.info(`Using extended timeout (${commandTimeout/1000}s) for command: ${command}`); + logger.info( + `Using extended timeout (${commandTimeout / 1000}s) for command: ${command}`, + ); } // Add request to queue with timeout info this.requestQueue.push({ request: { command, params, timeout: commandTimeout }, resolve, - reject + reject, }); // Process the queue if not already processing @@ -498,7 +579,7 @@ export class KiCADMcpServer { } }); } - + /** * Handle incoming data from Python process stdout * This is a persistent handler that processes all responses @@ -519,8 +600,10 @@ export class KiCADMcpServer { if (!this.currentRequestHandler) { // No pending request, clear buffer if it has data (shouldn't happen) if (this.responseBuffer.trim()) { - logger.warn(`Received data with no pending request: ${this.responseBuffer.substring(0, 100)}...`); - this.responseBuffer = ''; + logger.warn( + `Received data with no pending request: ${this.responseBuffer.substring(0, 100)}...`, + ); + this.responseBuffer = ""; } return; } @@ -530,7 +613,9 @@ export class KiCADMcpServer { const result = JSON.parse(this.responseBuffer); // If we get here, we have a valid JSON response - logger.debug(`Completed KiCAD command with result: ${result.success ? 'success' : 'failure'}`); + logger.debug( + `Completed KiCAD command with result: ${result.success ? "success" : "failure"}`, + ); // Clear the timeout since we got a response if (this.currentRequestHandler.timeoutHandle) { @@ -541,7 +626,7 @@ export class KiCADMcpServer { const handler = this.currentRequestHandler; // Clear state - this.responseBuffer = ''; + this.responseBuffer = ""; this.currentRequestHandler = null; this.processingRequest = false; @@ -550,7 +635,6 @@ export class KiCADMcpServer { // Process next request if any setTimeout(() => this.processNextRequest(), 0); - } catch (e) { // Not a complete JSON yet, keep collecting data // This is normal for large responses that come in chunks @@ -579,21 +663,29 @@ export class KiCADMcpServer { const requestStr = JSON.stringify(request); // Clear response buffer for new request - this.responseBuffer = ''; + this.responseBuffer = ""; // Set a timeout (use command-specific timeout or default) const timeoutDuration = request.timeout || 30000; const timeoutHandle = setTimeout(() => { - logger.error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`); - logger.error(`Buffer contents: ${this.responseBuffer.substring(0, 200)}...`); + logger.error( + `Command timeout after ${timeoutDuration / 1000}s: ${request.command}`, + ); + logger.error( + `Buffer contents: ${this.responseBuffer.substring(0, 200)}...`, + ); // Clear state - this.responseBuffer = ''; + this.responseBuffer = ""; this.currentRequestHandler = null; this.processingRequest = false; // Reject the promise - reject(new Error(`Command timeout after ${timeoutDuration/1000}s: ${request.command}`)); + reject( + new Error( + `Command timeout after ${timeoutDuration / 1000}s: ${request.command}`, + ), + ); // Process next request setTimeout(() => this.processNextRequest(), 0); @@ -604,7 +696,7 @@ export class KiCADMcpServer { // Write the request to the Python process logger.debug(`Sending request: ${requestStr}`); - this.pythonProcess?.stdin?.write(requestStr + '\n'); + this.pythonProcess?.stdin?.write(requestStr + "\n"); } catch (error) { logger.error(`Error processing request: ${error}`); diff --git a/src/tools/component.ts b/src/tools/component.ts index 7674330..24d5367 100644 --- a/src/tools/component.ts +++ b/src/tools/component.ts @@ -1,291 +1,644 @@ -/** - * Component management tools for KiCAD MCP server - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; -import { logger } from '../logger.js'; - -// Command function type for KiCAD script calls -type CommandFunction = (command: string, params: Record) => Promise; - -/** - * Register component management tools with the MCP server - * - * @param server MCP server instance - * @param callKicadScript Function to call KiCAD script commands - */ -export function registerComponentTools(server: McpServer, callKicadScript: CommandFunction): void { - logger.info('Registering component management tools'); - - // ------------------------------------------------------ - // Place Component Tool - // ------------------------------------------------------ - server.tool( - "place_component", - { - componentId: z.string().describe("Identifier for the component to place (e.g., 'R_0603_10k')"), - position: z.object({ - x: z.number().describe("X coordinate"), - y: z.number().describe("Y coordinate"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement") - }).describe("Position coordinates and unit"), - reference: z.string().optional().describe("Optional desired reference (e.g., 'R5')"), - value: z.string().optional().describe("Optional component value (e.g., '10k')"), - footprint: z.string().optional().describe("Optional specific footprint name"), - rotation: z.number().optional().describe("Optional rotation in degrees"), - layer: z.string().optional().describe("Optional layer (e.g., 'F.Cu', 'B.SilkS')") - }, - async ({ componentId, position, reference, value, footprint, rotation, layer }) => { - logger.debug(`Placing component: ${componentId} at ${position.x},${position.y} ${position.unit}`); - const result = await callKicadScript("place_component", { - componentId, - position, - reference, - value, - footprint, - rotation, - layer - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Move Component Tool - // ------------------------------------------------------ - server.tool( - "move_component", - { - reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), - position: z.object({ - x: z.number().describe("X coordinate"), - y: z.number().describe("Y coordinate"), - unit: z.enum(["mm", "inch"]).describe("Unit of measurement") - }).describe("New position coordinates and unit"), - rotation: z.number().optional().describe("Optional new rotation in degrees") - }, - async ({ reference, position, rotation }) => { - logger.debug(`Moving component: ${reference} to ${position.x},${position.y} ${position.unit}`); - const result = await callKicadScript("move_component", { - reference, - position, - rotation - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Rotate Component Tool - // ------------------------------------------------------ - server.tool( - "rotate_component", - { - reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), - angle: z.number().describe("Rotation angle in degrees (absolute, not relative)") - }, - async ({ reference, angle }) => { - logger.debug(`Rotating component: ${reference} to ${angle} degrees`); - const result = await callKicadScript("rotate_component", { - reference, - angle - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Delete Component Tool - // ------------------------------------------------------ - server.tool( - "delete_component", - { - reference: z.string().describe("Reference designator of the component to delete (e.g., 'R5')") - }, - async ({ reference }) => { - logger.debug(`Deleting component: ${reference}`); - const result = await callKicadScript("delete_component", { reference }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Edit Component Properties Tool - // ------------------------------------------------------ - server.tool( - "edit_component", - { - reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), - newReference: z.string().optional().describe("Optional new reference designator"), - value: z.string().optional().describe("Optional new component value"), - footprint: z.string().optional().describe("Optional new footprint") - }, - async ({ reference, newReference, value, footprint }) => { - logger.debug(`Editing component: ${reference}`); - const result = await callKicadScript("edit_component", { - reference, - newReference, - value, - footprint - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Find Component Tool - // ------------------------------------------------------ - server.tool( - "find_component", - { - reference: z.string().optional().describe("Reference designator to search for"), - value: z.string().optional().describe("Component value to search for") - }, - async ({ reference, value }) => { - logger.debug(`Finding component with ${reference ? `reference: ${reference}` : `value: ${value}`}`); - const result = await callKicadScript("find_component", { reference, value }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Get Component Properties Tool - // ------------------------------------------------------ - server.tool( - "get_component_properties", - { - reference: z.string().describe("Reference designator of the component (e.g., 'R5')") - }, - async ({ reference }) => { - logger.debug(`Getting properties for component: ${reference}`); - const result = await callKicadScript("get_component_properties", { reference }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Add Component Annotation Tool - // ------------------------------------------------------ - server.tool( - "add_component_annotation", - { - reference: z.string().describe("Reference designator of the component (e.g., 'R5')"), - annotation: z.string().describe("Annotation or comment text to add"), - visible: z.boolean().optional().describe("Whether the annotation should be visible on the PCB") - }, - async ({ reference, annotation, visible }) => { - logger.debug(`Adding annotation to component: ${reference}`); - const result = await callKicadScript("add_component_annotation", { - reference, - annotation, - visible - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Group Components Tool - // ------------------------------------------------------ - server.tool( - "group_components", - { - references: z.array(z.string()).describe("Reference designators of components to group"), - groupName: z.string().describe("Name for the component group") - }, - async ({ references, groupName }) => { - logger.debug(`Grouping components: ${references.join(', ')} as ${groupName}`); - const result = await callKicadScript("group_components", { - references, - groupName - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - // ------------------------------------------------------ - // Replace Component Tool - // ------------------------------------------------------ - server.tool( - "replace_component", - { - reference: z.string().describe("Reference designator of the component to replace"), - newComponentId: z.string().describe("ID of the new component to use"), - newFootprint: z.string().optional().describe("Optional new footprint"), - newValue: z.string().optional().describe("Optional new component value") - }, - async ({ reference, newComponentId, newFootprint, newValue }) => { - logger.debug(`Replacing component: ${reference} with ${newComponentId}`); - const result = await callKicadScript("replace_component", { - reference, - newComponentId, - newFootprint, - newValue - }); - - return { - content: [{ - type: "text", - text: JSON.stringify(result) - }] - }; - } - ); - - logger.info('Component management tools registered'); -} +/** + * Component management tools for KiCAD MCP server + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { logger } from "../logger.js"; + +// Command function type for KiCAD script calls +type CommandFunction = ( + command: string, + params: Record, +) => Promise; + +/** + * Register component management tools with the MCP server + * + * @param server MCP server instance + * @param callKicadScript Function to call KiCAD script commands + */ +export function registerComponentTools( + server: McpServer, + callKicadScript: CommandFunction, +): void { + logger.info("Registering component management tools"); + + // ------------------------------------------------------ + // Place Component Tool + // ------------------------------------------------------ + server.tool( + "place_component", + { + componentId: z + .string() + .describe("Identifier for the component to place (e.g., 'R_0603_10k')"), + position: z + .object({ + x: z.number().describe("X coordinate"), + y: z.number().describe("Y coordinate"), + unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + }) + .describe("Position coordinates and unit"), + reference: z + .string() + .optional() + .describe("Optional desired reference (e.g., 'R5')"), + value: z + .string() + .optional() + .describe("Optional component value (e.g., '10k')"), + footprint: z + .string() + .optional() + .describe("Optional specific footprint name"), + rotation: z.number().optional().describe("Optional rotation in degrees"), + layer: z + .string() + .optional() + .describe("Optional layer (e.g., 'F.Cu', 'B.SilkS')"), + }, + async ({ + componentId, + position, + reference, + value, + footprint, + rotation, + layer, + }) => { + logger.debug( + `Placing component: ${componentId} at ${position.x},${position.y} ${position.unit}`, + ); + const result = await callKicadScript("place_component", { + componentId, + position, + reference, + value, + footprint, + rotation, + layer, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Move Component Tool + // ------------------------------------------------------ + server.tool( + "move_component", + { + reference: z + .string() + .describe("Reference designator of the component (e.g., 'R5')"), + position: z + .object({ + x: z.number().describe("X coordinate"), + y: z.number().describe("Y coordinate"), + unit: z.enum(["mm", "inch"]).describe("Unit of measurement"), + }) + .describe("New position coordinates and unit"), + rotation: z + .number() + .optional() + .describe("Optional new rotation in degrees"), + }, + async ({ reference, position, rotation }) => { + logger.debug( + `Moving component: ${reference} to ${position.x},${position.y} ${position.unit}`, + ); + const result = await callKicadScript("move_component", { + reference, + position, + rotation, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Rotate Component Tool + // ------------------------------------------------------ + server.tool( + "rotate_component", + { + reference: z + .string() + .describe("Reference designator of the component (e.g., 'R5')"), + angle: z + .number() + .describe("Rotation angle in degrees (absolute, not relative)"), + }, + async ({ reference, angle }) => { + logger.debug(`Rotating component: ${reference} to ${angle} degrees`); + const result = await callKicadScript("rotate_component", { + reference, + angle, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Delete Component Tool + // ------------------------------------------------------ + server.tool( + "delete_component", + { + reference: z + .string() + .describe( + "Reference designator of the component to delete (e.g., 'R5')", + ), + }, + async ({ reference }) => { + logger.debug(`Deleting component: ${reference}`); + const result = await callKicadScript("delete_component", { reference }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Edit Component Properties Tool + // ------------------------------------------------------ + server.tool( + "edit_component", + { + reference: z + .string() + .describe("Reference designator of the component (e.g., 'R5')"), + newReference: z + .string() + .optional() + .describe("Optional new reference designator"), + value: z.string().optional().describe("Optional new component value"), + footprint: z.string().optional().describe("Optional new footprint"), + }, + async ({ reference, newReference, value, footprint }) => { + logger.debug(`Editing component: ${reference}`); + const result = await callKicadScript("edit_component", { + reference, + newReference, + value, + footprint, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Find Component Tool + // ------------------------------------------------------ + server.tool( + "find_component", + { + reference: z + .string() + .optional() + .describe("Reference designator to search for"), + value: z.string().optional().describe("Component value to search for"), + }, + async ({ reference, value }) => { + logger.debug( + `Finding component with ${reference ? `reference: ${reference}` : `value: ${value}`}`, + ); + const result = await callKicadScript("find_component", { + reference, + value, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Get Component Properties Tool + // ------------------------------------------------------ + server.tool( + "get_component_properties", + { + reference: z + .string() + .describe("Reference designator of the component (e.g., 'R5')"), + }, + async ({ reference }) => { + logger.debug(`Getting properties for component: ${reference}`); + const result = await callKicadScript("get_component_properties", { + reference, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Add Component Annotation Tool + // ------------------------------------------------------ + server.tool( + "add_component_annotation", + { + reference: z + .string() + .describe("Reference designator of the component (e.g., 'R5')"), + annotation: z.string().describe("Annotation or comment text to add"), + visible: z + .boolean() + .optional() + .describe("Whether the annotation should be visible on the PCB"), + }, + async ({ reference, annotation, visible }) => { + logger.debug(`Adding annotation to component: ${reference}`); + const result = await callKicadScript("add_component_annotation", { + reference, + annotation, + visible, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Group Components Tool + // ------------------------------------------------------ + server.tool( + "group_components", + { + references: z + .array(z.string()) + .describe("Reference designators of components to group"), + groupName: z.string().describe("Name for the component group"), + }, + async ({ references, groupName }) => { + logger.debug( + `Grouping components: ${references.join(", ")} as ${groupName}`, + ); + const result = await callKicadScript("group_components", { + references, + groupName, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Replace Component Tool + // ------------------------------------------------------ + server.tool( + "replace_component", + { + reference: z + .string() + .describe("Reference designator of the component to replace"), + newComponentId: z.string().describe("ID of the new component to use"), + newFootprint: z.string().optional().describe("Optional new footprint"), + newValue: z.string().optional().describe("Optional new component value"), + }, + async ({ reference, newComponentId, newFootprint, newValue }) => { + logger.debug(`Replacing component: ${reference} with ${newComponentId}`); + const result = await callKicadScript("replace_component", { + reference, + newComponentId, + newFootprint, + newValue, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Get Component Pads Tool + // ------------------------------------------------------ + server.tool( + "get_component_pads", + { + reference: z + .string() + .describe("Reference designator of the component (e.g., 'U1')"), + unit: z + .enum(["mm", "inch"]) + .optional() + .describe("Unit for coordinates (default: mm)"), + }, + async ({ reference, unit }) => { + logger.debug(`Getting pads for component: ${reference}`); + const result = await callKicadScript("get_component_pads", { + reference, + unit: unit || "mm", + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Get Component List Tool + // ------------------------------------------------------ + server.tool( + "get_component_list", + { + layer: z + .string() + .optional() + .describe("Filter by layer (e.g., 'F.Cu', 'B.Cu')"), + boundingBox: z + .object({ + x1: z.number(), + y1: z.number(), + x2: z.number(), + y2: z.number(), + unit: z.enum(["mm", "inch"]).optional(), + }) + .optional() + .describe("Filter by bounding box region"), + unit: z + .enum(["mm", "inch"]) + .optional() + .describe("Unit for coordinates (default: mm)"), + }, + async ({ layer, boundingBox, unit }) => { + logger.debug("Getting component list"); + const result = await callKicadScript("get_component_list", { + layer, + boundingBox, + unit: unit || "mm", + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Get Pad Position Tool + // ------------------------------------------------------ + server.tool( + "get_pad_position", + { + reference: z + .string() + .describe("Component reference designator (e.g., 'U1')"), + pad: z.string().describe("Pad number or name (e.g., '1', 'A1')"), + unit: z + .enum(["mm", "inch"]) + .optional() + .describe("Unit for coordinates (default: mm)"), + }, + async ({ reference, pad, unit }) => { + logger.debug(`Getting pad position for ${reference} pad ${pad}`); + const result = await callKicadScript("get_pad_position", { + reference, + pad, + unit: unit || "mm", + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Place Component Array Tool + // ------------------------------------------------------ + server.tool( + "place_component_array", + { + componentId: z.string().describe("Component identifier"), + startPosition: z + .object({ + x: z.number(), + y: z.number(), + unit: z.enum(["mm", "inch"]), + }) + .describe("Starting position"), + rows: z.number().describe("Number of rows"), + columns: z.number().describe("Number of columns"), + rowSpacing: z.number().describe("Spacing between rows"), + columnSpacing: z.number().describe("Spacing between columns"), + startReference: z + .string() + .optional() + .describe("Starting reference (e.g., 'R1')"), + footprint: z.string().optional().describe("Footprint name"), + value: z.string().optional().describe("Component value"), + rotation: z.number().optional().describe("Rotation in degrees"), + }, + async ({ + componentId, + startPosition, + rows, + columns, + rowSpacing, + columnSpacing, + startReference, + footprint, + value, + rotation, + }) => { + logger.debug( + `Placing component array: ${rows}x${columns} of ${componentId}`, + ); + const result = await callKicadScript("place_component_array", { + componentId, + startPosition, + rows, + columns, + rowSpacing, + columnSpacing, + startReference, + footprint, + value, + rotation, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Align Components Tool + // ------------------------------------------------------ + server.tool( + "align_components", + { + references: z + .array(z.string()) + .describe("Array of component references to align"), + alignmentType: z + .enum(["horizontal", "vertical", "grid"]) + .describe("Type of alignment"), + spacing: z + .number() + .optional() + .describe("Spacing between components in mm"), + referenceComponent: z + .string() + .optional() + .describe("Reference component for alignment"), + }, + async ({ references, alignmentType, spacing, referenceComponent }) => { + logger.debug(`Aligning components: ${references.join(", ")}`); + const result = await callKicadScript("align_components", { + references, + alignmentType, + spacing, + referenceComponent, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + // ------------------------------------------------------ + // Duplicate Component Tool + // ------------------------------------------------------ + server.tool( + "duplicate_component", + { + reference: z.string().describe("Reference of component to duplicate"), + offset: z + .object({ + x: z.number(), + y: z.number(), + unit: z.enum(["mm", "inch"]).optional(), + }) + .describe("Offset from original position"), + newReference: z.string().optional().describe("New reference designator"), + count: z + .number() + .optional() + .describe("Number of duplicates (default: 1)"), + }, + async ({ reference, offset, newReference, count }) => { + logger.debug(`Duplicating component: ${reference}`); + const result = await callKicadScript("duplicate_component", { + reference, + offset, + newReference, + count, + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + }, + ); + + logger.info("Component management tools registered"); +} diff --git a/src/tools/datasheet.ts b/src/tools/datasheet.ts new file mode 100644 index 0000000..588b66c --- /dev/null +++ b/src/tools/datasheet.ts @@ -0,0 +1,123 @@ +/** + * Datasheet tools for KiCAD MCP server + * + * Enriches KiCAD schematic symbols with LCSC datasheet URLs. + * URL schema: https://www.lcsc.com/datasheet/.pdf (no API key required) + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export function registerDatasheetTools( + server: McpServer, + callKicadScript: Function, +) { + // ── enrich_datasheets ────────────────────────────────────────────────────── + server.tool( + "enrich_datasheets", + `Fill in missing Datasheet URLs in a KiCAD schematic using LCSC part numbers. + +For every placed symbol that has: + • (property "LCSC" "C123456") set + • (property "Datasheet" "~") or empty + +Sets the Datasheet field to: + https://www.lcsc.com/datasheet/C123456.pdf + +The URL is then visible in KiCAD's footprint browser, symbol properties dialog, +and any tool that reads the standard KiCAD Datasheet field. +No API key or internet lookup required – the URL is constructed directly. + +Use dry_run=true to preview changes without writing.`, + { + schematic_path: z + .string() + .describe("Path to the .kicad_sch file to enrich"), + dry_run: z + .boolean() + .optional() + .default(false) + .describe( + "If true, show what would be changed without writing to disk (default: false)", + ), + }, + async (args: { schematic_path: string; dry_run?: boolean }) => { + const result = await callKicadScript("enrich_datasheets", args); + if (result.success) { + const lines: string[] = []; + + if (args.dry_run) { + lines.push(`[DRY RUN] Schematic: ${result.schematic}\n`); + } else { + lines.push(`Schematic: ${result.schematic}\n`); + } + + lines.push(`✓ Updated: ${result.updated}`); + lines.push(` Already set: ${result.already_set}`); + lines.push(` No LCSC number: ${result.no_lcsc}`); + lines.push(` No field: ${result.no_datasheet_field}`); + + if (result.details && result.details.length > 0) { + lines.push("\nComponents updated:"); + for (const d of result.details) { + lines.push(` ${d.reference.padEnd(6)} ${d.lcsc.padEnd(12)} → ${d.url}`); + } + } + + if (result.updated === 0 && !args.dry_run) { + lines.push( + "\nNo changes needed – all LCSC components already have a Datasheet URL.", + ); + } + + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [ + { + type: "text", + text: `Failed to enrich datasheets: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); + + // ── get_datasheet_url ────────────────────────────────────────────────────── + server.tool( + "get_datasheet_url", + `Get the LCSC datasheet URL for a component by LCSC number. + +Returns the direct PDF URL and product page URL. +No network request – URL is constructed from the LCSC number alone. + +Example: get_datasheet_url("C179739") +→ https://www.lcsc.com/datasheet/C179739.pdf`, + { + lcsc: z + .string() + .describe( + 'LCSC part number, with or without "C" prefix (e.g. "C179739" or "179739")', + ), + }, + async (args: { lcsc: string }) => { + const result = await callKicadScript("get_datasheet_url", { lcsc: args.lcsc }); + if (result.success) { + const lines = [ + `LCSC: ${result.lcsc}`, + `Datasheet PDF: ${result.datasheet_url}`, + `Product page: ${result.product_url}`, + ]; + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [ + { + type: "text", + text: `Invalid LCSC number: ${args.lcsc}`, + }, + ], + }; + }, + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts index af7390c..225f964 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -1,15 +1,16 @@ -/** - * Tools index for KiCAD MCP server - * - * Exports all tool registration functions - */ - -export { registerProjectTools } from './project.js'; -export { registerBoardTools } from './board.js'; -export { registerComponentTools } from './component.js'; -export { registerRoutingTools } from './routing.js'; -export { registerDesignRuleTools } from './design-rules.js'; -export { registerExportTools } from './export.js'; -export { registerSchematicTools } from './schematic.js'; -export { registerLibraryTools } from './library.js'; -export { registerUITools } from './ui.js'; +/** + * Tools index for KiCAD MCP server + * + * Exports all tool registration functions + */ + +export { registerProjectTools } from "./project.js"; +export { registerBoardTools } from "./board.js"; +export { registerComponentTools } from "./component.js"; +export { registerRoutingTools } from "./routing.js"; +export { registerDesignRuleTools } from "./design-rules.js"; +export { registerExportTools } from "./export.js"; +export { registerSchematicTools } from "./schematic.js"; +export { registerLibraryTools } from "./library.js"; +export { registerUITools } from "./ui.js"; +export { registerDatasheetTools } from "./datasheet.js"; diff --git a/src/tools/library.ts b/src/tools/library.ts index 9bd59bc..d81d52f 100644 --- a/src/tools/library.ts +++ b/src/tools/library.ts @@ -1,159 +1,193 @@ -/** - * Library tools for KiCAD MCP server - * Provides access to KiCAD footprint libraries and symbols - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; - -export function registerLibraryTools(server: McpServer, callKicadScript: Function) { - // List available footprint libraries - server.tool( - "list_libraries", - "List all available KiCAD footprint libraries", - { - search_paths: z.array(z.string()).optional() - .describe("Optional additional search paths for libraries") - }, - async (args: { search_paths?: string[] }) => { - const result = await callKicadScript("list_libraries", args); - if (result.success && result.libraries) { - return { - content: [ - { - type: "text", - text: `Found ${result.libraries.length} footprint libraries:\n${result.libraries.join('\n')}` - } - ] - }; - } - return { - content: [ - { - type: "text", - text: `Failed to list libraries: ${result.message || 'Unknown error'}` - } - ] - }; - } - ); - - // Search for footprints across all libraries - server.tool( - "search_footprints", - "Search for footprints matching a pattern across all libraries", - { - search_term: z.string() - .describe("Search term or pattern to match footprint names"), - library: z.string().optional() - .describe("Optional specific library to search in"), - limit: z.number().optional().default(50) - .describe("Maximum number of results to return") - }, - async (args: { search_term: string; library?: string; limit?: number }) => { - const result = await callKicadScript("search_footprints", args); - if (result.success && result.footprints) { - const footprintList = result.footprints.map((fp: any) => - `${fp.library}:${fp.name}${fp.description ? ' - ' + fp.description : ''}` - ).join('\n'); - return { - content: [ - { - type: "text", - text: `Found ${result.footprints.length} matching footprints:\n${footprintList}` - } - ] - }; - } - return { - content: [ - { - type: "text", - text: `Failed to search footprints: ${result.message || 'Unknown error'}` - } - ] - }; - } - ); - - // List footprints in a specific library - server.tool( - "list_library_footprints", - "List all footprints in a specific KiCAD library", - { - library_name: z.string() - .describe("Name of the library to list footprints from"), - filter: z.string().optional() - .describe("Optional filter pattern for footprint names"), - limit: z.number().optional().default(100) - .describe("Maximum number of footprints to list") - }, - async (args: { library_name: string; filter?: string; limit?: number }) => { - const result = await callKicadScript("list_library_footprints", args); - if (result.success && result.footprints) { - const footprintList = result.footprints.map((fp: string) => ` - ${fp}`).join('\n'); - return { - content: [ - { - type: "text", - text: `Library ${args.library_name} contains ${result.footprints.length} footprints:\n${footprintList}` - } - ] - }; - } - return { - content: [ - { - type: "text", - text: `Failed to list footprints in library ${args.library_name}: ${result.message || 'Unknown error'}` - } - ] - }; - } - ); - - // Get detailed information about a specific footprint - server.tool( - "get_footprint_info", - "Get detailed information about a specific footprint", - { - library_name: z.string() - .describe("Name of the library containing the footprint"), - footprint_name: z.string() - .describe("Name of the footprint to get information about") - }, - async (args: { library_name: string; footprint_name: string }) => { - const result = await callKicadScript("get_footprint_info", args); - if (result.success && result.info) { - const info = result.info; - const details = [ - `Footprint: ${info.name}`, - `Library: ${info.library}`, - info.description ? `Description: ${info.description}` : '', - info.keywords ? `Keywords: ${info.keywords}` : '', - info.pads ? `Number of pads: ${info.pads}` : '', - info.layers ? `Layers used: ${info.layers.join(', ')}` : '', - info.courtyard ? `Courtyard size: ${info.courtyard.width}mm x ${info.courtyard.height}mm` : '', - info.attributes ? `Attributes: ${JSON.stringify(info.attributes)}` : '' - ].filter(line => line).join('\n'); - - return { - content: [ - { - type: "text", - text: details - } - ] - }; - } - return { - content: [ - { - type: "text", - text: `Failed to get footprint info: ${result.message || 'Unknown error'}` - } - ] - }; - } - ); -} \ No newline at end of file +/** + * Library tools for KiCAD MCP server + * Provides access to KiCAD footprint libraries and symbols + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export function registerLibraryTools( + server: McpServer, + callKicadScript: Function, +) { + // List available footprint libraries + server.tool( + "list_libraries", + "List all available KiCAD footprint libraries", + { + search_paths: z + .array(z.string()) + .optional() + .describe("Optional additional search paths for libraries"), + }, + async (args: { search_paths?: string[] }) => { + const result = await callKicadScript("list_libraries", args); + if (result.success && result.libraries) { + return { + content: [ + { + type: "text", + text: `Found ${result.libraries.length} footprint libraries:\n${result.libraries.join("\n")}`, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: `Failed to list libraries: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); + + // Search for footprints across all libraries + server.tool( + "search_footprints", + "Search for footprints matching a pattern across all libraries", + { + search_term: z + .string() + .describe("Search term or pattern to match footprint names"), + library: z + .string() + .optional() + .describe("Optional specific library to search in"), + limit: z + .number() + .optional() + .default(50) + .describe("Maximum number of results to return"), + }, + async (args: { search_term: string; library?: string; limit?: number }) => { + const result = await callKicadScript("search_footprints", { + pattern: args.search_term, + library: args.library, + limit: args.limit, + }); + if (result.success && result.footprints) { + const footprintList = result.footprints + .map( + (fp: any) => + `${fp.full_name || fp.library + ":" + fp.footprint}${fp.description ? " - " + fp.description : ""}`, + ) + .join("\n"); + return { + content: [ + { + type: "text", + text: `Found ${result.footprints.length} matching footprints:\n${footprintList}`, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: `Failed to search footprints: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); + + // List footprints in a specific library + server.tool( + "list_library_footprints", + "List all footprints in a specific KiCAD library", + { + library_name: z + .string() + .describe("Name of the library to list footprints from"), + filter: z + .string() + .optional() + .describe("Optional filter pattern for footprint names"), + limit: z + .number() + .optional() + .default(100) + .describe("Maximum number of footprints to list"), + }, + async (args: { library_name: string; filter?: string; limit?: number }) => { + const result = await callKicadScript("list_library_footprints", args); + if (result.success && result.footprints) { + const footprintList = result.footprints + .map((fp: string) => ` - ${fp}`) + .join("\n"); + return { + content: [ + { + type: "text", + text: `Library ${args.library_name} contains ${result.footprints.length} footprints:\n${footprintList}`, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: `Failed to list footprints in library ${args.library_name}: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); + + // Get detailed information about a specific footprint + server.tool( + "get_footprint_info", + "Get detailed information about a specific footprint", + { + library_name: z + .string() + .describe("Name of the library containing the footprint"), + footprint_name: z + .string() + .describe("Name of the footprint to get information about"), + }, + async (args: { library_name: string; footprint_name: string }) => { + const result = await callKicadScript("get_footprint_info", args); + if (result.success && result.info) { + const info = result.info; + const details = [ + `Footprint: ${info.name}`, + `Library: ${info.library}`, + info.description ? `Description: ${info.description}` : "", + info.keywords ? `Keywords: ${info.keywords}` : "", + info.pads ? `Number of pads: ${info.pads}` : "", + info.layers ? `Layers used: ${info.layers.join(", ")}` : "", + info.courtyard + ? `Courtyard size: ${info.courtyard.width}mm x ${info.courtyard.height}mm` + : "", + info.attributes + ? `Attributes: ${JSON.stringify(info.attributes)}` + : "", + ] + .filter((line) => line) + .join("\n"); + + return { + content: [ + { + type: "text", + text: details, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: `Failed to get footprint info: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); +} diff --git a/src/tools/routing.ts b/src/tools/routing.ts index 9dd67b5..8f880bb 100644 --- a/src/tools/routing.ts +++ b/src/tools/routing.ts @@ -1,101 +1,324 @@ -/** - * Routing tools for KiCAD MCP server - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; - -export function registerRoutingTools(server: McpServer, callKicadScript: Function) { - // Add net tool - server.tool( - "add_net", - "Create a new net on the PCB", - { - name: z.string().describe("Net name"), - netClass: z.string().optional().describe("Net class name"), - }, - async (args: { name: string; netClass?: string }) => { - const result = await callKicadScript("add_net", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Route trace tool - server.tool( - "route_trace", - "Route a trace between two points", - { - start: z.object({ - x: z.number(), - y: z.number(), - unit: z.string().optional() - }).describe("Start position"), - end: z.object({ - x: z.number(), - y: z.number(), - unit: z.string().optional() - }).describe("End position"), - layer: z.string().describe("PCB layer"), - width: z.number().describe("Trace width in mm"), - net: z.string().describe("Net name"), - }, - async (args: any) => { - const result = await callKicadScript("route_trace", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Add via tool - server.tool( - "add_via", - "Add a via to the PCB", - { - position: z.object({ - x: z.number(), - y: z.number(), - unit: z.string().optional() - }).describe("Via position"), - net: z.string().describe("Net name"), - viaType: z.string().optional().describe("Via type (through, blind, buried)"), - }, - async (args: any) => { - const result = await callKicadScript("add_via", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Add copper pour tool - server.tool( - "add_copper_pour", - "Add a copper pour (ground/power plane) to the PCB", - { - layer: z.string().describe("PCB layer"), - net: z.string().describe("Net name"), - clearance: z.number().optional().describe("Clearance in mm"), - }, - async (args: any) => { - const result = await callKicadScript("add_copper_pour", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); -} +/** + * Routing tools for KiCAD MCP server + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export function registerRoutingTools( + server: McpServer, + callKicadScript: Function, +) { + // Add net tool + server.tool( + "add_net", + "Create a new net on the PCB", + { + name: z.string().describe("Net name"), + netClass: z.string().optional().describe("Net class name"), + }, + async (args: { name: string; netClass?: string }) => { + const result = await callKicadScript("add_net", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Route trace tool + server.tool( + "route_trace", + "Route a trace between two points", + { + start: z + .object({ + x: z.number(), + y: z.number(), + unit: z.string().optional(), + }) + .describe("Start position"), + end: z + .object({ + x: z.number(), + y: z.number(), + unit: z.string().optional(), + }) + .describe("End position"), + layer: z.string().describe("PCB layer"), + width: z.number().describe("Trace width in mm"), + net: z.string().describe("Net name"), + }, + async (args: any) => { + const result = await callKicadScript("route_trace", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Add via tool + server.tool( + "add_via", + "Add a via to the PCB", + { + position: z + .object({ + x: z.number(), + y: z.number(), + unit: z.string().optional(), + }) + .describe("Via position"), + net: z.string().describe("Net name"), + viaType: z + .string() + .optional() + .describe("Via type (through, blind, buried)"), + }, + async (args: any) => { + const result = await callKicadScript("add_via", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Add copper pour tool + server.tool( + "add_copper_pour", + "Add a copper pour (ground/power plane) to the PCB", + { + layer: z.string().describe("PCB layer"), + net: z.string().describe("Net name"), + clearance: z.number().optional().describe("Clearance in mm"), + }, + async (args: any) => { + const result = await callKicadScript("add_copper_pour", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Delete trace tool + server.tool( + "delete_trace", + "Delete traces from the PCB. Can delete by UUID, position, or bulk-delete all traces on a net.", + { + traceUuid: z + .string() + .optional() + .describe("UUID of a specific trace to delete"), + position: z + .object({ + x: z.number(), + y: z.number(), + unit: z.enum(["mm", "inch"]).optional(), + }) + .optional() + .describe("Delete trace nearest to this position"), + net: z + .string() + .optional() + .describe("Delete all traces on this net (bulk delete)"), + layer: z + .string() + .optional() + .describe("Filter by layer when using net-based deletion"), + includeVias: z + .boolean() + .optional() + .describe("Include vias in net-based deletion"), + }, + async (args: any) => { + const result = await callKicadScript("delete_trace", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Query traces tool + server.tool( + "query_traces", + "Query traces on the board with optional filters by net, layer, or bounding box.", + { + net: z.string().optional().describe("Filter by net name"), + layer: z.string().optional().describe("Filter by layer name"), + boundingBox: z + .object({ + x1: z.number(), + y1: z.number(), + x2: z.number(), + y2: z.number(), + unit: z.enum(["mm", "inch"]).optional(), + }) + .optional() + .describe("Filter by bounding box region"), + unit: z.enum(["mm", "inch"]).optional().describe("Unit for coordinates"), + }, + async (args: any) => { + const result = await callKicadScript("query_traces", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Get nets list tool + server.tool( + "get_nets_list", + "Get a list of all nets in the PCB with optional statistics.", + { + includeStats: z + .boolean() + .optional() + .describe("Include statistics (track count, total length, etc.)"), + unit: z + .enum(["mm", "inch"]) + .optional() + .describe("Unit for length measurements"), + }, + async (args: any) => { + const result = await callKicadScript("get_nets_list", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Modify trace tool + server.tool( + "modify_trace", + "Modify an existing trace (change width, layer, or net).", + { + traceUuid: z.string().describe("UUID of the trace to modify"), + width: z.number().optional().describe("New trace width in mm"), + layer: z.string().optional().describe("New layer name"), + net: z.string().optional().describe("New net name"), + }, + async (args: any) => { + const result = await callKicadScript("modify_trace", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Create netclass tool + server.tool( + "create_netclass", + "Create a new net class with custom design rules.", + { + name: z.string().describe("Net class name"), + traceWidth: z.number().optional().describe("Default trace width in mm"), + clearance: z.number().optional().describe("Clearance in mm"), + viaDiameter: z.number().optional().describe("Via diameter in mm"), + viaDrill: z.number().optional().describe("Via drill size in mm"), + }, + async (args: any) => { + const result = await callKicadScript("create_netclass", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Route differential pair tool + server.tool( + "route_differential_pair", + "Route a differential pair between two sets of points.", + { + positivePad: z + .object({ + reference: z.string(), + pad: z.string(), + }) + .describe("Positive pad (component and pad number)"), + negativePad: z + .object({ + reference: z.string(), + pad: z.string(), + }) + .describe("Negative pad (component and pad number)"), + layer: z.string().describe("PCB layer"), + width: z.number().describe("Trace width in mm"), + gap: z.number().describe("Gap between traces in mm"), + positiveNet: z.string().describe("Positive net name"), + negativeNet: z.string().describe("Negative net name"), + }, + async (args: any) => { + const result = await callKicadScript("route_differential_pair", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Refill zones tool + server.tool( + "refill_zones", + "Refill all copper zones on the board. WARNING: SWIG path has known segfault risk (see KNOWN_ISSUES.md). Prefer using IPC backend (KiCAD open) or triggering zone fill via KiCAD UI instead.", + {}, + async (args: any) => { + const result = await callKicadScript("refill_zones", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); +} diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 6e07758..23f9ff1 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1,268 +1,383 @@ -/** - * Schematic tools for KiCAD MCP server - */ - -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod'; - -export function registerSchematicTools(server: McpServer, callKicadScript: Function) { - // Create schematic tool - server.tool( - "create_schematic", - "Create a new schematic", - { - name: z.string().describe("Schematic name"), - path: z.string().optional().describe("Optional path"), - }, - async (args: { name: string; path?: string }) => { - const result = await callKicadScript("create_schematic", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Add component to schematic - server.tool( - "add_schematic_component", - "Add a component to the schematic. Symbol format is 'Library:SymbolName' (e.g., 'Device:R', 'EDA-MCP:ESP32-C3')", - { - schematicPath: z.string().describe("Path to the schematic file"), - symbol: z.string().describe("Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3)"), - reference: z.string().describe("Component reference (e.g., R1, U1)"), - value: z.string().optional().describe("Component value"), - position: z.object({ - x: z.number(), - y: z.number() - }).optional().describe("Position on schematic"), - }, - async (args: { schematicPath: string; symbol: string; reference: string; value?: string; position?: { x: number; y: number } }) => { - // Transform to what Python backend expects - const [library, symbolName] = args.symbol.includes(':') - ? args.symbol.split(':') - : ['Device', args.symbol]; - - const transformed = { - schematicPath: args.schematicPath, - component: { - library, - type: symbolName, - reference: args.reference, - value: args.value, - // Python expects flat x, y not nested position - x: args.position?.x ?? 0, - y: args.position?.y ?? 0 - } - }; - - const result = await callKicadScript("add_schematic_component", transformed); - if (result.success) { - return { - content: [{ - type: "text", - text: `Successfully added ${args.reference} (${args.symbol}) to schematic` - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to add component: ${result.message || JSON.stringify(result)}` - }] - }; - } - } - ); - - // Connect components with wire - server.tool( - "add_wire", - "Add a wire connection in the schematic", - { - start: z.object({ - x: z.number(), - y: z.number() - }).describe("Start position"), - end: z.object({ - x: z.number(), - y: z.number() - }).describe("End position"), - }, - async (args: any) => { - const result = await callKicadScript("add_wire", args); - return { - content: [{ - type: "text", - text: JSON.stringify(result, null, 2) - }] - }; - } - ); - - // Add pin-to-pin connection - server.tool( - "add_schematic_connection", - "Connect two component pins with a wire", - { - schematicPath: z.string().describe("Path to the schematic file"), - sourceRef: z.string().describe("Source component reference (e.g., R1)"), - sourcePin: z.string().describe("Source pin name/number (e.g., 1, 2, GND)"), - targetRef: z.string().describe("Target component reference (e.g., C1)"), - targetPin: z.string().describe("Target pin name/number (e.g., 1, 2, VCC)") - }, - async (args: { schematicPath: string; sourceRef: string; sourcePin: string; targetRef: string; targetPin: string }) => { - const result = await callKicadScript("add_schematic_connection", args); - if (result.success) { - return { - content: [{ - type: "text", - text: `Successfully connected ${args.sourceRef}/${args.sourcePin} to ${args.targetRef}/${args.targetPin}` - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to add connection: ${result.message || 'Unknown error'}` - }] - }; - } - } - ); - - // Add net label - server.tool( - "add_schematic_net_label", - "Add a net label to the schematic", - { - schematicPath: z.string().describe("Path to the schematic file"), - netName: z.string().describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"), - position: z.array(z.number()).length(2).describe("Position [x, y] for the label") - }, - async (args: { schematicPath: string; netName: string; position: number[] }) => { - const result = await callKicadScript("add_schematic_net_label", args); - if (result.success) { - return { - content: [{ - type: "text", - text: `Successfully added net label '${args.netName}' at position [${args.position}]` - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to add net label: ${result.message || 'Unknown error'}` - }] - }; - } - } - ); - - // Connect pin to net - server.tool( - "connect_to_net", - "Connect a component pin to a named net", - { - schematicPath: z.string().describe("Path to the schematic file"), - componentRef: z.string().describe("Component reference (e.g., U1, R1)"), - pinName: z.string().describe("Pin name/number to connect"), - netName: z.string().describe("Name of the net to connect to") - }, - async (args: { schematicPath: string; componentRef: string; pinName: string; netName: string }) => { - const result = await callKicadScript("connect_to_net", args); - if (result.success) { - return { - content: [{ - type: "text", - text: `Successfully connected ${args.componentRef}/${args.pinName} to net '${args.netName}'` - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to connect to net: ${result.message || 'Unknown error'}` - }] - }; - } - } - ); - - // Get net connections - server.tool( - "get_net_connections", - "Get all connections for a named net", - { - schematicPath: z.string().describe("Path to the schematic file"), - netName: z.string().describe("Name of the net to query") - }, - async (args: { schematicPath: string; netName: string }) => { - const result = await callKicadScript("get_net_connections", args); - if (result.success && result.connections) { - const connectionList = result.connections.map((conn: any) => - ` - ${conn.component}/${conn.pin}` - ).join('\n'); - return { - content: [{ - type: "text", - text: `Net '${args.netName}' connections:\n${connectionList}` - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to get net connections: ${result.message || 'Unknown error'}` - }] - }; - } - } - ); - - // Generate netlist - server.tool( - "generate_netlist", - "Generate a netlist from the schematic", - { - schematicPath: z.string().describe("Path to the schematic file") - }, - async (args: { schematicPath: string }) => { - const result = await callKicadScript("generate_netlist", args); - if (result.success && result.netlist) { - const netlist = result.netlist; - const output = [ - `=== Netlist for ${args.schematicPath} ===`, - `\nComponents (${netlist.components.length}):`, - ...netlist.components.map((comp: any) => - ` ${comp.reference}: ${comp.value} (${comp.footprint || 'No footprint'})` - ), - `\nNets (${netlist.nets.length}):`, - ...netlist.nets.map((net: any) => { - const connections = net.connections.map((conn: any) => - `${conn.component}/${conn.pin}` - ).join(', '); - return ` ${net.name}: ${connections}`; - }) - ].join('\n'); - - return { - content: [{ - type: "text", - text: output - }] - }; - } else { - return { - content: [{ - type: "text", - text: `Failed to generate netlist: ${result.message || 'Unknown error'}` - }] - }; - } - } - ); -} +/** + * Schematic tools for KiCAD MCP server + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; + +export function registerSchematicTools( + server: McpServer, + callKicadScript: Function, +) { + // Create schematic tool + server.tool( + "create_schematic", + "Create a new schematic", + { + name: z.string().describe("Schematic name"), + path: z.string().optional().describe("Optional path"), + }, + async (args: { name: string; path?: string }) => { + const result = await callKicadScript("create_schematic", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Add component to schematic + server.tool( + "add_schematic_component", + "Add a component to the schematic. Symbol format is 'Library:SymbolName' (e.g., 'Device:R', 'EDA-MCP:ESP32-C3')", + { + schematicPath: z.string().describe("Path to the schematic file"), + symbol: z + .string() + .describe( + "Symbol library:name reference (e.g., Device:R, EDA-MCP:ESP32-C3)", + ), + reference: z.string().describe("Component reference (e.g., R1, U1)"), + value: z.string().optional().describe("Component value"), + position: z + .object({ + x: z.number(), + y: z.number(), + }) + .optional() + .describe("Position on schematic"), + }, + async (args: { + schematicPath: string; + symbol: string; + reference: string; + value?: string; + position?: { x: number; y: number }; + }) => { + // Transform to what Python backend expects + const [library, symbolName] = args.symbol.includes(":") + ? args.symbol.split(":") + : ["Device", args.symbol]; + + const transformed = { + schematicPath: args.schematicPath, + component: { + library, + type: symbolName, + reference: args.reference, + value: args.value, + // Python expects flat x, y not nested position + x: args.position?.x ?? 0, + y: args.position?.y ?? 0, + }, + }; + + const result = await callKicadScript( + "add_schematic_component", + transformed, + ); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Successfully added ${args.reference} (${args.symbol}) to schematic`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to add component: ${result.message || JSON.stringify(result)}`, + }, + ], + }; + } + }, + ); + + // Delete component from schematic + server.tool( + "delete_schematic_component", + `Remove a placed symbol from a KiCAD schematic (.kicad_sch). + +This removes the symbol instance (the placed component) from the schematic. +It does NOT remove the symbol definition from lib_symbols. + +Note: This tool operates on schematic files (.kicad_sch). +To remove a footprint from a PCB, use delete_component instead.`, + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + reference: z + .string() + .describe("Reference designator of the component to remove (e.g. R1, U3)"), + }, + async (args: { schematicPath: string; reference: string }) => { + const result = await callKicadScript("delete_schematic_component", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Successfully removed ${args.reference} from schematic`, + }, + ], + }; + } + return { + content: [ + { + type: "text", + text: `Failed to remove component: ${result.message || "Unknown error"}`, + }, + ], + }; + }, + ); + + // Connect components with wire + server.tool( + "add_wire", + "Add a wire connection in the schematic", + { + start: z + .object({ + x: z.number(), + y: z.number(), + }) + .describe("Start position"), + end: z + .object({ + x: z.number(), + y: z.number(), + }) + .describe("End position"), + }, + async (args: any) => { + const result = await callKicadScript("add_wire", args); + return { + content: [ + { + type: "text", + text: JSON.stringify(result, null, 2), + }, + ], + }; + }, + ); + + // Add pin-to-pin connection + server.tool( + "add_schematic_connection", + "Connect two component pins with a wire", + { + schematicPath: z.string().describe("Path to the schematic file"), + sourceRef: z.string().describe("Source component reference (e.g., R1)"), + sourcePin: z + .string() + .describe("Source pin name/number (e.g., 1, 2, GND)"), + targetRef: z.string().describe("Target component reference (e.g., C1)"), + targetPin: z + .string() + .describe("Target pin name/number (e.g., 1, 2, VCC)"), + }, + async (args: { + schematicPath: string; + sourceRef: string; + sourcePin: string; + targetRef: string; + targetPin: string; + }) => { + const result = await callKicadScript("add_schematic_connection", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Successfully connected ${args.sourceRef}/${args.sourcePin} to ${args.targetRef}/${args.targetPin}`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to add connection: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + + // Add net label + server.tool( + "add_schematic_net_label", + "Add a net label to the schematic", + { + schematicPath: z.string().describe("Path to the schematic file"), + netName: z + .string() + .describe("Name of the net (e.g., VCC, GND, SIGNAL_1)"), + position: z + .array(z.number()) + .length(2) + .describe("Position [x, y] for the label"), + }, + async (args: { + schematicPath: string; + netName: string; + position: number[]; + }) => { + const result = await callKicadScript("add_schematic_net_label", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Successfully added net label '${args.netName}' at position [${args.position}]`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to add net label: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + + // Connect pin to net + server.tool( + "connect_to_net", + "Connect a component pin to a named net", + { + schematicPath: z.string().describe("Path to the schematic file"), + componentRef: z.string().describe("Component reference (e.g., U1, R1)"), + pinName: z.string().describe("Pin name/number to connect"), + netName: z.string().describe("Name of the net to connect to"), + }, + async (args: { + schematicPath: string; + componentRef: string; + pinName: string; + netName: string; + }) => { + const result = await callKicadScript("connect_to_net", args); + if (result.success) { + return { + content: [ + { + type: "text", + text: `Successfully connected ${args.componentRef}/${args.pinName} to net '${args.netName}'`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to connect to net: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + + // Get net connections + server.tool( + "get_net_connections", + "Get all connections for a named net", + { + schematicPath: z.string().describe("Path to the schematic file"), + netName: z.string().describe("Name of the net to query"), + }, + async (args: { schematicPath: string; netName: string }) => { + const result = await callKicadScript("get_net_connections", args); + if (result.success && result.connections) { + const connectionList = result.connections + .map((conn: any) => ` - ${conn.component}/${conn.pin}`) + .join("\n"); + return { + content: [ + { + type: "text", + text: `Net '${args.netName}' connections:\n${connectionList}`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to get net connections: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); + + // Generate netlist + server.tool( + "generate_netlist", + "Generate a netlist from the schematic", + { + schematicPath: z.string().describe("Path to the schematic file"), + }, + async (args: { schematicPath: string }) => { + const result = await callKicadScript("generate_netlist", args); + if (result.success && result.netlist) { + const netlist = result.netlist; + const output = [ + `=== Netlist for ${args.schematicPath} ===`, + `\nComponents (${netlist.components.length}):`, + ...netlist.components.map( + (comp: any) => + ` ${comp.reference}: ${comp.value} (${comp.footprint || "No footprint"})`, + ), + `\nNets (${netlist.nets.length}):`, + ...netlist.nets.map((net: any) => { + const connections = net.connections + .map((conn: any) => `${conn.component}/${conn.pin}`) + .join(", "); + return ` ${net.name}: ${connections}`; + }), + ].join("\n"); + + return { + content: [ + { + type: "text", + text: output, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to generate netlist: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); +}