style: apply Black formatting to all Python files

Add [tool.black] config to pyproject.toml and Black hook to
.pre-commit-config.yaml (rev 26.3.1), then auto-format all Python
source and test files with line-length=100, target-version=py310.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-03-29 13:01:08 +01:00
parent eee5bfb9ed
commit 75cead0860
53 changed files with 1847 additions and 2394 deletions

View File

@@ -9,3 +9,10 @@ repos:
- id: check-added-large-files - id: check-added-large-files
args: ['--maxkb=500'] args: ['--maxkb=500']
- id: check-merge-conflict - id: check-merge-conflict
- repo: https://github.com/psf/black
rev: 26.3.1
hooks:
- id: black
language_version: python3
args: [--config=pyproject.toml]

View File

@@ -2,3 +2,7 @@
name = "kicad-mcp-server" name = "kicad-mcp-server"
version = "2.1.0" version = "2.1.0"
requires-python = ">=3.10" requires-python = ">=3.10"
[tool.black]
line-length = 100
target-version = ["py310"]

View File

@@ -10,10 +10,10 @@ from .design_rules import DesignRuleCommands
from .export import ExportCommands from .export import ExportCommands
__all__ = [ __all__ = [
'ProjectCommands', "ProjectCommands",
'BoardCommands', "BoardCommands",
'ComponentCommands', "ComponentCommands",
'RoutingCommands', "RoutingCommands",
'DesignRuleCommands', "DesignRuleCommands",
'ExportCommands' "ExportCommands",
] ]

View File

@@ -8,4 +8,4 @@ It imports and re-exports the BoardCommands class from the board package.
from commands.board import BoardCommands from commands.board import BoardCommands
# Re-export the BoardCommands class for backward compatibility # Re-export the BoardCommands class for backward compatibility
__all__ = ['BoardCommands'] __all__ = ["BoardCommands"]

View File

@@ -12,7 +12,8 @@ from .layers import BoardLayerCommands
from .outline import BoardOutlineCommands from .outline import BoardOutlineCommands
from .view import BoardViewCommands from .view import BoardViewCommands
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class BoardCommands: class BoardCommands:
"""Handles board-related KiCAD operations""" """Handles board-related KiCAD operations"""
@@ -75,8 +76,8 @@ class BoardCommands:
"""Get a 2D image of the PCB""" """Get a 2D image of the PCB"""
self.view_commands.board = self.board self.view_commands.board = self.board
return self.view_commands.get_board_2d_view(params) return self.view_commands.get_board_2d_view(params)
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board""" """Get the bounding box extents of the board"""
self.view_commands.board = self.board self.view_commands.board = self.board
return self.view_commands.get_board_extents(params) return self.view_commands.get_board_extents(params)

View File

@@ -6,7 +6,8 @@ import pcbnew
import logging import logging
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class BoardLayerCommands: class BoardLayerCommands:
"""Handles board layer operations""" """Handles board layer operations"""
@@ -22,7 +23,7 @@ class BoardLayerCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
name = params.get("name") name = params.get("name")
@@ -34,7 +35,7 @@ class BoardLayerCommands:
return { return {
"success": False, "success": False,
"message": "Missing parameters", "message": "Missing parameters",
"errorDetails": "name, type, and position are required" "errorDetails": "name, type, and position are required",
} }
# Get layer stack # Get layer stack
@@ -47,7 +48,7 @@ class BoardLayerCommands:
return { return {
"success": False, "success": False,
"message": "Missing layer number", "message": "Missing layer number",
"errorDetails": "number is required for inner layers" "errorDetails": "number is required for inner layers",
} }
layer_id = pcbnew.In1_Cu + (number - 1) layer_id = pcbnew.In1_Cu + (number - 1)
elif position == "top": elif position == "top":
@@ -59,7 +60,7 @@ class BoardLayerCommands:
return { return {
"success": False, "success": False,
"message": "Invalid layer position", "message": "Invalid layer position",
"errorDetails": "position must be 'top', 'bottom', or 'inner'" "errorDetails": "position must be 'top', 'bottom', or 'inner'",
} }
# Set layer properties # Set layer properties
@@ -72,21 +73,12 @@ class BoardLayerCommands:
return { return {
"success": True, "success": True,
"message": f"Added layer: {name}", "message": f"Added layer: {name}",
"layer": { "layer": {"name": name, "type": layer_type, "position": position, "number": number},
"name": name,
"type": layer_type,
"position": position,
"number": number
}
} }
except Exception as e: except Exception as e:
logger.error(f"Error adding layer: {str(e)}") logger.error(f"Error adding layer: {str(e)}")
return { return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)}
"success": False,
"message": "Failed to add layer",
"errorDetails": str(e)
}
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]: def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the active layer for PCB operations""" """Set the active layer for PCB operations"""
@@ -95,7 +87,7 @@ class BoardLayerCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
layer = params.get("layer") layer = params.get("layer")
@@ -103,7 +95,7 @@ class BoardLayerCommands:
return { return {
"success": False, "success": False,
"message": "No layer specified", "message": "No layer specified",
"errorDetails": "layer parameter is required" "errorDetails": "layer parameter is required",
} }
# Find layer ID by name # Find layer ID by name
@@ -112,7 +104,7 @@ class BoardLayerCommands:
return { return {
"success": False, "success": False,
"message": "Layer not found", "message": "Layer not found",
"errorDetails": f"Layer '{layer}' does not exist" "errorDetails": f"Layer '{layer}' does not exist",
} }
# Set active layer # Set active layer
@@ -121,10 +113,7 @@ class BoardLayerCommands:
return { return {
"success": True, "success": True,
"message": f"Set active layer to: {layer}", "message": f"Set active layer to: {layer}",
"layer": { "layer": {"name": layer, "id": layer_id},
"name": layer,
"id": layer_id
}
} }
except Exception as e: except Exception as e:
@@ -132,7 +121,7 @@ class BoardLayerCommands:
return { return {
"success": False, "success": False,
"message": "Failed to set active layer", "message": "Failed to set active layer",
"errorDetails": str(e) "errorDetails": str(e),
} }
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]: def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -142,32 +131,27 @@ class BoardLayerCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
layers = [] layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id): if self.board.IsLayerEnabled(layer_id):
layers.append({ layers.append(
"name": self.board.GetLayerName(layer_id), {
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), "name": self.board.GetLayerName(layer_id),
"id": layer_id "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0 "id": layer_id,
# Active layer is a UI concept not applicable to headless scripting # Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
}) # Active layer is a UI concept not applicable to headless scripting
}
)
return { return {"success": True, "layers": layers}
"success": True,
"layers": layers
}
except Exception as e: except Exception as e:
logger.error(f"Error getting layer list: {str(e)}") logger.error(f"Error getting layer list: {str(e)}")
return { return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)}
"success": False,
"message": "Failed to get layer list",
"errorDetails": str(e)
}
def _get_layer_type(self, type_name: str) -> int: def _get_layer_type(self, type_name: str) -> int:
"""Convert layer type name to KiCAD layer type constant""" """Convert layer type name to KiCAD layer type constant"""
@@ -175,7 +159,7 @@ class BoardLayerCommands:
"copper": pcbnew.LT_SIGNAL, "copper": pcbnew.LT_SIGNAL,
"technical": pcbnew.LT_SIGNAL, "technical": pcbnew.LT_SIGNAL,
"user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead "user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead
"signal": pcbnew.LT_SIGNAL "signal": pcbnew.LT_SIGNAL,
} }
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL) return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
@@ -185,7 +169,7 @@ class BoardLayerCommands:
pcbnew.LT_SIGNAL: "signal", pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power", pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed", pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper" pcbnew.LT_JUMPER: "jumper",
} }
# Note: LT_USER was removed in KiCAD 9.0 # Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown") return type_map.get(type_id, "unknown")

View File

@@ -224,9 +224,7 @@ class BoardOutlineCommands:
} }
# Convert to internal units (nanometers) # Convert to internal units (nanometers)
scale = ( scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
1000000 if position.get("unit", "mm") == "mm" else 25400000
) # mm or inch to nm
x_nm = int(position["x"] * scale) x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale) y_nm = int(position["y"] * scale)
diameter_nm = int(diameter * scale) diameter_nm = int(diameter * scale)
@@ -252,9 +250,7 @@ class BoardOutlineCommands:
pad = pcbnew.PAD(module) pad = pcbnew.PAD(module)
pad.SetNumber(1) pad.SetNumber(1)
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE) pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
pad.SetAttribute( pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH
)
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm)) pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm)) pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
@@ -311,9 +307,7 @@ class BoardOutlineCommands:
} }
# Convert to internal units (nanometers) # Convert to internal units (nanometers)
scale = ( scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
1000000 if position.get("unit", "mm") == "mm" else 25400000
) # mm or inch to nm
x_nm = int(position["x"] * scale) x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale) y_nm = int(position["y"] * scale)
size_nm = int(size * scale) size_nm = int(size * scale)
@@ -372,9 +366,7 @@ class BoardOutlineCommands:
"errorDetails": str(e), "errorDetails": str(e),
} }
def _add_edge_line( def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int
) -> None:
"""Add a line to the edge cuts layer""" """Add a line to the edge cuts layer"""
line = pcbnew.PCB_SHAPE(self.board) line = pcbnew.PCB_SHAPE(self.board)
line.SetShape(pcbnew.SHAPE_T_SEGMENT) line.SetShape(pcbnew.SHAPE_T_SEGMENT)
@@ -396,18 +388,12 @@ class BoardOutlineCommands:
"""Add a rounded rectangle to the edge cuts layer""" """Add a rounded rectangle to the edge cuts layer"""
if radius_nm <= 0: if radius_nm <= 0:
# If no radius, create regular rectangle # If no radius, create regular rectangle
top_left = pcbnew.VECTOR2I( top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
center_x_nm - width_nm // 2, center_y_nm - height_nm // 2 top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
)
top_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
)
bottom_right = pcbnew.VECTOR2I( bottom_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2 center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
) )
bottom_left = pcbnew.VECTOR2I( bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
center_x_nm - width_nm // 2, center_y_nm + height_nm // 2
)
self._add_edge_line(top_left, top_right, layer) self._add_edge_line(top_left, top_right, layer)
self._add_edge_line(top_right, bottom_right, layer) self._add_edge_line(top_right, bottom_right, layer)

View File

@@ -6,7 +6,8 @@ import pcbnew
import logging import logging
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class BoardSizeCommands: class BoardSizeCommands:
"""Handles board size operations""" """Handles board size operations"""
@@ -22,7 +23,7 @@ class BoardSizeCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
width = params.get("width") width = params.get("width")
@@ -33,41 +34,36 @@ class BoardSizeCommands:
return { return {
"success": False, "success": False,
"message": "Missing dimensions", "message": "Missing dimensions",
"errorDetails": "Both width and height are required" "errorDetails": "Both width and height are required",
} }
# Create board outline using BoardOutlineCommands # Create board outline using BoardOutlineCommands
# This properly creates edge cuts on Edge.Cuts layer # This properly creates edge cuts on Edge.Cuts layer
from commands.board.outline import BoardOutlineCommands from commands.board.outline import BoardOutlineCommands
outline_commands = BoardOutlineCommands(self.board) outline_commands = BoardOutlineCommands(self.board)
# Create rectangular outline centered at origin # Create rectangular outline centered at origin
result = outline_commands.add_board_outline({ result = outline_commands.add_board_outline(
"shape": "rectangle", {
"centerX": width / 2, # Center X "shape": "rectangle",
"centerY": height / 2, # Center Y "centerX": width / 2, # Center X
"width": width, "centerY": height / 2, # Center Y
"height": height, "width": width,
"unit": unit "height": height,
}) "unit": unit,
}
)
if result.get("success"): if result.get("success"):
return { return {
"success": True, "success": True,
"message": f"Created board outline: {width}x{height} {unit}", "message": f"Created board outline: {width}x{height} {unit}",
"size": { "size": {"width": width, "height": height, "unit": unit},
"width": width,
"height": height,
"unit": unit
}
} }
else: else:
return result return result
except Exception as e: except Exception as e:
logger.error(f"Error setting board size: {str(e)}") logger.error(f"Error setting board size: {str(e)}")
return { return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)}
"success": False,
"message": "Failed to set board size",
"errorDetails": str(e)
}

View File

@@ -10,7 +10,8 @@ from PIL import Image
import io import io
import base64 import base64
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class BoardViewCommands: class BoardViewCommands:
"""Handles board viewing operations""" """Handles board viewing operations"""
@@ -26,7 +27,7 @@ class BoardViewCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
# Get board dimensions # Get board dimensions
@@ -42,26 +43,24 @@ class BoardViewCommands:
layers = [] layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT): for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id): if self.board.IsLayerEnabled(layer_id):
layers.append({ layers.append(
"name": self.board.GetLayerName(layer_id), {
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)), "name": self.board.GetLayerName(layer_id),
"id": layer_id "type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
}) "id": layer_id,
}
)
return { return {
"success": True, "success": True,
"board": { "board": {
"filename": self.board.GetFileName(), "filename": self.board.GetFileName(),
"size": { "size": {"width": width_mm, "height": height_mm, "unit": "mm"},
"width": width_mm,
"height": height_mm,
"unit": "mm"
},
"layers": layers, "layers": layers,
"title": self.board.GetTitleBlock().GetTitle() "title": self.board.GetTitleBlock().GetTitle(),
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0 # Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting # Active layer is a UI concept not applicable to headless scripting
} },
} }
except Exception as e: except Exception as e:
@@ -69,7 +68,7 @@ class BoardViewCommands:
return { return {
"success": False, "success": False,
"message": "Failed to get board information", "message": "Failed to get board information",
"errorDetails": str(e) "errorDetails": str(e),
} }
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]: def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -79,7 +78,7 @@ class BoardViewCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
# Get parameters # Get parameters
@@ -126,17 +125,14 @@ class BoardViewCommands:
# Convert SVG to requested format # Convert SVG to requested format
if format == "svg": if format == "svg":
with open(temp_svg, 'r') as f: with open(temp_svg, "r") as f:
svg_data = f.read() svg_data = f.read()
os.remove(temp_svg) os.remove(temp_svg)
return { return {"success": True, "imageData": svg_data, "format": "svg"}
"success": True,
"imageData": svg_data,
"format": "svg"
}
else: else:
# Use PIL to convert SVG to PNG/JPG # Use PIL to convert SVG to PNG/JPG
from cairosvg import svg2png from cairosvg import svg2png
png_data = svg2png(url=temp_svg, output_width=width, output_height=height) png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
os.remove(temp_svg) os.remove(temp_svg)
@@ -144,18 +140,18 @@ class BoardViewCommands:
# Convert PNG to JPG # Convert PNG to JPG
img = Image.open(io.BytesIO(png_data)) img = Image.open(io.BytesIO(png_data))
jpg_buffer = io.BytesIO() jpg_buffer = io.BytesIO()
img.convert('RGB').save(jpg_buffer, format='JPEG') img.convert("RGB").save(jpg_buffer, format="JPEG")
jpg_data = jpg_buffer.getvalue() jpg_data = jpg_buffer.getvalue()
return { return {
"success": True, "success": True,
"imageData": base64.b64encode(jpg_data).decode('utf-8'), "imageData": base64.b64encode(jpg_data).decode("utf-8"),
"format": "jpg" "format": "jpg",
} }
else: else:
return { return {
"success": True, "success": True,
"imageData": base64.b64encode(png_data).decode('utf-8'), "imageData": base64.b64encode(png_data).decode("utf-8"),
"format": "png" "format": "png",
} }
except Exception as e: except Exception as e:
@@ -163,7 +159,7 @@ class BoardViewCommands:
return { return {
"success": False, "success": False,
"message": "Failed to get board 2D view", "message": "Failed to get board 2D view",
"errorDetails": str(e) "errorDetails": str(e),
} }
def _get_layer_type_name(self, type_id: int) -> str: def _get_layer_type_name(self, type_id: int) -> str:
@@ -172,61 +168,58 @@ class BoardViewCommands:
pcbnew.LT_SIGNAL: "signal", pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power", pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed", pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper" pcbnew.LT_JUMPER: "jumper",
} }
# Note: LT_USER was removed in KiCAD 9.0 # Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown") return type_map.get(type_id, "unknown")
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]: def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board""" """Get the bounding box extents of the board"""
try: try:
if not self.board: if not self.board:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
# Get unit preference (default to mm) # Get unit preference (default to mm)
unit = params.get("unit", "mm") unit = params.get("unit", "mm")
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
# Get board bounding box # Get board bounding box
board_box = self.board.GetBoardEdgesBoundingBox() board_box = self.board.GetBoardEdgesBoundingBox()
# Extract bounds in nanometers, then convert # Extract bounds in nanometers, then convert
left = board_box.GetLeft() / scale left = board_box.GetLeft() / scale
top = board_box.GetTop() / scale top = board_box.GetTop() / scale
right = board_box.GetRight() / scale right = board_box.GetRight() / scale
bottom = board_box.GetBottom() / scale bottom = board_box.GetBottom() / scale
width = board_box.GetWidth() / scale width = board_box.GetWidth() / scale
height = board_box.GetHeight() / scale height = board_box.GetHeight() / scale
# Get center point # Get center point
center_x = board_box.GetCenter().x / scale center_x = board_box.GetCenter().x / scale
center_y = board_box.GetCenter().y / scale center_y = board_box.GetCenter().y / scale
return { return {
"success": True, "success": True,
"extents": { "extents": {
"left": left, "left": left,
"top": top, "top": top,
"right": right, "right": right,
"bottom": bottom, "bottom": bottom,
"width": width, "width": width,
"height": height, "height": height,
"center": { "center": {"x": center_x, "y": center_y},
"x": center_x, "unit": unit,
"y": center_y },
}, }
"unit": unit
} except Exception as e:
} logger.error(f"Error getting board extents: {str(e)}")
return {
except Exception as e: "success": False,
logger.error(f"Error getting board extents: {str(e)}") "message": "Failed to get board extents",
return { "errorDetails": str(e),
"success": False, }
"message": "Failed to get board extents",
"errorDetails": str(e)
}

View File

@@ -10,12 +10,15 @@ from typing import Dict, Any, Optional, List, Tuple
import base64 import base64
from commands.library import LibraryManager from commands.library import LibraryManager
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class ComponentCommands: class ComponentCommands:
"""Handles component-related KiCAD operations""" """Handles component-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None): def __init__(
self, board: Optional[pcbnew.BOARD] = None, library_manager: Optional[LibraryManager] = None
):
"""Initialize with optional board instance and library manager""" """Initialize with optional board instance and library manager"""
self.board = board self.board = board
self.library_manager = library_manager or LibraryManager() self.library_manager = library_manager or LibraryManager()
@@ -27,7 +30,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
# Get parameters # Get parameters
@@ -43,7 +46,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing parameters", "message": "Missing parameters",
"errorDetails": "componentId and position are required" "errorDetails": "componentId and position are required",
} }
# Find footprint using library manager # Find footprint using library manager
@@ -55,13 +58,14 @@ class ComponentCommands:
suggestions = self.library_manager.search_footprints(f"*{component_id}*", limit=5) suggestions = self.library_manager.search_footprints(f"*{component_id}*", limit=5)
suggestion_text = "" suggestion_text = ""
if suggestions: if suggestions:
suggestion_text = "\n\nDid you mean one of these?\n" + \ suggestion_text = "\n\nDid you mean one of these?\n" + "\n".join(
"\n".join([f" - {s['full_name']}" for s in suggestions]) [f" - {s['full_name']}" for s in suggestions]
)
return { return {
"success": False, "success": False,
"message": "Footprint not found", "message": "Footprint not found",
"errorDetails": f"Could not find footprint: {component_id}{suggestion_text}" "errorDetails": f"Could not find footprint: {component_id}{suggestion_text}",
} }
library_path, footprint_name = footprint_result library_path, footprint_name = footprint_result
@@ -78,7 +82,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Internal error", "message": "Internal error",
"errorDetails": "Could not determine library nickname" "errorDetails": "Could not determine library nickname",
} }
# Load the footprint # Load the footprint
@@ -87,7 +91,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Failed to load footprint", "message": "Failed to load footprint",
"errorDetails": f"Could not load footprint from {library_path}/{footprint_name}" "errorDetails": f"Could not load footprint from {library_path}/{footprint_name}",
} }
# Set position # Set position
@@ -145,14 +149,10 @@ class ComponentCommands:
"component": { "component": {
"reference": module.GetReference(), "reference": module.GetReference(),
"value": module.GetValue(), "value": module.GetValue(),
"position": { "position": {"x": position["x"], "y": position["y"], "unit": position["unit"]},
"x": position["x"],
"y": position["y"],
"unit": position["unit"]
},
"rotation": rotation, "rotation": rotation,
"layer": layer "layer": layer,
} },
} }
except Exception as e: except Exception as e:
@@ -160,7 +160,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Failed to place component", "message": "Failed to place component",
"errorDetails": str(e) "errorDetails": str(e),
} }
def move_component(self, params: Dict[str, Any]) -> Dict[str, Any]: def move_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -170,7 +170,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
reference = params.get("reference") reference = params.get("reference")
@@ -182,7 +182,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing parameters", "message": "Missing parameters",
"errorDetails": "reference and position are required" "errorDetails": "reference and position are required",
} }
# Find the component # Find the component
@@ -191,7 +191,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Component not found", "message": "Component not found",
"errorDetails": f"Could not find component: {reference}" "errorDetails": f"Could not find component: {reference}",
} }
# Set new position # Set new position
@@ -218,23 +218,17 @@ class ComponentCommands:
"message": f"Moved component: {reference}", "message": f"Moved component: {reference}",
"component": { "component": {
"reference": reference, "reference": reference,
"position": { "position": {"x": position["x"], "y": position["y"], "unit": position["unit"]},
"x": position["x"], "rotation": (
"y": position["y"], rotation if rotation is not None else module.GetOrientation().AsDegrees()
"unit": position["unit"] ),
}, "layer": self.board.GetLayerName(module.GetLayer()),
"rotation": rotation if rotation is not None else module.GetOrientation().AsDegrees(), },
"layer": self.board.GetLayerName(module.GetLayer())
}
} }
except Exception as e: except Exception as e:
logger.error(f"Error moving component: {str(e)}") logger.error(f"Error moving component: {str(e)}")
return { return {"success": False, "message": "Failed to move component", "errorDetails": str(e)}
"success": False,
"message": "Failed to move component",
"errorDetails": str(e)
}
def rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: def rotate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Rotate an existing component""" """Rotate an existing component"""
@@ -243,7 +237,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
reference = params.get("reference") reference = params.get("reference")
@@ -253,7 +247,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing parameters", "message": "Missing parameters",
"errorDetails": "reference and angle are required" "errorDetails": "reference and angle are required",
} }
# Find the component # Find the component
@@ -262,7 +256,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Component not found", "message": "Component not found",
"errorDetails": f"Could not find component: {reference}" "errorDetails": f"Could not find component: {reference}",
} }
# Set rotation # Set rotation
@@ -272,10 +266,7 @@ class ComponentCommands:
return { return {
"success": True, "success": True,
"message": f"Rotated component: {reference}", "message": f"Rotated component: {reference}",
"component": { "component": {"reference": reference, "rotation": angle},
"reference": reference,
"rotation": angle
}
} }
except Exception as e: except Exception as e:
@@ -283,7 +274,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Failed to rotate component", "message": "Failed to rotate component",
"errorDetails": str(e) "errorDetails": str(e),
} }
def delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]: def delete_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -293,7 +284,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
reference = params.get("reference") reference = params.get("reference")
@@ -301,7 +292,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing reference", "message": "Missing reference",
"errorDetails": "reference parameter is required" "errorDetails": "reference parameter is required",
} }
# Find the component # Find the component
@@ -310,23 +301,20 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Component not found", "message": "Component not found",
"errorDetails": f"Could not find component: {reference}" "errorDetails": f"Could not find component: {reference}",
} }
# Remove from board # Remove from board
self.board.Remove(module) self.board.Remove(module)
return { return {"success": True, "message": f"Deleted component: {reference}"}
"success": True,
"message": f"Deleted component: {reference}"
}
except Exception as e: except Exception as e:
logger.error(f"Error deleting component: {str(e)}") logger.error(f"Error deleting component: {str(e)}")
return { return {
"success": False, "success": False,
"message": "Failed to delete component", "message": "Failed to delete component",
"errorDetails": str(e) "errorDetails": str(e),
} }
def edit_component(self, params: Dict[str, Any]) -> Dict[str, Any]: def edit_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -336,7 +324,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
reference = params.get("reference") reference = params.get("reference")
@@ -348,7 +336,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing reference", "message": "Missing reference",
"errorDetails": "reference parameter is required" "errorDetails": "reference parameter is required",
} }
# Find the component # Find the component
@@ -357,7 +345,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Component not found", "message": "Component not found",
"errorDetails": f"Could not find component: {reference}" "errorDetails": f"Could not find component: {reference}",
} }
# Update properties # Update properties
@@ -385,17 +373,13 @@ class ComponentCommands:
"component": { "component": {
"reference": new_reference or reference, "reference": new_reference or reference,
"value": value or module.GetValue(), "value": value or module.GetValue(),
"footprint": footprint or module.GetFPIDAsString() "footprint": footprint or module.GetFPIDAsString(),
} },
} }
except Exception as e: except Exception as e:
logger.error(f"Error editing component: {str(e)}") logger.error(f"Error editing component: {str(e)}")
return { return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)}
"success": False,
"message": "Failed to edit component",
"errorDetails": str(e)
}
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]: def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get detailed properties of a component""" """Get detailed properties of a component"""
@@ -404,7 +388,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
reference = params.get("reference") reference = params.get("reference")
@@ -412,7 +396,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing reference", "message": "Missing reference",
"errorDetails": "reference parameter is required" "errorDetails": "reference parameter is required",
} }
# Find the component # Find the component
@@ -421,7 +405,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Component not found", "message": "Component not found",
"errorDetails": f"Could not find component: {reference}" "errorDetails": f"Could not find component: {reference}",
} }
# Get position in mm # Get position in mm
@@ -435,19 +419,15 @@ class ComponentCommands:
"reference": module.GetReference(), "reference": module.GetReference(),
"value": module.GetValue(), "value": module.GetValue(),
"footprint": module.GetFPIDAsString(), "footprint": module.GetFPIDAsString(),
"position": { "position": {"x": x_mm, "y": y_mm, "unit": "mm"},
"x": x_mm,
"y": y_mm,
"unit": "mm"
},
"rotation": module.GetOrientation().AsDegrees(), "rotation": module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(module.GetLayer()), "layer": self.board.GetLayerName(module.GetLayer()),
"attributes": { "attributes": {
"smd": module.GetAttributes() & pcbnew.FP_SMD, "smd": module.GetAttributes() & pcbnew.FP_SMD,
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE, "through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY "board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY,
} },
} },
} }
except Exception as e: except Exception as e:
@@ -455,7 +435,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Failed to get component properties", "message": "Failed to get component properties",
"errorDetails": str(e) "errorDetails": str(e),
} }
def get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]: def get_component_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -465,7 +445,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
components = [] components = []
@@ -474,30 +454,25 @@ class ComponentCommands:
x_mm = pos.x / 1000000 x_mm = pos.x / 1000000
y_mm = pos.y / 1000000 y_mm = pos.y / 1000000
components.append({ components.append(
"reference": module.GetReference(), {
"value": module.GetValue(), "reference": module.GetReference(),
"footprint": module.GetFPIDAsString(), "value": module.GetValue(),
"position": { "footprint": module.GetFPIDAsString(),
"x": x_mm, "position": {"x": x_mm, "y": y_mm, "unit": "mm"},
"y": y_mm, "rotation": module.GetOrientation().AsDegrees(),
"unit": "mm" "layer": self.board.GetLayerName(module.GetLayer()),
}, }
"rotation": module.GetOrientation().AsDegrees(), )
"layer": self.board.GetLayerName(module.GetLayer())
})
return { return {"success": True, "components": components}
"success": True,
"components": components
}
except Exception as e: except Exception as e:
logger.error(f"Error getting component list: {str(e)}") logger.error(f"Error getting component list: {str(e)}")
return { return {
"success": False, "success": False,
"message": "Failed to get component list", "message": "Failed to get component list",
"errorDetails": str(e) "errorDetails": str(e),
} }
def find_component(self, params: Dict[str, Any]) -> Dict[str, Any]: def find_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -507,7 +482,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
# Get search parameters # Get search parameters
@@ -519,7 +494,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing search criteria", "message": "Missing search criteria",
"errorDetails": "At least one of reference, value, or footprint pattern is required" "errorDetails": "At least one of reference, value, or footprint pattern is required",
} }
matches = [] matches = []
@@ -539,31 +514,25 @@ class ComponentCommands:
if match: if match:
pos = module.GetPosition() pos = module.GetPosition()
matches.append({ matches.append(
"reference": module.GetReference(), {
"value": module.GetValue(), "reference": module.GetReference(),
"footprint": module.GetFPIDAsString(), "value": module.GetValue(),
"position": { "footprint": module.GetFPIDAsString(),
"x": pos.x / 1000000, "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"y": pos.y / 1000000, "rotation": module.GetOrientation().AsDegrees(),
"unit": "mm" "layer": self.board.GetLayerName(module.GetLayer()),
}, }
"rotation": module.GetOrientation().AsDegrees(), )
"layer": self.board.GetLayerName(module.GetLayer())
})
return { return {"success": True, "matchCount": len(matches), "components": matches}
"success": True,
"matchCount": len(matches),
"components": matches
}
except Exception as e: except Exception as e:
logger.error(f"Error finding components: {str(e)}") logger.error(f"Error finding components: {str(e)}")
return { return {
"success": False, "success": False,
"message": "Failed to find components", "message": "Failed to find components",
"errorDetails": str(e) "errorDetails": str(e),
} }
def get_component_pads(self, params: Dict[str, Any]) -> Dict[str, Any]: def get_component_pads(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -573,7 +542,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
reference = params.get("reference") reference = params.get("reference")
@@ -581,7 +550,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing reference", "message": "Missing reference",
"errorDetails": "reference parameter is required" "errorDetails": "reference parameter is required",
} }
# Find the component # Find the component
@@ -590,7 +559,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Component not found", "message": "Component not found",
"errorDetails": f"Could not find component: {reference}" "errorDetails": f"Could not find component: {reference}",
} }
pads = [] pads = []
@@ -606,7 +575,7 @@ class ComponentCommands:
pcbnew.PAD_SHAPE_TRAPEZOID: "trapezoid", pcbnew.PAD_SHAPE_TRAPEZOID: "trapezoid",
pcbnew.PAD_SHAPE_ROUNDRECT: "roundrect", pcbnew.PAD_SHAPE_ROUNDRECT: "roundrect",
pcbnew.PAD_SHAPE_CHAMFERED_RECT: "chamfered_rect", pcbnew.PAD_SHAPE_CHAMFERED_RECT: "chamfered_rect",
pcbnew.PAD_SHAPE_CUSTOM: "custom" pcbnew.PAD_SHAPE_CUSTOM: "custom",
} }
shape = shape_map.get(pad.GetShape(), "unknown") shape = shape_map.get(pad.GetShape(), "unknown")
@@ -615,29 +584,25 @@ class ComponentCommands:
pcbnew.PAD_ATTRIB_PTH: "through_hole", pcbnew.PAD_ATTRIB_PTH: "through_hole",
pcbnew.PAD_ATTRIB_SMD: "smd", pcbnew.PAD_ATTRIB_SMD: "smd",
pcbnew.PAD_ATTRIB_CONN: "connector", pcbnew.PAD_ATTRIB_CONN: "connector",
pcbnew.PAD_ATTRIB_NPTH: "npth" pcbnew.PAD_ATTRIB_NPTH: "npth",
} }
pad_type = type_map.get(pad.GetAttribute(), "unknown") pad_type = type_map.get(pad.GetAttribute(), "unknown")
pads.append({ pads.append(
"name": pad.GetName(), {
"number": pad.GetNumber(), "name": pad.GetName(),
"position": { "number": pad.GetNumber(),
"x": pos.x / 1000000, "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"y": pos.y / 1000000, "net": pad.GetNetname(),
"unit": "mm" "netCode": pad.GetNetCode(),
}, "shape": shape,
"net": pad.GetNetname(), "type": pad_type,
"netCode": pad.GetNetCode(), "size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"},
"shape": shape, "drillSize": (
"type": pad_type, pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None
"size": { ),
"x": size.x / 1000000, }
"y": size.y / 1000000, )
"unit": "mm"
},
"drillSize": pad.GetDrillSize().x / 1000000 if pad.GetDrillSize().x > 0 else None
})
# Get component position for reference # Get component position for reference
comp_pos = module.GetPosition() comp_pos = module.GetPosition()
@@ -648,10 +613,10 @@ class ComponentCommands:
"componentPosition": { "componentPosition": {
"x": comp_pos.x / 1000000, "x": comp_pos.x / 1000000,
"y": comp_pos.y / 1000000, "y": comp_pos.y / 1000000,
"unit": "mm" "unit": "mm",
}, },
"padCount": len(pads), "padCount": len(pads),
"pads": pads "pads": pads,
} }
except Exception as e: except Exception as e:
@@ -659,7 +624,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Failed to get component pads", "message": "Failed to get component pads",
"errorDetails": str(e) "errorDetails": str(e),
} }
def get_pad_position(self, params: Dict[str, Any]) -> Dict[str, Any]: def get_pad_position(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -669,7 +634,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
reference = params.get("reference") reference = params.get("reference")
@@ -679,13 +644,13 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing reference", "message": "Missing reference",
"errorDetails": "reference parameter is required" "errorDetails": "reference parameter is required",
} }
if not pad_name: if not pad_name:
return { return {
"success": False, "success": False,
"message": "Missing pad identifier", "message": "Missing pad identifier",
"errorDetails": "padName or padNumber parameter is required" "errorDetails": "padName or padNumber parameter is required",
} }
# Find the component # Find the component
@@ -694,7 +659,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Component not found", "message": "Component not found",
"errorDetails": f"Could not find component: {reference}" "errorDetails": f"Could not find component: {reference}",
} }
# Find the specific pad # Find the specific pad
@@ -705,7 +670,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Pad not found", "message": "Pad not found",
"errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}" "errorDetails": f"Pad '{pad_name}' not found on {reference}. Available pads: {', '.join(available_pads)}",
} }
pos = pad.GetPosition() pos = pad.GetPosition()
@@ -715,18 +680,10 @@ class ComponentCommands:
"success": True, "success": True,
"reference": reference, "reference": reference,
"padName": pad.GetNumber(), "padName": pad.GetNumber(),
"position": { "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"net": pad.GetNetname(), "net": pad.GetNetname(),
"netCode": pad.GetNetCode(), "netCode": pad.GetNetCode(),
"size": { "size": {"x": size.x / 1000000, "y": size.y / 1000000, "unit": "mm"},
"x": size.x / 1000000,
"y": size.y / 1000000,
"unit": "mm"
}
} }
except Exception as e: except Exception as e:
@@ -734,7 +691,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Failed to get pad position", "message": "Failed to get pad position",
"errorDetails": str(e) "errorDetails": str(e),
} }
def place_component_array(self, params: Dict[str, Any]) -> Dict[str, Any]: def place_component_array(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -744,7 +701,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
component_id = params.get("componentId") component_id = params.get("componentId")
@@ -757,7 +714,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing parameters", "message": "Missing parameters",
"errorDetails": "componentId and count are required" "errorDetails": "componentId and count are required",
} }
if pattern == "grid": if pattern == "grid":
@@ -773,14 +730,14 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing grid parameters", "message": "Missing grid parameters",
"errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required" "errorDetails": "For grid pattern, startPosition, rows, columns, spacingX, and spacingY are required",
} }
if rows * columns != count: if rows * columns != count:
return { return {
"success": False, "success": False,
"message": "Invalid grid parameters", "message": "Invalid grid parameters",
"errorDetails": "rows * columns must equal count" "errorDetails": "rows * columns must equal count",
} }
placed_components = self._place_grid_array( placed_components = self._place_grid_array(
@@ -793,7 +750,7 @@ class ComponentCommands:
reference_prefix, reference_prefix,
value, value,
rotation, rotation,
layer layer,
) )
elif pattern == "circular": elif pattern == "circular":
@@ -808,7 +765,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing circular parameters", "message": "Missing circular parameters",
"errorDetails": "For circular pattern, center, radius, and angleStep are required" "errorDetails": "For circular pattern, center, radius, and angleStep are required",
} }
placed_components = self._place_circular_array( placed_components = self._place_circular_array(
@@ -821,20 +778,20 @@ class ComponentCommands:
reference_prefix, reference_prefix,
value, value,
rotation_offset, rotation_offset,
layer layer,
) )
else: else:
return { return {
"success": False, "success": False,
"message": "Invalid pattern", "message": "Invalid pattern",
"errorDetails": "Pattern must be 'grid' or 'circular'" "errorDetails": "Pattern must be 'grid' or 'circular'",
} }
return { return {
"success": True, "success": True,
"message": f"Placed {count} components in {pattern} pattern", "message": f"Placed {count} components in {pattern} pattern",
"components": placed_components "components": placed_components,
} }
except Exception as e: except Exception as e:
@@ -842,7 +799,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Failed to place component array", "message": "Failed to place component array",
"errorDetails": str(e) "errorDetails": str(e),
} }
def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]: def align_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -852,7 +809,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
references = params.get("references", []) references = params.get("references", [])
@@ -864,7 +821,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing references", "message": "Missing references",
"errorDetails": "At least two component references are required" "errorDetails": "At least two component references are required",
} }
# Find all referenced components # Find all referenced components
@@ -875,7 +832,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Component not found", "message": "Component not found",
"errorDetails": f"Could not find component: {ref}" "errorDetails": f"Could not find component: {ref}",
} }
components.append(module) components.append(module)
@@ -890,36 +847,34 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing edge parameter", "message": "Missing edge parameter",
"errorDetails": "Edge parameter is required for edge alignment" "errorDetails": "Edge parameter is required for edge alignment",
} }
self._align_components_to_edge(components, edge) self._align_components_to_edge(components, edge)
else: else:
return { return {
"success": False, "success": False,
"message": "Invalid alignment option", "message": "Invalid alignment option",
"errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'" "errorDetails": "Alignment must be 'horizontal', 'vertical', or 'edge'",
} }
# Prepare result data # Prepare result data
aligned_components = [] aligned_components = []
for module in components: for module in components:
pos = module.GetPosition() pos = module.GetPosition()
aligned_components.append({ aligned_components.append(
"reference": module.GetReference(), {
"position": { "reference": module.GetReference(),
"x": pos.x / 1000000, "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"y": pos.y / 1000000, "rotation": module.GetOrientation().AsDegrees(),
"unit": "mm" }
}, )
"rotation": module.GetOrientation().AsDegrees()
})
return { return {
"success": True, "success": True,
"message": f"Aligned {len(components)} components", "message": f"Aligned {len(components)} components",
"alignment": alignment, "alignment": alignment,
"distribution": distribution, "distribution": distribution,
"components": aligned_components "components": aligned_components,
} }
except Exception as e: except Exception as e:
@@ -927,7 +882,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Failed to align components", "message": "Failed to align components",
"errorDetails": str(e) "errorDetails": str(e),
} }
def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]: def duplicate_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -937,7 +892,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first" "errorDetails": "Load or create a board first",
} }
reference = params.get("reference") reference = params.get("reference")
@@ -949,7 +904,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Missing parameters", "message": "Missing parameters",
"errorDetails": "reference and newReference are required" "errorDetails": "reference and newReference are required",
} }
# Find the source component # Find the source component
@@ -958,7 +913,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Component not found", "message": "Component not found",
"errorDetails": f"Could not find component: {reference}" "errorDetails": f"Could not find component: {reference}",
} }
# Check if new reference already exists # Check if new reference already exists
@@ -966,7 +921,7 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Reference already exists", "message": "Reference already exists",
"errorDetails": f"A component with reference {new_reference} already exists" "errorDetails": f"A component with reference {new_reference} already exists",
} }
# Create new footprint with the same properties # Create new footprint with the same properties
@@ -1014,14 +969,10 @@ class ComponentCommands:
"reference": new_reference, "reference": new_reference,
"value": new_module.GetValue(), "value": new_module.GetValue(),
"footprint": new_module.GetFPIDAsString(), "footprint": new_module.GetFPIDAsString(),
"position": { "position": {"x": pos.x / 1000000, "y": pos.y / 1000000, "unit": "mm"},
"x": pos.x / 1000000,
"y": pos.y / 1000000,
"unit": "mm"
},
"rotation": new_module.GetOrientation().AsDegrees(), "rotation": new_module.GetOrientation().AsDegrees(),
"layer": self.board.GetLayerName(new_module.GetLayer()) "layer": self.board.GetLayerName(new_module.GetLayer()),
} },
} }
except Exception as e: except Exception as e:
@@ -1029,12 +980,22 @@ class ComponentCommands:
return { return {
"success": False, "success": False,
"message": "Failed to duplicate component", "message": "Failed to duplicate component",
"errorDetails": str(e) "errorDetails": str(e),
} }
def _place_grid_array(self, component_id: str, start_position: Dict[str, Any], def _place_grid_array(
rows: int, columns: int, spacing_x: float, spacing_y: float, self,
reference_prefix: str, value: str, rotation: float, layer: str) -> List[Dict[str, Any]]: component_id: str,
start_position: Dict[str, Any],
rows: int,
columns: int,
spacing_x: float,
spacing_y: float,
reference_prefix: str,
value: str,
rotation: float,
layer: str,
) -> List[Dict[str, Any]]:
"""Place components in a grid pattern and return the list of placed components""" """Place components in a grid pattern and return the list of placed components"""
placed = [] placed = []
@@ -1058,24 +1019,35 @@ class ComponentCommands:
component_reference = f"{reference_prefix}{index}" component_reference = f"{reference_prefix}{index}"
# Place component # Place component
result = self.place_component({ result = self.place_component(
"componentId": component_id, {
"position": {"x": x, "y": y, "unit": unit}, "componentId": component_id,
"reference": component_reference, "position": {"x": x, "y": y, "unit": unit},
"value": value, "reference": component_reference,
"rotation": rotation, "value": value,
"layer": layer "rotation": rotation,
}) "layer": layer,
}
)
if result["success"]: if result["success"]:
placed.append(result["component"]) placed.append(result["component"])
return placed return placed
def _place_circular_array(self, component_id: str, center: Dict[str, Any], def _place_circular_array(
radius: float, count: int, angle_start: float, self,
angle_step: float, reference_prefix: str, component_id: str,
value: str, rotation_offset: float, layer: str) -> List[Dict[str, Any]]: center: Dict[str, Any],
radius: float,
count: int,
angle_start: float,
angle_step: float,
reference_prefix: str,
value: str,
rotation_offset: float,
layer: str,
) -> List[Dict[str, Any]]:
"""Place components in a circular pattern and return the list of placed components""" """Place components in a circular pattern and return the list of placed components"""
placed = [] placed = []
@@ -1098,22 +1070,25 @@ class ComponentCommands:
component_rotation = angle + rotation_offset component_rotation = angle + rotation_offset
# Place component # Place component
result = self.place_component({ result = self.place_component(
"componentId": component_id, {
"position": {"x": x, "y": y, "unit": unit}, "componentId": component_id,
"reference": component_reference, "position": {"x": x, "y": y, "unit": unit},
"value": value, "reference": component_reference,
"rotation": component_rotation, "value": value,
"layer": layer "rotation": component_rotation,
}) "layer": layer,
}
)
if result["success"]: if result["success"]:
placed.append(result["component"]) placed.append(result["component"])
return placed return placed
def _align_components_horizontally(self, components: List[pcbnew.FOOTPRINT], def _align_components_horizontally(
distribution: str, spacing: Optional[float]) -> None: self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float]
) -> None:
"""Align components horizontally and optionally distribute them""" """Align components horizontally and optionally distribute them"""
if not components: if not components:
return return
@@ -1157,8 +1132,9 @@ class ComponentCommands:
x_current += spacing_nm x_current += spacing_nm
components[i].SetPosition(pcbnew.VECTOR2I(x_current, pos.y)) components[i].SetPosition(pcbnew.VECTOR2I(x_current, pos.y))
def _align_components_vertically(self, components: List[pcbnew.FOOTPRINT], def _align_components_vertically(
distribution: str, spacing: Optional[float]) -> None: self, components: List[pcbnew.FOOTPRINT], distribution: str, spacing: Optional[float]
) -> None:
"""Align components vertically and optionally distribute them""" """Align components vertically and optionally distribute them"""
if not components: if not components:
return return

View File

@@ -13,9 +13,7 @@ try:
DYNAMIC_LOADING_AVAILABLE = True DYNAMIC_LOADING_AVAILABLE = True
except ImportError: except ImportError:
logger.warning( logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
"Dynamic symbol loader not available - falling back to template-only mode"
)
DYNAMIC_LOADING_AVAILABLE = False DYNAMIC_LOADING_AVAILABLE = False
@@ -135,32 +133,22 @@ class ComponentManager:
# Check if schematic path is available # Check if schematic path is available
if schematic_path is None: if schematic_path is None:
logger.warning( logger.warning("Dynamic loading requires schematic file path but none was provided")
"Dynamic loading requires schematic file path but none was provided"
)
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R") fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
return (fallback, False) return (fallback, False)
# Determine library name # Determine library name
if library is None: if library is None:
# Default library for common component types # Default library for common component types
library = ( library = "Device" # Most passives and basic components are in Device library
"Device" # Most passives and basic components are in Device library
)
try: try:
logger.info( logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}"
)
# Use dynamic symbol loader to inject symbol and create template # Use dynamic symbol loader to inject symbol and create template
template_ref = loader.load_symbol_dynamically( template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
schematic_path, library, comp_type
)
logger.info( logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
f"Successfully loaded symbol dynamically. Template ref: {template_ref}"
)
# Signal that schematic needs reload to see new template # Signal that schematic needs reload to see new template
return (template_ref, True) return (template_ref, True)
@@ -198,9 +186,7 @@ class ComponentManager:
# Get component type and determine template # Get component type and determine template
comp_type = component_def.get("type", "R") comp_type = component_def.get("type", "R")
library = component_def.get( library = component_def.get("library", None) # Optional library specification
"library", None
) # Optional library specification
# Get template reference (static or dynamic) # Get template reference (static or dynamic)
template_ref, needs_reload = ComponentManager.get_or_create_template( template_ref, needs_reload = ComponentManager.get_or_create_template(
@@ -209,9 +195,7 @@ class ComponentManager:
# If dynamic loading occurred, reload schematic to see new template # If dynamic loading occurred, reload schematic to see new template
if needs_reload and schematic_path: if needs_reload and schematic_path:
logger.info( logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
f"Reloading schematic after dynamic loading: {schematic_path}"
)
schematic = SchematicManager.load_schematic(str(schematic_path)) schematic = SchematicManager.load_schematic(str(schematic_path))
# Find template symbol by reference (handles special characters like +) # Find template symbol by reference (handles special characters like +)
@@ -303,9 +287,7 @@ class ComponentManager:
return False return False
@staticmethod @staticmethod
def update_component( def update_component(schematic: Schematic, component_ref: str, new_properties: dict):
schematic: Schematic, component_ref: str, new_properties: dict
):
"""Update component properties by reference designator""" """Update component properties by reference designator"""
try: try:
symbol_to_update = None symbol_to_update = None
@@ -401,19 +383,13 @@ if __name__ == "__main__":
# Get a component # Get a component
retrieved_comp = ComponentManager.get_component(test_sch, "C1") retrieved_comp = ComponentManager.get_component(test_sch, "C1")
if retrieved_comp: if retrieved_comp:
print( print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})"
)
# Update a component # Update a component
ComponentManager.update_component( ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
test_sch, "R1", {"value": "20k", "Tolerance": "5%"}
)
# Search components # Search components
matching_comps = ComponentManager.search_components( matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
test_sch, "100"
) # Search by position
print(f"Search results for '100': {[c.reference for c in matching_comps]}") print(f"Search results for '100': {[c.reference for c in matching_comps]}")
# Get all components # Get all components
@@ -423,9 +399,7 @@ if __name__ == "__main__":
# Remove a component # Remove a component
ComponentManager.remove_component(test_sch, "D1") ComponentManager.remove_component(test_sch, "D1")
all_comps_after_remove = ComponentManager.get_all_components(test_sch) all_comps_after_remove = ComponentManager.get_all_components(test_sch)
print( print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}"
)
# Save the schematic (optional) # Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch") # SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")

View File

@@ -48,9 +48,7 @@ class ConnectionManager:
logger.error("Schematic does not have label collection") logger.error("Schematic does not have label collection")
return None return None
label = schematic.label.append( 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}") logger.info(f"Added net label '{net_name}' at {position}")
return label return label
except Exception as e: except Exception as e:
@@ -58,9 +56,7 @@ class ConnectionManager:
return None return None
@staticmethod @staticmethod
def connect_to_net( def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str):
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 Connect a component pin to a named net using a wire stub and label
@@ -93,9 +89,7 @@ class ConnectionManager:
# Stub direction follows the pin's outward angle from the PinLocator # Stub direction follows the pin's outward angle from the PinLocator
pin_angle_deg = getattr(locator, "_last_pin_angle", 0) pin_angle_deg = getattr(locator, "_last_pin_angle", 0)
try: try:
pin_angle_deg = ( pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
)
except Exception: except Exception:
pin_angle_deg = 0 pin_angle_deg = 0
import math as _math import math as _math
@@ -172,9 +166,7 @@ class ConnectionManager:
connected = [] connected = []
failed = [] failed = []
for pin_num in sorted( for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0):
src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0
):
try: try:
net_name = ( net_name = (
f"{net_prefix}_{int(pin_num) + pin_offset}" f"{net_prefix}_{int(pin_num) + pin_offset}"
@@ -200,15 +192,11 @@ class ConnectionManager:
failed.append(f"{target_ref}/{pin_num} (pin not found)") failed.append(f"{target_ref}/{pin_num} (pin not found)")
continue continue
connected.append( connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]")
f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]"
)
except Exception as e: except Exception as e:
failed.append(f"{source_ref}/{pin_num}: {e}") failed.append(f"{source_ref}/{pin_num}: {e}")
logger.info( logger.info(f"connect_passthrough: {len(connected)} connected, {len(failed)} failed")
f"connect_passthrough: {len(connected)} connected, {len(failed)} failed"
)
return {"connected": connected, "failed": failed} return {"connected": connected, "failed": failed}
@staticmethod @staticmethod
@@ -256,9 +244,7 @@ class ConnectionManager:
logger.info(f"No labels found for net '{net_name}'") logger.info(f"No labels found for net '{net_name}'")
return connections return connections
logger.debug( logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'")
f"Found {len(net_label_positions)} labels for net '{net_name}'"
)
# 2. Find all wires connected to these label positions # 2. Find all wires connected to these label positions
if not hasattr(schematic, "wire"): if not hasattr(schematic, "wire"):
@@ -272,9 +258,7 @@ class ConnectionManager:
wire_points = [] wire_points = []
for point in wire.pts.xy: for point in wire.pts.xy:
if hasattr(point, "value"): if hasattr(point, "value"):
wire_points.append( wire_points.append([float(point.value[0]), float(point.value[1])])
[float(point.value[0]), float(point.value[1])]
)
# Check if any wire point touches a label # Check if any wire point touches a label
wire_connected = False wire_connected = False
@@ -334,18 +318,14 @@ class ConnectionManager:
# Check each pin # Check each pin
for pin_num, pin_data in pins.items(): for pin_num, pin_data in pins.items():
# Get pin location # Get pin location
pin_loc = locator.get_pin_location( pin_loc = locator.get_pin_location(schematic_path, ref, pin_num)
schematic_path, ref, pin_num
)
if not pin_loc: if not pin_loc:
continue continue
# Check if pin coincides with any wire point # Check if pin coincides with any wire point
for wire_pt in connected_wire_points: for wire_pt in connected_wire_points:
if points_coincide(pin_loc, list(wire_pt)): if points_coincide(pin_loc, list(wire_pt)):
connections.append( connections.append({"component": ref, "pin": pin_num})
{"component": ref, "pin": pin_num}
)
break # Pin found, no need to check more wire points break # Pin found, no need to check more wire points
except Exception as e: except Exception as e:
@@ -364,9 +344,7 @@ class ConnectionManager:
# Check if symbol is near any wire point (within 10mm) # Check if symbol is near any wire point (within 10mm)
for wire_pt in connected_wire_points: for wire_pt in connected_wire_points:
dist = ( dist = ((symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2) ** 0.5
(symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2
) ** 0.5
if dist < 10.0: # 10mm proximity threshold 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 break # Only add once per component
@@ -419,9 +397,7 @@ class ConnectionManager:
component_info = { component_info = {
"reference": symbol.property.Reference.value, "reference": symbol.property.Reference.value,
"value": ( "value": (
symbol.property.Value.value symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
if hasattr(symbol.property, "Value")
else ""
), ),
"footprint": ( "footprint": (
symbol.property.Footprint.value symbol.property.Footprint.value
@@ -444,9 +420,7 @@ class ConnectionManager:
schematic, net_name, schematic_path schematic, net_name, schematic_path
) )
if connections: if connections:
netlist["nets"].append( netlist["nets"].append({"name": net_name, "connections": connections})
{"name": net_name, "connections": connections}
)
logger.info( logger.info(
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components" f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components"

View File

@@ -81,9 +81,7 @@ class DatasheetManager:
return lib_sym_start, lib_sym_end return lib_sym_start, lib_sym_end
@staticmethod @staticmethod
def _process_symbol_block( def _process_symbol_block(lines: List[str], block_start: int, block_end: int) -> Optional[Dict]:
lines: List[str], block_start: int, block_end: int
) -> Optional[Dict]:
""" """
Extract LCSC and Datasheet info from a placed symbol block. Extract LCSC and Datasheet info from a placed symbol block.
@@ -114,9 +112,7 @@ class DatasheetManager:
"datasheet_value": datasheet_current, "datasheet_value": datasheet_current,
} }
def enrich_schematic( def enrich_schematic(self, schematic_path: Path, dry_run: bool = False) -> Dict:
self, schematic_path: Path, dry_run: bool = False
) -> Dict:
""" """
Scan a .kicad_sch file and fill in missing LCSC datasheet URLs. Scan a .kicad_sch file and fill in missing LCSC datasheet URLs.
@@ -223,9 +219,7 @@ class DatasheetManager:
no_lcsc += 1 no_lcsc += 1
elif ds_value not in EMPTY_DATASHEET_VALUES: elif ds_value not in EMPTY_DATASHEET_VALUES:
already_set += 1 already_set += 1
logger.debug( logger.debug(f"Symbol {reference}: Datasheet already set to {ds_value!r}")
f"Symbol {reference}: Datasheet already set to {ds_value!r}"
)
else: else:
url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm) url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm)
if not dry_run: if not dry_run:
@@ -256,9 +250,7 @@ class DatasheetManager:
if not dry_run and updated > 0: if not dry_run and updated > 0:
with open(schematic_path, "w", encoding="utf-8") as f: with open(schematic_path, "w", encoding="utf-8") as f:
f.write("\n".join(new_lines)) f.write("\n".join(new_lines))
logger.info( logger.info(f"Saved {schematic_path.name}: {updated} datasheet URLs written")
f"Saved {schematic_path.name}: {updated} datasheet URLs written"
)
return { return {
"success": True, "success": True,

View File

@@ -58,13 +58,9 @@ class DesignRuleCommands:
# Set micro via settings (use properties - methods removed in KiCAD 9.0) # Set micro via settings (use properties - methods removed in KiCAD 9.0)
if "microViaDiameter" in params: if "microViaDiameter" in params:
design_settings.m_MicroViasMinSize = int( design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
params["microViaDiameter"] * scale
)
if "microViaDrill" in params: if "microViaDrill" in params:
design_settings.m_MicroViasMinDrill = int( design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
params["microViaDrill"] * scale
)
# Set minimum values # Set minimum values
if "minTrackWidth" in params: if "minTrackWidth" in params:
@@ -77,19 +73,13 @@ class DesignRuleCommands:
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale) design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
if "minMicroViaDiameter" in params: if "minMicroViaDiameter" in params:
design_settings.m_MicroViasMinSize = int( design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
params["minMicroViaDiameter"] * scale
)
if "minMicroViaDrill" in params: if "minMicroViaDrill" in params:
design_settings.m_MicroViasMinDrill = int( design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
params["minMicroViaDrill"] * scale
)
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill # KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
if "minHoleDiameter" in params: if "minHoleDiameter" in params:
design_settings.m_MinThroughDrill = int( design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
params["minHoleDiameter"] * scale
)
# KiCAD 9.0: Added hole clearance settings # KiCAD 9.0: Added hole clearance settings
if "holeClearance" in params: if "holeClearance" in params:
@@ -216,9 +206,7 @@ class DesignRuleCommands:
} }
# Create temporary JSON output file # Create temporary JSON output file
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
mode="w", suffix=".json", delete=False
) as tmp:
json_output = tmp.name json_output = tmp.name
try: try:
@@ -297,9 +285,7 @@ class DesignRuleCommands:
# Determine where to save the violations file # Determine where to save the violations file
board_dir = os.path.dirname(board_file) board_dir = os.path.dirname(board_file)
board_name = os.path.splitext(os.path.basename(board_file))[0] board_name = os.path.splitext(os.path.basename(board_file))[0]
violations_file = os.path.join( violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
board_dir, f"{board_name}_drc_violations.json"
)
# Always save violations to JSON file (for large result sets) # Always save violations to JSON file (for large result sets)
with open(violations_file, "w", encoding="utf-8") as f: with open(violations_file, "w", encoding="utf-8") as f:
@@ -453,9 +439,7 @@ class DesignRuleCommands:
# Filter by severity if specified # Filter by severity if specified
if severity != "all": if severity != "all":
filtered_violations = [ filtered_violations = [v for v in all_violations if v.get("severity") == severity]
v for v in all_violations if v.get("severity") == severity
]
else: else:
filtered_violations = all_violations filtered_violations = all_violations

View File

@@ -46,7 +46,12 @@ class DynamicSymbolLoader:
Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty" / "symbols", Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty" / "symbols",
Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols", Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols",
] ]
for env_var in ["KICAD10_SYMBOL_DIR", "KICAD9_SYMBOL_DIR", "KICAD8_SYMBOL_DIR", "KICAD_SYMBOL_DIR"]: for env_var in [
"KICAD10_SYMBOL_DIR",
"KICAD9_SYMBOL_DIR",
"KICAD8_SYMBOL_DIR",
"KICAD_SYMBOL_DIR",
]:
if env_var in os.environ: if env_var in os.environ:
possible_paths.insert(0, Path(os.environ[env_var])) possible_paths.insert(0, Path(os.environ[env_var]))
@@ -83,7 +88,9 @@ class DynamicSymbolLoader:
with open(table_path, "r", encoding="utf-8") as f: with open(table_path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
lib_pattern = r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?' lib_pattern = (
r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?'
)
for match in re.finditer(lib_pattern, content, re.IGNORECASE): for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1) nickname = match.group(1)
if nickname != library_name: if nickname != library_name:
@@ -208,9 +215,7 @@ class DynamicSymbolLoader:
return items return items
def _inline_extends_symbol( def _inline_extends_symbol(self, lib_content: str, symbol_name: str, child_block: str) -> str:
self, lib_content: str, symbol_name: str, child_block: str
) -> str:
""" """
Fully inline a child symbol that uses (extends "ParentName") by merging Fully inline a child symbol that uses (extends "ParentName") by merging
the parent's pins / graphics into the child definition. the parent's pins / graphics into the child definition.
@@ -255,22 +260,16 @@ class DynamicSymbolLoader:
for item in self._iter_top_level_items(parent_block): for item in self._iter_top_level_items(parent_block):
prop_match = re.match(r'[\s\t]*\(property "([^"]+)"', item) prop_match = re.match(r'[\s\t]*\(property "([^"]+)"', item)
sub_match = re.search( sub_match = re.search(r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item)
r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item
)
if prop_match: if prop_match:
pname = prop_match.group(1) pname = prop_match.group(1)
parent_prop_names.add(pname) parent_prop_names.add(pname)
body_lines.append( body_lines.append(child_props[pname] if pname in child_props else item)
child_props[pname] if pname in child_props else item
)
elif sub_match: elif sub_match:
# Rename ParentName_0_1 → ChildName_0_1 # Rename ParentName_0_1 → ChildName_0_1
body_lines.append( body_lines.append(item.replace(f'"{parent_name}_', f'"{symbol_name}_'))
item.replace(f'"{parent_name}_', f'"{symbol_name}_') elif re.match(r"[\s\t]*\(extends ", item):
)
elif re.match(r'[\s\t]*\(extends ', item):
pass # drop extends clause pass # drop extends clause
else: else:
body_lines.append(item) # pin_names, in_bom, on_board … body_lines.append(item) # pin_names, in_bom, on_board …
@@ -280,16 +279,12 @@ class DynamicSymbolLoader:
if pname not in parent_prop_names: if pname not in parent_prop_names:
body_lines.append(pblock) body_lines.append(pblock)
first_line = parent_block.split("\n")[0].replace( first_line = parent_block.split("\n")[0].replace(f'"{parent_name}"', f'"{symbol_name}"')
f'"{parent_name}"', f'"{symbol_name}"'
)
last_line = parent_block.split("\n")[-1] last_line = parent_block.split("\n")[-1]
return first_line + "\n" + "\n".join(body_lines) + "\n" + last_line return first_line + "\n" + "\n".join(body_lines) + "\n" + last_line
def extract_symbol_from_library( def extract_symbol_from_library(self, library_name: str, symbol_name: str) -> Optional[str]:
self, library_name: str, symbol_name: str
) -> Optional[str]:
""" """
Extract a symbol definition from a KiCad .kicad_sym library file. Extract a symbol definition from a KiCad .kicad_sym library file.
Returns the raw text block, ready to be injected into a schematic. Returns the raw text block, ready to be injected into a schematic.
@@ -311,9 +306,7 @@ class DynamicSymbolLoader:
block = self._extract_symbol_block(lib_content, symbol_name) block = self._extract_symbol_block(lib_content, symbol_name)
if block is None: if block is None:
logger.warning( logger.warning(f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym")
f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym"
)
return None return None
# If the symbol uses (extends "ParentName"), inline the parent content # If the symbol uses (extends "ParentName"), inline the parent content
@@ -322,9 +315,7 @@ class DynamicSymbolLoader:
# load a schematic whose lib_symbols section contains it. # load a schematic whose lib_symbols section contains it.
if re.search(r'\(extends "([^"]+)"\)', block): if re.search(r'\(extends "([^"]+)"\)', block):
parent_name = re.search(r'\(extends "([^"]+)"\)', block).group(1) parent_name = re.search(r'\(extends "([^"]+)"\)', block).group(1)
logger.info( logger.info(f"Symbol {symbol_name} extends {parent_name}, inlining parent content")
f"Symbol {symbol_name} extends {parent_name}, inlining parent content"
)
block = self._inline_extends_symbol(lib_content, symbol_name, block) block = self._inline_extends_symbol(lib_content, symbol_name, block)
# Prefix top-level symbol name with library # Prefix top-level symbol name with library
@@ -362,9 +353,7 @@ class DynamicSymbolLoader:
# Extract symbol from library # Extract symbol from library
symbol_block = self.extract_symbol_from_library(library_name, symbol_name) symbol_block = self.extract_symbol_from_library(library_name, symbol_name)
if not symbol_block: if not symbol_block:
raise ValueError( raise ValueError(f"Symbol '{symbol_name}' not found in library '{library_name}'")
f"Symbol '{symbol_name}' not found in library '{library_name}'"
)
# Indent the block to match lib_symbols indentation (4 spaces for top-level) # Indent the block to match lib_symbols indentation (4 spaces for top-level)
indented_lines = [] indented_lines = []
@@ -399,11 +388,7 @@ class DynamicSymbolLoader:
f.write(content) f.write(content)
# Handle both Path objects and strings # Handle both Path objects and strings
sch_name = ( sch_name = schematic_path.name if hasattr(schematic_path, "name") else str(schematic_path)
schematic_path.name
if hasattr(schematic_path, "name")
else str(schematic_path)
)
logger.info(f"Injected symbol {full_name} into {sch_name}") logger.info(f"Injected symbol {full_name} into {sch_name}")
return True return True
@@ -457,9 +442,7 @@ class DynamicSymbolLoader:
with open(schematic_path, "w", encoding="utf-8") as f: with open(schematic_path, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
logger.info( logger.info(f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})")
f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})"
)
return True return True
def load_symbol_dynamically( def load_symbol_dynamically(

View File

@@ -105,22 +105,16 @@ class ExportCommands:
] ]
try: try:
result = subprocess.run( result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
cmd, capture_output=True, text=True, timeout=60
)
if result.returncode == 0: if result.returncode == 0:
# Get list of generated drill files # Get list of generated drill files
for file in os.listdir(output_dir): for file in os.listdir(output_dir):
if file.endswith((".drl", ".cnc")): if file.endswith((".drl", ".cnc")):
drill_files.append(file) drill_files.append(file)
else: else:
logger.warning( logger.warning(f"Drill file generation failed: {result.stderr}")
f"Drill file generation failed: {result.stderr}"
)
except Exception as drill_error: except Exception as drill_error:
logger.warning( logger.warning(f"Could not generate drill files: {str(drill_error)}")
f"Could not generate drill files: {str(drill_error)}"
)
else: else:
logger.warning("kicad-cli not available for drill file generation") logger.warning("kicad-cli not available for drill file generation")
@@ -236,9 +230,7 @@ class ExportCommands:
# Get the actual output filename that was created # Get the actual output filename that was created
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0] board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
actual_filename = f"{board_name}-{base_name}.pdf" actual_filename = f"{board_name}-{base_name}.pdf"
actual_output_path = os.path.join( actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename)
os.path.dirname(output_path), actual_filename
)
return { return {
"success": True, "success": True,
@@ -395,9 +387,7 @@ class ExportCommands:
if not include_components: if not include_components:
cmd.append("--no-components") cmd.append("--no-components")
if include_copper: if include_copper:
cmd.extend( cmd.extend(["--include-tracks", "--include-pads", "--include-zones"])
["--include-tracks", "--include-pads", "--include-zones"]
)
if include_silkscreen: if include_silkscreen:
cmd.append("--include-silkscreen") cmd.append("--include-silkscreen")
if include_solder_mask: if include_solder_mask:
@@ -696,6 +686,7 @@ class ExportCommands:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
from pathlib import Path from pathlib import Path
logs_dir = Path(project_dir) / "logs" logs_dir = Path(project_dir) / "logs"
logs_dir.mkdir(exist_ok=True) logs_dir.mkdir(exist_ok=True)
dest = str(logs_dir / f"mcp_log_{timestamp}.txt") dest = str(logs_dir / f"mcp_log_{timestamp}.txt")

View File

@@ -16,7 +16,7 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger("kicad_interface") logger = logging.getLogger("kicad_interface")
KICAD9_FORMAT_VERSION = "20250114" # .kicad_sch schematic files KICAD9_FORMAT_VERSION = "20250114" # .kicad_sch schematic files
KICAD9_FOOTPRINT_VERSION = "20241229" # .kicad_mod footprint files KICAD9_FOOTPRINT_VERSION = "20241229" # .kicad_mod footprint files
@@ -106,7 +106,7 @@ class FootprintCreator:
# ---- header ---- # ---- header ----
lines.append(f'(footprint "{name}"') lines.append(f'(footprint "{name}"')
lines.append(f' (version {KICAD9_FOOTPRINT_VERSION})') lines.append(f" (version {KICAD9_FOOTPRINT_VERSION})")
lines.append(f' (generator "kicad-mcp")') lines.append(f' (generator "kicad-mcp")')
lines.append(f' (generator_version "9.0")') lines.append(f' (generator_version "9.0")')
lines.append(f' (layer "F.Cu")') lines.append(f' (layer "F.Cu")')
@@ -122,25 +122,21 @@ class FootprintCreator:
val_x = value_position.get("x", 0.0) if value_position else 0.0 val_x = value_position.get("x", 0.0) if value_position else 0.0
val_y = value_position.get("y", 1.27) if value_position else 1.27 val_y = value_position.get("y", 1.27) if value_position else 1.27
lines.append( lines.append(f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)')
f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)'
)
lines.append(f' (layer "F.SilkS")') lines.append(f' (layer "F.SilkS")')
lines.append(f' (uuid "{_new_uuid()}")') lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))') lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f' )') lines.append(f" )")
lines.append( lines.append(f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)')
f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)'
)
lines.append(f' (layer "F.Fab")') lines.append(f' (layer "F.Fab")')
lines.append(f' (uuid "{_new_uuid()}")') lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))') lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f' )') lines.append(f" )")
lines.append(f' (property "Datasheet" "" (at 0 0 0)') lines.append(f' (property "Datasheet" "" (at 0 0 0)')
lines.append(f' (layer "F.Fab")') lines.append(f' (layer "F.Fab")')
lines.append(f' (uuid "{_new_uuid()}")') lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))') lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f' )') lines.append(f" )")
lines.append("") lines.append("")
# ---- courtyard ---- # ---- courtyard ----
@@ -217,33 +213,32 @@ class FootprintCreator:
changes = [] changes = []
if size: if size:
new_size = f'(size {_fmt(size["w"])} {_fmt(size["h"])})' new_size = f'(size {_fmt(size["w"])} {_fmt(size["h"])})'
block, n = re.subn(r'\(size\s+[\d.]+\s+[\d.]+\)', new_size, block) block, n = re.subn(r"\(size\s+[\d.]+\s+[\d.]+\)", new_size, block)
if n: if n:
changes.append(f"size→{new_size}") changes.append(f"size→{new_size}")
if at: if at:
angle = at.get("angle", 0) angle = at.get("angle", 0)
new_at = f'(at {_fmt(at["x"])} {_fmt(at["y"])} {_fmt(angle)})' new_at = f'(at {_fmt(at["x"])} {_fmt(at["y"])} {_fmt(angle)})'
block, n = re.subn(r'\(at\s+[-\d.]+\s+[-\d.]+(?:\s+[-\d.]+)?\)', new_at, block) block, n = re.subn(r"\(at\s+[-\d.]+\s+[-\d.]+(?:\s+[-\d.]+)?\)", new_at, block)
if n: if n:
changes.append(f"at→{new_at}") changes.append(f"at→{new_at}")
if drill is not None: if drill is not None:
if isinstance(drill, (int, float)): if isinstance(drill, (int, float)):
new_drill = f'(drill {_fmt(drill)})' new_drill = f"(drill {_fmt(drill)})"
else: else:
new_drill = f'(drill oval {_fmt(drill["w"])} {_fmt(drill["h"])})' new_drill = f'(drill oval {_fmt(drill["w"])} {_fmt(drill["h"])})'
block, n = re.subn(r'\(drill(?:\s+oval)?\s+[-\d.]+(?:\s+[-\d.]+)?\)', new_drill, block) block, n = re.subn(
r"\(drill(?:\s+oval)?\s+[-\d.]+(?:\s+[-\d.]+)?\)", new_drill, block
)
if n: if n:
changes.append(f"drill→{new_drill}") changes.append(f"drill→{new_drill}")
else: else:
# Insert drill before closing paren of pad block # Insert drill before closing paren of pad block
block = block.rstrip().rstrip(')') + f'\n {new_drill}\n )' block = block.rstrip().rstrip(")") + f"\n {new_drill}\n )"
changes.append(f"drill (inserted)→{new_drill}") changes.append(f"drill (inserted)→{new_drill}")
if shape: if shape:
block, n = re.subn( block, n = re.subn(
r'(pad\s+"[^"]*"\s+\w+\s+)\w+', r'(pad\s+"[^"]*"\s+\w+\s+)\w+', lambda m: m.group(1) + shape, block, count=1
lambda m: m.group(1) + shape,
block,
count=1
) )
if n: if n:
changes.append(f"shape→{shape}") changes.append(f"shape→{shape}")
@@ -280,7 +275,7 @@ class FootprintCreator:
if not updated: if not updated:
return { return {
"success": False, "success": False,
"error": f"Pad \"{pad_number}\" not found or no changes made in {footprint_path}", "error": f'Pad "{pad_number}" not found or no changes made in {footprint_path}',
} }
path.write_text("\n".join(result_lines), encoding="utf-8") path.write_text("\n".join(result_lines), encoding="utf-8")
@@ -429,6 +424,7 @@ class FootprintCreator:
# Internal helpers # # Internal helpers #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _esc(s: str) -> str: def _esc(s: str) -> str:
"""Escape double-quotes inside S-Expression string values.""" """Escape double-quotes inside S-Expression string values."""
return s.replace('"', '\\"') return s.replace('"', '\\"')
@@ -436,6 +432,7 @@ def _esc(s: str) -> str:
def _new_uuid() -> str: def _new_uuid() -> str:
import uuid import uuid
return str(uuid.uuid4()) return str(uuid.uuid4())
@@ -445,8 +442,8 @@ _DEFAULT_THT_LAYERS = ["*.Cu", "*.Mask"]
def _pad_lines(pad: Dict[str, Any]) -> List[str]: def _pad_lines(pad: Dict[str, Any]) -> List[str]:
number = str(pad.get("number", "1")) number = str(pad.get("number", "1"))
ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole ptype = pad.get("type", "smd").lower() # smd | thru_hole | np_thru_hole
shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect shape = pad.get("shape", "rect").lower() # rect | circle | oval | roundrect
at = pad.get("at", {"x": 0.0, "y": 0.0}) at = pad.get("at", {"x": 0.0, "y": 0.0})
size = pad.get("size", {"w": 1.0, "h": 1.0}) size = pad.get("size", {"w": 1.0, "h": 1.0})
drill = pad.get("drill", None) drill = pad.get("drill", None)
@@ -462,7 +459,9 @@ def _pad_lines(pad: Dict[str, Any]) -> List[str]:
sh = _fmt(size.get("h", 1.0)) sh = _fmt(size.get("h", 1.0))
if layers is None: if layers is None:
layers = _DEFAULT_THT_LAYERS if ptype in ("thru_hole", "np_thru_hole") else _DEFAULT_SMD_LAYERS layers = (
_DEFAULT_THT_LAYERS if ptype in ("thru_hole", "np_thru_hole") else _DEFAULT_SMD_LAYERS
)
layers_str = " ".join(f'"{l}"' for l in layers) layers_str = " ".join(f'"{l}"' for l in layers)
lines = [f' (pad "{number}" {ptype} {shape}'] lines = [f' (pad "{number}" {ptype} {shape}']
@@ -494,13 +493,13 @@ def _rect_lines(rect: Dict[str, Any], layer: str, default_width: float = 0.05) -
y2 = _fmt(rect.get("y2", 1.0)) y2 = _fmt(rect.get("y2", 1.0))
w = _fmt(rect.get("width", default_width)) w = _fmt(rect.get("width", default_width))
return [ return [
f' (fp_rect', f" (fp_rect",
f' (start {x1} {y1})', f" (start {x1} {y1})",
f' (end {x2} {y2})', f" (end {x2} {y2})",
f' (stroke (width {w}) (type default))', f" (stroke (width {w}) (type default))",
f' (fill none)', f" (fill none)",
f' (layer "{layer}")', f' (layer "{layer}")',
f' (uuid "{_new_uuid()}")', f' (uuid "{_new_uuid()}")',
f' )', f" )",
"", "",
] ]

View File

@@ -22,9 +22,7 @@ logger = logging.getLogger("kicad_interface")
# Default Freerouting JAR location # Default Freerouting JAR location
DEFAULT_FREEROUTING_JAR = os.environ.get( DEFAULT_FREEROUTING_JAR = os.environ.get(
"FREEROUTING_JAR", "FREEROUTING_JAR",
os.path.join( os.path.join(os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"),
os.path.expanduser("~"), ".kicad-mcp", "freerouting.jar"
),
) )
DOCKER_IMAGE = "eclipse-temurin:21-jre" DOCKER_IMAGE = "eclipse-temurin:21-jre"
@@ -102,21 +100,36 @@ def _build_freerouting_cmd(
ses_name = os.path.basename(ses_path) ses_name = os.path.basename(ses_path)
jar_name = os.path.basename(jar_path) jar_name = os.path.basename(jar_path)
return [ return [
docker_exe, "run", "--rm", docker_exe,
"-v", f"{jar_path}:/app/{jar_name}:ro", "run",
"-v", f"{board_dir}:/work", "--rm",
"-v",
f"{jar_path}:/app/{jar_name}:ro",
"-v",
f"{board_dir}:/work",
DOCKER_IMAGE, DOCKER_IMAGE,
"java", "-jar", f"/app/{jar_name}", "java",
"-de", f"/work/{dsn_name}", "-jar",
"-do", f"/work/{ses_name}", f"/app/{jar_name}",
"-mp", str(passes), "-de",
f"/work/{dsn_name}",
"-do",
f"/work/{ses_name}",
"-mp",
str(passes),
] ]
else: else:
java_exe = _find_java() java_exe = _find_java()
return [ return [
java_exe, "-jar", jar_path, java_exe,
"-de", dsn_path, "-do", ses_path, "-jar",
"-mp", str(passes), jar_path,
"-de",
dsn_path,
"-do",
ses_path,
"-mp",
str(passes),
] ]
@@ -126,9 +139,7 @@ class FreeroutingCommands:
def __init__(self, board=None): def __init__(self, board=None):
self.board = board self.board = board
def _resolve_execution_mode( def _resolve_execution_mode(self, jar_path: str) -> Dict[str, Any]:
self, jar_path: str
) -> Dict[str, Any]:
"""Determine how to run Freerouting: direct or docker. """Determine how to run Freerouting: direct or docker.
Returns dict with 'mode', 'use_docker', or 'error'. Returns dict with 'mode', 'use_docker', or 'error'.
@@ -152,8 +163,7 @@ class FreeroutingCommands:
return { return {
"mode": "error", "mode": "error",
"error": ( "error": (
"Neither Java 21+ nor Docker found. " "Neither Java 21+ nor Docker found. " "Install one of them to use Freerouting."
"Install one of them to use Freerouting."
), ),
} }
@@ -190,14 +200,10 @@ class FreeroutingCommands:
return { return {
"success": False, "success": False,
"message": "No board file path available", "message": "No board file path available",
"errorDetails": ( "errorDetails": ("Provide boardPath or open a project first"),
"Provide boardPath or open a project first"
),
} }
jar_path = params.get( jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
"freeroutingJar", DEFAULT_FREEROUTING_JAR
)
timeout = params.get("timeout", 300) timeout = params.get("timeout", 300)
passes = params.get("maxPasses", 20) passes = params.get("maxPasses", 20)
@@ -238,9 +244,7 @@ class FreeroutingCommands:
return { return {
"success": False, "success": False,
"message": "DSN export failed", "message": "DSN export failed",
"errorDetails": ( "errorDetails": (f"ExportSpecctraDSN returned: {result}"),
f"ExportSpecctraDSN returned: {result}"
),
} }
except Exception as e: except Exception as e:
return { return {
@@ -260,14 +264,10 @@ class FreeroutingCommands:
logger.info(f"DSN exported: {dsn_size} bytes") logger.info(f"DSN exported: {dsn_size} bytes")
# Step 2: Run Freerouting # Step 2: Run Freerouting
cmd = _build_freerouting_cmd( cmd = _build_freerouting_cmd(jar_path, dsn_path, ses_path, passes, use_docker)
jar_path, dsn_path, ses_path, passes, use_docker
)
mode_label = "docker" if use_docker else "direct" mode_label = "docker" if use_docker else "direct"
logger.info( logger.info(f"Running Freerouting ({mode_label}): {' '.join(cmd)}")
f"Running Freerouting ({mode_label}): {' '.join(cmd)}"
)
start_time = time.time() start_time = time.time()
try: try:
@@ -283,10 +283,7 @@ class FreeroutingCommands:
if proc.returncode != 0: if proc.returncode != 0:
return { return {
"success": False, "success": False,
"message": ( "message": (f"Freerouting exited with code " f"{proc.returncode}"),
f"Freerouting exited with code "
f"{proc.returncode}"
),
"errorDetails": proc.stderr or proc.stdout, "errorDetails": proc.stderr or proc.stdout,
"elapsed_seconds": elapsed, "elapsed_seconds": elapsed,
"mode": mode_label, "mode": mode_label,
@@ -294,12 +291,8 @@ class FreeroutingCommands:
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
return { return {
"success": False, "success": False,
"message": ( "message": (f"Freerouting timed out after {timeout}s"),
f"Freerouting timed out after {timeout}s" "errorDetails": ("Increase timeout or reduce board complexity"),
),
"errorDetails": (
"Increase timeout or reduce board complexity"
),
} }
except Exception as e: except Exception as e:
return { return {
@@ -313,10 +306,7 @@ class FreeroutingCommands:
return { return {
"success": False, "success": False,
"message": "Freerouting did not produce SES output", "message": "Freerouting did not produce SES output",
"errorDetails": ( "errorDetails": (f"Expected at: {ses_path}. " f"Stdout: {proc.stdout[:500]}"),
f"Expected at: {ses_path}. "
f"Stdout: {proc.stdout[:500]}"
),
"elapsed_seconds": elapsed, "elapsed_seconds": elapsed,
} }
@@ -331,9 +321,7 @@ class FreeroutingCommands:
return { return {
"success": False, "success": False,
"message": "SES import failed", "message": "SES import failed",
"errorDetails": ( "errorDetails": (f"ImportSpecctraSES returned: {result}"),
f"ImportSpecctraSES returned: {result}"
),
"elapsed_seconds": elapsed, "elapsed_seconds": elapsed,
} }
except Exception as e: except Exception as e:
@@ -348,9 +336,7 @@ class FreeroutingCommands:
try: try:
self.board.Save(board_path) self.board.Save(board_path)
except Exception as e: except Exception as e:
logger.warning( logger.warning(f"Board save after autoroute failed: {e}")
f"Board save after autoroute failed: {e}"
)
# Collect stats # Collect stats
tracks = self.board.GetTracks() tracks = self.board.GetTracks()
@@ -373,9 +359,7 @@ class FreeroutingCommands:
"tracks": track_count, "tracks": track_count,
"vias": via_count, "vias": via_count,
}, },
"freerouting_stdout": ( "freerouting_stdout": (proc.stdout[:1000] if proc.stdout else ""),
proc.stdout[:1000] if proc.stdout else ""
),
} }
def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]: def export_dsn(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -396,36 +380,26 @@ class FreeroutingCommands:
"errorDetails": "Load or create a board first", "errorDetails": "Load or create a board first",
} }
board_path = ( board_path = params.get("boardPath") or self.board.GetFileName()
params.get("boardPath") or self.board.GetFileName()
)
output_path = params.get("outputPath") output_path = params.get("outputPath")
if not output_path: if not output_path:
if board_path: if board_path:
output_path = ( output_path = os.path.splitext(board_path)[0] + ".dsn"
os.path.splitext(board_path)[0] + ".dsn"
)
else: else:
return { return {
"success": False, "success": False,
"message": "No output path", "message": "No output path",
"errorDetails": ( "errorDetails": ("Provide outputPath or have a board open"),
"Provide outputPath or have a board open"
),
} }
try: try:
result = pcbnew.ExportSpecctraDSN( result = pcbnew.ExportSpecctraDSN(self.board, output_path)
self.board, output_path
)
if result is not True and result != 0: if result is not True and result != 0:
return { return {
"success": False, "success": False,
"message": "DSN export failed", "message": "DSN export failed",
"errorDetails": ( "errorDetails": (f"ExportSpecctraDSN returned: {result}"),
f"ExportSpecctraDSN returned: {result}"
),
} }
except Exception as e: except Exception as e:
return { return {
@@ -434,11 +408,7 @@ class FreeroutingCommands:
"errorDetails": str(e), "errorDetails": str(e),
} }
file_size = ( file_size = os.path.getsize(output_path) if os.path.isfile(output_path) else 0
os.path.getsize(output_path)
if os.path.isfile(output_path)
else 0
)
return { return {
"success": True, "success": True,
"message": f"Exported DSN to {output_path}", "message": f"Exported DSN to {output_path}",
@@ -469,9 +439,7 @@ class FreeroutingCommands:
return { return {
"success": False, "success": False,
"message": "Missing sesPath parameter", "message": "Missing sesPath parameter",
"errorDetails": ( "errorDetails": ("Provide the path to the .ses file"),
"Provide the path to the .ses file"
),
} }
if not os.path.isfile(ses_path): if not os.path.isfile(ses_path):
@@ -482,16 +450,12 @@ class FreeroutingCommands:
} }
try: try:
result = pcbnew.ImportSpecctraSES( result = pcbnew.ImportSpecctraSES(self.board, ses_path)
self.board, ses_path
)
if result is not True and result != 0: if result is not True and result != 0:
return { return {
"success": False, "success": False,
"message": "SES import failed", "message": "SES import failed",
"errorDetails": ( "errorDetails": (f"ImportSpecctraSES returned: {result}"),
f"ImportSpecctraSES returned: {result}"
),
} }
except Exception as e: except Exception as e:
return { return {
@@ -500,24 +464,16 @@ class FreeroutingCommands:
"errorDetails": str(e), "errorDetails": str(e),
} }
board_path = ( board_path = params.get("boardPath") or self.board.GetFileName()
params.get("boardPath") or self.board.GetFileName()
)
if board_path: if board_path:
try: try:
self.board.Save(board_path) self.board.Save(board_path)
except Exception as e: except Exception as e:
logger.warning( logger.warning(f"Board save after SES import failed: {e}")
f"Board save after SES import failed: {e}"
)
tracks = self.board.GetTracks() tracks = self.board.GetTracks()
track_count = sum( track_count = sum(1 for t in tracks if t.GetClass() != "PCB_VIA")
1 for t in tracks if t.GetClass() != "PCB_VIA" via_count = sum(1 for t in tracks if t.GetClass() == "PCB_VIA")
)
via_count = sum(
1 for t in tracks if t.GetClass() == "PCB_VIA"
)
return { return {
"success": True, "success": True,
@@ -528,13 +484,9 @@ class FreeroutingCommands:
}, },
} }
def check_freerouting( def check_freerouting(self, params: Dict[str, Any]) -> Dict[str, Any]:
self, params: Dict[str, Any]
) -> Dict[str, Any]:
"""Check if Freerouting and Java/Docker are available.""" """Check if Freerouting and Java/Docker are available."""
jar_path = params.get( jar_path = params.get("freeroutingJar", DEFAULT_FREEROUTING_JAR)
"freeroutingJar", DEFAULT_FREEROUTING_JAR
)
# Check local Java # Check local Java
java_exe = _find_java() java_exe = _find_java()
@@ -548,11 +500,7 @@ class FreeroutingCommands:
text=True, text=True,
timeout=10, timeout=10,
) )
java_version = ( java_version = (proc.stderr or proc.stdout).strip().split("\n")[0]
(proc.stderr or proc.stdout)
.strip()
.split("\n")[0]
)
java_21_ok = _java_version_ok(java_exe) java_21_ok = _java_version_ok(java_exe)
except Exception: except Exception:
pass pass

View File

@@ -18,7 +18,7 @@ import json
from typing import Optional, Dict, List, Callable from typing import Optional, Dict, List, Callable
from pathlib import Path from pathlib import Path
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class JLCPCBClient: class JLCPCBClient:
@@ -31,7 +31,12 @@ class JLCPCBClient:
BASE_URL = "https://jlcpcb.com/external" BASE_URL = "https://jlcpcb.com/external"
def __init__(self, app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None): def __init__(
self,
app_id: Optional[str] = None,
access_key: Optional[str] = None,
secret_key: Optional[str] = None,
):
""" """
Initialize JLCPCB API client Initialize JLCPCB API client
@@ -40,20 +45,24 @@ class JLCPCBClient:
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var) access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var) secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
""" """
self.app_id = app_id or os.getenv('JLCPCB_APP_ID') self.app_id = app_id or os.getenv("JLCPCB_APP_ID")
self.access_key = access_key or os.getenv('JLCPCB_API_KEY') self.access_key = access_key or os.getenv("JLCPCB_API_KEY")
self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET') self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET")
if not self.app_id or not self.access_key or not self.secret_key: if not self.app_id or not self.access_key or not self.secret_key:
logger.warning("JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.") logger.warning(
"JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
)
@staticmethod @staticmethod
def _generate_nonce() -> str: def _generate_nonce() -> str:
"""Generate a 32-character random nonce""" """Generate a 32-character random nonce"""
chars = string.ascii_letters + string.digits chars = string.ascii_letters + string.digits
return ''.join(secrets.choice(chars) for _ in range(32)) return "".join(secrets.choice(chars) for _ in range(32))
def _build_signature_string(self, method: str, path: str, timestamp: int, nonce: str, body: str) -> str: def _build_signature_string(
self, method: str, path: str, timestamp: int, nonce: str, body: str
) -> str:
""" """
Build the signature string according to JLCPCB spec Build the signature string according to JLCPCB spec
@@ -87,11 +96,9 @@ class JLCPCBClient:
Base64-encoded signature Base64-encoded signature
""" """
signature_bytes = hmac.new( signature_bytes = hmac.new(
self.secret_key.encode('utf-8'), self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
signature_string.encode('utf-8'),
hashlib.sha256
).digest() ).digest()
return base64.b64encode(signature_bytes).decode('utf-8') return base64.b64encode(signature_bytes).decode("utf-8")
def _get_auth_header(self, method: str, path: str, body: str = "") -> str: def _get_auth_header(self, method: str, path: str, body: str = "") -> str:
""" """
@@ -106,7 +113,9 @@ class JLCPCBClient:
Authorization header value Authorization header value
""" """
if not self.app_id or not self.access_key or not self.secret_key: if not self.app_id or not self.access_key or not self.secret_key:
raise Exception("JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.") raise Exception(
"JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
)
nonce = self._generate_nonce() nonce = self._generate_nonce()
timestamp = int(time.time()) timestamp = int(time.time())
@@ -116,7 +125,9 @@ class JLCPCBClient:
logger.debug(f"Signature string:\n{repr(signature_string)}") logger.debug(f"Signature string:\n{repr(signature_string)}")
logger.debug(f"Signature: {signature}") logger.debug(f"Signature: {signature}")
logger.debug(f"Auth header: JOP appid=\"{self.app_id}\",accesskey=\"{self.access_key}\",nonce=\"{nonce}\",timestamp=\"{timestamp}\",signature=\"{signature}\"") logger.debug(
f'Auth header: JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
)
return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"' return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
@@ -138,22 +149,16 @@ class JLCPCBClient:
# Convert payload to JSON string for signing # Convert payload to JSON string for signing
# For POST requests, we always send JSON, even if empty dict # For POST requests, we always send JSON, even if empty dict
body_str = json.dumps(payload, separators=(',', ':')) body_str = json.dumps(payload, separators=(",", ":"))
# Generate authorization header # Generate authorization header
auth_header = self._get_auth_header("POST", path, body_str) auth_header = self._get_auth_header("POST", path, body_str)
headers = { headers = {"Authorization": auth_header, "Content-Type": "application/json"}
"Authorization": auth_header,
"Content-Type": "application/json"
}
try: try:
response = requests.post( response = requests.post(
f"{self.BASE_URL}{path}", f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
headers=headers,
json=payload,
timeout=60
) )
logger.debug(f"Response status: {response.status_code}") logger.debug(f"Response status: {response.status_code}")
@@ -163,18 +168,19 @@ class JLCPCBClient:
response.raise_for_status() response.raise_for_status()
data = response.json() data = response.json()
if data.get('code') != 200: if data.get("code") != 200:
raise Exception(f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}") raise Exception(
f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}"
)
return data['data'] return data["data"]
except requests.exceptions.RequestException as e: except requests.exceptions.RequestException as e:
logger.error(f"Failed to fetch parts page: {e}") logger.error(f"Failed to fetch parts page: {e}")
raise Exception(f"JLCPCB API request failed: {e}") raise Exception(f"JLCPCB API request failed: {e}")
def download_full_database( def download_full_database(
self, self, callback: Optional[Callable[[int, int, str], None]] = None
callback: Optional[Callable[[int, int, str], None]] = None
) -> List[Dict]: ) -> List[Dict]:
""" """
Download entire parts library from JLCPCB Download entire parts library from JLCPCB
@@ -197,10 +203,10 @@ class JLCPCBClient:
try: try:
data = self.fetch_parts_page(last_key) data = self.fetch_parts_page(last_key)
parts = data.get('componentInfos', []) parts = data.get("componentInfos", [])
all_parts.extend(parts) all_parts.extend(parts)
last_key = data.get('lastKey') last_key = data.get("lastKey")
if callback: if callback:
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...") callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
@@ -245,7 +251,9 @@ class JLCPCBClient:
return None return None
def test_jlcpcb_connection(app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None) -> bool: def test_jlcpcb_connection(
app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None
) -> bool:
""" """
Test JLCPCB API connection Test JLCPCB API connection
@@ -268,7 +276,7 @@ def test_jlcpcb_connection(app_id: Optional[str] = None, access_key: Optional[st
return False return False
if __name__ == '__main__': if __name__ == "__main__":
# Test the JLCPCB client # Test the JLCPCB client
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -279,7 +287,7 @@ if __name__ == '__main__':
client = JLCPCBClient() client = JLCPCBClient()
print("\nFetching first page of parts...") print("\nFetching first page of parts...")
data = client.fetch_parts_page() data = client.fetch_parts_page()
parts = data.get('componentInfos', []) parts = data.get("componentInfos", [])
print(f"✓ Retrieved {len(parts)} parts in first page") print(f"✓ Retrieved {len(parts)} parts in first page")
if parts: if parts:

View File

@@ -13,7 +13,7 @@ from pathlib import Path
from typing import List, Dict, Optional from typing import List, Dict, Optional
from datetime import datetime from datetime import datetime
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class JLCPCBPartsManager: class JLCPCBPartsManager:
@@ -49,7 +49,7 @@ class JLCPCBPartsManager:
cursor = self.conn.cursor() cursor = self.conn.cursor()
# Create components table # Create components table
cursor.execute(''' cursor.execute("""
CREATE TABLE IF NOT EXISTS components ( CREATE TABLE IF NOT EXISTS components (
lcsc TEXT PRIMARY KEY, lcsc TEXT PRIMARY KEY,
category TEXT, category TEXT,
@@ -65,17 +65,19 @@ class JLCPCBPartsManager:
price_json TEXT, price_json TEXT,
last_updated INTEGER last_updated INTEGER
) )
''') """)
# Create indexes for fast searching # Create indexes for fast searching
cursor.execute('CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)') cursor.execute(
cursor.execute('CREATE INDEX IF NOT EXISTS idx_package ON components(package)') "CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)"
cursor.execute('CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)') )
cursor.execute('CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)') cursor.execute("CREATE INDEX IF NOT EXISTS idx_package ON components(package)")
cursor.execute('CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)') cursor.execute("CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)")
# Full-text search index for descriptions # Full-text search index for descriptions
cursor.execute(''' cursor.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5( CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5(
lcsc, lcsc,
description, description,
@@ -83,7 +85,7 @@ class JLCPCBPartsManager:
manufacturer, manufacturer,
content=components content=components
) )
''') """)
self.conn.commit() self.conn.commit()
logger.info(f"Initialized JLCPCB parts database at {self.db_path}") logger.info(f"Initialized JLCPCB parts database at {self.db_path}")
@@ -103,32 +105,35 @@ class JLCPCBPartsManager:
for i, part in enumerate(parts): for i, part in enumerate(parts):
try: try:
# Extract price breaks # Extract price breaks
price_json = json.dumps(part.get('prices', [])) price_json = json.dumps(part.get("prices", []))
# Determine library type # Determine library type
library_type = self._determine_library_type(part) library_type = self._determine_library_type(part)
cursor.execute(''' cursor.execute(
"""
INSERT OR REPLACE INTO components ( INSERT OR REPLACE INTO components (
lcsc, category, subcategory, mfr_part, package, lcsc, category, subcategory, mfr_part, package,
solder_joints, manufacturer, library_type, description, solder_joints, manufacturer, library_type, description,
datasheet, stock, price_json, last_updated datasheet, stock, price_json, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', ( """,
part.get('componentCode'), # lcsc (
part.get('firstSortName'), # category part.get("componentCode"), # lcsc
part.get('secondSortName'), # subcategory part.get("firstSortName"), # category
part.get('componentModelEn'), # mfr_part part.get("secondSortName"), # subcategory
part.get('componentSpecificationEn'), # package part.get("componentModelEn"), # mfr_part
part.get('soldPoint'), # solder_joints part.get("componentSpecificationEn"), # package
part.get('componentBrandEn'), # manufacturer part.get("soldPoint"), # solder_joints
library_type, # library_type part.get("componentBrandEn"), # manufacturer
part.get('describe'), # description library_type, # library_type
part.get('dataManualUrl'), # datasheet part.get("describe"), # description
part.get('stockCount', 0), # stock part.get("dataManualUrl"), # datasheet
price_json, # price_json part.get("stockCount", 0), # stock
int(datetime.now().timestamp()) # last_updated price_json, # price_json
)) int(datetime.now().timestamp()), # last_updated
),
)
imported += 1 imported += 1
@@ -140,10 +145,10 @@ class JLCPCBPartsManager:
skipped += 1 skipped += 1
# Update FTS index # Update FTS index
cursor.execute(''' cursor.execute("""
INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer) INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer)
SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components
''') """)
self.conn.commit() self.conn.commit()
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped") logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
@@ -151,16 +156,16 @@ class JLCPCBPartsManager:
def _determine_library_type(self, part: Dict) -> str: def _determine_library_type(self, part: Dict) -> str:
"""Determine if part is Basic, Extended, or Preferred""" """Determine if part is Basic, Extended, or Preferred"""
# JLCPCB API should provide this, but if not, we infer from assembly type # JLCPCB API should provide this, but if not, we infer from assembly type
assembly_type = part.get('assemblyType', '') assembly_type = part.get("assemblyType", "")
if 'Basic' in assembly_type or part.get('libraryType') == 'base': if "Basic" in assembly_type or part.get("libraryType") == "base":
return 'Basic' return "Basic"
elif 'Extended' in assembly_type: elif "Extended" in assembly_type:
return 'Extended' return "Extended"
elif 'Prefer' in assembly_type: elif "Prefer" in assembly_type:
return 'Preferred' return "Preferred"
else: else:
return 'Extended' # Default to Extended return "Extended" # Default to Extended
def import_jlcsearch_parts(self, parts: List[Dict], progress_callback=None): def import_jlcsearch_parts(self, parts: List[Dict], progress_callback=None):
""" """
@@ -178,56 +183,59 @@ class JLCPCBPartsManager:
try: try:
# JLCSearch format is different from official API # JLCSearch format is different from official API
# LCSC is an integer, we need to add 'C' prefix # LCSC is an integer, we need to add 'C' prefix
lcsc = part.get('lcsc') lcsc = part.get("lcsc")
if isinstance(lcsc, int): if isinstance(lcsc, int):
lcsc = f"C{lcsc}" lcsc = f"C{lcsc}"
# Build price JSON from jlcsearch single price # Build price JSON from jlcsearch single price
price = part.get('price') or part.get('price1') price = part.get("price") or part.get("price1")
price_json = json.dumps([{"qty": 1, "price": price}] if price else []) price_json = json.dumps([{"qty": 1, "price": price}] if price else [])
# Determine library type from is_basic flag # Determine library type from is_basic flag
library_type = 'Basic' if part.get('is_basic') else 'Extended' library_type = "Basic" if part.get("is_basic") else "Extended"
if part.get('is_preferred'): if part.get("is_preferred"):
library_type = 'Preferred' library_type = "Preferred"
# Extract description from various fields # Extract description from various fields
description_parts = [] description_parts = []
if 'resistance' in part: if "resistance" in part:
description_parts.append(f"{part['resistance']}Ω") description_parts.append(f"{part['resistance']}Ω")
if 'capacitance' in part: if "capacitance" in part:
description_parts.append(f"{part['capacitance']}F") description_parts.append(f"{part['capacitance']}F")
if 'tolerance_fraction' in part: if "tolerance_fraction" in part:
tol = part['tolerance_fraction'] * 100 tol = part["tolerance_fraction"] * 100
description_parts.append(f"±{tol}%") description_parts.append(f"±{tol}%")
if 'power_watts' in part: if "power_watts" in part:
description_parts.append(f"{part['power_watts']}mW") description_parts.append(f"{part['power_watts']}mW")
if 'voltage' in part: if "voltage" in part:
description_parts.append(f"{part['voltage']}V") description_parts.append(f"{part['voltage']}V")
description = part.get('description', ' '.join(description_parts)) description = part.get("description", " ".join(description_parts))
cursor.execute(''' cursor.execute(
"""
INSERT OR REPLACE INTO components ( INSERT OR REPLACE INTO components (
lcsc, category, subcategory, mfr_part, package, lcsc, category, subcategory, mfr_part, package,
solder_joints, manufacturer, library_type, description, solder_joints, manufacturer, library_type, description,
datasheet, stock, price_json, last_updated datasheet, stock, price_json, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', ( """,
lcsc, # lcsc with C prefix (
part.get('category', ''), # category lcsc, # lcsc with C prefix
part.get('subcategory', ''), # subcategory part.get("category", ""), # category
part.get('mfr', ''), # mfr_part part.get("subcategory", ""), # subcategory
part.get('package', ''), # package part.get("mfr", ""), # mfr_part
0, # solder_joints (not in jlcsearch) part.get("package", ""), # package
part.get('manufacturer', ''), # manufacturer 0, # solder_joints (not in jlcsearch)
library_type, # library_type part.get("manufacturer", ""), # manufacturer
description, # description library_type, # library_type
'', # datasheet (not in jlcsearch) description, # description
part.get('stock', 0), # stock "", # datasheet (not in jlcsearch)
price_json, # price_json part.get("stock", 0), # stock
int(datetime.now().timestamp()) # last_updated price_json, # price_json
)) int(datetime.now().timestamp()), # last_updated
),
)
imported += 1 imported += 1
@@ -239,10 +247,10 @@ class JLCPCBPartsManager:
skipped += 1 skipped += 1
# Update FTS index # Update FTS index
cursor.execute(''' cursor.execute("""
INSERT INTO components_fts(components_fts) INSERT INTO components_fts(components_fts)
VALUES('rebuild') VALUES('rebuild')
''') """)
self.conn.commit() self.conn.commit()
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped") logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
@@ -255,7 +263,7 @@ class JLCPCBPartsManager:
library_type: Optional[str] = None, library_type: Optional[str] = None,
manufacturer: Optional[str] = None, manufacturer: Optional[str] = None,
in_stock: bool = True, in_stock: bool = True,
limit: int = 20 limit: int = 20,
) -> List[Dict]: ) -> List[Dict]:
""" """
Search for parts with filters Search for parts with filters
@@ -283,15 +291,14 @@ class JLCPCBPartsManager:
# Add prefix wildcard to each term for partial matching # Add prefix wildcard to each term for partial matching
# (e.g., "BQ25895" becomes "BQ25895*" so FTS matches "BQ25895RTWR") # (e.g., "BQ25895" becomes "BQ25895*" so FTS matches "BQ25895RTWR")
fts_query = " ".join( fts_query = " ".join(
f"{term}*" if not term.endswith("*") else term f"{term}*" if not term.endswith("*") else term for term in query.strip().split()
for term in query.strip().split()
) )
sql_parts.append(''' sql_parts.append("""
AND lcsc IN ( AND lcsc IN (
SELECT lcsc FROM components_fts SELECT lcsc FROM components_fts
WHERE components_fts MATCH ? WHERE components_fts MATCH ?
) )
''') """)
params.append(fts_query) params.append(fts_query)
if category: if category:
@@ -343,11 +350,11 @@ class JLCPCBPartsManager:
if row: if row:
part = dict(row) part = dict(row)
# Parse price JSON # Parse price JSON
if part.get('price_json'): if part.get("price_json"):
try: try:
part['price_breaks'] = json.loads(part['price_json']) part["price_breaks"] = json.loads(part["price_json"])
except: except:
part['price_breaks'] = [] part["price_breaks"] = []
return part return part
return None return None
@@ -356,23 +363,25 @@ class JLCPCBPartsManager:
cursor = self.conn.cursor() cursor = self.conn.cursor()
cursor.execute("SELECT COUNT(*) as total FROM components") cursor.execute("SELECT COUNT(*) as total FROM components")
total = cursor.fetchone()['total'] total = cursor.fetchone()["total"]
cursor.execute("SELECT COUNT(*) as basic FROM components WHERE library_type = 'Basic'") cursor.execute("SELECT COUNT(*) as basic FROM components WHERE library_type = 'Basic'")
basic = cursor.fetchone()['basic'] basic = cursor.fetchone()["basic"]
cursor.execute("SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'") cursor.execute(
extended = cursor.fetchone()['extended'] "SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'"
)
extended = cursor.fetchone()["extended"]
cursor.execute("SELECT COUNT(*) as in_stock FROM components WHERE stock > 0") cursor.execute("SELECT COUNT(*) as in_stock FROM components WHERE stock > 0")
in_stock = cursor.fetchone()['in_stock'] in_stock = cursor.fetchone()["in_stock"]
return { return {
'total_parts': total, "total_parts": total,
'basic_parts': basic, "basic_parts": basic,
'extended_parts': extended, "extended_parts": extended,
'in_stock': in_stock, "in_stock": in_stock,
'db_path': self.db_path "db_path": self.db_path,
} }
def map_package_to_footprint(self, package: str) -> List[str]: def map_package_to_footprint(self, package: str) -> List[str]:
@@ -390,43 +399,22 @@ class JLCPCBPartsManager:
"0402": [ "0402": [
"Resistor_SMD:R_0402_1005Metric", "Resistor_SMD:R_0402_1005Metric",
"Capacitor_SMD:C_0402_1005Metric", "Capacitor_SMD:C_0402_1005Metric",
"LED_SMD:LED_0402_1005Metric" "LED_SMD:LED_0402_1005Metric",
], ],
"0603": [ "0603": [
"Resistor_SMD:R_0603_1608Metric", "Resistor_SMD:R_0603_1608Metric",
"Capacitor_SMD:C_0603_1608Metric", "Capacitor_SMD:C_0603_1608Metric",
"LED_SMD:LED_0603_1608Metric" "LED_SMD:LED_0603_1608Metric",
], ],
"0805": [ "0805": ["Resistor_SMD:R_0805_2012Metric", "Capacitor_SMD:C_0805_2012Metric"],
"Resistor_SMD:R_0805_2012Metric", "1206": ["Resistor_SMD:R_1206_3216Metric", "Capacitor_SMD:C_1206_3216Metric"],
"Capacitor_SMD:C_0805_2012Metric" "SOT-23": ["Package_TO_SOT_SMD:SOT-23", "Package_TO_SOT_SMD:SOT-23-3"],
], "SOT-23-5": ["Package_TO_SOT_SMD:SOT-23-5"],
"1206": [ "SOT-23-6": ["Package_TO_SOT_SMD:SOT-23-6"],
"Resistor_SMD:R_1206_3216Metric", "SOIC-8": ["Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"],
"Capacitor_SMD:C_1206_3216Metric" "SOIC-16": ["Package_SO:SOIC-16_3.9x9.9mm_P1.27mm"],
], "QFN-20": ["Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm"],
"SOT-23": [ "QFN-32": ["Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm"],
"Package_TO_SOT_SMD:SOT-23",
"Package_TO_SOT_SMD:SOT-23-3"
],
"SOT-23-5": [
"Package_TO_SOT_SMD:SOT-23-5"
],
"SOT-23-6": [
"Package_TO_SOT_SMD:SOT-23-6"
],
"SOIC-8": [
"Package_SO:SOIC-8_3.9x4.9mm_P1.27mm"
],
"SOIC-16": [
"Package_SO:SOIC-16_3.9x9.9mm_P1.27mm"
],
"QFN-20": [
"Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm"
],
"QFN-32": [
"Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm"
]
} }
# Normalize package name # Normalize package name
@@ -457,24 +445,21 @@ class JLCPCBPartsManager:
# Search for parts in same category with same package # Search for parts in same category with same package
alternatives = self.search_parts( alternatives = self.search_parts(
category=part['subcategory'], category=part["subcategory"], package=part["package"], in_stock=True, limit=limit * 3
package=part['package'],
in_stock=True,
limit=limit * 3
) )
# Filter out the original part # Filter out the original part
alternatives = [p for p in alternatives if p['lcsc'] != lcsc_number] alternatives = [p for p in alternatives if p["lcsc"] != lcsc_number]
# Sort by: Basic first, then by price, then by stock # Sort by: Basic first, then by price, then by stock
def sort_key(p): def sort_key(p):
is_basic = 1 if p.get('library_type') == 'Basic' else 0 is_basic = 1 if p.get("library_type") == "Basic" else 0
try: try:
prices = json.loads(p.get('price_json', '[]')) prices = json.loads(p.get("price_json", "[]"))
price = float(prices[0].get('price', 999)) if prices else 999 price = float(prices[0].get("price", 999)) if prices else 999
except: except:
price = 999 price = 999
stock = p.get('stock', 0) stock = p.get("stock", 0)
return (-is_basic, price, -stock) return (-is_basic, price, -stock)
@@ -488,7 +473,7 @@ class JLCPCBPartsManager:
self.conn.close() self.conn.close()
if __name__ == '__main__': if __name__ == "__main__":
# Test the parts manager # Test the parts manager
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@@ -503,8 +488,10 @@ if __name__ == '__main__':
print(f" In stock: {stats['in_stock']}") print(f" In stock: {stats['in_stock']}")
print(f" Database: {stats['db_path']}") print(f" Database: {stats['db_path']}")
if stats['total_parts'] > 0: if stats["total_parts"] > 0:
print("\nSearching for '10k resistor'...") print("\nSearching for '10k resistor'...")
results = manager.search_parts(query="10k resistor", limit=5) results = manager.search_parts(query="10k resistor", limit=5)
for part in results: for part in results:
print(f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})") print(
f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})"
)

View File

@@ -10,7 +10,7 @@ import requests
from typing import Optional, Dict, List, Callable from typing import Optional, Dict, List, Callable
import time import time
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
class JLCSearchClient: class JLCSearchClient:
@@ -28,11 +28,7 @@ class JLCSearchClient:
pass pass
def search_components( def search_components(
self, self, category: str = "components", limit: int = 100, offset: int = 0, **filters
category: str = "components",
limit: int = 100,
offset: int = 0,
**filters
) -> List[Dict]: ) -> List[Dict]:
""" """
Search components in JLCSearch database Search components in JLCSearch database
@@ -48,11 +44,7 @@ class JLCSearchClient:
""" """
url = f"{self.BASE_URL}/{category}/list.json" url = f"{self.BASE_URL}/{category}/list.json"
params = { params = {"limit": limit, "offset": offset, **filters}
"limit": limit,
"offset": offset,
**filters
}
try: try:
response = requests.get(url, params=params, timeout=30) response = requests.get(url, params=params, timeout=30)
@@ -71,7 +63,9 @@ class JLCSearchClient:
logger.error(f"Failed to search JLCSearch: {e}") logger.error(f"Failed to search JLCSearch: {e}")
raise Exception(f"JLCSearch API request failed: {e}") raise Exception(f"JLCSearch API request failed: {e}")
def search_resistors(self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]: def search_resistors(
self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100
) -> List[Dict]:
""" """
Search for resistors Search for resistors
@@ -100,7 +94,9 @@ class JLCSearchClient:
return self.search_components("resistors", limit=limit, **filters) return self.search_components("resistors", limit=limit, **filters)
def search_capacitors(self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]: def search_capacitors(
self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100
) -> List[Dict]:
""" """
Search for capacitors Search for capacitors
@@ -141,9 +137,7 @@ class JLCSearchClient:
return None return None
def download_all_components( def download_all_components(
self, self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
callback: Optional[Callable[[int, str], None]] = None,
batch_size: int = 100
) -> List[Dict]: ) -> List[Dict]:
""" """
Download all components from jlcsearch database Download all components from jlcsearch database
@@ -165,11 +159,7 @@ class JLCSearchClient:
while True: while True:
try: try:
batch = self.search_components( batch = self.search_components("components", limit=batch_size, offset=offset)
"components",
limit=batch_size,
offset=offset
)
# Stop if no results returned (end of catalog) # Stop if no results returned (end of catalog)
if not batch or len(batch) == 0: if not batch or len(batch) == 0:
@@ -219,7 +209,7 @@ def test_jlcsearch_connection() -> bool:
return False return False
if __name__ == '__main__': if __name__ == "__main__":
# Test the JLCSearch client # Test the JLCSearch client
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)

View File

@@ -116,9 +116,7 @@ class LibraryManager:
self.libraries[nickname] = resolved_uri self.libraries[nickname] = resolved_uri
logger.debug(f" Found library: {nickname} -> {resolved_uri}") logger.debug(f" Found library: {nickname} -> {resolved_uri}")
else: else:
logger.warning( logger.warning(f" Could not resolve URI for library {nickname}: {uri}")
f" Could not resolve URI for library {nickname}: {uri}"
)
except Exception as e: except Exception as e:
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}") logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
@@ -221,12 +219,7 @@ class LibraryManager:
/ "9.0" / "9.0"
/ "kicad_common.json", # macOS / "kicad_common.json", # macOS
Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux
Path.home() Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows
/ "AppData"
/ "Roaming"
/ "kicad"
/ "9.0"
/ "kicad_common.json", # Windows
] ]
for config_path in kicad_common_paths: for config_path in kicad_common_paths:
@@ -352,9 +345,7 @@ class LibraryManager:
for library_nickname, library_path in self.libraries.items(): for library_nickname, library_path in self.libraries.items():
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod" fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
if fp_file.exists(): if fp_file.exists():
logger.info( logger.info(f"Found footprint {footprint_name} in library {library_nickname}")
f"Found footprint {footprint_name} in library {library_nickname}"
)
return (library_path, footprint_name) return (library_path, footprint_name)
logger.warning(f"Footprint not found in any library: {footprint_name}") logger.warning(f"Footprint not found in any library: {footprint_name}")
@@ -461,9 +452,7 @@ class LibraryCommands:
# Filter by library if specified # Filter by library if specified
if library_filter: if library_filter:
results = [ results = [
r r for r in results if r.get("library", "").lower() == library_filter.lower()
for r in results
if r.get("library", "").lower() == library_filter.lower()
] ]
results = results[:limit] results = results[:limit]
@@ -527,7 +516,6 @@ class LibraryCommands:
info: Dict = { info: Dict = {
"library": library_nickname, "library": library_nickname,
"name": footprint_name, "name": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}", "full_name": f"{library_nickname}:{footprint_name}",
"library_path": library_path, "library_path": library_path,
} }
@@ -536,19 +524,24 @@ class LibraryCommands:
try: try:
from parsers.kicad_mod_parser import parse_kicad_mod from parsers.kicad_mod_parser import parse_kicad_mod
from pathlib import Path as _Path from pathlib import Path as _Path
mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod") mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod")
parsed = parse_kicad_mod(mod_file) parsed = parse_kicad_mod(mod_file)
if parsed: if parsed:
# Merge parser output into info; keep our resolved library context # Merge parser output into info; keep our resolved library context
info.update(parsed) info.update(parsed)
info["name"] = footprint_name # entry name wins over in-file name info["name"] = footprint_name # entry name wins over in-file name
info["library"] = library_nickname info["library"] = library_nickname
info["full_name"] = f"{library_nickname}:{footprint_name}" info["full_name"] = f"{library_nickname}:{footprint_name}"
info["library_path"] = library_path info["library_path"] = library_path
else: else:
logger.warning(f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info") logger.warning(
f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info"
)
except Exception as parse_err: except Exception as parse_err:
logger.warning(f"get_footprint_info: parser error ({parse_err}), using minimal info") logger.warning(
f"get_footprint_info: parser error ({parse_err}), using minimal info"
)
return {"success": True, "info": info} return {"success": True, "info": info}

View File

@@ -1,8 +1,10 @@
from skip import Schematic from skip import Schematic
# Symbol class might not be directly importable in the current version # Symbol class might not be directly importable in the current version
import os import os
import glob import glob
class LibraryManager: class LibraryManager:
"""Manage symbol libraries""" """Manage symbol libraries"""
@@ -14,9 +16,11 @@ class LibraryManager:
# This would need to be configured for the specific environment # This would need to be configured for the specific environment
search_paths = [ search_paths = [
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern "C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern "/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern
os.path.expanduser("~/Documents/KiCad/*/symbols/*.kicad_sym") # User libraries pattern os.path.expanduser(
"~/Documents/KiCad/*/symbols/*.kicad_sym"
), # User libraries pattern
] ]
libraries = [] libraries = []
@@ -30,7 +34,9 @@ class LibraryManager:
# Extract library names from paths # Extract library names from paths
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries] library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
print(f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}") print(
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
)
# Return both full paths and library names # Return both full paths and library names
return {"paths": libraries, "names": library_names} return {"paths": libraries, "names": library_names}
@@ -47,7 +53,9 @@ class LibraryManager:
# A potential approach would be to load the library file using KiCAD's Python API # A potential approach would be to load the library file using KiCAD's Python API
# or by parsing the library file format. # or by parsing the library file format.
# KiCAD symbol libraries are .kicad_sym files which are S-expression format # KiCAD symbol libraries are .kicad_sym files which are S-expression format
print(f"Attempted to list symbols in library {library_path}. This requires advanced implementation.") print(
f"Attempted to list symbols in library {library_path}. This requires advanced implementation."
)
return [] return []
except Exception as e: except Exception as e:
print(f"Error listing symbols in library {library_path}: {e}") print(f"Error listing symbols in library {library_path}: {e}")
@@ -59,7 +67,9 @@ class LibraryManager:
try: try:
# Similar to list_library_symbols, this might require a more direct approach # Similar to list_library_symbols, this might require a more direct approach
# using KiCAD's Python API or by parsing the symbol library. # using KiCAD's Python API or by parsing the symbol library.
print(f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation.") print(
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
)
return {} return {}
except Exception as e: except Exception as e:
print(f"Error getting symbol details for {symbol_name} in {library_path}: {e}") print(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
@@ -78,7 +88,9 @@ class LibraryManager:
libraries = LibraryManager.list_available_libraries(search_paths) libraries = LibraryManager.list_available_libraries(search_paths)
results = [] results = []
print(f"Searched for symbols matching '{query}'. This requires advanced implementation.") print(
f"Searched for symbols matching '{query}'. This requires advanced implementation."
)
return results return results
except Exception as e: except Exception as e:
print(f"Error searching for symbols matching '{query}': {e}") print(f"Error searching for symbols matching '{query}': {e}")
@@ -119,7 +131,8 @@ class LibraryManager:
# Default fallback # Default fallback
return {"library": "Device", "symbol": "R"} return {"library": "Device", "symbol": "R"}
if __name__ == '__main__':
if __name__ == "__main__":
# Example Usage (for testing) # Example Usage (for testing)
# List available libraries # List available libraries
libraries = LibraryManager.list_available_libraries() libraries = LibraryManager.list_available_libraries()

View File

@@ -12,26 +12,27 @@ from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
@dataclass @dataclass
class SymbolInfo: class SymbolInfo:
"""Information about a symbol in a library""" """Information about a symbol in a library"""
name: str # Symbol name (without library prefix)
library: str # Library nickname name: str # Symbol name (without library prefix)
full_ref: str # "Library:SymbolName" library: str # Library nickname
value: str = "" # Value property full_ref: str # "Library:SymbolName"
description: str = "" # Description property value: str = "" # Value property
footprint: str = "" # Footprint reference if present description: str = "" # Description property
lcsc_id: str = "" # LCSC property if present footprint: str = "" # Footprint reference if present
lcsc_id: str = "" # LCSC property if present
manufacturer: str = "" # Manufacturer property manufacturer: str = "" # Manufacturer property
mpn: str = "" # Part/MPN property mpn: str = "" # Part/MPN property
category: str = "" # Category property category: str = "" # Category property
datasheet: str = "" # Datasheet URL datasheet: str = "" # Datasheet URL
stock: str = "" # Stock (from JLCPCB libs) stock: str = "" # Stock (from JLCPCB libs)
price: str = "" # Price (from JLCPCB libs) price: str = "" # Price (from JLCPCB libs)
lib_class: str = "" # Basic/Preferred/Extended lib_class: str = "" # Basic/Preferred/Extended
class SymbolLibraryManager: class SymbolLibraryManager:
@@ -107,7 +108,7 @@ class SymbolLibraryManager:
) )
""" """
try: try:
with open(table_path, 'r', encoding='utf-8') as f: with open(table_path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
# Simple regex-based parser for lib entries # Simple regex-based parser for lib entries
@@ -155,25 +156,25 @@ class SymbolLibraryManager:
# Common KiCAD environment variables # Common KiCAD environment variables
env_vars = { env_vars = {
'KICAD10_SYMBOL_DIR': self._find_kicad_symbol_dir(), "KICAD10_SYMBOL_DIR": self._find_kicad_symbol_dir(),
'KICAD9_SYMBOL_DIR': self._find_kicad_symbol_dir(), "KICAD9_SYMBOL_DIR": self._find_kicad_symbol_dir(),
'KICAD8_SYMBOL_DIR': self._find_kicad_symbol_dir(), "KICAD8_SYMBOL_DIR": self._find_kicad_symbol_dir(),
'KICAD_SYMBOL_DIR': self._find_kicad_symbol_dir(), "KICAD_SYMBOL_DIR": self._find_kicad_symbol_dir(),
'KICAD10_3RD_PARTY': self._find_3rd_party_dir(), "KICAD10_3RD_PARTY": self._find_3rd_party_dir(),
'KICAD9_3RD_PARTY': self._find_3rd_party_dir(), "KICAD9_3RD_PARTY": self._find_3rd_party_dir(),
'KICAD8_3RD_PARTY': self._find_3rd_party_dir(), "KICAD8_3RD_PARTY": self._find_3rd_party_dir(),
'KISYSSYM': self._find_kicad_symbol_dir(), "KISYSSYM": self._find_kicad_symbol_dir(),
} }
# Project directory # Project directory
if self.project_path: if self.project_path:
env_vars['KIPRJMOD'] = str(self.project_path) env_vars["KIPRJMOD"] = str(self.project_path)
# Replace environment variables # Replace environment variables
for var, value in env_vars.items(): for var, value in env_vars.items():
if value: 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 # Expand ~ to home directory
resolved = os.path.expanduser(resolved) resolved = os.path.expanduser(resolved)
@@ -199,10 +200,10 @@ class SymbolLibraryManager:
] ]
# Check environment variable # Check environment variable
if 'KICAD9_SYMBOL_DIR' in os.environ: if "KICAD9_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ['KICAD9_SYMBOL_DIR']) possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"])
if 'KICAD8_SYMBOL_DIR' in os.environ: if "KICAD8_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ['KICAD8_SYMBOL_DIR']) possible_paths.insert(0, os.environ["KICAD8_SYMBOL_DIR"])
for path in possible_paths: for path in possible_paths:
if os.path.isdir(path): if os.path.isdir(path):
@@ -219,12 +220,12 @@ class SymbolLibraryManager:
] ]
# Check environment variable # Check environment variable
if 'KICAD10_3RD_PARTY' in os.environ: if "KICAD10_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ['KICAD10_3RD_PARTY']) possible_paths.insert(0, os.environ["KICAD10_3RD_PARTY"])
if 'KICAD9_3RD_PARTY' in os.environ: if "KICAD9_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ['KICAD9_3RD_PARTY']) possible_paths.insert(0, os.environ["KICAD9_3RD_PARTY"])
if 'KICAD8_3RD_PARTY' in os.environ: if "KICAD8_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ['KICAD8_3RD_PARTY']) possible_paths.insert(0, os.environ["KICAD8_3RD_PARTY"])
for path in possible_paths: for path in possible_paths:
if os.path.isdir(path): if os.path.isdir(path):
@@ -246,7 +247,7 @@ class SymbolLibraryManager:
symbols = [] symbols = []
try: try:
with open(library_path, 'r', encoding='utf-8') as f: with open(library_path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
# Find all top-level symbol definitions # Find all top-level symbol definitions
@@ -261,7 +262,7 @@ class SymbolLibraryManager:
symbol_name = match.group(1) symbol_name = match.group(1)
# Skip sub-symbols (they contain _0_, _1_, etc. suffixes) # Skip sub-symbols (they contain _0_, _1_, etc. suffixes)
if re.search(r'_\d+_\d+$', symbol_name): if re.search(r"_\d+_\d+$", symbol_name):
continue continue
# Find the start position of this symbol # Find the start position of this symbol
@@ -280,17 +281,17 @@ class SymbolLibraryManager:
name=symbol_name, name=symbol_name,
library=library_name, library=library_name,
full_ref=f"{library_name}:{symbol_name}", full_ref=f"{library_name}:{symbol_name}",
value=properties.get('Value', ''), value=properties.get("Value", ""),
description=properties.get('Description', ''), description=properties.get("Description", ""),
footprint=properties.get('Footprint', ''), footprint=properties.get("Footprint", ""),
lcsc_id=properties.get('LCSC', ''), lcsc_id=properties.get("LCSC", ""),
manufacturer=properties.get('Manufacturer', ''), manufacturer=properties.get("Manufacturer", ""),
mpn=properties.get('Part', properties.get('MPN', '')), mpn=properties.get("Part", properties.get("MPN", "")),
category=properties.get('Category', ''), category=properties.get("Category", ""),
datasheet=properties.get('Datasheet', ''), datasheet=properties.get("Datasheet", ""),
stock=properties.get('Stock', ''), stock=properties.get("Stock", ""),
price=properties.get('Price', ''), price=properties.get("Price", ""),
lib_class=properties.get('Class', ''), lib_class=properties.get("Class", ""),
) )
symbols.append(symbol_info) symbols.append(symbol_info)
@@ -351,7 +352,9 @@ class SymbolLibraryManager:
return symbols return symbols
def search_symbols(self, query: str, limit: int = 20, library_filter: Optional[str] = None) -> List[SymbolInfo]: def search_symbols(
self, query: str, limit: int = 20, library_filter: Optional[str] = None
) -> List[SymbolInfo]:
""" """
Search for symbols matching a query Search for symbols matching a query
@@ -370,7 +373,9 @@ class SymbolLibraryManager:
libraries_to_search = self.libraries.keys() libraries_to_search = self.libraries.keys()
if library_filter: if library_filter:
filter_lower = library_filter.lower() filter_lower = library_filter.lower()
libraries_to_search = [lib for lib in libraries_to_search if filter_lower in lib.lower()] libraries_to_search = [
lib for lib in libraries_to_search if filter_lower in lib.lower()
]
for library_nickname in libraries_to_search: for library_nickname in libraries_to_search:
symbols = self.list_symbols(library_nickname) symbols = self.list_symbols(library_nickname)
@@ -495,17 +500,13 @@ class SymbolLibraryCommands:
"""List all available symbol libraries""" """List all available symbol libraries"""
try: try:
libraries = self.library_manager.list_libraries() libraries = self.library_manager.list_libraries()
return { return {"success": True, "libraries": libraries, "count": len(libraries)}
"success": True,
"libraries": libraries,
"count": len(libraries)
}
except Exception as e: except Exception as e:
logger.error(f"Error listing symbol libraries: {e}") logger.error(f"Error listing symbol libraries: {e}")
return { return {
"success": False, "success": False,
"message": "Failed to list symbol libraries", "message": "Failed to list symbol libraries",
"errorDetails": str(e) "errorDetails": str(e),
} }
def search_symbols(self, params: Dict) -> Dict: def search_symbols(self, params: Dict) -> Dict:
@@ -513,10 +514,7 @@ class SymbolLibraryCommands:
try: try:
query = params.get("query", "") query = params.get("query", "")
if not query: if not query:
return { return {"success": False, "message": "Missing query parameter"}
"success": False,
"message": "Missing query parameter"
}
limit = params.get("limit", 20) limit = params.get("limit", 20)
library_filter = params.get("library") library_filter = params.get("library")
@@ -527,25 +525,18 @@ class SymbolLibraryCommands:
"success": True, "success": True,
"symbols": [asdict(s) for s in results], "symbols": [asdict(s) for s in results],
"count": len(results), "count": len(results),
"query": query "query": query,
} }
except Exception as e: except Exception as e:
logger.error(f"Error searching symbols: {e}") logger.error(f"Error searching symbols: {e}")
return { return {"success": False, "message": "Failed to search symbols", "errorDetails": str(e)}
"success": False,
"message": "Failed to search symbols",
"errorDetails": str(e)
}
def list_library_symbols(self, params: Dict) -> Dict: def list_library_symbols(self, params: Dict) -> Dict:
"""List all symbols in a specific library""" """List all symbols in a specific library"""
try: try:
library = params.get("library") library = params.get("library")
if not library: if not library:
return { return {"success": False, "message": "Missing library parameter"}
"success": False,
"message": "Missing library parameter"
}
# Check if library exists in sym-lib-table # Check if library exists in sym-lib-table
if library not in self.library_manager.libraries: if library not in self.library_manager.libraries:
@@ -554,10 +545,10 @@ class SymbolLibraryCommands:
"success": False, "success": False,
"message": f"Library '{library}' not found in sym-lib-table", "message": f"Library '{library}' not found in sym-lib-table",
"errorDetails": f"Library '{library}' is not registered in your KiCad symbol library table. " "errorDetails": f"Library '{library}' is not registered in your KiCad symbol library table. "
f"Found {len(available_libs)} libraries. " f"Found {len(available_libs)} libraries. "
f"Please add this library to your sym-lib-table file, or use one of the available libraries.", f"Please add this library to your sym-lib-table file, or use one of the available libraries.",
"available_libraries_count": len(available_libs), "available_libraries_count": len(available_libs),
"suggestion": "Use 'list_symbol_libraries' to see all available libraries" "suggestion": "Use 'list_symbol_libraries' to see all available libraries",
} }
symbols = self.library_manager.list_symbols(library) symbols = self.library_manager.list_symbols(library)
@@ -566,14 +557,14 @@ class SymbolLibraryCommands:
"success": True, "success": True,
"library": library, "library": library,
"symbols": [asdict(s) for s in symbols], "symbols": [asdict(s) for s in symbols],
"count": len(symbols) "count": len(symbols),
} }
except Exception as e: except Exception as e:
logger.error(f"Error listing library symbols: {e}") logger.error(f"Error listing library symbols: {e}")
return { return {
"success": False, "success": False,
"message": "Failed to list library symbols", "message": "Failed to list library symbols",
"errorDetails": str(e) "errorDetails": str(e),
} }
def get_symbol_info(self, params: Dict) -> Dict: def get_symbol_info(self, params: Dict) -> Dict:
@@ -581,34 +572,25 @@ class SymbolLibraryCommands:
try: try:
symbol_spec = params.get("symbol") symbol_spec = params.get("symbol")
if not symbol_spec: if not symbol_spec:
return { return {"success": False, "message": "Missing symbol parameter"}
"success": False,
"message": "Missing symbol parameter"
}
result = self.library_manager.find_symbol(symbol_spec) result = self.library_manager.find_symbol(symbol_spec)
if result: if result:
return { return {"success": True, "symbol_info": asdict(result)}
"success": True,
"symbol_info": asdict(result)
}
else: else:
return { return {"success": False, "message": f"Symbol not found: {symbol_spec}"}
"success": False,
"message": f"Symbol not found: {symbol_spec}"
}
except Exception as e: except Exception as e:
logger.error(f"Error getting symbol info: {e}") logger.error(f"Error getting symbol info: {e}")
return { return {
"success": False, "success": False,
"message": "Failed to get symbol info", "message": "Failed to get symbol info",
"errorDetails": str(e) "errorDetails": str(e),
} }
if __name__ == '__main__': if __name__ == "__main__":
# Test the symbol library manager # Test the symbol library manager
import json import json

View File

@@ -117,11 +117,7 @@ class PinLocator:
# Find lib_symbols section # Find lib_symbols section
lib_symbols = None lib_symbols = None
for item in sch_data: for item in sch_data:
if ( if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
lib_symbols = item lib_symbols = item
break break
@@ -131,11 +127,7 @@ class PinLocator:
# Find the specific symbol definition # Find the specific symbol definition
for item in lib_symbols[1:]: # Skip 'lib_symbols' itself for item in lib_symbols[1:]: # Skip 'lib_symbols' itself
if ( if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
isinstance(item, list)
and len(item) > 1
and item[0] == Symbol("symbol")
):
symbol_name = str(item[1]).strip('"') symbol_name = str(item[1]).strip('"')
if symbol_name == lib_id: if symbol_name == lib_id:
# Found the symbol, parse pins # Found the symbol, parse pins
@@ -220,20 +212,14 @@ class PinLocator:
symbol_at = target_symbol.at.value symbol_at = target_symbol.at.value
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
lib_id = ( lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
if not lib_id: if not lib_id:
return None return None
pins = self.get_symbol_pins(schematic_path, lib_id) pins = self.get_symbol_pins(schematic_path, lib_id)
if pin_number not in pins: if pin_number not in pins:
matched_num = next( matched_num = next(
( (num for num, data in pins.items() if data.get("name") == pin_number),
num
for num, data in pins.items()
if data.get("name") == pin_number
),
None, None,
) )
if matched_num: if matched_num:
@@ -290,9 +276,7 @@ class PinLocator:
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0 symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
# Get symbol lib_id # Get symbol lib_id
lib_id = ( lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
if not lib_id: if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id") logger.error(f"Symbol {symbol_reference} has no lib_id")
return None return None
@@ -311,11 +295,7 @@ class PinLocator:
if pin_number not in pins: if pin_number not in pins:
# Try matching by pin name (e.g. "VCC1", "SDA", "GND") # Try matching by pin name (e.g. "VCC1", "SDA", "GND")
matched_num = next( matched_num = next(
( (num for num, data in pins.items() if data.get("name") == pin_number),
num
for num, data in pins.items()
if data.get("name") == pin_number
),
None, None,
) )
if matched_num: if matched_num:
@@ -336,26 +316,18 @@ class PinLocator:
pin_rel_x = pin_data["x"] pin_rel_x = pin_data["x"]
pin_rel_y = pin_data["y"] pin_rel_y = pin_data["y"]
logger.debug( logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})")
f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})"
)
# Apply symbol rotation to pin position # Apply symbol rotation to pin position
if symbol_rotation != 0: if symbol_rotation != 0:
pin_rel_x, pin_rel_y = self.rotate_point( pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation)
pin_rel_x, pin_rel_y, symbol_rotation logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})")
)
logger.debug(
f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})"
)
# Calculate absolute position # Calculate absolute position
abs_x = symbol_x + pin_rel_x abs_x = symbol_x + pin_rel_x
abs_y = symbol_y + pin_rel_y abs_y = symbol_y + pin_rel_y
logger.info( logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})")
f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})"
)
return [abs_x, abs_y] return [abs_x, abs_y]
except Exception as e: except Exception as e:
@@ -397,9 +369,7 @@ class PinLocator:
return {} return {}
# Get lib_id # Get lib_id
lib_id = ( lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
if not lib_id: if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id") logger.error(f"Symbol {symbol_reference} has no lib_id")
return {} return {}
@@ -412,9 +382,7 @@ class PinLocator:
# Calculate location for each pin # Calculate location for each pin
result = {} result = {}
for pin_num in pins.keys(): for pin_num in pins.keys():
location = self.get_pin_location( location = self.get_pin_location(schematic_path, symbol_reference, pin_num)
schematic_path, symbol_reference, pin_num
)
if location: if location:
result[pin_num] = location result[pin_num] = location
@@ -444,9 +412,7 @@ if __name__ == "__main__":
# Create test schematic with components (cross-platform temp directory) # Create test schematic with components (cross-platform temp directory)
test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch" test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch"
template_path = ( template_path = (
Path(__file__).parent.parent Path(__file__).parent.parent / "templates" / "template_with_symbols_expanded.kicad_sch"
/ "templates"
/ "template_with_symbols_expanded.kicad_sch"
) )
shutil.copy(template_path, test_path) shutil.copy(template_path, test_path)

View File

@@ -22,9 +22,7 @@ class ProjectCommands:
"""Create a new KiCAD project""" """Create a new KiCAD project"""
try: try:
# Accept both 'name' (from MCP tool) and 'projectName' (legacy) # Accept both 'name' (from MCP tool) and 'projectName' (legacy)
project_name = params.get("name") or params.get( project_name = params.get("name") or params.get("projectName", "New_Project")
"projectName", "New_Project"
)
path = params.get("path", os.getcwd()) path = params.get("path", os.getcwd())
template = params.get("template") template = params.get("template")
@@ -101,9 +99,7 @@ class ProjectCommands:
schematic_uuid = str(uuid_module.uuid4()) schematic_uuid = str(uuid_module.uuid4())
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f: with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
f.write( f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write(f" (uuid {schematic_uuid})\n\n") f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n') f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n") f.write(" (lib_symbols\n )\n\n")
@@ -207,9 +203,7 @@ class ProjectCommands:
"success": True, "success": True,
"message": f"Saved project to: {self.board.GetFileName()}", "message": f"Saved project to: {self.board.GetFileName()}",
"project": { "project": {
"name": os.path.splitext( "name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
os.path.basename(self.board.GetFileName())
)[0],
"path": self.board.GetFileName(), "path": self.board.GetFileName(),
}, },
} }

View File

@@ -149,9 +149,9 @@ class RoutingCommands:
# KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects # KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects
# the actual placed layer after Flip(). # the actual placed layer after Flip().
fp_start = footprints[from_ref] fp_start = footprints[from_ref]
fp_end = footprints[to_ref] fp_end = footprints[to_ref]
start_layer = self.board.GetLayerName(fp_start.GetLayer()) start_layer = self.board.GetLayerName(fp_start.GetLayer())
end_layer = self.board.GetLayerName(fp_end.GetLayer()) end_layer = self.board.GetLayerName(fp_end.GetLayer())
copper_layers = {"F.Cu", "B.Cu"} copper_layers = {"F.Cu", "B.Cu"}
needs_via = ( needs_via = (
start_layer in copper_layers start_layer in copper_layers
@@ -168,24 +168,34 @@ class RoutingCommands:
via_y = (start_pos.y + end_pos.y) / 2 / scale via_y = (start_pos.y + end_pos.y) / 2 / scale
# Trace on start layer: start_pad → via # Trace on start layer: start_pad → via
r1 = self.route_trace({ r1 = self.route_trace(
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"}, {
"end": {"x": via_x, "y": via_y, "unit": "mm"}, "start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"layer": start_layer, "width": width, "net": net, "end": {"x": via_x, "y": via_y, "unit": "mm"},
}) "layer": start_layer,
"width": width,
"net": net,
}
)
# Via connecting both layers # Via connecting both layers
self.add_via({ self.add_via(
"position": {"x": via_x, "y": via_y, "unit": "mm"}, {
"net": net, "position": {"x": via_x, "y": via_y, "unit": "mm"},
"from_layer": start_layer, "net": net,
"to_layer": end_layer, "from_layer": start_layer,
}) "to_layer": end_layer,
}
)
# Trace on end layer: via → end_pad # Trace on end layer: via → end_pad
r2 = self.route_trace({ r2 = self.route_trace(
"start": {"x": via_x, "y": via_y, "unit": "mm"}, {
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"}, "start": {"x": via_x, "y": via_y, "unit": "mm"},
"layer": end_layer, "width": width, "net": net, "end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
}) "layer": end_layer,
"width": width,
"net": net,
}
)
success = r1.get("success") and r2.get("success") success = r1.get("success") and r2.get("success")
result = { result = {
"success": success, "success": success,
@@ -195,21 +205,28 @@ class RoutingCommands:
} }
else: else:
# Same layer — direct trace # Same layer — direct trace
result = self.route_trace({ result = self.route_trace(
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"}, {
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"}, "start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"layer": layer if layer else start_layer, "end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"width": width, "net": net, "layer": layer if layer else start_layer,
}) "width": width,
"net": net,
}
)
if result.get("success"): if result.get("success"):
result["fromPad"] = { result["fromPad"] = {
"ref": from_ref, "pad": from_pad, "ref": from_ref,
"x": start_pos.x / scale, "y": start_pos.y / scale, "pad": from_pad,
"x": start_pos.x / scale,
"y": start_pos.y / scale,
} }
result["toPad"] = { result["toPad"] = {
"ref": to_ref, "pad": to_pad, "ref": to_ref,
"x": end_pos.x / scale, "y": end_pos.y / scale, "pad": to_pad,
"x": end_pos.x / scale,
"y": end_pos.y / scale,
} }
return result return result
@@ -352,21 +369,15 @@ class RoutingCommands:
via = pcbnew.PCB_VIA(self.board) via = pcbnew.PCB_VIA(self.board)
# Set position # Set position
scale = ( scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
1000000 if position["unit"] == "mm" else 25400000
) # mm or inch to nm
x_nm = int(position["x"] * scale) x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale) y_nm = int(position["y"] * scale)
via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm)) via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
# Set size and drill (default to board's current via settings) # Set size and drill (default to board's current via settings)
design_settings = self.board.GetDesignSettings() design_settings = self.board.GetDesignSettings()
via.SetWidth( via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize())
int(size * 1000000) if size else design_settings.GetCurrentViaSize() via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill())
)
via.SetDrill(
int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill()
)
# Set layers # Set layers
from_id = self.board.GetLayerID(from_layer) from_id = self.board.GetLayerID(from_layer)
@@ -500,9 +511,7 @@ class RoutingCommands:
# Find track by position # Find track by position
if position: if position:
scale = ( scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
1000000 if position["unit"] == "mm" else 25400000
) # mm or inch to nm
x_nm = int(position["x"] * scale) x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale) y_nm = int(position["y"] * scale)
point = pcbnew.VECTOR2I(x_nm, y_nm) point = pcbnew.VECTOR2I(x_nm, y_nm)
@@ -940,9 +949,7 @@ class RoutingCommands:
else: else:
traces_to_copy.append(track) traces_to_copy.append(track)
filter_method = ( filter_method = "net-based" if use_net_filter else "geometric (pads have no nets)"
"net-based" if use_net_filter else "geometric (pads have no nets)"
)
logger.info( logger.info(
f"copy_routing_pattern: {len(traces_to_copy)} traces, " f"copy_routing_pattern: {len(traces_to_copy)} traces, "
f"{len(vias_to_copy)} vias selected via {filter_method}" f"{len(vias_to_copy)} vias selected via {filter_method}"
@@ -958,9 +965,7 @@ class RoutingCommands:
# Create new track # Create new track
new_track = pcbnew.PCB_TRACK(self.board) new_track = pcbnew.PCB_TRACK(self.board)
new_track.SetStart( new_track.SetStart(pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y))
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.SetEnd(pcbnew.VECTOR2I(end.x + offset_x, end.y + offset_y))
new_track.SetLayer(track.GetLayer()) new_track.SetLayer(track.GetLayer())
@@ -1320,15 +1325,11 @@ class RoutingCommands:
pos_start = pcbnew.VECTOR2I( pos_start = pcbnew.VECTOR2I(
int(start_point.x + offset_x), int(start_point.y + offset_y) int(start_point.x + offset_x), int(start_point.y + offset_y)
) )
pos_end = pcbnew.VECTOR2I( pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y))
int(end_point.x + offset_x), int(end_point.y + offset_y)
)
neg_start = pcbnew.VECTOR2I( neg_start = pcbnew.VECTOR2I(
int(start_point.x - offset_x), int(start_point.y - offset_y) int(start_point.x - offset_x), int(start_point.y - offset_y)
) )
neg_end = pcbnew.VECTOR2I( neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y))
int(end_point.x - offset_x), int(end_point.y - offset_y)
)
# Create positive trace # Create positive trace
pos_track = pcbnew.PCB_TRACK(self.board) pos_track = pcbnew.PCB_TRACK(self.board)
@@ -1395,9 +1396,7 @@ class RoutingCommands:
return pad.GetPosition() return pad.GetPosition()
raise ValueError("Invalid point specification") raise ValueError("Invalid point specification")
def _point_to_track_distance( def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float:
self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK
) -> float:
"""Calculate distance from point to track segment""" """Calculate distance from point to track segment"""
start = track.GetStart() start = track.GetStart()
end = track.GetEnd() end = track.GetEnd()

View File

@@ -31,31 +31,28 @@ class SchematicManager:
# Regenerate UUID to ensure uniqueness for each created schematic # Regenerate UUID to ensure uniqueness for each created schematic
import re import re
with open(output_path, 'r', encoding='utf-8') as f:
with open(output_path, "r", encoding="utf-8") as f:
content = f.read() content = f.read()
new_uuid = str(uuid.uuid4()) new_uuid = str(uuid.uuid4())
content = re.sub( content = re.sub(
r'\(uuid [0-9a-fA-F-]+\)', r"\(uuid [0-9a-fA-F-]+\)",
f'(uuid {new_uuid})', f"(uuid {new_uuid})",
content, content,
count=1 # Only replace first (schematic) UUID count=1, # Only replace first (schematic) UUID
) )
with open(output_path, 'w', encoding='utf-8', newline='\n') as f: with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(content) f.write(content)
logger.info(f"Created schematic from template: {output_path}") logger.info(f"Created schematic from template: {output_path}")
else: else:
# Fallback: create minimal schematic # Fallback: create minimal schematic
logger.warning( logger.warning(f"Template not found at {template_path}, creating minimal schematic")
f"Template not found at {template_path}, creating minimal schematic"
)
# Generate unique UUID for this schematic # Generate unique UUID for this schematic
schematic_uuid = str(uuid.uuid4()) schematic_uuid = str(uuid.uuid4())
# Write with explicit UTF-8 encoding and Unix line endings for cross-platform compatibility # Write with explicit UTF-8 encoding and Unix line endings for cross-platform compatibility
with open(output_path, "w", encoding="utf-8", newline="\n") as f: with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write( f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write(f" (uuid {schematic_uuid})\n\n") f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n') f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n") f.write(" (lib_symbols\n )\n\n")

View File

@@ -169,28 +169,16 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
for sub in sexp[1:]: for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]: for pt in sub[1:]:
if ( if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
isinstance(pt, list)
and len(pt) >= 3
and pt[0] == Symbol("xy")
):
points.append((float(pt[1]), float(pt[2]))) points.append((float(pt[1]), float(pt[2])))
elif tag == Symbol("circle"): elif tag == Symbol("circle"):
# (circle (center x y) (radius r) ...) # (circle (center x y) (radius r) ...)
cx, cy, r = 0.0, 0.0, 0.0 cx, cy, r = 0.0, 0.0, 0.0
for sub in sexp[1:]: for sub in sexp[1:]:
if ( if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"):
isinstance(sub, list)
and len(sub) >= 3
and sub[0] == Symbol("center")
):
cx, cy = float(sub[1]), float(sub[2]) cx, cy = float(sub[1]), float(sub[2])
elif ( elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"):
isinstance(sub, list)
and len(sub) >= 2
and sub[0] == Symbol("radius")
):
r = float(sub[1]) r = float(sub[1])
if r > 0: if r > 0:
points.extend( points.extend(
@@ -212,11 +200,7 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
for sub in sexp[1:]: for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"): if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]: for pt in sub[1:]:
if ( if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
isinstance(pt, list)
and len(pt) >= 3
and pt[0] == Symbol("xy")
):
points.append((float(pt[1]), float(pt[2]))) points.append((float(pt[1]), float(pt[2])))
else: else:
@@ -243,11 +227,7 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
""" """
lib_symbols_section = None lib_symbols_section = None
for item in sexp_data: for item in sexp_data:
if ( if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
lib_symbols_section = item lib_symbols_section = item
break break
@@ -465,9 +445,7 @@ def _compute_symbol_bbox_direct(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def find_overlapping_elements( def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]:
schematic_path: Path, tolerance: float = 0.5
) -> Dict[str, Any]:
""" """
Detect spatially overlapping symbols, wires, and labels. Detect spatially overlapping symbols, wires, and labels.
@@ -490,9 +468,7 @@ def find_overlapping_elements(
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) --- # --- Symbol-symbol overlap using bounding-box intersection (O(n²)) ---
non_template_symbols = [ non_template_symbols = [
s s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"]
for s in symbols
if not s["reference"].startswith("_TEMPLATE") and s["reference"]
] ]
# Pre-compute bounding boxes for all non-template symbols # Pre-compute bounding boxes for all non-template symbols
@@ -503,9 +479,7 @@ def find_overlapping_elements(
graphics_points = lib_data.get("graphics_points", []) graphics_points = lib_data.get("graphics_points", [])
bbox = None bbox = None
if pin_defs: if pin_defs:
bbox = _compute_symbol_bbox_direct( bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
sym, pin_defs, graphics_points=graphics_points
)
symbol_bboxes.append((sym, bbox)) symbol_bboxes.append((sym, bbox))
for i in range(len(symbol_bboxes)): for i in range(len(symbol_bboxes)):
@@ -706,9 +680,7 @@ def get_elements_in_region(
if ( if (
_point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y) _point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y)
or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y) or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y)
or _line_segment_intersects_aabb( or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y)
s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y
)
): ):
region_wires.append( region_wires.append(
{ {
@@ -877,15 +849,11 @@ def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
ux, uy = dx / length, dy / length ux, uy = dx / length, dy / length
if start_at_pin: if start_at_pin:
nsx, nsy = sx + ux * nudge, sy + uy * nudge nsx, nsy = sx + ux * nudge, sy + uy * nudge
if not _line_segment_intersects_aabb( if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2):
nsx, nsy, ex, ey, bx1, by1, bx2, by2
):
continue # Wire terminates at pin from outside continue # Wire terminates at pin from outside
else: else:
nex, ney = ex - ux * nudge, ey - uy * nudge nex, ney = ex - ux * nudge, ey - uy * nudge
if not _line_segment_intersects_aabb( if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2):
sx, sy, nex, ney, bx1, by1, bx2, by2
):
continue # Wire terminates at pin from outside continue # Wire terminates at pin from outside
sym = sd["sym"] sym = sd["sym"]

View File

@@ -34,9 +34,7 @@ Polygon = List[Point]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SVG path tokenizer # SVG path tokenizer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_TOKEN_RE = re.compile( _TOKEN_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)")
r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)"
)
def _tokenize_path(d: str) -> List[str]: def _tokenize_path(d: str) -> List[str]:
@@ -57,9 +55,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
""" """
polygons: List[Polygon] = [] polygons: List[Polygon] = []
current: Polygon = [] current: Polygon = []
cx, cy = 0.0, 0.0 # current point cx, cy = 0.0, 0.0 # current point
sx, sy = 0.0, 0.0 # subpath start sx, sy = 0.0, 0.0 # subpath start
last_ctrl = None # last bezier control point (for S/T commands) last_ctrl = None # last bezier control point (for S/T commands)
last_cmd = "" last_cmd = ""
i = 0 i = 0
@@ -73,13 +71,15 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
i += n i += n
return vals return vals
def cubic_bezier_points(p0: Point, p1: Point, p2: Point, p3: Point, steps: int = 16) -> List[Point]: def cubic_bezier_points(
p0: Point, p1: Point, p2: Point, p3: Point, steps: int = 16
) -> List[Point]:
pts = [] pts = []
for k in range(1, steps + 1): for k in range(1, steps + 1):
t = k / steps t = k / steps
mt = 1 - t mt = 1 - t
x = mt**3*p0[0] + 3*mt**2*t*p1[0] + 3*mt*t**2*p2[0] + t**3*p3[0] x = mt**3 * p0[0] + 3 * mt**2 * t * p1[0] + 3 * mt * t**2 * p2[0] + t**3 * p3[0]
y = mt**3*p0[1] + 3*mt**2*t*p1[1] + 3*mt*t**2*p2[1] + t**3*p3[1] y = mt**3 * p0[1] + 3 * mt**2 * t * p1[1] + 3 * mt * t**2 * p2[1] + t**3 * p3[1]
pts.append((x, y)) pts.append((x, y))
return pts return pts
@@ -88,13 +88,23 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
for k in range(1, steps + 1): for k in range(1, steps + 1):
t = k / steps t = k / steps
mt = 1 - t mt = 1 - t
x = mt**2*p0[0] + 2*mt*t*p1[0] + t**2*p2[0] x = mt**2 * p0[0] + 2 * mt * t * p1[0] + t**2 * p2[0]
y = mt**2*p0[1] + 2*mt*t*p1[1] + t**2*p2[1] y = mt**2 * p0[1] + 2 * mt * t * p1[1] + t**2 * p2[1]
pts.append((x, y)) pts.append((x, y))
return pts return pts
def arc_points(x1: float, y1: float, rx: float, ry: float, phi_deg: float, def arc_points(
large_arc: int, sweep: int, x2: float, y2: float, steps: int = 20) -> List[Point]: x1: float,
y1: float,
rx: float,
ry: float,
phi_deg: float,
large_arc: int,
sweep: int,
x2: float,
y2: float,
steps: int = 20,
) -> List[Point]:
"""Approximate SVG arc as polygon points (endpoint parameterization → centre).""" """Approximate SVG arc as polygon points (endpoint parameterization → centre)."""
if rx == 0 or ry == 0: if rx == 0 or ry == 0:
return [(x2, y2)] return [(x2, y2)]
@@ -104,13 +114,13 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
x1p = cos_phi * dx + sin_phi * dy x1p = cos_phi * dx + sin_phi * dy
y1p = -sin_phi * dx + cos_phi * dy y1p = -sin_phi * dx + cos_phi * dy
rx, ry = abs(rx), abs(ry) rx, ry = abs(rx), abs(ry)
lam = (x1p / rx)**2 + (y1p / ry)**2 lam = (x1p / rx) ** 2 + (y1p / ry) ** 2
if lam > 1: if lam > 1:
lam = math.sqrt(lam) lam = math.sqrt(lam)
rx *= lam rx *= lam
ry *= lam ry *= lam
num = max(0.0, (rx*ry)**2 - (rx*y1p)**2 - (ry*x1p)**2) num = max(0.0, (rx * ry) ** 2 - (rx * y1p) ** 2 - (ry * x1p) ** 2)
den = (rx*y1p)**2 + (ry*x1p)**2 den = (rx * y1p) ** 2 + (ry * x1p) ** 2
sq = math.sqrt(num / den) if den != 0 else 0 sq = math.sqrt(num / den) if den != 0 else 0
if large_arc == sweep: if large_arc == sweep:
sq = -sq sq = -sq
@@ -120,8 +130,10 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2 cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
def angle(ux, uy, vx, vy): def angle(ux, uy, vx, vy):
a = math.acos(max(-1, min(1, (ux*vx + uy*vy) / (math.hypot(ux, uy) * math.hypot(vx, vy))))) a = math.acos(
if ux*vy - uy*vx < 0: max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy))))
)
if ux * vy - uy * vx < 0:
a = -a a = -a
return a return a
@@ -144,8 +156,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
# --- main loop --- # --- main loop ---
while i < len(tokens): while i < len(tokens):
tok = tokens[i] tok = tokens[i]
if tok.lstrip('+-').replace('.', '', 1).replace('e', '', 1).replace('E', '', 1).lstrip('+-').isdigit() or \ if tok.lstrip("+-").replace(".", "", 1).replace("e", "", 1).replace("E", "", 1).lstrip(
re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', tok): "+-"
).isdigit() or re.match(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", tok):
# implicit repeat of last command # implicit repeat of last command
pass pass
else: else:
@@ -155,7 +168,7 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
rel = cmd.islower() rel = cmd.islower()
if cmd in ('M', 'm'): if cmd in ("M", "m"):
x, y = consume(2) x, y = consume(2)
if rel: if rel:
cx, cy = cx + x, cy + y cx, cy = cx + x, cy + y
@@ -166,9 +179,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
current = [(cx, cy)] current = [(cx, cy)]
sx, sy = cx, cy sx, sy = cx, cy
# subsequent coordinates are implicit L/l # subsequent coordinates are implicit L/l
cmd = 'l' if rel else 'L' cmd = "l" if rel else "L"
elif cmd in ('L', 'l'): elif cmd in ("L", "l"):
x, y = consume(2) x, y = consume(2)
if rel: if rel:
cx, cy = cx + x, cy + y cx, cy = cx + x, cy + y
@@ -176,36 +189,46 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
cx, cy = x, y cx, cy = x, y
current.append((cx, cy)) current.append((cx, cy))
elif cmd in ('H', 'h'): elif cmd in ("H", "h"):
x = float(tokens[i]); i += 1 x = float(tokens[i])
i += 1
cx = cx + x if rel else x cx = cx + x if rel else x
current.append((cx, cy)) current.append((cx, cy))
elif cmd in ('V', 'v'): elif cmd in ("V", "v"):
y = float(tokens[i]); i += 1 y = float(tokens[i])
i += 1
cy = cy + y if rel else y cy = cy + y if rel else y
current.append((cx, cy)) current.append((cx, cy))
elif cmd in ('Z', 'z'): elif cmd in ("Z", "z"):
current.append((sx, sy)) # close current.append((sx, sy)) # close
polygons.append(current) polygons.append(current)
current = [] current = []
cx, cy = sx, sy cx, cy = sx, sy
elif cmd in ('C', 'c'): elif cmd in ("C", "c"):
x1, y1, x2, y2, x, y = consume(6) x1, y1, x2, y2, x, y = consume(6)
if rel: if rel:
x1 += cx; y1 += cy; x2 += cx; y2 += cy; x += cx; y += cy x1 += cx
y1 += cy
x2 += cx
y2 += cy
x += cx
y += cy
pts = cubic_bezier_points((cx, cy), (x1, y1), (x2, y2), (x, y)) pts = cubic_bezier_points((cx, cy), (x1, y1), (x2, y2), (x, y))
current.extend(pts) current.extend(pts)
last_ctrl = (x2, y2) last_ctrl = (x2, y2)
cx, cy = x, y cx, cy = x, y
elif cmd in ('S', 's'): elif cmd in ("S", "s"):
x2, y2, x, y = consume(4) x2, y2, x, y = consume(4)
if rel: if rel:
x2 += cx; y2 += cy; x += cx; y += cy x2 += cx
if last_ctrl and last_cmd in ('C', 'c', 'S', 's'): y2 += cy
x += cx
y += cy
if last_ctrl and last_cmd in ("C", "c", "S", "s"):
x1 = 2 * cx - last_ctrl[0] x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1] y1 = 2 * cy - last_ctrl[1]
else: else:
@@ -215,20 +238,24 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
last_ctrl = (x2, y2) last_ctrl = (x2, y2)
cx, cy = x, y cx, cy = x, y
elif cmd in ('Q', 'q'): elif cmd in ("Q", "q"):
x1, y1, x, y = consume(4) x1, y1, x, y = consume(4)
if rel: if rel:
x1 += cx; y1 += cy; x += cx; y += cy x1 += cx
y1 += cy
x += cx
y += cy
pts = quad_bezier_points((cx, cy), (x1, y1), (x, y)) pts = quad_bezier_points((cx, cy), (x1, y1), (x, y))
current.extend(pts) current.extend(pts)
last_ctrl = (x1, y1) last_ctrl = (x1, y1)
cx, cy = x, y cx, cy = x, y
elif cmd in ('T', 't'): elif cmd in ("T", "t"):
x, y = consume(2) x, y = consume(2)
if rel: if rel:
x += cx; y += cy x += cx
if last_ctrl and last_cmd in ('Q', 'q', 'T', 't'): y += cy
if last_ctrl and last_cmd in ("Q", "q", "T", "t"):
x1 = 2 * cx - last_ctrl[0] x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1] y1 = 2 * cy - last_ctrl[1]
else: else:
@@ -238,11 +265,12 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
last_ctrl = (x1, y1) last_ctrl = (x1, y1)
cx, cy = x, y cx, cy = x, y
elif cmd in ('A', 'a'): elif cmd in ("A", "a"):
rx, ry, phi, large, sweep, x, y = consume(7) rx, ry, phi, large, sweep, x, y = consume(7)
large, sweep = int(large), int(sweep) large, sweep = int(large), int(sweep)
if rel: if rel:
x += cx; y += cy x += cx
y += cy
pts = arc_points(cx, cy, rx, ry, phi, large, sweep, x, y) pts = arc_points(cx, cy, rx, ry, phi, large, sweep, x, y)
current.extend(pts) current.extend(pts)
cx, cy = x, y cx, cy = x, y
@@ -264,48 +292,45 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _parse_transform(transform_str: str) -> List[List[float]]: def _parse_transform(transform_str: str) -> List[List[float]]:
"""Parse SVG transform attribute, return list of 3×3 matrix rows [a,b,c; d,e,f; 0,0,1].""" """Parse SVG transform attribute, return list of 3×3 matrix rows [a,b,c; d,e,f; 0,0,1]."""
def identity(): def identity():
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]] return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def mat_mul(A, B): def mat_mul(A, B):
return [ return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
for r in range(3)
]
result = identity() result = identity()
for m in re.finditer( for m in re.finditer(
r'(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)', r"(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)", transform_str
transform_str
): ):
func = m.group(1) func = m.group(1)
args = [float(v) for v in re.split(r'[\s,]+', m.group(2).strip()) if v] args = [float(v) for v in re.split(r"[\s,]+", m.group(2).strip()) if v]
mat = identity() mat = identity()
if func == 'matrix' and len(args) == 6: if func == "matrix" and len(args) == 6:
a, b, c, d, e, f = args a, b, c, d, e, f = args
mat = [[a, c, e], [b, d, f], [0, 0, 1]] mat = [[a, c, e], [b, d, f], [0, 0, 1]]
elif func == 'translate': elif func == "translate":
tx = args[0] tx = args[0]
ty = args[1] if len(args) > 1 else 0 ty = args[1] if len(args) > 1 else 0
mat = [[1, 0, tx], [0, 1, ty], [0, 0, 1]] mat = [[1, 0, tx], [0, 1, ty], [0, 0, 1]]
elif func == 'scale': elif func == "scale":
sx = args[0] sx = args[0]
sy = args[1] if len(args) > 1 else sx sy = args[1] if len(args) > 1 else sx
mat = [[sx, 0, 0], [0, sy, 0], [0, 0, 1]] mat = [[sx, 0, 0], [0, sy, 0], [0, 0, 1]]
elif func == 'rotate': elif func == "rotate":
angle = math.radians(args[0]) angle = math.radians(args[0])
cos, sin = math.cos(angle), math.sin(angle) cos, sin = math.cos(angle), math.sin(angle)
if len(args) == 3: if len(args) == 3:
cx_, cy_ = args[1], args[2] cx_, cy_ = args[1], args[2]
t1 = [[1, 0, cx_], [0, 1, cy_], [0, 0, 1]] t1 = [[1, 0, cx_], [0, 1, cy_], [0, 0, 1]]
r = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]] r = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
t2 = [[1, 0, -cx_], [0, 1, -cy_], [0, 0, 1]] t2 = [[1, 0, -cx_], [0, 1, -cy_], [0, 0, 1]]
mat = mat_mul(mat_mul(t1, r), t2) mat = mat_mul(mat_mul(t1, r), t2)
else: else:
mat = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]] mat = [[cos, -sin, 0], [sin, cos, 0], [0, 0, 1]]
elif func == 'skewX': elif func == "skewX":
mat = [[1, math.tan(math.radians(args[0])), 0], [0, 1, 0], [0, 0, 1]] mat = [[1, math.tan(math.radians(args[0])), 0], [0, 1, 0], [0, 0, 1]]
elif func == 'skewY': elif func == "skewY":
mat = [[1, 0, 0], [math.tan(math.radians(args[0])), 1, 0], [0, 0, 1]] mat = [[1, 0, 0], [math.tan(math.radians(args[0])), 1, 0], [0, 0, 1]]
result = mat_mul(result, mat) result = mat_mul(result, mat)
return result return result
@@ -321,25 +346,22 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
def _mat_mul(A, B): def _mat_mul(A, B):
return [ return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
for r in range(3)
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SVG element → polygon extractor # SVG element → polygon extractor
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SVG_NS = re.compile(r'\{[^}]+\}') SVG_NS = re.compile(r"\{[^}]+\}")
def _tag(el: ET.Element) -> str: def _tag(el: ET.Element) -> str:
return SVG_NS.sub('', el.tag) return SVG_NS.sub("", el.tag)
def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optional[str]: def _get_attr(el: ET.Element, name: str, default: Optional[str] = None) -> Optional[str]:
for key in el.attrib: for key in el.attrib:
if SVG_NS.sub('', key) == name: if SVG_NS.sub("", key) == name:
return el.attrib[key] return el.attrib[key]
return default return default
@@ -351,13 +373,13 @@ def _identity():
def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]) -> List[Polygon]: def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]) -> List[Polygon]:
"""Recursively extract all polygons from an SVG element tree.""" """Recursively extract all polygons from an SVG element tree."""
tag = _tag(el) tag = _tag(el)
display = _get_attr(el, 'display', 'inline') display = _get_attr(el, "display", "inline")
visibility = _get_attr(el, 'visibility', 'visible') visibility = _get_attr(el, "visibility", "visible")
if display == 'none' or visibility == 'hidden': if display == "none" or visibility == "hidden":
return [] return []
# Accumulate transform # Accumulate transform
transform_str = _get_attr(el, 'transform', '') transform_str = _get_attr(el, "transform", "")
if transform_str: if transform_str:
local_mat = _parse_transform(transform_str) local_mat = _parse_transform(transform_str)
mat = _mat_mul(parent_mat, local_mat) mat = _mat_mul(parent_mat, local_mat)
@@ -366,65 +388,73 @@ def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]
result: List[Polygon] = [] result: List[Polygon] = []
if tag == 'g' or tag == 'svg': if tag == "g" or tag == "svg":
for child in el: for child in el:
result.extend(_extract_polygons_from_element(child, mat)) result.extend(_extract_polygons_from_element(child, mat))
elif tag == 'path': elif tag == "path":
d = _get_attr(el, 'd', '') d = _get_attr(el, "d", "")
if d: if d:
tokens = _tokenize_path(d) tokens = _tokenize_path(d)
polygons = _parse_path_tokens(tokens) polygons = _parse_path_tokens(tokens)
for poly in polygons: for poly in polygons:
result.append(_apply_transform(poly, mat)) result.append(_apply_transform(poly, mat))
elif tag == 'rect': elif tag == "rect":
x = float(_get_attr(el, 'x', '0') or 0) x = float(_get_attr(el, "x", "0") or 0)
y = float(_get_attr(el, 'y', '0') or 0) y = float(_get_attr(el, "y", "0") or 0)
w = float(_get_attr(el, 'width', '0') or 0) w = float(_get_attr(el, "width", "0") or 0)
h = float(_get_attr(el, 'height', '0') or 0) h = float(_get_attr(el, "height", "0") or 0)
if w > 0 and h > 0: if w > 0 and h > 0:
pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)] pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
result.append(_apply_transform(pts, mat)) result.append(_apply_transform(pts, mat))
elif tag == 'circle': elif tag == "circle":
cx_ = float(_get_attr(el, 'cx', '0') or 0) cx_ = float(_get_attr(el, "cx", "0") or 0)
cy_ = float(_get_attr(el, 'cy', '0') or 0) cy_ = float(_get_attr(el, "cy", "0") or 0)
r = float(_get_attr(el, 'r', '0') or 0) r = float(_get_attr(el, "r", "0") or 0)
if r > 0: if r > 0:
steps = 36 steps = 36
pts = [(cx_ + r * math.cos(2 * math.pi * k / steps), pts = [
cy_ + r * math.sin(2 * math.pi * k / steps)) (
for k in range(steps + 1)] cx_ + r * math.cos(2 * math.pi * k / steps),
cy_ + r * math.sin(2 * math.pi * k / steps),
)
for k in range(steps + 1)
]
result.append(_apply_transform(pts, mat)) result.append(_apply_transform(pts, mat))
elif tag == 'ellipse': elif tag == "ellipse":
cx_ = float(_get_attr(el, 'cx', '0') or 0) cx_ = float(_get_attr(el, "cx", "0") or 0)
cy_ = float(_get_attr(el, 'cy', '0') or 0) cy_ = float(_get_attr(el, "cy", "0") or 0)
rx = float(_get_attr(el, 'rx', '0') or 0) rx = float(_get_attr(el, "rx", "0") or 0)
ry = float(_get_attr(el, 'ry', '0') or 0) ry = float(_get_attr(el, "ry", "0") or 0)
if rx > 0 and ry > 0: if rx > 0 and ry > 0:
steps = 36 steps = 36
pts = [(cx_ + rx * math.cos(2 * math.pi * k / steps), pts = [
cy_ + ry * math.sin(2 * math.pi * k / steps)) (
for k in range(steps + 1)] cx_ + rx * math.cos(2 * math.pi * k / steps),
cy_ + ry * math.sin(2 * math.pi * k / steps),
)
for k in range(steps + 1)
]
result.append(_apply_transform(pts, mat)) result.append(_apply_transform(pts, mat))
elif tag in ('polygon', 'polyline'): elif tag in ("polygon", "polyline"):
points_str = _get_attr(el, 'points', '') points_str = _get_attr(el, "points", "")
if points_str: if points_str:
nums = [float(v) for v in re.split(r'[\s,]+', points_str.strip()) if v] nums = [float(v) for v in re.split(r"[\s,]+", points_str.strip()) if v]
pts = [(nums[k], nums[k + 1]) for k in range(0, len(nums) - 1, 2)] pts = [(nums[k], nums[k + 1]) for k in range(0, len(nums) - 1, 2)]
if tag == 'polygon' and pts: if tag == "polygon" and pts:
pts.append(pts[0]) # close pts.append(pts[0]) # close
if pts: if pts:
result.append(_apply_transform(pts, mat)) result.append(_apply_transform(pts, mat))
elif tag == 'line': elif tag == "line":
x1 = float(_get_attr(el, 'x1', '0') or 0) x1 = float(_get_attr(el, "x1", "0") or 0)
y1 = float(_get_attr(el, 'y1', '0') or 0) y1 = float(_get_attr(el, "y1", "0") or 0)
x2 = float(_get_attr(el, 'x2', '0') or 0) x2 = float(_get_attr(el, "x2", "0") or 0)
y2 = float(_get_attr(el, 'y2', '0') or 0) y2 = float(_get_attr(el, "y2", "0") or 0)
pts = [(x1, y1), (x2, y2)] pts = [(x1, y1), (x2, y2)]
result.append(_apply_transform(pts, mat)) result.append(_apply_transform(pts, mat))
@@ -453,20 +483,24 @@ def _build_gr_poly(points: List[Point], layer: str, stroke_width: float, filled:
row = [] row = []
fill_str = "yes" if filled else "none" fill_str = "yes" if filled else "none"
uid = str(uuid.uuid4()) uid = str(uuid.uuid4())
lines = [ lines = (
"\t(gr_poly", [
"\t\t(pts", "\t(gr_poly",
] + pts_lines + [ "\t\t(pts",
"\t\t)", ]
"\t\t(stroke", + pts_lines
f"\t\t\t(width {stroke_width:.4f})", + [
"\t\t\t(type solid)", "\t\t)",
"\t\t)", "\t\t(stroke",
f"\t\t(fill {fill_str})", f"\t\t\t(width {stroke_width:.4f})",
f'\t\t(layer "{layer}")', "\t\t\t(type solid)",
f'\t\t(uuid "{uid}")', "\t\t)",
"\t)", f"\t\t(fill {fill_str})",
] f'\t\t(layer "{layer}")',
f'\t\t(uuid "{uid}")',
"\t)",
]
)
return "\n".join(lines) return "\n".join(lines)
@@ -510,15 +544,15 @@ def import_svg_to_pcb(
root = tree.getroot() root = tree.getroot()
# Determine SVG viewport # Determine SVG viewport
vb = _get_attr(root, 'viewBox') vb = _get_attr(root, "viewBox")
if vb: if vb:
parts = [float(v) for v in re.split(r'[\s,]+', vb.strip()) if v] parts = [float(v) for v in re.split(r"[\s,]+", vb.strip()) if v]
svg_x0, svg_y0, svg_w, svg_h = parts[0], parts[1], parts[2], parts[3] svg_x0, svg_y0, svg_w, svg_h = parts[0], parts[1], parts[2], parts[3]
else: else:
w_str = _get_attr(root, 'width', '100') or '100' w_str = _get_attr(root, "width", "100") or "100"
h_str = _get_attr(root, 'height', '100') or '100' h_str = _get_attr(root, "height", "100") or "100"
svg_w = float(re.sub(r'[^\d.]', '', w_str) or 100) svg_w = float(re.sub(r"[^\d.]", "", w_str) or 100)
svg_h = float(re.sub(r'[^\d.]', '', h_str) or 100) svg_h = float(re.sub(r"[^\d.]", "", h_str) or 100)
svg_x0, svg_y0 = 0.0, 0.0 svg_x0, svg_y0 = 0.0, 0.0
if svg_w == 0 or svg_h == 0: if svg_w == 0 or svg_h == 0:
@@ -569,7 +603,10 @@ def import_svg_to_pcb(
insert_block = "\n" + "\n".join(gr_lines) + "\n" insert_block = "\n" + "\n".join(gr_lines) + "\n"
last_paren = pcb_content.rfind(")") last_paren = pcb_content.rfind(")")
if last_paren == -1: if last_paren == -1:
return {"success": False, "message": "PCB file format error: no closing parenthesis found"} return {
"success": False,
"message": "PCB file format error: no closing parenthesis found",
}
new_content = pcb_content[:last_paren] + insert_block + pcb_content[last_paren:] new_content = pcb_content[:last_paren] + insert_block + pcb_content[last_paren:]
@@ -597,5 +634,6 @@ def import_svg_to_pcb(
except Exception as e: except Exception as e:
logger.error(f"SVG import failed: {e}") logger.error(f"SVG import failed: {e}")
import traceback import traceback
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}

View File

@@ -24,15 +24,31 @@ KICAD9_SYMBOL_LIB_VERSION = "20241209"
# Pin electrical types # Pin electrical types
PIN_TYPES = { PIN_TYPES = {
"input", "output", "bidirectional", "tri_state", "passive", "input",
"free", "unspecified", "power_in", "power_out", "output",
"open_collector", "open_emitter", "no_connect", "bidirectional",
"tri_state",
"passive",
"free",
"unspecified",
"power_in",
"power_out",
"open_collector",
"open_emitter",
"no_connect",
} }
# Pin graphic shapes # Pin graphic shapes
PIN_SHAPES = { PIN_SHAPES = {
"line", "inverted", "clock", "inverted_clock", "input_low", "line",
"clock_low", "output_low", "falling_edge_clock", "non_logic", "inverted",
"clock",
"inverted_clock",
"input_low",
"clock_low",
"output_low",
"falling_edge_clock",
"non_logic",
} }
@@ -125,11 +141,11 @@ class SymbolCreator:
lib_content = lib_path.read_text(encoding="utf-8") lib_content = lib_path.read_text(encoding="utf-8")
else: else:
lib_content = ( lib_content = (
f'(kicad_symbol_lib\n' f"(kicad_symbol_lib\n"
f' (version {KICAD9_SYMBOL_LIB_VERSION})\n' f" (version {KICAD9_SYMBOL_LIB_VERSION})\n"
f' (generator "kicad-mcp")\n' f' (generator "kicad-mcp")\n'
f' (generator_version "9.0")\n' f' (generator_version "9.0")\n'
f')\n' f")\n"
) )
# Check for duplicate # Check for duplicate
@@ -209,7 +225,7 @@ class SymbolCreator:
# Only top-level symbols (not sub-symbols like _0_1 or _1_1) # Only top-level symbols (not sub-symbols like _0_1 or _1_1)
names = re.findall(r'^\s*\(symbol "([^"_][^"]*)"', content, re.MULTILINE) names = re.findall(r'^\s*\(symbol "([^"_][^"]*)"', content, re.MULTILINE)
# Filter out sub-symbols (contain _N_N suffix) # Filter out sub-symbols (contain _N_N suffix)
symbols = [n for n in names if not re.search(r'_\d+_\d+$', n)] symbols = [n for n in names if not re.search(r"_\d+_\d+$", n)]
return { return {
"success": True, "success": True,
"library_path": str(lib_path), "library_path": str(lib_path),
@@ -332,9 +348,9 @@ class SymbolCreator:
board_str = "yes" if on_board else "no" board_str = "yes" if on_board else "no"
lines.append(f' (symbol "{name}"') lines.append(f' (symbol "{name}"')
lines.append(f' (exclude_from_sim no)') lines.append(f" (exclude_from_sim no)")
lines.append(f' (in_bom {bom_str})') lines.append(f" (in_bom {bom_str})")
lines.append(f' (on_board {board_str})') lines.append(f" (on_board {board_str})")
# Properties # Properties
lines.extend(_property_block("Reference", reference_prefix, 2.54, 0, visible=True)) lines.extend(_property_block("Reference", reference_prefix, 2.54, 0, visible=True))
@@ -351,15 +367,15 @@ class SymbolCreator:
lines.extend(_rect_sym_lines(rect)) lines.extend(_rect_sym_lines(rect))
for pl in polylines: for pl in polylines:
lines.extend(_polyline_lines(pl)) lines.extend(_polyline_lines(pl))
lines.append(f' )') lines.append(f" )")
# Sub-symbol _1_1: pins # Sub-symbol _1_1: pins
lines.append(f' (symbol "{name}_1_1"') lines.append(f' (symbol "{name}_1_1"')
for pin in pins: for pin in pins:
lines.extend(_pin_lines(pin)) lines.extend(_pin_lines(pin))
lines.append(f' )') lines.append(f" )")
lines.append(f' )') lines.append(f" )")
return "\n".join(lines) return "\n".join(lines)
def _remove_symbol(self, content: str, name: str) -> str: def _remove_symbol(self, content: str, name: str) -> str:
@@ -372,8 +388,9 @@ class SymbolCreator:
for line in lines: for line in lines:
stripped = line.strip() stripped = line.strip()
if not skip: if not skip:
if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and \ if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and not re.search(
not re.search(r'_\d+_\d+"', line): r'_\d+_\d+"', line
):
skip = True skip = True
depth = stripped.count("(") - stripped.count(")") depth = stripped.count("(") - stripped.count(")")
continue continue
@@ -390,17 +407,16 @@ class SymbolCreator:
# S-Expression helper functions # # S-Expression helper functions #
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def _property_block(
key: str, value: str, x: float, y: float, visible: bool = True def _property_block(key: str, value: str, x: float, y: float, visible: bool = True) -> List[str]:
) -> List[str]:
hide = "" if visible else "\n (hide yes)" hide = "" if visible else "\n (hide yes)"
return [ return [
f' (property "{_esc(key)}" "{_esc(value)}"', f' (property "{_esc(key)}" "{_esc(value)}"',
f' (at {_fmt(x)} {_fmt(y)} 0)', f" (at {_fmt(x)} {_fmt(y)} 0)",
f' (effects', f" (effects",
f' (font (size 1.27 1.27))', f" (font (size 1.27 1.27))",
f' ){hide}', f" ){hide}",
f' )', f" )",
] ]
@@ -412,12 +428,12 @@ def _rect_sym_lines(rect: Dict[str, Any]) -> List[str]:
w = _fmt(rect.get("width", 0.254)) w = _fmt(rect.get("width", 0.254))
fill = rect.get("fill", "background") fill = rect.get("fill", "background")
return [ return [
f' (rectangle', f" (rectangle",
f' (start {x1} {y1})', f" (start {x1} {y1})",
f' (end {x2} {y2})', f" (end {x2} {y2})",
f' (stroke (width {w}) (type default))', f" (stroke (width {w}) (type default))",
f' (fill (type {fill}))', f" (fill (type {fill}))",
f' )', f" )",
] ]
@@ -426,16 +442,16 @@ def _polyline_lines(pl: Dict[str, Any]) -> List[str]:
w = _fmt(pl.get("width", 0.254)) w = _fmt(pl.get("width", 0.254))
fill = pl.get("fill", "none") fill = pl.get("fill", "none")
lines = [ lines = [
f' (polyline', f" (polyline",
f' (pts', f" (pts",
] ]
for pt in pts: for pt in pts:
lines.append(f' (xy {_fmt(pt["x"])} {_fmt(pt["y"])})') lines.append(f' (xy {_fmt(pt["x"])} {_fmt(pt["y"])})')
lines += [ lines += [
f' )', f" )",
f' (stroke (width {w}) (type default))', f" (stroke (width {w}) (type default))",
f' (fill (type {fill}))', f" (fill (type {fill}))",
f' )', f" )",
] ]
return lines return lines
@@ -452,14 +468,14 @@ def _pin_lines(pin: Dict[str, Any]) -> List[str]:
pin_number = str(pin.get("number", "1")) pin_number = str(pin.get("number", "1"))
return [ return [
f' (pin {ptype} {shape}', f" (pin {ptype} {shape}",
f' (at {x} {y} {angle})', f" (at {x} {y} {angle})",
f' (length {length})', f" (length {length})",
f' (name "{_esc(pin_name)}"', f' (name "{_esc(pin_name)}"',
f' (effects (font (size 1.27 1.27)))', f" (effects (font (size 1.27 1.27)))",
f' )', f" )",
f' (number "{_esc(pin_number)}"', f' (number "{_esc(pin_number)}"',
f' (effects (font (size 1.27 1.27)))', f" (effects (font (size 1.27 1.27)))",
f' )', f" )",
f' )', f" )",
] ]

View File

@@ -95,9 +95,7 @@ def _parse_virtual_connections(schematic, schematic_path):
locator = PinLocator() locator = PinLocator()
for symbol in schematic.symbol: for symbol in schematic.symbol:
try: try:
if not hasattr(symbol, "property") or not hasattr( if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
symbol.property, "Reference"
):
continue continue
ref = symbol.property.Reference.value ref = symbol.property.Reference.value
if not ref.startswith("#PWR"): if not ref.startswith("#PWR"):
@@ -194,9 +192,7 @@ def _find_pins_on_net(
ref = None ref = None
for symbol in schematic.symbol: for symbol in schematic.symbol:
try: try:
if not hasattr(symbol, "property") or not hasattr( if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
symbol.property, "Reference"
):
continue continue
ref = symbol.property.Reference.value ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"): if ref.startswith("_TEMPLATE"):
@@ -241,9 +237,7 @@ def get_wire_connections(
adjacency, iu_to_wires = _build_adjacency(all_wires) adjacency, iu_to_wires = _build_adjacency(all_wires)
point_to_label, label_to_points = _parse_virtual_connections( point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
schematic, schematic_path
)
visited, net_points = _find_connected_wires( visited, net_points = _find_connected_wires(
x_mm, x_mm,

View File

@@ -76,11 +76,7 @@ class WireManager:
# Find insertion point (before sheet_instances) # Find insertion point (before sheet_instances)
sheet_instances_index = None sheet_instances_index = None
for i, item in enumerate(sch_data): for i, item in enumerate(sch_data):
if ( if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
isinstance(item, list)
and len(item) > 0
and item[0] == _SYM_SHEET_INSTANCES
):
sheet_instances_index = i sheet_instances_index = i
break break
@@ -146,20 +142,14 @@ class WireManager:
# KiCAD wire elements only support exactly 2 pts each. # KiCAD wire elements only support exactly 2 pts each.
# Split N waypoints into N-1 individual wire segments. # Split N waypoints into N-1 individual wire segments.
wire_sexps = [ wire_sexps = [
WireManager._make_wire_sexp( WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type)
points[i], points[i + 1], stroke_width, stroke_type
)
for i in range(len(points) - 1) for i in range(len(points) - 1)
] ]
# Find insertion point # Find insertion point
sheet_instances_index = None sheet_instances_index = None
for i, item in enumerate(sch_data): for i, item in enumerate(sch_data):
if ( if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
isinstance(item, list)
and len(item) > 0
and item[0] == _SYM_SHEET_INSTANCES
):
sheet_instances_index = i sheet_instances_index = i
break break
@@ -235,11 +225,7 @@ class WireManager:
# Find insertion point # Find insertion point
sheet_instances_index = None sheet_instances_index = None
for i, item in enumerate(sch_data): for i, item in enumerate(sch_data):
if ( if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
isinstance(item, list)
and len(item) > 0
and item[0] == _SYM_SHEET_INSTANCES
):
sheet_instances_index = i sheet_instances_index = i
break break
@@ -274,11 +260,7 @@ class WireManager:
Parse a wire S-expression item in a single pass. Parse a wire S-expression item in a single pass.
Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire. Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire.
""" """
if not ( if not (isinstance(wire_item, list) and len(wire_item) >= 2 and wire_item[0] == _SYM_WIRE):
isinstance(wire_item, list)
and len(wire_item) >= 2
and wire_item[0] == _SYM_WIRE
):
return None return None
start = end = None start = end = None
stroke_width: float = 0 stroke_width: float = 0
@@ -379,9 +361,7 @@ class WireManager:
return splits return splits
@staticmethod @staticmethod
def add_junction( def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool:
schematic_path: Path, position: List[float], diameter: float = 0
) -> bool:
""" """
Add a junction (connection dot) to the schematic. Add a junction (connection dot) to the schematic.
@@ -423,11 +403,7 @@ class WireManager:
# Find insertion point # Find insertion point
sheet_instances_index = None sheet_instances_index = None
for i, item in enumerate(sch_data): for i, item in enumerate(sch_data):
if ( if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
isinstance(item, list)
and len(item) > 0
and item[0] == _SYM_SHEET_INSTANCES
):
sheet_instances_index = i sheet_instances_index = i
break break
@@ -484,11 +460,7 @@ class WireManager:
# Find insertion point # Find insertion point
sheet_instances_index = None sheet_instances_index = None
for i, item in enumerate(sch_data): for i, item in enumerate(sch_data):
if ( if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
isinstance(item, list)
and len(item) > 0
and item[0] == _SYM_SHEET_INSTANCES
):
sheet_instances_index = i sheet_instances_index = i
break break
@@ -544,9 +516,7 @@ class WireManager:
ex, ey = end_point ex, ey = end_point
for i, item in enumerate(sch_data): for i, item in enumerate(sch_data):
if not ( if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE):
isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE
):
continue continue
# Extract pts from the wire s-expression # Extract pts from the wire s-expression
@@ -626,9 +596,7 @@ class WireManager:
sch_data = sexpdata.loads(sch_content) sch_data = sexpdata.loads(sch_content)
for i, item in enumerate(sch_data): for i, item in enumerate(sch_data):
if not ( if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL):
isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL
):
continue continue
# Second element is the label text # Second element is the label text
@@ -649,8 +617,7 @@ class WireManager:
continue continue
lx, ly = float(at_entry[1]), float(at_entry[2]) lx, ly = float(at_entry[1]), float(at_entry[2])
if not ( if not (
abs(lx - position[0]) < tolerance abs(lx - position[0]) < tolerance and abs(ly - position[1]) < tolerance
and abs(ly - position[1]) < tolerance
): ):
continue continue

View File

@@ -23,5 +23,5 @@ Usage:
from kicad_api.factory import create_backend from kicad_api.factory import create_backend
from kicad_api.base import KiCADBackend from kicad_api.base import KiCADBackend
__all__ = ['create_backend', 'KiCADBackend'] __all__ = ["create_backend", "KiCADBackend"]
__version__ = '2.0.0-alpha.1' __version__ = "2.0.0-alpha.1"

View File

@@ -3,6 +3,7 @@ Abstract base class for KiCAD API backends
Defines the interface that all KiCAD backends must implement. Defines the interface that all KiCAD backends must implement.
""" """
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
@@ -97,7 +98,7 @@ class KiCADBackend(ABC):
# Board Operations # Board Operations
@abstractmethod @abstractmethod
def get_board(self) -> 'BoardAPI': def get_board(self) -> "BoardAPI":
""" """
Get board API for current project Get board API for current project
@@ -167,7 +168,7 @@ class BoardAPI(ABC):
x: float, x: float,
y: float, y: float,
rotation: float = 0, rotation: float = 0,
layer: str = "F.Cu" layer: str = "F.Cu",
) -> bool: ) -> bool:
""" """
Place a component on the board Place a component on the board
@@ -194,7 +195,7 @@ class BoardAPI(ABC):
end_y: float, end_y: float,
width: float = 0.25, width: float = 0.25,
layer: str = "F.Cu", layer: str = "F.Cu",
net_name: Optional[str] = None net_name: Optional[str] = None,
) -> bool: ) -> bool:
""" """
Add a track (trace) to the board Add a track (trace) to the board
@@ -220,7 +221,7 @@ class BoardAPI(ABC):
diameter: float = 0.8, diameter: float = 0.8,
drill: float = 0.4, drill: float = 0.4,
net_name: Optional[str] = None, net_name: Optional[str] = None,
via_type: str = "through" via_type: str = "through",
) -> bool: ) -> bool:
""" """
Add a via to the board Add a via to the board
@@ -275,14 +276,17 @@ class BoardAPI(ABC):
class BackendError(Exception): class BackendError(Exception):
"""Base exception for backend errors""" """Base exception for backend errors"""
pass pass
class ConnectionError(BackendError): class ConnectionError(BackendError):
"""Raised when connection to KiCAD fails""" """Raised when connection to KiCAD fails"""
pass pass
class APINotAvailableError(BackendError): class APINotAvailableError(BackendError):
"""Raised when required API is not available""" """Raised when required API is not available"""
pass pass

View File

@@ -3,6 +3,7 @@ Backend factory for creating appropriate KiCAD API backend
Auto-detects available backends and provides fallback mechanism. Auto-detects available backends and provides fallback mechanism.
""" """
import os import os
import logging import logging
from typing import Optional from typing import Optional
@@ -34,16 +35,16 @@ def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
""" """
# Check environment variable override # Check environment variable override
if backend_type is None: if backend_type is None:
backend_type = os.environ.get('KICAD_BACKEND', 'auto').lower() backend_type = os.environ.get("KICAD_BACKEND", "auto").lower()
logger.info(f"Requested backend: {backend_type}") logger.info(f"Requested backend: {backend_type}")
# Try specific backend if requested # Try specific backend if requested
if backend_type == 'ipc': if backend_type == "ipc":
return _create_ipc_backend() return _create_ipc_backend()
elif backend_type == 'swig': elif backend_type == "swig":
return _create_swig_backend() return _create_swig_backend()
elif backend_type == 'auto': elif backend_type == "auto":
return _auto_detect_backend() return _auto_detect_backend()
else: else:
raise ValueError(f"Unknown backend type: {backend_type}") raise ValueError(f"Unknown backend type: {backend_type}")
@@ -61,13 +62,13 @@ def _create_ipc_backend() -> KiCADBackend:
""" """
try: try:
from kicad_api.ipc_backend import IPCBackend from kicad_api.ipc_backend import IPCBackend
logger.info("Creating IPC backend") logger.info("Creating IPC backend")
return IPCBackend() return IPCBackend()
except ImportError as e: except ImportError as e:
logger.error(f"IPC backend not available: {e}") logger.error(f"IPC backend not available: {e}")
raise APINotAvailableError( raise APINotAvailableError(
"IPC backend requires 'kicad-python' package. " "IPC backend requires 'kicad-python' package. " "Install with: pip install kicad-python"
"Install with: pip install kicad-python"
) from e ) from e
@@ -83,6 +84,7 @@ def _create_swig_backend() -> KiCADBackend:
""" """
try: try:
from kicad_api.swig_backend import SWIGBackend from kicad_api.swig_backend import SWIGBackend
logger.info("Creating SWIG backend") logger.info("Creating SWIG backend")
logger.warning( logger.warning(
"SWIG backend is DEPRECATED and will be removed in KiCAD 10.0. " "SWIG backend is DEPRECATED and will be removed in KiCAD 10.0. "
@@ -92,8 +94,7 @@ def _create_swig_backend() -> KiCADBackend:
except ImportError as e: except ImportError as e:
logger.error(f"SWIG backend not available: {e}") logger.error(f"SWIG backend not available: {e}")
raise APINotAvailableError( raise APINotAvailableError(
"SWIG backend requires 'pcbnew' module. " "SWIG backend requires 'pcbnew' module. " "Ensure KiCAD Python module is in PYTHONPATH."
"Ensure KiCAD Python module is in PYTHONPATH."
) from e ) from e
@@ -129,8 +130,7 @@ def _auto_detect_backend() -> KiCADBackend:
try: try:
backend = _create_swig_backend() backend = _create_swig_backend()
logger.warning( logger.warning(
"Using deprecated SWIG backend. " "Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running."
"For best results, use IPC API with KiCAD running."
) )
return backend return backend
except (ImportError, APINotAvailableError) as e: except (ImportError, APINotAvailableError) as e:
@@ -160,22 +160,18 @@ def get_available_backends() -> dict:
# Check IPC (kicad-python uses 'kipy' module name) # Check IPC (kicad-python uses 'kipy' module name)
try: try:
import kipy import kipy
results['ipc'] = {
'available': True, results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")}
'version': getattr(kipy, '__version__', 'unknown')
}
except ImportError: except ImportError:
results['ipc'] = {'available': False, 'version': None} results["ipc"] = {"available": False, "version": None}
# Check SWIG # Check SWIG
try: try:
import pcbnew import pcbnew
results['swig'] = {
'available': True, results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()}
'version': pcbnew.GetBuildVersion()
}
except ImportError: except ImportError:
results['swig'] = {'available': False, 'version': None} results["swig"] = {"available": False, "version": None}
return results return results
@@ -183,6 +179,7 @@ def get_available_backends() -> dict:
if __name__ == "__main__": if __name__ == "__main__":
# Quick diagnostic # Quick diagnostic
import json import json
print("KiCAD Backend Availability:") print("KiCAD Backend Availability:")
print(json.dumps(get_available_backends(), indent=2)) print(json.dumps(get_available_backends(), indent=2))

View File

@@ -13,18 +13,14 @@ Key Benefits over SWIG:
- Stable API that won't break between versions - Stable API that won't break between versions
- Multi-language support - Multi-language support
""" """
import logging import logging
import os import os
import platform import platform
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List, Callable from typing import Optional, Dict, Any, List, Callable
from kicad_api.base import ( from kicad_api.base import KiCADBackend, BoardAPI, ConnectionError, APINotAvailableError
KiCADBackend,
BoardAPI,
ConnectionError,
APINotAvailableError
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -78,10 +74,10 @@ class IPCBackend(KiCADBackend):
# Common socket locations (Unix-like systems only) # Common socket locations (Unix-like systems only)
# Windows uses named pipes, handled by auto-detect # Windows uses named pipes, handled by auto-detect
if platform.system() != "Windows": if platform.system() != "Windows":
socket_paths_to_try.append('ipc:///tmp/kicad/api.sock') # Linux default socket_paths_to_try.append("ipc:///tmp/kicad/api.sock") # Linux default
# XDG runtime directory (requires getuid, Unix only) # XDG runtime directory (requires getuid, Unix only)
if hasattr(os, 'getuid'): if hasattr(os, "getuid"):
socket_paths_to_try.append(f'ipc:///run/user/{os.getuid()}/kicad/api.sock') socket_paths_to_try.append(f"ipc:///run/user/{os.getuid()}/kicad/api.sock")
# Auto-detect for all platforms (Windows uses named pipes, Unix uses sockets) # Auto-detect for all platforms (Windows uses named pipes, Unix uses sockets)
socket_paths_to_try.append(None) socket_paths_to_try.append(None)
@@ -117,8 +113,7 @@ class IPCBackend(KiCADBackend):
except ImportError as e: except ImportError as e:
logger.error("kicad-python library not found") logger.error("kicad-python library not found")
raise APINotAvailableError( raise APINotAvailableError(
"IPC backend requires kicad-python. " "IPC backend requires kicad-python. " "Install with: pip install kicad-python"
"Install with: pip install kicad-python"
) from e ) from e
except Exception as e: except Exception as e:
logger.error(f"Failed to connect via IPC: {e}") logger.error(f"Failed to connect via IPC: {e}")
@@ -190,7 +185,7 @@ class IPCBackend(KiCADBackend):
return { return {
"success": False, "success": False,
"message": "Direct project creation not supported via IPC", "message": "Direct project creation not supported via IPC",
"suggestion": "Open KiCAD and create a new project, or use SWIG backend" "suggestion": "Open KiCAD and create a new project, or use SWIG backend",
} }
def open_project(self, path: Path) -> Dict[str, Any]: def open_project(self, path: Path) -> Dict[str, Any]:
@@ -209,22 +204,18 @@ class IPCBackend(KiCADBackend):
return { return {
"success": True, "success": True,
"message": f"Project already open: {path}", "message": f"Project already open: {path}",
"path": str(path) "path": str(path),
} }
return { return {
"success": False, "success": False,
"message": "Project not currently open in KiCAD", "message": "Project not currently open in KiCAD",
"suggestion": "Open the project in KiCAD first, then connect via IPC" "suggestion": "Open the project in KiCAD first, then connect via IPC",
} }
except Exception as e: except Exception as e:
logger.error(f"Failed to check project: {e}") logger.error(f"Failed to check project: {e}")
return { return {"success": False, "message": "Failed to check project", "errorDetails": str(e)}
"success": False,
"message": "Failed to check project",
"errorDetails": str(e)
}
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]: def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
"""Save current project via IPC.""" """Save current project via IPC."""
@@ -240,17 +231,10 @@ class IPCBackend(KiCADBackend):
self._notify_change("save", {"path": str(path) if path else "current"}) self._notify_change("save", {"path": str(path) if path else "current"})
return { return {"success": True, "message": "Project saved successfully"}
"success": True,
"message": "Project saved successfully"
}
except Exception as e: except Exception as e:
logger.error(f"Failed to save project: {e}") logger.error(f"Failed to save project: {e}")
return { return {"success": False, "message": "Failed to save project", "errorDetails": str(e)}
"success": False,
"message": "Failed to save project",
"errorDetails": str(e)
}
def close_project(self) -> None: def close_project(self) -> None:
"""Close current project (not supported via IPC).""" """Close current project (not supported via IPC)."""
@@ -380,8 +364,8 @@ class IPCBoardAPI(BoardAPI):
# Find bounding box of edge cuts # Find bounding box of edge cuts
from kipy.util.units import to_mm from kipy.util.units import to_mm
min_x = min_y = float('inf') min_x = min_y = float("inf")
max_x = max_y = float('-inf') max_x = max_y = float("-inf")
for shape in shapes: for shape in shapes:
# Check if on Edge.Cuts layer # Check if on Edge.Cuts layer
@@ -392,14 +376,10 @@ class IPCBoardAPI(BoardAPI):
max_x = max(max_x, bbox.max.x) max_x = max(max_x, bbox.max.x)
max_y = max(max_y, bbox.max.y) max_y = max(max_y, bbox.max.y)
if min_x == float('inf'): if min_x == float("inf"):
return {"width": 0, "height": 0, "unit": "mm"} return {"width": 0, "height": 0, "unit": "mm"}
return { return {"width": to_mm(max_x - min_x), "height": to_mm(max_y - min_y), "unit": "mm"}
"width": to_mm(max_x - min_x),
"height": to_mm(max_y - min_y),
"unit": "mm"
}
except Exception as e: except Exception as e:
logger.error(f"Failed to get board size: {e}") logger.error(f"Failed to get board size: {e}")
@@ -432,19 +412,23 @@ class IPCBoardAPI(BoardAPI):
for fp in footprints: for fp in footprints:
try: try:
pos = fp.position pos = fp.position
components.append({ components.append(
"reference": fp.reference_field.text.value if fp.reference_field else "", {
"value": fp.value_field.text.value if fp.value_field else "", "reference": (
"footprint": str(fp.definition.library_link) if fp.definition else "", fp.reference_field.text.value if fp.reference_field else ""
"position": { ),
"x": to_mm(pos.x) if pos else 0, "value": fp.value_field.text.value if fp.value_field else "",
"y": to_mm(pos.y) if pos else 0, "footprint": str(fp.definition.library_link) if fp.definition else "",
"unit": "mm" "position": {
}, "x": to_mm(pos.x) if pos else 0,
"rotation": fp.orientation.degrees if fp.orientation else 0, "y": to_mm(pos.y) if pos else 0,
"layer": str(fp.layer) if hasattr(fp, 'layer') else "F.Cu", "unit": "mm",
"id": str(fp.id) if hasattr(fp, 'id') else "" },
}) "rotation": fp.orientation.degrees if fp.orientation else 0,
"layer": str(fp.layer) if hasattr(fp, "layer") else "F.Cu",
"id": str(fp.id) if hasattr(fp, "id") else "",
}
)
except Exception as e: except Exception as e:
logger.warning(f"Error processing footprint: {e}") logger.warning(f"Error processing footprint: {e}")
continue continue
@@ -463,7 +447,7 @@ class IPCBoardAPI(BoardAPI):
y: float, y: float,
rotation: float = 0, rotation: float = 0,
layer: str = "F.Cu", layer: str = "F.Cu",
value: str = "" value: str = "",
) -> bool: ) -> bool:
""" """
Place a component on the board. Place a component on the board.
@@ -495,7 +479,9 @@ class IPCBoardAPI(BoardAPI):
) )
else: else:
# Fallback: Create a basic placeholder footprint via IPC # Fallback: Create a basic placeholder footprint via IPC
logger.warning(f"Could not load footprint '{footprint}' from library, creating placeholder") logger.warning(
f"Could not load footprint '{footprint}' from library, creating placeholder"
)
return self._place_placeholder_footprint( return self._place_placeholder_footprint(
reference, footprint, x, y, rotation, layer, value reference, footprint, x, y, rotation, layer, value
) )
@@ -518,8 +504,8 @@ class IPCBoardAPI(BoardAPI):
import pcbnew import pcbnew
# Parse library and footprint name # Parse library and footprint name
if ':' in footprint_path: if ":" in footprint_path:
lib_name, fp_name = footprint_path.split(':', 1) lib_name, fp_name = footprint_path.split(":", 1)
else: else:
# Try to find the footprint in all libraries # Try to find the footprint in all libraries
lib_name = None lib_name = None
@@ -560,14 +546,7 @@ class IPCBoardAPI(BoardAPI):
return None return None
def _place_loaded_footprint( def _place_loaded_footprint(
self, self, loaded_fp, reference: str, x: float, y: float, rotation: float, layer: str, value: str
loaded_fp,
reference: str,
x: float,
y: float,
rotation: float,
layer: str,
value: str
) -> bool: ) -> bool:
""" """
Place a loaded pcbnew footprint onto the board. Place a loaded pcbnew footprint onto the board.
@@ -589,7 +568,7 @@ class IPCBoardAPI(BoardAPI):
try: try:
docs = self._kicad.get_open_documents() docs = self._kicad.get_open_documents()
for doc in docs: for doc in docs:
if hasattr(doc, 'path') and str(doc.path).endswith('.kicad_pcb'): if hasattr(doc, "path") and str(doc.path).endswith(".kicad_pcb"):
board_path = str(doc.path) board_path = str(doc.path)
break break
except Exception as e: except Exception as e:
@@ -637,24 +616,27 @@ class IPCBoardAPI(BoardAPI):
except Exception as e: except Exception as e:
logger.debug(f"Could not refresh IPC board: {e}") logger.debug(f"Could not refresh IPC board: {e}")
self._notify("component_placed", { self._notify(
"reference": reference, "component_placed",
"footprint": loaded_fp.GetFPIDAsString(), {
"position": {"x": x, "y": y}, "reference": reference,
"rotation": rotation, "footprint": loaded_fp.GetFPIDAsString(),
"layer": layer, "position": {"x": x, "y": y},
"loaded_from_library": True "rotation": rotation,
}) "layer": layer,
"loaded_from_library": True,
},
)
logger.info(f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm") logger.info(
f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm"
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error placing loaded footprint: {e}") logger.error(f"Error placing loaded footprint: {e}")
# Fall back to placeholder # Fall back to placeholder
return self._place_placeholder_footprint( return self._place_placeholder_footprint(reference, "", x, y, rotation, layer, value)
reference, "", x, y, rotation, layer, value
)
def _place_placeholder_footprint( def _place_placeholder_footprint(
self, self,
@@ -664,7 +646,7 @@ class IPCBoardAPI(BoardAPI):
y: float, y: float,
rotation: float, rotation: float,
layer: str, layer: str,
value: str value: str,
) -> bool: ) -> bool:
""" """
Place a placeholder footprint when library loading fails. Place a placeholder footprint when library loading fails.
@@ -701,15 +683,18 @@ class IPCBoardAPI(BoardAPI):
board.create_items(fp) board.create_items(fp)
board.push_commit(commit, f"Placed component {reference}") board.push_commit(commit, f"Placed component {reference}")
self._notify("component_placed", { self._notify(
"reference": reference, "component_placed",
"footprint": footprint, {
"position": {"x": x, "y": y}, "reference": reference,
"rotation": rotation, "footprint": footprint,
"layer": layer, "position": {"x": x, "y": y},
"loaded_from_library": False, "rotation": rotation,
"is_placeholder": True "layer": layer,
}) "loaded_from_library": False,
"is_placeholder": True,
},
)
logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm") logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm")
return True return True
@@ -718,7 +703,9 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to place placeholder component: {e}") logger.error(f"Failed to place placeholder component: {e}")
return False return False
def move_component(self, reference: str, x: float, y: float, rotation: Optional[float] = None) -> bool: def move_component(
self, reference: str, x: float, y: float, rotation: Optional[float] = None
) -> bool:
"""Move a component to a new position (updates UI immediately).""" """Move a component to a new position (updates UI immediately)."""
try: try:
from kipy.geometry import Vector2, Angle from kipy.geometry import Vector2, Angle
@@ -749,11 +736,10 @@ class IPCBoardAPI(BoardAPI):
board.update_items([target_fp]) board.update_items([target_fp])
board.push_commit(commit, f"Moved component {reference}") board.push_commit(commit, f"Moved component {reference}")
self._notify("component_moved", { self._notify(
"reference": reference, "component_moved",
"position": {"x": x, "y": y}, {"reference": reference, "position": {"x": x, "y": y}, "rotation": rotation},
"rotation": rotation )
})
return True return True
@@ -799,7 +785,7 @@ class IPCBoardAPI(BoardAPI):
end_y: float, end_y: float,
width: float = 0.25, width: float = 0.25,
layer: str = "F.Cu", layer: str = "F.Cu",
net_name: Optional[str] = None net_name: Optional[str] = None,
) -> bool: ) -> bool:
""" """
Add a track (trace) to the board. Add a track (trace) to the board.
@@ -842,13 +828,16 @@ class IPCBoardAPI(BoardAPI):
board.create_items(track) board.create_items(track)
board.push_commit(commit, "Added track") board.push_commit(commit, "Added track")
self._notify("track_added", { self._notify(
"start": {"x": start_x, "y": start_y}, "track_added",
"end": {"x": end_x, "y": end_y}, {
"width": width, "start": {"x": start_x, "y": start_y},
"layer": layer, "end": {"x": end_x, "y": end_y},
"net": net_name "width": width,
}) "layer": layer,
"net": net_name,
},
)
logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm") logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm")
return True return True
@@ -864,7 +853,7 @@ class IPCBoardAPI(BoardAPI):
diameter: float = 0.8, diameter: float = 0.8,
drill: float = 0.4, drill: float = 0.4,
net_name: Optional[str] = None, net_name: Optional[str] = None,
via_type: str = "through" via_type: str = "through",
) -> bool: ) -> bool:
""" """
Add a via to the board. Add a via to the board.
@@ -906,13 +895,16 @@ class IPCBoardAPI(BoardAPI):
board.create_items(via) board.create_items(via)
board.push_commit(commit, "Added via") board.push_commit(commit, "Added via")
self._notify("via_added", { self._notify(
"position": {"x": x, "y": y}, "via_added",
"diameter": diameter, {
"drill": drill, "position": {"x": x, "y": y},
"net": net_name, "diameter": diameter,
"type": via_type "drill": drill,
}) "net": net_name,
"type": via_type,
},
)
logger.info(f"Added via at ({x}, {y}) mm") logger.info(f"Added via at ({x}, {y}) mm")
return True return True
@@ -928,7 +920,7 @@ class IPCBoardAPI(BoardAPI):
y: float, y: float,
layer: str = "F.SilkS", layer: str = "F.SilkS",
size: float = 1.0, size: float = 1.0,
rotation: float = 0 rotation: float = 0,
) -> bool: ) -> bool:
"""Add text to the board.""" """Add text to the board."""
try: try:
@@ -959,11 +951,7 @@ class IPCBoardAPI(BoardAPI):
board.create_items(board_text) board.create_items(board_text)
board.push_commit(commit, f"Added text: {text}") board.push_commit(commit, f"Added text: {text}")
self._notify("text_added", { self._notify("text_added", {"text": text, "position": {"x": x, "y": y}, "layer": layer})
"text": text,
"position": {"x": x, "y": y},
"layer": layer
})
return True return True
@@ -982,20 +970,16 @@ class IPCBoardAPI(BoardAPI):
result = [] result = []
for track in tracks: for track in tracks:
try: try:
result.append({ result.append(
"start": { {
"x": to_mm(track.start.x), "start": {"x": to_mm(track.start.x), "y": to_mm(track.start.y)},
"y": to_mm(track.start.y) "end": {"x": to_mm(track.end.x), "y": to_mm(track.end.y)},
}, "width": to_mm(track.width),
"end": { "layer": str(track.layer),
"x": to_mm(track.end.x), "net": track.net.name if track.net else "",
"y": to_mm(track.end.y) "id": str(track.id) if hasattr(track, "id") else "",
}, }
"width": to_mm(track.width), )
"layer": str(track.layer),
"net": track.net.name if track.net else "",
"id": str(track.id) if hasattr(track, 'id') else ""
})
except Exception as e: except Exception as e:
logger.warning(f"Error processing track: {e}") logger.warning(f"Error processing track: {e}")
continue continue
@@ -1017,17 +1001,16 @@ class IPCBoardAPI(BoardAPI):
result = [] result = []
for via in vias: for via in vias:
try: try:
result.append({ result.append(
"position": { {
"x": to_mm(via.position.x), "position": {"x": to_mm(via.position.x), "y": to_mm(via.position.y)},
"y": to_mm(via.position.y) "diameter": to_mm(via.diameter),
}, "drill": to_mm(via.drill_diameter),
"diameter": to_mm(via.diameter), "net": via.net.name if via.net else "",
"drill": to_mm(via.drill_diameter), "type": str(via.type),
"net": via.net.name if via.net else "", "id": str(via.id) if hasattr(via, "id") else "",
"type": str(via.type), }
"id": str(via.id) if hasattr(via, 'id') else "" )
})
except Exception as e: except Exception as e:
logger.warning(f"Error processing via: {e}") logger.warning(f"Error processing via: {e}")
continue continue
@@ -1047,10 +1030,9 @@ class IPCBoardAPI(BoardAPI):
result = [] result = []
for net in nets: for net in nets:
try: try:
result.append({ result.append(
"name": net.name, {"name": net.name, "code": net.code if hasattr(net, "code") else 0}
"code": net.code if hasattr(net, 'code') else 0 )
})
except Exception as e: except Exception as e:
logger.warning(f"Error processing net: {e}") logger.warning(f"Error processing net: {e}")
continue continue
@@ -1070,7 +1052,7 @@ class IPCBoardAPI(BoardAPI):
min_thickness: float = 0.25, min_thickness: float = 0.25,
priority: int = 0, priority: int = 0,
fill_mode: str = "solid", fill_mode: str = "solid",
name: str = "" name: str = "",
) -> bool: ) -> bool:
""" """
Add a copper pour zone to the board. Add a copper pour zone to the board.
@@ -1157,12 +1139,10 @@ class IPCBoardAPI(BoardAPI):
board.create_items(zone) board.create_items(zone)
board.push_commit(commit, f"Added copper zone on {layer}") board.push_commit(commit, f"Added copper zone on {layer}")
self._notify("zone_added", { self._notify(
"layer": layer, "zone_added",
"net": net_name, {"layer": layer, "net": net_name, "points": len(points), "priority": priority},
"points": len(points), )
"priority": priority
})
logger.info(f"Added zone on {layer} with {len(points)} points") logger.info(f"Added zone on {layer} with {len(points)} points")
return True return True
@@ -1182,14 +1162,18 @@ class IPCBoardAPI(BoardAPI):
result = [] result = []
for zone in zones: for zone in zones:
try: try:
result.append({ result.append(
"name": zone.name if hasattr(zone, 'name') else "", {
"net": zone.net.name if zone.net else "", "name": zone.name if hasattr(zone, "name") else "",
"priority": zone.priority if hasattr(zone, 'priority') else 0, "net": zone.net.name if zone.net else "",
"layers": [str(l) for l in zone.layers] if hasattr(zone, 'layers') else [], "priority": zone.priority if hasattr(zone, "priority") else 0,
"filled": zone.filled if hasattr(zone, 'filled') else False, "layers": (
"id": str(zone.id) if hasattr(zone, 'id') else "" [str(l) for l in zone.layers] if hasattr(zone, "layers") else []
}) ),
"filled": zone.filled if hasattr(zone, "filled") else False,
"id": str(zone.id) if hasattr(zone, "id") else "",
}
)
except Exception as e: except Exception as e:
logger.warning(f"Error processing zone: {e}") logger.warning(f"Error processing zone: {e}")
continue continue
@@ -1219,10 +1203,9 @@ class IPCBoardAPI(BoardAPI):
result = [] result = []
for item in selection: for item in selection:
result.append({ result.append(
"type": type(item).__name__, {"type": type(item).__name__, "id": str(item.id) if hasattr(item, "id") else ""}
"id": str(item.id) if hasattr(item, 'id') else "" )
})
return result return result
except Exception as e: except Exception as e:
@@ -1241,4 +1224,4 @@ class IPCBoardAPI(BoardAPI):
# Export for factory # Export for factory
__all__ = ['IPCBackend', 'IPCBoardAPI'] __all__ = ["IPCBackend", "IPCBoardAPI"]

View File

@@ -8,16 +8,12 @@ WARNING: SWIG bindings are deprecated as of KiCAD 9.0
and will be removed in KiCAD 10.0. and will be removed in KiCAD 10.0.
Please migrate to IPC backend. Please migrate to IPC backend.
""" """
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any, List
from kicad_api.base import ( from kicad_api.base import KiCADBackend, BoardAPI, ConnectionError, APINotAvailableError
KiCADBackend,
BoardAPI,
ConnectionError,
APINotAvailableError
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,6 +44,7 @@ class SWIGBackend(KiCADBackend):
""" """
try: try:
import pcbnew import pcbnew
self._pcbnew = pcbnew self._pcbnew = pcbnew
version = pcbnew.GetBuildVersion() version = pcbnew.GetBuildVersion()
logger.info(f"✓ Connected to pcbnew (SWIG): {version}") logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
@@ -191,7 +188,7 @@ class SWIGBoardAPI(BoardAPI):
x: float, x: float,
y: float, y: float,
rotation: float = 0, rotation: float = 0,
layer: str = "F.Cu" layer: str = "F.Cu",
) -> bool: ) -> bool:
"""Place component using existing implementation""" """Place component using existing implementation"""
from commands.component import ComponentCommands from commands.component import ComponentCommands
@@ -202,7 +199,7 @@ class SWIGBoardAPI(BoardAPI):
position={"x": x, "y": y, "unit": "mm"}, position={"x": x, "y": y, "unit": "mm"},
reference=reference, reference=reference,
rotation=rotation, rotation=rotation,
layer=layer layer=layer,
) )
return result.get("success", False) return result.get("success", False)
except Exception as e: except Exception as e:

View File

@@ -52,9 +52,7 @@ if sys.platform == "win32":
# List versions # List versions
try: try:
versions = [ versions = [
d d for d in os.listdir(base_path) if os.path.isdir(os.path.join(base_path, 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)}") logger.info(f" Versions found: {', '.join(versions)}")
for version in versions: for version in versions:
@@ -92,9 +90,7 @@ paths_added = PlatformHelper.add_kicad_to_python_path()
if paths_added: if paths_added:
logger.info("Successfully added KiCAD Python paths to sys.path") logger.info("Successfully added KiCAD Python paths to sys.path")
else: else:
logger.warning( logger.warning("No KiCAD Python paths found - attempting to import pcbnew from system path")
"No KiCAD Python paths found - attempting to import pcbnew from system path"
)
logger.info(f"Current Python path: {sys.path}") logger.info(f"Current Python path: {sys.path}")
@@ -434,9 +430,7 @@ class KiCADInterface:
"check_freerouting": self.freerouting_commands.check_freerouting, "check_freerouting": self.freerouting_commands.check_freerouting,
} }
logger.info( logger.info(f"KiCAD interface initialized (backend: {'IPC' if self.use_ipc else 'SWIG'})")
f"KiCAD interface initialized (backend: {'IPC' if self.use_ipc else 'SWIG'})"
)
# Commands that can be handled via IPC for real-time updates # Commands that can be handled via IPC for real-time updates
IPC_CAPABLE_COMMANDS = { IPC_CAPABLE_COMMANDS = {
@@ -475,11 +469,7 @@ class KiCADInterface:
try: try:
# Check if we can use IPC for this command (real-time UI sync) # Check if we can use IPC for this command (real-time UI sync)
if ( if self.use_ipc and self.ipc_board_api and command in self.IPC_CAPABLE_COMMANDS:
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_name = self.IPC_CAPABLE_COMMANDS[command]
ipc_handler = getattr(self, ipc_handler_name, None) ipc_handler = getattr(self, ipc_handler_name, None)
@@ -602,9 +592,7 @@ class KiCADInterface:
# - TypeScript tools use: name, path # - TypeScript tools use: name, path
# - Python schema uses: filename, title # - Python schema uses: filename, title
# - Legacy uses: projectName, path, metadata # - Legacy uses: projectName, path, metadata
project_name = ( 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 # Handle filename parameter - it may contain full path
filename = params.get("filename") filename = params.get("filename")
@@ -665,13 +653,9 @@ class KiCADInterface:
board_path = params.get("boardPath") board_path = params.get("boardPath")
if board_path: if board_path:
board_path_norm = str(Path(board_path).resolve()) board_path_norm = str(Path(board_path).resolve())
current_board_file = ( current_board_file = str(Path(self.board.GetFileName()).resolve()) if self.board else ""
str(Path(self.board.GetFileName()).resolve()) if self.board else ""
)
if board_path_norm != current_board_file: if board_path_norm != current_board_file:
logger.info( logger.info(f"boardPath differs from current board — reloading: {board_path}")
f"boardPath differs from current board — reloading: {board_path}"
)
try: try:
self.board = pcbnew.LoadBoard(board_path) self.board = pcbnew.LoadBoard(board_path)
self._update_command_handlers() self._update_command_handlers()
@@ -689,9 +673,7 @@ class KiCADInterface:
self._current_project_path = project_path self._current_project_path = project_path
local_lib = FootprintLibraryManager(project_path=project_path) local_lib = FootprintLibraryManager(project_path=project_path)
self.component_commands = ComponentCommands(self.board, local_lib) self.component_commands = ComponentCommands(self.board, local_lib)
logger.info( logger.info(f"Reloaded FootprintLibraryManager with project_path={project_path}")
f"Reloaded FootprintLibraryManager with project_path={project_path}"
)
return self.component_commands.place_component(params) return self.component_commands.place_component(params)
@@ -788,9 +770,7 @@ class KiCADInterface:
# Skip lib_symbols section # Skip lib_symbols section
lib_sym_pos = content.find("(lib_symbols") lib_sym_pos = content.find("(lib_symbols")
lib_sym_end = ( lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
)
# Find ALL placed symbol blocks matching the reference (handles duplicates). # Find ALL placed symbol blocks matching the reference (handles duplicates).
# Use content-string search so multi-line KiCAD format is handled correctly: # Use content-string search so multi-line KiCAD format is handled correctly:
@@ -840,9 +820,7 @@ class KiCADInterface:
f.write(content) f.write(content)
deleted_count = len(blocks_to_delete) deleted_count = len(blocks_to_delete)
logger.info( logger.info(f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}")
f"Deleted {deleted_count} instance(s) of {reference} from {sch_file.name}"
)
return { return {
"success": True, "success": True,
"reference": reference, "reference": reference,
@@ -918,9 +896,7 @@ class KiCADInterface:
# Skip lib_symbols section # Skip lib_symbols section
lib_sym_pos = content.find("(lib_symbols") lib_sym_pos = content.find("(lib_symbols")
lib_sym_end = ( lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
)
# Find placed symbol blocks that match the reference # Find placed symbol blocks that match the reference
# Search for (symbol (lib_id "...") ... (property "Reference" "<ref>" ...) ...) # Search for (symbol (lib_id "...") ... (property "Reference" "<ref>" ...) ...)
@@ -1052,9 +1028,7 @@ class KiCADInterface:
# Skip lib_symbols section # Skip lib_symbols section
lib_sym_pos = content.find("(lib_symbols") lib_sym_pos = content.find("(lib_symbols")
lib_sym_end = ( lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
)
# Find the placed symbol block for this reference # Find the placed symbol block for this reference
block_start = block_end = None block_start = block_end = None
@@ -1215,9 +1189,7 @@ class KiCADInterface:
match = find_nearest_pin(points[-1], snap_tolerance) match = find_nearest_pin(points[-1], snap_tolerance)
if match: if match:
ref, pin_num, coords = match ref, pin_num, coords = match
logger.info( logger.info(f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})")
f"Snapped end point {points[-1]} -> {coords} (pin {ref}/{pin_num})"
)
snapped_info.append( snapped_info.append(
f"end snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]" f"end snapped to {ref}/{pin_num} at [{coords[0]}, {coords[1]}]"
) )
@@ -1312,9 +1284,7 @@ class KiCADInterface:
def _handle_create_footprint(self, params): def _handle_create_footprint(self, params):
"""Create a new .kicad_mod footprint file in a .pretty library.""" """Create a new .kicad_mod footprint file in a .pretty library."""
logger.info( logger.info(f"create_footprint: {params.get('name')} in {params.get('libraryPath')}")
f"create_footprint: {params.get('name')} in {params.get('libraryPath')}"
)
try: try:
creator = FootprintCreator() creator = FootprintCreator()
return creator.create_footprint( return creator.create_footprint(
@@ -1358,9 +1328,7 @@ class KiCADInterface:
logger.info("list_footprint_libraries") logger.info("list_footprint_libraries")
try: try:
creator = FootprintCreator() creator = FootprintCreator()
return creator.list_footprint_libraries( return creator.list_footprint_libraries(search_paths=params.get("searchPaths"))
search_paths=params.get("searchPaths")
)
except Exception as e: except Exception as e:
logger.error(f"list_footprint_libraries error: {e}") logger.error(f"list_footprint_libraries error: {e}")
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@@ -1387,9 +1355,7 @@ class KiCADInterface:
def _handle_create_symbol(self, params): def _handle_create_symbol(self, params):
"""Create a new symbol in a .kicad_sym library.""" """Create a new symbol in a .kicad_sym library."""
logger.info( logger.info(f"create_symbol: {params.get('name')} in {params.get('libraryPath')}")
f"create_symbol: {params.get('name')} in {params.get('libraryPath')}"
)
try: try:
creator = SymbolCreator() creator = SymbolCreator()
return creator.create_symbol( return creator.create_symbol(
@@ -1413,9 +1379,7 @@ class KiCADInterface:
def _handle_delete_symbol(self, params): def _handle_delete_symbol(self, params):
"""Delete a symbol from a .kicad_sym library.""" """Delete a symbol from a .kicad_sym library."""
logger.info( logger.info(f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}")
f"delete_symbol: {params.get('name')} from {params.get('libraryPath')}"
)
try: try:
creator = SymbolCreator() creator = SymbolCreator()
return creator.delete_symbol( return creator.delete_symbol(
@@ -1663,8 +1627,7 @@ class KiCADInterface:
if pin_num in pins_def: if pin_num in pins_def:
entry["name"] = pins_def[pin_num].get("name", pin_num) entry["name"] = pins_def[pin_num].get("name", pin_num)
entry["angle"] = ( entry["angle"] = (
locator.get_pin_angle(Path(schematic_path), reference, pin_num) locator.get_pin_angle(Path(schematic_path), reference, pin_num) or 0
or 0
) )
result[pin_num] = entry result[pin_num] = entry
@@ -1747,9 +1710,7 @@ class KiCADInterface:
"message": "cairosvg not installed — returning SVG instead of PNG. Install with: pip install cairosvg", "message": "cairosvg not installed — returning SVG instead of PNG. Install with: pip install cairosvg",
} }
png_data = svg2png( png_data = svg2png(url=svg_path, output_width=width, output_height=height)
url=svg_path, output_width=width, output_height=height
)
return { return {
"success": True, "success": True,
@@ -1814,15 +1775,9 @@ class KiCADInterface:
if ref_prefix_filter and not ref.startswith(ref_prefix_filter): if ref_prefix_filter and not ref.startswith(ref_prefix_filter):
continue continue
value = ( value = symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
symbol.property.Value.value
if hasattr(symbol.property, "Value")
else ""
)
footprint = ( footprint = (
symbol.property.Footprint.value symbol.property.Footprint.value if hasattr(symbol.property, "Footprint") else ""
if hasattr(symbol.property, "Footprint")
else ""
) )
position = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0] position = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0]
uuid_val = symbol.uuid.value if hasattr(symbol, "uuid") else "" uuid_val = symbol.uuid.value if hasattr(symbol, "uuid") else ""
@@ -1849,9 +1804,7 @@ class KiCADInterface:
"position": {"x": coords[0], "y": coords[1]}, "position": {"x": coords[0], "y": coords[1]},
} }
if pin_num in pins_def: if pin_num in pins_def:
pin_info["name"] = pins_def[pin_num].get( pin_info["name"] = pins_def[pin_num].get("name", pin_num)
"name", pin_num
)
pin_list.append(pin_info) pin_list.append(pin_info)
comp["pins"] = pin_list comp["pins"] = pin_list
except Exception: except Exception:
@@ -2016,9 +1969,7 @@ class KiCADInterface:
if not ref.startswith("#PWR"): if not ref.startswith("#PWR"):
continue continue
value = ( value = (
symbol.property.Value.value symbol.property.Value.value if hasattr(symbol.property, "Value") else ref
if hasattr(symbol.property, "Value")
else ref
) )
pos = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0] pos = symbol.at.value if hasattr(symbol, "at") else [0, 0, 0]
labels.append( labels.append(
@@ -2239,9 +2190,7 @@ class KiCADInterface:
start_point = [start.get("x", 0), start.get("y", 0)] start_point = [start.get("x", 0), start.get("y", 0)]
end_point = [end.get("x", 0), end.get("y", 0)] end_point = [end.get("x", 0), end.get("y", 0)]
deleted = WireManager.delete_wire( deleted = WireManager.delete_wire(Path(schematic_path), start_point, end_point)
Path(schematic_path), start_point, end_point
)
if deleted: if deleted:
return {"success": True} return {"success": True}
else: else:
@@ -2453,9 +2402,7 @@ class KiCADInterface:
"errorDetails": "Install KiCAD 8.0+ or add kicad-cli to PATH.", "errorDetails": "Install KiCAD 8.0+ or add kicad-cli to PATH.",
} }
with tempfile.NamedTemporaryFile( with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
mode="w", suffix=".json", delete=False
) as tmp:
json_output = tmp.name json_output = tmp.name
try: try:
@@ -2471,9 +2418,7 @@ class KiCADInterface:
] ]
logger.info(f"Running ERC command: {' '.join(cmd)}") logger.info(f"Running ERC command: {' '.join(cmd)}")
result = subprocess.run( result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
cmd, capture_output=True, text=True, timeout=120
)
if result.returncode != 0: if result.returncode != 0:
logger.error(f"ERC command failed: {result.stderr}") logger.error(f"ERC command failed: {result.stderr}")
@@ -2542,9 +2487,7 @@ class KiCADInterface:
if not schematic: if not schematic:
return {"success": False, "message": "Failed to load schematic"} return {"success": False, "message": "Failed to load schematic"}
netlist = ConnectionManager.generate_netlist( netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path)
schematic, schematic_path=schematic_path
)
return {"success": True, "netlist": netlist} return {"success": True, "netlist": netlist}
except Exception as e: except Exception as e:
logger.error(f"Error generating netlist: {str(e)}") logger.error(f"Error generating netlist: {str(e)}")
@@ -2599,9 +2542,7 @@ class KiCADInterface:
if not schematic: if not schematic:
return {"success": False, "message": "Failed to load schematic"} return {"success": False, "message": "Failed to load schematic"}
netlist = ConnectionManager.generate_netlist( netlist = ConnectionManager.generate_netlist(schematic, schematic_path=schematic_path)
schematic, schematic_path=schematic_path
)
# Build (reference, pad_number) -> net_name map # Build (reference, pad_number) -> net_name map
pad_net_map = {} # {(ref, pin_str): net_name} pad_net_map = {} # {(ref, pin_str): net_name}
@@ -2890,9 +2831,7 @@ class KiCADInterface:
"message": "Missing required parameters: pcbPath, svgPath", "message": "Missing required parameters: pcbPath, svgPath",
} }
result = import_svg_to_pcb( result = import_svg_to_pcb(pcb_path, svg_path, x, y, width, layer, stroke_width, filled)
pcb_path, svg_path, x, y, width, layer, stroke_width, filled
)
# import_svg_to_pcb writes gr_poly entries directly to the .kicad_pcb file, # import_svg_to_pcb writes gr_poly entries directly to the .kicad_pcb file,
# bypassing the pcbnew in-memory board object. Any subsequent board.Save() # bypassing the pcbnew in-memory board object. Any subsequent board.Save()
@@ -2950,9 +2889,7 @@ class KiCADInterface:
prompt_file = None prompt_file = None
if prompt_text: if prompt_text:
prompt_filename = ( prompt_filename = f"PROMPT_step{step}_{ts}.md" if step else f"PROMPT_{ts}.md"
f"PROMPT_step{step}_{ts}.md" if step else f"PROMPT_{ts}.md"
)
prompt_file = logs_dir / prompt_filename prompt_file = logs_dir / prompt_filename
prompt_file.write_text(prompt_text, encoding="utf-8") prompt_file.write_text(prompt_text, encoding="utf-8")
logger.info(f"Prompt saved: {prompt_file}") logger.info(f"Prompt saved: {prompt_file}")
@@ -2962,9 +2899,7 @@ class KiCADInterface:
system = platform.system() system = platform.system()
if system == "Windows": if system == "Windows":
mcp_log_dir = os.path.join( mcp_log_dir = os.path.join(os.environ.get("APPDATA", ""), "Claude", "logs")
os.environ.get("APPDATA", ""), "Claude", "logs"
)
elif system == "Darwin": elif system == "Darwin":
mcp_log_dir = os.path.expanduser("~/Library/Logs/Claude") mcp_log_dir = os.path.expanduser("~/Library/Logs/Claude")
else: else:
@@ -2979,15 +2914,11 @@ class KiCADInterface:
if "Initializing server" in line: if "Initializing server" in line:
session_start = i session_start = i
session_lines = all_lines[session_start:] session_lines = all_lines[session_start:]
log_filename = ( log_filename = f"mcp_log_step{step}_{ts}.txt" if step else f"mcp_log_{ts}.txt"
f"mcp_log_step{step}_{ts}.txt" if step else f"mcp_log_{ts}.txt"
)
mcp_log_dest = logs_dir / log_filename mcp_log_dest = logs_dir / log_filename
with open(mcp_log_dest, "w", encoding="utf-8") as f: with open(mcp_log_dest, "w", encoding="utf-8") as f:
f.writelines(session_lines) f.writelines(session_lines)
logger.info( logger.info(f"MCP session log saved: {mcp_log_dest} ({len(session_lines)} lines)")
f"MCP session log saved: {mcp_log_dest} ({len(session_lines)} lines)"
)
base_name = Path(project_dir).name base_name = Path(project_dir).name
suffix_parts = [p for p in [f"step{step}" if step else "", label, ts] if p] suffix_parts = [p for p in [f"step{step}" if step else "", label, ts] if p]
@@ -2996,9 +2927,7 @@ class KiCADInterface:
snapshots_base.mkdir(exist_ok=True) snapshots_base.mkdir(exist_ok=True)
snapshot_dir = str(snapshots_base / snapshot_name) snapshot_dir = str(snapshots_base / snapshot_name)
shutil.copytree( shutil.copytree(project_dir, snapshot_dir, ignore=shutil.ignore_patterns("snapshots"))
project_dir, snapshot_dir, ignore=shutil.ignore_patterns("snapshots")
)
logger.info(f"Project snapshot saved: {snapshot_dir}") logger.info(f"Project snapshot saved: {snapshot_dir}")
return { return {
"success": True, "success": True,
@@ -3077,9 +3006,7 @@ class KiCADInterface:
} }
self.board.Save(board_path) self.board.Save(board_path)
zone_count = ( zone_count = self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0
self.board.GetAreaCount() if hasattr(self.board, "GetAreaCount") else 0
)
# Run pcbnew zone fill in an isolated subprocess to prevent crashes # Run pcbnew zone fill in an isolated subprocess to prevent crashes
import subprocess, sys, textwrap import subprocess, sys, textwrap
@@ -3117,11 +3044,7 @@ print("ok")
"success": False, "success": False,
"message": "Zone fill failed in subprocess — zones are defined and will fill when opened in KiCAD (press B). Continuing is safe.", "message": "Zone fill failed in subprocess — zones are defined and will fill when opened in KiCAD (press B). Continuing is safe.",
"zoneCount": zone_count, "zoneCount": zone_count,
"details": ( "details": (result.stderr[:300] if result.stderr else result.stdout[:300]),
result.stderr[:300]
if result.stderr
else result.stdout[:300]
),
} }
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
logger.warning("Zone fill subprocess timed out after 60s") logger.warning("Zone fill subprocess timed out after 60s")
@@ -3151,16 +3074,8 @@ print("ok")
net = params.get("net") net = params.get("net")
# Handle both dict format and direct x/y # Handle both dict format and direct x/y
start_x = ( start_x = start.get("x", 0) if isinstance(start, dict) else params.get("startX", 0)
start.get("x", 0) start_y = start.get("y", 0) if isinstance(start, dict) else params.get("startY", 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_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) end_y = end.get("y", 0) if isinstance(end, dict) else params.get("endY", 0)
@@ -3177,9 +3092,7 @@ print("ok")
return { return {
"success": success, "success": success,
"message": ( "message": (
"Added trace (visible in KiCAD UI)" "Added trace (visible in KiCAD UI)" if success else "Failed to add trace"
if success
else "Failed to add trace"
), ),
"trace": { "trace": {
"start": {"x": start_x, "y": start_y, "unit": "mm"}, "start": {"x": start_x, "y": start_y, "unit": "mm"},
@@ -3197,16 +3110,8 @@ print("ok")
"""IPC handler for add_via - adds via with real-time UI update""" """IPC handler for add_via - adds via with real-time UI update"""
try: try:
position = params.get("position", {}) position = params.get("position", {})
x = ( x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0)
position.get("x", 0) y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 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) size = params.get("size", 0.8)
drill = params.get("drill", 0.4) drill = params.get("drill", 0.4)
@@ -3220,11 +3125,7 @@ print("ok")
return { return {
"success": success, "success": success,
"message": ( "message": ("Added via (visible in KiCAD UI)" if success else "Failed to add via"),
"Added via (visible in KiCAD UI)"
if success
else "Failed to add via"
),
"via": { "via": {
"position": {"x": x, "y": y, "unit": "mm"}, "position": {"x": x, "y": y, "unit": "mm"},
"size": size, "size": size,
@@ -3271,9 +3172,7 @@ print("ok")
# Convert points format if needed (handle both {x, y} and {x, y, unit}) # Convert points format if needed (handle both {x, y} and {x, y, unit})
formatted_points = [] formatted_points = []
for point in points: for point in points:
formatted_points.append( formatted_points.append({"x": point.get("x", 0), "y": point.get("y", 0)})
{"x": point.get("x", 0), "y": point.get("y", 0)}
)
success = self.ipc_board_api.add_zone( success = self.ipc_board_api.add_zone(
points=formatted_points, points=formatted_points,
@@ -3315,9 +3214,7 @@ print("ok")
return { return {
"success": success, "success": success,
"message": ( "message": (
"Zones refilled (visible in KiCAD UI)" "Zones refilled (visible in KiCAD UI)" if success else "Failed to refill zones"
if success
else "Failed to refill zones"
), ),
} }
except Exception as e: except Exception as e:
@@ -3329,16 +3226,8 @@ print("ok")
try: try:
text = params.get("text", "") text = params.get("text", "")
position = params.get("position", {}) position = params.get("position", {})
x = ( x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0)
position.get("x", 0) y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 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") layer = params.get("layer", "F.SilkS")
size = params.get("size", 1.0) size = params.get("size", 1.0)
rotation = params.get("rotation", 0) rotation = params.get("rotation", 0)
@@ -3412,16 +3301,8 @@ print("ok")
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
footprint = params.get("footprint", "") footprint = params.get("footprint", "")
position = params.get("position", {}) position = params.get("position", {})
x = ( x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0)
position.get("x", 0) y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 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) rotation = params.get("rotation", 0)
layer = params.get("layer", "F.Cu") layer = params.get("layer", "F.Cu")
value = params.get("value", "") value = params.get("value", "")
@@ -3460,16 +3341,8 @@ print("ok")
try: try:
reference = params.get("reference", params.get("componentId", "")) reference = params.get("reference", params.get("componentId", ""))
position = params.get("position", {}) position = params.get("position", {})
x = ( x = position.get("x", 0) if isinstance(position, dict) else params.get("x", 0)
position.get("x", 0) y = position.get("y", 0) if isinstance(position, dict) else params.get("y", 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") rotation = params.get("rotation")
success = self.ipc_board_api.move_component( success = self.ipc_board_api.move_component(
@@ -3534,9 +3407,7 @@ print("ok")
"""IPC handler for delete_trace - Note: IPC doesn't support direct trace deletion yet""" """IPC handler for delete_trace - Note: IPC doesn't support direct trace deletion yet"""
# IPC API doesn't have a direct delete track method # IPC API doesn't have a direct delete track method
# Fall back to SWIG for this operation # Fall back to SWIG for this operation
logger.info( logger.info("delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)")
"delete_trace: Falling back to SWIG (IPC doesn't support trace deletion)"
)
return self.routing_commands.delete_trace(params) return self.routing_commands.delete_trace(params)
def _ipc_get_nets_list(self, params): def _ipc_get_nets_list(self, params):
@@ -3594,9 +3465,7 @@ print("ok")
segment.start = Vector2.from_xy( segment.start = Vector2.from_xy(
from_mm(start.get("x", 0)), from_mm(start.get("y", 0)) from_mm(start.get("x", 0)), from_mm(start.get("y", 0))
) )
segment.end = Vector2.from_xy( segment.end = Vector2.from_xy(from_mm(end.get("x", 0)), from_mm(end.get("y", 0)))
from_mm(end.get("x", 0)), from_mm(end.get("y", 0))
)
segment.layer = BoardLayer.BL_Edge_Cuts segment.layer = BoardLayer.BL_Edge_Cuts
segment.attributes.stroke.width = from_mm(width) segment.attributes.stroke.width = from_mm(width)
@@ -3731,9 +3600,7 @@ print("ok")
"success": True, "success": True,
"backend": "ipc" if self.use_ipc else "swig", "backend": "ipc" if self.use_ipc else "swig",
"realtime_sync": self.use_ipc, "realtime_sync": self.use_ipc,
"ipc_connected": ( "ipc_connected": (self.ipc_backend.is_connected() if self.ipc_backend else False),
self.ipc_backend.is_connected() if self.ipc_backend else False
),
"version": self.ipc_backend.get_version() if self.ipc_backend else "N/A", "version": self.ipc_backend.get_version() if self.ipc_backend else "N/A",
"message": ( "message": (
"Using IPC backend with real-time UI sync" "Using IPC backend with real-time UI sync"
@@ -3760,9 +3627,7 @@ print("ok")
return { return {
"success": success, "success": success,
"message": ( "message": (
"Track added (visible in KiCAD UI)" "Track added (visible in KiCAD UI)" if success else "Failed to add track"
if success
else "Failed to add track"
), ),
"realtime": True, "realtime": True,
} }
@@ -3786,11 +3651,7 @@ print("ok")
) )
return { return {
"success": success, "success": success,
"message": ( "message": ("Via added (visible in KiCAD UI)" if success else "Failed to add via"),
"Via added (visible in KiCAD UI)"
if success
else "Failed to add via"
),
"realtime": True, "realtime": True,
} }
except Exception as e: except Exception as e:
@@ -3814,9 +3675,7 @@ print("ok")
return { return {
"success": success, "success": success,
"message": ( "message": (
"Text added (visible in KiCAD UI)" "Text added (visible in KiCAD UI)" if success else "Failed to add text"
if success
else "Failed to add text"
), ),
"realtime": True, "realtime": True,
} }
@@ -3979,9 +3838,7 @@ print("ok")
return {"success": False, "message": f"Part not found: {lcsc_number}"} return {"success": False, "message": f"Part not found: {lcsc_number}"}
# Get suggested KiCAD footprints # Get suggested KiCAD footprints
footprints = self.jlcpcb_parts.map_package_to_footprint( footprints = self.jlcpcb_parts.map_package_to_footprint(part.get("package", ""))
part.get("package", "")
)
return {"success": True, "part": part, "footprints": footprints} return {"success": True, "part": part, "footprints": footprints}
@@ -4013,9 +3870,7 @@ print("ok")
reference_price = None reference_price = None
if original_part and original_part.get("price_breaks"): if original_part and original_part.get("price_breaks"):
try: try:
reference_price = float( reference_price = float(original_part["price_breaks"][0].get("price", 0))
original_part["price_breaks"][0].get("price", 0)
)
except: except:
pass pass
@@ -4141,9 +3996,7 @@ def main():
tools.append(tool_def) tools.append(tool_def)
else: else:
# Fallback for tools without schemas # Fallback for tools without schemas
logger.warning( logger.warning(f"No schema defined for tool: {cmd_name}")
f"No schema defined for tool: {cmd_name}"
)
tools.append( tools.append(
{ {
"name": cmd_name, "name": cmd_name,
@@ -4172,11 +4025,7 @@ def main():
response = { response = {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": request_id, "id": request_id,
"result": { "result": {"content": [{"type": "text", "text": json.dumps(result)}]},
"content": [
{"type": "text", "text": json.dumps(result)}
]
},
} }
elif method == "resources/list": elif method == "resources/list":
logger.info("Handling MCP resources/list") logger.info("Handling MCP resources/list")
@@ -4201,9 +4050,7 @@ def main():
} }
else: else:
# Read the resource # Read the resource
resource_data = handle_resource_read( resource_data = handle_resource_read(resource_uri, interface)
resource_uri, interface
)
response = { response = {
"jsonrpc": "2.0", "jsonrpc": "2.0",

View File

@@ -27,6 +27,7 @@ logger = logging.getLogger("kicad_interface")
# Public API # Public API
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]: def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
""" """
Parse a .kicad_mod file and return a dict whose keys match the fields Parse a .kicad_mod file and return a dict whose keys match the fields
@@ -56,7 +57,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE) m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE)
if not m: if not m:
# Older / unquoted format # Older / unquoted format
m = re.search(r'^\s*\(footprint\s+(\S+)', content, re.MULTILINE) m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE)
result["name"] = _unescape(m.group(1)) if m else path.stem result["name"] = _unescape(m.group(1)) if m else path.stem
logger.debug(f"parse_kicad_mod: name={result['name']!r}") logger.debug(f"parse_kicad_mod: name={result['name']!r}")
@@ -78,7 +79,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
# Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom]) # Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom])
# TYPE is smd | through_hole (no quotes) # TYPE is smd | through_hole (no quotes)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
m = re.search(r'\(attr\s+([^)]+)\)', content) m = re.search(r"\(attr\s+([^)]+)\)", content)
if m: if m:
tokens = m.group(1).split() tokens = m.group(1).split()
result["attributes"] = { result["attributes"] = {
@@ -107,7 +108,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
layers: set = set() layers: set = set()
for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content): for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content):
layers.add(m.group(1)) layers.add(m.group(1))
for m in re.finditer(r'\(layers\s+([^)]+)\)', content): for m in re.finditer(r"\(layers\s+([^)]+)\)", content):
for lyr in re.findall(r'"([^"]+)"', m.group(1)): for lyr in re.findall(r'"([^"]+)"', m.group(1)):
layers.add(lyr) layers.add(lyr)
result["layers"] = sorted(layers) result["layers"] = sorted(layers)
@@ -128,6 +129,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
# Internal helpers # Internal helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _extract_pads(content: str) -> List[Dict[str, Any]]: def _extract_pads(content: str) -> List[Dict[str, Any]]:
""" """
Parse all (pad …) blocks and return a list of pad objects. Parse all (pad …) blocks and return a list of pad objects.
@@ -147,8 +149,8 @@ def _extract_pads(content: str) -> List[Dict[str, Any]]:
# KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …) # KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …)
quoted_pattern = re.compile( quoted_pattern = re.compile(
r'\(pad\s+"([^"]*)"\s+' r'\(pad\s+"([^"]*)"\s+'
r'(thru_hole|smd|np_thru_hole|connect)\s+' r"(thru_hole|smd|np_thru_hole|connect)\s+"
r'(rect|circle|oval|roundrect|trapezoid|custom)\b' r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
) )
for m in quoted_pattern.finditer(content): for m in quoted_pattern.finditer(content):
number, ptype, shape = m.group(1), m.group(2), m.group(3) number, ptype, shape = m.group(1), m.group(2), m.group(3)
@@ -159,9 +161,9 @@ def _extract_pads(content: str) -> List[Dict[str, Any]]:
if not pads: if not pads:
# Older / unquoted format: (pad NUMBER TYPE SHAPE …) # Older / unquoted format: (pad NUMBER TYPE SHAPE …)
unquoted_pattern = re.compile( unquoted_pattern = re.compile(
r'\(pad\s+(\S+)\s+' r"\(pad\s+(\S+)\s+"
r'(thru_hole|smd|np_thru_hole|connect)\s+' r"(thru_hole|smd|np_thru_hole|connect)\s+"
r'(rect|circle|oval|roundrect|trapezoid|custom)\b' r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
) )
for m in unquoted_pattern.finditer(content): for m in unquoted_pattern.finditer(content):
number, ptype, shape = m.group(1), m.group(2), m.group(3) number, ptype, shape = m.group(1), m.group(2), m.group(3)
@@ -183,7 +185,7 @@ def _extract_blocks(content: str, token: str) -> List[str]:
parenthesis depth. This correctly handles nested parens inside blocks. parenthesis depth. This correctly handles nested parens inside blocks.
""" """
blocks: List[str] = [] blocks: List[str] = []
pattern = re.compile(r'\(' + re.escape(token) + r'\b') pattern = re.compile(r"\(" + re.escape(token) + r"\b")
for match in pattern.finditer(content): for match in pattern.finditer(content):
start = match.start() start = match.start()
@@ -191,9 +193,9 @@ def _extract_blocks(content: str, token: str) -> List[str]:
i = start i = start
while i < len(content): while i < len(content):
ch = content[i] ch = content[i]
if ch == '(': if ch == "(":
depth += 1 depth += 1
elif ch == ')': elif ch == ")":
depth -= 1 depth -= 1
if depth == 0: if depth == 0:
blocks.append(content[start : i + 1]) blocks.append(content[start : i + 1])
@@ -219,8 +221,8 @@ def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
for block in _extract_blocks(content, "fp_rect"): for block in _extract_blocks(content, "fp_rect"):
if "F.CrtYd" not in block: if "F.CrtYd" not in block:
continue continue
s = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', block) s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block)
e = re.search(r'\(end\s+([-\d.]+)\s+([-\d.]+)\)', block) e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block)
if s and e: if s and e:
xs += [float(s.group(1)), float(e.group(1))] xs += [float(s.group(1)), float(e.group(1))]
ys += [float(s.group(2)), float(e.group(2))] ys += [float(s.group(2)), float(e.group(2))]
@@ -234,7 +236,7 @@ def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
for block in _extract_blocks(content, "fp_line"): for block in _extract_blocks(content, "fp_line"):
if "F.CrtYd" not in block: if "F.CrtYd" not in block:
continue continue
for m in re.finditer(r'\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)', block): for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block):
xs.append(float(m.group(1))) xs.append(float(m.group(1)))
ys.append(float(m.group(2))) ys.append(float(m.group(2)))

View File

@@ -4,4 +4,4 @@ Resource definitions for KiCAD MCP Server
from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
__all__ = ['RESOURCE_DEFINITIONS', 'handle_resource_read'] __all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"]

View File

@@ -10,7 +10,7 @@ import base64
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
import logging import logging
logger = logging.getLogger('kicad_interface') logger = logging.getLogger("kicad_interface")
# ============================================================================= # =============================================================================
# RESOURCE DEFINITIONS # RESOURCE DEFINITIONS
@@ -21,56 +21,57 @@ RESOURCE_DEFINITIONS = [
"uri": "kicad://project/current/info", "uri": "kicad://project/current/info",
"name": "Current Project Information", "name": "Current Project Information",
"description": "Metadata about the currently open KiCAD project including paths, name, and status", "description": "Metadata about the currently open KiCAD project including paths, name, and status",
"mimeType": "application/json" "mimeType": "application/json",
}, },
{ {
"uri": "kicad://project/current/board", "uri": "kicad://project/current/board",
"name": "Board Properties", "name": "Board Properties",
"description": "Comprehensive board information including dimensions, layer count, and design rules", "description": "Comprehensive board information including dimensions, layer count, and design rules",
"mimeType": "application/json" "mimeType": "application/json",
}, },
{ {
"uri": "kicad://project/current/components", "uri": "kicad://project/current/components",
"name": "Component List", "name": "Component List",
"description": "List of all components on the board with references, footprints, values, and positions", "description": "List of all components on the board with references, footprints, values, and positions",
"mimeType": "application/json" "mimeType": "application/json",
}, },
{ {
"uri": "kicad://project/current/nets", "uri": "kicad://project/current/nets",
"name": "Electrical Nets", "name": "Electrical Nets",
"description": "List of all electrical nets and their connections", "description": "List of all electrical nets and their connections",
"mimeType": "application/json" "mimeType": "application/json",
}, },
{ {
"uri": "kicad://project/current/layers", "uri": "kicad://project/current/layers",
"name": "Layer Stack", "name": "Layer Stack",
"description": "Board layer configuration and properties", "description": "Board layer configuration and properties",
"mimeType": "application/json" "mimeType": "application/json",
}, },
{ {
"uri": "kicad://project/current/design-rules", "uri": "kicad://project/current/design-rules",
"name": "Design Rules", "name": "Design Rules",
"description": "Current design rule settings for clearances, track widths, and constraints", "description": "Current design rule settings for clearances, track widths, and constraints",
"mimeType": "application/json" "mimeType": "application/json",
}, },
{ {
"uri": "kicad://project/current/drc-report", "uri": "kicad://project/current/drc-report",
"name": "DRC Violations", "name": "DRC Violations",
"description": "Design Rule Check violations and warnings from last DRC run", "description": "Design Rule Check violations and warnings from last DRC run",
"mimeType": "application/json" "mimeType": "application/json",
}, },
{ {
"uri": "kicad://board/preview.png", "uri": "kicad://board/preview.png",
"name": "Board Preview Image", "name": "Board Preview Image",
"description": "2D rendering of the current board state", "description": "2D rendering of the current board state",
"mimeType": "image/png" "mimeType": "image/png",
} },
] ]
# ============================================================================= # =============================================================================
# RESOURCE READ HANDLERS # RESOURCE READ HANDLERS
# ============================================================================= # =============================================================================
def handle_resource_read(uri: str, interface) -> Dict[str, Any]: def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
""" """
Handle reading a resource by URI Handle reading a resource by URI
@@ -92,7 +93,7 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
"kicad://project/current/layers": _get_layers, "kicad://project/current/layers": _get_layers,
"kicad://project/current/design-rules": _get_design_rules, "kicad://project/current/design-rules": _get_design_rules,
"kicad://project/current/drc-report": _get_drc_report, "kicad://project/current/drc-report": _get_drc_report,
"kicad://board/preview.png": _get_board_preview "kicad://board/preview.png": _get_board_preview,
} }
handler = handlers.get(uri) handler = handlers.get(uri)
@@ -102,67 +103,71 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
except Exception as e: except Exception as e:
logger.error(f"Error reading resource {uri}: {str(e)}") logger.error(f"Error reading resource {uri}: {str(e)}")
return { return {
"contents": [{ "contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}]
"uri": uri,
"mimeType": "text/plain",
"text": f"Error: {str(e)}"
}]
} }
else: else:
return { return {
"contents": [{ "contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}]
"uri": uri,
"mimeType": "text/plain",
"text": f"Unknown resource: {uri}"
}]
} }
# ============================================================================= # =============================================================================
# INDIVIDUAL RESOURCE HANDLERS # INDIVIDUAL RESOURCE HANDLERS
# ============================================================================= # =============================================================================
def _get_project_info(interface) -> Dict[str, Any]: def _get_project_info(interface) -> Dict[str, Any]:
"""Get current project information""" """Get current project information"""
result = interface.project_commands.get_project_info({}) result = interface.project_commands.get_project_info({})
if result.get("success"): if result.get("success"):
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/info", {
"mimeType": "application/json", "uri": "kicad://project/current/info",
"text": json.dumps(result.get("project", {}), indent=2) "mimeType": "application/json",
}] "text": json.dumps(result.get("project", {}), indent=2),
}
]
} }
else: else:
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/info", {
"mimeType": "text/plain", "uri": "kicad://project/current/info",
"text": "No project currently open" "mimeType": "text/plain",
}] "text": "No project currently open",
}
]
} }
def _get_board_info(interface) -> Dict[str, Any]: def _get_board_info(interface) -> Dict[str, Any]:
"""Get board properties and metadata""" """Get board properties and metadata"""
result = interface.board_commands.get_board_info({}) result = interface.board_commands.get_board_info({})
if result.get("success"): if result.get("success"):
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/board", {
"mimeType": "application/json", "uri": "kicad://project/current/board",
"text": json.dumps(result.get("board", {}), indent=2) "mimeType": "application/json",
}] "text": json.dumps(result.get("board", {}), indent=2),
}
]
} }
else: else:
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/board", {
"mimeType": "text/plain", "uri": "kicad://project/current/board",
"text": "No board currently loaded" "mimeType": "text/plain",
}] "text": "No board currently loaded",
}
]
} }
def _get_components(interface) -> Dict[str, Any]: def _get_components(interface) -> Dict[str, Any]:
"""Get list of all components""" """Get list of all components"""
result = interface.component_commands.get_component_list({}) result = interface.component_commands.get_component_list({})
@@ -170,24 +175,28 @@ def _get_components(interface) -> Dict[str, Any]:
if result.get("success"): if result.get("success"):
components = result.get("components", []) components = result.get("components", [])
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/components", {
"mimeType": "application/json", "uri": "kicad://project/current/components",
"text": json.dumps({ "mimeType": "application/json",
"count": len(components), "text": json.dumps(
"components": components {"count": len(components), "components": components}, indent=2
}, indent=2) ),
}] }
]
} }
else: else:
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/components", {
"mimeType": "application/json", "uri": "kicad://project/current/components",
"text": json.dumps({"count": 0, "components": []}, indent=2) "mimeType": "application/json",
}] "text": json.dumps({"count": 0, "components": []}, indent=2),
}
]
} }
def _get_nets(interface) -> Dict[str, Any]: def _get_nets(interface) -> Dict[str, Any]:
"""Get list of electrical nets""" """Get list of electrical nets"""
result = interface.routing_commands.get_nets_list({}) result = interface.routing_commands.get_nets_list({})
@@ -195,24 +204,26 @@ def _get_nets(interface) -> Dict[str, Any]:
if result.get("success"): if result.get("success"):
nets = result.get("nets", []) nets = result.get("nets", [])
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/nets", {
"mimeType": "application/json", "uri": "kicad://project/current/nets",
"text": json.dumps({ "mimeType": "application/json",
"count": len(nets), "text": json.dumps({"count": len(nets), "nets": nets}, indent=2),
"nets": nets }
}, indent=2) ]
}]
} }
else: else:
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/nets", {
"mimeType": "application/json", "uri": "kicad://project/current/nets",
"text": json.dumps({"count": 0, "nets": []}, indent=2) "mimeType": "application/json",
}] "text": json.dumps({"count": 0, "nets": []}, indent=2),
}
]
} }
def _get_layers(interface) -> Dict[str, Any]: def _get_layers(interface) -> Dict[str, Any]:
"""Get layer stack information""" """Get layer stack information"""
result = interface.board_commands.get_layer_list({}) result = interface.board_commands.get_layer_list({})
@@ -220,45 +231,52 @@ def _get_layers(interface) -> Dict[str, Any]:
if result.get("success"): if result.get("success"):
layers = result.get("layers", []) layers = result.get("layers", [])
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/layers", {
"mimeType": "application/json", "uri": "kicad://project/current/layers",
"text": json.dumps({ "mimeType": "application/json",
"count": len(layers), "text": json.dumps({"count": len(layers), "layers": layers}, indent=2),
"layers": layers }
}, indent=2) ]
}]
} }
else: else:
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/layers", {
"mimeType": "application/json", "uri": "kicad://project/current/layers",
"text": json.dumps({"count": 0, "layers": []}, indent=2) "mimeType": "application/json",
}] "text": json.dumps({"count": 0, "layers": []}, indent=2),
}
]
} }
def _get_design_rules(interface) -> Dict[str, Any]: def _get_design_rules(interface) -> Dict[str, Any]:
"""Get design rule settings""" """Get design rule settings"""
result = interface.design_rule_commands.get_design_rules({}) result = interface.design_rule_commands.get_design_rules({})
if result.get("success"): if result.get("success"):
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/design-rules", {
"mimeType": "application/json", "uri": "kicad://project/current/design-rules",
"text": json.dumps(result.get("rules", {}), indent=2) "mimeType": "application/json",
}] "text": json.dumps(result.get("rules", {}), indent=2),
}
]
} }
else: else:
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/design-rules", {
"mimeType": "text/plain", "uri": "kicad://project/current/design-rules",
"text": "Design rules not available" "mimeType": "text/plain",
}] "text": "Design rules not available",
}
]
} }
def _get_drc_report(interface) -> Dict[str, Any]: def _get_drc_report(interface) -> Dict[str, Any]:
"""Get DRC violations""" """Get DRC violations"""
result = interface.design_rule_commands.get_drc_violations({}) result = interface.design_rule_commands.get_drc_violations({})
@@ -266,28 +284,35 @@ def _get_drc_report(interface) -> Dict[str, Any]:
if result.get("success"): if result.get("success"):
violations = result.get("violations", []) violations = result.get("violations", [])
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/drc-report", {
"mimeType": "application/json", "uri": "kicad://project/current/drc-report",
"text": json.dumps({ "mimeType": "application/json",
"count": len(violations), "text": json.dumps(
"violations": violations {"count": len(violations), "violations": violations}, indent=2
}, indent=2) ),
}] }
]
} }
else: else:
return { return {
"contents": [{ "contents": [
"uri": "kicad://project/current/drc-report", {
"mimeType": "application/json", "uri": "kicad://project/current/drc-report",
"text": json.dumps({ "mimeType": "application/json",
"count": 0, "text": json.dumps(
"violations": [], {
"message": "Run DRC first to get violations" "count": 0,
}, indent=2) "violations": [],
}] "message": "Run DRC first to get violations",
},
indent=2,
),
}
]
} }
def _get_board_preview(interface) -> Dict[str, Any]: def _get_board_preview(interface) -> Dict[str, Any]:
"""Get board preview as PNG image""" """Get board preview as PNG image"""
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600}) result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})
@@ -296,18 +321,22 @@ def _get_board_preview(interface) -> Dict[str, Any]:
# Image data should already be base64 encoded # Image data should already be base64 encoded
image_data = result.get("imageData", "") image_data = result.get("imageData", "")
return { return {
"contents": [{ "contents": [
"uri": "kicad://board/preview.png", {
"mimeType": "image/png", "uri": "kicad://board/preview.png",
"blob": image_data # Base64 encoded PNG "mimeType": "image/png",
}] "blob": image_data, # Base64 encoded PNG
}
]
} }
else: else:
# Return a placeholder message # Return a placeholder message
return { return {
"contents": [{ "contents": [
"uri": "kicad://board/preview.png", {
"mimeType": "text/plain", "uri": "kicad://board/preview.png",
"text": "Board preview not available" "mimeType": "text/plain",
}] "text": "Board preview not available",
}
]
} }

View File

@@ -4,4 +4,4 @@ Tool schema definitions for KiCAD MCP Server
from .tool_schemas import TOOL_SCHEMAS from .tool_schemas import TOOL_SCHEMAS
__all__ = ['TOOL_SCHEMAS'] __all__ = ["TOOL_SCHEMAS"]

View File

@@ -13,6 +13,7 @@ Prerequisites:
Usage: Usage:
./venv/bin/python python/test_ipc_backend.py ./venv/bin/python python/test_ipc_backend.py
""" """
import sys import sys
import os import os
@@ -23,17 +24,16 @@ import logging
# Set up logging # Set up logging
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def test_connection(): def test_connection():
"""Test basic IPC connection to KiCAD.""" """Test basic IPC connection to KiCAD."""
print("\n" + "="*60) print("\n" + "=" * 60)
print("TEST 1: IPC Connection") print("TEST 1: IPC Connection")
print("="*60) print("=" * 60)
try: try:
from kicad_api.ipc_backend import IPCBackend from kicad_api.ipc_backend import IPCBackend
@@ -64,9 +64,9 @@ def test_connection():
def test_board_access(backend): def test_board_access(backend):
"""Test board access and component listing.""" """Test board access and component listing."""
print("\n" + "="*60) print("\n" + "=" * 60)
print("TEST 2: Board Access") print("TEST 2: Board Access")
print("="*60) print("=" * 60)
try: try:
board_api = backend.get_board() board_api = backend.get_board()
@@ -79,11 +79,11 @@ def test_board_access(backend):
if components: if components:
print("\n First 5 components:") print("\n First 5 components:")
for comp in components[:5]: for comp in components[:5]:
ref = comp.get('reference', 'N/A') ref = comp.get("reference", "N/A")
val = comp.get('value', 'N/A') val = comp.get("value", "N/A")
pos = comp.get('position', {}) pos = comp.get("position", {})
x = pos.get('x', 0) x = pos.get("x", 0)
y = pos.get('y', 0) y = pos.get("y", 0)
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm") print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
return board_api return board_api
@@ -95,9 +95,9 @@ def test_board_access(backend):
def test_board_info(board_api): def test_board_info(board_api):
"""Test getting board information.""" """Test getting board information."""
print("\n" + "="*60) print("\n" + "=" * 60)
print("TEST 3: Board Information") print("TEST 3: Board Information")
print("="*60) print("=" * 60)
try: try:
# Get board size # Get board size
@@ -136,28 +136,23 @@ def test_board_info(board_api):
def test_realtime_track(board_api, interactive=False): def test_realtime_track(board_api, interactive=False):
"""Test adding a track in real-time (appears immediately in KiCAD UI).""" """Test adding a track in real-time (appears immediately in KiCAD UI)."""
print("\n" + "="*60) print("\n" + "=" * 60)
print("TEST 4: Real-time Track Addition") print("TEST 4: Real-time Track Addition")
print("="*60) print("=" * 60)
print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.") print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.")
print("Watch the KiCAD window!") print("Watch the KiCAD window!")
if interactive: if interactive:
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower() response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
if response != 'y': if response != "y":
print("Skipped track test") print("Skipped track test")
return False return False
try: try:
# Add a track # Add a track
success = board_api.add_track( success = board_api.add_track(
start_x=100.0, start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu"
start_y=100.0,
end_x=120.0,
end_y=100.0,
width=0.25,
layer="F.Cu"
) )
if success: if success:
@@ -175,28 +170,22 @@ def test_realtime_track(board_api, interactive=False):
def test_realtime_via(board_api, interactive=False): def test_realtime_via(board_api, interactive=False):
"""Test adding a via in real-time (appears immediately in KiCAD UI).""" """Test adding a via in real-time (appears immediately in KiCAD UI)."""
print("\n" + "="*60) print("\n" + "=" * 60)
print("TEST 5: Real-time Via Addition") print("TEST 5: Real-time Via Addition")
print("="*60) print("=" * 60)
print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.") print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.")
print("Watch the KiCAD window!") print("Watch the KiCAD window!")
if interactive: if interactive:
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower() response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
if response != 'y': if response != "y":
print("Skipped via test") print("Skipped via test")
return False return False
try: try:
# Add a via # Add a via
success = board_api.add_via( success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through")
x=120.0,
y=100.0,
diameter=0.8,
drill=0.4,
via_type="through"
)
if success: if success:
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm") print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
@@ -213,26 +202,20 @@ def test_realtime_via(board_api, interactive=False):
def test_realtime_text(board_api, interactive=False): def test_realtime_text(board_api, interactive=False):
"""Test adding text in real-time.""" """Test adding text in real-time."""
print("\n" + "="*60) print("\n" + "=" * 60)
print("TEST 6: Real-time Text Addition") print("TEST 6: Real-time Text Addition")
print("="*60) print("=" * 60)
print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.") print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.")
if interactive: if interactive:
response = input("\nProceed with adding test text? [y/N]: ").strip().lower() response = input("\nProceed with adding test text? [y/N]: ").strip().lower()
if response != 'y': if response != "y":
print("Skipped text test") print("Skipped text test")
return False return False
try: try:
success = board_api.add_text( success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0)
text="MCP Test",
x=100.0,
y=95.0,
layer="F.SilkS",
size=1.0
)
if success: if success:
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm") print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
@@ -248,9 +231,9 @@ def test_realtime_text(board_api, interactive=False):
def test_selection(board_api, interactive=False): def test_selection(board_api, interactive=False):
"""Test getting the current selection from KiCAD UI.""" """Test getting the current selection from KiCAD UI."""
print("\n" + "="*60) print("\n" + "=" * 60)
print("TEST 7: UI Selection") print("TEST 7: UI Selection")
print("="*60) print("=" * 60)
if interactive: if interactive:
print("\nSelect some items in KiCAD, then press Enter...") print("\nSelect some items in KiCAD, then press Enter...")
@@ -274,26 +257,26 @@ def test_selection(board_api, interactive=False):
def run_all_tests(interactive=False): def run_all_tests(interactive=False):
"""Run all IPC backend tests.""" """Run all IPC backend tests."""
print("\n" + "="*60) print("\n" + "=" * 60)
print("KiCAD IPC Backend Test Suite") print("KiCAD IPC Backend Test Suite")
print("="*60) print("=" * 60)
print("\nThis script tests real-time communication with KiCAD via IPC API.") print("\nThis script tests real-time communication with KiCAD via IPC API.")
print("Make sure KiCAD is running with a board open.\n") print("Make sure KiCAD is running with a board open.\n")
# Test connection # Test connection
backend = test_connection() backend = test_connection()
if not backend: if not backend:
print("\n" + "="*60) print("\n" + "=" * 60)
print("TESTS FAILED: Could not connect to KiCAD") print("TESTS FAILED: Could not connect to KiCAD")
print("="*60) print("=" * 60)
return False return False
# Test board access # Test board access
board_api = test_board_access(backend) board_api = test_board_access(backend)
if not board_api: if not board_api:
print("\n" + "="*60) print("\n" + "=" * 60)
print("TESTS FAILED: Could not access board") print("TESTS FAILED: Could not access board")
print("="*60) print("=" * 60)
return False return False
# Test board info # Test board info
@@ -307,9 +290,9 @@ def run_all_tests(interactive=False):
# Test selection # Test selection
test_selection(board_api, interactive) test_selection(board_api, interactive)
print("\n" + "="*60) print("\n" + "=" * 60)
print("TESTS COMPLETE") print("TESTS COMPLETE")
print("="*60) print("=" * 60)
print("\nThe IPC backend is working! Changes appear in real-time.") print("\nThe IPC backend is working! Changes appear in real-time.")
print("No manual reload required - this is the power of the IPC API!") print("No manual reload required - this is the power of the IPC API!")
@@ -321,9 +304,14 @@ def run_all_tests(interactive=False):
if __name__ == "__main__": if __name__ == "__main__":
import argparse import argparse
parser = argparse.ArgumentParser(description='Test KiCAD IPC Backend')
parser.add_argument('-i', '--interactive', action='store_true', parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend")
help='Run in interactive mode (prompts before modifications)') parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="Run in interactive mode (prompts before modifications)",
)
args = parser.parse_args() args = parser.parse_args()
success = run_all_tests(interactive=args.interactive) success = run_all_tests(interactive=args.interactive)

View File

@@ -5,6 +5,7 @@ Key regression: the handler previously used a line-by-line regex that required
`(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts `(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts
them on *separate* lines, so every real-world delete returned "not found". them on *separate* lines, so every real-world delete returned "not found".
""" """
import re import re
import sys import sys
import tempfile import tempfile
@@ -17,7 +18,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
# Inline format (single line) matches what tests previously used # Inline format (single line) matches what tests previously used
PLACED_RESISTOR_INLINE = '''\ PLACED_RESISTOR_INLINE = """\
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1) (symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
(in_bom yes) (on_board yes) (dnp no) (in_bom yes) (on_board yes) (dnp no)
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
@@ -34,11 +35,11 @@ PLACED_RESISTOR_INLINE = '''\
(effects (font (size 1.27 1.27)) hide) (effects (font (size 1.27 1.27)) hide)
) )
) )
''' """
# Multi-line format as KiCAD's own file writer produces it. # Multi-line format as KiCAD's own file writer produces it.
# (symbol and (lib_id are on separate lines, which broke the old regex. # (symbol and (lib_id are on separate lines, which broke the old regex.
PLACED_RESISTOR_MULTILINE = '''\ PLACED_RESISTOR_MULTILINE = """\
\t(symbol \t(symbol
\t\t(lib_id "Device:R") \t\t(lib_id "Device:R")
\t\t(at 50 50 0) \t\t(at 50 50 0)
@@ -64,10 +65,10 @@ PLACED_RESISTOR_MULTILINE = '''\
\t\t\t) \t\t\t)
\t\t) \t\t)
\t) \t)
''' """
# Multi-line power symbol the exact scenario that was reported as broken. # Multi-line power symbol the exact scenario that was reported as broken.
PLACED_POWER_SYMBOL_MULTILINE = '''\ PLACED_POWER_SYMBOL_MULTILINE = """\
\t(symbol \t(symbol
\t\t(lib_id "power:VCC") \t\t(lib_id "power:VCC")
\t\t(at 365.6 38.1 0) \t\t(at 365.6 38.1 0)
@@ -94,7 +95,7 @@ PLACED_POWER_SYMBOL_MULTILINE = '''\
\t\t\t) \t\t\t)
\t\t) \t\t)
\t) \t)
''' """
def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path: def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
@@ -112,6 +113,7 @@ def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
# Unit tests regression proof for the old regex vs the new approach # Unit tests regression proof for the old regex vs the new approach
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.unit @pytest.mark.unit
class TestDeleteDetectionRegex: class TestDeleteDetectionRegex:
"""Verify that the new content-string pattern finds blocks in both formats.""" """Verify that the new content-string pattern finds blocks in both formats."""
@@ -154,10 +156,12 @@ class TestDeleteDetectionRegex:
# Integration tests real file I/O using the handler # Integration tests real file I/O using the handler
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.integration @pytest.mark.integration
class TestDeleteSchematicComponentIntegration: class TestDeleteSchematicComponentIntegration:
def _get_handler(self): def _get_handler(self):
from kicad_interface import KiCADInterface from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface) iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_delete_schematic_component return iface._handle_delete_schematic_component

View File

@@ -47,9 +47,7 @@ def reset_pcbnew_mock():
@pytest.fixture @pytest.fixture
def mock_board(): def mock_board():
board = MagicMock() board = MagicMock()
board.GetFileName.return_value = ( board.GetFileName.return_value = "/tmp/test_project/test.kicad_pcb"
"/tmp/test_project/test.kicad_pcb"
)
board.GetTracks.return_value = [] board.GetTracks.return_value = []
return board return board
@@ -101,15 +99,14 @@ def _patch_no_runtime():
class TestCheckFreerouting: class TestCheckFreerouting:
def test_no_java_no_docker(self, cmds): def test_no_java_no_docker(self, cmds):
with patch( with (
"commands.freerouting._find_java", return_value=None patch("commands.freerouting._find_java", return_value=None),
), patch( patch(
"commands.freerouting._docker_available", "commands.freerouting._docker_available",
return_value=False, return_value=False,
),
): ):
result = cmds.check_freerouting( result = cmds.check_freerouting({"freeroutingJar": "/nonexistent.jar"})
{"freeroutingJar": "/nonexistent.jar"}
)
assert result["success"] is True assert result["success"] is True
assert result["java"]["found"] is False assert result["java"]["found"] is False
assert result["docker"]["available"] is False assert result["docker"]["available"] is False
@@ -119,48 +116,46 @@ class TestCheckFreerouting:
def test_java_too_old_docker_available(self, cmds, tmp_path): def test_java_too_old_docker_available(self, cmds, tmp_path):
jar = tmp_path / "freerouting.jar" jar = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
with patch( with (
"commands.freerouting._find_java", patch(
return_value="/usr/bin/java", "commands.freerouting._find_java",
), patch( return_value="/usr/bin/java",
"commands.freerouting._java_version_ok", ),
return_value=False, patch(
), patch( "commands.freerouting._java_version_ok",
"commands.freerouting._docker_available", return_value=False,
return_value=True, ),
), patch( patch(
"commands.freerouting.subprocess.run" "commands.freerouting._docker_available",
) as mock_run: return_value=True,
mock_run.return_value = MagicMock( ),
stderr='openjdk version "17.0.1"', stdout="" patch("commands.freerouting.subprocess.run") as mock_run,
) ):
result = cmds.check_freerouting( mock_run.return_value = MagicMock(stderr='openjdk version "17.0.1"', stdout="")
{"freeroutingJar": str(jar)} result = cmds.check_freerouting({"freeroutingJar": str(jar)})
)
assert result["ready"] is True assert result["ready"] is True
assert result["execution_mode"] == "docker" assert result["execution_mode"] == "docker"
def test_java_21_direct(self, cmds, tmp_path): def test_java_21_direct(self, cmds, tmp_path):
jar = tmp_path / "freerouting.jar" jar = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
with patch( with (
"commands.freerouting._find_java", patch(
return_value="/usr/bin/java", "commands.freerouting._find_java",
), patch( return_value="/usr/bin/java",
"commands.freerouting._java_version_ok", ),
return_value=True, patch(
), patch( "commands.freerouting._java_version_ok",
"commands.freerouting._docker_available", return_value=True,
return_value=False, ),
), patch( patch(
"commands.freerouting.subprocess.run" "commands.freerouting._docker_available",
) as mock_run: return_value=False,
mock_run.return_value = MagicMock( ),
stderr='openjdk version "21.0.1"', stdout="" patch("commands.freerouting.subprocess.run") as mock_run,
) ):
result = cmds.check_freerouting( mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="")
{"freeroutingJar": str(jar)} result = cmds.check_freerouting({"freeroutingJar": str(jar)})
)
assert result["ready"] is True assert result["ready"] is True
assert result["execution_mode"] == "direct" assert result["execution_mode"] == "direct"
@@ -198,9 +193,7 @@ class TestExportDsn:
assert result["path"] == output assert result["path"] == output
def test_export_failure(self, cmds): def test_export_failure(self, cmds):
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception( pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("DSN error")
"DSN error"
)
result = cmds.export_dsn({}) result = cmds.export_dsn({})
assert result["success"] is False assert result["success"] is False
assert "DSN error" in result["errorDetails"] assert "DSN error" in result["errorDetails"]
@@ -213,9 +206,7 @@ class TestExportDsn:
class TestImportSes: class TestImportSes:
def test_no_board(self, cmds_no_board): def test_no_board(self, cmds_no_board):
result = cmds_no_board.import_ses( result = cmds_no_board.import_ses({"sesPath": "/tmp/test.ses"})
{"sesPath": "/tmp/test.ses"}
)
assert result["success"] is False assert result["success"] is False
assert "No board" in result["message"] assert "No board" in result["message"]
@@ -225,9 +216,7 @@ class TestImportSes:
assert "Missing sesPath" in result["message"] assert "Missing sesPath" in result["message"]
def test_ses_file_not_found(self, cmds): def test_ses_file_not_found(self, cmds):
result = cmds.import_ses( result = cmds.import_ses({"sesPath": "/nonexistent/test.ses"})
{"sesPath": "/nonexistent/test.ses"}
)
assert result["success"] is False assert result["success"] is False
assert "not found" in result["message"] assert "not found" in result["message"]
@@ -245,9 +234,7 @@ class TestImportSes:
ses_file = tmp_path / "test.ses" ses_file = tmp_path / "test.ses"
ses_file.write_text("(session test)") ses_file.write_text("(session test)")
pcbnew_mock.ImportSpecctraSES.side_effect = Exception( pcbnew_mock.ImportSpecctraSES.side_effect = Exception("SES error")
"SES error"
)
result = cmds.import_ses({"sesPath": str(ses_file)}) result = cmds.import_ses({"sesPath": str(ses_file)})
assert result["success"] is False assert result["success"] is False
assert "SES error" in result["errorDetails"] assert "SES error" in result["errorDetails"]
@@ -268,16 +255,12 @@ class TestAutoroute:
jar = tmp_path / "freerouting.jar" jar = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
with _patch_no_runtime(): with _patch_no_runtime():
result = cmds.autoroute( result = cmds.autoroute({"freeroutingJar": str(jar)})
{"freeroutingJar": str(jar)}
)
assert result["success"] is False assert result["success"] is False
assert "No suitable Java runtime" in result["message"] assert "No suitable Java runtime" in result["message"]
def test_no_jar(self, cmds): def test_no_jar(self, cmds):
result = cmds.autoroute( result = cmds.autoroute({"freeroutingJar": "/nonexistent/freerouting.jar"})
{"freeroutingJar": "/nonexistent/freerouting.jar"}
)
assert result["success"] is False assert result["success"] is False
assert "JAR not found" in result["message"] assert "JAR not found" in result["message"]
@@ -286,21 +269,15 @@ class TestAutoroute:
jar = tmp_path / "freerouting.jar" jar = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception( pcbnew_mock.ExportSpecctraDSN.side_effect = Exception("export fail")
"export fail"
)
with _patch_direct_java(): with _patch_direct_java():
result = cmds.autoroute( result = cmds.autoroute({"freeroutingJar": str(jar)})
{"freeroutingJar": str(jar)}
)
assert result["success"] is False assert result["success"] is False
assert "DSN export failed" in result["message"] assert "DSN export failed" in result["message"]
@patch("commands.freerouting.subprocess.run") @patch("commands.freerouting.subprocess.run")
def test_freerouting_timeout( def test_freerouting_timeout(self, mock_run, cmds, tmp_path):
self, mock_run, cmds, tmp_path
):
import subprocess import subprocess
jar = tmp_path / "freerouting.jar" jar = tmp_path / "freerouting.jar"
@@ -312,24 +289,19 @@ class TestAutoroute:
dsn_file = board_dir / "test.dsn" dsn_file = board_dir / "test.dsn"
cmds.board.GetFileName.return_value = str(board_file) cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = ( pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] dsn_file.write_text("(pcb)"),
) True,
mock_run.side_effect = subprocess.TimeoutExpired( )[1]
cmd="", timeout=10 mock_run.side_effect = subprocess.TimeoutExpired(cmd="", timeout=10)
)
with _patch_direct_java(): with _patch_direct_java():
result = cmds.autoroute( result = cmds.autoroute({"freeroutingJar": str(jar), "timeout": 10})
{"freeroutingJar": str(jar), "timeout": 10}
)
assert result["success"] is False assert result["success"] is False
assert "timed out" in result["message"] assert "timed out" in result["message"]
@patch("commands.freerouting.subprocess.run") @patch("commands.freerouting.subprocess.run")
def test_full_success_direct( def test_full_success_direct(self, mock_run, cmds, tmp_path):
self, mock_run, cmds, tmp_path
):
jar = tmp_path / "freerouting.jar" jar = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
board_dir = tmp_path / "project" board_dir = tmp_path / "project"
@@ -341,12 +313,11 @@ class TestAutoroute:
cmds.board.GetFileName.return_value = str(board_file) cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = ( pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] dsn_file.write_text("(pcb)"),
) True,
mock_run.return_value = MagicMock( )[1]
returncode=0, stdout="Routing completed", stderr="" mock_run.return_value = MagicMock(returncode=0, stdout="Routing completed", stderr="")
)
ses_file.write_text("(session)") ses_file.write_text("(session)")
pcbnew_mock.ImportSpecctraSES.return_value = True pcbnew_mock.ImportSpecctraSES.return_value = True
@@ -357,9 +328,7 @@ class TestAutoroute:
cmds.board.GetTracks.return_value = [track, track, via] cmds.board.GetTracks.return_value = [track, track, via]
with _patch_direct_java(): with _patch_direct_java():
result = cmds.autoroute( result = cmds.autoroute({"freeroutingJar": str(jar)})
{"freeroutingJar": str(jar)}
)
assert result["success"] is True assert result["success"] is True
assert result["mode"] == "direct" assert result["mode"] == "direct"
@@ -368,9 +337,7 @@ class TestAutoroute:
assert "elapsed_seconds" in result assert "elapsed_seconds" in result
@patch("commands.freerouting.subprocess.run") @patch("commands.freerouting.subprocess.run")
def test_full_success_docker( def test_full_success_docker(self, mock_run, cmds, tmp_path):
self, mock_run, cmds, tmp_path
):
jar = tmp_path / "freerouting.jar" jar = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
board_dir = tmp_path / "project" board_dir = tmp_path / "project"
@@ -382,24 +349,24 @@ class TestAutoroute:
cmds.board.GetFileName.return_value = str(board_file) cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = ( pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] dsn_file.write_text("(pcb)"),
) True,
mock_run.return_value = MagicMock( )[1]
returncode=0, stdout="Routing completed", stderr="" mock_run.return_value = MagicMock(returncode=0, stdout="Routing completed", stderr="")
)
ses_file.write_text("(session)") ses_file.write_text("(session)")
pcbnew_mock.ImportSpecctraSES.return_value = True pcbnew_mock.ImportSpecctraSES.return_value = True
cmds.board.GetTracks.return_value = [MagicMock()] cmds.board.GetTracks.return_value = [MagicMock()]
with _patch_docker_mode(), patch( with (
"commands.freerouting._find_docker", _patch_docker_mode(),
return_value="/usr/bin/docker", patch(
"commands.freerouting._find_docker",
return_value="/usr/bin/docker",
),
): ):
result = cmds.autoroute( result = cmds.autoroute({"freeroutingJar": str(jar)})
{"freeroutingJar": str(jar)}
)
assert result["success"] is True assert result["success"] is True
assert result["mode"] == "docker" assert result["mode"] == "docker"
@@ -409,9 +376,7 @@ class TestAutoroute:
assert "--rm" in call_args assert "--rm" in call_args
@patch("commands.freerouting.subprocess.run") @patch("commands.freerouting.subprocess.run")
def test_freerouting_nonzero_exit( def test_freerouting_nonzero_exit(self, mock_run, cmds, tmp_path):
self, mock_run, cmds, tmp_path
):
jar = tmp_path / "freerouting.jar" jar = tmp_path / "freerouting.jar"
jar.touch() jar.touch()
board_dir = tmp_path / "project" board_dir = tmp_path / "project"
@@ -421,17 +386,14 @@ class TestAutoroute:
dsn_file = board_dir / "test.dsn" dsn_file = board_dir / "test.dsn"
cmds.board.GetFileName.return_value = str(board_file) cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = ( pcbnew_mock.ExportSpecctraDSN.side_effect = lambda b, p: (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1] dsn_file.write_text("(pcb)"),
) True,
mock_run.return_value = MagicMock( )[1]
returncode=1, stdout="", stderr="OutOfMemoryError" mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="OutOfMemoryError")
)
with _patch_direct_java(): with _patch_direct_java():
result = cmds.autoroute( result = cmds.autoroute({"freeroutingJar": str(jar)})
{"freeroutingJar": str(jar)}
)
assert result["success"] is False assert result["success"] is False
assert "exited with code 1" in result["message"] assert "exited with code 1" in result["message"]
@@ -451,10 +413,13 @@ class TestFindJava:
assert _find_java() == "/usr/bin/java" assert _find_java() == "/usr/bin/java"
def test_none_when_not_found(self): def test_none_when_not_found(self):
with patch( with (
"commands.freerouting.shutil.which", patch(
return_value=None, "commands.freerouting.shutil.which",
), patch("os.path.isfile", return_value=False): return_value=None,
),
patch("os.path.isfile", return_value=False),
):
assert _find_java() is None assert _find_java() is None
@@ -462,18 +427,14 @@ class TestFindDocker:
def test_finds_docker(self): def test_finds_docker(self):
with patch( with patch(
"commands.freerouting.shutil.which", "commands.freerouting.shutil.which",
side_effect=lambda x: "/usr/bin/docker" side_effect=lambda x: "/usr/bin/docker" if x == "docker" else None,
if x == "docker"
else None,
): ):
assert _find_docker() == "/usr/bin/docker" assert _find_docker() == "/usr/bin/docker"
def test_finds_podman(self): def test_finds_podman(self):
with patch( with patch(
"commands.freerouting.shutil.which", "commands.freerouting.shutil.which",
side_effect=lambda x: "/usr/bin/podman" side_effect=lambda x: "/usr/bin/podman" if x == "podman" else None,
if x == "podman"
else None,
): ):
assert _find_docker() == "/usr/bin/podman" assert _find_docker() == "/usr/bin/podman"
@@ -487,12 +448,13 @@ class TestFindDocker:
class TestDockerAvailable: class TestDockerAvailable:
def test_docker_found(self): def test_docker_found(self):
with patch( with (
"commands.freerouting._find_docker", patch(
return_value="/usr/bin/docker", "commands.freerouting._find_docker",
), patch( return_value="/usr/bin/docker",
"commands.freerouting.subprocess.run" ),
) as mock_run: patch("commands.freerouting.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=0) mock_run.return_value = MagicMock(returncode=0)
assert _docker_available() is True assert _docker_available() is True
@@ -504,33 +466,26 @@ class TestDockerAvailable:
assert _docker_available() is False assert _docker_available() is False
def test_docker_not_running(self): def test_docker_not_running(self):
with patch( with (
"commands.freerouting._find_docker", patch(
return_value="/usr/bin/docker", "commands.freerouting._find_docker",
), patch( return_value="/usr/bin/docker",
"commands.freerouting.subprocess.run" ),
) as mock_run: patch("commands.freerouting.subprocess.run") as mock_run,
):
mock_run.return_value = MagicMock(returncode=1) mock_run.return_value = MagicMock(returncode=1)
assert _docker_available() is False assert _docker_available() is False
class TestJavaVersionOk: class TestJavaVersionOk:
def test_java_21(self): def test_java_21(self):
with patch( with patch("commands.freerouting.subprocess.run") as mock_run:
"commands.freerouting.subprocess.run" mock_run.return_value = MagicMock(stderr='openjdk version "21.0.1"', stdout="")
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "21.0.1"', stdout=""
)
assert _java_version_ok("/usr/bin/java") is True assert _java_version_ok("/usr/bin/java") is True
def test_java_17(self): def test_java_17(self):
with patch( with patch("commands.freerouting.subprocess.run") as mock_run:
"commands.freerouting.subprocess.run" mock_run.return_value = MagicMock(stderr='openjdk version "17.0.18"', stdout="")
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "17.0.18"', stdout=""
)
assert _java_version_ok("/usr/bin/java") is False assert _java_version_ok("/usr/bin/java") is False
def test_java_error(self): def test_java_error(self):

View File

@@ -256,18 +256,14 @@ class TestFindOverlappingElements:
def test_overlapping_symbols_detected(self): def test_overlapping_symbols_detected(self):
# Two resistors at nearly the same position — bboxes fully overlap # Two resistors at nearly the same position — bboxes fully overlap
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100)
"R2", 100.1, 100
)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1 assert result["totalOverlaps"] >= 1
assert len(result["overlappingSymbols"]) >= 1 assert len(result["overlappingSymbols"]) >= 1
def test_well_separated_symbols_not_flagged(self): def test_well_separated_symbols_not_flagged(self):
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200)
"R2", 200, 200
)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0 assert result["totalOverlaps"] == 0
@@ -294,9 +290,7 @@ class TestFindOverlappingElements:
""" """
# R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81] # R1 at y=100, R2 at y=105 — pin spans [96.19, 103.81] and [101.19, 108.81]
# These overlap in Y from 101.19 to 103.81 # These overlap in Y from 101.19 to 103.81
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105)
"R2", 100, 105
)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1, ( assert result["totalOverlaps"] >= 1, (
@@ -310,9 +304,7 @@ class TestFindOverlappingElements:
R pins at y ±3.81, but different X positions far enough apart. R pins at y ±3.81, but different X positions far enough apart.
""" """
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp( extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100)
"R2", 110, 100
)
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5) result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0 assert result["totalOverlaps"] == 0
@@ -419,9 +411,7 @@ class TestIntegrationFindWiresCrossingSymbols:
tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire) tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire)
result = find_wires_crossing_symbols(tmp) result = find_wires_crossing_symbols(tmp)
# The wire must not be reported against the far-away R? at (200, 100) # The wire must not be reported against the far-away R? at (200, 100)
collisions_at_200 = [ collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5]
c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5
]
assert len(collisions_at_200) == 0, ( assert len(collisions_at_200) == 0, (
"Wire at x≈100 must not be flagged against the R? at x=200; " "Wire at x≈100 must not be flagged against the R? at x=200; "
"likely caused by reference-lookup always returning the first 'R?'" "likely caused by reference-lookup always returning the first 'R?'"
@@ -458,9 +448,7 @@ class TestIntegrationFindWiresCrossingSymbols:
tmp = _make_temp_schematic(extra) tmp = _make_temp_schematic(extra)
result = find_wires_crossing_symbols(tmp) result = find_wires_crossing_symbols(tmp)
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"] d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
assert ( assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged"
len(d1_crossings) == 0
), "Wire terminating at pin from outside should not be flagged"
def test_wire_shorts_component_pins_detected_as_collision(self): def test_wire_shorts_component_pins_detected_as_collision(self):
"""Regression: a wire connecting pin1→pin2 of the same component """Regression: a wire connecting pin1→pin2 of the same component
@@ -825,9 +813,7 @@ class TestComputeSymbolBboxWithGraphics:
} }
graphics_points = [(-1.016, -2.54), (1.016, 2.54)] graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
bbox = _compute_symbol_bbox_direct( bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
sym, pin_defs, graphics_points=graphics_points
)
assert bbox is not None assert bbox is not None
min_x, min_y, max_x, max_y = bbox min_x, min_y, max_x, max_y = bbox
# X should come from rectangle: 100 ± 1.016 # X should come from rectangle: 100 ± 1.016
@@ -902,9 +888,7 @@ class TestComputeSymbolBboxWithGraphics:
# Rectangle corners in local coords # Rectangle corners in local coords
graphics_points = [(-1.016, -2.54), (1.016, 2.54)] graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
bbox = _compute_symbol_bbox_direct( bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
sym, pin_defs, graphics_points=graphics_points
)
assert bbox is not None assert bbox is not None
min_x, min_y, max_x, max_y = bbox min_x, min_y, max_x, max_y = bbox
# After 90° rotation, X and Y swap roles # After 90° rotation, X and Y swap roles
@@ -935,9 +919,7 @@ class TestIntegrationGraphicsBbox:
assert len(graphics_points) >= 2, "Should have extracted rectangle points" assert len(graphics_points) >= 2, "Should have extracted rectangle points"
bbox = _compute_symbol_bbox_direct( bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points)
r1, pin_defs, graphics_points=graphics_points
)
assert bbox is not None assert bbox is not None
min_x, min_y, max_x, max_y = bbox min_x, min_y, max_x, max_y = bbox
# Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion # Rectangle is ±1.016 in X, NOT ±1.5 from degenerate expansion

View File

@@ -8,6 +8,7 @@ Covers:
(tested by calling _handle_* methods on a lightweight stub that avoids (tested by calling _handle_* methods on a lightweight stub that avoids
importing the full kicad_interface module). importing the full kicad_interface module).
""" """
import shutil import shutil
import tempfile import tempfile
from pathlib import Path from pathlib import Path
@@ -43,9 +44,7 @@ _WIRE_SCH = """\
def _write_temp_sch(content: str) -> Path: def _write_temp_sch(content: str) -> Path:
"""Write *content* to a temp file and return its Path.""" """Write *content* to a temp file and return its Path."""
tmp = tempfile.NamedTemporaryFile( tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8")
suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8"
)
tmp.write(content) tmp.write(content)
tmp.close() tmp.close()
return Path(tmp.name) return Path(tmp.name)
@@ -66,9 +65,7 @@ class TestDeleteWireUnit:
self.WireManager = WireManager self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path): def test_nonexistent_file_returns_false(self, tmp_path):
result = self.WireManager.delete_wire( result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10])
tmp_path / "nope.kicad_sch", [0, 0], [10, 10]
)
assert result is False assert result is False
def test_no_matching_wire_returns_false(self, tmp_path): def test_no_matching_wire_returns_false(self, tmp_path):
@@ -100,9 +97,7 @@ class TestDeleteLabelUnit:
self.WireManager = WireManager self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path): def test_nonexistent_file_returns_false(self, tmp_path):
result = self.WireManager.delete_label( result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC")
tmp_path / "nope.kicad_sch", "VCC"
)
assert result is False assert result is False
def test_missing_label_returns_false(self, tmp_path): def test_missing_label_returns_false(self, tmp_path):
@@ -114,9 +109,7 @@ class TestDeleteLabelUnit:
def test_position_kwarg_accepted(self, tmp_path): def test_position_kwarg_accepted(self, tmp_path):
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch) shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_label( result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5)
sch, "VCC", position=[10.0, 20.0], tolerance=0.5
)
assert result is False assert result is False
@@ -145,9 +138,7 @@ class TestDeleteWireIntegration:
wire_items = [ wire_items = [
item item
for item in data for item in data
if isinstance(item, list) if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
and item
and item[0] == sexpdata.Symbol("wire")
] ]
assert wire_items == [], "Wire should have been removed from the file" assert wire_items == [], "Wire should have been removed from the file"
@@ -165,18 +156,14 @@ class TestDeleteWireIntegration:
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
# Coordinates differ by 0.3 mm — within default tolerance of 0.5 # Coordinates differ by 0.3 mm — within default tolerance of 0.5
result = self.WireManager.delete_wire( result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5)
sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5
)
assert result is True assert result is True
def test_outside_tolerance_no_delete(self, tmp_path): def test_outside_tolerance_no_delete(self, tmp_path):
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_wire( result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0)
sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0
)
# tolerance=0.0 means exact float equality — may still match on most # tolerance=0.0 means exact float equality — may still match on most
# platforms, but the key thing is that a *distant* miss is rejected # platforms, but the key thing is that a *distant* miss is rejected
sch2 = tmp_path / "test2.kicad_sch" sch2 = tmp_path / "test2.kicad_sch"
@@ -200,9 +187,7 @@ class TestDeleteWireIntegration:
labels = [ labels = [
item item
for item in data for item in data
if isinstance(item, list) if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
and item
and item[0] == sexpdata.Symbol("label")
] ]
assert len(labels) == 1 assert len(labels) == 1
@@ -230,9 +215,7 @@ class TestDeleteLabelIntegration:
labels = [ labels = [
item item
for item in data for item in data
if isinstance(item, list) if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
and item
and item[0] == sexpdata.Symbol("label")
] ]
assert labels == [], "Label should have been removed" assert labels == [], "Label should have been removed"
@@ -247,9 +230,7 @@ class TestDeleteLabelIntegration:
sch = tmp_path / "test.kicad_sch" sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8") sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_label( result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5)
sch, "VCC", position=[99.0, 99.0], tolerance=0.5
)
assert result is False assert result is False
def test_wire_preserved_after_label_deletion(self, tmp_path): def test_wire_preserved_after_label_deletion(self, tmp_path):
@@ -260,9 +241,7 @@ class TestDeleteLabelIntegration:
wires = [ wires = [
item item
for item in data for item in data
if isinstance(item, list) if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
and item
and item[0] == sexpdata.Symbol("wire")
] ]
assert len(wires) == 1 assert len(wires) == 1
@@ -387,9 +366,7 @@ class TestHandlerParamValidation:
params = {} params = {}
schematic_path = params.get("schematicPath") schematic_path = params.get("schematicPath")
result = ( result = (
{"success": False, "message": "schematicPath is required"} {"success": False, "message": "schematicPath is required"} if not schematic_path else {}
if not schematic_path
else {}
) )
assert result["success"] is False assert result["success"] is False

View File

@@ -265,18 +265,14 @@ class TestCoreLogic:
def test_find_connected_wires_no_wire_at_point(self): def test_find_connected_wires_no_wire_at_point(self):
wires = [[(0, 0), (10_000, 0)]] wires = [[(0, 0), (10_000, 0)]]
adjacency, iu_to_wires = _build_adjacency(wires) adjacency, iu_to_wires = _build_adjacency(wires)
visited, net_points = _find_connected_wires( visited, net_points = _find_connected_wires(5.0, 0.0, wires, iu_to_wires, adjacency)
5.0, 0.0, wires, iu_to_wires, adjacency
)
assert visited is None assert visited is None
assert net_points is None assert net_points is None
def test_find_connected_wires_single_wire(self): def test_find_connected_wires_single_wire(self):
wires = [[(0, 0), (10_000, 0)]] wires = [[(0, 0), (10_000, 0)]]
adjacency, iu_to_wires = _build_adjacency(wires) adjacency, iu_to_wires = _build_adjacency(wires)
visited, net_points = _find_connected_wires( visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
0.0, 0.0, wires, iu_to_wires, adjacency
)
assert visited == {0} assert visited == {0}
assert (0, 0) in net_points assert (0, 0) in net_points
assert (10_000, 0) in net_points assert (10_000, 0) in net_points
@@ -289,9 +285,7 @@ class TestCoreLogic:
[(20_000, 0), (30_000, 0)], [(20_000, 0), (30_000, 0)],
] ]
adjacency, iu_to_wires = _build_adjacency(wires) adjacency, iu_to_wires = _build_adjacency(wires)
visited, net_points = _find_connected_wires( visited, net_points = _find_connected_wires(0.0, 0.0, wires, iu_to_wires, adjacency)
0.0, 0.0, wires, iu_to_wires, adjacency
)
assert visited == {0, 1, 2} assert visited == {0, 1, 2}
def test_find_connected_wires_does_not_cross_gap(self): def test_find_connected_wires_does_not_cross_gap(self):

View File

@@ -44,11 +44,7 @@ def _parse_sch(path: Path):
def _find_elements(sch_data, tag: str): def _find_elements(sch_data, tag: str):
"""Return all top-level S-expression elements with the given tag Symbol.""" """Return all top-level S-expression elements with the given tag Symbol."""
return [ return [item for item in sch_data if isinstance(item, list) and item and item[0] == Symbol(tag)]
item
for item in sch_data
if isinstance(item, list) and item and item[0] == Symbol(tag)
]
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -188,9 +184,7 @@ class TestHandleAddSchematicWireValidation:
assert "Schematic path" in result["message"] assert "Schematic path" in result["message"]
def test_missing_waypoints(self): def test_missing_waypoints(self):
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire({"schematicPath": "/tmp/x.kicad_sch"})
{"schematicPath": "/tmp/x.kicad_sch"}
)
assert result["success"] is False assert result["success"] is False
assert "waypoint" in result["message"].lower() assert "waypoint" in result["message"].lower()
@@ -334,9 +328,7 @@ class TestPinSnapping:
def __init__(self): def __init__(self):
pass pass
skip_mod.Schematic = lambda path: type( skip_mod.Schematic = lambda path: type("FakeSch", (), {"symbol": [FakeSymbol()]})()
"FakeSch", (), {"symbol": [FakeSymbol()]}
)()
mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]} mock_pins.return_value = {"1": [10.0, 20.0], "2": [10.0, 30.0]}
@@ -350,9 +342,7 @@ class TestPinSnapping:
with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None): with patch.object(KiCADInterface, "__init__", lambda self, *a, **kw: None):
iface = KiCADInterface.__new__(KiCADInterface) iface = KiCADInterface.__new__(KiCADInterface)
with patch( with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
"commands.wire_manager.WireManager.add_wire", return_value=True
) as mw:
result = iface._handle_add_schematic_wire( result = iface._handle_add_schematic_wire(
{ {
"schematicPath": str(self.sch_path), "schematicPath": str(self.sch_path),
@@ -371,9 +361,10 @@ class TestPinSnapping:
def test_snap_disabled_passes_original_coords(self): def test_snap_disabled_passes_original_coords(self):
"""With snapToPins=False the handler should not load PinLocator at all.""" """With snapToPins=False the handler should not load PinLocator at all."""
with patch( with (
"commands.wire_manager.WireManager.add_wire", return_value=True patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw,
) as mw, patch("commands.pin_locator.PinLocator") as mock_locator_cls: patch("commands.pin_locator.PinLocator") as mock_locator_cls,
):
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
"schematicPath": str(self.sch_path), "schematicPath": str(self.sch_path),
@@ -389,9 +380,7 @@ class TestPinSnapping:
@patch("commands.wire_manager.WireManager.add_wire", return_value=True) @patch("commands.wire_manager.WireManager.add_wire", return_value=True)
def test_snap_miss_leaves_coords_unchanged(self, mock_wire): def test_snap_miss_leaves_coords_unchanged(self, mock_wire):
"""Point beyond tolerance should not be snapped.""" """Point beyond tolerance should not be snapped."""
with patch( with patch("commands.wire_manager.WireManager.add_wire", return_value=True) as mw:
"commands.wire_manager.WireManager.add_wire", return_value=True
) as mw:
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
"schematicPath": str(self.sch_path), "schematicPath": str(self.sch_path),
@@ -409,9 +398,7 @@ class TestPinSnapping:
def test_intermediate_waypoints_not_snapped(self, mock_poly): def test_intermediate_waypoints_not_snapped(self, mock_poly):
"""Middle waypoints must remain unchanged even with snapToPins=True.""" """Middle waypoints must remain unchanged even with snapToPins=True."""
mid = [50.0, 50.0] mid = [50.0, 50.0]
with patch( with patch("commands.wire_manager.WireManager.add_polyline_wire", return_value=True) as mp:
"commands.wire_manager.WireManager.add_polyline_wire", return_value=True
) as mp:
result = self.iface._handle_add_schematic_wire( result = self.iface._handle_add_schematic_wire(
{ {
"schematicPath": str(self.sch_path), "schematicPath": str(self.sch_path),
@@ -451,9 +438,7 @@ class TestHandleAddSchematicJunction:
assert "Schematic path" in result["message"] assert "Schematic path" in result["message"]
def test_missing_position(self): def test_missing_position(self):
result = self.iface._handle_add_schematic_junction( result = self.iface._handle_add_schematic_junction({"schematicPath": "/tmp/x.kicad_sch"})
{"schematicPath": "/tmp/x.kicad_sch"}
)
assert result["success"] is False assert result["success"] is False
assert "Position" in result["message"] assert "Position" in result["message"]
@@ -551,9 +536,7 @@ class TestIntegrationWireManager:
assert ok is True assert ok is True
data = _parse_sch(sch) data = _parse_sch(sch)
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert ( assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
len(wires) == 3
), f"4 waypoints should produce 3 wire segments, got {len(wires)}"
def test_add_junction_writes_junction_element(self, sch): def test_add_junction_writes_junction_element(self, sch):
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
@@ -575,14 +558,10 @@ class TestIntegrationWireManager:
data = _parse_sch(sch) data = _parse_sch(sch)
wire = _find_elements(data, "wire")[0] wire = _find_elements(data, "wire")[0]
pts = [ pts = [
item item for item in wire if isinstance(item, list) and item and item[0] == Symbol("pts")
for item in wire
if isinstance(item, list) and item and item[0] == Symbol("pts")
][0] ][0]
xy_entries = [ xy_entries = [
item item for item in pts if isinstance(item, list) and item and item[0] == Symbol("xy")
for item in pts
if isinstance(item, list) and item and item[0] == Symbol("xy")
] ]
assert xy_entries[0][1] == 5.0 assert xy_entries[0][1] == 5.0
assert xy_entries[0][2] == 7.5 assert xy_entries[0][2] == 7.5
@@ -644,9 +623,7 @@ class TestIntegrationHandlerEndToEnd:
assert result["success"] is True assert result["success"] is True
data = _parse_sch(self.sch) data = _parse_sch(self.sch)
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert ( assert len(wires) == 3, f"4 waypoints should produce 3 wire segments, got {len(wires)}"
len(wires) == 3
), f"4 waypoints should produce 3 wire segments, got {len(wires)}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -812,9 +789,7 @@ class TestMakeWireSexp:
def test_custom_stroke(self): def test_custom_stroke(self):
from commands.wire_manager import WireManager from commands.wire_manager import WireManager
sexp = WireManager._make_wire_sexp( sexp = WireManager._make_wire_sexp([0, 0], [5, 0], stroke_width=0.5, stroke_type="dash")
[0, 0], [5, 0], stroke_width=0.5, stroke_type="dash"
)
parsed = WireManager._parse_wire(sexp) parsed = WireManager._parse_wire(sexp)
assert parsed is not None assert parsed is not None
_, _, width, stype = parsed _, _, width, stype = parsed
@@ -922,9 +897,7 @@ class TestBreakWiresAtPoint:
data = [Symbol("kicad_sch")] data = [Symbol("kicad_sch")]
data.append( data.append(
WireManager._make_wire_sexp( WireManager._make_wire_sexp([0, 0], [20, 0], stroke_width=0.5, stroke_type="dash")
[0, 0], [20, 0], stroke_width=0.5, stroke_type="dash"
)
) )
data.append([Symbol("sheet_instances")]) data.append([Symbol("sheet_instances")])
splits = WireManager._break_wires_at_point(data, [10, 0]) splits = WireManager._break_wires_at_point(data, [10, 0])
@@ -998,9 +971,7 @@ class TestIntegrationTJunction:
WireManager.add_junction(sch, [15, 0]) WireManager.add_junction(sch, [15, 0])
data = _parse_sch(sch) data = _parse_sch(sch)
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert ( assert len(wires) == 2, f"Expected 2 wires after junction split, got {len(wires)}"
len(wires) == 2
), f"Expected 2 wires after junction split, got {len(wires)}"
junctions = _find_elements(data, "junction") junctions = _find_elements(data, "junction")
assert len(junctions) == 1 assert len(junctions) == 1
@@ -1012,9 +983,7 @@ class TestIntegrationTJunction:
WireManager.add_junction(sch, [20, 0]) WireManager.add_junction(sch, [20, 0])
data = _parse_sch(sch) data = _parse_sch(sch)
wires = _find_elements(data, "wire") wires = _find_elements(data, "wire")
assert ( assert len(wires) == 1, f"Expected 1 wire (no split at endpoint), got {len(wires)}"
len(wires) == 1
), f"Expected 1 wire (no split at endpoint), got {len(wires)}"
def test_polyline_breaks_existing_wire(self, sch): def test_polyline_breaks_existing_wire(self, sch):
"""Polyline whose start/end hits mid-wire should break it.""" """Polyline whose start/end hits mid-wire should break it."""

View File

@@ -3,77 +3,82 @@ KiCAD Process Management Utilities
Detects if KiCAD is running and provides auto-launch functionality. Detects if KiCAD is running and provides auto-launch functionality.
""" """
import os
import subprocess import os
import logging import subprocess
import platform import logging
import time import platform
import ctypes import time
from ctypes import wintypes import ctypes
from pathlib import Path from ctypes import wintypes
from pathlib import Path
from typing import Optional, List from typing import Optional, List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class KiCADProcessManager: class KiCADProcessManager:
"""Manages KiCAD process detection and launching""" """Manages KiCAD process detection and launching"""
@staticmethod @staticmethod
def _windows_list_processes() -> List[dict]: def _windows_list_processes() -> List[dict]:
"""List running processes on Windows using Toolhelp API.""" """List running processes on Windows using Toolhelp API."""
processes: List[dict] = [] processes: List[dict] = []
try: try:
TH32CS_SNAPPROCESS = 0x00000002 TH32CS_SNAPPROCESS = 0x00000002
try: try:
ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined] ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined]
except AttributeError: except AttributeError:
ulong_ptr = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong ulong_ptr = (
ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong
class PROCESSENTRY32W(ctypes.Structure): )
_fields_ = [
("dwSize", wintypes.DWORD), class PROCESSENTRY32W(ctypes.Structure):
("cntUsage", wintypes.DWORD), _fields_ = [
("th32ProcessID", wintypes.DWORD), ("dwSize", wintypes.DWORD),
("th32DefaultHeapID", ulong_ptr), ("cntUsage", wintypes.DWORD),
("th32ModuleID", wintypes.DWORD), ("th32ProcessID", wintypes.DWORD),
("cntThreads", wintypes.DWORD), ("th32DefaultHeapID", ulong_ptr),
("th32ParentProcessID", wintypes.DWORD), ("th32ModuleID", wintypes.DWORD),
("pcPriClassBase", wintypes.LONG), ("cntThreads", wintypes.DWORD),
("dwFlags", wintypes.DWORD), ("th32ParentProcessID", wintypes.DWORD),
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH), ("pcPriClassBase", wintypes.LONG),
] ("dwFlags", wintypes.DWORD),
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot ]
Process32FirstW = ctypes.windll.kernel32.Process32FirstW
Process32NextW = ctypes.windll.kernel32.Process32NextW CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot
CloseHandle = ctypes.windll.kernel32.CloseHandle Process32FirstW = ctypes.windll.kernel32.Process32FirstW
Process32NextW = ctypes.windll.kernel32.Process32NextW
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) CloseHandle = ctypes.windll.kernel32.CloseHandle
if snapshot == wintypes.HANDLE(-1).value:
return processes snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
if snapshot == wintypes.HANDLE(-1).value:
entry = PROCESSENTRY32W() return processes
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
entry = PROCESSENTRY32W()
if Process32FirstW(snapshot, ctypes.byref(entry)): entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
while True:
processes.append({ if Process32FirstW(snapshot, ctypes.byref(entry)):
"pid": str(entry.th32ProcessID), while True:
"name": entry.szExeFile, processes.append(
"command": entry.szExeFile {
}) "pid": str(entry.th32ProcessID),
if not Process32NextW(snapshot, ctypes.byref(entry)): "name": entry.szExeFile,
break "command": entry.szExeFile,
}
CloseHandle(snapshot) )
except Exception as e: if not Process32NextW(snapshot, ctypes.byref(entry)):
logger.error(f"Error listing Windows processes: {e}") break
return processes CloseHandle(snapshot)
except Exception as e:
@staticmethod logger.error(f"Error listing Windows processes: {e}")
def is_running() -> bool:
return processes
@staticmethod
def is_running() -> bool:
""" """
Check if KiCAD is currently running Check if KiCAD is currently running
@@ -87,27 +92,21 @@ class KiCADProcessManager:
# Check for actual pcbnew/kicad binaries (not python scripts) # Check for actual pcbnew/kicad binaries (not python scripts)
# Use exact process name matching to avoid matching our own kicad_interface.py # Use exact process name matching to avoid matching our own kicad_interface.py
result = subprocess.run( result = subprocess.run(
["pgrep", "-x", "pcbnew|kicad"], ["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True
capture_output=True,
text=True
) )
if result.returncode == 0: if result.returncode == 0:
return True return True
# Also check with -f for full path matching, but exclude our script # Also check with -f for full path matching, but exclude our script
result = subprocess.run( result = subprocess.run(
["pgrep", "-f", "/pcbnew|/kicad"], ["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True
capture_output=True,
text=True
) )
# Double-check it's not our own process # Double-check it's not our own process
if result.returncode == 0: if result.returncode == 0:
pids = result.stdout.strip().split('\n') pids = result.stdout.strip().split("\n")
for pid in pids: for pid in pids:
try: try:
cmdline = subprocess.run( cmdline = subprocess.run(
["ps", "-p", pid, "-o", "command="], ["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
capture_output=True,
text=True
) )
if "kicad_interface.py" not in cmdline.stdout: if "kicad_interface.py" not in cmdline.stdout:
return True return True
@@ -117,18 +116,16 @@ class KiCADProcessManager:
elif system == "Darwin": # macOS elif system == "Darwin": # macOS
result = subprocess.run( result = subprocess.run(
["pgrep", "-f", "KiCad|pcbnew"], ["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
capture_output=True,
text=True
) )
return result.returncode == 0 return result.returncode == 0
elif system == "Windows": elif system == "Windows":
processes = KiCADProcessManager._windows_list_processes() processes = KiCADProcessManager._windows_list_processes()
for proc in processes: for proc in processes:
name = (proc.get("name") or "").lower() name = (proc.get("name") or "").lower()
if name in ("pcbnew.exe", "kicad.exe"): if name in ("pcbnew.exe", "kicad.exe"):
return True return True
return False return False
else: else:
@@ -151,14 +148,14 @@ class KiCADProcessManager:
# Try to find executable in PATH first # Try to find executable in PATH first
for cmd in ["pcbnew", "kicad"]: for cmd in ["pcbnew", "kicad"]:
result = subprocess.run( result = subprocess.run(
["which", cmd] if system != "Windows" else ["where", cmd], ["which", cmd] if system != "Windows" else ["where", cmd],
capture_output=True, capture_output=True,
text=True, text=True,
encoding="mbcs" if system == "Windows" else None, encoding="mbcs" if system == "Windows" else None,
errors="ignore" if system == "Windows" else None, errors="ignore" if system == "Windows" else None,
timeout=5 if system == "Windows" else None timeout=5 if system == "Windows" else None,
) )
if result.returncode == 0: if result.returncode == 0:
path = result.stdout.strip().split("\n")[0] path = result.stdout.strip().split("\n")[0]
logger.info(f"Found KiCAD executable: {path}") logger.info(f"Found KiCAD executable: {path}")
@@ -232,7 +229,7 @@ class KiCADProcessManager:
cmd, cmd,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL stderr=subprocess.DEVNULL,
) )
else: else:
# Unix: Use nohup or start in background # Unix: Use nohup or start in background
@@ -240,7 +237,7 @@ class KiCADProcessManager:
cmd, cmd,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=True start_new_session=True,
) )
# Wait for process to start # Wait for process to start
@@ -275,28 +272,30 @@ class KiCADProcessManager:
try: try:
if system in ["Linux", "Darwin"]: if system in ["Linux", "Darwin"]:
result = subprocess.run( result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
["ps", "aux"],
capture_output=True,
text=True
)
for line in result.stdout.split("\n"): for line in result.stdout.split("\n"):
# Only match actual KiCAD binaries, not our MCP server processes # Only match actual KiCAD binaries, not our MCP server processes
if ("pcbnew" in line.lower() or "kicad" in line.lower()) and "kicad_interface.py" not in line and "grep" not in line: if (
("pcbnew" in line.lower() or "kicad" in line.lower())
and "kicad_interface.py" not in line
and "grep" not in line
):
# More specific check: must have /pcbnew or /kicad in the path # More specific check: must have /pcbnew or /kicad in the path
if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line: if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line:
parts = line.split() parts = line.split()
if len(parts) >= 11: if len(parts) >= 11:
processes.append({ processes.append(
"pid": parts[1], {
"name": parts[10], "pid": parts[1],
"command": " ".join(parts[10:]) "name": parts[10],
}) "command": " ".join(parts[10:]),
}
)
elif system == "Windows": elif system == "Windows":
for proc in KiCADProcessManager._windows_list_processes(): for proc in KiCADProcessManager._windows_list_processes():
name = (proc.get("name") or "").lower() name = (proc.get("name") or "").lower()
if "pcbnew" in name or "kicad" in name: if "pcbnew" in name or "kicad" in name:
processes.append(proc) processes.append(proc)
except Exception as e: except Exception as e:
@@ -304,6 +303,7 @@ class KiCADProcessManager:
return processes return processes
def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict: def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict:
""" """
Check if KiCAD is running and optionally launch it Check if KiCAD is running and optionally launch it
@@ -325,7 +325,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
"running": True, "running": True,
"launched": False, "launched": False,
"processes": processes, "processes": processes,
"message": "KiCAD is already running" "message": "KiCAD is already running",
} }
if not auto_launch: if not auto_launch:
@@ -333,7 +333,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
"running": False, "running": False,
"launched": False, "launched": False,
"processes": [], "processes": [],
"message": "KiCAD is not running (auto-launch disabled)" "message": "KiCAD is not running (auto-launch disabled)",
} }
# Try to launch # Try to launch
@@ -345,5 +345,5 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
"launched": success, "launched": success,
"processes": manager.get_process_info() if success else [], "processes": manager.get_process_info() if success else [],
"message": "KiCAD launched successfully" if success else "Failed to launch KiCAD", "message": "KiCAD launched successfully" if success else "Failed to launch KiCAD",
"project": str(project_path) if project_path else None "project": str(project_path) if project_path else None,
} }

View File

@@ -4,6 +4,7 @@ Platform detection and path utilities for cross-platform compatibility
This module provides helpers for detecting the current platform and This module provides helpers for detecting the current platform and
getting appropriate paths for KiCAD, configuration, logs, etc. getting appropriate paths for KiCAD, configuration, logs, etc.
""" """
import os import os
import platform import platform
import sys import sys
@@ -74,19 +75,23 @@ class PlatformHelper:
# Also check based on Python version # Also check based on Python version
py_version = f"{sys.version_info.major}.{sys.version_info.minor}" py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
candidates.extend([ candidates.extend(
Path(f"/usr/lib/python{py_version}/dist-packages/kicad"), [
Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"), Path(f"/usr/lib/python{py_version}/dist-packages/kicad"),
]) Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"),
]
)
# Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian) # Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian)
# This is where pcbnew.py typically lives on modern systems # This is where pcbnew.py typically lives on modern systems
candidates.extend([ candidates.extend(
Path(f"/usr/lib/python3/dist-packages"), [
Path(f"/usr/lib/python{py_version}/dist-packages"), Path(f"/usr/lib/python3/dist-packages"),
Path(f"/usr/local/lib/python3/dist-packages"), Path(f"/usr/lib/python{py_version}/dist-packages"),
Path(f"/usr/local/lib/python{py_version}/dist-packages"), Path(f"/usr/local/lib/python3/dist-packages"),
]) Path(f"/usr/local/lib/python{py_version}/dist-packages"),
]
)
paths = [p for p in candidates if p.exists()] paths = [p for p in candidates if p.exists()]
@@ -102,7 +107,17 @@ class PlatformHelper:
for kicad_app in kicad_app_paths: for kicad_app in kicad_app_paths:
if kicad_app.exists(): if kicad_app.exists():
for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]: for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]:
path = kicad_app / "Contents" / "Frameworks" / "Python.framework" / "Versions" / version / "lib" / f"python{version}" / "site-packages" path = (
kicad_app
/ "Contents"
/ "Frameworks"
/ "Python.framework"
/ "Versions"
/ version
/ "lib"
/ f"python{version}"
/ "site-packages"
)
if path.exists(): if path.exists():
paths.append(path) paths.append(path)
@@ -110,7 +125,7 @@ class PlatformHelper:
homebrew_paths = [ homebrew_paths = [
Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon
Path("/opt/homebrew/lib/python3.11/site-packages"), Path("/opt/homebrew/lib/python3.11/site-packages"),
Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac
Path("/usr/local/lib/python3.11/site-packages"), Path("/usr/local/lib/python3.11/site-packages"),
] ]
for hp in homebrew_paths: for hp in homebrew_paths:
@@ -161,7 +176,10 @@ class PlatformHelper:
patterns = [ patterns = [
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", "/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
"/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", "/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
str(Path.home() / "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"), str(
Path.home()
/ "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"
),
] ]
# Add user library paths for all platforms # Add user library paths for all platforms
@@ -301,6 +319,7 @@ def detect_platform() -> dict:
if __name__ == "__main__": if __name__ == "__main__":
# Quick test/diagnostic # Quick test/diagnostic
import json import json
info = detect_platform() info = detect_platform()
print("Platform Information:") print("Platform Information:")
print(json.dumps(info, indent=2)) print(json.dumps(info, indent=2))

View File

@@ -3,6 +3,7 @@ Tests for platform_helper utility
These are unit tests that work on all platforms. These are unit tests that work on all platforms.
""" """
import pytest import pytest
import platform import platform
from pathlib import Path from pathlib import Path
@@ -176,6 +177,7 @@ class TestKiCADPathDetection:
PlatformHelper.add_kicad_to_python_path() PlatformHelper.add_kicad_to_python_path()
try: try:
import pcbnew import pcbnew
# If we get here, pcbnew is available # If we get here, pcbnew is available
assert pcbnew is not None assert pcbnew is not None
version = pcbnew.GetBuildVersion() version = pcbnew.GetBuildVersion()