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
args: ['--maxkb=500']
- 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"
version = "2.1.0"
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
__all__ = [
'ProjectCommands',
'BoardCommands',
'ComponentCommands',
'RoutingCommands',
'DesignRuleCommands',
'ExportCommands'
"ProjectCommands",
"BoardCommands",
"ComponentCommands",
"RoutingCommands",
"DesignRuleCommands",
"ExportCommands",
]

View File

@@ -8,4 +8,4 @@ It imports and re-exports the BoardCommands class from the board package.
from commands.board import BoardCommands
# 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 .view import BoardViewCommands
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class BoardCommands:
"""Handles board-related KiCAD operations"""

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,8 @@ from PIL import Image
import io
import base64
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class BoardViewCommands:
"""Handles board viewing operations"""
@@ -26,7 +27,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get board dimensions
@@ -42,26 +43,24 @@ class BoardViewCommands:
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
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)),
"id": layer_id
})
"id": layer_id,
}
)
return {
"success": True,
"board": {
"filename": self.board.GetFileName(),
"size": {
"width": width_mm,
"height": height_mm,
"unit": "mm"
},
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
"layers": layers,
"title": self.board.GetTitleBlock().GetTitle()
"title": self.board.GetTitleBlock().GetTitle(),
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
}
},
}
except Exception as e:
@@ -69,7 +68,7 @@ class BoardViewCommands:
return {
"success": False,
"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]:
@@ -79,7 +78,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get parameters
@@ -126,17 +125,14 @@ class BoardViewCommands:
# Convert SVG to requested format
if format == "svg":
with open(temp_svg, 'r') as f:
with open(temp_svg, "r") as f:
svg_data = f.read()
os.remove(temp_svg)
return {
"success": True,
"imageData": svg_data,
"format": "svg"
}
return {"success": True, "imageData": svg_data, "format": "svg"}
else:
# Use PIL to convert SVG to PNG/JPG
from cairosvg import svg2png
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
os.remove(temp_svg)
@@ -144,18 +140,18 @@ class BoardViewCommands:
# Convert PNG to JPG
img = Image.open(io.BytesIO(png_data))
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()
return {
"success": True,
"imageData": base64.b64encode(jpg_data).decode('utf-8'),
"format": "jpg"
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
"format": "jpg",
}
else:
return {
"success": True,
"imageData": base64.b64encode(png_data).decode('utf-8'),
"format": "png"
"imageData": base64.b64encode(png_data).decode("utf-8"),
"format": "png",
}
except Exception as e:
@@ -163,7 +159,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board 2D view",
"errorDetails": str(e)
"errorDetails": str(e),
}
def _get_layer_type_name(self, type_id: int) -> str:
@@ -172,7 +168,7 @@ class BoardViewCommands:
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper"
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")
@@ -184,7 +180,7 @@ class BoardViewCommands:
return {
"success": False,
"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)
@@ -215,12 +211,9 @@ class BoardViewCommands:
"bottom": bottom,
"width": width,
"height": height,
"center": {
"x": center_x,
"y": center_y
"center": {"x": center_x, "y": center_y},
"unit": unit,
},
"unit": unit
}
}
except Exception as e:
@@ -228,5 +221,5 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board extents",
"errorDetails": str(e)
"errorDetails": str(e),
}

View File

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

View File

@@ -13,9 +13,7 @@ try:
DYNAMIC_LOADING_AVAILABLE = True
except ImportError:
logger.warning(
"Dynamic symbol loader not available - falling back to template-only mode"
)
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
DYNAMIC_LOADING_AVAILABLE = False
@@ -135,32 +133,22 @@ class ComponentManager:
# Check if schematic path is available
if schematic_path is None:
logger.warning(
"Dynamic loading requires schematic file path but none was provided"
)
logger.warning("Dynamic loading requires schematic file path but none was provided")
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
return (fallback, False)
# Determine library name
if library is None:
# Default library for common component types
library = (
"Device" # Most passives and basic components are in Device library
)
library = "Device" # Most passives and basic components are in Device library
try:
logger.info(
f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}"
)
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
# Use dynamic symbol loader to inject symbol and create template
template_ref = loader.load_symbol_dynamically(
schematic_path, library, comp_type
)
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
logger.info(
f"Successfully loaded symbol dynamically. Template ref: {template_ref}"
)
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
# Signal that schematic needs reload to see new template
return (template_ref, True)
@@ -198,9 +186,7 @@ class ComponentManager:
# Get component type and determine template
comp_type = component_def.get("type", "R")
library = component_def.get(
"library", None
) # Optional library specification
library = component_def.get("library", None) # Optional library specification
# Get template reference (static or dynamic)
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 needs_reload and schematic_path:
logger.info(
f"Reloading schematic after dynamic loading: {schematic_path}"
)
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
schematic = SchematicManager.load_schematic(str(schematic_path))
# Find template symbol by reference (handles special characters like +)
@@ -303,9 +287,7 @@ class ComponentManager:
return False
@staticmethod
def update_component(
schematic: Schematic, component_ref: str, new_properties: dict
):
def update_component(schematic: Schematic, component_ref: str, new_properties: dict):
"""Update component properties by reference designator"""
try:
symbol_to_update = None
@@ -401,19 +383,13 @@ if __name__ == "__main__":
# Get a component
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
if retrieved_comp:
print(
f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})"
)
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
# Update a component
ComponentManager.update_component(
test_sch, "R1", {"value": "20k", "Tolerance": "5%"}
)
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
# Search components
matching_comps = ComponentManager.search_components(
test_sch, "100"
) # Search by position
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
# Get all components
@@ -423,9 +399,7 @@ if __name__ == "__main__":
# Remove a component
ComponentManager.remove_component(test_sch, "D1")
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
print(
f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}"
)
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
# Save the schematic (optional)
# 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")
return None
label = schematic.label.append(
text=net_name, at={"x": position[0], "y": position[1]}
)
label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]})
logger.info(f"Added net label '{net_name}' at {position}")
return label
except Exception as e:
@@ -58,9 +56,7 @@ class ConnectionManager:
return None
@staticmethod
def connect_to_net(
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
):
def connect_to_net(schematic_path: Path, component_ref: str, pin_name: str, net_name: str):
"""
Connect a component pin to a named net using a wire stub and label
@@ -93,9 +89,7 @@ class ConnectionManager:
# Stub direction follows the pin's outward angle from the PinLocator
pin_angle_deg = getattr(locator, "_last_pin_angle", 0)
try:
pin_angle_deg = (
locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
)
pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
except Exception:
pin_angle_deg = 0
import math as _math
@@ -172,9 +166,7 @@ class ConnectionManager:
connected = []
failed = []
for pin_num in sorted(
src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0
):
for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0):
try:
net_name = (
f"{net_prefix}_{int(pin_num) + pin_offset}"
@@ -200,15 +192,11 @@ class ConnectionManager:
failed.append(f"{target_ref}/{pin_num} (pin not found)")
continue
connected.append(
f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]"
)
connected.append(f"{source_ref}/{pin_num} <-> {target_ref}/{pin_num} [{net_name}]")
except Exception as e:
failed.append(f"{source_ref}/{pin_num}: {e}")
logger.info(
f"connect_passthrough: {len(connected)} connected, {len(failed)} failed"
)
logger.info(f"connect_passthrough: {len(connected)} connected, {len(failed)} failed")
return {"connected": connected, "failed": failed}
@staticmethod
@@ -256,9 +244,7 @@ class ConnectionManager:
logger.info(f"No labels found for net '{net_name}'")
return connections
logger.debug(
f"Found {len(net_label_positions)} labels for net '{net_name}'"
)
logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'")
# 2. Find all wires connected to these label positions
if not hasattr(schematic, "wire"):
@@ -272,9 +258,7 @@ class ConnectionManager:
wire_points = []
for point in wire.pts.xy:
if hasattr(point, "value"):
wire_points.append(
[float(point.value[0]), float(point.value[1])]
)
wire_points.append([float(point.value[0]), float(point.value[1])])
# Check if any wire point touches a label
wire_connected = False
@@ -334,18 +318,14 @@ class ConnectionManager:
# Check each pin
for pin_num, pin_data in pins.items():
# Get pin location
pin_loc = locator.get_pin_location(
schematic_path, ref, pin_num
)
pin_loc = locator.get_pin_location(schematic_path, ref, pin_num)
if not pin_loc:
continue
# Check if pin coincides with any wire point
for wire_pt in connected_wire_points:
if points_coincide(pin_loc, list(wire_pt)):
connections.append(
{"component": ref, "pin": pin_num}
)
connections.append({"component": ref, "pin": pin_num})
break # Pin found, no need to check more wire points
except Exception as e:
@@ -364,9 +344,7 @@ class ConnectionManager:
# Check if symbol is near any wire point (within 10mm)
for wire_pt in connected_wire_points:
dist = (
(symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2
) ** 0.5
dist = ((symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2) ** 0.5
if dist < 10.0: # 10mm proximity threshold
connections.append({"component": ref, "pin": "unknown"})
break # Only add once per component
@@ -419,9 +397,7 @@ class ConnectionManager:
component_info = {
"reference": symbol.property.Reference.value,
"value": (
symbol.property.Value.value
if hasattr(symbol.property, "Value")
else ""
symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
),
"footprint": (
symbol.property.Footprint.value
@@ -444,9 +420,7 @@ class ConnectionManager:
schematic, net_name, schematic_path
)
if connections:
netlist["nets"].append(
{"name": net_name, "connections": connections}
)
netlist["nets"].append({"name": net_name, "connections": connections})
logger.info(
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components"

View File

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

View File

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

View File

@@ -46,7 +46,12 @@ class DynamicSymbolLoader:
Path.home() / "Documents" / "KiCad" / "10.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:
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:
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):
nickname = match.group(1)
if nickname != library_name:
@@ -208,9 +215,7 @@ class DynamicSymbolLoader:
return items
def _inline_extends_symbol(
self, lib_content: str, symbol_name: str, child_block: str
) -> str:
def _inline_extends_symbol(self, lib_content: str, symbol_name: str, child_block: str) -> str:
"""
Fully inline a child symbol that uses (extends "ParentName") by merging
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):
prop_match = re.match(r'[\s\t]*\(property "([^"]+)"', item)
sub_match = re.search(
r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item
)
sub_match = re.search(r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item)
if prop_match:
pname = prop_match.group(1)
parent_prop_names.add(pname)
body_lines.append(
child_props[pname] if pname in child_props else item
)
body_lines.append(child_props[pname] if pname in child_props else item)
elif sub_match:
# Rename ParentName_0_1 → ChildName_0_1
body_lines.append(
item.replace(f'"{parent_name}_', f'"{symbol_name}_')
)
elif re.match(r'[\s\t]*\(extends ', item):
body_lines.append(item.replace(f'"{parent_name}_', f'"{symbol_name}_'))
elif re.match(r"[\s\t]*\(extends ", item):
pass # drop extends clause
else:
body_lines.append(item) # pin_names, in_bom, on_board …
@@ -280,16 +279,12 @@ class DynamicSymbolLoader:
if pname not in parent_prop_names:
body_lines.append(pblock)
first_line = parent_block.split("\n")[0].replace(
f'"{parent_name}"', f'"{symbol_name}"'
)
first_line = parent_block.split("\n")[0].replace(f'"{parent_name}"', f'"{symbol_name}"')
last_line = parent_block.split("\n")[-1]
return first_line + "\n" + "\n".join(body_lines) + "\n" + last_line
def extract_symbol_from_library(
self, library_name: str, symbol_name: str
) -> Optional[str]:
def extract_symbol_from_library(self, library_name: str, symbol_name: str) -> Optional[str]:
"""
Extract a symbol definition from a KiCad .kicad_sym library file.
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)
if block is None:
logger.warning(
f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym"
)
logger.warning(f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym")
return None
# 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.
if re.search(r'\(extends "([^"]+)"\)', block):
parent_name = re.search(r'\(extends "([^"]+)"\)', block).group(1)
logger.info(
f"Symbol {symbol_name} extends {parent_name}, inlining parent content"
)
logger.info(f"Symbol {symbol_name} extends {parent_name}, inlining parent content")
block = self._inline_extends_symbol(lib_content, symbol_name, block)
# Prefix top-level symbol name with library
@@ -362,9 +353,7 @@ class DynamicSymbolLoader:
# Extract symbol from library
symbol_block = self.extract_symbol_from_library(library_name, symbol_name)
if not symbol_block:
raise ValueError(
f"Symbol '{symbol_name}' not found in library '{library_name}'"
)
raise ValueError(f"Symbol '{symbol_name}' not found in library '{library_name}'")
# Indent the block to match lib_symbols indentation (4 spaces for top-level)
indented_lines = []
@@ -399,11 +388,7 @@ class DynamicSymbolLoader:
f.write(content)
# Handle both Path objects and strings
sch_name = (
schematic_path.name
if hasattr(schematic_path, "name")
else str(schematic_path)
)
sch_name = schematic_path.name if hasattr(schematic_path, "name") else str(schematic_path)
logger.info(f"Injected symbol {full_name} into {sch_name}")
return True
@@ -457,9 +442,7 @@ class DynamicSymbolLoader:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write(content)
logger.info(
f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})"
)
logger.info(f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})")
return True
def load_symbol_dynamically(

View File

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

View File

@@ -106,7 +106,7 @@ class FootprintCreator:
# ---- header ----
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_version "9.0")')
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_y = value_position.get("y", 1.27) if value_position else 1.27
lines.append(
f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)'
)
lines.append(f' (property "Reference" "REF**" (at {_fmt(ref_x)} {_fmt(ref_y)} 0)')
lines.append(f' (layer "F.SilkS")')
lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
lines.append(f' )')
lines.append(
f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)'
)
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f" )")
lines.append(f' (property "Value" "{_esc(name)}" (at {_fmt(val_x)} {_fmt(val_y)} 0)')
lines.append(f' (layer "F.Fab")')
lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
lines.append(f' )')
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f" )")
lines.append(f' (property "Datasheet" "" (at 0 0 0)')
lines.append(f' (layer "F.Fab")')
lines.append(f' (uuid "{_new_uuid()}")')
lines.append(f' (effects (font (size 1 1) (thickness 0.15)))')
lines.append(f' )')
lines.append(f" (effects (font (size 1 1) (thickness 0.15)))")
lines.append(f" )")
lines.append("")
# ---- courtyard ----
@@ -217,33 +213,32 @@ class FootprintCreator:
changes = []
if size:
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:
changes.append(f"size→{new_size}")
if at:
angle = at.get("angle", 0)
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:
changes.append(f"at→{new_at}")
if drill is not None:
if isinstance(drill, (int, float)):
new_drill = f'(drill {_fmt(drill)})'
new_drill = f"(drill {_fmt(drill)})"
else:
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:
changes.append(f"drill→{new_drill}")
else:
# 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}")
if shape:
block, n = re.subn(
r'(pad\s+"[^"]*"\s+\w+\s+)\w+',
lambda m: m.group(1) + shape,
block,
count=1
r'(pad\s+"[^"]*"\s+\w+\s+)\w+', lambda m: m.group(1) + shape, block, count=1
)
if n:
changes.append(f"shape→{shape}")
@@ -280,7 +275,7 @@ class FootprintCreator:
if not updated:
return {
"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")
@@ -429,6 +424,7 @@ class FootprintCreator:
# Internal helpers #
# ------------------------------------------------------------------ #
def _esc(s: str) -> str:
"""Escape double-quotes inside S-Expression string values."""
return s.replace('"', '\\"')
@@ -436,6 +432,7 @@ def _esc(s: str) -> str:
def _new_uuid() -> str:
import uuid
return str(uuid.uuid4())
@@ -462,7 +459,9 @@ def _pad_lines(pad: Dict[str, Any]) -> List[str]:
sh = _fmt(size.get("h", 1.0))
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)
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))
w = _fmt(rect.get("width", default_width))
return [
f' (fp_rect',
f' (start {x1} {y1})',
f' (end {x2} {y2})',
f' (stroke (width {w}) (type default))',
f' (fill none)',
f" (fp_rect",
f" (start {x1} {y1})",
f" (end {x2} {y2})",
f" (stroke (width {w}) (type default))",
f" (fill none)",
f' (layer "{layer}")',
f' (uuid "{_new_uuid()}")',
f' )',
f" )",
"",
]

View File

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

View File

@@ -18,7 +18,7 @@ import json
from typing import Optional, Dict, List, Callable
from pathlib import Path
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class JLCPCBClient:
@@ -31,7 +31,12 @@ class JLCPCBClient:
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
@@ -40,20 +45,24 @@ class JLCPCBClient:
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
"""
self.app_id = app_id or os.getenv('JLCPCB_APP_ID')
self.access_key = access_key or os.getenv('JLCPCB_API_KEY')
self.secret_key = secret_key or os.getenv('JLCPCB_API_SECRET')
self.app_id = app_id or os.getenv("JLCPCB_APP_ID")
self.access_key = access_key or os.getenv("JLCPCB_API_KEY")
self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET")
if not self.app_id or not self.access_key or not self.secret_key:
logger.warning("JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables.")
logger.warning(
"JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
)
@staticmethod
def _generate_nonce() -> str:
"""Generate a 32-character random nonce"""
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
@@ -87,11 +96,9 @@ class JLCPCBClient:
Base64-encoded signature
"""
signature_bytes = hmac.new(
self.secret_key.encode('utf-8'),
signature_string.encode('utf-8'),
hashlib.sha256
self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
).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:
"""
@@ -106,7 +113,9 @@ class JLCPCBClient:
Authorization header value
"""
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()
timestamp = int(time.time())
@@ -116,7 +125,9 @@ class JLCPCBClient:
logger.debug(f"Signature string:\n{repr(signature_string)}")
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}"'
@@ -138,22 +149,16 @@ class JLCPCBClient:
# Convert payload to JSON string for signing
# 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
auth_header = self._get_auth_header("POST", path, body_str)
headers = {
"Authorization": auth_header,
"Content-Type": "application/json"
}
headers = {"Authorization": auth_header, "Content-Type": "application/json"}
try:
response = requests.post(
f"{self.BASE_URL}{path}",
headers=headers,
json=payload,
timeout=60
f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
)
logger.debug(f"Response status: {response.status_code}")
@@ -163,18 +168,19 @@ class JLCPCBClient:
response.raise_for_status()
data = response.json()
if data.get('code') != 200:
raise Exception(f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}")
if data.get("code") != 200:
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:
logger.error(f"Failed to fetch parts page: {e}")
raise Exception(f"JLCPCB API request failed: {e}")
def download_full_database(
self,
callback: Optional[Callable[[int, int, str], None]] = None
self, callback: Optional[Callable[[int, int, str], None]] = None
) -> List[Dict]:
"""
Download entire parts library from JLCPCB
@@ -197,10 +203,10 @@ class JLCPCBClient:
try:
data = self.fetch_parts_page(last_key)
parts = data.get('componentInfos', [])
parts = data.get("componentInfos", [])
all_parts.extend(parts)
last_key = data.get('lastKey')
last_key = data.get("lastKey")
if callback:
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
@@ -245,7 +251,9 @@ class JLCPCBClient:
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
@@ -268,7 +276,7 @@ def test_jlcpcb_connection(app_id: Optional[str] = None, access_key: Optional[st
return False
if __name__ == '__main__':
if __name__ == "__main__":
# Test the JLCPCB client
logging.basicConfig(level=logging.INFO)
@@ -279,7 +287,7 @@ if __name__ == '__main__':
client = JLCPCBClient()
print("\nFetching first page of parts...")
data = client.fetch_parts_page()
parts = data.get('componentInfos', [])
parts = data.get("componentInfos", [])
print(f"✓ Retrieved {len(parts)} parts in first page")
if parts:

View File

@@ -13,7 +13,7 @@ from pathlib import Path
from typing import List, Dict, Optional
from datetime import datetime
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class JLCPCBPartsManager:
@@ -49,7 +49,7 @@ class JLCPCBPartsManager:
cursor = self.conn.cursor()
# Create components table
cursor.execute('''
cursor.execute("""
CREATE TABLE IF NOT EXISTS components (
lcsc TEXT PRIMARY KEY,
category TEXT,
@@ -65,17 +65,19 @@ class JLCPCBPartsManager:
price_json TEXT,
last_updated INTEGER
)
''')
""")
# Create indexes for fast searching
cursor.execute('CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_package ON components(package)')
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)')
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)"
)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_package ON components(package)")
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
cursor.execute('''
cursor.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5(
lcsc,
description,
@@ -83,7 +85,7 @@ class JLCPCBPartsManager:
manufacturer,
content=components
)
''')
""")
self.conn.commit()
logger.info(f"Initialized JLCPCB parts database at {self.db_path}")
@@ -103,32 +105,35 @@ class JLCPCBPartsManager:
for i, part in enumerate(parts):
try:
# Extract price breaks
price_json = json.dumps(part.get('prices', []))
price_json = json.dumps(part.get("prices", []))
# Determine library type
library_type = self._determine_library_type(part)
cursor.execute('''
cursor.execute(
"""
INSERT OR REPLACE INTO components (
lcsc, category, subcategory, mfr_part, package,
solder_joints, manufacturer, library_type, description,
datasheet, stock, price_json, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
part.get('componentCode'), # lcsc
part.get('firstSortName'), # category
part.get('secondSortName'), # subcategory
part.get('componentModelEn'), # mfr_part
part.get('componentSpecificationEn'), # package
part.get('soldPoint'), # solder_joints
part.get('componentBrandEn'), # manufacturer
""",
(
part.get("componentCode"), # lcsc
part.get("firstSortName"), # category
part.get("secondSortName"), # subcategory
part.get("componentModelEn"), # mfr_part
part.get("componentSpecificationEn"), # package
part.get("soldPoint"), # solder_joints
part.get("componentBrandEn"), # manufacturer
library_type, # library_type
part.get('describe'), # description
part.get('dataManualUrl'), # datasheet
part.get('stockCount', 0), # stock
part.get("describe"), # description
part.get("dataManualUrl"), # datasheet
part.get("stockCount", 0), # stock
price_json, # price_json
int(datetime.now().timestamp()) # last_updated
))
int(datetime.now().timestamp()), # last_updated
),
)
imported += 1
@@ -140,10 +145,10 @@ class JLCPCBPartsManager:
skipped += 1
# Update FTS index
cursor.execute('''
cursor.execute("""
INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer)
SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components
''')
""")
self.conn.commit()
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
@@ -151,16 +156,16 @@ class JLCPCBPartsManager:
def _determine_library_type(self, part: Dict) -> str:
"""Determine if part is Basic, Extended, or Preferred"""
# 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':
return 'Basic'
elif 'Extended' in assembly_type:
return 'Extended'
elif 'Prefer' in assembly_type:
return 'Preferred'
if "Basic" in assembly_type or part.get("libraryType") == "base":
return "Basic"
elif "Extended" in assembly_type:
return "Extended"
elif "Prefer" in assembly_type:
return "Preferred"
else:
return 'Extended' # Default to Extended
return "Extended" # Default to Extended
def import_jlcsearch_parts(self, parts: List[Dict], progress_callback=None):
"""
@@ -178,56 +183,59 @@ class JLCPCBPartsManager:
try:
# JLCSearch format is different from official API
# LCSC is an integer, we need to add 'C' prefix
lcsc = part.get('lcsc')
lcsc = part.get("lcsc")
if isinstance(lcsc, int):
lcsc = f"C{lcsc}"
# 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 [])
# Determine library type from is_basic flag
library_type = 'Basic' if part.get('is_basic') else 'Extended'
if part.get('is_preferred'):
library_type = 'Preferred'
library_type = "Basic" if part.get("is_basic") else "Extended"
if part.get("is_preferred"):
library_type = "Preferred"
# Extract description from various fields
description_parts = []
if 'resistance' in part:
if "resistance" in part:
description_parts.append(f"{part['resistance']}Ω")
if 'capacitance' in part:
if "capacitance" in part:
description_parts.append(f"{part['capacitance']}F")
if 'tolerance_fraction' in part:
tol = part['tolerance_fraction'] * 100
if "tolerance_fraction" in part:
tol = part["tolerance_fraction"] * 100
description_parts.append(f"±{tol}%")
if 'power_watts' in part:
if "power_watts" in part:
description_parts.append(f"{part['power_watts']}mW")
if 'voltage' in part:
if "voltage" in part:
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 (
lcsc, category, subcategory, mfr_part, package,
solder_joints, manufacturer, library_type, description,
datasheet, stock, price_json, last_updated
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
""",
(
lcsc, # lcsc with C prefix
part.get('category', ''), # category
part.get('subcategory', ''), # subcategory
part.get('mfr', ''), # mfr_part
part.get('package', ''), # package
part.get("category", ""), # category
part.get("subcategory", ""), # subcategory
part.get("mfr", ""), # mfr_part
part.get("package", ""), # package
0, # solder_joints (not in jlcsearch)
part.get('manufacturer', ''), # manufacturer
part.get("manufacturer", ""), # manufacturer
library_type, # library_type
description, # description
'', # datasheet (not in jlcsearch)
part.get('stock', 0), # stock
"", # datasheet (not in jlcsearch)
part.get("stock", 0), # stock
price_json, # price_json
int(datetime.now().timestamp()) # last_updated
))
int(datetime.now().timestamp()), # last_updated
),
)
imported += 1
@@ -239,10 +247,10 @@ class JLCPCBPartsManager:
skipped += 1
# Update FTS index
cursor.execute('''
cursor.execute("""
INSERT INTO components_fts(components_fts)
VALUES('rebuild')
''')
""")
self.conn.commit()
logger.info(f"Import complete: {imported} parts imported, {skipped} skipped")
@@ -255,7 +263,7 @@ class JLCPCBPartsManager:
library_type: Optional[str] = None,
manufacturer: Optional[str] = None,
in_stock: bool = True,
limit: int = 20
limit: int = 20,
) -> List[Dict]:
"""
Search for parts with filters
@@ -283,15 +291,14 @@ class JLCPCBPartsManager:
# Add prefix wildcard to each term for partial matching
# (e.g., "BQ25895" becomes "BQ25895*" so FTS matches "BQ25895RTWR")
fts_query = " ".join(
f"{term}*" if not term.endswith("*") else term
for term in query.strip().split()
f"{term}*" if not term.endswith("*") else term for term in query.strip().split()
)
sql_parts.append('''
sql_parts.append("""
AND lcsc IN (
SELECT lcsc FROM components_fts
WHERE components_fts MATCH ?
)
''')
""")
params.append(fts_query)
if category:
@@ -343,11 +350,11 @@ class JLCPCBPartsManager:
if row:
part = dict(row)
# Parse price JSON
if part.get('price_json'):
if part.get("price_json"):
try:
part['price_breaks'] = json.loads(part['price_json'])
part["price_breaks"] = json.loads(part["price_json"])
except:
part['price_breaks'] = []
part["price_breaks"] = []
return part
return None
@@ -356,23 +363,25 @@ class JLCPCBPartsManager:
cursor = self.conn.cursor()
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'")
basic = cursor.fetchone()['basic']
basic = cursor.fetchone()["basic"]
cursor.execute("SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'")
extended = cursor.fetchone()['extended']
cursor.execute(
"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")
in_stock = cursor.fetchone()['in_stock']
in_stock = cursor.fetchone()["in_stock"]
return {
'total_parts': total,
'basic_parts': basic,
'extended_parts': extended,
'in_stock': in_stock,
'db_path': self.db_path
"total_parts": total,
"basic_parts": basic,
"extended_parts": extended,
"in_stock": in_stock,
"db_path": self.db_path,
}
def map_package_to_footprint(self, package: str) -> List[str]:
@@ -390,43 +399,22 @@ class JLCPCBPartsManager:
"0402": [
"Resistor_SMD:R_0402_1005Metric",
"Capacitor_SMD:C_0402_1005Metric",
"LED_SMD:LED_0402_1005Metric"
"LED_SMD:LED_0402_1005Metric",
],
"0603": [
"Resistor_SMD:R_0603_1608Metric",
"Capacitor_SMD:C_0603_1608Metric",
"LED_SMD:LED_0603_1608Metric"
"LED_SMD:LED_0603_1608Metric",
],
"0805": [
"Resistor_SMD:R_0805_2012Metric",
"Capacitor_SMD:C_0805_2012Metric"
],
"1206": [
"Resistor_SMD:R_1206_3216Metric",
"Capacitor_SMD:C_1206_3216Metric"
],
"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"
],
"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"
]
"0805": ["Resistor_SMD:R_0805_2012Metric", "Capacitor_SMD:C_0805_2012Metric"],
"1206": ["Resistor_SMD:R_1206_3216Metric", "Capacitor_SMD:C_1206_3216Metric"],
"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"],
"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
@@ -457,24 +445,21 @@ class JLCPCBPartsManager:
# Search for parts in same category with same package
alternatives = self.search_parts(
category=part['subcategory'],
package=part['package'],
in_stock=True,
limit=limit * 3
category=part["subcategory"], package=part["package"], in_stock=True, limit=limit * 3
)
# 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
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:
prices = json.loads(p.get('price_json', '[]'))
price = float(prices[0].get('price', 999)) if prices else 999
prices = json.loads(p.get("price_json", "[]"))
price = float(prices[0].get("price", 999)) if prices else 999
except:
price = 999
stock = p.get('stock', 0)
stock = p.get("stock", 0)
return (-is_basic, price, -stock)
@@ -488,7 +473,7 @@ class JLCPCBPartsManager:
self.conn.close()
if __name__ == '__main__':
if __name__ == "__main__":
# Test the parts manager
logging.basicConfig(level=logging.INFO)
@@ -503,8 +488,10 @@ if __name__ == '__main__':
print(f" In stock: {stats['in_stock']}")
print(f" Database: {stats['db_path']}")
if stats['total_parts'] > 0:
if stats["total_parts"] > 0:
print("\nSearching for '10k resistor'...")
results = manager.search_parts(query="10k resistor", limit=5)
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
import time
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class JLCSearchClient:
@@ -28,11 +28,7 @@ class JLCSearchClient:
pass
def search_components(
self,
category: str = "components",
limit: int = 100,
offset: int = 0,
**filters
self, category: str = "components", limit: int = 100, offset: int = 0, **filters
) -> List[Dict]:
"""
Search components in JLCSearch database
@@ -48,11 +44,7 @@ class JLCSearchClient:
"""
url = f"{self.BASE_URL}/{category}/list.json"
params = {
"limit": limit,
"offset": offset,
**filters
}
params = {"limit": limit, "offset": offset, **filters}
try:
response = requests.get(url, params=params, timeout=30)
@@ -71,7 +63,9 @@ class JLCSearchClient:
logger.error(f"Failed to search JLCSearch: {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
@@ -100,7 +94,9 @@ class JLCSearchClient:
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
@@ -141,9 +137,7 @@ class JLCSearchClient:
return None
def download_all_components(
self,
callback: Optional[Callable[[int, str], None]] = None,
batch_size: int = 100
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
) -> List[Dict]:
"""
Download all components from jlcsearch database
@@ -165,11 +159,7 @@ class JLCSearchClient:
while True:
try:
batch = self.search_components(
"components",
limit=batch_size,
offset=offset
)
batch = self.search_components("components", limit=batch_size, offset=offset)
# Stop if no results returned (end of catalog)
if not batch or len(batch) == 0:
@@ -219,7 +209,7 @@ def test_jlcsearch_connection() -> bool:
return False
if __name__ == '__main__':
if __name__ == "__main__":
# Test the JLCSearch client
logging.basicConfig(level=logging.INFO)

View File

@@ -116,9 +116,7 @@ class LibraryManager:
self.libraries[nickname] = resolved_uri
logger.debug(f" Found library: {nickname} -> {resolved_uri}")
else:
logger.warning(
f" Could not resolve URI for library {nickname}: {uri}"
)
logger.warning(f" Could not resolve URI for library {nickname}: {uri}")
except Exception as e:
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
@@ -221,12 +219,7 @@ class LibraryManager:
/ "9.0"
/ "kicad_common.json", # macOS
Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux
Path.home()
/ "AppData"
/ "Roaming"
/ "kicad"
/ "9.0"
/ "kicad_common.json", # Windows
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows
]
for config_path in kicad_common_paths:
@@ -352,9 +345,7 @@ class LibraryManager:
for library_nickname, library_path in self.libraries.items():
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
if fp_file.exists():
logger.info(
f"Found footprint {footprint_name} in library {library_nickname}"
)
logger.info(f"Found footprint {footprint_name} in library {library_nickname}")
return (library_path, footprint_name)
logger.warning(f"Footprint not found in any library: {footprint_name}")
@@ -461,9 +452,7 @@ class LibraryCommands:
# Filter by library if specified
if library_filter:
results = [
r
for r in results
if r.get("library", "").lower() == library_filter.lower()
r for r in results if r.get("library", "").lower() == library_filter.lower()
]
results = results[:limit]
@@ -527,7 +516,6 @@ class LibraryCommands:
info: Dict = {
"library": library_nickname,
"name": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}",
"library_path": library_path,
}
@@ -536,6 +524,7 @@ class LibraryCommands:
try:
from parsers.kicad_mod_parser import parse_kicad_mod
from pathlib import Path as _Path
mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod")
parsed = parse_kicad_mod(mod_file)
if parsed:
@@ -546,9 +535,13 @@ class LibraryCommands:
info["full_name"] = f"{library_nickname}:{footprint_name}"
info["library_path"] = library_path
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:
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}

View File

@@ -1,8 +1,10 @@
from skip import Schematic
# Symbol class might not be directly importable in the current version
import os
import glob
class LibraryManager:
"""Manage symbol libraries"""
@@ -16,7 +18,9 @@ class LibraryManager:
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern
"/usr/share/kicad/symbols/*.kicad_sym", # Linux 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 = []
@@ -30,7 +34,9 @@ class LibraryManager:
# Extract library names from paths
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 {"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
# or by parsing the library file 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 []
except Exception as e:
print(f"Error listing symbols in library {library_path}: {e}")
@@ -59,7 +67,9 @@ class LibraryManager:
try:
# Similar to list_library_symbols, this might require a more direct approach
# 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 {}
except Exception as 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)
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
except Exception as e:
print(f"Error searching for symbols matching '{query}': {e}")
@@ -119,7 +131,8 @@ class LibraryManager:
# Default fallback
return {"library": "Device", "symbol": "R"}
if __name__ == '__main__':
if __name__ == "__main__":
# Example Usage (for testing)
# List available libraries
libraries = LibraryManager.list_available_libraries()

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,31 +31,28 @@ class SchematicManager:
# Regenerate UUID to ensure uniqueness for each created schematic
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()
new_uuid = str(uuid.uuid4())
content = re.sub(
r'\(uuid [0-9a-fA-F-]+\)',
f'(uuid {new_uuid})',
r"\(uuid [0-9a-fA-F-]+\)",
f"(uuid {new_uuid})",
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)
logger.info(f"Created schematic from template: {output_path}")
else:
# Fallback: create minimal schematic
logger.warning(
f"Template not found at {template_path}, creating minimal schematic"
)
logger.warning(f"Template not found at {template_path}, creating minimal schematic")
# Generate unique UUID for this schematic
schematic_uuid = str(uuid.uuid4())
# 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:
f.write(
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\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:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]:
if (
isinstance(pt, list)
and len(pt) >= 3
and pt[0] == Symbol("xy")
):
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
points.append((float(pt[1]), float(pt[2])))
elif tag == Symbol("circle"):
# (circle (center x y) (radius r) ...)
cx, cy, r = 0.0, 0.0, 0.0
for sub in sexp[1:]:
if (
isinstance(sub, list)
and len(sub) >= 3
and sub[0] == Symbol("center")
):
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"):
cx, cy = float(sub[1]), float(sub[2])
elif (
isinstance(sub, list)
and len(sub) >= 2
and sub[0] == Symbol("radius")
):
elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"):
r = float(sub[1])
if r > 0:
points.extend(
@@ -212,11 +200,7 @@ def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]:
if (
isinstance(pt, list)
and len(pt) >= 3
and pt[0] == Symbol("xy")
):
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
points.append((float(pt[1]), float(pt[2])))
else:
@@ -243,11 +227,7 @@ def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
"""
lib_symbols_section = None
for item in sexp_data:
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
lib_symbols_section = item
break
@@ -465,9 +445,7 @@ def _compute_symbol_bbox_direct(
# ---------------------------------------------------------------------------
def find_overlapping_elements(
schematic_path: Path, tolerance: float = 0.5
) -> Dict[str, Any]:
def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]:
"""
Detect spatially overlapping symbols, wires, and labels.
@@ -490,9 +468,7 @@ def find_overlapping_elements(
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) ---
non_template_symbols = [
s
for s in symbols
if not s["reference"].startswith("_TEMPLATE") and s["reference"]
s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"]
]
# Pre-compute bounding boxes for all non-template symbols
@@ -503,9 +479,7 @@ def find_overlapping_elements(
graphics_points = lib_data.get("graphics_points", [])
bbox = None
if pin_defs:
bbox = _compute_symbol_bbox_direct(
sym, pin_defs, graphics_points=graphics_points
)
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
symbol_bboxes.append((sym, bbox))
for i in range(len(symbol_bboxes)):
@@ -706,9 +680,7 @@ def get_elements_in_region(
if (
_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 _line_segment_intersects_aabb(
s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y
)
or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y)
):
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
if start_at_pin:
nsx, nsy = sx + ux * nudge, sy + uy * nudge
if not _line_segment_intersects_aabb(
nsx, nsy, ex, ey, bx1, by1, bx2, by2
):
if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2):
continue # Wire terminates at pin from outside
else:
nex, ney = ex - ux * nudge, ey - uy * nudge
if not _line_segment_intersects_aabb(
sx, sy, nex, ney, bx1, by1, bx2, by2
):
if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2):
continue # Wire terminates at pin from outside
sym = sd["sym"]

View File

@@ -34,9 +34,7 @@ Polygon = List[Point]
# ---------------------------------------------------------------------------
# SVG path tokenizer
# ---------------------------------------------------------------------------
_TOKEN_RE = re.compile(
r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)"
)
_TOKEN_RE = re.compile(r"([MmZzLlHhVvCcSsQqTtAa])|([+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?)")
def _tokenize_path(d: str) -> List[str]:
@@ -73,7 +71,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
i += n
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 = []
for k in range(1, steps + 1):
t = k / steps
@@ -93,8 +93,18 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
pts.append((x, y))
return pts
def arc_points(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]:
def arc_points(
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)."""
if rx == 0 or ry == 0:
return [(x2, y2)]
@@ -120,7 +130,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
cy_ = sin_phi * cxp + cos_phi * cyp + (y1 + y2) / 2
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(
max(-1, min(1, (ux * vx + uy * vy) / (math.hypot(ux, uy) * math.hypot(vx, vy))))
)
if ux * vy - uy * vx < 0:
a = -a
return a
@@ -144,8 +156,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
# --- main loop ---
while i < len(tokens):
tok = tokens[i]
if tok.lstrip('+-').replace('.', '', 1).replace('e', '', 1).replace('E', '', 1).lstrip('+-').isdigit() or \
re.match(r'^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$', tok):
if tok.lstrip("+-").replace(".", "", 1).replace("e", "", 1).replace("E", "", 1).lstrip(
"+-"
).isdigit() or re.match(r"^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$", tok):
# implicit repeat of last command
pass
else:
@@ -155,7 +168,7 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
rel = cmd.islower()
if cmd in ('M', 'm'):
if cmd in ("M", "m"):
x, y = consume(2)
if rel:
cx, cy = cx + x, cy + y
@@ -166,9 +179,9 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
current = [(cx, cy)]
sx, sy = cx, cy
# 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)
if rel:
cx, cy = cx + x, cy + y
@@ -176,36 +189,46 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
cx, cy = x, y
current.append((cx, cy))
elif cmd in ('H', 'h'):
x = float(tokens[i]); i += 1
elif cmd in ("H", "h"):
x = float(tokens[i])
i += 1
cx = cx + x if rel else x
current.append((cx, cy))
elif cmd in ('V', 'v'):
y = float(tokens[i]); i += 1
elif cmd in ("V", "v"):
y = float(tokens[i])
i += 1
cy = cy + y if rel else y
current.append((cx, cy))
elif cmd in ('Z', 'z'):
elif cmd in ("Z", "z"):
current.append((sx, sy)) # close
polygons.append(current)
current = []
cx, cy = sx, sy
elif cmd in ('C', 'c'):
elif cmd in ("C", "c"):
x1, y1, x2, y2, x, y = consume(6)
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))
current.extend(pts)
last_ctrl = (x2, y2)
cx, cy = x, y
elif cmd in ('S', 's'):
elif cmd in ("S", "s"):
x2, y2, x, y = consume(4)
if rel:
x2 += cx; y2 += cy; x += cx; y += cy
if last_ctrl and last_cmd in ('C', 'c', 'S', 's'):
x2 += cx
y2 += cy
x += cx
y += cy
if last_ctrl and last_cmd in ("C", "c", "S", "s"):
x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1]
else:
@@ -215,20 +238,24 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
last_ctrl = (x2, y2)
cx, cy = x, y
elif cmd in ('Q', 'q'):
elif cmd in ("Q", "q"):
x1, y1, x, y = consume(4)
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))
current.extend(pts)
last_ctrl = (x1, y1)
cx, cy = x, y
elif cmd in ('T', 't'):
elif cmd in ("T", "t"):
x, y = consume(2)
if rel:
x += cx; y += cy
if last_ctrl and last_cmd in ('Q', 'q', 'T', 't'):
x += cx
y += cy
if last_ctrl and last_cmd in ("Q", "q", "T", "t"):
x1 = 2 * cx - last_ctrl[0]
y1 = 2 * cy - last_ctrl[1]
else:
@@ -238,11 +265,12 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
last_ctrl = (x1, y1)
cx, cy = x, y
elif cmd in ('A', 'a'):
elif cmd in ("A", "a"):
rx, ry, phi, large, sweep, x, y = consume(7)
large, sweep = int(large), int(sweep)
if rel:
x += cx; y += cy
x += cx
y += cy
pts = arc_points(cx, cy, rx, ry, phi, large, sweep, x, y)
current.extend(pts)
cx, cy = x, y
@@ -264,35 +292,32 @@ def _parse_path_tokens(tokens: List[str]) -> List[Polygon]:
# ---------------------------------------------------------------------------
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]."""
def identity():
return [[1, 0, 0], [0, 1, 0], [0, 0, 1]]
def mat_mul(A, B):
return [
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
for r in range(3)
]
return [[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)] for r in range(3)]
result = identity()
for m in re.finditer(
r'(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)',
transform_str
r"(matrix|translate|scale|rotate|skewX|skewY)\s*\(([^)]*)\)", transform_str
):
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()
if func == 'matrix' and len(args) == 6:
if func == "matrix" and len(args) == 6:
a, b, c, d, e, f = args
mat = [[a, c, e], [b, d, f], [0, 0, 1]]
elif func == 'translate':
elif func == "translate":
tx = args[0]
ty = args[1] if len(args) > 1 else 0
mat = [[1, 0, tx], [0, 1, ty], [0, 0, 1]]
elif func == 'scale':
elif func == "scale":
sx = args[0]
sy = args[1] if len(args) > 1 else sx
mat = [[sx, 0, 0], [0, sy, 0], [0, 0, 1]]
elif func == 'rotate':
elif func == "rotate":
angle = math.radians(args[0])
cos, sin = math.cos(angle), math.sin(angle)
if len(args) == 3:
@@ -303,9 +328,9 @@ def _parse_transform(transform_str: str) -> List[List[float]]:
mat = mat_mul(mat_mul(t1, r), t2)
else:
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]]
elif func == 'skewY':
elif func == "skewY":
mat = [[1, 0, 0], [math.tan(math.radians(args[0])), 1, 0], [0, 0, 1]]
result = mat_mul(result, mat)
return result
@@ -321,25 +346,22 @@ def _apply_transform(pts: List[Point], mat: List[List[float]]) -> List[Point]:
def _mat_mul(A, B):
return [
[sum(A[r][k] * B[k][c] for k in range(3)) for c in range(3)]
for r in range(3)
]
return [[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_NS = re.compile(r'\{[^}]+\}')
SVG_NS = re.compile(r"\{[^}]+\}")
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]:
for key in el.attrib:
if SVG_NS.sub('', key) == name:
if SVG_NS.sub("", key) == name:
return el.attrib[key]
return default
@@ -351,13 +373,13 @@ def _identity():
def _extract_polygons_from_element(el: ET.Element, parent_mat: List[List[float]]) -> List[Polygon]:
"""Recursively extract all polygons from an SVG element tree."""
tag = _tag(el)
display = _get_attr(el, 'display', 'inline')
visibility = _get_attr(el, 'visibility', 'visible')
if display == 'none' or visibility == 'hidden':
display = _get_attr(el, "display", "inline")
visibility = _get_attr(el, "visibility", "visible")
if display == "none" or visibility == "hidden":
return []
# Accumulate transform
transform_str = _get_attr(el, 'transform', '')
transform_str = _get_attr(el, "transform", "")
if transform_str:
local_mat = _parse_transform(transform_str)
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] = []
if tag == 'g' or tag == 'svg':
if tag == "g" or tag == "svg":
for child in el:
result.extend(_extract_polygons_from_element(child, mat))
elif tag == 'path':
d = _get_attr(el, 'd', '')
elif tag == "path":
d = _get_attr(el, "d", "")
if d:
tokens = _tokenize_path(d)
polygons = _parse_path_tokens(tokens)
for poly in polygons:
result.append(_apply_transform(poly, mat))
elif tag == 'rect':
x = float(_get_attr(el, 'x', '0') or 0)
y = float(_get_attr(el, 'y', '0') or 0)
w = float(_get_attr(el, 'width', '0') or 0)
h = float(_get_attr(el, 'height', '0') or 0)
elif tag == "rect":
x = float(_get_attr(el, "x", "0") or 0)
y = float(_get_attr(el, "y", "0") or 0)
w = float(_get_attr(el, "width", "0") or 0)
h = float(_get_attr(el, "height", "0") or 0)
if w > 0 and h > 0:
pts = [(x, y), (x + w, y), (x + w, y + h), (x, y + h), (x, y)]
result.append(_apply_transform(pts, mat))
elif tag == 'circle':
cx_ = float(_get_attr(el, 'cx', '0') or 0)
cy_ = float(_get_attr(el, 'cy', '0') or 0)
r = float(_get_attr(el, 'r', '0') or 0)
elif tag == "circle":
cx_ = float(_get_attr(el, "cx", "0") or 0)
cy_ = float(_get_attr(el, "cy", "0") or 0)
r = float(_get_attr(el, "r", "0") or 0)
if r > 0:
steps = 36
pts = [(cx_ + r * math.cos(2 * math.pi * k / steps),
cy_ + r * math.sin(2 * math.pi * k / steps))
for k in range(steps + 1)]
pts = [
(
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))
elif tag == 'ellipse':
cx_ = float(_get_attr(el, 'cx', '0') or 0)
cy_ = float(_get_attr(el, 'cy', '0') or 0)
rx = float(_get_attr(el, 'rx', '0') or 0)
ry = float(_get_attr(el, 'ry', '0') or 0)
elif tag == "ellipse":
cx_ = float(_get_attr(el, "cx", "0") or 0)
cy_ = float(_get_attr(el, "cy", "0") or 0)
rx = float(_get_attr(el, "rx", "0") or 0)
ry = float(_get_attr(el, "ry", "0") or 0)
if rx > 0 and ry > 0:
steps = 36
pts = [(cx_ + rx * math.cos(2 * math.pi * k / steps),
cy_ + ry * math.sin(2 * math.pi * k / steps))
for k in range(steps + 1)]
pts = [
(
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))
elif tag in ('polygon', 'polyline'):
points_str = _get_attr(el, 'points', '')
elif tag in ("polygon", "polyline"):
points_str = _get_attr(el, "points", "")
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)]
if tag == 'polygon' and pts:
if tag == "polygon" and pts:
pts.append(pts[0]) # close
if pts:
result.append(_apply_transform(pts, mat))
elif tag == 'line':
x1 = float(_get_attr(el, 'x1', '0') or 0)
y1 = float(_get_attr(el, 'y1', '0') or 0)
x2 = float(_get_attr(el, 'x2', '0') or 0)
y2 = float(_get_attr(el, 'y2', '0') or 0)
elif tag == "line":
x1 = float(_get_attr(el, "x1", "0") or 0)
y1 = float(_get_attr(el, "y1", "0") or 0)
x2 = float(_get_attr(el, "x2", "0") or 0)
y2 = float(_get_attr(el, "y2", "0") or 0)
pts = [(x1, y1), (x2, y2)]
result.append(_apply_transform(pts, mat))
@@ -453,10 +483,13 @@ def _build_gr_poly(points: List[Point], layer: str, stroke_width: float, filled:
row = []
fill_str = "yes" if filled else "none"
uid = str(uuid.uuid4())
lines = [
lines = (
[
"\t(gr_poly",
"\t\t(pts",
] + pts_lines + [
]
+ pts_lines
+ [
"\t\t)",
"\t\t(stroke",
f"\t\t\t(width {stroke_width:.4f})",
@@ -467,6 +500,7 @@ def _build_gr_poly(points: List[Point], layer: str, stroke_width: float, filled:
f'\t\t(uuid "{uid}")',
"\t)",
]
)
return "\n".join(lines)
@@ -510,15 +544,15 @@ def import_svg_to_pcb(
root = tree.getroot()
# Determine SVG viewport
vb = _get_attr(root, 'viewBox')
vb = _get_attr(root, "viewBox")
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]
else:
w_str = _get_attr(root, 'width', '100') or '100'
h_str = _get_attr(root, 'height', '100') or '100'
svg_w = float(re.sub(r'[^\d.]', '', w_str) or 100)
svg_h = float(re.sub(r'[^\d.]', '', h_str) or 100)
w_str = _get_attr(root, "width", "100") or "100"
h_str = _get_attr(root, "height", "100") 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_x0, svg_y0 = 0.0, 0.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"
last_paren = pcb_content.rfind(")")
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:]
@@ -597,5 +634,6 @@ def import_svg_to_pcb(
except Exception as e:
logger.error(f"SVG import failed: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}

View File

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

View File

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

View File

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

View File

@@ -23,5 +23,5 @@ Usage:
from kicad_api.factory import create_backend
from kicad_api.base import KiCADBackend
__all__ = ['create_backend', 'KiCADBackend']
__version__ = '2.0.0-alpha.1'
__all__ = ["create_backend", "KiCADBackend"]
__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.
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional, Dict, Any, List
@@ -97,7 +98,7 @@ class KiCADBackend(ABC):
# Board Operations
@abstractmethod
def get_board(self) -> 'BoardAPI':
def get_board(self) -> "BoardAPI":
"""
Get board API for current project
@@ -167,7 +168,7 @@ class BoardAPI(ABC):
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu"
layer: str = "F.Cu",
) -> bool:
"""
Place a component on the board
@@ -194,7 +195,7 @@ class BoardAPI(ABC):
end_y: float,
width: float = 0.25,
layer: str = "F.Cu",
net_name: Optional[str] = None
net_name: Optional[str] = None,
) -> bool:
"""
Add a track (trace) to the board
@@ -220,7 +221,7 @@ class BoardAPI(ABC):
diameter: float = 0.8,
drill: float = 0.4,
net_name: Optional[str] = None,
via_type: str = "through"
via_type: str = "through",
) -> bool:
"""
Add a via to the board
@@ -275,14 +276,17 @@ class BoardAPI(ABC):
class BackendError(Exception):
"""Base exception for backend errors"""
pass
class ConnectionError(BackendError):
"""Raised when connection to KiCAD fails"""
pass
class APINotAvailableError(BackendError):
"""Raised when required API is not available"""
pass

View File

@@ -3,6 +3,7 @@ Backend factory for creating appropriate KiCAD API backend
Auto-detects available backends and provides fallback mechanism.
"""
import os
import logging
from typing import Optional
@@ -34,16 +35,16 @@ def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
"""
# Check environment variable override
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}")
# Try specific backend if requested
if backend_type == 'ipc':
if backend_type == "ipc":
return _create_ipc_backend()
elif backend_type == 'swig':
elif backend_type == "swig":
return _create_swig_backend()
elif backend_type == 'auto':
elif backend_type == "auto":
return _auto_detect_backend()
else:
raise ValueError(f"Unknown backend type: {backend_type}")
@@ -61,13 +62,13 @@ def _create_ipc_backend() -> KiCADBackend:
"""
try:
from kicad_api.ipc_backend import IPCBackend
logger.info("Creating IPC backend")
return IPCBackend()
except ImportError as e:
logger.error(f"IPC backend not available: {e}")
raise APINotAvailableError(
"IPC backend requires 'kicad-python' package. "
"Install with: pip install kicad-python"
"IPC backend requires 'kicad-python' package. " "Install with: pip install kicad-python"
) from e
@@ -83,6 +84,7 @@ def _create_swig_backend() -> KiCADBackend:
"""
try:
from kicad_api.swig_backend import SWIGBackend
logger.info("Creating SWIG backend")
logger.warning(
"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:
logger.error(f"SWIG backend not available: {e}")
raise APINotAvailableError(
"SWIG backend requires 'pcbnew' module. "
"Ensure KiCAD Python module is in PYTHONPATH."
"SWIG backend requires 'pcbnew' module. " "Ensure KiCAD Python module is in PYTHONPATH."
) from e
@@ -129,8 +130,7 @@ def _auto_detect_backend() -> KiCADBackend:
try:
backend = _create_swig_backend()
logger.warning(
"Using deprecated SWIG backend. "
"For best results, use IPC API with KiCAD running."
"Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running."
)
return backend
except (ImportError, APINotAvailableError) as e:
@@ -160,22 +160,18 @@ def get_available_backends() -> dict:
# Check IPC (kicad-python uses 'kipy' module name)
try:
import kipy
results['ipc'] = {
'available': True,
'version': getattr(kipy, '__version__', 'unknown')
}
results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")}
except ImportError:
results['ipc'] = {'available': False, 'version': None}
results["ipc"] = {"available": False, "version": None}
# Check SWIG
try:
import pcbnew
results['swig'] = {
'available': True,
'version': pcbnew.GetBuildVersion()
}
results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()}
except ImportError:
results['swig'] = {'available': False, 'version': None}
results["swig"] = {"available": False, "version": None}
return results
@@ -183,6 +179,7 @@ def get_available_backends() -> dict:
if __name__ == "__main__":
# Quick diagnostic
import json
print("KiCAD Backend Availability:")
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
- Multi-language support
"""
import logging
import os
import platform
from pathlib import Path
from typing import Optional, Dict, Any, List, Callable
from kicad_api.base import (
KiCADBackend,
BoardAPI,
ConnectionError,
APINotAvailableError
)
from kicad_api.base import KiCADBackend, BoardAPI, ConnectionError, APINotAvailableError
logger = logging.getLogger(__name__)
@@ -78,10 +74,10 @@ class IPCBackend(KiCADBackend):
# Common socket locations (Unix-like systems only)
# Windows uses named pipes, handled by auto-detect
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)
if hasattr(os, 'getuid'):
socket_paths_to_try.append(f'ipc:///run/user/{os.getuid()}/kicad/api.sock')
if hasattr(os, "getuid"):
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)
socket_paths_to_try.append(None)
@@ -117,8 +113,7 @@ class IPCBackend(KiCADBackend):
except ImportError as e:
logger.error("kicad-python library not found")
raise APINotAvailableError(
"IPC backend requires kicad-python. "
"Install with: pip install kicad-python"
"IPC backend requires kicad-python. " "Install with: pip install kicad-python"
) from e
except Exception as e:
logger.error(f"Failed to connect via IPC: {e}")
@@ -190,7 +185,7 @@ class IPCBackend(KiCADBackend):
return {
"success": False,
"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]:
@@ -209,22 +204,18 @@ class IPCBackend(KiCADBackend):
return {
"success": True,
"message": f"Project already open: {path}",
"path": str(path)
"path": str(path),
}
return {
"success": False,
"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:
logger.error(f"Failed to check project: {e}")
return {
"success": False,
"message": "Failed to check project",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to check project", "errorDetails": str(e)}
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
"""Save current project via IPC."""
@@ -240,17 +231,10 @@ class IPCBackend(KiCADBackend):
self._notify_change("save", {"path": str(path) if path else "current"})
return {
"success": True,
"message": "Project saved successfully"
}
return {"success": True, "message": "Project saved successfully"}
except Exception as e:
logger.error(f"Failed to save project: {e}")
return {
"success": False,
"message": "Failed to save project",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to save project", "errorDetails": str(e)}
def close_project(self) -> None:
"""Close current project (not supported via IPC)."""
@@ -380,8 +364,8 @@ class IPCBoardAPI(BoardAPI):
# Find bounding box of edge cuts
from kipy.util.units import to_mm
min_x = min_y = float('inf')
max_x = max_y = float('-inf')
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
for shape in shapes:
# Check if on Edge.Cuts layer
@@ -392,14 +376,10 @@ class IPCBoardAPI(BoardAPI):
max_x = max(max_x, bbox.max.x)
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": to_mm(max_x - min_x),
"height": to_mm(max_y - min_y),
"unit": "mm"
}
return {"width": to_mm(max_x - min_x), "height": to_mm(max_y - min_y), "unit": "mm"}
except Exception as e:
logger.error(f"Failed to get board size: {e}")
@@ -432,19 +412,23 @@ class IPCBoardAPI(BoardAPI):
for fp in footprints:
try:
pos = fp.position
components.append({
"reference": fp.reference_field.text.value if fp.reference_field else "",
components.append(
{
"reference": (
fp.reference_field.text.value if fp.reference_field else ""
),
"value": fp.value_field.text.value if fp.value_field else "",
"footprint": str(fp.definition.library_link) if fp.definition else "",
"position": {
"x": to_mm(pos.x) if pos else 0,
"y": to_mm(pos.y) if pos else 0,
"unit": "mm"
"unit": "mm",
},
"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 ""
})
"layer": str(fp.layer) if hasattr(fp, "layer") else "F.Cu",
"id": str(fp.id) if hasattr(fp, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing footprint: {e}")
continue
@@ -463,7 +447,7 @@ class IPCBoardAPI(BoardAPI):
y: float,
rotation: float = 0,
layer: str = "F.Cu",
value: str = ""
value: str = "",
) -> bool:
"""
Place a component on the board.
@@ -495,7 +479,9 @@ class IPCBoardAPI(BoardAPI):
)
else:
# 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(
reference, footprint, x, y, rotation, layer, value
)
@@ -518,8 +504,8 @@ class IPCBoardAPI(BoardAPI):
import pcbnew
# Parse library and footprint name
if ':' in footprint_path:
lib_name, fp_name = footprint_path.split(':', 1)
if ":" in footprint_path:
lib_name, fp_name = footprint_path.split(":", 1)
else:
# Try to find the footprint in all libraries
lib_name = None
@@ -560,14 +546,7 @@ class IPCBoardAPI(BoardAPI):
return None
def _place_loaded_footprint(
self,
loaded_fp,
reference: str,
x: float,
y: float,
rotation: float,
layer: str,
value: str
self, loaded_fp, reference: str, x: float, y: float, rotation: float, layer: str, value: str
) -> bool:
"""
Place a loaded pcbnew footprint onto the board.
@@ -589,7 +568,7 @@ class IPCBoardAPI(BoardAPI):
try:
docs = self._kicad.get_open_documents()
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)
break
except Exception as e:
@@ -637,24 +616,27 @@ class IPCBoardAPI(BoardAPI):
except Exception as e:
logger.debug(f"Could not refresh IPC board: {e}")
self._notify("component_placed", {
self._notify(
"component_placed",
{
"reference": reference,
"footprint": loaded_fp.GetFPIDAsString(),
"position": {"x": x, "y": y},
"rotation": rotation,
"layer": layer,
"loaded_from_library": True
})
"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
except Exception as e:
logger.error(f"Error placing loaded footprint: {e}")
# Fall back to placeholder
return self._place_placeholder_footprint(
reference, "", x, y, rotation, layer, value
)
return self._place_placeholder_footprint(reference, "", x, y, rotation, layer, value)
def _place_placeholder_footprint(
self,
@@ -664,7 +646,7 @@ class IPCBoardAPI(BoardAPI):
y: float,
rotation: float,
layer: str,
value: str
value: str,
) -> bool:
"""
Place a placeholder footprint when library loading fails.
@@ -701,15 +683,18 @@ class IPCBoardAPI(BoardAPI):
board.create_items(fp)
board.push_commit(commit, f"Placed component {reference}")
self._notify("component_placed", {
self._notify(
"component_placed",
{
"reference": reference,
"footprint": footprint,
"position": {"x": x, "y": y},
"rotation": rotation,
"layer": layer,
"loaded_from_library": False,
"is_placeholder": True
})
"is_placeholder": True,
},
)
logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm")
return True
@@ -718,7 +703,9 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to place placeholder component: {e}")
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)."""
try:
from kipy.geometry import Vector2, Angle
@@ -749,11 +736,10 @@ class IPCBoardAPI(BoardAPI):
board.update_items([target_fp])
board.push_commit(commit, f"Moved component {reference}")
self._notify("component_moved", {
"reference": reference,
"position": {"x": x, "y": y},
"rotation": rotation
})
self._notify(
"component_moved",
{"reference": reference, "position": {"x": x, "y": y}, "rotation": rotation},
)
return True
@@ -799,7 +785,7 @@ class IPCBoardAPI(BoardAPI):
end_y: float,
width: float = 0.25,
layer: str = "F.Cu",
net_name: Optional[str] = None
net_name: Optional[str] = None,
) -> bool:
"""
Add a track (trace) to the board.
@@ -842,13 +828,16 @@ class IPCBoardAPI(BoardAPI):
board.create_items(track)
board.push_commit(commit, "Added track")
self._notify("track_added", {
self._notify(
"track_added",
{
"start": {"x": start_x, "y": start_y},
"end": {"x": end_x, "y": end_y},
"width": width,
"layer": layer,
"net": net_name
})
"net": net_name,
},
)
logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm")
return True
@@ -864,7 +853,7 @@ class IPCBoardAPI(BoardAPI):
diameter: float = 0.8,
drill: float = 0.4,
net_name: Optional[str] = None,
via_type: str = "through"
via_type: str = "through",
) -> bool:
"""
Add a via to the board.
@@ -906,13 +895,16 @@ class IPCBoardAPI(BoardAPI):
board.create_items(via)
board.push_commit(commit, "Added via")
self._notify("via_added", {
self._notify(
"via_added",
{
"position": {"x": x, "y": y},
"diameter": diameter,
"drill": drill,
"net": net_name,
"type": via_type
})
"type": via_type,
},
)
logger.info(f"Added via at ({x}, {y}) mm")
return True
@@ -928,7 +920,7 @@ class IPCBoardAPI(BoardAPI):
y: float,
layer: str = "F.SilkS",
size: float = 1.0,
rotation: float = 0
rotation: float = 0,
) -> bool:
"""Add text to the board."""
try:
@@ -959,11 +951,7 @@ class IPCBoardAPI(BoardAPI):
board.create_items(board_text)
board.push_commit(commit, f"Added text: {text}")
self._notify("text_added", {
"text": text,
"position": {"x": x, "y": y},
"layer": layer
})
self._notify("text_added", {"text": text, "position": {"x": x, "y": y}, "layer": layer})
return True
@@ -982,20 +970,16 @@ class IPCBoardAPI(BoardAPI):
result = []
for track in tracks:
try:
result.append({
"start": {
"x": to_mm(track.start.x),
"y": to_mm(track.start.y)
},
"end": {
"x": to_mm(track.end.x),
"y": to_mm(track.end.y)
},
result.append(
{
"start": {"x": to_mm(track.start.x), "y": to_mm(track.start.y)},
"end": {"x": to_mm(track.end.x), "y": to_mm(track.end.y)},
"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 ""
})
"id": str(track.id) if hasattr(track, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing track: {e}")
continue
@@ -1017,17 +1001,16 @@ class IPCBoardAPI(BoardAPI):
result = []
for via in vias:
try:
result.append({
"position": {
"x": to_mm(via.position.x),
"y": to_mm(via.position.y)
},
result.append(
{
"position": {"x": to_mm(via.position.x), "y": to_mm(via.position.y)},
"diameter": to_mm(via.diameter),
"drill": to_mm(via.drill_diameter),
"net": via.net.name if via.net else "",
"type": str(via.type),
"id": str(via.id) if hasattr(via, 'id') else ""
})
"id": str(via.id) if hasattr(via, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing via: {e}")
continue
@@ -1047,10 +1030,9 @@ class IPCBoardAPI(BoardAPI):
result = []
for net in nets:
try:
result.append({
"name": net.name,
"code": net.code if hasattr(net, 'code') else 0
})
result.append(
{"name": net.name, "code": net.code if hasattr(net, "code") else 0}
)
except Exception as e:
logger.warning(f"Error processing net: {e}")
continue
@@ -1070,7 +1052,7 @@ class IPCBoardAPI(BoardAPI):
min_thickness: float = 0.25,
priority: int = 0,
fill_mode: str = "solid",
name: str = ""
name: str = "",
) -> bool:
"""
Add a copper pour zone to the board.
@@ -1157,12 +1139,10 @@ class IPCBoardAPI(BoardAPI):
board.create_items(zone)
board.push_commit(commit, f"Added copper zone on {layer}")
self._notify("zone_added", {
"layer": layer,
"net": net_name,
"points": len(points),
"priority": priority
})
self._notify(
"zone_added",
{"layer": layer, "net": net_name, "points": len(points), "priority": priority},
)
logger.info(f"Added zone on {layer} with {len(points)} points")
return True
@@ -1182,14 +1162,18 @@ class IPCBoardAPI(BoardAPI):
result = []
for zone in zones:
try:
result.append({
"name": zone.name if hasattr(zone, 'name') else "",
result.append(
{
"name": zone.name if hasattr(zone, "name") else "",
"net": zone.net.name if zone.net else "",
"priority": zone.priority if hasattr(zone, 'priority') else 0,
"layers": [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 ""
})
"priority": zone.priority if hasattr(zone, "priority") else 0,
"layers": (
[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:
logger.warning(f"Error processing zone: {e}")
continue
@@ -1219,10 +1203,9 @@ class IPCBoardAPI(BoardAPI):
result = []
for item in selection:
result.append({
"type": type(item).__name__,
"id": str(item.id) if hasattr(item, 'id') else ""
})
result.append(
{"type": type(item).__name__, "id": str(item.id) if hasattr(item, "id") else ""}
)
return result
except Exception as e:
@@ -1241,4 +1224,4 @@ class IPCBoardAPI(BoardAPI):
# 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.
Please migrate to IPC backend.
"""
import logging
from pathlib import Path
from typing import Optional, Dict, Any, List
from kicad_api.base import (
KiCADBackend,
BoardAPI,
ConnectionError,
APINotAvailableError
)
from kicad_api.base import KiCADBackend, BoardAPI, ConnectionError, APINotAvailableError
logger = logging.getLogger(__name__)
@@ -48,6 +44,7 @@ class SWIGBackend(KiCADBackend):
"""
try:
import pcbnew
self._pcbnew = pcbnew
version = pcbnew.GetBuildVersion()
logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
@@ -191,7 +188,7 @@ class SWIGBoardAPI(BoardAPI):
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu"
layer: str = "F.Cu",
) -> bool:
"""Place component using existing implementation"""
from commands.component import ComponentCommands
@@ -202,7 +199,7 @@ class SWIGBoardAPI(BoardAPI):
position={"x": x, "y": y, "unit": "mm"},
reference=reference,
rotation=rotation,
layer=layer
layer=layer,
)
return result.get("success", False)
except Exception as e:

View File

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

View File

@@ -27,6 +27,7 @@ logger = logging.getLogger("kicad_interface")
# Public API
# ---------------------------------------------------------------------------
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
@@ -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)
if not m:
# 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
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])
# TYPE is smd | through_hole (no quotes)
# ------------------------------------------------------------------
m = re.search(r'\(attr\s+([^)]+)\)', content)
m = re.search(r"\(attr\s+([^)]+)\)", content)
if m:
tokens = m.group(1).split()
result["attributes"] = {
@@ -107,7 +108,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
layers: set = set()
for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content):
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)):
layers.add(lyr)
result["layers"] = sorted(layers)
@@ -128,6 +129,7 @@ def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
# Internal helpers
# ---------------------------------------------------------------------------
def _extract_pads(content: str) -> List[Dict[str, Any]]:
"""
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 …)
quoted_pattern = re.compile(
r'\(pad\s+"([^"]*)"\s+'
r'(thru_hole|smd|np_thru_hole|connect)\s+'
r'(rect|circle|oval|roundrect|trapezoid|custom)\b'
r"(thru_hole|smd|np_thru_hole|connect)\s+"
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
)
for m in quoted_pattern.finditer(content):
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:
# Older / unquoted format: (pad NUMBER TYPE SHAPE …)
unquoted_pattern = re.compile(
r'\(pad\s+(\S+)\s+'
r'(thru_hole|smd|np_thru_hole|connect)\s+'
r'(rect|circle|oval|roundrect|trapezoid|custom)\b'
r"\(pad\s+(\S+)\s+"
r"(thru_hole|smd|np_thru_hole|connect)\s+"
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
)
for m in unquoted_pattern.finditer(content):
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.
"""
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):
start = match.start()
@@ -191,9 +193,9 @@ def _extract_blocks(content: str, token: str) -> List[str]:
i = start
while i < len(content):
ch = content[i]
if ch == '(':
if ch == "(":
depth += 1
elif ch == ')':
elif ch == ")":
depth -= 1
if depth == 0:
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"):
if "F.CrtYd" not in block:
continue
s = re.search(r'\(start\s+([-\d.]+)\s+([-\d.]+)\)', block)
e = re.search(r'\(end\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)
if s and e:
xs += [float(s.group(1)), float(e.group(1))]
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"):
if "F.CrtYd" not in block:
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)))
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
__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
import logging
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
# =============================================================================
# RESOURCE DEFINITIONS
@@ -21,56 +21,57 @@ RESOURCE_DEFINITIONS = [
"uri": "kicad://project/current/info",
"name": "Current Project Information",
"description": "Metadata about the currently open KiCAD project including paths, name, and status",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/board",
"name": "Board Properties",
"description": "Comprehensive board information including dimensions, layer count, and design rules",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/components",
"name": "Component List",
"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",
"name": "Electrical Nets",
"description": "List of all electrical nets and their connections",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/layers",
"name": "Layer Stack",
"description": "Board layer configuration and properties",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/design-rules",
"name": "Design Rules",
"description": "Current design rule settings for clearances, track widths, and constraints",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/drc-report",
"name": "DRC Violations",
"description": "Design Rule Check violations and warnings from last DRC run",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://board/preview.png",
"name": "Board Preview Image",
"description": "2D rendering of the current board state",
"mimeType": "image/png"
}
"mimeType": "image/png",
},
]
# =============================================================================
# RESOURCE READ HANDLERS
# =============================================================================
def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
"""
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/design-rules": _get_design_rules,
"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)
@@ -102,45 +103,44 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
except Exception as e:
logger.error(f"Error reading resource {uri}: {str(e)}")
return {
"contents": [{
"uri": uri,
"mimeType": "text/plain",
"text": f"Error: {str(e)}"
}]
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}]
}
else:
return {
"contents": [{
"uri": uri,
"mimeType": "text/plain",
"text": f"Unknown resource: {uri}"
}]
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}]
}
# =============================================================================
# INDIVIDUAL RESOURCE HANDLERS
# =============================================================================
def _get_project_info(interface) -> Dict[str, Any]:
"""Get current project information"""
result = interface.project_commands.get_project_info({})
if result.get("success"):
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/info",
"mimeType": "application/json",
"text": json.dumps(result.get("project", {}), indent=2)
}]
"text": json.dumps(result.get("project", {}), indent=2),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/info",
"mimeType": "text/plain",
"text": "No project currently open"
}]
"text": "No project currently open",
}
]
}
def _get_board_info(interface) -> Dict[str, Any]:
"""Get board properties and metadata"""
@@ -148,20 +148,25 @@ def _get_board_info(interface) -> Dict[str, Any]:
if result.get("success"):
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/board",
"mimeType": "application/json",
"text": json.dumps(result.get("board", {}), indent=2)
}]
"text": json.dumps(result.get("board", {}), indent=2),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/board",
"mimeType": "text/plain",
"text": "No board currently loaded"
}]
"text": "No board currently loaded",
}
]
}
def _get_components(interface) -> Dict[str, Any]:
"""Get list of all components"""
@@ -170,23 +175,27 @@ def _get_components(interface) -> Dict[str, Any]:
if result.get("success"):
components = result.get("components", [])
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/components",
"mimeType": "application/json",
"text": json.dumps({
"count": len(components),
"components": components
}, indent=2)
}]
"text": json.dumps(
{"count": len(components), "components": components}, indent=2
),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/components",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "components": []}, indent=2)
}]
"text": json.dumps({"count": 0, "components": []}, indent=2),
}
]
}
def _get_nets(interface) -> Dict[str, Any]:
"""Get list of electrical nets"""
@@ -195,23 +204,25 @@ def _get_nets(interface) -> Dict[str, Any]:
if result.get("success"):
nets = result.get("nets", [])
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/nets",
"mimeType": "application/json",
"text": json.dumps({
"count": len(nets),
"nets": nets
}, indent=2)
}]
"text": json.dumps({"count": len(nets), "nets": nets}, indent=2),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/nets",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "nets": []}, indent=2)
}]
"text": json.dumps({"count": 0, "nets": []}, indent=2),
}
]
}
def _get_layers(interface) -> Dict[str, Any]:
"""Get layer stack information"""
@@ -220,23 +231,25 @@ def _get_layers(interface) -> Dict[str, Any]:
if result.get("success"):
layers = result.get("layers", [])
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/layers",
"mimeType": "application/json",
"text": json.dumps({
"count": len(layers),
"layers": layers
}, indent=2)
}]
"text": json.dumps({"count": len(layers), "layers": layers}, indent=2),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/layers",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "layers": []}, indent=2)
}]
"text": json.dumps({"count": 0, "layers": []}, indent=2),
}
]
}
def _get_design_rules(interface) -> Dict[str, Any]:
"""Get design rule settings"""
@@ -244,20 +257,25 @@ def _get_design_rules(interface) -> Dict[str, Any]:
if result.get("success"):
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/design-rules",
"mimeType": "application/json",
"text": json.dumps(result.get("rules", {}), indent=2)
}]
"text": json.dumps(result.get("rules", {}), indent=2),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/design-rules",
"mimeType": "text/plain",
"text": "Design rules not available"
}]
"text": "Design rules not available",
}
]
}
def _get_drc_report(interface) -> Dict[str, Any]:
"""Get DRC violations"""
@@ -266,27 +284,34 @@ def _get_drc_report(interface) -> Dict[str, Any]:
if result.get("success"):
violations = result.get("violations", [])
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/drc-report",
"mimeType": "application/json",
"text": json.dumps({
"count": len(violations),
"violations": violations
}, indent=2)
}]
"text": json.dumps(
{"count": len(violations), "violations": violations}, indent=2
),
}
]
}
else:
return {
"contents": [{
"contents": [
{
"uri": "kicad://project/current/drc-report",
"mimeType": "application/json",
"text": json.dumps({
"text": json.dumps(
{
"count": 0,
"violations": [],
"message": "Run DRC first to get violations"
}, indent=2)
}]
"message": "Run DRC first to get violations",
},
indent=2,
),
}
]
}
def _get_board_preview(interface) -> Dict[str, Any]:
"""Get board preview as PNG image"""
@@ -296,18 +321,22 @@ def _get_board_preview(interface) -> Dict[str, Any]:
# Image data should already be base64 encoded
image_data = result.get("imageData", "")
return {
"contents": [{
"contents": [
{
"uri": "kicad://board/preview.png",
"mimeType": "image/png",
"blob": image_data # Base64 encoded PNG
}]
"blob": image_data, # Base64 encoded PNG
}
]
}
else:
# Return a placeholder message
return {
"contents": [{
"contents": [
{
"uri": "kicad://board/preview.png",
"mimeType": "text/plain",
"text": "Board preview not available"
}]
"text": "Board preview not available",
}
]
}

View File

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

View File

@@ -13,6 +13,7 @@ Prerequisites:
Usage:
./venv/bin/python python/test_ipc_backend.py
"""
import sys
import os
@@ -23,8 +24,7 @@ import logging
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
@@ -79,11 +79,11 @@ def test_board_access(backend):
if components:
print("\n First 5 components:")
for comp in components[:5]:
ref = comp.get('reference', 'N/A')
val = comp.get('value', 'N/A')
pos = comp.get('position', {})
x = pos.get('x', 0)
y = pos.get('y', 0)
ref = comp.get("reference", "N/A")
val = comp.get("value", "N/A")
pos = comp.get("position", {})
x = pos.get("x", 0)
y = pos.get("y", 0)
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
return board_api
@@ -145,19 +145,14 @@ def test_realtime_track(board_api, interactive=False):
if interactive:
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
if response != 'y':
if response != "y":
print("Skipped track test")
return False
try:
# Add a track
success = board_api.add_track(
start_x=100.0,
start_y=100.0,
end_x=120.0,
end_y=100.0,
width=0.25,
layer="F.Cu"
start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu"
)
if success:
@@ -184,19 +179,13 @@ def test_realtime_via(board_api, interactive=False):
if interactive:
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
if response != 'y':
if response != "y":
print("Skipped via test")
return False
try:
# Add a via
success = board_api.add_via(
x=120.0,
y=100.0,
diameter=0.8,
drill=0.4,
via_type="through"
)
success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through")
if success:
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
@@ -221,18 +210,12 @@ def test_realtime_text(board_api, interactive=False):
if interactive:
response = input("\nProceed with adding test text? [y/N]: ").strip().lower()
if response != 'y':
if response != "y":
print("Skipped text test")
return False
try:
success = board_api.add_text(
text="MCP Test",
x=100.0,
y=95.0,
layer="F.SilkS",
size=1.0
)
success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0)
if success:
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
@@ -321,9 +304,14 @@ def run_all_tests(interactive=False):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Test KiCAD IPC Backend')
parser.add_argument('-i', '--interactive', action='store_true',
help='Run in interactive mode (prompts before modifications)')
parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend")
parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="Run in interactive mode (prompts before modifications)",
)
args = parser.parse_args()
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
them on *separate* lines, so every real-world delete returned "not found".
"""
import re
import sys
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"
# 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)
(in_bom yes) (on_board yes) (dnp no)
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
@@ -34,11 +35,11 @@ PLACED_RESISTOR_INLINE = '''\
(effects (font (size 1.27 1.27)) hide)
)
)
'''
"""
# Multi-line format as KiCAD's own file writer produces it.
# (symbol and (lib_id are on separate lines, which broke the old regex.
PLACED_RESISTOR_MULTILINE = '''\
PLACED_RESISTOR_MULTILINE = """\
\t(symbol
\t\t(lib_id "Device:R")
\t\t(at 50 50 0)
@@ -64,10 +65,10 @@ PLACED_RESISTOR_MULTILINE = '''\
\t\t\t)
\t\t)
\t)
'''
"""
# Multi-line power symbol the exact scenario that was reported as broken.
PLACED_POWER_SYMBOL_MULTILINE = '''\
PLACED_POWER_SYMBOL_MULTILINE = """\
\t(symbol
\t\t(lib_id "power:VCC")
\t\t(at 365.6 38.1 0)
@@ -94,7 +95,7 @@ PLACED_POWER_SYMBOL_MULTILINE = '''\
\t\t\t)
\t\t)
\t)
'''
"""
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
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeleteDetectionRegex:
"""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
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDeleteSchematicComponentIntegration:
def _get_handler(self):
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_delete_schematic_component

View File

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

View File

@@ -256,18 +256,14 @@ class TestFindOverlappingElements:
def test_overlapping_symbols_detected(self):
# Two resistors at nearly the same position — bboxes fully overlap
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 100.1, 100
)
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100.1, 100)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1
assert len(result["overlappingSymbols"]) >= 1
def test_well_separated_symbols_not_flagged(self):
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 200, 200
)
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 200, 200)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
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]
# These overlap in Y from 101.19 to 103.81
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 100, 105
)
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 100, 105)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] >= 1, (
@@ -310,9 +304,7 @@ class TestFindOverlappingElements:
R pins at y ±3.81, but different X positions far enough apart.
"""
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp(
"R2", 110, 100
)
extra = _make_resistor_sexp("R1", 100, 100) + _make_resistor_sexp("R2", 110, 100)
tmp = _make_temp_schematic(extra)
result = find_overlapping_elements(tmp, tolerance=0.5)
assert result["totalOverlaps"] == 0
@@ -419,9 +411,7 @@ class TestIntegrationFindWiresCrossingSymbols:
tmp = _make_temp_schematic(r_at_100 + r_at_200 + wire)
result = find_wires_crossing_symbols(tmp)
# The wire must not be reported against the far-away R? at (200, 100)
collisions_at_200 = [
c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5
]
collisions_at_200 = [c for c in result if abs(c["component"]["position"]["x"] - 200) < 0.5]
assert len(collisions_at_200) == 0, (
"Wire at x≈100 must not be flagged against the R? at x=200; "
"likely caused by reference-lookup always returning the first 'R?'"
@@ -458,9 +448,7 @@ class TestIntegrationFindWiresCrossingSymbols:
tmp = _make_temp_schematic(extra)
result = find_wires_crossing_symbols(tmp)
d1_crossings = [c for c in result if c["component"]["reference"] == "D1"]
assert (
len(d1_crossings) == 0
), "Wire terminating at pin from outside should not be flagged"
assert len(d1_crossings) == 0, "Wire terminating at pin from outside should not be flagged"
def test_wire_shorts_component_pins_detected_as_collision(self):
"""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)]
bbox = _compute_symbol_bbox_direct(
sym, pin_defs, graphics_points=graphics_points
)
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# X should come from rectangle: 100 ± 1.016
@@ -902,9 +888,7 @@ class TestComputeSymbolBboxWithGraphics:
# Rectangle corners in local coords
graphics_points = [(-1.016, -2.54), (1.016, 2.54)]
bbox = _compute_symbol_bbox_direct(
sym, pin_defs, graphics_points=graphics_points
)
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# After 90° rotation, X and Y swap roles
@@ -935,9 +919,7 @@ class TestIntegrationGraphicsBbox:
assert len(graphics_points) >= 2, "Should have extracted rectangle points"
bbox = _compute_symbol_bbox_direct(
r1, pin_defs, graphics_points=graphics_points
)
bbox = _compute_symbol_bbox_direct(r1, pin_defs, graphics_points=graphics_points)
assert bbox is not None
min_x, min_y, max_x, max_y = bbox
# 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
importing the full kicad_interface module).
"""
import shutil
import tempfile
from pathlib import Path
@@ -43,9 +44,7 @@ _WIRE_SCH = """\
def _write_temp_sch(content: str) -> Path:
"""Write *content* to a temp file and return its Path."""
tmp = tempfile.NamedTemporaryFile(
suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8"
)
tmp = tempfile.NamedTemporaryFile(suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8")
tmp.write(content)
tmp.close()
return Path(tmp.name)
@@ -66,9 +65,7 @@ class TestDeleteWireUnit:
self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path):
result = self.WireManager.delete_wire(
tmp_path / "nope.kicad_sch", [0, 0], [10, 10]
)
result = self.WireManager.delete_wire(tmp_path / "nope.kicad_sch", [0, 0], [10, 10])
assert result is False
def test_no_matching_wire_returns_false(self, tmp_path):
@@ -100,9 +97,7 @@ class TestDeleteLabelUnit:
self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path):
result = self.WireManager.delete_label(
tmp_path / "nope.kicad_sch", "VCC"
)
result = self.WireManager.delete_label(tmp_path / "nope.kicad_sch", "VCC")
assert result is False
def test_missing_label_returns_false(self, tmp_path):
@@ -114,9 +109,7 @@ class TestDeleteLabelUnit:
def test_position_kwarg_accepted(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_label(
sch, "VCC", position=[10.0, 20.0], tolerance=0.5
)
result = self.WireManager.delete_label(sch, "VCC", position=[10.0, 20.0], tolerance=0.5)
assert result is False
@@ -145,9 +138,7 @@ class TestDeleteWireIntegration:
wire_items = [
item
for item in data
if isinstance(item, list)
and item
and item[0] == sexpdata.Symbol("wire")
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
]
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")
# Coordinates differ by 0.3 mm — within default tolerance of 0.5
result = self.WireManager.delete_wire(
sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5
)
result = self.WireManager.delete_wire(sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5)
assert result is True
def test_outside_tolerance_no_delete(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_wire(
sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0
)
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0)
# tolerance=0.0 means exact float equality — may still match on most
# platforms, but the key thing is that a *distant* miss is rejected
sch2 = tmp_path / "test2.kicad_sch"
@@ -200,9 +187,7 @@ class TestDeleteWireIntegration:
labels = [
item
for item in data
if isinstance(item, list)
and item
and item[0] == sexpdata.Symbol("label")
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
]
assert len(labels) == 1
@@ -230,9 +215,7 @@ class TestDeleteLabelIntegration:
labels = [
item
for item in data
if isinstance(item, list)
and item
and item[0] == sexpdata.Symbol("label")
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("label")
]
assert labels == [], "Label should have been removed"
@@ -247,9 +230,7 @@ class TestDeleteLabelIntegration:
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_label(
sch, "VCC", position=[99.0, 99.0], tolerance=0.5
)
result = self.WireManager.delete_label(sch, "VCC", position=[99.0, 99.0], tolerance=0.5)
assert result is False
def test_wire_preserved_after_label_deletion(self, tmp_path):
@@ -260,9 +241,7 @@ class TestDeleteLabelIntegration:
wires = [
item
for item in data
if isinstance(item, list)
and item
and item[0] == sexpdata.Symbol("wire")
if isinstance(item, list) and item and item[0] == sexpdata.Symbol("wire")
]
assert len(wires) == 1
@@ -387,9 +366,7 @@ class TestHandlerParamValidation:
params = {}
schematic_path = params.get("schematicPath")
result = (
{"success": False, "message": "schematicPath is required"}
if not schematic_path
else {}
{"success": False, "message": "schematicPath is required"} if not schematic_path else {}
)
assert result["success"] is False

View File

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

View File

@@ -3,6 +3,7 @@ KiCAD Process Management Utilities
Detects if KiCAD is running and provides auto-launch functionality.
"""
import os
import subprocess
import logging
@@ -28,7 +29,9 @@ class KiCADProcessManager:
try:
ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined]
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_ = [
@@ -58,11 +61,13 @@ class KiCADProcessManager:
if Process32FirstW(snapshot, ctypes.byref(entry)):
while True:
processes.append({
processes.append(
{
"pid": str(entry.th32ProcessID),
"name": entry.szExeFile,
"command": entry.szExeFile
})
"command": entry.szExeFile,
}
)
if not Process32NextW(snapshot, ctypes.byref(entry)):
break
@@ -87,27 +92,21 @@ class KiCADProcessManager:
# Check for actual pcbnew/kicad binaries (not python scripts)
# Use exact process name matching to avoid matching our own kicad_interface.py
result = subprocess.run(
["pgrep", "-x", "pcbnew|kicad"],
capture_output=True,
text=True
["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True
)
if result.returncode == 0:
return True
# Also check with -f for full path matching, but exclude our script
result = subprocess.run(
["pgrep", "-f", "/pcbnew|/kicad"],
capture_output=True,
text=True
["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True
)
# Double-check it's not our own process
if result.returncode == 0:
pids = result.stdout.strip().split('\n')
pids = result.stdout.strip().split("\n")
for pid in pids:
try:
cmdline = subprocess.run(
["ps", "-p", pid, "-o", "command="],
capture_output=True,
text=True
["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
)
if "kicad_interface.py" not in cmdline.stdout:
return True
@@ -117,9 +116,7 @@ class KiCADProcessManager:
elif system == "Darwin": # macOS
result = subprocess.run(
["pgrep", "-f", "KiCad|pcbnew"],
capture_output=True,
text=True
["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
)
return result.returncode == 0
@@ -157,7 +154,7 @@ class KiCADProcessManager:
text=True,
encoding="mbcs" 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:
path = result.stdout.strip().split("\n")[0]
@@ -232,7 +229,7 @@ class KiCADProcessManager:
cmd,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
stderr=subprocess.DEVNULL,
)
else:
# Unix: Use nohup or start in background
@@ -240,7 +237,7 @@ class KiCADProcessManager:
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
start_new_session=True,
)
# Wait for process to start
@@ -275,23 +272,25 @@ class KiCADProcessManager:
try:
if system in ["Linux", "Darwin"]:
result = subprocess.run(
["ps", "aux"],
capture_output=True,
text=True
)
result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
for line in result.stdout.split("\n"):
# 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
if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line:
parts = line.split()
if len(parts) >= 11:
processes.append({
processes.append(
{
"pid": parts[1],
"name": parts[10],
"command": " ".join(parts[10:])
})
"command": " ".join(parts[10:]),
}
)
elif system == "Windows":
for proc in KiCADProcessManager._windows_list_processes():
@@ -304,6 +303,7 @@ class KiCADProcessManager:
return processes
def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict:
"""
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,
"launched": False,
"processes": processes,
"message": "KiCAD is already running"
"message": "KiCAD is already running",
}
if not auto_launch:
@@ -333,7 +333,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
"running": False,
"launched": False,
"processes": [],
"message": "KiCAD is not running (auto-launch disabled)"
"message": "KiCAD is not running (auto-launch disabled)",
}
# Try to launch
@@ -345,5 +345,5 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
"launched": success,
"processes": manager.get_process_info() if success else [],
"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
getting appropriate paths for KiCAD, configuration, logs, etc.
"""
import os
import platform
import sys
@@ -74,19 +75,23 @@ class PlatformHelper:
# Also check based on Python version
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"),
])
]
)
# Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian)
# 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/local/lib/python3/dist-packages"),
Path(f"/usr/local/lib/python{py_version}/dist-packages"),
])
]
)
paths = [p for p in candidates if p.exists()]
@@ -102,7 +107,17 @@ class PlatformHelper:
for kicad_app in kicad_app_paths:
if kicad_app.exists():
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():
paths.append(path)
@@ -161,7 +176,10 @@ class PlatformHelper:
patterns = [
"/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
@@ -301,6 +319,7 @@ def detect_platform() -> dict:
if __name__ == "__main__":
# Quick test/diagnostic
import json
info = detect_platform()
print("Platform Information:")
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.
"""
import pytest
import platform
from pathlib import Path
@@ -176,6 +177,7 @@ class TestKiCADPathDetection:
PlatformHelper.add_kicad_to_python_path()
try:
import pcbnew
# If we get here, pcbnew is available
assert pcbnew is not None
version = pcbnew.GetBuildVersion()