chore: normalize all tracked files to LF line endings

Mechanical application of the `.gitattributes` rules from the prior commit.
All 50 files differ only in line endings — verified by
`git diff --cached --ignore-all-space` being empty.

Before: main had 42 CRLF + 27 LF Python files plus mixed-ending in YAML,
templates, and shell scripts. After: every text file is LF (except the
Windows-native *.ps1, *.bat scripts which remain CRLF per gitattributes).

This eliminates the noisy-diff failure mode seen in PR #102, where a
small logic change produced a 918-line diff due to whole-file CRLF→LF
conversion.
This commit is contained in:
Eugene Mikhantyev
2026-04-18 15:23:00 +01:00
parent 2c35ff40a7
commit bfc25639c2
50 changed files with 18167 additions and 18167 deletions

View File

@@ -1,19 +1,19 @@
"""
KiCAD command implementations package
"""
from .board import BoardCommands
from .component import ComponentCommands
from .design_rules import DesignRuleCommands
from .export import ExportCommands
from .project import ProjectCommands
from .routing import RoutingCommands
__all__ = [
"ProjectCommands",
"BoardCommands",
"ComponentCommands",
"RoutingCommands",
"DesignRuleCommands",
"ExportCommands",
]
"""
KiCAD command implementations package
"""
from .board import BoardCommands
from .component import ComponentCommands
from .design_rules import DesignRuleCommands
from .export import ExportCommands
from .project import ProjectCommands
from .routing import RoutingCommands
__all__ = [
"ProjectCommands",
"BoardCommands",
"ComponentCommands",
"RoutingCommands",
"DesignRuleCommands",
"ExportCommands",
]

View File

@@ -1,11 +1,11 @@
"""
Board-related command implementations for KiCAD interface
This file is maintained for backward compatibility.
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"]
"""
Board-related command implementations for KiCAD interface
This file is maintained for backward compatibility.
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"]

View File

@@ -1,85 +1,85 @@
"""
Board-related command implementations for KiCAD interface
"""
import logging
from typing import Any, Dict, Optional
import pcbnew
from .layers import BoardLayerCommands
from .outline import BoardOutlineCommands
# Import specialized modules
from .size import BoardSizeCommands
from .view import BoardViewCommands
logger = logging.getLogger("kicad_interface")
class BoardCommands:
"""Handles board-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
# Initialize specialized command classes
self.size_commands = BoardSizeCommands(board)
self.layer_commands = BoardLayerCommands(board)
self.outline_commands = BoardOutlineCommands(board)
self.view_commands = BoardViewCommands(board)
# Delegate board size commands
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the size of the PCB board"""
self.size_commands.board = self.board
return self.size_commands.set_board_size(params)
# Delegate layer commands
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a new layer to the PCB"""
self.layer_commands.board = self.board
return self.layer_commands.add_layer(params)
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the active layer for PCB operations"""
self.layer_commands.board = self.board
return self.layer_commands.set_active_layer(params)
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a list of all layers in the PCB"""
self.layer_commands.board = self.board
return self.layer_commands.get_layer_list(params)
# Delegate board outline commands
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a board outline to the PCB"""
self.outline_commands.board = self.board
return self.outline_commands.add_board_outline(params)
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a mounting hole to the PCB"""
self.outline_commands.board = self.board
return self.outline_commands.add_mounting_hole(params)
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add text annotation to the PCB"""
self.outline_commands.board = self.board
return self.outline_commands.add_text(params)
# Delegate view commands
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current board"""
self.view_commands.board = self.board
return self.view_commands.get_board_info(params)
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a 2D image of the PCB"""
self.view_commands.board = self.board
return self.view_commands.get_board_2d_view(params)
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
self.view_commands.board = self.board
return self.view_commands.get_board_extents(params)
"""
Board-related command implementations for KiCAD interface
"""
import logging
from typing import Any, Dict, Optional
import pcbnew
from .layers import BoardLayerCommands
from .outline import BoardOutlineCommands
# Import specialized modules
from .size import BoardSizeCommands
from .view import BoardViewCommands
logger = logging.getLogger("kicad_interface")
class BoardCommands:
"""Handles board-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
# Initialize specialized command classes
self.size_commands = BoardSizeCommands(board)
self.layer_commands = BoardLayerCommands(board)
self.outline_commands = BoardOutlineCommands(board)
self.view_commands = BoardViewCommands(board)
# Delegate board size commands
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the size of the PCB board"""
self.size_commands.board = self.board
return self.size_commands.set_board_size(params)
# Delegate layer commands
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a new layer to the PCB"""
self.layer_commands.board = self.board
return self.layer_commands.add_layer(params)
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the active layer for PCB operations"""
self.layer_commands.board = self.board
return self.layer_commands.set_active_layer(params)
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a list of all layers in the PCB"""
self.layer_commands.board = self.board
return self.layer_commands.get_layer_list(params)
# Delegate board outline commands
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a board outline to the PCB"""
self.outline_commands.board = self.board
return self.outline_commands.add_board_outline(params)
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a mounting hole to the PCB"""
self.outline_commands.board = self.board
return self.outline_commands.add_mounting_hole(params)
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add text annotation to the PCB"""
self.outline_commands.board = self.board
return self.outline_commands.add_text(params)
# Delegate view commands
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current board"""
self.view_commands.board = self.board
return self.view_commands.get_board_info(params)
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a 2D image of the PCB"""
self.view_commands.board = self.board
return self.view_commands.get_board_2d_view(params)
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
self.view_commands.board = self.board
return self.view_commands.get_board_extents(params)

View File

@@ -1,176 +1,176 @@
"""
Board layer command implementations for KiCAD interface
"""
import logging
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
class BoardLayerCommands:
"""Handles board layer operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a new layer to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
name = params.get("name")
layer_type = params.get("type")
position = params.get("position")
number = params.get("number")
if not name or not layer_type or not position:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "name, type, and position are required",
}
# Get layer stack
layer_stack = self.board.GetLayerStack()
# Determine layer ID based on position and number
layer_id = None
if position == "inner":
if number is None:
return {
"success": False,
"message": "Missing layer number",
"errorDetails": "number is required for inner layers",
}
layer_id = pcbnew.In1_Cu + (number - 1)
elif position == "top":
layer_id = pcbnew.F_Cu
elif position == "bottom":
layer_id = pcbnew.B_Cu
if layer_id is None:
return {
"success": False,
"message": "Invalid layer position",
"errorDetails": "position must be 'top', 'bottom', or 'inner'",
}
# Set layer properties
layer_stack.SetLayerName(layer_id, name)
layer_stack.SetLayerType(layer_id, self._get_layer_type(layer_type))
# Enable the layer
self.board.SetLayerEnabled(layer_id, True)
return {
"success": True,
"message": f"Added layer: {name}",
"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)}
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the active layer for PCB operations"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
layer = params.get("layer")
if not layer:
return {
"success": False,
"message": "No layer specified",
"errorDetails": "layer parameter is required",
}
# Find layer ID by name
layer_id = self.board.GetLayerID(layer)
if layer_id < 0:
return {
"success": False,
"message": "Layer not found",
"errorDetails": f"Layer '{layer}' does not exist",
}
# Set active layer
self.board.SetActiveLayer(layer_id)
return {
"success": True,
"message": f"Set active layer to: {layer}",
"layer": {"name": layer, "id": layer_id},
}
except Exception as e:
logger.error(f"Error setting active layer: {str(e)}")
return {
"success": False,
"message": "Failed to set active layer",
"errorDetails": str(e),
}
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a list of all layers in the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"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(
{
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(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}
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)}
def _get_layer_type(self, type_name: str) -> int:
"""Convert layer type name to KiCAD layer type constant"""
type_map = {
"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,
}
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
def _get_layer_type_name(self, type_id: int) -> str:
"""Convert KiCAD layer type constant to name"""
type_map = {
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")
"""
Board layer command implementations for KiCAD interface
"""
import logging
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
class BoardLayerCommands:
"""Handles board layer operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a new layer to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
name = params.get("name")
layer_type = params.get("type")
position = params.get("position")
number = params.get("number")
if not name or not layer_type or not position:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "name, type, and position are required",
}
# Get layer stack
layer_stack = self.board.GetLayerStack()
# Determine layer ID based on position and number
layer_id = None
if position == "inner":
if number is None:
return {
"success": False,
"message": "Missing layer number",
"errorDetails": "number is required for inner layers",
}
layer_id = pcbnew.In1_Cu + (number - 1)
elif position == "top":
layer_id = pcbnew.F_Cu
elif position == "bottom":
layer_id = pcbnew.B_Cu
if layer_id is None:
return {
"success": False,
"message": "Invalid layer position",
"errorDetails": "position must be 'top', 'bottom', or 'inner'",
}
# Set layer properties
layer_stack.SetLayerName(layer_id, name)
layer_stack.SetLayerType(layer_id, self._get_layer_type(layer_type))
# Enable the layer
self.board.SetLayerEnabled(layer_id, True)
return {
"success": True,
"message": f"Added layer: {name}",
"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)}
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the active layer for PCB operations"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
layer = params.get("layer")
if not layer:
return {
"success": False,
"message": "No layer specified",
"errorDetails": "layer parameter is required",
}
# Find layer ID by name
layer_id = self.board.GetLayerID(layer)
if layer_id < 0:
return {
"success": False,
"message": "Layer not found",
"errorDetails": f"Layer '{layer}' does not exist",
}
# Set active layer
self.board.SetActiveLayer(layer_id)
return {
"success": True,
"message": f"Set active layer to: {layer}",
"layer": {"name": layer, "id": layer_id},
}
except Exception as e:
logger.error(f"Error setting active layer: {str(e)}")
return {
"success": False,
"message": "Failed to set active layer",
"errorDetails": str(e),
}
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a list of all layers in the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"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(
{
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(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}
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)}
def _get_layer_type(self, type_name: str) -> int:
"""Convert layer type name to KiCAD layer type constant"""
type_map = {
"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,
}
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
def _get_layer_type_name(self, type_id: int) -> str:
"""Convert KiCAD layer type constant to name"""
type_map = {
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")

View File

@@ -1,484 +1,484 @@
"""
Board outline command implementations for KiCAD interface
"""
import logging
import math
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
class BoardOutlineCommands:
"""Handles board outline operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a board outline to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Claude sends dimensions nested inside a "params" key:
# {"shape": "rectangle", "params": {"x": 0, "y": 0, "width": 38, ...}}
# Unwrap the inner dict if present so we read dimensions from the right level.
inner = params.get("params", params)
shape = params.get("shape", "rectangle")
width = inner.get("width")
height = inner.get("height")
radius = inner.get("radius")
# Accept both "cornerRadius" and "radius" regardless of shape name.
# The AI often sends shape="rectangle" with radius=2.5 — we treat that as rounded_rectangle.
corner_radius = inner.get("cornerRadius", inner.get("radius", 0))
if shape == "rectangle" and corner_radius > 0:
shape = "rounded_rectangle"
points = inner.get("points", [])
unit = inner.get("unit", "mm")
# Position: accept top-left corner (x/y) or center (centerX/centerY).
# Default: top-left at (0,0) so the board occupies positive coordinate space
# and is consistent with component placement coordinates.
x = inner.get("x")
y = inner.get("y")
if x is not None or y is not None:
ox = x if x is not None else 0.0
oy = y if y is not None else 0.0
center_x = ox + (width or 0) / 2.0
center_y = oy + (height or 0) / 2.0
else:
raw_cx = inner.get("centerX")
raw_cy = inner.get("centerY")
if raw_cx is not None or raw_cy is not None:
center_x = raw_cx if raw_cx is not None else 0.0
center_y = raw_cy if raw_cy is not None else 0.0
else:
# No position given → place top-left at (0,0)
center_x = (width or 0) / 2.0
center_y = (height or 0) / 2.0
if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]:
return {
"success": False,
"message": "Invalid shape",
"errorDetails": f"Shape '{shape}' not supported",
}
# Convert to internal units (nanometers)
scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm
# Create drawing for edge cuts
edge_layer = self.board.GetLayerID("Edge.Cuts")
if shape == "rectangle":
if width is None or height is None:
return {
"success": False,
"message": "Missing dimensions",
"errorDetails": "Both width and height are required for rectangle",
}
width_nm = int(width * scale)
height_nm = int(height * scale)
center_x_nm = int(center_x * scale)
center_y_nm = int(center_y * scale)
# Create 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
)
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
)
# Add lines for rectangle
self._add_edge_line(top_left, top_right, edge_layer)
self._add_edge_line(top_right, bottom_right, edge_layer)
self._add_edge_line(bottom_right, bottom_left, edge_layer)
self._add_edge_line(bottom_left, top_left, edge_layer)
elif shape == "rounded_rectangle":
if width is None or height is None:
return {
"success": False,
"message": "Missing dimensions",
"errorDetails": "Both width and height are required for rounded rectangle",
}
width_nm = int(width * scale)
height_nm = int(height * scale)
center_x_nm = int(center_x * scale)
center_y_nm = int(center_y * scale)
corner_radius_nm = int(corner_radius * scale)
# Create rounded rectangle
self._add_rounded_rect(
center_x_nm,
center_y_nm,
width_nm,
height_nm,
corner_radius_nm,
edge_layer,
)
elif shape == "circle":
if radius is None:
return {
"success": False,
"message": "Missing radius",
"errorDetails": "Radius is required for circle",
}
center_x_nm = int(center_x * scale)
center_y_nm = int(center_y * scale)
radius_nm = int(radius * scale)
# Create circle
circle = pcbnew.PCB_SHAPE(self.board)
circle.SetShape(pcbnew.SHAPE_T_CIRCLE)
circle.SetCenter(pcbnew.VECTOR2I(center_x_nm, center_y_nm))
circle.SetEnd(pcbnew.VECTOR2I(center_x_nm + radius_nm, center_y_nm))
circle.SetLayer(edge_layer)
circle.SetWidth(0) # Zero width for edge cuts
self.board.Add(circle)
elif shape == "polygon":
if not points or len(points) < 3:
return {
"success": False,
"message": "Missing points",
"errorDetails": "At least 3 points are required for polygon",
}
# Convert points to nm
polygon_points = []
for point in points:
x_nm = int(point["x"] * scale)
y_nm = int(point["y"] * scale)
polygon_points.append(pcbnew.VECTOR2I(x_nm, y_nm))
# Add lines for polygon
for i in range(len(polygon_points)):
self._add_edge_line(
polygon_points[i],
polygon_points[(i + 1) % len(polygon_points)],
edge_layer,
)
return {
"success": True,
"message": f"Added board outline: {shape}",
"outline": {
"shape": shape,
"width": width,
"height": height,
"center": {"x": center_x, "y": center_y, "unit": unit},
"radius": radius,
"cornerRadius": corner_radius,
"points": points,
},
}
except Exception as e:
logger.error(f"Error adding board outline: {str(e)}")
return {
"success": False,
"message": "Failed to add board outline",
"errorDetails": str(e),
}
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a mounting hole to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
position = params.get("position")
diameter = params.get("diameter")
pad_diameter = params.get("padDiameter")
plated = params.get("plated", False)
if not position or not diameter:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "position and diameter are required",
}
# Convert to internal units (nanometers)
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)
pad_diameter_nm = (
int(pad_diameter * scale) if pad_diameter else diameter_nm + scale
) # 1mm larger by default
# Create footprint for mounting hole with unique reference
existing_mh = [
fp.GetReference()
for fp in self.board.GetFootprints()
if fp.GetReference().startswith("MH")
]
next_num = 1
while f"MH{next_num}" in existing_mh:
next_num += 1
module = pcbnew.FOOTPRINT(self.board)
module.SetReference(f"MH{next_num}")
module.SetValue(f"MountingHole_{diameter}mm")
# Create the pad for the hole
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.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
module.Add(pad)
# Position the mounting hole
module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
# Add to board
self.board.Add(module)
return {
"success": True,
"message": "Added mounting hole",
"mountingHole": {
"position": position,
"diameter": diameter,
"padDiameter": pad_diameter or diameter + 1,
"plated": plated,
},
}
except Exception as e:
logger.error(f"Error adding mounting hole: {str(e)}")
return {
"success": False,
"message": "Failed to add mounting hole",
"errorDetails": str(e),
}
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add text annotation to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
text = params.get("text")
position = params.get("position")
layer = params.get("layer", "F.SilkS")
size = params.get("size", 1.0)
thickness = params.get("thickness", 0.15)
rotation = params.get("rotation", 0)
mirror = params.get("mirror", False)
if not text or not position:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "text and position are required",
}
# Convert to internal units (nanometers)
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)
thickness_nm = int(thickness * scale)
# Get layer ID
layer_id = self.board.GetLayerID(layer)
if layer_id < 0:
return {
"success": False,
"message": "Invalid layer",
"errorDetails": f"Layer '{layer}' does not exist",
}
# Create text
pcb_text = pcbnew.PCB_TEXT(self.board)
pcb_text.SetText(text)
pcb_text.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
pcb_text.SetLayer(layer_id)
pcb_text.SetTextSize(pcbnew.VECTOR2I(size_nm, size_nm))
pcb_text.SetTextThickness(thickness_nm)
# Set rotation angle - KiCAD 9.0 uses EDA_ANGLE
try:
# Try KiCAD 9.0+ API (EDA_ANGLE)
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
pcb_text.SetTextAngle(angle)
except (AttributeError, TypeError):
# Fall back to older API (decidegrees as integer)
pcb_text.SetTextAngle(int(rotation * 10))
pcb_text.SetMirrored(mirror)
# Add to board
self.board.Add(pcb_text)
return {
"success": True,
"message": "Added text annotation",
"text": {
"text": text,
"position": position,
"layer": layer,
"size": size,
"thickness": thickness,
"rotation": rotation,
"mirror": mirror,
},
}
except Exception as e:
logger.error(f"Error adding text: {str(e)}")
return {
"success": False,
"message": "Failed to add text",
"errorDetails": str(e),
}
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)
line.SetStart(start)
line.SetEnd(end)
line.SetLayer(layer)
line.SetWidth(0) # Zero width for edge cuts
self.board.Add(line)
def _add_rounded_rect(
self,
center_x_nm: int,
center_y_nm: int,
width_nm: int,
height_nm: int,
radius_nm: int,
layer: int,
) -> None:
"""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)
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)
self._add_edge_line(top_left, top_right, layer)
self._add_edge_line(top_right, bottom_right, layer)
self._add_edge_line(bottom_right, bottom_left, layer)
self._add_edge_line(bottom_left, top_left, layer)
return
# Calculate corner centers
half_width = width_nm // 2
half_height = height_nm // 2
# Ensure radius is not larger than half the smallest dimension
max_radius = min(half_width, half_height)
if radius_nm > max_radius:
radius_nm = max_radius
# Calculate corner centers
top_left_center = pcbnew.VECTOR2I(
center_x_nm - half_width + radius_nm, center_y_nm - half_height + radius_nm
)
top_right_center = pcbnew.VECTOR2I(
center_x_nm + half_width - radius_nm, center_y_nm - half_height + radius_nm
)
bottom_right_center = pcbnew.VECTOR2I(
center_x_nm + half_width - radius_nm, center_y_nm + half_height - radius_nm
)
bottom_left_center = pcbnew.VECTOR2I(
center_x_nm - half_width + radius_nm, center_y_nm + half_height - radius_nm
)
# Add arcs for corners
self._add_corner_arc(top_left_center, radius_nm, 180, 270, layer)
self._add_corner_arc(top_right_center, radius_nm, 270, 0, layer)
self._add_corner_arc(bottom_right_center, radius_nm, 0, 90, layer)
self._add_corner_arc(bottom_left_center, radius_nm, 90, 180, layer)
# Add lines for straight edges
# Top edge
self._add_edge_line(
pcbnew.VECTOR2I(top_left_center.x, top_left_center.y - radius_nm),
pcbnew.VECTOR2I(top_right_center.x, top_right_center.y - radius_nm),
layer,
)
# Right edge
self._add_edge_line(
pcbnew.VECTOR2I(top_right_center.x + radius_nm, top_right_center.y),
pcbnew.VECTOR2I(bottom_right_center.x + radius_nm, bottom_right_center.y),
layer,
)
# Bottom edge
self._add_edge_line(
pcbnew.VECTOR2I(bottom_right_center.x, bottom_right_center.y + radius_nm),
pcbnew.VECTOR2I(bottom_left_center.x, bottom_left_center.y + radius_nm),
layer,
)
# Left edge
self._add_edge_line(
pcbnew.VECTOR2I(bottom_left_center.x - radius_nm, bottom_left_center.y),
pcbnew.VECTOR2I(top_left_center.x - radius_nm, top_left_center.y),
layer,
)
def _add_corner_arc(
self,
center: pcbnew.VECTOR2I,
radius: int,
start_angle: float,
end_angle: float,
layer: int,
) -> None:
"""Add an arc for a rounded corner"""
# Create arc for corner
arc = pcbnew.PCB_SHAPE(self.board)
arc.SetShape(pcbnew.SHAPE_T_ARC)
arc.SetCenter(center)
# Calculate start and end points
start_x = center.x + int(radius * math.cos(math.radians(start_angle)))
start_y = center.y + int(radius * math.sin(math.radians(start_angle)))
end_x = center.x + int(radius * math.cos(math.radians(end_angle)))
end_y = center.y + int(radius * math.sin(math.radians(end_angle)))
arc.SetStart(pcbnew.VECTOR2I(start_x, start_y))
arc.SetEnd(pcbnew.VECTOR2I(end_x, end_y))
arc.SetLayer(layer)
arc.SetWidth(0) # Zero width for edge cuts
self.board.Add(arc)
"""
Board outline command implementations for KiCAD interface
"""
import logging
import math
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
class BoardOutlineCommands:
"""Handles board outline operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a board outline to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Claude sends dimensions nested inside a "params" key:
# {"shape": "rectangle", "params": {"x": 0, "y": 0, "width": 38, ...}}
# Unwrap the inner dict if present so we read dimensions from the right level.
inner = params.get("params", params)
shape = params.get("shape", "rectangle")
width = inner.get("width")
height = inner.get("height")
radius = inner.get("radius")
# Accept both "cornerRadius" and "radius" regardless of shape name.
# The AI often sends shape="rectangle" with radius=2.5 — we treat that as rounded_rectangle.
corner_radius = inner.get("cornerRadius", inner.get("radius", 0))
if shape == "rectangle" and corner_radius > 0:
shape = "rounded_rectangle"
points = inner.get("points", [])
unit = inner.get("unit", "mm")
# Position: accept top-left corner (x/y) or center (centerX/centerY).
# Default: top-left at (0,0) so the board occupies positive coordinate space
# and is consistent with component placement coordinates.
x = inner.get("x")
y = inner.get("y")
if x is not None or y is not None:
ox = x if x is not None else 0.0
oy = y if y is not None else 0.0
center_x = ox + (width or 0) / 2.0
center_y = oy + (height or 0) / 2.0
else:
raw_cx = inner.get("centerX")
raw_cy = inner.get("centerY")
if raw_cx is not None or raw_cy is not None:
center_x = raw_cx if raw_cx is not None else 0.0
center_y = raw_cy if raw_cy is not None else 0.0
else:
# No position given → place top-left at (0,0)
center_x = (width or 0) / 2.0
center_y = (height or 0) / 2.0
if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]:
return {
"success": False,
"message": "Invalid shape",
"errorDetails": f"Shape '{shape}' not supported",
}
# Convert to internal units (nanometers)
scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm
# Create drawing for edge cuts
edge_layer = self.board.GetLayerID("Edge.Cuts")
if shape == "rectangle":
if width is None or height is None:
return {
"success": False,
"message": "Missing dimensions",
"errorDetails": "Both width and height are required for rectangle",
}
width_nm = int(width * scale)
height_nm = int(height * scale)
center_x_nm = int(center_x * scale)
center_y_nm = int(center_y * scale)
# Create 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
)
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
)
# Add lines for rectangle
self._add_edge_line(top_left, top_right, edge_layer)
self._add_edge_line(top_right, bottom_right, edge_layer)
self._add_edge_line(bottom_right, bottom_left, edge_layer)
self._add_edge_line(bottom_left, top_left, edge_layer)
elif shape == "rounded_rectangle":
if width is None or height is None:
return {
"success": False,
"message": "Missing dimensions",
"errorDetails": "Both width and height are required for rounded rectangle",
}
width_nm = int(width * scale)
height_nm = int(height * scale)
center_x_nm = int(center_x * scale)
center_y_nm = int(center_y * scale)
corner_radius_nm = int(corner_radius * scale)
# Create rounded rectangle
self._add_rounded_rect(
center_x_nm,
center_y_nm,
width_nm,
height_nm,
corner_radius_nm,
edge_layer,
)
elif shape == "circle":
if radius is None:
return {
"success": False,
"message": "Missing radius",
"errorDetails": "Radius is required for circle",
}
center_x_nm = int(center_x * scale)
center_y_nm = int(center_y * scale)
radius_nm = int(radius * scale)
# Create circle
circle = pcbnew.PCB_SHAPE(self.board)
circle.SetShape(pcbnew.SHAPE_T_CIRCLE)
circle.SetCenter(pcbnew.VECTOR2I(center_x_nm, center_y_nm))
circle.SetEnd(pcbnew.VECTOR2I(center_x_nm + radius_nm, center_y_nm))
circle.SetLayer(edge_layer)
circle.SetWidth(0) # Zero width for edge cuts
self.board.Add(circle)
elif shape == "polygon":
if not points or len(points) < 3:
return {
"success": False,
"message": "Missing points",
"errorDetails": "At least 3 points are required for polygon",
}
# Convert points to nm
polygon_points = []
for point in points:
x_nm = int(point["x"] * scale)
y_nm = int(point["y"] * scale)
polygon_points.append(pcbnew.VECTOR2I(x_nm, y_nm))
# Add lines for polygon
for i in range(len(polygon_points)):
self._add_edge_line(
polygon_points[i],
polygon_points[(i + 1) % len(polygon_points)],
edge_layer,
)
return {
"success": True,
"message": f"Added board outline: {shape}",
"outline": {
"shape": shape,
"width": width,
"height": height,
"center": {"x": center_x, "y": center_y, "unit": unit},
"radius": radius,
"cornerRadius": corner_radius,
"points": points,
},
}
except Exception as e:
logger.error(f"Error adding board outline: {str(e)}")
return {
"success": False,
"message": "Failed to add board outline",
"errorDetails": str(e),
}
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add a mounting hole to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
position = params.get("position")
diameter = params.get("diameter")
pad_diameter = params.get("padDiameter")
plated = params.get("plated", False)
if not position or not diameter:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "position and diameter are required",
}
# Convert to internal units (nanometers)
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)
pad_diameter_nm = (
int(pad_diameter * scale) if pad_diameter else diameter_nm + scale
) # 1mm larger by default
# Create footprint for mounting hole with unique reference
existing_mh = [
fp.GetReference()
for fp in self.board.GetFootprints()
if fp.GetReference().startswith("MH")
]
next_num = 1
while f"MH{next_num}" in existing_mh:
next_num += 1
module = pcbnew.FOOTPRINT(self.board)
module.SetReference(f"MH{next_num}")
module.SetValue(f"MountingHole_{diameter}mm")
# Create the pad for the hole
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.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
module.Add(pad)
# Position the mounting hole
module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
# Add to board
self.board.Add(module)
return {
"success": True,
"message": "Added mounting hole",
"mountingHole": {
"position": position,
"diameter": diameter,
"padDiameter": pad_diameter or diameter + 1,
"plated": plated,
},
}
except Exception as e:
logger.error(f"Error adding mounting hole: {str(e)}")
return {
"success": False,
"message": "Failed to add mounting hole",
"errorDetails": str(e),
}
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add text annotation to the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
text = params.get("text")
position = params.get("position")
layer = params.get("layer", "F.SilkS")
size = params.get("size", 1.0)
thickness = params.get("thickness", 0.15)
rotation = params.get("rotation", 0)
mirror = params.get("mirror", False)
if not text or not position:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "text and position are required",
}
# Convert to internal units (nanometers)
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)
thickness_nm = int(thickness * scale)
# Get layer ID
layer_id = self.board.GetLayerID(layer)
if layer_id < 0:
return {
"success": False,
"message": "Invalid layer",
"errorDetails": f"Layer '{layer}' does not exist",
}
# Create text
pcb_text = pcbnew.PCB_TEXT(self.board)
pcb_text.SetText(text)
pcb_text.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
pcb_text.SetLayer(layer_id)
pcb_text.SetTextSize(pcbnew.VECTOR2I(size_nm, size_nm))
pcb_text.SetTextThickness(thickness_nm)
# Set rotation angle - KiCAD 9.0 uses EDA_ANGLE
try:
# Try KiCAD 9.0+ API (EDA_ANGLE)
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
pcb_text.SetTextAngle(angle)
except (AttributeError, TypeError):
# Fall back to older API (decidegrees as integer)
pcb_text.SetTextAngle(int(rotation * 10))
pcb_text.SetMirrored(mirror)
# Add to board
self.board.Add(pcb_text)
return {
"success": True,
"message": "Added text annotation",
"text": {
"text": text,
"position": position,
"layer": layer,
"size": size,
"thickness": thickness,
"rotation": rotation,
"mirror": mirror,
},
}
except Exception as e:
logger.error(f"Error adding text: {str(e)}")
return {
"success": False,
"message": "Failed to add text",
"errorDetails": str(e),
}
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)
line.SetStart(start)
line.SetEnd(end)
line.SetLayer(layer)
line.SetWidth(0) # Zero width for edge cuts
self.board.Add(line)
def _add_rounded_rect(
self,
center_x_nm: int,
center_y_nm: int,
width_nm: int,
height_nm: int,
radius_nm: int,
layer: int,
) -> None:
"""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)
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)
self._add_edge_line(top_left, top_right, layer)
self._add_edge_line(top_right, bottom_right, layer)
self._add_edge_line(bottom_right, bottom_left, layer)
self._add_edge_line(bottom_left, top_left, layer)
return
# Calculate corner centers
half_width = width_nm // 2
half_height = height_nm // 2
# Ensure radius is not larger than half the smallest dimension
max_radius = min(half_width, half_height)
if radius_nm > max_radius:
radius_nm = max_radius
# Calculate corner centers
top_left_center = pcbnew.VECTOR2I(
center_x_nm - half_width + radius_nm, center_y_nm - half_height + radius_nm
)
top_right_center = pcbnew.VECTOR2I(
center_x_nm + half_width - radius_nm, center_y_nm - half_height + radius_nm
)
bottom_right_center = pcbnew.VECTOR2I(
center_x_nm + half_width - radius_nm, center_y_nm + half_height - radius_nm
)
bottom_left_center = pcbnew.VECTOR2I(
center_x_nm - half_width + radius_nm, center_y_nm + half_height - radius_nm
)
# Add arcs for corners
self._add_corner_arc(top_left_center, radius_nm, 180, 270, layer)
self._add_corner_arc(top_right_center, radius_nm, 270, 0, layer)
self._add_corner_arc(bottom_right_center, radius_nm, 0, 90, layer)
self._add_corner_arc(bottom_left_center, radius_nm, 90, 180, layer)
# Add lines for straight edges
# Top edge
self._add_edge_line(
pcbnew.VECTOR2I(top_left_center.x, top_left_center.y - radius_nm),
pcbnew.VECTOR2I(top_right_center.x, top_right_center.y - radius_nm),
layer,
)
# Right edge
self._add_edge_line(
pcbnew.VECTOR2I(top_right_center.x + radius_nm, top_right_center.y),
pcbnew.VECTOR2I(bottom_right_center.x + radius_nm, bottom_right_center.y),
layer,
)
# Bottom edge
self._add_edge_line(
pcbnew.VECTOR2I(bottom_right_center.x, bottom_right_center.y + radius_nm),
pcbnew.VECTOR2I(bottom_left_center.x, bottom_left_center.y + radius_nm),
layer,
)
# Left edge
self._add_edge_line(
pcbnew.VECTOR2I(bottom_left_center.x - radius_nm, bottom_left_center.y),
pcbnew.VECTOR2I(top_left_center.x - radius_nm, top_left_center.y),
layer,
)
def _add_corner_arc(
self,
center: pcbnew.VECTOR2I,
radius: int,
start_angle: float,
end_angle: float,
layer: int,
) -> None:
"""Add an arc for a rounded corner"""
# Create arc for corner
arc = pcbnew.PCB_SHAPE(self.board)
arc.SetShape(pcbnew.SHAPE_T_ARC)
arc.SetCenter(center)
# Calculate start and end points
start_x = center.x + int(radius * math.cos(math.radians(start_angle)))
start_y = center.y + int(radius * math.sin(math.radians(start_angle)))
end_x = center.x + int(radius * math.cos(math.radians(end_angle)))
end_y = center.y + int(radius * math.sin(math.radians(end_angle)))
arc.SetStart(pcbnew.VECTOR2I(start_x, start_y))
arc.SetEnd(pcbnew.VECTOR2I(end_x, end_y))
arc.SetLayer(layer)
arc.SetWidth(0) # Zero width for edge cuts
self.board.Add(arc)

View File

@@ -1,70 +1,70 @@
"""
Board size command implementations for KiCAD interface
"""
import logging
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
class BoardSizeCommands:
"""Handles board size operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the size of the PCB board by creating edge cuts outline"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
width = params.get("width")
height = params.get("height")
unit = params.get("unit", "mm")
if width is None or height is None:
return {
"success": False,
"message": "Missing dimensions",
"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(
{
"shape": "rectangle",
"centerX": width / 2, # Center X
"centerY": height / 2, # Center Y
"width": width,
"height": height,
"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},
}
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)}
"""
Board size command implementations for KiCAD interface
"""
import logging
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
class BoardSizeCommands:
"""Handles board size operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the size of the PCB board by creating edge cuts outline"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
width = params.get("width")
height = params.get("height")
unit = params.get("unit", "mm")
if width is None or height is None:
return {
"success": False,
"message": "Missing dimensions",
"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(
{
"shape": "rectangle",
"centerX": width / 2, # Center X
"centerY": height / 2, # Center Y
"width": width,
"height": height,
"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},
}
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)}

View File

@@ -1,226 +1,226 @@
"""
Board view command implementations for KiCAD interface
"""
import base64
import io
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
from PIL import Image
logger = logging.getLogger("kicad_interface")
class BoardViewCommands:
"""Handles board viewing operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get board dimensions
board_box = self.board.GetBoardEdgesBoundingBox()
width_nm = board_box.GetWidth()
height_nm = board_box.GetHeight()
# Convert to mm
width_mm = width_nm / 1000000
height_mm = height_nm / 1000000
# Get layer information
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
layers.append(
{
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id,
}
)
return {
"success": True,
"board": {
"filename": self.board.GetFileName(),
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
"layers": layers,
"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:
logger.error(f"Error getting board info: {str(e)}")
return {
"success": False,
"message": "Failed to get board information",
"errorDetails": str(e),
}
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a 2D image of the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get parameters
width = params.get("width", 800)
height = params.get("height", 600)
format = params.get("format", "png")
layers = params.get("layers", [])
# Create plot controller
plotter = pcbnew.PLOT_CONTROLLER(self.board)
# Set up plot options
plot_opts = plotter.GetPlotOptions()
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
plot_opts.SetScale(1)
plot_opts.SetMirror(False)
# Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers
plot_opts.SetPlotFrameRef(False)
plot_opts.SetPlotValue(True)
plot_opts.SetPlotReference(True)
# Plot to SVG first (for vector output)
# Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
# Plot specified layers or all enabled layers
# Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer()
if layers:
for layer_name in layers:
layer_id = self.board.GetLayerID(layer_name)
if layer_id >= 0 and self.board.IsLayerEnabled(layer_id):
plotter.SetLayer(layer_id)
plotter.PlotLayer()
else:
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
plotter.SetLayer(layer_id)
plotter.PlotLayer()
# Get the actual filename that was created (includes project name prefix)
temp_svg = plotter.GetPlotFileName()
plotter.ClosePlot()
# Convert SVG to requested format
if format == "svg":
with open(temp_svg, "r") as f:
svg_data = f.read()
os.remove(temp_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)
if format == "jpg":
# Convert PNG to JPG
img = Image.open(io.BytesIO(png_data))
jpg_buffer = io.BytesIO()
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",
}
else:
return {
"success": True,
"imageData": base64.b64encode(png_data).decode("utf-8"),
"format": "png",
}
except Exception as e:
logger.error(f"Error getting board 2D view: {str(e)}")
return {
"success": False,
"message": "Failed to get board 2D view",
"errorDetails": str(e),
}
def _get_layer_type_name(self, type_id: int) -> str:
"""Convert KiCAD layer type constant to name"""
type_map = {
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get unit preference (default to mm)
unit = params.get("unit", "mm")
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
# Get board bounding box
board_box = self.board.GetBoardEdgesBoundingBox()
# Extract bounds in nanometers, then convert
left = board_box.GetLeft() / scale
top = board_box.GetTop() / scale
right = board_box.GetRight() / scale
bottom = board_box.GetBottom() / scale
width = board_box.GetWidth() / scale
height = board_box.GetHeight() / scale
# Get center point
center_x = board_box.GetCenter().x / scale
center_y = board_box.GetCenter().y / scale
return {
"success": True,
"extents": {
"left": left,
"top": top,
"right": right,
"bottom": bottom,
"width": width,
"height": height,
"center": {"x": center_x, "y": center_y},
"unit": unit,
},
}
except Exception as e:
logger.error(f"Error getting board extents: {str(e)}")
return {
"success": False,
"message": "Failed to get board extents",
"errorDetails": str(e),
}
"""
Board view command implementations for KiCAD interface
"""
import base64
import io
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
from PIL import Image
logger = logging.getLogger("kicad_interface")
class BoardViewCommands:
"""Handles board viewing operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get board dimensions
board_box = self.board.GetBoardEdgesBoundingBox()
width_nm = board_box.GetWidth()
height_nm = board_box.GetHeight()
# Convert to mm
width_mm = width_nm / 1000000
height_mm = height_nm / 1000000
# Get layer information
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
layers.append(
{
"name": self.board.GetLayerName(layer_id),
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
"id": layer_id,
}
)
return {
"success": True,
"board": {
"filename": self.board.GetFileName(),
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
"layers": layers,
"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:
logger.error(f"Error getting board info: {str(e)}")
return {
"success": False,
"message": "Failed to get board information",
"errorDetails": str(e),
}
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get a 2D image of the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get parameters
width = params.get("width", 800)
height = params.get("height", 600)
format = params.get("format", "png")
layers = params.get("layers", [])
# Create plot controller
plotter = pcbnew.PLOT_CONTROLLER(self.board)
# Set up plot options
plot_opts = plotter.GetPlotOptions()
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
plot_opts.SetScale(1)
plot_opts.SetMirror(False)
# Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers
plot_opts.SetPlotFrameRef(False)
plot_opts.SetPlotValue(True)
plot_opts.SetPlotReference(True)
# Plot to SVG first (for vector output)
# Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
# Plot specified layers or all enabled layers
# Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer()
if layers:
for layer_name in layers:
layer_id = self.board.GetLayerID(layer_name)
if layer_id >= 0 and self.board.IsLayerEnabled(layer_id):
plotter.SetLayer(layer_id)
plotter.PlotLayer()
else:
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
plotter.SetLayer(layer_id)
plotter.PlotLayer()
# Get the actual filename that was created (includes project name prefix)
temp_svg = plotter.GetPlotFileName()
plotter.ClosePlot()
# Convert SVG to requested format
if format == "svg":
with open(temp_svg, "r") as f:
svg_data = f.read()
os.remove(temp_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)
if format == "jpg":
# Convert PNG to JPG
img = Image.open(io.BytesIO(png_data))
jpg_buffer = io.BytesIO()
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",
}
else:
return {
"success": True,
"imageData": base64.b64encode(png_data).decode("utf-8"),
"format": "png",
}
except Exception as e:
logger.error(f"Error getting board 2D view: {str(e)}")
return {
"success": False,
"message": "Failed to get board 2D view",
"errorDetails": str(e),
}
def _get_layer_type_name(self, type_id: int) -> str:
"""Convert KiCAD layer type constant to name"""
type_map = {
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get the bounding box extents of the board"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
# Get unit preference (default to mm)
unit = params.get("unit", "mm")
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
# Get board bounding box
board_box = self.board.GetBoardEdgesBoundingBox()
# Extract bounds in nanometers, then convert
left = board_box.GetLeft() / scale
top = board_box.GetTop() / scale
right = board_box.GetRight() / scale
bottom = board_box.GetBottom() / scale
width = board_box.GetWidth() / scale
height = board_box.GetHeight() / scale
# Get center point
center_x = board_box.GetCenter().x / scale
center_y = board_box.GetCenter().y / scale
return {
"success": True,
"extents": {
"left": left,
"top": top,
"right": right,
"bottom": bottom,
"width": width,
"height": height,
"center": {"x": center_x, "y": center_y},
"unit": unit,
},
}
except Exception as e:
logger.error(f"Error getting board extents: {str(e)}")
return {
"success": False,
"message": "Failed to get board extents",
"errorDetails": str(e),
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,410 +1,410 @@
import logging
import os
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from skip import Schematic
logger = logging.getLogger(__name__)
# Import dynamic symbol loader
try:
from commands.dynamic_symbol_loader import DynamicSymbolLoader
DYNAMIC_LOADING_AVAILABLE = True
except ImportError:
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
DYNAMIC_LOADING_AVAILABLE = False
class ComponentManager:
"""Manage components in a schematic"""
# Initialize dynamic loader (class variable, shared across instances)
_dynamic_loader = None
@classmethod
def get_dynamic_loader(cls) -> Any:
"""Get or create dynamic symbol loader instance"""
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
cls._dynamic_loader = DynamicSymbolLoader()
return cls._dynamic_loader
# Template symbol references mapping component type to template reference
TEMPLATE_MAP = {
# Passives
"R": "_TEMPLATE_R",
"C": "_TEMPLATE_C",
"L": "_TEMPLATE_L",
"Y": "_TEMPLATE_Y",
"Crystal": "_TEMPLATE_Y",
# Semiconductors
"D": "_TEMPLATE_D",
"LED": "_TEMPLATE_LED",
"Q": "_TEMPLATE_Q_NPN",
"Q_NPN": "_TEMPLATE_Q_NPN",
"Q_NMOS": "_TEMPLATE_Q_NMOS",
"MOSFET": "_TEMPLATE_Q_NMOS",
# ICs
"U": "_TEMPLATE_U_OPAMP",
"OpAmp": "_TEMPLATE_U_OPAMP",
"IC": "_TEMPLATE_U_OPAMP",
"U_REG": "_TEMPLATE_U_REG",
"Regulator": "_TEMPLATE_U_REG",
# Connectors
"J": "_TEMPLATE_J2",
"J2": "_TEMPLATE_J2",
"J4": "_TEMPLATE_J4",
"Conn_2": "_TEMPLATE_J2",
"Conn_4": "_TEMPLATE_J4",
# Misc
"SW": "_TEMPLATE_SW",
"Button": "_TEMPLATE_SW",
"Switch": "_TEMPLATE_SW",
}
@classmethod
def get_or_create_template(
cls,
schematic: Schematic,
comp_type: str,
library: Optional[str] = None,
schematic_path: Optional[Path] = None,
) -> tuple:
"""
Get template reference for a component type, creating it dynamically if needed
Args:
schematic: Schematic object
comp_type: Component type (e.g., 'R', 'LED', 'STM32F103C8Tx')
library: Optional library name (defaults to 'Device' for common types)
schematic_path: Optional path to schematic file (required for dynamic loading)
Returns:
Tuple of (template_ref, needs_reload) where needs_reload indicates if schematic must be reloaded
"""
# Helper function to check if template exists in schematic
def template_exists(schematic: Any, template_ref: str) -> bool:
"""Check if template exists by iterating symbols (handles special characters)"""
for symbol in schematic.symbol:
if (
hasattr(symbol.property, "Reference")
and symbol.property.Reference.value == template_ref
):
return True
return False
# 1. Check static template map first
if comp_type in cls.TEMPLATE_MAP:
template_ref = cls.TEMPLATE_MAP[comp_type]
# Verify template exists in schematic
if template_exists(schematic, template_ref):
logger.debug(f"Using static template: {template_ref}")
return (template_ref, False)
# 2. Check if dynamically loaded template already exists
# Build potential template reference names
potential_refs = []
if library:
potential_refs.append(f"_TEMPLATE_{library}_{comp_type}")
potential_refs.append(f"_TEMPLATE_{comp_type}")
if comp_type in cls.TEMPLATE_MAP:
potential_refs.append(cls.TEMPLATE_MAP[comp_type])
# Check each potential reference
for template_ref in potential_refs:
if template_exists(schematic, template_ref):
logger.debug(f"Found existing template: {template_ref}")
return (template_ref, False)
# 3. Try dynamic loading
if not DYNAMIC_LOADING_AVAILABLE:
logger.warning(
f"Component type '{comp_type}' not in static templates and dynamic loading unavailable"
)
# Fall back to basic resistor template
return ("_TEMPLATE_R", False)
loader = cls.get_dynamic_loader()
if not loader:
logger.warning("Dynamic loader unavailable, using fallback template")
return ("_TEMPLATE_R", False)
# Check if schematic path is available
if schematic_path is None:
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
try:
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)
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
# Signal that schematic needs reload to see new template
return (template_ref, True)
except Exception as e:
logger.error(f"Dynamic loading failed: {e}")
import traceback
logger.error(traceback.format_exc())
# Fall back to static template if available
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
return (fallback, False)
@staticmethod
def add_component(
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
) -> Any:
"""
Add a component to the schematic by cloning from template
Args:
schematic: Schematic object to add component to
component_def: Component definition dictionary
schematic_path: Optional path to schematic file (enables dynamic symbol loading)
Returns:
Tuple of (new_symbol, needs_reload) where needs_reload indicates if caller should reload schematic
"""
try:
from commands.schematic import SchematicManager
logger.info(
f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}"
)
logger.debug(f"Full component_def: {component_def}")
# Get component type and determine template
comp_type = component_def.get("type", "R")
library = component_def.get("library", None) # Optional library specification
# Get template reference (static or dynamic)
template_ref, needs_reload = ComponentManager.get_or_create_template(
schematic, comp_type, library, schematic_path
)
# 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}")
schematic = SchematicManager.load_schematic(str(schematic_path))
# Find template symbol by reference (handles special characters like +)
template_symbol = None
for symbol in schematic.symbol:
if (
hasattr(symbol.property, "Reference")
and symbol.property.Reference.value == template_ref
):
template_symbol = symbol
break
if not template_symbol:
logger.error(
f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}"
)
raise ValueError(
f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch"
)
# Clone the template symbol
new_symbol = template_symbol.clone()
logger.debug(f"Cloned template symbol {template_ref}")
# Set reference
reference = component_def.get("reference", "R?")
new_symbol.property.Reference.value = reference
logger.debug(f"Set reference to {reference}")
# Set value
if "value" in component_def:
new_symbol.property.Value.value = component_def["value"]
logger.debug(f"Set value to {component_def['value']}")
# Set footprint
if "footprint" in component_def:
new_symbol.property.Footprint.value = component_def["footprint"]
logger.debug(f"Set footprint to {component_def['footprint']}")
# Set datasheet
if "datasheet" in component_def:
new_symbol.property.Datasheet.value = component_def["datasheet"]
# Set position
x = component_def.get("x", 0)
y = component_def.get("y", 0)
rotation = component_def.get("rotation", 0)
new_symbol.at.value = [x, y, rotation]
logger.debug(f"Set position to ({x}, {y}, {rotation})")
# Set BOM and board flags
new_symbol.in_bom.value = component_def.get("in_bom", True)
new_symbol.on_board.value = component_def.get("on_board", True)
new_symbol.dnp.value = component_def.get("dnp", False)
# Generate new UUID
new_symbol.uuid.value = str(uuid.uuid4())
# Append to schematic
schematic.symbol.append(new_symbol)
logger.info(f"Successfully added component {reference} to schematic")
return new_symbol
except Exception as e:
logger.error(f"Error adding component: {e}", exc_info=True)
raise
@staticmethod
def remove_component(schematic: Schematic, component_ref: str) -> bool:
"""Remove a component from the schematic by reference designator"""
try:
# kicad-skip doesn't have a direct remove_symbol method by reference.
# We need to find the symbol and then remove it from the symbols list.
symbol_to_remove = None
for symbol in schematic.symbol:
if symbol.reference == component_ref:
symbol_to_remove = symbol
break
if symbol_to_remove:
schematic.symbol._elements.remove(symbol_to_remove)
logger.info(f"Removed component {component_ref} from schematic.")
return True
else:
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
logger.error(f"Error removing component {component_ref}: {e}")
return False
@staticmethod
def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool:
"""Update component properties by reference designator"""
try:
symbol_to_update = None
for symbol in schematic.symbol:
if symbol.reference == component_ref:
symbol_to_update = symbol
break
if symbol_to_update:
for key, value in new_properties.items():
if key in symbol_to_update.property:
symbol_to_update.property[key].value = value
else:
symbol_to_update.property.append(key, value)
logger.info(f"Updated properties for component {component_ref}.")
return True
else:
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
logger.error(f"Error updating component {component_ref}: {e}")
return False
@staticmethod
def get_component(schematic: Schematic, component_ref: str) -> Any:
"""Get a component by reference designator"""
for symbol in schematic.symbol:
if symbol.reference == component_ref:
logger.debug(f"Found component with reference {component_ref}.")
return symbol
logger.warning(f"Component with reference {component_ref} not found.")
return None
@staticmethod
def search_components(schematic: Schematic, query: str) -> List[Any]:
"""Search for components matching criteria (basic implementation)"""
# This is a basic search, could be expanded to use regex or more complex logic
matching_components = []
query_lower = query.lower()
for symbol in schematic.symbol:
if (
query_lower in symbol.reference.lower()
or query_lower in symbol.name.lower()
or (
hasattr(symbol.property, "Value")
and query_lower in symbol.property.Value.value.lower()
)
):
matching_components.append(symbol)
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
return matching_components
@staticmethod
def get_all_components(schematic: Schematic) -> List[Any]:
"""Get all components in schematic"""
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
return list(schematic.symbol)
if __name__ == "__main__":
# Example Usage (for testing)
from schematic import ( # Assuming schematic.py is in the same directory
SchematicManager,
)
# Create a new schematic
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
# Add components
comp1_def = {"type": "R", "reference": "R1", "value": "10k", "x": 100, "y": 100}
comp2_def = {
"type": "C",
"reference": "C1",
"value": "0.1uF",
"x": 200,
"y": 100,
"library": "Device",
}
comp3_def = {
"type": "LED",
"reference": "D1",
"x": 300,
"y": 100,
"library": "Device",
"properties": {"Color": "Red"},
}
comp1 = ComponentManager.add_component(test_sch, comp1_def)
comp2 = ComponentManager.add_component(test_sch, comp2_def)
comp3 = ComponentManager.add_component(test_sch, comp3_def)
# Get a component
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
if retrieved_comp:
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
# Update a component
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
# Search components
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
all_comps = ComponentManager.get_all_components(test_sch)
print(f"All components: {[c.reference for c in all_comps]}")
# 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]}")
# Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")
# Clean up (if saved)
# if os.path.exists("component_test.kicad_sch"):
# os.remove("component_test.kicad_sch")
# print("Cleaned up component_test.kicad_sch")
import logging
import os
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from skip import Schematic
logger = logging.getLogger(__name__)
# Import dynamic symbol loader
try:
from commands.dynamic_symbol_loader import DynamicSymbolLoader
DYNAMIC_LOADING_AVAILABLE = True
except ImportError:
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
DYNAMIC_LOADING_AVAILABLE = False
class ComponentManager:
"""Manage components in a schematic"""
# Initialize dynamic loader (class variable, shared across instances)
_dynamic_loader = None
@classmethod
def get_dynamic_loader(cls) -> Any:
"""Get or create dynamic symbol loader instance"""
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
cls._dynamic_loader = DynamicSymbolLoader()
return cls._dynamic_loader
# Template symbol references mapping component type to template reference
TEMPLATE_MAP = {
# Passives
"R": "_TEMPLATE_R",
"C": "_TEMPLATE_C",
"L": "_TEMPLATE_L",
"Y": "_TEMPLATE_Y",
"Crystal": "_TEMPLATE_Y",
# Semiconductors
"D": "_TEMPLATE_D",
"LED": "_TEMPLATE_LED",
"Q": "_TEMPLATE_Q_NPN",
"Q_NPN": "_TEMPLATE_Q_NPN",
"Q_NMOS": "_TEMPLATE_Q_NMOS",
"MOSFET": "_TEMPLATE_Q_NMOS",
# ICs
"U": "_TEMPLATE_U_OPAMP",
"OpAmp": "_TEMPLATE_U_OPAMP",
"IC": "_TEMPLATE_U_OPAMP",
"U_REG": "_TEMPLATE_U_REG",
"Regulator": "_TEMPLATE_U_REG",
# Connectors
"J": "_TEMPLATE_J2",
"J2": "_TEMPLATE_J2",
"J4": "_TEMPLATE_J4",
"Conn_2": "_TEMPLATE_J2",
"Conn_4": "_TEMPLATE_J4",
# Misc
"SW": "_TEMPLATE_SW",
"Button": "_TEMPLATE_SW",
"Switch": "_TEMPLATE_SW",
}
@classmethod
def get_or_create_template(
cls,
schematic: Schematic,
comp_type: str,
library: Optional[str] = None,
schematic_path: Optional[Path] = None,
) -> tuple:
"""
Get template reference for a component type, creating it dynamically if needed
Args:
schematic: Schematic object
comp_type: Component type (e.g., 'R', 'LED', 'STM32F103C8Tx')
library: Optional library name (defaults to 'Device' for common types)
schematic_path: Optional path to schematic file (required for dynamic loading)
Returns:
Tuple of (template_ref, needs_reload) where needs_reload indicates if schematic must be reloaded
"""
# Helper function to check if template exists in schematic
def template_exists(schematic: Any, template_ref: str) -> bool:
"""Check if template exists by iterating symbols (handles special characters)"""
for symbol in schematic.symbol:
if (
hasattr(symbol.property, "Reference")
and symbol.property.Reference.value == template_ref
):
return True
return False
# 1. Check static template map first
if comp_type in cls.TEMPLATE_MAP:
template_ref = cls.TEMPLATE_MAP[comp_type]
# Verify template exists in schematic
if template_exists(schematic, template_ref):
logger.debug(f"Using static template: {template_ref}")
return (template_ref, False)
# 2. Check if dynamically loaded template already exists
# Build potential template reference names
potential_refs = []
if library:
potential_refs.append(f"_TEMPLATE_{library}_{comp_type}")
potential_refs.append(f"_TEMPLATE_{comp_type}")
if comp_type in cls.TEMPLATE_MAP:
potential_refs.append(cls.TEMPLATE_MAP[comp_type])
# Check each potential reference
for template_ref in potential_refs:
if template_exists(schematic, template_ref):
logger.debug(f"Found existing template: {template_ref}")
return (template_ref, False)
# 3. Try dynamic loading
if not DYNAMIC_LOADING_AVAILABLE:
logger.warning(
f"Component type '{comp_type}' not in static templates and dynamic loading unavailable"
)
# Fall back to basic resistor template
return ("_TEMPLATE_R", False)
loader = cls.get_dynamic_loader()
if not loader:
logger.warning("Dynamic loader unavailable, using fallback template")
return ("_TEMPLATE_R", False)
# Check if schematic path is available
if schematic_path is None:
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
try:
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)
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
# Signal that schematic needs reload to see new template
return (template_ref, True)
except Exception as e:
logger.error(f"Dynamic loading failed: {e}")
import traceback
logger.error(traceback.format_exc())
# Fall back to static template if available
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
return (fallback, False)
@staticmethod
def add_component(
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
) -> Any:
"""
Add a component to the schematic by cloning from template
Args:
schematic: Schematic object to add component to
component_def: Component definition dictionary
schematic_path: Optional path to schematic file (enables dynamic symbol loading)
Returns:
Tuple of (new_symbol, needs_reload) where needs_reload indicates if caller should reload schematic
"""
try:
from commands.schematic import SchematicManager
logger.info(
f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}"
)
logger.debug(f"Full component_def: {component_def}")
# Get component type and determine template
comp_type = component_def.get("type", "R")
library = component_def.get("library", None) # Optional library specification
# Get template reference (static or dynamic)
template_ref, needs_reload = ComponentManager.get_or_create_template(
schematic, comp_type, library, schematic_path
)
# 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}")
schematic = SchematicManager.load_schematic(str(schematic_path))
# Find template symbol by reference (handles special characters like +)
template_symbol = None
for symbol in schematic.symbol:
if (
hasattr(symbol.property, "Reference")
and symbol.property.Reference.value == template_ref
):
template_symbol = symbol
break
if not template_symbol:
logger.error(
f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}"
)
raise ValueError(
f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch"
)
# Clone the template symbol
new_symbol = template_symbol.clone()
logger.debug(f"Cloned template symbol {template_ref}")
# Set reference
reference = component_def.get("reference", "R?")
new_symbol.property.Reference.value = reference
logger.debug(f"Set reference to {reference}")
# Set value
if "value" in component_def:
new_symbol.property.Value.value = component_def["value"]
logger.debug(f"Set value to {component_def['value']}")
# Set footprint
if "footprint" in component_def:
new_symbol.property.Footprint.value = component_def["footprint"]
logger.debug(f"Set footprint to {component_def['footprint']}")
# Set datasheet
if "datasheet" in component_def:
new_symbol.property.Datasheet.value = component_def["datasheet"]
# Set position
x = component_def.get("x", 0)
y = component_def.get("y", 0)
rotation = component_def.get("rotation", 0)
new_symbol.at.value = [x, y, rotation]
logger.debug(f"Set position to ({x}, {y}, {rotation})")
# Set BOM and board flags
new_symbol.in_bom.value = component_def.get("in_bom", True)
new_symbol.on_board.value = component_def.get("on_board", True)
new_symbol.dnp.value = component_def.get("dnp", False)
# Generate new UUID
new_symbol.uuid.value = str(uuid.uuid4())
# Append to schematic
schematic.symbol.append(new_symbol)
logger.info(f"Successfully added component {reference} to schematic")
return new_symbol
except Exception as e:
logger.error(f"Error adding component: {e}", exc_info=True)
raise
@staticmethod
def remove_component(schematic: Schematic, component_ref: str) -> bool:
"""Remove a component from the schematic by reference designator"""
try:
# kicad-skip doesn't have a direct remove_symbol method by reference.
# We need to find the symbol and then remove it from the symbols list.
symbol_to_remove = None
for symbol in schematic.symbol:
if symbol.reference == component_ref:
symbol_to_remove = symbol
break
if symbol_to_remove:
schematic.symbol._elements.remove(symbol_to_remove)
logger.info(f"Removed component {component_ref} from schematic.")
return True
else:
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
logger.error(f"Error removing component {component_ref}: {e}")
return False
@staticmethod
def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool:
"""Update component properties by reference designator"""
try:
symbol_to_update = None
for symbol in schematic.symbol:
if symbol.reference == component_ref:
symbol_to_update = symbol
break
if symbol_to_update:
for key, value in new_properties.items():
if key in symbol_to_update.property:
symbol_to_update.property[key].value = value
else:
symbol_to_update.property.append(key, value)
logger.info(f"Updated properties for component {component_ref}.")
return True
else:
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
logger.error(f"Error updating component {component_ref}: {e}")
return False
@staticmethod
def get_component(schematic: Schematic, component_ref: str) -> Any:
"""Get a component by reference designator"""
for symbol in schematic.symbol:
if symbol.reference == component_ref:
logger.debug(f"Found component with reference {component_ref}.")
return symbol
logger.warning(f"Component with reference {component_ref} not found.")
return None
@staticmethod
def search_components(schematic: Schematic, query: str) -> List[Any]:
"""Search for components matching criteria (basic implementation)"""
# This is a basic search, could be expanded to use regex or more complex logic
matching_components = []
query_lower = query.lower()
for symbol in schematic.symbol:
if (
query_lower in symbol.reference.lower()
or query_lower in symbol.name.lower()
or (
hasattr(symbol.property, "Value")
and query_lower in symbol.property.Value.value.lower()
)
):
matching_components.append(symbol)
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
return matching_components
@staticmethod
def get_all_components(schematic: Schematic) -> List[Any]:
"""Get all components in schematic"""
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
return list(schematic.symbol)
if __name__ == "__main__":
# Example Usage (for testing)
from schematic import ( # Assuming schematic.py is in the same directory
SchematicManager,
)
# Create a new schematic
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
# Add components
comp1_def = {"type": "R", "reference": "R1", "value": "10k", "x": 100, "y": 100}
comp2_def = {
"type": "C",
"reference": "C1",
"value": "0.1uF",
"x": 200,
"y": 100,
"library": "Device",
}
comp3_def = {
"type": "LED",
"reference": "D1",
"x": 300,
"y": 100,
"library": "Device",
"properties": {"Color": "Red"},
}
comp1 = ComponentManager.add_component(test_sch, comp1_def)
comp2 = ComponentManager.add_component(test_sch, comp2_def)
comp3 = ComponentManager.add_component(test_sch, comp3_def)
# Get a component
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
if retrieved_comp:
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
# Update a component
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
# Search components
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
all_comps = ComponentManager.get_all_components(test_sch)
print(f"All components: {[c.reference for c in all_comps]}")
# 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]}")
# Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")
# Clean up (if saved)
# if os.path.exists("component_test.kicad_sch"):
# os.remove("component_test.kicad_sch")
# print("Cleaned up component_test.kicad_sch")

View File

@@ -1,459 +1,459 @@
"""
Design rules command implementations for KiCAD interface
"""
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
class DesignRuleCommands:
"""Handles design rule checking and configuration"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set design rules for the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
design_settings = self.board.GetDesignSettings()
# Convert mm to nanometers for KiCAD internal units
scale = 1000000 # mm to nm
# Set clearance
if "clearance" in params:
design_settings.m_MinClearance = int(params["clearance"] * scale)
# KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed)
# Track if we set any custom track/via values
custom_values_set = False
if "trackWidth" in params:
design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale))
custom_values_set = True
# Via settings
if "viaDiameter" in params:
design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale))
custom_values_set = True
if "viaDrill" in params:
design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale))
custom_values_set = True
# KiCAD 9.0: Activate custom track/via values so they become the current values
if custom_values_set:
design_settings.UseCustomTrackViaSize(True)
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
if "microViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
if "microViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
# Set minimum values
if "minTrackWidth" in params:
design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
if "minViaDiameter" in params:
design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
# KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead
if "minViaDrill" in params:
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
if "minMicroViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
if "minMicroViaDrill" in params:
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)
# KiCAD 9.0: Added hole clearance settings
if "holeClearance" in params:
design_settings.m_HoleClearance = int(params["holeClearance"] * scale)
if "holeToHoleMin" in params:
design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale)
# Build response with KiCAD 9.0 compatible properties
# After UseCustomTrackViaSize(True), GetCurrent* returns the custom values
response_rules = {
"clearance": design_settings.m_MinClearance / scale,
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
"minViaDiameter": design_settings.m_ViasMinSize / scale,
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
"holeClearance": design_settings.m_HoleClearance / scale,
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
}
return {
"success": True,
"message": "Updated design rules",
"rules": response_rules,
}
except Exception as e:
logger.error(f"Error setting design rules: {str(e)}")
return {
"success": False,
"message": "Failed to set design rules",
"errorDetails": str(e),
}
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get current design rules - KiCAD 9.0 compatible"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
design_settings = self.board.GetDesignSettings()
scale = 1000000 # nm to mm
# Build rules dict with KiCAD 9.0 compatible properties
rules = {
# Core clearance and track settings
"clearance": design_settings.m_MinClearance / scale,
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
# Via settings (current values from methods)
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
# Via minimum values
"minViaDiameter": design_settings.m_ViasMinSize / scale,
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
# Micro via settings
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
# KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
"holeClearance": design_settings.m_HoleClearance / scale,
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
# Other constraints
"copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
"silkClearance": design_settings.m_SilkClearance / scale,
}
return {"success": True, "rules": rules}
except Exception as e:
logger.error(f"Error getting design rules: {str(e)}")
return {
"success": False,
"message": "Failed to get design rules",
"errorDetails": str(e),
}
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Design Rule Check using kicad-cli"""
import json
import platform
import shutil
import subprocess
import tempfile
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
report_path = params.get("reportPath")
# Get the board file path
board_file = self.board.GetFileName()
if not board_file or not os.path.exists(board_file):
return {
"success": False,
"message": "Board file not found",
"errorDetails": "Cannot run DRC without a saved board file",
}
# Find kicad-cli executable
kicad_cli = self._find_kicad_cli()
if not kicad_cli:
return {
"success": False,
"message": "kicad-cli not found",
"errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.",
}
# Create temporary JSON output file
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json_output = tmp.name
try:
# Build command
cmd = [
kicad_cli,
"pcb",
"drc",
"--format",
"json",
"--output",
json_output,
"--units",
"mm",
board_file,
]
logger.info(f"Running DRC command: {' '.join(cmd)}")
# Run DRC
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600, # 10 minute timeout for large boards (21MB PCB needs time)
)
if result.returncode != 0:
logger.error(f"DRC command failed: {result.stderr}")
return {
"success": False,
"message": "DRC command failed",
"errorDetails": result.stderr,
}
# Read JSON output
with open(json_output, "r", encoding="utf-8") as f:
drc_data = json.load(f)
# Parse violations from kicad-cli output
violations = []
violation_counts: dict[str, int] = {}
severity_counts = {"error": 0, "warning": 0, "info": 0}
for violation in drc_data.get("violations", []):
vtype = violation.get("type", "unknown")
vseverity = violation.get("severity", "error")
# Extract location from first item's pos (kicad-cli JSON format)
items = violation.get("items", [])
loc_x, loc_y = 0, 0
if items and "pos" in items[0]:
loc_x = items[0]["pos"].get("x", 0)
loc_y = items[0]["pos"].get("y", 0)
violations.append(
{
"type": vtype,
"severity": vseverity,
"message": violation.get("description", ""),
"location": {
"x": loc_x,
"y": loc_y,
"unit": "mm",
},
}
)
# Count violations by type
violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
# Count by severity
if vseverity in severity_counts:
severity_counts[vseverity] += 1
# 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")
# Always save violations to JSON file (for large result sets)
with open(violations_file, "w", encoding="utf-8") as f:
json.dump(
{
"board": board_file,
"timestamp": drc_data.get("date", "unknown"),
"total_violations": len(violations),
"violation_counts": violation_counts,
"severity_counts": severity_counts,
"violations": violations,
},
f,
indent=2,
)
# Save text report if requested
if report_path:
report_path = os.path.abspath(os.path.expanduser(report_path))
cmd_report = [
kicad_cli,
"pcb",
"drc",
"--format",
"report",
"--output",
report_path,
"--units",
"mm",
board_file,
]
subprocess.run(cmd_report, capture_output=True, timeout=600)
# Return summary only (not full violations list)
return {
"success": True,
"message": f"Found {len(violations)} DRC violations",
"summary": {
"total": len(violations),
"by_severity": severity_counts,
"by_type": violation_counts,
},
"violationsFile": violations_file,
"reportPath": report_path if report_path else None,
}
finally:
# Clean up temp JSON file
if os.path.exists(json_output):
os.unlink(json_output)
except subprocess.TimeoutExpired:
logger.error("DRC command timed out")
return {
"success": False,
"message": "DRC command timed out",
"errorDetails": "Command took longer than 600 seconds (10 minutes)",
}
except Exception as e:
logger.error(f"Error running DRC: {str(e)}")
return {
"success": False,
"message": "Failed to run DRC",
"errorDetails": str(e),
}
def _find_kicad_cli(self) -> Optional[str]:
"""Find kicad-cli executable"""
import platform
import shutil
# Try system PATH first
cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli"
cli_path = shutil.which(cli_name)
if cli_path:
return cli_path
# Try common installation paths (version-specific)
if platform.system() == "Windows":
common_paths = [
r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe",
r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe",
r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
r"C:\Program Files\KiCad\bin\kicad-cli.exe",
]
for path in common_paths:
if os.path.exists(path):
return path
elif platform.system() == "Darwin": # macOS
common_paths = [
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
"/usr/local/bin/kicad-cli",
]
for path in common_paths:
if os.path.exists(path):
return path
else: # Linux
common_paths = [
"/usr/bin/kicad-cli",
"/usr/local/bin/kicad-cli",
]
for path in common_paths:
if os.path.exists(path):
return path
return None
def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""
Get list of DRC violations
Note: This command internally uses run_drc() which calls kicad-cli.
The old BOARD.GetDRCMarkers() API was removed in KiCAD 9.0.
This implementation provides backward compatibility by parsing kicad-cli output.
"""
import json
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
severity = params.get("severity", "all")
# Run DRC using kicad-cli (this saves violations to JSON file)
drc_result = self.run_drc({})
if not drc_result.get("success"):
return drc_result # Return the error from run_drc
# Read violations from the saved JSON file
violations_file = drc_result.get("violationsFile")
if not violations_file or not os.path.exists(violations_file):
return {
"success": False,
"message": "Violations file not found",
"errorDetails": "run_drc did not create violations file",
}
# Load violations from file
with open(violations_file, "r", encoding="utf-8") as f:
data = json.load(f)
all_violations = data.get("violations", [])
# Filter by severity if specified
if severity != "all":
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
else:
filtered_violations = all_violations
return {
"success": True,
"violations": filtered_violations,
"violationsFile": violations_file, # Include file path for reference
}
except Exception as e:
logger.error(f"Error getting DRC violations: {str(e)}")
return {
"success": False,
"message": "Failed to get DRC violations",
"errorDetails": str(e),
}
"""
Design rules command implementations for KiCAD interface
"""
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
class DesignRuleCommands:
"""Handles design rule checking and configuration"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set design rules for the PCB"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
design_settings = self.board.GetDesignSettings()
# Convert mm to nanometers for KiCAD internal units
scale = 1000000 # mm to nm
# Set clearance
if "clearance" in params:
design_settings.m_MinClearance = int(params["clearance"] * scale)
# KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed)
# Track if we set any custom track/via values
custom_values_set = False
if "trackWidth" in params:
design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale))
custom_values_set = True
# Via settings
if "viaDiameter" in params:
design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale))
custom_values_set = True
if "viaDrill" in params:
design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale))
custom_values_set = True
# KiCAD 9.0: Activate custom track/via values so they become the current values
if custom_values_set:
design_settings.UseCustomTrackViaSize(True)
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
if "microViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
if "microViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
# Set minimum values
if "minTrackWidth" in params:
design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
if "minViaDiameter" in params:
design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
# KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead
if "minViaDrill" in params:
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
if "minMicroViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
if "minMicroViaDrill" in params:
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)
# KiCAD 9.0: Added hole clearance settings
if "holeClearance" in params:
design_settings.m_HoleClearance = int(params["holeClearance"] * scale)
if "holeToHoleMin" in params:
design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale)
# Build response with KiCAD 9.0 compatible properties
# After UseCustomTrackViaSize(True), GetCurrent* returns the custom values
response_rules = {
"clearance": design_settings.m_MinClearance / scale,
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
"minViaDiameter": design_settings.m_ViasMinSize / scale,
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
"holeClearance": design_settings.m_HoleClearance / scale,
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
}
return {
"success": True,
"message": "Updated design rules",
"rules": response_rules,
}
except Exception as e:
logger.error(f"Error setting design rules: {str(e)}")
return {
"success": False,
"message": "Failed to set design rules",
"errorDetails": str(e),
}
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get current design rules - KiCAD 9.0 compatible"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
design_settings = self.board.GetDesignSettings()
scale = 1000000 # nm to mm
# Build rules dict with KiCAD 9.0 compatible properties
rules = {
# Core clearance and track settings
"clearance": design_settings.m_MinClearance / scale,
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
# Via settings (current values from methods)
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
# Via minimum values
"minViaDiameter": design_settings.m_ViasMinSize / scale,
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
# Micro via settings
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
# KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
"holeClearance": design_settings.m_HoleClearance / scale,
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
# Other constraints
"copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
"silkClearance": design_settings.m_SilkClearance / scale,
}
return {"success": True, "rules": rules}
except Exception as e:
logger.error(f"Error getting design rules: {str(e)}")
return {
"success": False,
"message": "Failed to get design rules",
"errorDetails": str(e),
}
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Design Rule Check using kicad-cli"""
import json
import platform
import shutil
import subprocess
import tempfile
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
report_path = params.get("reportPath")
# Get the board file path
board_file = self.board.GetFileName()
if not board_file or not os.path.exists(board_file):
return {
"success": False,
"message": "Board file not found",
"errorDetails": "Cannot run DRC without a saved board file",
}
# Find kicad-cli executable
kicad_cli = self._find_kicad_cli()
if not kicad_cli:
return {
"success": False,
"message": "kicad-cli not found",
"errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.",
}
# Create temporary JSON output file
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json_output = tmp.name
try:
# Build command
cmd = [
kicad_cli,
"pcb",
"drc",
"--format",
"json",
"--output",
json_output,
"--units",
"mm",
board_file,
]
logger.info(f"Running DRC command: {' '.join(cmd)}")
# Run DRC
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=600, # 10 minute timeout for large boards (21MB PCB needs time)
)
if result.returncode != 0:
logger.error(f"DRC command failed: {result.stderr}")
return {
"success": False,
"message": "DRC command failed",
"errorDetails": result.stderr,
}
# Read JSON output
with open(json_output, "r", encoding="utf-8") as f:
drc_data = json.load(f)
# Parse violations from kicad-cli output
violations = []
violation_counts: dict[str, int] = {}
severity_counts = {"error": 0, "warning": 0, "info": 0}
for violation in drc_data.get("violations", []):
vtype = violation.get("type", "unknown")
vseverity = violation.get("severity", "error")
# Extract location from first item's pos (kicad-cli JSON format)
items = violation.get("items", [])
loc_x, loc_y = 0, 0
if items and "pos" in items[0]:
loc_x = items[0]["pos"].get("x", 0)
loc_y = items[0]["pos"].get("y", 0)
violations.append(
{
"type": vtype,
"severity": vseverity,
"message": violation.get("description", ""),
"location": {
"x": loc_x,
"y": loc_y,
"unit": "mm",
},
}
)
# Count violations by type
violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
# Count by severity
if vseverity in severity_counts:
severity_counts[vseverity] += 1
# 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")
# Always save violations to JSON file (for large result sets)
with open(violations_file, "w", encoding="utf-8") as f:
json.dump(
{
"board": board_file,
"timestamp": drc_data.get("date", "unknown"),
"total_violations": len(violations),
"violation_counts": violation_counts,
"severity_counts": severity_counts,
"violations": violations,
},
f,
indent=2,
)
# Save text report if requested
if report_path:
report_path = os.path.abspath(os.path.expanduser(report_path))
cmd_report = [
kicad_cli,
"pcb",
"drc",
"--format",
"report",
"--output",
report_path,
"--units",
"mm",
board_file,
]
subprocess.run(cmd_report, capture_output=True, timeout=600)
# Return summary only (not full violations list)
return {
"success": True,
"message": f"Found {len(violations)} DRC violations",
"summary": {
"total": len(violations),
"by_severity": severity_counts,
"by_type": violation_counts,
},
"violationsFile": violations_file,
"reportPath": report_path if report_path else None,
}
finally:
# Clean up temp JSON file
if os.path.exists(json_output):
os.unlink(json_output)
except subprocess.TimeoutExpired:
logger.error("DRC command timed out")
return {
"success": False,
"message": "DRC command timed out",
"errorDetails": "Command took longer than 600 seconds (10 minutes)",
}
except Exception as e:
logger.error(f"Error running DRC: {str(e)}")
return {
"success": False,
"message": "Failed to run DRC",
"errorDetails": str(e),
}
def _find_kicad_cli(self) -> Optional[str]:
"""Find kicad-cli executable"""
import platform
import shutil
# Try system PATH first
cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli"
cli_path = shutil.which(cli_name)
if cli_path:
return cli_path
# Try common installation paths (version-specific)
if platform.system() == "Windows":
common_paths = [
r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe",
r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe",
r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
r"C:\Program Files\KiCad\bin\kicad-cli.exe",
]
for path in common_paths:
if os.path.exists(path):
return path
elif platform.system() == "Darwin": # macOS
common_paths = [
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
"/usr/local/bin/kicad-cli",
]
for path in common_paths:
if os.path.exists(path):
return path
else: # Linux
common_paths = [
"/usr/bin/kicad-cli",
"/usr/local/bin/kicad-cli",
]
for path in common_paths:
if os.path.exists(path):
return path
return None
def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""
Get list of DRC violations
Note: This command internally uses run_drc() which calls kicad-cli.
The old BOARD.GetDRCMarkers() API was removed in KiCAD 9.0.
This implementation provides backward compatibility by parsing kicad-cli output.
"""
import json
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
severity = params.get("severity", "all")
# Run DRC using kicad-cli (this saves violations to JSON file)
drc_result = self.run_drc({})
if not drc_result.get("success"):
return drc_result # Return the error from run_drc
# Read violations from the saved JSON file
violations_file = drc_result.get("violationsFile")
if not violations_file or not os.path.exists(violations_file):
return {
"success": False,
"message": "Violations file not found",
"errorDetails": "run_drc did not create violations file",
}
# Load violations from file
with open(violations_file, "r", encoding="utf-8") as f:
data = json.load(f)
all_violations = data.get("violations", [])
# Filter by severity if specified
if severity != "all":
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
else:
filtered_violations = all_violations
return {
"success": True,
"violations": filtered_violations,
"violationsFile": violations_file, # Include file path for reference
}
except Exception as e:
logger.error(f"Error getting DRC violations: {str(e)}")
return {
"success": False,
"message": "Failed to get DRC violations",
"errorDetails": str(e),
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,303 +1,303 @@
"""
JLCPCB API client for fetching parts data
Handles authentication and downloading the JLCPCB parts library
for integration with KiCAD component selection.
"""
import base64
import hashlib
import hmac
import json
import logging
import os
import secrets
import string
import time
from pathlib import Path
from typing import Callable, Dict, List, Optional
import requests
logger = logging.getLogger("kicad_interface")
class JLCPCBClient:
"""
Client for JLCPCB API
Handles HMAC-SHA256 signature-based authentication and fetching
the complete parts library from JLCPCB's external API.
"""
BASE_URL = "https://jlcpcb.com/external"
def __init__(
self,
app_id: Optional[str] = None,
access_key: Optional[str] = None,
secret_key: Optional[str] = None,
):
"""
Initialize JLCPCB API client
Args:
app_id: JLCPCB App ID (or reads from JLCPCB_APP_ID env var)
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
"""
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."
)
@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))
def _build_signature_string(
self, method: str, path: str, timestamp: int, nonce: str, body: str
) -> str:
"""
Build the signature string according to JLCPCB spec
Format:
<HTTP Method>\n
<Request Path>\n
<Timestamp>\n
<Nonce>\n
<Request Body>\n
Args:
method: HTTP method (GET, POST, etc.)
path: Request path with query params
timestamp: Unix timestamp in seconds
nonce: 32-character random string
body: Request body (empty string for GET)
Returns:
Signature string
"""
return f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n"
def _sign(self, signature_string: str) -> str:
"""
Sign the signature string with HMAC-SHA256
Args:
signature_string: The string to sign
Returns:
Base64-encoded signature
"""
signature_bytes = hmac.new(
self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
).digest()
return base64.b64encode(signature_bytes).decode("utf-8")
def _get_auth_header(self, method: str, path: str, body: str = "") -> str:
"""
Generate the Authorization header for JLCPCB API requests
Args:
method: HTTP method (GET, POST, etc.)
path: Request path with query params
body: Request body JSON string (empty for GET)
Returns:
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."
)
nonce = self._generate_nonce()
timestamp = int(time.time())
signature_string = self._build_signature_string(method, path, timestamp, nonce, body)
signature = self._sign(signature_string)
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}"'
)
return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
def fetch_parts_page(self, last_key: Optional[str] = None) -> Dict:
"""
Fetch one page of parts from JLCPCB API
Args:
last_key: Pagination key from previous response (None for first page)
Returns:
Response dict with parts data and pagination info
"""
path = "/component/getComponentInfos"
payload = {}
if last_key:
payload["lastKey"] = last_key
# Convert payload to JSON string for signing
# For POST requests, we always send JSON, even if empty dict
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"}
try:
response = requests.post(
f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
)
logger.debug(f"Response status: {response.status_code}")
logger.debug(f"Response headers: {response.headers}")
logger.debug(f"Response text: {response.text}")
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}"
)
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
) -> List[Dict]:
"""
Download entire parts library from JLCPCB
Args:
callback: Optional progress callback function(current_page, total_parts, status_msg)
Returns:
List of all parts
"""
all_parts = []
last_key = None
page = 0
logger.info("Starting full JLCPCB parts database download...")
while True:
page += 1
try:
data = self.fetch_parts_page(last_key)
parts = data.get("componentInfos", [])
all_parts.extend(parts)
last_key = data.get("lastKey")
if callback:
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
else:
logger.info(f"Page {page}: Downloaded {len(all_parts)} parts so far...")
# Check if there are more pages
if not last_key or len(parts) == 0:
break
# Rate limiting - be nice to the API
time.sleep(0.5)
except Exception as e:
logger.error(f"Error downloading parts at page {page}: {e}")
if len(all_parts) > 0:
logger.warning(f"Partial download available: {len(all_parts)} parts")
return all_parts
else:
raise
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
return all_parts
def get_part_by_lcsc(self, lcsc_number: str) -> Optional[Dict]:
"""
Get detailed information for a specific LCSC part number
Note: This uses the same endpoint as fetching parts, as JLCPCB doesn't
have a dedicated single-part endpoint. In practice, you should use
the local database after initial download.
Args:
lcsc_number: LCSC part number (e.g., "C25804")
Returns:
Part info dict or None if not found
"""
# For now, this would require searching through pages
# In practice, you'd use the local database
logger.warning("get_part_by_lcsc should use local database, not API")
return None
def test_jlcpcb_connection(
app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None
) -> bool:
"""
Test JLCPCB API connection
Args:
app_id: Optional App ID (uses env var if not provided)
access_key: Optional Access Key (uses env var if not provided)
secret_key: Optional Secret Key (uses env var if not provided)
Returns:
True if connection successful, False otherwise
"""
try:
client = JLCPCBClient(app_id, access_key, secret_key)
# Test by fetching first page
data = client.fetch_parts_page()
logger.info("JLCPCB API connection test successful")
return True
except Exception as e:
logger.error(f"JLCPCB API connection test failed: {e}")
return False
if __name__ == "__main__":
# Test the JLCPCB client
logging.basicConfig(level=logging.INFO)
print("Testing JLCPCB API connection...")
if test_jlcpcb_connection():
print("✓ Connection successful!")
client = JLCPCBClient()
print("\nFetching first page of parts...")
data = client.fetch_parts_page()
parts = data.get("componentInfos", [])
print(f"✓ Retrieved {len(parts)} parts in first page")
if parts:
print(f"\nExample part:")
part = parts[0]
print(f" LCSC: {part.get('componentCode')}")
print(f" MFR Part: {part.get('componentModelEn')}")
print(f" Category: {part.get('firstSortName')} / {part.get('secondSortName')}")
print(f" Package: {part.get('componentSpecificationEn')}")
print(f" Stock: {part.get('stockCount')}")
else:
print("✗ Connection failed. Check your API credentials.")
"""
JLCPCB API client for fetching parts data
Handles authentication and downloading the JLCPCB parts library
for integration with KiCAD component selection.
"""
import base64
import hashlib
import hmac
import json
import logging
import os
import secrets
import string
import time
from pathlib import Path
from typing import Callable, Dict, List, Optional
import requests
logger = logging.getLogger("kicad_interface")
class JLCPCBClient:
"""
Client for JLCPCB API
Handles HMAC-SHA256 signature-based authentication and fetching
the complete parts library from JLCPCB's external API.
"""
BASE_URL = "https://jlcpcb.com/external"
def __init__(
self,
app_id: Optional[str] = None,
access_key: Optional[str] = None,
secret_key: Optional[str] = None,
):
"""
Initialize JLCPCB API client
Args:
app_id: JLCPCB App ID (or reads from JLCPCB_APP_ID env var)
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
"""
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."
)
@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))
def _build_signature_string(
self, method: str, path: str, timestamp: int, nonce: str, body: str
) -> str:
"""
Build the signature string according to JLCPCB spec
Format:
<HTTP Method>\n
<Request Path>\n
<Timestamp>\n
<Nonce>\n
<Request Body>\n
Args:
method: HTTP method (GET, POST, etc.)
path: Request path with query params
timestamp: Unix timestamp in seconds
nonce: 32-character random string
body: Request body (empty string for GET)
Returns:
Signature string
"""
return f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n"
def _sign(self, signature_string: str) -> str:
"""
Sign the signature string with HMAC-SHA256
Args:
signature_string: The string to sign
Returns:
Base64-encoded signature
"""
signature_bytes = hmac.new(
self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
).digest()
return base64.b64encode(signature_bytes).decode("utf-8")
def _get_auth_header(self, method: str, path: str, body: str = "") -> str:
"""
Generate the Authorization header for JLCPCB API requests
Args:
method: HTTP method (GET, POST, etc.)
path: Request path with query params
body: Request body JSON string (empty for GET)
Returns:
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."
)
nonce = self._generate_nonce()
timestamp = int(time.time())
signature_string = self._build_signature_string(method, path, timestamp, nonce, body)
signature = self._sign(signature_string)
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}"'
)
return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
def fetch_parts_page(self, last_key: Optional[str] = None) -> Dict:
"""
Fetch one page of parts from JLCPCB API
Args:
last_key: Pagination key from previous response (None for first page)
Returns:
Response dict with parts data and pagination info
"""
path = "/component/getComponentInfos"
payload = {}
if last_key:
payload["lastKey"] = last_key
# Convert payload to JSON string for signing
# For POST requests, we always send JSON, even if empty dict
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"}
try:
response = requests.post(
f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
)
logger.debug(f"Response status: {response.status_code}")
logger.debug(f"Response headers: {response.headers}")
logger.debug(f"Response text: {response.text}")
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}"
)
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
) -> List[Dict]:
"""
Download entire parts library from JLCPCB
Args:
callback: Optional progress callback function(current_page, total_parts, status_msg)
Returns:
List of all parts
"""
all_parts = []
last_key = None
page = 0
logger.info("Starting full JLCPCB parts database download...")
while True:
page += 1
try:
data = self.fetch_parts_page(last_key)
parts = data.get("componentInfos", [])
all_parts.extend(parts)
last_key = data.get("lastKey")
if callback:
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
else:
logger.info(f"Page {page}: Downloaded {len(all_parts)} parts so far...")
# Check if there are more pages
if not last_key or len(parts) == 0:
break
# Rate limiting - be nice to the API
time.sleep(0.5)
except Exception as e:
logger.error(f"Error downloading parts at page {page}: {e}")
if len(all_parts) > 0:
logger.warning(f"Partial download available: {len(all_parts)} parts")
return all_parts
else:
raise
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
return all_parts
def get_part_by_lcsc(self, lcsc_number: str) -> Optional[Dict]:
"""
Get detailed information for a specific LCSC part number
Note: This uses the same endpoint as fetching parts, as JLCPCB doesn't
have a dedicated single-part endpoint. In practice, you should use
the local database after initial download.
Args:
lcsc_number: LCSC part number (e.g., "C25804")
Returns:
Part info dict or None if not found
"""
# For now, this would require searching through pages
# In practice, you'd use the local database
logger.warning("get_part_by_lcsc should use local database, not API")
return None
def test_jlcpcb_connection(
app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None
) -> bool:
"""
Test JLCPCB API connection
Args:
app_id: Optional App ID (uses env var if not provided)
access_key: Optional Access Key (uses env var if not provided)
secret_key: Optional Secret Key (uses env var if not provided)
Returns:
True if connection successful, False otherwise
"""
try:
client = JLCPCBClient(app_id, access_key, secret_key)
# Test by fetching first page
data = client.fetch_parts_page()
logger.info("JLCPCB API connection test successful")
return True
except Exception as e:
logger.error(f"JLCPCB API connection test failed: {e}")
return False
if __name__ == "__main__":
# Test the JLCPCB client
logging.basicConfig(level=logging.INFO)
print("Testing JLCPCB API connection...")
if test_jlcpcb_connection():
print("✓ Connection successful!")
client = JLCPCBClient()
print("\nFetching first page of parts...")
data = client.fetch_parts_page()
parts = data.get("componentInfos", [])
print(f"✓ Retrieved {len(parts)} parts in first page")
if parts:
print(f"\nExample part:")
part = parts[0]
print(f" LCSC: {part.get('componentCode')}")
print(f" MFR Part: {part.get('componentModelEn')}")
print(f" Category: {part.get('firstSortName')} / {part.get('secondSortName')}")
print(f" Package: {part.get('componentSpecificationEn')}")
print(f" Stock: {part.get('stockCount')}")
else:
print("✗ Connection failed. Check your API credentials.")

File diff suppressed because it is too large Load Diff

View File

@@ -1,240 +1,240 @@
"""
JLCSearch API client (public, no authentication required)
Alternative to official JLCPCB API using the community-maintained
jlcsearch service at https://jlcsearch.tscircuit.com/
"""
import logging
import time
from typing import Any, Callable, Dict, List, Optional, Union
import requests
logger = logging.getLogger("kicad_interface")
class JLCSearchClient:
"""
Client for JLCSearch public API (tscircuit)
Provides access to JLCPCB parts database without authentication
via the community-maintained jlcsearch service.
"""
BASE_URL = "https://jlcsearch.tscircuit.com"
def __init__(self) -> None:
"""Initialize JLCSearch API client"""
pass
def search_components(
self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict
) -> List[Dict]:
"""
Search components in JLCSearch database
Args:
category: Component category (e.g., "resistors", "capacitors", "components")
limit: Maximum number of results
offset: Offset for pagination
**filters: Additional filters (e.g., package="0603", resistance=1000)
Returns:
List of component dicts
"""
url = f"{self.BASE_URL}/{category}/list.json"
params = {"limit": limit, "offset": offset, **filters}
try:
response = requests.get(url, params=params, timeout=30)
response.raise_for_status()
data = response.json()
# The response has the category name as key
# e.g., {"resistors": [...]} or {"components": [...]}
for key, value in data.items():
if isinstance(value, list):
return value
return []
except requests.exceptions.RequestException as e:
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]:
"""
Search for resistors
Args:
resistance: Resistance value in ohms
package: Package type (e.g., "0603", "0805")
limit: Maximum results
Returns:
List of resistor dicts with fields:
- lcsc: LCSC number (integer)
- mfr: Manufacturer part number
- package: Package size
- is_basic: True if basic library part
- resistance: Resistance in ohms
- tolerance_fraction: Tolerance (0.01 = 1%)
- power_watts: Power rating in mW
- stock: Available stock
- price1: Price per unit
"""
filters: Dict[str, Any] = {}
if resistance is not None:
filters["resistance"] = resistance
if package:
filters["package"] = package
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]:
"""
Search for capacitors
Args:
capacitance: Capacitance value in farads
package: Package type
limit: Maximum results
Returns:
List of capacitor dicts
"""
filters: Dict[str, Any] = {}
if capacitance is not None:
filters["capacitance"] = capacitance
if package:
filters["package"] = package
return self.search_components("capacitors", limit=limit, **filters)
def get_part_by_lcsc(self, lcsc_number: int) -> Optional[Dict]:
"""
Get part details by LCSC number
Args:
lcsc_number: LCSC number (integer, without 'C' prefix)
Returns:
Part dict or None if not found
"""
# Search across all components filtering by LCSC
# Note: jlcsearch doesn't have a dedicated single-part endpoint
# so we search and filter
try:
results = self.search_components("components", limit=1, lcsc=lcsc_number)
return results[0] if results else None
except Exception as e:
logger.error(f"Failed to get part C{lcsc_number}: {e}")
return None
def download_all_components(
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
) -> List[Dict]:
"""
Download all components from jlcsearch database
Note: tscircuit API has a hard-coded 100 result limit per request.
Full catalog download requires ~25,000 paginated requests (~40-60 minutes).
Args:
callback: Optional progress callback function(parts_count, status_msg)
batch_size: Number of parts per batch (max 100 due to API limit)
Returns:
List of all parts
"""
all_parts = []
offset = 0
logger.info("Starting full jlcsearch parts database download...")
while True:
try:
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:
break
all_parts.extend(batch)
offset += len(batch)
if callback:
callback(len(all_parts), f"Downloaded {len(all_parts)} parts...")
else:
logger.info(f"Downloaded {len(all_parts)} parts so far...")
# Continue pagination - API returns exactly 100 results per page until exhausted
# Only stop when we get 0 results (handled above)
# Rate limiting - be nice to the API
time.sleep(0.1)
except Exception as e:
logger.error(f"Error downloading parts at offset {offset}: {e}")
if len(all_parts) > 0:
logger.warning(f"Partial download available: {len(all_parts)} parts")
return all_parts
else:
raise
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
return all_parts
def test_jlcsearch_connection() -> bool:
"""
Test JLCSearch API connection
Returns:
True if connection successful, False otherwise
"""
try:
client = JLCSearchClient()
# Test by searching for 1k resistors
results = client.search_resistors(resistance=1000, limit=5)
logger.info(f"JLCSearch API connection test successful - found {len(results)} resistors")
return True
except Exception as e:
logger.error(f"JLCSearch API connection test failed: {e}")
return False
if __name__ == "__main__":
# Test the JLCSearch client
logging.basicConfig(level=logging.INFO)
print("Testing JLCSearch API connection...")
if test_jlcsearch_connection():
print("✓ Connection successful!")
client = JLCSearchClient()
print("\nSearching for 1k 0603 resistors...")
resistors = client.search_resistors(resistance=1000, package="0603", limit=5)
print(f"✓ Found {len(resistors)} resistors")
if resistors:
print(f"\nExample resistor:")
r = resistors[0]
print(f" LCSC: C{r.get('lcsc')}")
print(f" MFR: {r.get('mfr')}")
print(f" Package: {r.get('package')}")
print(f" Resistance: {r.get('resistance')}Ω")
print(f" Tolerance: {r.get('tolerance_fraction', 0) * 100}%")
print(f" Power: {r.get('power_watts')}mW")
print(f" Stock: {r.get('stock')}")
print(f" Price: ${r.get('price1')}")
print(f" Basic Library: {'Yes' if r.get('is_basic') else 'No'}")
else:
print("✗ Connection failed")
"""
JLCSearch API client (public, no authentication required)
Alternative to official JLCPCB API using the community-maintained
jlcsearch service at https://jlcsearch.tscircuit.com/
"""
import logging
import time
from typing import Any, Callable, Dict, List, Optional, Union
import requests
logger = logging.getLogger("kicad_interface")
class JLCSearchClient:
"""
Client for JLCSearch public API (tscircuit)
Provides access to JLCPCB parts database without authentication
via the community-maintained jlcsearch service.
"""
BASE_URL = "https://jlcsearch.tscircuit.com"
def __init__(self) -> None:
"""Initialize JLCSearch API client"""
pass
def search_components(
self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict
) -> List[Dict]:
"""
Search components in JLCSearch database
Args:
category: Component category (e.g., "resistors", "capacitors", "components")
limit: Maximum number of results
offset: Offset for pagination
**filters: Additional filters (e.g., package="0603", resistance=1000)
Returns:
List of component dicts
"""
url = f"{self.BASE_URL}/{category}/list.json"
params = {"limit": limit, "offset": offset, **filters}
try:
response = requests.get(url, params=params, timeout=30)
response.raise_for_status()
data = response.json()
# The response has the category name as key
# e.g., {"resistors": [...]} or {"components": [...]}
for key, value in data.items():
if isinstance(value, list):
return value
return []
except requests.exceptions.RequestException as e:
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]:
"""
Search for resistors
Args:
resistance: Resistance value in ohms
package: Package type (e.g., "0603", "0805")
limit: Maximum results
Returns:
List of resistor dicts with fields:
- lcsc: LCSC number (integer)
- mfr: Manufacturer part number
- package: Package size
- is_basic: True if basic library part
- resistance: Resistance in ohms
- tolerance_fraction: Tolerance (0.01 = 1%)
- power_watts: Power rating in mW
- stock: Available stock
- price1: Price per unit
"""
filters: Dict[str, Any] = {}
if resistance is not None:
filters["resistance"] = resistance
if package:
filters["package"] = package
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]:
"""
Search for capacitors
Args:
capacitance: Capacitance value in farads
package: Package type
limit: Maximum results
Returns:
List of capacitor dicts
"""
filters: Dict[str, Any] = {}
if capacitance is not None:
filters["capacitance"] = capacitance
if package:
filters["package"] = package
return self.search_components("capacitors", limit=limit, **filters)
def get_part_by_lcsc(self, lcsc_number: int) -> Optional[Dict]:
"""
Get part details by LCSC number
Args:
lcsc_number: LCSC number (integer, without 'C' prefix)
Returns:
Part dict or None if not found
"""
# Search across all components filtering by LCSC
# Note: jlcsearch doesn't have a dedicated single-part endpoint
# so we search and filter
try:
results = self.search_components("components", limit=1, lcsc=lcsc_number)
return results[0] if results else None
except Exception as e:
logger.error(f"Failed to get part C{lcsc_number}: {e}")
return None
def download_all_components(
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
) -> List[Dict]:
"""
Download all components from jlcsearch database
Note: tscircuit API has a hard-coded 100 result limit per request.
Full catalog download requires ~25,000 paginated requests (~40-60 minutes).
Args:
callback: Optional progress callback function(parts_count, status_msg)
batch_size: Number of parts per batch (max 100 due to API limit)
Returns:
List of all parts
"""
all_parts = []
offset = 0
logger.info("Starting full jlcsearch parts database download...")
while True:
try:
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:
break
all_parts.extend(batch)
offset += len(batch)
if callback:
callback(len(all_parts), f"Downloaded {len(all_parts)} parts...")
else:
logger.info(f"Downloaded {len(all_parts)} parts so far...")
# Continue pagination - API returns exactly 100 results per page until exhausted
# Only stop when we get 0 results (handled above)
# Rate limiting - be nice to the API
time.sleep(0.1)
except Exception as e:
logger.error(f"Error downloading parts at offset {offset}: {e}")
if len(all_parts) > 0:
logger.warning(f"Partial download available: {len(all_parts)} parts")
return all_parts
else:
raise
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
return all_parts
def test_jlcsearch_connection() -> bool:
"""
Test JLCSearch API connection
Returns:
True if connection successful, False otherwise
"""
try:
client = JLCSearchClient()
# Test by searching for 1k resistors
results = client.search_resistors(resistance=1000, limit=5)
logger.info(f"JLCSearch API connection test successful - found {len(results)} resistors")
return True
except Exception as e:
logger.error(f"JLCSearch API connection test failed: {e}")
return False
if __name__ == "__main__":
# Test the JLCSearch client
logging.basicConfig(level=logging.INFO)
print("Testing JLCSearch API connection...")
if test_jlcsearch_connection():
print("✓ Connection successful!")
client = JLCSearchClient()
print("\nSearching for 1k 0603 resistors...")
resistors = client.search_resistors(resistance=1000, package="0603", limit=5)
print(f"✓ Found {len(resistors)} resistors")
if resistors:
print(f"\nExample resistor:")
r = resistors[0]
print(f" LCSC: C{r.get('lcsc')}")
print(f" MFR: {r.get('mfr')}")
print(f" Package: {r.get('package')}")
print(f" Resistance: {r.get('resistance')}Ω")
print(f" Tolerance: {r.get('tolerance_fraction', 0) * 100}%")
print(f" Power: {r.get('power_watts')}mW")
print(f" Stock: {r.get('stock')}")
print(f" Price: ${r.get('price1')}")
print(f" Basic Library: {'Yes' if r.get('is_basic') else 'No'}")
else:
print("✗ Connection failed")

File diff suppressed because it is too large Load Diff

View File

@@ -1,130 +1,130 @@
import glob
import logging
# Symbol class might not be directly importable in the current version
import os
from typing import Any, Dict, List, Optional
from skip import Schematic
logger = logging.getLogger(__name__)
class LibraryManager:
"""Manage symbol libraries"""
@staticmethod
def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]:
"""List all available symbol libraries"""
if search_paths is None:
# Default library paths based on common KiCAD installations
# This would need to be configured for the specific environment
search_paths = [
"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
]
libraries = []
for path_pattern in search_paths:
try:
# Use glob to find all matching files
matching_libs = glob.glob(path_pattern, recursive=True)
libraries.extend(matching_libs)
except Exception as e:
logger.error(f"Error searching for libraries at {path_pattern}: {e}")
# Extract library names from paths
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
logger.info(
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}
@staticmethod
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
"""Get detailed information about a symbol"""
try:
logger.warning(
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
)
return {}
except Exception as e:
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
return {}
@staticmethod
def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]:
"""Search for symbols matching criteria"""
try:
# This would typically involve:
# 1. Getting a list of all libraries using list_available_libraries
# 2. For each library, getting a list of all symbols
# 3. Filtering symbols based on the query
# For now, this is a placeholder implementation
libraries = LibraryManager.list_available_libraries(search_paths)
results = []
logger.warning(
f"Searched for symbols matching '{query}'. This requires advanced implementation."
)
return results
except Exception as e:
logger.error(f"Error searching for symbols matching '{query}': {e}")
return []
@staticmethod
def get_default_symbol_for_component_type(
component_type: str, search_paths: Optional[List[str]] = None
) -> Dict[str, str]:
"""Get a recommended default symbol for a given component type"""
# This method provides a simplified way to get a symbol for common component types
# It's useful when the user doesn't specify a particular library/symbol
# Define common mappings from component type to library/symbol
common_mappings = {
"resistor": {"library": "Device", "symbol": "R"},
"capacitor": {"library": "Device", "symbol": "C"},
"inductor": {"library": "Device", "symbol": "L"},
"diode": {"library": "Device", "symbol": "D"},
"led": {"library": "Device", "symbol": "LED"},
"transistor_npn": {"library": "Device", "symbol": "Q_NPN_BCE"},
"transistor_pnp": {"library": "Device", "symbol": "Q_PNP_BCE"},
"opamp": {"library": "Amplifier_Operational", "symbol": "OpAmp_Dual_Generic"},
"microcontroller": {"library": "MCU_Module", "symbol": "Arduino_UNO_R3"},
# Add more common components as needed
}
# Normalize input to lowercase
component_type_lower = component_type.lower()
# Try direct match first
if component_type_lower in common_mappings:
return common_mappings[component_type_lower]
# Try partial matches
for key, value in common_mappings.items():
if component_type_lower in key or key in component_type_lower:
return value
# Default fallback
return {"library": "Device", "symbol": "R"}
if __name__ == "__main__":
# Example Usage (for testing)
# List available libraries
libraries = LibraryManager.list_available_libraries()
# Get default symbol for a component type
resistor_sym = LibraryManager.get_default_symbol_for_component_type("resistor")
print(f"Default symbol for resistor: {resistor_sym['library']}/{resistor_sym['symbol']}")
# Try a partial match
cap_sym = LibraryManager.get_default_symbol_for_component_type("cap")
print(f"Default symbol for 'cap': {cap_sym['library']}/{cap_sym['symbol']}")
import glob
import logging
# Symbol class might not be directly importable in the current version
import os
from typing import Any, Dict, List, Optional
from skip import Schematic
logger = logging.getLogger(__name__)
class LibraryManager:
"""Manage symbol libraries"""
@staticmethod
def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]:
"""List all available symbol libraries"""
if search_paths is None:
# Default library paths based on common KiCAD installations
# This would need to be configured for the specific environment
search_paths = [
"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
]
libraries = []
for path_pattern in search_paths:
try:
# Use glob to find all matching files
matching_libs = glob.glob(path_pattern, recursive=True)
libraries.extend(matching_libs)
except Exception as e:
logger.error(f"Error searching for libraries at {path_pattern}: {e}")
# Extract library names from paths
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
logger.info(
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}
@staticmethod
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
"""Get detailed information about a symbol"""
try:
logger.warning(
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
)
return {}
except Exception as e:
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
return {}
@staticmethod
def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]:
"""Search for symbols matching criteria"""
try:
# This would typically involve:
# 1. Getting a list of all libraries using list_available_libraries
# 2. For each library, getting a list of all symbols
# 3. Filtering symbols based on the query
# For now, this is a placeholder implementation
libraries = LibraryManager.list_available_libraries(search_paths)
results = []
logger.warning(
f"Searched for symbols matching '{query}'. This requires advanced implementation."
)
return results
except Exception as e:
logger.error(f"Error searching for symbols matching '{query}': {e}")
return []
@staticmethod
def get_default_symbol_for_component_type(
component_type: str, search_paths: Optional[List[str]] = None
) -> Dict[str, str]:
"""Get a recommended default symbol for a given component type"""
# This method provides a simplified way to get a symbol for common component types
# It's useful when the user doesn't specify a particular library/symbol
# Define common mappings from component type to library/symbol
common_mappings = {
"resistor": {"library": "Device", "symbol": "R"},
"capacitor": {"library": "Device", "symbol": "C"},
"inductor": {"library": "Device", "symbol": "L"},
"diode": {"library": "Device", "symbol": "D"},
"led": {"library": "Device", "symbol": "LED"},
"transistor_npn": {"library": "Device", "symbol": "Q_NPN_BCE"},
"transistor_pnp": {"library": "Device", "symbol": "Q_PNP_BCE"},
"opamp": {"library": "Amplifier_Operational", "symbol": "OpAmp_Dual_Generic"},
"microcontroller": {"library": "MCU_Module", "symbol": "Arduino_UNO_R3"},
# Add more common components as needed
}
# Normalize input to lowercase
component_type_lower = component_type.lower()
# Try direct match first
if component_type_lower in common_mappings:
return common_mappings[component_type_lower]
# Try partial matches
for key, value in common_mappings.items():
if component_type_lower in key or key in component_type_lower:
return value
# Default fallback
return {"library": "Device", "symbol": "R"}
if __name__ == "__main__":
# Example Usage (for testing)
# List available libraries
libraries = LibraryManager.list_available_libraries()
# Get default symbol for a component type
resistor_sym = LibraryManager.get_default_symbol_for_component_type("resistor")
print(f"Default symbol for resistor: {resistor_sym['library']}/{resistor_sym['symbol']}")
# Try a partial match
cap_sym = LibraryManager.get_default_symbol_for_component_type("cap")
print(f"Default symbol for 'cap': {cap_sym['library']}/{cap_sym['symbol']}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,255 +1,255 @@
"""
Project-related command implementations for KiCAD interface
"""
import logging
import os
import shutil
from typing import Any, Dict, Optional
import pcbnew # type: ignore
logger = logging.getLogger("kicad_interface")
class ProjectCommands:
"""Handles project-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""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")
path = params.get("path", os.getcwd())
template = params.get("template")
# Generate the full project path
project_path = os.path.join(path, project_name)
if not project_path.endswith(".kicad_pro"):
project_path += ".kicad_pro"
# Create project directory if it doesn't exist
os.makedirs(os.path.dirname(project_path), exist_ok=True)
# Create a new board
board = pcbnew.BOARD()
# Set project properties
board.GetTitleBlock().SetTitle(project_name)
# Set current date with proper parameter
from datetime import datetime
current_date = datetime.now().strftime("%Y-%m-%d")
board.GetTitleBlock().SetDate(current_date)
# If template is specified, try to load it
if template:
template_path = os.path.expanduser(template)
if os.path.exists(template_path):
template_board = pcbnew.LoadBoard(template_path)
# Copy settings from template
board.SetDesignSettings(template_board.GetDesignSettings())
board.SetLayerStack(template_board.GetLayerStack())
# Save the board
board_path = project_path.replace(".kicad_pro", ".kicad_pcb")
board.SetFileName(board_path)
pcbnew.SaveBoard(board_path, board)
# Create schematic from template (use expanded template with symbol definitions)
schematic_path = project_path.replace(".kicad_pro", ".kicad_sch")
template_sch_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"templates",
"template_with_symbols_expanded.kicad_sch",
)
if os.path.exists(template_sch_path):
# Copy template schematic
shutil.copy(template_sch_path, schematic_path)
# Regenerate UUID to ensure uniqueness for each created project
import re
import uuid as uuid_module
with open(schematic_path, "r", encoding="utf-8") as f:
content = f.read()
new_uuid = str(uuid_module.uuid4())
content = re.sub(
r"\(uuid [0-9a-fA-F-]+\)",
f"(uuid {new_uuid})",
content,
count=1, # Only replace first (schematic) UUID
)
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
f.write(content)
logger.info(f"Created schematic from template: {schematic_path}")
else:
# Fallback: create minimal schematic
logger.warning(
f"Template not found at {template_sch_path}, creating minimal schematic"
)
import uuid as uuid_module
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(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")
f.write(' (sheet_instances\n (path "/" (page "1"))\n )\n')
f.write(")\n")
# Create project file with schematic reference
with open(project_path, "w") as f:
f.write("{\n")
f.write(' "board": {\n')
f.write(f' "filename": "{os.path.basename(board_path)}"\n')
f.write(" },\n")
f.write(' "sheets": [\n')
f.write(f' ["root", "{os.path.basename(schematic_path)}"]\n')
f.write(" ]\n")
f.write("}\n")
self.board = board
return {
"success": True,
"message": f"Created project: {project_name}",
"project": {
"name": project_name,
"path": project_path,
"boardPath": board_path,
"schematicPath": schematic_path,
},
}
except Exception as e:
logger.error(f"Error creating project: {str(e)}")
return {
"success": False,
"message": "Failed to create project",
"errorDetails": str(e),
}
def open_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Open an existing KiCAD project"""
try:
filename = params.get("filename")
if not filename:
return {
"success": False,
"message": "No filename provided",
"errorDetails": "The filename parameter is required",
}
# Expand user path and make absolute
filename = os.path.abspath(os.path.expanduser(filename))
# If it's a project file, get the board file
if filename.endswith(".kicad_pro"):
board_path = filename.replace(".kicad_pro", ".kicad_pcb")
else:
board_path = filename
# Load the board
board = pcbnew.LoadBoard(board_path)
self.board = board
return {
"success": True,
"message": f"Opened project: {os.path.basename(board_path)}",
"project": {
"name": os.path.splitext(os.path.basename(board_path))[0],
"path": filename,
"boardPath": board_path,
},
}
except Exception as e:
logger.error(f"Error opening project: {str(e)}")
return {
"success": False,
"message": "Failed to open project",
"errorDetails": str(e),
}
def save_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Save the current KiCAD project"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
filename = params.get("filename")
if filename:
# Save to new location
filename = os.path.abspath(os.path.expanduser(filename))
self.board.SetFileName(filename)
# Save the board
pcbnew.SaveBoard(self.board.GetFileName(), self.board)
return {
"success": True,
"message": f"Saved project to: {self.board.GetFileName()}",
"project": {
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
"path": self.board.GetFileName(),
},
}
except Exception as e:
logger.error(f"Error saving project: {str(e)}")
return {
"success": False,
"message": "Failed to save project",
"errorDetails": str(e),
}
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current project"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
title_block = self.board.GetTitleBlock()
filename = self.board.GetFileName()
return {
"success": True,
"project": {
"name": os.path.splitext(os.path.basename(filename))[0],
"path": filename,
"title": title_block.GetTitle(),
"date": title_block.GetDate(),
"revision": title_block.GetRevision(),
"company": title_block.GetCompany(),
"comment1": title_block.GetComment(0),
"comment2": title_block.GetComment(1),
"comment3": title_block.GetComment(2),
"comment4": title_block.GetComment(3),
},
}
except Exception as e:
logger.error(f"Error getting project info: {str(e)}")
return {
"success": False,
"message": "Failed to get project information",
"errorDetails": str(e),
}
"""
Project-related command implementations for KiCAD interface
"""
import logging
import os
import shutil
from typing import Any, Dict, Optional
import pcbnew # type: ignore
logger = logging.getLogger("kicad_interface")
class ProjectCommands:
"""Handles project-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""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")
path = params.get("path", os.getcwd())
template = params.get("template")
# Generate the full project path
project_path = os.path.join(path, project_name)
if not project_path.endswith(".kicad_pro"):
project_path += ".kicad_pro"
# Create project directory if it doesn't exist
os.makedirs(os.path.dirname(project_path), exist_ok=True)
# Create a new board
board = pcbnew.BOARD()
# Set project properties
board.GetTitleBlock().SetTitle(project_name)
# Set current date with proper parameter
from datetime import datetime
current_date = datetime.now().strftime("%Y-%m-%d")
board.GetTitleBlock().SetDate(current_date)
# If template is specified, try to load it
if template:
template_path = os.path.expanduser(template)
if os.path.exists(template_path):
template_board = pcbnew.LoadBoard(template_path)
# Copy settings from template
board.SetDesignSettings(template_board.GetDesignSettings())
board.SetLayerStack(template_board.GetLayerStack())
# Save the board
board_path = project_path.replace(".kicad_pro", ".kicad_pcb")
board.SetFileName(board_path)
pcbnew.SaveBoard(board_path, board)
# Create schematic from template (use expanded template with symbol definitions)
schematic_path = project_path.replace(".kicad_pro", ".kicad_sch")
template_sch_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"templates",
"template_with_symbols_expanded.kicad_sch",
)
if os.path.exists(template_sch_path):
# Copy template schematic
shutil.copy(template_sch_path, schematic_path)
# Regenerate UUID to ensure uniqueness for each created project
import re
import uuid as uuid_module
with open(schematic_path, "r", encoding="utf-8") as f:
content = f.read()
new_uuid = str(uuid_module.uuid4())
content = re.sub(
r"\(uuid [0-9a-fA-F-]+\)",
f"(uuid {new_uuid})",
content,
count=1, # Only replace first (schematic) UUID
)
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
f.write(content)
logger.info(f"Created schematic from template: {schematic_path}")
else:
# Fallback: create minimal schematic
logger.warning(
f"Template not found at {template_sch_path}, creating minimal schematic"
)
import uuid as uuid_module
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(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")
f.write(' (sheet_instances\n (path "/" (page "1"))\n )\n')
f.write(")\n")
# Create project file with schematic reference
with open(project_path, "w") as f:
f.write("{\n")
f.write(' "board": {\n')
f.write(f' "filename": "{os.path.basename(board_path)}"\n')
f.write(" },\n")
f.write(' "sheets": [\n')
f.write(f' ["root", "{os.path.basename(schematic_path)}"]\n')
f.write(" ]\n")
f.write("}\n")
self.board = board
return {
"success": True,
"message": f"Created project: {project_name}",
"project": {
"name": project_name,
"path": project_path,
"boardPath": board_path,
"schematicPath": schematic_path,
},
}
except Exception as e:
logger.error(f"Error creating project: {str(e)}")
return {
"success": False,
"message": "Failed to create project",
"errorDetails": str(e),
}
def open_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Open an existing KiCAD project"""
try:
filename = params.get("filename")
if not filename:
return {
"success": False,
"message": "No filename provided",
"errorDetails": "The filename parameter is required",
}
# Expand user path and make absolute
filename = os.path.abspath(os.path.expanduser(filename))
# If it's a project file, get the board file
if filename.endswith(".kicad_pro"):
board_path = filename.replace(".kicad_pro", ".kicad_pcb")
else:
board_path = filename
# Load the board
board = pcbnew.LoadBoard(board_path)
self.board = board
return {
"success": True,
"message": f"Opened project: {os.path.basename(board_path)}",
"project": {
"name": os.path.splitext(os.path.basename(board_path))[0],
"path": filename,
"boardPath": board_path,
},
}
except Exception as e:
logger.error(f"Error opening project: {str(e)}")
return {
"success": False,
"message": "Failed to open project",
"errorDetails": str(e),
}
def save_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Save the current KiCAD project"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
filename = params.get("filename")
if filename:
# Save to new location
filename = os.path.abspath(os.path.expanduser(filename))
self.board.SetFileName(filename)
# Save the board
pcbnew.SaveBoard(self.board.GetFileName(), self.board)
return {
"success": True,
"message": f"Saved project to: {self.board.GetFileName()}",
"project": {
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
"path": self.board.GetFileName(),
},
}
except Exception as e:
logger.error(f"Error saving project: {str(e)}")
return {
"success": False,
"message": "Failed to save project",
"errorDetails": str(e),
}
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current project"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
title_block = self.board.GetTitleBlock()
filename = self.board.GetFileName()
return {
"success": True,
"project": {
"name": os.path.splitext(os.path.basename(filename))[0],
"path": filename,
"title": title_block.GetTitle(),
"date": title_block.GetDate(),
"revision": title_block.GetRevision(),
"company": title_block.GetCompany(),
"comment1": title_block.GetComment(0),
"comment2": title_block.GetComment(1),
"comment3": title_block.GetComment(2),
"comment4": title_block.GetComment(3),
},
}
except Exception as e:
logger.error(f"Error getting project info: {str(e)}")
return {
"success": False,
"message": "Failed to get project information",
"errorDetails": str(e),
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,494 +1,494 @@
"""
Wire Connectivity Analysis for KiCad Schematics
Traces wire networks from a point and finds connected component pins.
Uses KiCad's internal integer unit system (10,000 IU per mm) for exact
coordinate matching, mirroring KiCad's own connectivity algorithm.
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from commands.pin_locator import PinLocator
logger = logging.getLogger("kicad_interface")
_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter
def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
"""Convert mm coordinates to KiCad internal units (integer)."""
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
"""Extract wire endpoints from a schematic object as IU tuples."""
all_wires = []
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
pts = []
for point in wire.pts.xy:
if hasattr(point, "value"):
pts.append(_to_iu(float(point.value[0]), float(point.value[1])))
if len(pts) >= 2:
all_wires.append(pts)
return all_wires
def _build_adjacency(
all_wires: List[List[Tuple[int, int]]],
) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]:
"""Build wire adjacency using exact IU coordinate matching.
Wires that share an endpoint are adjacent — this naturally handles
junctions since all wires meeting at the same point get connected.
Returns a tuple of:
- adjacency: list of sets, one per wire, containing adjacent wire indices
- iu_to_wires: dict mapping each IU endpoint to the set of wire indices
that have an endpoint at that exact coordinate (used for seed queries)
"""
# Map each IU endpoint to all wire indices that touch it
iu_to_wires: Dict[Tuple[int, int], Set[int]] = {}
for i, pts in enumerate(all_wires):
for pt in pts:
iu_to_wires.setdefault(pt, set()).add(i)
# Wires that share an IU endpoint are adjacent
adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))]
for wire_set in iu_to_wires.values():
wire_list = list(wire_set)
for a in wire_list:
for b in wire_list:
if a != b:
adjacency[a].add(b)
return adjacency, iu_to_wires
def _parse_virtual_connections(
schematic: Any, schematic_path: Any
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
"""Return virtual connectivity from net labels and power symbols.
Returns a tuple of:
- point_to_label: Dict[Tuple[int,int], str] — IU position → label name
- label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions
"""
point_to_label: Dict[Tuple[int, int], str] = {}
label_to_points: Dict[str, List[Tuple[int, int]]] = {}
if hasattr(schematic, "label"):
for label in schematic.label:
try:
if not hasattr(label, "value"):
continue
name = label.value
if not hasattr(label, "at") or not hasattr(label.at, "value"):
continue
coords = label.at.value
pt = _to_iu(float(coords[0]), float(coords[1]))
point_to_label[pt] = name
label_to_points.setdefault(name, []).append(pt)
except Exception as e:
logger.warning(f"Error parsing net label: {e}")
if hasattr(schematic, "symbol"):
locator = PinLocator()
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if not ref.startswith("#PWR"):
continue
if ref.startswith("_TEMPLATE"):
continue
if not hasattr(symbol.property, "Value"):
continue
name = symbol.property.Value.value
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins or "1" not in all_pins:
continue
pin_data = all_pins["1"]
pt = _to_iu(float(pin_data[0]), float(pin_data[1]))
point_to_label[pt] = name
label_to_points.setdefault(name, []).append(pt)
except Exception as e:
logger.warning(f"Error parsing power symbol: {e}")
return point_to_label, label_to_points
def _find_connected_wires(
x_mm: float,
y_mm: float,
all_wires: List[List[Tuple[int, int]]],
iu_to_wires: Dict[Tuple[int, int], Set[int]],
adjacency: List[Set[int]],
point_to_label: Optional[Dict[Tuple[int, int], str]] = None,
label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None,
) -> Tuple:
"""BFS from query point. Returns (visited wire indices, net IU points) or (None, None).
Requires query point (x_mm, y_mm) to be exactly on a wire endpoint (exact IU match).
"""
query_iu = _to_iu(x_mm, y_mm)
# Find seed wires: exact IU match on the query endpoint
seed_set = iu_to_wires.get(query_iu)
if not seed_set:
return (None, None)
seed_indices: Set[int] = set(seed_set)
# BFS flood-fill using pre-compiled adjacency
visited: Set[int] = set(seed_indices)
queue = list(seed_indices)
net_points: Set[Tuple[int, int]] = set()
for i in seed_indices:
net_points.update(all_wires[i])
seen_labels: Set[str] = set()
while queue:
wire_idx = queue.pop()
for neighbor_idx in adjacency[wire_idx]:
if neighbor_idx not in visited:
visited.add(neighbor_idx)
queue.append(neighbor_idx)
net_points.update(all_wires[neighbor_idx])
if point_to_label and label_to_points:
for pt in all_wires[wire_idx]:
label_name = point_to_label.get(pt)
if label_name and label_name not in seen_labels:
seen_labels.add(label_name)
for other_pt in label_to_points.get(label_name, []):
if other_pt == pt:
continue
for idx in iu_to_wires.get(other_pt, set()):
if idx not in visited:
visited.add(idx)
queue.append(idx)
net_points.update(all_wires[idx])
return (visited, net_points)
def _find_pins_on_net(
net_points: Set[Tuple[int, int]],
schematic_path: Any,
schematic: Any,
) -> List[Dict]:
"""Find component pins that land on net points using exact IU matching.
Returns a list of {"component": ref, "pin": pin_num} dicts.
"""
def _on_net(px_mm: float, py_mm: float) -> bool:
return _to_iu(px_mm, py_mm) in net_points
locator = PinLocator()
pins = []
seen: Set[Tuple] = set()
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
if _on_net(pin_data[0], pin_data[1]):
key = (ref, pin_num)
if key not in seen:
seen.add(key)
pins.append({"component": ref, "pin": pin_num})
except Exception as e:
logger.warning(
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
)
return pins
def get_wire_connections(
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Optional[Dict]:
"""Find the net name and all component pins reachable from a point via connected wires.
The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match).
Interior (mid-segment) points are not matched —
use wire endpoint coordinates obtained from the schematic data.
Net labels and power symbols are traversed: wires on the same named net are
treated as connected even when they are not geometrically adjacent.
Returns dict with keys:
- "net": str or None (net label/power name, None if unnamed)
- "pins": list of {"component": str, "pin": str}
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
- "query_point": {"x": float, "y": float}
Or None if no wire endpoint found within tolerance of the query point.
"""
all_wires = _parse_wires(schematic)
query_point = {"x": x_mm, "y": y_mm}
if not all_wires:
return {"net": None, "pins": [], "wires": [], "query_point": query_point}
adjacency, iu_to_wires = _build_adjacency(all_wires)
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
if visited is None:
return None
# Resolve net name: first label anchor that falls on this net's IU points
net: Optional[str] = None
for pt in net_points:
label = point_to_label.get(pt)
if label is not None:
net = label
break
wires_out = [
{
"start": {
"x": all_wires[i][0][0] / _IU_PER_MM,
"y": all_wires[i][0][1] / _IU_PER_MM,
},
"end": {
"x": all_wires[i][-1][0] / _IU_PER_MM,
"y": all_wires[i][-1][1] / _IU_PER_MM,
},
}
for i in visited
]
if not hasattr(schematic, "symbol"):
return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
pins = _find_pins_on_net(net_points, schematic_path, schematic)
return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
def count_pins_on_net(
schematic: Any,
schematic_path: str,
net_name: str,
all_wires: List[List[Tuple[int, int]]],
iu_to_wires: Dict[Tuple[int, int], Set[int]],
adjacency: List[Set[int]],
point_to_label: Dict[Tuple[int, int], str],
label_to_points: Dict[str, List[Tuple[int, int]]],
) -> int:
"""Count the number of component pins connected to the named net.
A pin is counted if its IU coordinate falls on the wire-network reachable
from any label anchor for *net_name*, or directly on a label anchor of that
net (pin directly touching a label with no intervening wire).
Returns the count of distinct (component, pin_num) pairs on this net.
"""
label_positions = label_to_points.get(net_name, [])
if not label_positions:
return 0
# Collect the union of all net-points across all label positions for this net
all_net_points: Set[Tuple[int, int]] = set()
for lx, ly in label_positions:
# Include the label anchor itself so pins directly at the label count
all_net_points.add((lx, ly))
# Trace from this label position into the wire graph
x_mm = lx / _IU_PER_MM
y_mm = ly / _IU_PER_MM
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
if net_points:
all_net_points |= net_points
if not hasattr(schematic, "symbol"):
return 0
locator = PinLocator()
seen: Set[Tuple[str, str]] = set()
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1]))
if pin_iu in all_net_points:
key = (ref, pin_num)
if key not in seen:
seen.add(key)
except Exception as e:
logger.warning(
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
)
return len(seen)
def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]:
"""Return net labels that are not connected to any component pin.
A label is "floating" when no component pin's IU coordinate falls on the
wire-network reachable from the label's anchor position. These labels are
likely placed off-grid or incorrectly positioned and will cause ERC errors.
Returns a list of dicts with keys:
- "name": str — the net label text
- "x": float — label X position in mm
- "y": float — label Y position in mm
- "type": str — "label" or "global_label"
"""
all_wires = _parse_wires(schematic)
if all_wires:
adjacency, iu_to_wires = _build_adjacency(all_wires)
else:
adjacency = []
iu_to_wires = {}
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
# Build a set of all pin IU positions for fast lookup
pin_iu_set: Set[Tuple[int, int]] = set()
if hasattr(schematic, "symbol"):
locator = PinLocator()
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_data in all_pins.values():
pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1])))
except Exception as e:
logger.warning(f"Error reading pins for floating-label check: {e}")
floating: List[Dict[str, Any]] = []
if not hasattr(schematic, "label"):
return floating
for label in schematic.label:
try:
if not hasattr(label, "value"):
continue
name = label.value
if not hasattr(label, "at") or not hasattr(label.at, "value"):
continue
coords = label.at.value
lx_mm = float(coords[0])
ly_mm = float(coords[1])
label_iu = _to_iu(lx_mm, ly_mm)
# Check if the label anchor itself is a pin position
if label_iu in pin_iu_set:
continue
# Trace the wire-network from this label and check for pins
if all_wires:
_, net_points = _find_connected_wires(
lx_mm,
ly_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
else:
net_points = None
if net_points is not None and net_points & pin_iu_set:
continue # at least one pin on this net
floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"})
except Exception as e:
logger.warning(f"Error checking label for floating status: {e}")
return floating
def get_net_at_point(
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Dict[str, Any]:
"""Return the net name at the given coordinate, or null if none found.
Checks net label positions first (exact IU match within tolerance), then
wire endpoints. Returns a dict with keys:
- "net_name": str or None
- "position": {"x": float, "y": float}
- "source": "net_label" | "wire_endpoint" | None
"""
query_iu = _to_iu(x_mm, y_mm)
position = {"x": x_mm, "y": y_mm}
# Build label map from schematic
point_to_label, _ = _parse_virtual_connections(schematic, schematic_path)
# Check if query point is exactly on a net label / power symbol position
label_name = point_to_label.get(query_iu)
if label_name is not None:
return {"net_name": label_name, "position": position, "source": "net_label"}
# Check if query point is on a wire endpoint
all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else []
if all_wires:
adjacency, iu_to_wires = _build_adjacency(all_wires)
if query_iu in iu_to_wires:
# Found a wire endpoint — trace the net to get the name
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=None,
)
if visited is not None:
net: Optional[str] = None
if net_points:
for pt in net_points:
net = point_to_label.get(pt)
if net is not None:
break
return {"net_name": net, "position": position, "source": "wire_endpoint"}
return {"net_name": None, "position": position, "source": None}
"""
Wire Connectivity Analysis for KiCad Schematics
Traces wire networks from a point and finds connected component pins.
Uses KiCad's internal integer unit system (10,000 IU per mm) for exact
coordinate matching, mirroring KiCad's own connectivity algorithm.
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from commands.pin_locator import PinLocator
logger = logging.getLogger("kicad_interface")
_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter
def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
"""Convert mm coordinates to KiCad internal units (integer)."""
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
"""Extract wire endpoints from a schematic object as IU tuples."""
all_wires = []
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
pts = []
for point in wire.pts.xy:
if hasattr(point, "value"):
pts.append(_to_iu(float(point.value[0]), float(point.value[1])))
if len(pts) >= 2:
all_wires.append(pts)
return all_wires
def _build_adjacency(
all_wires: List[List[Tuple[int, int]]],
) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]:
"""Build wire adjacency using exact IU coordinate matching.
Wires that share an endpoint are adjacent — this naturally handles
junctions since all wires meeting at the same point get connected.
Returns a tuple of:
- adjacency: list of sets, one per wire, containing adjacent wire indices
- iu_to_wires: dict mapping each IU endpoint to the set of wire indices
that have an endpoint at that exact coordinate (used for seed queries)
"""
# Map each IU endpoint to all wire indices that touch it
iu_to_wires: Dict[Tuple[int, int], Set[int]] = {}
for i, pts in enumerate(all_wires):
for pt in pts:
iu_to_wires.setdefault(pt, set()).add(i)
# Wires that share an IU endpoint are adjacent
adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))]
for wire_set in iu_to_wires.values():
wire_list = list(wire_set)
for a in wire_list:
for b in wire_list:
if a != b:
adjacency[a].add(b)
return adjacency, iu_to_wires
def _parse_virtual_connections(
schematic: Any, schematic_path: Any
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
"""Return virtual connectivity from net labels and power symbols.
Returns a tuple of:
- point_to_label: Dict[Tuple[int,int], str] — IU position → label name
- label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions
"""
point_to_label: Dict[Tuple[int, int], str] = {}
label_to_points: Dict[str, List[Tuple[int, int]]] = {}
if hasattr(schematic, "label"):
for label in schematic.label:
try:
if not hasattr(label, "value"):
continue
name = label.value
if not hasattr(label, "at") or not hasattr(label.at, "value"):
continue
coords = label.at.value
pt = _to_iu(float(coords[0]), float(coords[1]))
point_to_label[pt] = name
label_to_points.setdefault(name, []).append(pt)
except Exception as e:
logger.warning(f"Error parsing net label: {e}")
if hasattr(schematic, "symbol"):
locator = PinLocator()
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if not ref.startswith("#PWR"):
continue
if ref.startswith("_TEMPLATE"):
continue
if not hasattr(symbol.property, "Value"):
continue
name = symbol.property.Value.value
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins or "1" not in all_pins:
continue
pin_data = all_pins["1"]
pt = _to_iu(float(pin_data[0]), float(pin_data[1]))
point_to_label[pt] = name
label_to_points.setdefault(name, []).append(pt)
except Exception as e:
logger.warning(f"Error parsing power symbol: {e}")
return point_to_label, label_to_points
def _find_connected_wires(
x_mm: float,
y_mm: float,
all_wires: List[List[Tuple[int, int]]],
iu_to_wires: Dict[Tuple[int, int], Set[int]],
adjacency: List[Set[int]],
point_to_label: Optional[Dict[Tuple[int, int], str]] = None,
label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None,
) -> Tuple:
"""BFS from query point. Returns (visited wire indices, net IU points) or (None, None).
Requires query point (x_mm, y_mm) to be exactly on a wire endpoint (exact IU match).
"""
query_iu = _to_iu(x_mm, y_mm)
# Find seed wires: exact IU match on the query endpoint
seed_set = iu_to_wires.get(query_iu)
if not seed_set:
return (None, None)
seed_indices: Set[int] = set(seed_set)
# BFS flood-fill using pre-compiled adjacency
visited: Set[int] = set(seed_indices)
queue = list(seed_indices)
net_points: Set[Tuple[int, int]] = set()
for i in seed_indices:
net_points.update(all_wires[i])
seen_labels: Set[str] = set()
while queue:
wire_idx = queue.pop()
for neighbor_idx in adjacency[wire_idx]:
if neighbor_idx not in visited:
visited.add(neighbor_idx)
queue.append(neighbor_idx)
net_points.update(all_wires[neighbor_idx])
if point_to_label and label_to_points:
for pt in all_wires[wire_idx]:
label_name = point_to_label.get(pt)
if label_name and label_name not in seen_labels:
seen_labels.add(label_name)
for other_pt in label_to_points.get(label_name, []):
if other_pt == pt:
continue
for idx in iu_to_wires.get(other_pt, set()):
if idx not in visited:
visited.add(idx)
queue.append(idx)
net_points.update(all_wires[idx])
return (visited, net_points)
def _find_pins_on_net(
net_points: Set[Tuple[int, int]],
schematic_path: Any,
schematic: Any,
) -> List[Dict]:
"""Find component pins that land on net points using exact IU matching.
Returns a list of {"component": ref, "pin": pin_num} dicts.
"""
def _on_net(px_mm: float, py_mm: float) -> bool:
return _to_iu(px_mm, py_mm) in net_points
locator = PinLocator()
pins = []
seen: Set[Tuple] = set()
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
if _on_net(pin_data[0], pin_data[1]):
key = (ref, pin_num)
if key not in seen:
seen.add(key)
pins.append({"component": ref, "pin": pin_num})
except Exception as e:
logger.warning(
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
)
return pins
def get_wire_connections(
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Optional[Dict]:
"""Find the net name and all component pins reachable from a point via connected wires.
The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match).
Interior (mid-segment) points are not matched —
use wire endpoint coordinates obtained from the schematic data.
Net labels and power symbols are traversed: wires on the same named net are
treated as connected even when they are not geometrically adjacent.
Returns dict with keys:
- "net": str or None (net label/power name, None if unnamed)
- "pins": list of {"component": str, "pin": str}
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
- "query_point": {"x": float, "y": float}
Or None if no wire endpoint found within tolerance of the query point.
"""
all_wires = _parse_wires(schematic)
query_point = {"x": x_mm, "y": y_mm}
if not all_wires:
return {"net": None, "pins": [], "wires": [], "query_point": query_point}
adjacency, iu_to_wires = _build_adjacency(all_wires)
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
if visited is None:
return None
# Resolve net name: first label anchor that falls on this net's IU points
net: Optional[str] = None
for pt in net_points:
label = point_to_label.get(pt)
if label is not None:
net = label
break
wires_out = [
{
"start": {
"x": all_wires[i][0][0] / _IU_PER_MM,
"y": all_wires[i][0][1] / _IU_PER_MM,
},
"end": {
"x": all_wires[i][-1][0] / _IU_PER_MM,
"y": all_wires[i][-1][1] / _IU_PER_MM,
},
}
for i in visited
]
if not hasattr(schematic, "symbol"):
return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
pins = _find_pins_on_net(net_points, schematic_path, schematic)
return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
def count_pins_on_net(
schematic: Any,
schematic_path: str,
net_name: str,
all_wires: List[List[Tuple[int, int]]],
iu_to_wires: Dict[Tuple[int, int], Set[int]],
adjacency: List[Set[int]],
point_to_label: Dict[Tuple[int, int], str],
label_to_points: Dict[str, List[Tuple[int, int]]],
) -> int:
"""Count the number of component pins connected to the named net.
A pin is counted if its IU coordinate falls on the wire-network reachable
from any label anchor for *net_name*, or directly on a label anchor of that
net (pin directly touching a label with no intervening wire).
Returns the count of distinct (component, pin_num) pairs on this net.
"""
label_positions = label_to_points.get(net_name, [])
if not label_positions:
return 0
# Collect the union of all net-points across all label positions for this net
all_net_points: Set[Tuple[int, int]] = set()
for lx, ly in label_positions:
# Include the label anchor itself so pins directly at the label count
all_net_points.add((lx, ly))
# Trace from this label position into the wire graph
x_mm = lx / _IU_PER_MM
y_mm = ly / _IU_PER_MM
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
if net_points:
all_net_points |= net_points
if not hasattr(schematic, "symbol"):
return 0
locator = PinLocator()
seen: Set[Tuple[str, str]] = set()
ref = None
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_num, pin_data in all_pins.items():
pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1]))
if pin_iu in all_net_points:
key = (ref, pin_num)
if key not in seen:
seen.add(key)
except Exception as e:
logger.warning(
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
)
return len(seen)
def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]:
"""Return net labels that are not connected to any component pin.
A label is "floating" when no component pin's IU coordinate falls on the
wire-network reachable from the label's anchor position. These labels are
likely placed off-grid or incorrectly positioned and will cause ERC errors.
Returns a list of dicts with keys:
- "name": str — the net label text
- "x": float — label X position in mm
- "y": float — label Y position in mm
- "type": str — "label" or "global_label"
"""
all_wires = _parse_wires(schematic)
if all_wires:
adjacency, iu_to_wires = _build_adjacency(all_wires)
else:
adjacency = []
iu_to_wires = {}
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
# Build a set of all pin IU positions for fast lookup
pin_iu_set: Set[Tuple[int, int]] = set()
if hasattr(schematic, "symbol"):
locator = PinLocator()
for symbol in schematic.symbol:
try:
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
continue
ref = symbol.property.Reference.value
if ref.startswith("_TEMPLATE"):
continue
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
if not all_pins:
continue
for pin_data in all_pins.values():
pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1])))
except Exception as e:
logger.warning(f"Error reading pins for floating-label check: {e}")
floating: List[Dict[str, Any]] = []
if not hasattr(schematic, "label"):
return floating
for label in schematic.label:
try:
if not hasattr(label, "value"):
continue
name = label.value
if not hasattr(label, "at") or not hasattr(label.at, "value"):
continue
coords = label.at.value
lx_mm = float(coords[0])
ly_mm = float(coords[1])
label_iu = _to_iu(lx_mm, ly_mm)
# Check if the label anchor itself is a pin position
if label_iu in pin_iu_set:
continue
# Trace the wire-network from this label and check for pins
if all_wires:
_, net_points = _find_connected_wires(
lx_mm,
ly_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=label_to_points,
)
else:
net_points = None
if net_points is not None and net_points & pin_iu_set:
continue # at least one pin on this net
floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"})
except Exception as e:
logger.warning(f"Error checking label for floating status: {e}")
return floating
def get_net_at_point(
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
) -> Dict[str, Any]:
"""Return the net name at the given coordinate, or null if none found.
Checks net label positions first (exact IU match within tolerance), then
wire endpoints. Returns a dict with keys:
- "net_name": str or None
- "position": {"x": float, "y": float}
- "source": "net_label" | "wire_endpoint" | None
"""
query_iu = _to_iu(x_mm, y_mm)
position = {"x": x_mm, "y": y_mm}
# Build label map from schematic
point_to_label, _ = _parse_virtual_connections(schematic, schematic_path)
# Check if query point is exactly on a net label / power symbol position
label_name = point_to_label.get(query_iu)
if label_name is not None:
return {"net_name": label_name, "position": position, "source": "net_label"}
# Check if query point is on a wire endpoint
all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else []
if all_wires:
adjacency, iu_to_wires = _build_adjacency(all_wires)
if query_iu in iu_to_wires:
# Found a wire endpoint — trace the net to get the name
visited, net_points = _find_connected_wires(
x_mm,
y_mm,
all_wires,
iu_to_wires,
adjacency,
point_to_label=point_to_label,
label_to_points=None,
)
if visited is not None:
net: Optional[str] = None
if net_points:
for pt in net_points:
net = point_to_label.get(pt)
if net is not None:
break
return {"net_name": net, "position": position, "source": "wire_endpoint"}
return {"net_name": None, "position": position, "source": None}

View File

@@ -1,439 +1,439 @@
"""
WireDragger — drag connected wires when a schematic component is moved.
All methods operate on in-memory sexpdata lists (no disk I/O).
"""
import logging
import math
import uuid
from typing import Any, Dict, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
# Module-level Symbol constants
_K = {
name: Symbol(name)
for name in [
"symbol",
"at",
"lib_id",
"mirror",
"lib_symbols",
"pts",
"xy",
"wire",
"junction",
"property",
"stroke",
"width",
"type",
"uuid",
]
}
EPS = 1e-4 # mm — coordinate match tolerance
def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]:
"""Rotate (x, y) around the origin by angle_deg degrees (CCW)."""
if angle_deg == 0:
return x, y
rad = math.radians(angle_deg)
c, s = math.cos(rad), math.sin(rad)
return x * c - y * s, x * s + y * c
def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool:
return abs(ax - bx) < eps and abs(ay - by) < eps
class WireDragger:
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
@staticmethod
def find_symbol(sch_data: list, reference: str) -> Any:
"""
Find a placed symbol by reference designator.
Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y)
or None if the reference is not found.
mirror_x=True means the symbol has (mirror x) — flips the X local axis.
mirror_y=True means the symbol has (mirror y) — flips the Y local axis.
"""
sym_k = _K["symbol"]
prop_k = _K["property"]
at_k = _K["at"]
lib_id_k = _K["lib_id"]
mirror_k = _K["mirror"]
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue
# Check Reference property
ref_val = None
for sub in item[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
if str(sub[1]).strip('"') == "Reference":
ref_val = str(sub[2]).strip('"')
break
if ref_val != reference:
continue
old_x = old_y = rotation = 0.0
lib_id = ""
mirror_x = mirror_y = False
for sub in item[1:]:
if not isinstance(sub, list) or not sub:
continue
tag = sub[0]
if tag == at_k:
if len(sub) >= 3:
old_x = float(sub[1])
old_y = float(sub[2])
if len(sub) >= 4:
rotation = float(sub[3])
elif tag == lib_id_k and len(sub) >= 2:
lib_id = str(sub[1]).strip('"')
elif tag == mirror_k and len(sub) >= 2:
mv = str(sub[1])
if mv == "x":
mirror_x = True
elif mv == "y":
mirror_y = True
return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y
return None
@staticmethod
def get_pin_defs(sch_data: list, lib_id: str) -> Dict:
"""
Get pin definitions from lib_symbols for the given lib_id.
Returns the same dict format as PinLocator.parse_symbol_definition:
{pin_num: {"x": ..., "y": ..., ...}}.
"""
from commands.pin_locator import PinLocator
lib_sym_k = _K["lib_symbols"]
symbol_k = _K["symbol"]
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == lib_sym_k):
continue
for sym_def in item[1:]:
if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k):
continue
if len(sym_def) < 2:
continue
name = str(sym_def[1]).strip('"')
if name == lib_id:
return PinLocator.parse_symbol_definition(sym_def)
break # only one lib_symbols section
return {}
@staticmethod
def pin_world_xy(
px: float,
py: float,
sym_x: float,
sym_y: float,
rotation: float,
mirror_x: bool,
mirror_y: bool,
) -> Tuple[float, float]:
"""
Compute the world coordinate of a pin given the symbol transform.
KiCAD applies mirror first (in local space), then rotation, then translation.
mirror_x negates the local X axis; mirror_y negates the local Y axis.
"""
lx, ly = px, py
if mirror_x:
lx = -lx
if mirror_y:
ly = -ly
rx, ry = _rotate(lx, ly, rotation)
return sym_x + rx, sym_y + ry
@staticmethod
def compute_pin_positions(
sch_data: list,
reference: str,
new_x: float,
new_y: float,
) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]:
"""
Compute world pin positions before and after a component move.
Returns {pin_num: (old_world_xy, new_world_xy)}.
old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y).
"""
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return {}
_, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found
pins = WireDragger.get_pin_defs(sch_data, lib_id)
result: Dict[str, Tuple] = {}
for pin_num, pin in pins.items():
px, py = pin["x"], pin["y"]
old_wx, old_wy = WireDragger.pin_world_xy(
px, py, old_x, old_y, rotation, mirror_x, mirror_y
)
new_wx, new_wy = WireDragger.pin_world_xy(
px, py, new_x, new_y, rotation, mirror_x, mirror_y
)
result[pin_num] = (
(round(old_wx, 6), round(old_wy, 6)),
(round(new_wx, 6), round(new_wy, 6)),
)
return result
@staticmethod
def drag_wires(
sch_data: list,
old_to_new: Dict[Tuple[float, float], Tuple[float, float]],
eps: float = EPS,
) -> Dict:
"""
Move wire endpoints and junctions from old positions to new positions.
Removes zero-length wires that result from the move.
Modifies sch_data in place.
old_to_new: {(old_x, old_y): (new_x, new_y)}
Returns {'endpoints_moved': N, 'wires_removed': M}.
"""
wire_k = _K["wire"]
pts_k = _K["pts"]
xy_k = _K["xy"]
junction_k = _K["junction"]
at_k = _K["at"]
def find_new(x: float, y: float) -> Optional[Tuple[float, float]]:
for (ox, oy), (nx, ny) in old_to_new.items():
if _coords_match(x, y, ox, oy, eps):
return nx, ny
return None
endpoints_moved = 0
zero_length_indices = []
# First pass: update wire endpoints
for idx, item in enumerate(sch_data):
if not (isinstance(item, list) and item and item[0] == wire_k):
continue
pts_sub = None
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == pts_k:
pts_sub = sub
break
if pts_sub is None:
continue
xy_items = [
p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k
]
for xy_item in xy_items:
nc = find_new(float(xy_item[1]), float(xy_item[2]))
if nc is not None:
xy_item[1] = nc[0]
xy_item[2] = nc[1]
endpoints_moved += 1
# Check if this wire is now zero-length
if len(xy_items) >= 2:
x1, y1 = float(xy_items[0][1]), float(xy_items[0][2])
x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2])
if _coords_match(x1, y1, x2, y2, eps):
zero_length_indices.append(idx)
# Remove zero-length wires (backwards to preserve indices)
for idx in reversed(zero_length_indices):
del sch_data[idx]
# Second pass: update junctions
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == junction_k):
continue
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
nc = find_new(float(sub[1]), float(sub[2]))
if nc is not None:
sub[1] = nc[0]
sub[2] = nc[1]
break
return {
"endpoints_moved": endpoints_moved,
"wires_removed": len(zero_length_indices),
}
@staticmethod
def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool:
"""
Update the (at x y rot) of the named symbol in sch_data.
Returns True if the symbol was found and updated.
"""
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return False
item = found[0]
at_k = _K["at"]
prop_k = _K["property"]
# Find current position and compute delta
old_x = old_y = None
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
old_x, old_y = sub[1], sub[2]
sub[1] = new_x
sub[2] = new_y
break
if old_x is None or old_y is None:
return False
dx = new_x - old_x
dy = new_y - old_y
# Shift all property label positions by the same delta
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == prop_k:
for psub in sub[1:]:
if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3:
psub[1] += dx
psub[2] += dy
break
return True
@staticmethod
def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list:
"""Build a wire s-expression list in KiCAD schematic format."""
wire_uuid = str(uuid.uuid4())
return [
_K["wire"],
[_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]],
[_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]],
[_K["uuid"], wire_uuid],
]
@staticmethod
def get_all_stationary_pin_positions(
sch_data: list,
moved_reference: str,
) -> Dict[Tuple[float, float], str]:
"""
Return a map of {world_xy: reference} for every pin of every symbol
in sch_data *except* moved_reference.
This is used to detect pins of stationary components that coincide
with pins of the moved component (touching-pin connections).
"""
sym_k = _K["symbol"]
prop_k = _K["property"]
result: Dict[Tuple[float, float], str] = {}
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue
# Determine reference
ref_val = None
for sub in item[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
if str(sub[1]).strip('"') == "Reference":
ref_val = str(sub[2]).strip('"')
break
if ref_val is None or ref_val == moved_reference:
continue
# Skip template / power symbols whose references start with special chars
# but we still want to handle them — no filtering needed here.
# Find lib_id and position for this symbol
found = WireDragger.find_symbol(sch_data, ref_val)
if found is None:
continue
_, sx, sy, rotation, lib_id, mirror_x, mirror_y = found
pins = WireDragger.get_pin_defs(sch_data, lib_id)
for pin_num, pin in pins.items():
wx, wy = WireDragger.pin_world_xy(
pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y
)
key = (round(wx, 6), round(wy, 6))
result[key] = ref_val
return result
@staticmethod
def synthesize_touching_pin_wires(
sch_data: list,
moved_reference: str,
pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]],
eps: float = EPS,
) -> int:
"""
Detect touching-pin connections and synthesize wire segments to bridge gaps
created by moving a component.
For each pin of *moved_reference* whose old world position coincides with
a pin of a stationary component:
- If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy.
- If the pin now lands on another stationary pin's position, skip (they touch again).
- If old_xy == new_xy, do nothing (no gap was created).
Modifies sch_data in place.
Returns the number of wire segments synthesized.
"""
if not pin_positions:
return 0
stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference)
if not stationary_pins:
return 0
synthesized = 0
for pin_num, (old_xy, new_xy) in pin_positions.items():
# Check if a stationary pin touches this pin's old position
touching = any(
_coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
)
if not touching:
continue
# The pin has moved — check if it actually separated
if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps):
# Pin didn't actually move; no gap
continue
# Check if the pin's new position happens to touch another stationary pin
# (component moved into a different touching position — no wire needed)
rejoining = any(
_coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
)
if rejoining:
logger.debug(
f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} "
f"and rejoins another stationary pin; no wire synthesized"
)
continue
logger.info(
f"Synthesizing wire for touching-pin connection: "
f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}"
)
wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1])
# Insert before the last item (sheet_instances) to keep file tidy,
# but appending is also valid — just append.
sch_data.append(wire)
synthesized += 1
return synthesized
"""
WireDragger — drag connected wires when a schematic component is moved.
All methods operate on in-memory sexpdata lists (no disk I/O).
"""
import logging
import math
import uuid
from typing import Any, Dict, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
# Module-level Symbol constants
_K = {
name: Symbol(name)
for name in [
"symbol",
"at",
"lib_id",
"mirror",
"lib_symbols",
"pts",
"xy",
"wire",
"junction",
"property",
"stroke",
"width",
"type",
"uuid",
]
}
EPS = 1e-4 # mm — coordinate match tolerance
def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]:
"""Rotate (x, y) around the origin by angle_deg degrees (CCW)."""
if angle_deg == 0:
return x, y
rad = math.radians(angle_deg)
c, s = math.cos(rad), math.sin(rad)
return x * c - y * s, x * s + y * c
def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool:
return abs(ax - bx) < eps and abs(ay - by) < eps
class WireDragger:
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
@staticmethod
def find_symbol(sch_data: list, reference: str) -> Any:
"""
Find a placed symbol by reference designator.
Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y)
or None if the reference is not found.
mirror_x=True means the symbol has (mirror x) — flips the X local axis.
mirror_y=True means the symbol has (mirror y) — flips the Y local axis.
"""
sym_k = _K["symbol"]
prop_k = _K["property"]
at_k = _K["at"]
lib_id_k = _K["lib_id"]
mirror_k = _K["mirror"]
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue
# Check Reference property
ref_val = None
for sub in item[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
if str(sub[1]).strip('"') == "Reference":
ref_val = str(sub[2]).strip('"')
break
if ref_val != reference:
continue
old_x = old_y = rotation = 0.0
lib_id = ""
mirror_x = mirror_y = False
for sub in item[1:]:
if not isinstance(sub, list) or not sub:
continue
tag = sub[0]
if tag == at_k:
if len(sub) >= 3:
old_x = float(sub[1])
old_y = float(sub[2])
if len(sub) >= 4:
rotation = float(sub[3])
elif tag == lib_id_k and len(sub) >= 2:
lib_id = str(sub[1]).strip('"')
elif tag == mirror_k and len(sub) >= 2:
mv = str(sub[1])
if mv == "x":
mirror_x = True
elif mv == "y":
mirror_y = True
return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y
return None
@staticmethod
def get_pin_defs(sch_data: list, lib_id: str) -> Dict:
"""
Get pin definitions from lib_symbols for the given lib_id.
Returns the same dict format as PinLocator.parse_symbol_definition:
{pin_num: {"x": ..., "y": ..., ...}}.
"""
from commands.pin_locator import PinLocator
lib_sym_k = _K["lib_symbols"]
symbol_k = _K["symbol"]
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == lib_sym_k):
continue
for sym_def in item[1:]:
if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k):
continue
if len(sym_def) < 2:
continue
name = str(sym_def[1]).strip('"')
if name == lib_id:
return PinLocator.parse_symbol_definition(sym_def)
break # only one lib_symbols section
return {}
@staticmethod
def pin_world_xy(
px: float,
py: float,
sym_x: float,
sym_y: float,
rotation: float,
mirror_x: bool,
mirror_y: bool,
) -> Tuple[float, float]:
"""
Compute the world coordinate of a pin given the symbol transform.
KiCAD applies mirror first (in local space), then rotation, then translation.
mirror_x negates the local X axis; mirror_y negates the local Y axis.
"""
lx, ly = px, py
if mirror_x:
lx = -lx
if mirror_y:
ly = -ly
rx, ry = _rotate(lx, ly, rotation)
return sym_x + rx, sym_y + ry
@staticmethod
def compute_pin_positions(
sch_data: list,
reference: str,
new_x: float,
new_y: float,
) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]:
"""
Compute world pin positions before and after a component move.
Returns {pin_num: (old_world_xy, new_world_xy)}.
old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y).
"""
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return {}
_, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found
pins = WireDragger.get_pin_defs(sch_data, lib_id)
result: Dict[str, Tuple] = {}
for pin_num, pin in pins.items():
px, py = pin["x"], pin["y"]
old_wx, old_wy = WireDragger.pin_world_xy(
px, py, old_x, old_y, rotation, mirror_x, mirror_y
)
new_wx, new_wy = WireDragger.pin_world_xy(
px, py, new_x, new_y, rotation, mirror_x, mirror_y
)
result[pin_num] = (
(round(old_wx, 6), round(old_wy, 6)),
(round(new_wx, 6), round(new_wy, 6)),
)
return result
@staticmethod
def drag_wires(
sch_data: list,
old_to_new: Dict[Tuple[float, float], Tuple[float, float]],
eps: float = EPS,
) -> Dict:
"""
Move wire endpoints and junctions from old positions to new positions.
Removes zero-length wires that result from the move.
Modifies sch_data in place.
old_to_new: {(old_x, old_y): (new_x, new_y)}
Returns {'endpoints_moved': N, 'wires_removed': M}.
"""
wire_k = _K["wire"]
pts_k = _K["pts"]
xy_k = _K["xy"]
junction_k = _K["junction"]
at_k = _K["at"]
def find_new(x: float, y: float) -> Optional[Tuple[float, float]]:
for (ox, oy), (nx, ny) in old_to_new.items():
if _coords_match(x, y, ox, oy, eps):
return nx, ny
return None
endpoints_moved = 0
zero_length_indices = []
# First pass: update wire endpoints
for idx, item in enumerate(sch_data):
if not (isinstance(item, list) and item and item[0] == wire_k):
continue
pts_sub = None
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == pts_k:
pts_sub = sub
break
if pts_sub is None:
continue
xy_items = [
p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k
]
for xy_item in xy_items:
nc = find_new(float(xy_item[1]), float(xy_item[2]))
if nc is not None:
xy_item[1] = nc[0]
xy_item[2] = nc[1]
endpoints_moved += 1
# Check if this wire is now zero-length
if len(xy_items) >= 2:
x1, y1 = float(xy_items[0][1]), float(xy_items[0][2])
x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2])
if _coords_match(x1, y1, x2, y2, eps):
zero_length_indices.append(idx)
# Remove zero-length wires (backwards to preserve indices)
for idx in reversed(zero_length_indices):
del sch_data[idx]
# Second pass: update junctions
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == junction_k):
continue
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
nc = find_new(float(sub[1]), float(sub[2]))
if nc is not None:
sub[1] = nc[0]
sub[2] = nc[1]
break
return {
"endpoints_moved": endpoints_moved,
"wires_removed": len(zero_length_indices),
}
@staticmethod
def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool:
"""
Update the (at x y rot) of the named symbol in sch_data.
Returns True if the symbol was found and updated.
"""
found = WireDragger.find_symbol(sch_data, reference)
if found is None:
return False
item = found[0]
at_k = _K["at"]
prop_k = _K["property"]
# Find current position and compute delta
old_x = old_y = None
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
old_x, old_y = sub[1], sub[2]
sub[1] = new_x
sub[2] = new_y
break
if old_x is None or old_y is None:
return False
dx = new_x - old_x
dy = new_y - old_y
# Shift all property label positions by the same delta
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == prop_k:
for psub in sub[1:]:
if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3:
psub[1] += dx
psub[2] += dy
break
return True
@staticmethod
def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list:
"""Build a wire s-expression list in KiCAD schematic format."""
wire_uuid = str(uuid.uuid4())
return [
_K["wire"],
[_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]],
[_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]],
[_K["uuid"], wire_uuid],
]
@staticmethod
def get_all_stationary_pin_positions(
sch_data: list,
moved_reference: str,
) -> Dict[Tuple[float, float], str]:
"""
Return a map of {world_xy: reference} for every pin of every symbol
in sch_data *except* moved_reference.
This is used to detect pins of stationary components that coincide
with pins of the moved component (touching-pin connections).
"""
sym_k = _K["symbol"]
prop_k = _K["property"]
result: Dict[Tuple[float, float], str] = {}
for item in sch_data:
if not (isinstance(item, list) and item and item[0] == sym_k):
continue
# Determine reference
ref_val = None
for sub in item[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
if str(sub[1]).strip('"') == "Reference":
ref_val = str(sub[2]).strip('"')
break
if ref_val is None or ref_val == moved_reference:
continue
# Skip template / power symbols whose references start with special chars
# but we still want to handle them — no filtering needed here.
# Find lib_id and position for this symbol
found = WireDragger.find_symbol(sch_data, ref_val)
if found is None:
continue
_, sx, sy, rotation, lib_id, mirror_x, mirror_y = found
pins = WireDragger.get_pin_defs(sch_data, lib_id)
for pin_num, pin in pins.items():
wx, wy = WireDragger.pin_world_xy(
pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y
)
key = (round(wx, 6), round(wy, 6))
result[key] = ref_val
return result
@staticmethod
def synthesize_touching_pin_wires(
sch_data: list,
moved_reference: str,
pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]],
eps: float = EPS,
) -> int:
"""
Detect touching-pin connections and synthesize wire segments to bridge gaps
created by moving a component.
For each pin of *moved_reference* whose old world position coincides with
a pin of a stationary component:
- If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy.
- If the pin now lands on another stationary pin's position, skip (they touch again).
- If old_xy == new_xy, do nothing (no gap was created).
Modifies sch_data in place.
Returns the number of wire segments synthesized.
"""
if not pin_positions:
return 0
stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference)
if not stationary_pins:
return 0
synthesized = 0
for pin_num, (old_xy, new_xy) in pin_positions.items():
# Check if a stationary pin touches this pin's old position
touching = any(
_coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
)
if not touching:
continue
# The pin has moved — check if it actually separated
if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps):
# Pin didn't actually move; no gap
continue
# Check if the pin's new position happens to touch another stationary pin
# (component moved into a different touching position — no wire needed)
rejoining = any(
_coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
)
if rejoining:
logger.debug(
f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} "
f"and rejoins another stationary pin; no wire synthesized"
)
continue
logger.info(
f"Synthesizing wire for touching-pin connection: "
f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}"
)
wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1])
# Insert before the last item (sheet_instances) to keep file tidy,
# but appending is also valid — just append.
sch_data.append(wire)
synthesized += 1
return synthesized

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,27 @@
"""
KiCAD API Abstraction Layer
This module provides a unified interface to KiCAD's Python APIs,
supporting both the legacy SWIG bindings and the new IPC API.
Usage:
from kicad_api import create_backend
# Auto-detect best available backend
backend = create_backend()
# Or specify explicitly
backend = create_backend('ipc') # Use IPC API
backend = create_backend('swig') # Use legacy SWIG
# Connect and use
if backend.connect():
board = backend.get_board()
board.set_size(100, 80)
"""
from kicad_api.base import KiCADBackend
from kicad_api.factory import create_backend
__all__ = ["create_backend", "KiCADBackend"]
__version__ = "2.0.0-alpha.1"
"""
KiCAD API Abstraction Layer
This module provides a unified interface to KiCAD's Python APIs,
supporting both the legacy SWIG bindings and the new IPC API.
Usage:
from kicad_api import create_backend
# Auto-detect best available backend
backend = create_backend()
# Or specify explicitly
backend = create_backend('ipc') # Use IPC API
backend = create_backend('swig') # Use legacy SWIG
# Connect and use
if backend.connect():
board = backend.get_board()
board.set_size(100, 80)
"""
from kicad_api.base import KiCADBackend
from kicad_api.factory import create_backend
__all__ = ["create_backend", "KiCADBackend"]
__version__ = "2.0.0-alpha.1"

View File

@@ -1,293 +1,293 @@
"""
Abstract base class for KiCAD API backends
Defines the interface that all KiCAD backends must implement.
"""
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class KiCADBackend(ABC):
"""Abstract base class for KiCAD API backends"""
@abstractmethod
def connect(self) -> bool:
"""
Connect to KiCAD
Returns:
True if connection successful, False otherwise
"""
pass
@abstractmethod
def disconnect(self) -> None:
"""Disconnect from KiCAD and clean up resources"""
pass
@abstractmethod
def is_connected(self) -> bool:
"""
Check if currently connected to KiCAD
Returns:
True if connected, False otherwise
"""
pass
@abstractmethod
def get_version(self) -> str:
"""
Get KiCAD version
Returns:
Version string (e.g., "9.0.0")
"""
pass
# Project Operations
@abstractmethod
def create_project(self, path: Path, name: str) -> Dict[str, Any]:
"""
Create a new KiCAD project
Args:
path: Directory path for the project
name: Project name
Returns:
Dictionary with project info
"""
pass
@abstractmethod
def open_project(self, path: Path) -> Dict[str, Any]:
"""
Open an existing KiCAD project
Args:
path: Path to .kicad_pro file
Returns:
Dictionary with project info
"""
pass
@abstractmethod
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
"""
Save the current project
Args:
path: Optional new path to save to
Returns:
Dictionary with save status
"""
pass
@abstractmethod
def close_project(self) -> None:
"""Close the current project"""
pass
# Board Operations
@abstractmethod
def get_board(self) -> "BoardAPI":
"""
Get board API for current project
Returns:
BoardAPI instance
"""
pass
class BoardAPI(ABC):
"""Abstract interface for board operations"""
@abstractmethod
def set_size(self, width: float, height: float, unit: str = "mm") -> bool:
"""
Set board size
Args:
width: Board width
height: Board height
unit: Unit of measurement ("mm" or "in")
Returns:
True if successful
"""
pass
@abstractmethod
def get_size(self) -> Dict[str, Any]:
"""
Get current board size
Returns:
Dictionary with width, height, unit
"""
pass
@abstractmethod
def add_layer(self, layer_name: str, layer_type: str) -> bool:
"""
Add a layer to the board
Args:
layer_name: Name of the layer
layer_type: Type ("copper", "technical", "user")
Returns:
True if successful
"""
pass
@abstractmethod
def list_components(self) -> List[Dict[str, Any]]:
"""
List all components on the board
Returns:
List of component dictionaries
"""
pass
@abstractmethod
def place_component(
self,
reference: str,
footprint: str,
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu",
value: str = "",
) -> bool:
"""
Place a component on the board
Args:
reference: Component reference (e.g., "R1")
footprint: Footprint library path
x: X position (mm)
y: Y position (mm)
rotation: Rotation angle (degrees)
layer: Layer name
Returns:
True if successful
"""
pass
# Routing Operations
def add_track(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
width: float = 0.25,
layer: str = "F.Cu",
net_name: Optional[str] = None,
) -> bool:
"""
Add a track (trace) to the board
Args:
start_x: Start X position (mm)
start_y: Start Y position (mm)
end_x: End X position (mm)
end_y: End Y position (mm)
width: Track width (mm)
layer: Layer name
net_name: Optional net name
Returns:
True if successful
"""
raise NotImplementedError()
def add_via(
self,
x: float,
y: float,
diameter: float = 0.8,
drill: float = 0.4,
net_name: Optional[str] = None,
via_type: str = "through",
) -> bool:
"""
Add a via to the board
Args:
x: X position (mm)
y: Y position (mm)
diameter: Via diameter (mm)
drill: Drill diameter (mm)
net_name: Optional net name
via_type: Via type ("through", "blind", "micro")
Returns:
True if successful
"""
raise NotImplementedError()
# Transaction support for undo/redo
def begin_transaction(self, description: str = "MCP Operation") -> None:
"""Begin a transaction for grouping operations."""
pass # Optional - not all backends support this
def commit_transaction(self, description: str = "MCP Operation") -> None:
"""Commit the current transaction."""
pass # Optional
def rollback_transaction(self) -> None:
"""Roll back the current transaction."""
pass # Optional
def save(self) -> bool:
"""Save the board."""
raise NotImplementedError()
# Query operations
def get_tracks(self) -> List[Dict[str, Any]]:
"""Get all tracks on the board."""
raise NotImplementedError()
def get_vias(self) -> List[Dict[str, Any]]:
"""Get all vias on the board."""
raise NotImplementedError()
def get_nets(self) -> List[Dict[str, Any]]:
"""Get all nets on the board."""
raise NotImplementedError()
def get_selection(self) -> List[Dict[str, Any]]:
"""Get currently selected items."""
raise NotImplementedError()
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
"""
Abstract base class for KiCAD API backends
Defines the interface that all KiCAD backends must implement.
"""
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
class KiCADBackend(ABC):
"""Abstract base class for KiCAD API backends"""
@abstractmethod
def connect(self) -> bool:
"""
Connect to KiCAD
Returns:
True if connection successful, False otherwise
"""
pass
@abstractmethod
def disconnect(self) -> None:
"""Disconnect from KiCAD and clean up resources"""
pass
@abstractmethod
def is_connected(self) -> bool:
"""
Check if currently connected to KiCAD
Returns:
True if connected, False otherwise
"""
pass
@abstractmethod
def get_version(self) -> str:
"""
Get KiCAD version
Returns:
Version string (e.g., "9.0.0")
"""
pass
# Project Operations
@abstractmethod
def create_project(self, path: Path, name: str) -> Dict[str, Any]:
"""
Create a new KiCAD project
Args:
path: Directory path for the project
name: Project name
Returns:
Dictionary with project info
"""
pass
@abstractmethod
def open_project(self, path: Path) -> Dict[str, Any]:
"""
Open an existing KiCAD project
Args:
path: Path to .kicad_pro file
Returns:
Dictionary with project info
"""
pass
@abstractmethod
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
"""
Save the current project
Args:
path: Optional new path to save to
Returns:
Dictionary with save status
"""
pass
@abstractmethod
def close_project(self) -> None:
"""Close the current project"""
pass
# Board Operations
@abstractmethod
def get_board(self) -> "BoardAPI":
"""
Get board API for current project
Returns:
BoardAPI instance
"""
pass
class BoardAPI(ABC):
"""Abstract interface for board operations"""
@abstractmethod
def set_size(self, width: float, height: float, unit: str = "mm") -> bool:
"""
Set board size
Args:
width: Board width
height: Board height
unit: Unit of measurement ("mm" or "in")
Returns:
True if successful
"""
pass
@abstractmethod
def get_size(self) -> Dict[str, Any]:
"""
Get current board size
Returns:
Dictionary with width, height, unit
"""
pass
@abstractmethod
def add_layer(self, layer_name: str, layer_type: str) -> bool:
"""
Add a layer to the board
Args:
layer_name: Name of the layer
layer_type: Type ("copper", "technical", "user")
Returns:
True if successful
"""
pass
@abstractmethod
def list_components(self) -> List[Dict[str, Any]]:
"""
List all components on the board
Returns:
List of component dictionaries
"""
pass
@abstractmethod
def place_component(
self,
reference: str,
footprint: str,
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu",
value: str = "",
) -> bool:
"""
Place a component on the board
Args:
reference: Component reference (e.g., "R1")
footprint: Footprint library path
x: X position (mm)
y: Y position (mm)
rotation: Rotation angle (degrees)
layer: Layer name
Returns:
True if successful
"""
pass
# Routing Operations
def add_track(
self,
start_x: float,
start_y: float,
end_x: float,
end_y: float,
width: float = 0.25,
layer: str = "F.Cu",
net_name: Optional[str] = None,
) -> bool:
"""
Add a track (trace) to the board
Args:
start_x: Start X position (mm)
start_y: Start Y position (mm)
end_x: End X position (mm)
end_y: End Y position (mm)
width: Track width (mm)
layer: Layer name
net_name: Optional net name
Returns:
True if successful
"""
raise NotImplementedError()
def add_via(
self,
x: float,
y: float,
diameter: float = 0.8,
drill: float = 0.4,
net_name: Optional[str] = None,
via_type: str = "through",
) -> bool:
"""
Add a via to the board
Args:
x: X position (mm)
y: Y position (mm)
diameter: Via diameter (mm)
drill: Drill diameter (mm)
net_name: Optional net name
via_type: Via type ("through", "blind", "micro")
Returns:
True if successful
"""
raise NotImplementedError()
# Transaction support for undo/redo
def begin_transaction(self, description: str = "MCP Operation") -> None:
"""Begin a transaction for grouping operations."""
pass # Optional - not all backends support this
def commit_transaction(self, description: str = "MCP Operation") -> None:
"""Commit the current transaction."""
pass # Optional
def rollback_transaction(self) -> None:
"""Roll back the current transaction."""
pass # Optional
def save(self) -> bool:
"""Save the board."""
raise NotImplementedError()
# Query operations
def get_tracks(self) -> List[Dict[str, Any]]:
"""Get all tracks on the board."""
raise NotImplementedError()
def get_vias(self) -> List[Dict[str, Any]]:
"""Get all vias on the board."""
raise NotImplementedError()
def get_nets(self) -> List[Dict[str, Any]]:
"""Get all nets on the board."""
raise NotImplementedError()
def get_selection(self) -> List[Dict[str, Any]]:
"""Get currently selected items."""
raise NotImplementedError()
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

@@ -1,195 +1,195 @@
"""
Backend factory for creating appropriate KiCAD API backend
Auto-detects available backends and provides fallback mechanism.
"""
import logging
import os
from pathlib import Path
from typing import Optional
from kicad_api.base import APINotAvailableError, KiCADBackend
logger = logging.getLogger(__name__)
def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
"""
Create appropriate KiCAD backend
Args:
backend_type: Backend to use:
- 'ipc': Use IPC API (recommended)
- 'swig': Use legacy SWIG bindings
- None or 'auto': Auto-detect (try IPC first, fall back to SWIG)
Returns:
KiCADBackend instance
Raises:
APINotAvailableError: If no backend is available
Environment Variables:
KICAD_BACKEND: Override backend selection ('ipc', 'swig', or 'auto')
"""
# Check environment variable override
if backend_type is None:
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":
return _create_ipc_backend()
elif backend_type == "swig":
return _create_swig_backend()
elif backend_type == "auto":
return _auto_detect_backend()
else:
raise ValueError(f"Unknown backend type: {backend_type}")
def _create_ipc_backend() -> KiCADBackend:
"""
Create IPC backend
Returns:
IPCBackend instance
Raises:
APINotAvailableError: If kicad-python not available
"""
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"
) from e
def _create_swig_backend() -> KiCADBackend:
"""
Create SWIG backend
Returns:
SWIGBackend instance
Raises:
APINotAvailableError: If pcbnew not available
"""
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. "
"Please migrate to IPC backend."
)
return SWIGBackend()
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."
) from e
def _auto_detect_backend() -> KiCADBackend:
"""
Auto-detect best available backend
Priority:
1. IPC API (if kicad-python available and KiCAD running)
2. SWIG API (if pcbnew available)
Returns:
Best available KiCADBackend
Raises:
APINotAvailableError: If no backend available
"""
logger.info("Auto-detecting available KiCAD backend...")
# Try IPC first (preferred)
try:
backend = _create_ipc_backend()
# Test connection
if backend.connect():
logger.info("✓ IPC backend available and connected")
return backend
else:
logger.warning("IPC backend available but connection failed")
except (ImportError, APINotAvailableError) as e:
logger.debug(f"IPC backend not available: {e}")
# Fall back to SWIG
try:
backend = _create_swig_backend()
logger.warning(
"Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running."
)
return backend
except (ImportError, APINotAvailableError) as e:
logger.error(f"SWIG backend not available: {e}")
# No backend available
raise APINotAvailableError(
"No KiCAD backend available. Please install either:\n"
" - kicad-python (recommended): pip install kicad-python\n"
" - Ensure KiCAD Python module (pcbnew) is in PYTHONPATH"
)
def get_available_backends() -> dict:
"""
Check which backends are available
Returns:
Dictionary with backend availability:
{
'ipc': {'available': bool, 'version': str or None},
'swig': {'available': bool, 'version': str or None}
}
"""
results = {}
# Check IPC (kicad-python uses 'kipy' module name)
try:
import kipy
results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")}
except ImportError:
results["ipc"] = {"available": False, "version": None}
# Check SWIG
try:
import pcbnew
results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()}
except ImportError:
results["swig"] = {"available": False, "version": None}
return results
if __name__ == "__main__":
# Quick diagnostic
import json
print("KiCAD Backend Availability:")
print(json.dumps(get_available_backends(), indent=2))
print("\nAttempting to create backend...")
try:
backend = create_backend()
print(f"✓ Created backend: {type(backend).__name__}")
if backend.connect():
print(f"✓ Connected to KiCAD: {backend.get_version()}")
else:
print("✗ Failed to connect to KiCAD")
except Exception as e:
print(f"✗ Error: {e}")
"""
Backend factory for creating appropriate KiCAD API backend
Auto-detects available backends and provides fallback mechanism.
"""
import logging
import os
from pathlib import Path
from typing import Optional
from kicad_api.base import APINotAvailableError, KiCADBackend
logger = logging.getLogger(__name__)
def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
"""
Create appropriate KiCAD backend
Args:
backend_type: Backend to use:
- 'ipc': Use IPC API (recommended)
- 'swig': Use legacy SWIG bindings
- None or 'auto': Auto-detect (try IPC first, fall back to SWIG)
Returns:
KiCADBackend instance
Raises:
APINotAvailableError: If no backend is available
Environment Variables:
KICAD_BACKEND: Override backend selection ('ipc', 'swig', or 'auto')
"""
# Check environment variable override
if backend_type is None:
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":
return _create_ipc_backend()
elif backend_type == "swig":
return _create_swig_backend()
elif backend_type == "auto":
return _auto_detect_backend()
else:
raise ValueError(f"Unknown backend type: {backend_type}")
def _create_ipc_backend() -> KiCADBackend:
"""
Create IPC backend
Returns:
IPCBackend instance
Raises:
APINotAvailableError: If kicad-python not available
"""
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"
) from e
def _create_swig_backend() -> KiCADBackend:
"""
Create SWIG backend
Returns:
SWIGBackend instance
Raises:
APINotAvailableError: If pcbnew not available
"""
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. "
"Please migrate to IPC backend."
)
return SWIGBackend()
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."
) from e
def _auto_detect_backend() -> KiCADBackend:
"""
Auto-detect best available backend
Priority:
1. IPC API (if kicad-python available and KiCAD running)
2. SWIG API (if pcbnew available)
Returns:
Best available KiCADBackend
Raises:
APINotAvailableError: If no backend available
"""
logger.info("Auto-detecting available KiCAD backend...")
# Try IPC first (preferred)
try:
backend = _create_ipc_backend()
# Test connection
if backend.connect():
logger.info("✓ IPC backend available and connected")
return backend
else:
logger.warning("IPC backend available but connection failed")
except (ImportError, APINotAvailableError) as e:
logger.debug(f"IPC backend not available: {e}")
# Fall back to SWIG
try:
backend = _create_swig_backend()
logger.warning(
"Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running."
)
return backend
except (ImportError, APINotAvailableError) as e:
logger.error(f"SWIG backend not available: {e}")
# No backend available
raise APINotAvailableError(
"No KiCAD backend available. Please install either:\n"
" - kicad-python (recommended): pip install kicad-python\n"
" - Ensure KiCAD Python module (pcbnew) is in PYTHONPATH"
)
def get_available_backends() -> dict:
"""
Check which backends are available
Returns:
Dictionary with backend availability:
{
'ipc': {'available': bool, 'version': str or None},
'swig': {'available': bool, 'version': str or None}
}
"""
results = {}
# Check IPC (kicad-python uses 'kipy' module name)
try:
import kipy
results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")}
except ImportError:
results["ipc"] = {"available": False, "version": None}
# Check SWIG
try:
import pcbnew
results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()}
except ImportError:
results["swig"] = {"available": False, "version": None}
return results
if __name__ == "__main__":
# Quick diagnostic
import json
print("KiCAD Backend Availability:")
print(json.dumps(get_available_backends(), indent=2))
print("\nAttempting to create backend...")
try:
backend = create_backend()
print(f"✓ Created backend: {type(backend).__name__}")
if backend.connect():
print(f"✓ Connected to KiCAD: {backend.get_version()}")
else:
print("✗ Failed to connect to KiCAD")
except Exception as e:
print(f"✗ Error: {e}")

File diff suppressed because it is too large Load Diff

View File

@@ -1,218 +1,218 @@
"""
SWIG Backend (Legacy - DEPRECATED)
Uses the legacy SWIG-based pcbnew Python bindings.
This backend wraps the existing implementation for backward compatibility.
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 Any, Dict, List, Optional
from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend
logger = logging.getLogger(__name__)
class SWIGBackend(KiCADBackend):
"""
Legacy SWIG-based backend
Wraps existing commands/project.py, commands/component.py, etc.
for compatibility during migration period.
"""
def __init__(self) -> None:
self._connected = False
self._pcbnew = None
logger.warning(
"⚠️ Using DEPRECATED SWIG backend. "
"This will be removed in KiCAD 10.0. "
"Please migrate to IPC API."
)
def connect(self) -> bool:
"""
'Connect' to SWIG API (just validates pcbnew import)
Returns:
True if pcbnew module available
"""
try:
import pcbnew
self._pcbnew = pcbnew
version = pcbnew.GetBuildVersion()
logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
self._connected = True
return True
except ImportError as e:
logger.error("pcbnew module not found")
raise APINotAvailableError(
"SWIG backend requires pcbnew module. "
"Ensure KiCAD Python module is in PYTHONPATH."
) from e
def disconnect(self) -> None:
"""Disconnect from SWIG API (no-op)"""
self._connected = False
self._pcbnew = None
logger.info("Disconnected from SWIG backend")
def is_connected(self) -> bool:
"""Check if connected"""
return self._connected
def get_version(self) -> str:
"""Get KiCAD version"""
if not self.is_connected():
raise ConnectionError("Not connected")
return self._pcbnew.GetBuildVersion()
# Project Operations
def create_project(self, path: Path, name: str) -> Dict[str, Any]:
"""Create project using existing SWIG implementation"""
if not self.is_connected():
raise ConnectionError("Not connected")
# Import existing implementation
from commands.project import ProjectCommands
try:
result = ProjectCommands.create_project(str(path), name)
return result
except Exception as e:
logger.error(f"Failed to create project: {e}")
raise
def open_project(self, path: Path) -> Dict[str, Any]:
"""Open project using existing SWIG implementation"""
if not self.is_connected():
raise ConnectionError("Not connected")
from commands.project import ProjectCommands
try:
result = ProjectCommands().open_project({"filename": str(path)})
return result
except Exception as e:
logger.error(f"Failed to open project: {e}")
raise
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
"""Save project using existing SWIG implementation"""
if not self.is_connected():
raise ConnectionError("Not connected")
from commands.project import ProjectCommands
try:
params: Dict[str, Any] = {}
if path:
params["filename"] = str(path)
result = ProjectCommands().save_project(params)
return result
except Exception as e:
logger.error(f"Failed to save project: {e}")
raise
def close_project(self) -> None:
"""Close project (SWIG doesn't have explicit close)"""
logger.info("Closing project (SWIG backend)")
# SWIG backend doesn't maintain project state,
# so this is essentially a no-op
# Board Operations
def get_board(self) -> BoardAPI:
"""Get board API"""
if not self.is_connected():
raise ConnectionError("Not connected")
return SWIGBoardAPI(self._pcbnew)
class SWIGBoardAPI(BoardAPI):
"""Board API implementation wrapping SWIG/pcbnew"""
def __init__(self, pcbnew_module: Any) -> None:
self.pcbnew = pcbnew_module
self._board = None
def set_size(self, width: float, height: float, unit: str = "mm") -> bool:
"""Set board size using existing implementation"""
from commands.board import BoardCommands
try:
result = BoardCommands(board=self._board).set_board_size(
{"width": width, "height": height, "unit": unit}
)
return result.get("success", False)
except Exception as e:
logger.error(f"Failed to set board size: {e}")
return False
def get_size(self) -> Dict[str, Any]:
"""Get board size"""
# TODO: Implement using existing SWIG code
raise NotImplementedError("get_size not yet wrapped")
def add_layer(self, layer_name: str, layer_type: str) -> bool:
"""Add layer using existing implementation"""
from commands.board import BoardCommands
try:
result = BoardCommands.add_layer(layer_name, layer_type)
return result.get("success", False)
except Exception as e:
logger.error(f"Failed to add layer: {e}")
return False
def list_components(self) -> List[Dict[str, Any]]:
"""List components using existing implementation"""
from commands.component import ComponentCommands
try:
result = ComponentCommands(board=self._board).get_component_list({})
if result.get("success"):
return result.get("components", [])
return []
except Exception as e:
logger.error(f"Failed to list components: {e}")
return []
def place_component(
self,
reference: str,
footprint: str,
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu",
value: str = "",
) -> bool:
"""Place component using existing implementation"""
from commands.component import ComponentCommands
try:
result = ComponentCommands(board=self._board).place_component(
{
"componentId": footprint,
"position": {"x": x, "y": y, "unit": "mm"},
"reference": reference,
"rotation": rotation,
"layer": layer,
}
)
return result.get("success", False)
except Exception as e:
logger.error(f"Failed to place component: {e}")
return False
# This backend serves as a wrapper during the migration period.
# Once IPC backend is fully implemented, this can be deprecated.
"""
SWIG Backend (Legacy - DEPRECATED)
Uses the legacy SWIG-based pcbnew Python bindings.
This backend wraps the existing implementation for backward compatibility.
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 Any, Dict, List, Optional
from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend
logger = logging.getLogger(__name__)
class SWIGBackend(KiCADBackend):
"""
Legacy SWIG-based backend
Wraps existing commands/project.py, commands/component.py, etc.
for compatibility during migration period.
"""
def __init__(self) -> None:
self._connected = False
self._pcbnew = None
logger.warning(
"⚠️ Using DEPRECATED SWIG backend. "
"This will be removed in KiCAD 10.0. "
"Please migrate to IPC API."
)
def connect(self) -> bool:
"""
'Connect' to SWIG API (just validates pcbnew import)
Returns:
True if pcbnew module available
"""
try:
import pcbnew
self._pcbnew = pcbnew
version = pcbnew.GetBuildVersion()
logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
self._connected = True
return True
except ImportError as e:
logger.error("pcbnew module not found")
raise APINotAvailableError(
"SWIG backend requires pcbnew module. "
"Ensure KiCAD Python module is in PYTHONPATH."
) from e
def disconnect(self) -> None:
"""Disconnect from SWIG API (no-op)"""
self._connected = False
self._pcbnew = None
logger.info("Disconnected from SWIG backend")
def is_connected(self) -> bool:
"""Check if connected"""
return self._connected
def get_version(self) -> str:
"""Get KiCAD version"""
if not self.is_connected():
raise ConnectionError("Not connected")
return self._pcbnew.GetBuildVersion()
# Project Operations
def create_project(self, path: Path, name: str) -> Dict[str, Any]:
"""Create project using existing SWIG implementation"""
if not self.is_connected():
raise ConnectionError("Not connected")
# Import existing implementation
from commands.project import ProjectCommands
try:
result = ProjectCommands.create_project(str(path), name)
return result
except Exception as e:
logger.error(f"Failed to create project: {e}")
raise
def open_project(self, path: Path) -> Dict[str, Any]:
"""Open project using existing SWIG implementation"""
if not self.is_connected():
raise ConnectionError("Not connected")
from commands.project import ProjectCommands
try:
result = ProjectCommands().open_project({"filename": str(path)})
return result
except Exception as e:
logger.error(f"Failed to open project: {e}")
raise
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
"""Save project using existing SWIG implementation"""
if not self.is_connected():
raise ConnectionError("Not connected")
from commands.project import ProjectCommands
try:
params: Dict[str, Any] = {}
if path:
params["filename"] = str(path)
result = ProjectCommands().save_project(params)
return result
except Exception as e:
logger.error(f"Failed to save project: {e}")
raise
def close_project(self) -> None:
"""Close project (SWIG doesn't have explicit close)"""
logger.info("Closing project (SWIG backend)")
# SWIG backend doesn't maintain project state,
# so this is essentially a no-op
# Board Operations
def get_board(self) -> BoardAPI:
"""Get board API"""
if not self.is_connected():
raise ConnectionError("Not connected")
return SWIGBoardAPI(self._pcbnew)
class SWIGBoardAPI(BoardAPI):
"""Board API implementation wrapping SWIG/pcbnew"""
def __init__(self, pcbnew_module: Any) -> None:
self.pcbnew = pcbnew_module
self._board = None
def set_size(self, width: float, height: float, unit: str = "mm") -> bool:
"""Set board size using existing implementation"""
from commands.board import BoardCommands
try:
result = BoardCommands(board=self._board).set_board_size(
{"width": width, "height": height, "unit": unit}
)
return result.get("success", False)
except Exception as e:
logger.error(f"Failed to set board size: {e}")
return False
def get_size(self) -> Dict[str, Any]:
"""Get board size"""
# TODO: Implement using existing SWIG code
raise NotImplementedError("get_size not yet wrapped")
def add_layer(self, layer_name: str, layer_type: str) -> bool:
"""Add layer using existing implementation"""
from commands.board import BoardCommands
try:
result = BoardCommands.add_layer(layer_name, layer_type)
return result.get("success", False)
except Exception as e:
logger.error(f"Failed to add layer: {e}")
return False
def list_components(self) -> List[Dict[str, Any]]:
"""List components using existing implementation"""
from commands.component import ComponentCommands
try:
result = ComponentCommands(board=self._board).get_component_list({})
if result.get("success"):
return result.get("components", [])
return []
except Exception as e:
logger.error(f"Failed to list components: {e}")
return []
def place_component(
self,
reference: str,
footprint: str,
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu",
value: str = "",
) -> bool:
"""Place component using existing implementation"""
from commands.component import ComponentCommands
try:
result = ComponentCommands(board=self._board).place_component(
{
"componentId": footprint,
"position": {"x": x, "y": y, "unit": "mm"},
"reference": reference,
"rotation": rotation,
"layer": layer,
}
)
return result.get("success", False)
except Exception as e:
logger.error(f"Failed to place component: {e}")
return False
# This backend serves as a wrapper during the migration period.
# Once IPC backend is fully implemented, this can be deprecated.

View File

@@ -1 +1 @@
# parsers package
# parsers package

View File

@@ -1,250 +1,250 @@
"""
Parser for KiCad .kicad_mod footprint files.
Extracts the fields that the MCP get_footprint_info tool exposes to clients:
name footprint name (str)
library library nickname, injected by caller (str)
description (descr "") token (str | None)
keywords (tags "") token (str | None)
pads list of pad objects: [{number, type, shape}, …] (list[dict])
layers sorted unique list of canonical layer names used (list[str])
courtyard {"width": float, "height": float} from F.CrtYd geometry (dict | None)
attributes {"type": str, "board_only": bool, …} (dict | None)
KiCad S-expression file format reference:
https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint
"""
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
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
expected by the TypeScript MCP tool handler (src/tools/library.ts).
Returns None if the file does not exist or cannot be read.
"""
path = Path(file_path)
if not path.exists():
logger.debug(f"parse_kicad_mod: file not found: {file_path}")
return None
try:
content = path.read_text(encoding="utf-8")
except OSError as e:
logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}")
return None
logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)")
result: Dict[str, Any] = {}
# ------------------------------------------------------------------
# Footprint name: (footprint "NAME" …
# Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix).
# ------------------------------------------------------------------
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)
result["name"] = _unescape(m.group(1)) if m else path.stem
logger.debug(f"parse_kicad_mod: name={result['name']!r}")
# ------------------------------------------------------------------
# Description: (descr "…")
# ------------------------------------------------------------------
m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content)
result["description"] = _unescape(m.group(1)) if m else None
logger.debug(f"parse_kicad_mod: description={result['description']!r}")
# ------------------------------------------------------------------
# Keywords / tags: (tags "…")
# ------------------------------------------------------------------
m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content)
result["keywords"] = _unescape(m.group(1)) if m else None
logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}")
# ------------------------------------------------------------------
# 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)
if m:
tokens = m.group(1).split()
result["attributes"] = {
"type": tokens[0] if tokens else "unspecified",
"board_only": "board_only" in tokens,
"exclude_from_pos_files": "exclude_from_pos_files" in tokens,
"exclude_from_bom": "exclude_from_bom" in tokens,
}
else:
result["attributes"] = None
logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}")
# ------------------------------------------------------------------
# Pads: (pad "NUMBER" TYPE SHAPE …)
# Return each pad as an object; deduplicate by number (first wins).
# ------------------------------------------------------------------
result["pads"] = _extract_pads(content)
logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}")
# ------------------------------------------------------------------
# Layers: all unique canonical layer names across the whole file.
# Sources:
# (layer "NAME") single-layer items (fp_line, fp_text, …)
# (layers "A" "B" …) pad layer lists
# ------------------------------------------------------------------
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 lyr in re.findall(r'"([^"]+)"', m.group(1)):
layers.add(lyr)
result["layers"] = sorted(layers)
logger.debug(f"parse_kicad_mod: layers={result['layers']}")
# ------------------------------------------------------------------
# Courtyard: derive bounding box from F.CrtYd geometry.
# Prefer fp_rect (most common for standard footprints), fall back to
# fp_line segments.
# ------------------------------------------------------------------
result["courtyard"] = _extract_courtyard(content)
logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}")
return result
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _extract_pads(content: str) -> List[Dict[str, Any]]:
"""
Parse all (pad …) blocks and return a list of pad objects.
Each object has:
number pad number string, e.g. "1", "A1", "GND"
type thru_hole | smd | np_thru_hole | connect
shape rect | circle | oval | roundrect | trapezoid | custom
Pads are deduplicated by number (first occurrence wins) so that the
list represents the logical pads of the footprint, not duplicated
copper entries.
"""
pads: List[Dict[str, Any]] = []
seen_numbers: dict = {}
# 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"
)
for m in quoted_pattern.finditer(content):
number, ptype, shape = m.group(1), m.group(2), m.group(3)
if number not in seen_numbers:
seen_numbers[number] = True
pads.append({"number": number, "type": ptype, "shape": shape})
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"
)
for m in unquoted_pattern.finditer(content):
number, ptype, shape = m.group(1), m.group(2), m.group(3)
if number not in seen_numbers:
seen_numbers[number] = True
pads.append({"number": number, "type": ptype, "shape": shape})
return pads
def _unescape(s: str) -> str:
"""Reverse KiCad S-expression string escaping."""
return s.replace('\\"', '"').replace("\\\\", "\\")
def _extract_blocks(content: str, token: str) -> List[str]:
"""
Return all S-expression blocks that start with `(token ` by tracking
parenthesis depth. This correctly handles nested parens inside blocks.
"""
blocks: List[str] = []
pattern = re.compile(r"\(" + re.escape(token) + r"\b")
for match in pattern.finditer(content):
start = match.start()
depth = 0
i = start
while i < len(content):
ch = content[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
blocks.append(content[start : i + 1])
break
i += 1
return blocks
def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
"""
Compute the courtyard bounding box from F.CrtYd geometry.
Strategy:
1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end.
2. Fall back to fp_line segments on F.CrtYd — compute bounding box of
all endpoints.
"""
xs: List[float] = []
ys: List[float] = []
# --- fp_rect pass ---
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)
if s and e:
xs += [float(s.group(1)), float(e.group(1))]
ys += [float(s.group(2)), float(e.group(2))]
logger.debug(
f"_extract_courtyard: fp_rect F.CrtYd "
f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})"
)
# --- fp_line pass (only if fp_rect found nothing) ---
if not xs:
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):
xs.append(float(m.group(1)))
ys.append(float(m.group(2)))
if not xs:
logger.debug("_extract_courtyard: no F.CrtYd geometry found")
return None
width = round(abs(max(xs) - min(xs)), 6)
height = round(abs(max(ys) - min(ys)), 6)
logger.debug(f"_extract_courtyard: result width={width} height={height}")
return {"width": width, "height": height}
"""
Parser for KiCad .kicad_mod footprint files.
Extracts the fields that the MCP get_footprint_info tool exposes to clients:
name footprint name (str)
library library nickname, injected by caller (str)
description (descr "") token (str | None)
keywords (tags "") token (str | None)
pads list of pad objects: [{number, type, shape}, …] (list[dict])
layers sorted unique list of canonical layer names used (list[str])
courtyard {"width": float, "height": float} from F.CrtYd geometry (dict | None)
attributes {"type": str, "board_only": bool, …} (dict | None)
KiCad S-expression file format reference:
https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint
"""
import logging
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
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
expected by the TypeScript MCP tool handler (src/tools/library.ts).
Returns None if the file does not exist or cannot be read.
"""
path = Path(file_path)
if not path.exists():
logger.debug(f"parse_kicad_mod: file not found: {file_path}")
return None
try:
content = path.read_text(encoding="utf-8")
except OSError as e:
logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}")
return None
logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)")
result: Dict[str, Any] = {}
# ------------------------------------------------------------------
# Footprint name: (footprint "NAME" …
# Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix).
# ------------------------------------------------------------------
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)
result["name"] = _unescape(m.group(1)) if m else path.stem
logger.debug(f"parse_kicad_mod: name={result['name']!r}")
# ------------------------------------------------------------------
# Description: (descr "…")
# ------------------------------------------------------------------
m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content)
result["description"] = _unescape(m.group(1)) if m else None
logger.debug(f"parse_kicad_mod: description={result['description']!r}")
# ------------------------------------------------------------------
# Keywords / tags: (tags "…")
# ------------------------------------------------------------------
m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content)
result["keywords"] = _unescape(m.group(1)) if m else None
logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}")
# ------------------------------------------------------------------
# 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)
if m:
tokens = m.group(1).split()
result["attributes"] = {
"type": tokens[0] if tokens else "unspecified",
"board_only": "board_only" in tokens,
"exclude_from_pos_files": "exclude_from_pos_files" in tokens,
"exclude_from_bom": "exclude_from_bom" in tokens,
}
else:
result["attributes"] = None
logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}")
# ------------------------------------------------------------------
# Pads: (pad "NUMBER" TYPE SHAPE …)
# Return each pad as an object; deduplicate by number (first wins).
# ------------------------------------------------------------------
result["pads"] = _extract_pads(content)
logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}")
# ------------------------------------------------------------------
# Layers: all unique canonical layer names across the whole file.
# Sources:
# (layer "NAME") single-layer items (fp_line, fp_text, …)
# (layers "A" "B" …) pad layer lists
# ------------------------------------------------------------------
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 lyr in re.findall(r'"([^"]+)"', m.group(1)):
layers.add(lyr)
result["layers"] = sorted(layers)
logger.debug(f"parse_kicad_mod: layers={result['layers']}")
# ------------------------------------------------------------------
# Courtyard: derive bounding box from F.CrtYd geometry.
# Prefer fp_rect (most common for standard footprints), fall back to
# fp_line segments.
# ------------------------------------------------------------------
result["courtyard"] = _extract_courtyard(content)
logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}")
return result
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _extract_pads(content: str) -> List[Dict[str, Any]]:
"""
Parse all (pad …) blocks and return a list of pad objects.
Each object has:
number pad number string, e.g. "1", "A1", "GND"
type thru_hole | smd | np_thru_hole | connect
shape rect | circle | oval | roundrect | trapezoid | custom
Pads are deduplicated by number (first occurrence wins) so that the
list represents the logical pads of the footprint, not duplicated
copper entries.
"""
pads: List[Dict[str, Any]] = []
seen_numbers: dict = {}
# 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"
)
for m in quoted_pattern.finditer(content):
number, ptype, shape = m.group(1), m.group(2), m.group(3)
if number not in seen_numbers:
seen_numbers[number] = True
pads.append({"number": number, "type": ptype, "shape": shape})
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"
)
for m in unquoted_pattern.finditer(content):
number, ptype, shape = m.group(1), m.group(2), m.group(3)
if number not in seen_numbers:
seen_numbers[number] = True
pads.append({"number": number, "type": ptype, "shape": shape})
return pads
def _unescape(s: str) -> str:
"""Reverse KiCad S-expression string escaping."""
return s.replace('\\"', '"').replace("\\\\", "\\")
def _extract_blocks(content: str, token: str) -> List[str]:
"""
Return all S-expression blocks that start with `(token ` by tracking
parenthesis depth. This correctly handles nested parens inside blocks.
"""
blocks: List[str] = []
pattern = re.compile(r"\(" + re.escape(token) + r"\b")
for match in pattern.finditer(content):
start = match.start()
depth = 0
i = start
while i < len(content):
ch = content[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
blocks.append(content[start : i + 1])
break
i += 1
return blocks
def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
"""
Compute the courtyard bounding box from F.CrtYd geometry.
Strategy:
1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end.
2. Fall back to fp_line segments on F.CrtYd — compute bounding box of
all endpoints.
"""
xs: List[float] = []
ys: List[float] = []
# --- fp_rect pass ---
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)
if s and e:
xs += [float(s.group(1)), float(e.group(1))]
ys += [float(s.group(2)), float(e.group(2))]
logger.debug(
f"_extract_courtyard: fp_rect F.CrtYd "
f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})"
)
# --- fp_line pass (only if fp_rect found nothing) ---
if not xs:
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):
xs.append(float(m.group(1)))
ys.append(float(m.group(2)))
if not xs:
logger.debug("_extract_courtyard: no F.CrtYd geometry found")
return None
width = round(abs(max(xs) - min(xs)), 6)
height = round(abs(max(ys) - min(ys)), 6)
logger.debug(f"_extract_courtyard: result width={width} height={height}")
return {"width": width, "height": height}

View File

@@ -1,13 +1,13 @@
# KiCAD MCP Python Interface Requirements
# Image processing
Pillow>=9.0.0
cairosvg>=2.7.0
# Type hints
typing-extensions>=4.0.0
# Logging
colorlog>=6.7.0
kicad-skip
# KiCAD MCP Python Interface Requirements
# Image processing
Pillow>=9.0.0
cairosvg>=2.7.0
# Type hints
typing-extensions>=4.0.0
# Logging
colorlog>=6.7.0
kicad-skip

View File

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

View File

@@ -1,342 +1,342 @@
"""
Resource definitions for exposing KiCAD project state via MCP
Resources follow the MCP 2025-06-18 specification, providing
read-only access to project data for LLM context.
"""
import base64
import json
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger("kicad_interface")
# =============================================================================
# RESOURCE DEFINITIONS
# =============================================================================
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",
},
{
"uri": "kicad://project/current/board",
"name": "Board Properties",
"description": "Comprehensive board information including dimensions, layer count, and design rules",
"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",
},
{
"uri": "kicad://project/current/nets",
"name": "Electrical Nets",
"description": "List of all electrical nets and their connections",
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/layers",
"name": "Layer Stack",
"description": "Board layer configuration and properties",
"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",
},
{
"uri": "kicad://project/current/drc-report",
"name": "DRC Violations",
"description": "Design Rule Check violations and warnings from last DRC run",
"mimeType": "application/json",
},
{
"uri": "kicad://board/preview.png",
"name": "Board Preview Image",
"description": "2D rendering of the current board state",
"mimeType": "image/png",
},
]
# =============================================================================
# RESOURCE READ HANDLERS
# =============================================================================
def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]:
"""
Handle reading a resource by URI
Args:
uri: Resource URI to read
interface: KiCADInterface instance with access to board state
Returns:
Dict with resource contents following MCP spec
"""
logger.info(f"Reading resource: {uri}")
handlers = {
"kicad://project/current/info": _get_project_info,
"kicad://project/current/board": _get_board_info,
"kicad://project/current/components": _get_components,
"kicad://project/current/nets": _get_nets,
"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,
}
handler = handlers.get(uri)
if handler:
try:
return handler(interface)
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)}"}]
}
else:
return {
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}]
}
# =============================================================================
# INDIVIDUAL RESOURCE HANDLERS
# =============================================================================
def _get_project_info(interface: Any) -> Dict[str, Any]:
"""Get current project information"""
result = interface.project_commands.get_project_info({})
if result.get("success"):
return {
"contents": [
{
"uri": "kicad://project/current/info",
"mimeType": "application/json",
"text": json.dumps(result.get("project", {}), indent=2),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/info",
"mimeType": "text/plain",
"text": "No project currently open",
}
]
}
def _get_board_info(interface: Any) -> Dict[str, Any]:
"""Get board properties and metadata"""
result = interface.board_commands.get_board_info({})
if result.get("success"):
return {
"contents": [
{
"uri": "kicad://project/current/board",
"mimeType": "application/json",
"text": json.dumps(result.get("board", {}), indent=2),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/board",
"mimeType": "text/plain",
"text": "No board currently loaded",
}
]
}
def _get_components(interface: Any) -> Dict[str, Any]:
"""Get list of all components"""
result = interface.component_commands.get_component_list({})
if result.get("success"):
components = result.get("components", [])
return {
"contents": [
{
"uri": "kicad://project/current/components",
"mimeType": "application/json",
"text": json.dumps(
{"count": len(components), "components": components}, indent=2
),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/components",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "components": []}, indent=2),
}
]
}
def _get_nets(interface: Any) -> Dict[str, Any]:
"""Get list of electrical nets"""
result = interface.routing_commands.get_nets_list({})
if result.get("success"):
nets = result.get("nets", [])
return {
"contents": [
{
"uri": "kicad://project/current/nets",
"mimeType": "application/json",
"text": json.dumps({"count": len(nets), "nets": nets}, indent=2),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/nets",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "nets": []}, indent=2),
}
]
}
def _get_layers(interface: Any) -> Dict[str, Any]:
"""Get layer stack information"""
result = interface.board_commands.get_layer_list({})
if result.get("success"):
layers = result.get("layers", [])
return {
"contents": [
{
"uri": "kicad://project/current/layers",
"mimeType": "application/json",
"text": json.dumps({"count": len(layers), "layers": layers}, indent=2),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/layers",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "layers": []}, indent=2),
}
]
}
def _get_design_rules(interface: Any) -> Dict[str, Any]:
"""Get design rule settings"""
result = interface.design_rule_commands.get_design_rules({})
if result.get("success"):
return {
"contents": [
{
"uri": "kicad://project/current/design-rules",
"mimeType": "application/json",
"text": json.dumps(result.get("rules", {}), indent=2),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/design-rules",
"mimeType": "text/plain",
"text": "Design rules not available",
}
]
}
def _get_drc_report(interface: Any) -> Dict[str, Any]:
"""Get DRC violations"""
result = interface.design_rule_commands.get_drc_violations({})
if result.get("success"):
violations = result.get("violations", [])
return {
"contents": [
{
"uri": "kicad://project/current/drc-report",
"mimeType": "application/json",
"text": json.dumps(
{"count": len(violations), "violations": violations}, indent=2
),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/drc-report",
"mimeType": "application/json",
"text": json.dumps(
{
"count": 0,
"violations": [],
"message": "Run DRC first to get violations",
},
indent=2,
),
}
]
}
def _get_board_preview(interface: Any) -> Dict[str, Any]:
"""Get board preview as PNG image"""
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})
if result.get("success") and "imageData" in result:
# Image data should already be base64 encoded
image_data = result.get("imageData", "")
return {
"contents": [
{
"uri": "kicad://board/preview.png",
"mimeType": "image/png",
"blob": image_data, # Base64 encoded PNG
}
]
}
else:
# Return a placeholder message
return {
"contents": [
{
"uri": "kicad://board/preview.png",
"mimeType": "text/plain",
"text": "Board preview not available",
}
]
}
"""
Resource definitions for exposing KiCAD project state via MCP
Resources follow the MCP 2025-06-18 specification, providing
read-only access to project data for LLM context.
"""
import base64
import json
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger("kicad_interface")
# =============================================================================
# RESOURCE DEFINITIONS
# =============================================================================
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",
},
{
"uri": "kicad://project/current/board",
"name": "Board Properties",
"description": "Comprehensive board information including dimensions, layer count, and design rules",
"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",
},
{
"uri": "kicad://project/current/nets",
"name": "Electrical Nets",
"description": "List of all electrical nets and their connections",
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/layers",
"name": "Layer Stack",
"description": "Board layer configuration and properties",
"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",
},
{
"uri": "kicad://project/current/drc-report",
"name": "DRC Violations",
"description": "Design Rule Check violations and warnings from last DRC run",
"mimeType": "application/json",
},
{
"uri": "kicad://board/preview.png",
"name": "Board Preview Image",
"description": "2D rendering of the current board state",
"mimeType": "image/png",
},
]
# =============================================================================
# RESOURCE READ HANDLERS
# =============================================================================
def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]:
"""
Handle reading a resource by URI
Args:
uri: Resource URI to read
interface: KiCADInterface instance with access to board state
Returns:
Dict with resource contents following MCP spec
"""
logger.info(f"Reading resource: {uri}")
handlers = {
"kicad://project/current/info": _get_project_info,
"kicad://project/current/board": _get_board_info,
"kicad://project/current/components": _get_components,
"kicad://project/current/nets": _get_nets,
"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,
}
handler = handlers.get(uri)
if handler:
try:
return handler(interface)
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)}"}]
}
else:
return {
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}]
}
# =============================================================================
# INDIVIDUAL RESOURCE HANDLERS
# =============================================================================
def _get_project_info(interface: Any) -> Dict[str, Any]:
"""Get current project information"""
result = interface.project_commands.get_project_info({})
if result.get("success"):
return {
"contents": [
{
"uri": "kicad://project/current/info",
"mimeType": "application/json",
"text": json.dumps(result.get("project", {}), indent=2),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/info",
"mimeType": "text/plain",
"text": "No project currently open",
}
]
}
def _get_board_info(interface: Any) -> Dict[str, Any]:
"""Get board properties and metadata"""
result = interface.board_commands.get_board_info({})
if result.get("success"):
return {
"contents": [
{
"uri": "kicad://project/current/board",
"mimeType": "application/json",
"text": json.dumps(result.get("board", {}), indent=2),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/board",
"mimeType": "text/plain",
"text": "No board currently loaded",
}
]
}
def _get_components(interface: Any) -> Dict[str, Any]:
"""Get list of all components"""
result = interface.component_commands.get_component_list({})
if result.get("success"):
components = result.get("components", [])
return {
"contents": [
{
"uri": "kicad://project/current/components",
"mimeType": "application/json",
"text": json.dumps(
{"count": len(components), "components": components}, indent=2
),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/components",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "components": []}, indent=2),
}
]
}
def _get_nets(interface: Any) -> Dict[str, Any]:
"""Get list of electrical nets"""
result = interface.routing_commands.get_nets_list({})
if result.get("success"):
nets = result.get("nets", [])
return {
"contents": [
{
"uri": "kicad://project/current/nets",
"mimeType": "application/json",
"text": json.dumps({"count": len(nets), "nets": nets}, indent=2),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/nets",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "nets": []}, indent=2),
}
]
}
def _get_layers(interface: Any) -> Dict[str, Any]:
"""Get layer stack information"""
result = interface.board_commands.get_layer_list({})
if result.get("success"):
layers = result.get("layers", [])
return {
"contents": [
{
"uri": "kicad://project/current/layers",
"mimeType": "application/json",
"text": json.dumps({"count": len(layers), "layers": layers}, indent=2),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/layers",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "layers": []}, indent=2),
}
]
}
def _get_design_rules(interface: Any) -> Dict[str, Any]:
"""Get design rule settings"""
result = interface.design_rule_commands.get_design_rules({})
if result.get("success"):
return {
"contents": [
{
"uri": "kicad://project/current/design-rules",
"mimeType": "application/json",
"text": json.dumps(result.get("rules", {}), indent=2),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/design-rules",
"mimeType": "text/plain",
"text": "Design rules not available",
}
]
}
def _get_drc_report(interface: Any) -> Dict[str, Any]:
"""Get DRC violations"""
result = interface.design_rule_commands.get_drc_violations({})
if result.get("success"):
violations = result.get("violations", [])
return {
"contents": [
{
"uri": "kicad://project/current/drc-report",
"mimeType": "application/json",
"text": json.dumps(
{"count": len(violations), "violations": violations}, indent=2
),
}
]
}
else:
return {
"contents": [
{
"uri": "kicad://project/current/drc-report",
"mimeType": "application/json",
"text": json.dumps(
{
"count": 0,
"violations": [],
"message": "Run DRC first to get violations",
},
indent=2,
),
}
]
}
def _get_board_preview(interface: Any) -> Dict[str, Any]:
"""Get board preview as PNG image"""
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})
if result.get("success") and "imageData" in result:
# Image data should already be base64 encoded
image_data = result.get("imageData", "")
return {
"contents": [
{
"uri": "kicad://board/preview.png",
"mimeType": "image/png",
"blob": image_data, # Base64 encoded PNG
}
]
}
else:
# Return a placeholder message
return {
"contents": [
{
"uri": "kicad://board/preview.png",
"mimeType": "text/plain",
"text": "Board preview not available",
}
]
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,137 +1,137 @@
(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")
(uuid b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e)
(paper "A4")
(lib_symbols
(symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes)
(property "Reference" "R" (at 2.032 0 90)
(effects (font (size 1.27 1.27)))
)
(property "Value" "R" (at 0 0 90)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -1.778 0 90)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
(stroke (width 0.254) (type default))
(fill (type none))
)
)
(symbol "R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes)
(property "Reference" "C" (at 0.635 2.54 0)
(effects (font (size 1.27 1.27)) (justify left))
)
(property "Value" "C" (at 0.635 -2.54 0)
(effects (font (size 1.27 1.27)) (justify left))
)
(property "Footprint" "" (at 0.9652 -3.81 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "C_0_1"
(polyline
(pts
(xy -2.032 -0.762)
(xy 2.032 -0.762)
)
(stroke (width 0.508) (type default))
(fill (type none))
)
(polyline
(pts
(xy -2.032 0.762)
(xy 2.032 0.762)
)
(stroke (width 0.508) (type default))
(fill (type none))
)
)
(symbol "C_1_1"
(pin passive line (at 0 3.81 270) (length 2.794)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 0 -3.81 90) (length 2.794)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes)
(property "Reference" "D" (at 0 2.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "LED" (at 0 -2.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "LED_0_1"
(polyline
(pts
(xy -1.27 -1.27)
(xy -1.27 1.27)
)
(stroke (width 0.254) (type default))
(fill (type none))
)
(polyline
(pts
(xy -1.27 0)
(xy 1.27 0)
)
(stroke (width 0) (type default))
(fill (type none))
)
(polyline
(pts
(xy 1.27 -1.27)
(xy 1.27 1.27)
(xy -1.27 0)
(xy 1.27 -1.27)
)
(stroke (width 0.254) (type default))
(fill (type none))
)
)
(symbol "LED_1_1"
(pin passive line (at -3.81 0 0) (length 2.54)
(name "K" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 3.81 0 180) (length 2.54)
(name "A" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
)
(sheet_instances
(path "/" (page "1"))
)
)
(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")
(uuid b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e)
(paper "A4")
(lib_symbols
(symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes)
(property "Reference" "R" (at 2.032 0 90)
(effects (font (size 1.27 1.27)))
)
(property "Value" "R" (at 0 0 90)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -1.778 0 90)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
(stroke (width 0.254) (type default))
(fill (type none))
)
)
(symbol "R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes)
(property "Reference" "C" (at 0.635 2.54 0)
(effects (font (size 1.27 1.27)) (justify left))
)
(property "Value" "C" (at 0.635 -2.54 0)
(effects (font (size 1.27 1.27)) (justify left))
)
(property "Footprint" "" (at 0.9652 -3.81 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "C_0_1"
(polyline
(pts
(xy -2.032 -0.762)
(xy 2.032 -0.762)
)
(stroke (width 0.508) (type default))
(fill (type none))
)
(polyline
(pts
(xy -2.032 0.762)
(xy 2.032 0.762)
)
(stroke (width 0.508) (type default))
(fill (type none))
)
)
(symbol "C_1_1"
(pin passive line (at 0 3.81 270) (length 2.794)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 0 -3.81 90) (length 2.794)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes)
(property "Reference" "D" (at 0 2.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "LED" (at 0 -2.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "LED_0_1"
(polyline
(pts
(xy -1.27 -1.27)
(xy -1.27 1.27)
)
(stroke (width 0.254) (type default))
(fill (type none))
)
(polyline
(pts
(xy -1.27 0)
(xy 1.27 0)
)
(stroke (width 0) (type default))
(fill (type none))
)
(polyline
(pts
(xy 1.27 -1.27)
(xy 1.27 1.27)
(xy -1.27 0)
(xy 1.27 -1.27)
)
(stroke (width 0.254) (type default))
(fill (type none))
)
)
(symbol "LED_1_1"
(pin passive line (at -3.81 0 0) (length 2.54)
(name "K" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 3.81 0 180) (length 2.54)
(name "A" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
)
(sheet_instances
(path "/" (page "1"))
)
)

View File

@@ -1,194 +1,194 @@
(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")
(uuid a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d)
(paper "A4")
(lib_symbols
(symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes)
(property "Reference" "R" (at 2.032 0 90)
(effects (font (size 1.27 1.27)))
)
(property "Value" "R" (at 0 0 90)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -1.778 0 90)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
(stroke (width 0.254) (type default))
(fill (type none))
)
)
(symbol "R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes)
(property "Reference" "C" (at 0.635 2.54 0)
(effects (font (size 1.27 1.27)) (justify left))
)
(property "Value" "C" (at 0.635 -2.54 0)
(effects (font (size 1.27 1.27)) (justify left))
)
(property "Footprint" "" (at 0.9652 -3.81 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "C_0_1"
(polyline
(pts
(xy -2.032 -0.762)
(xy 2.032 -0.762)
)
(stroke (width 0.508) (type default))
(fill (type none))
)
(polyline
(pts
(xy -2.032 0.762)
(xy 2.032 0.762)
)
(stroke (width 0.508) (type default))
(fill (type none))
)
)
(symbol "C_1_1"
(pin passive line (at 0 3.81 270) (length 2.794)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 0 -3.81 90) (length 2.794)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes)
(property "Reference" "D" (at 0 2.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "LED" (at 0 -2.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "LED_0_1"
(polyline
(pts
(xy -1.27 -1.27)
(xy -1.27 1.27)
)
(stroke (width 0.254) (type default))
(fill (type none))
)
(polyline
(pts
(xy -1.27 0)
(xy 1.27 0)
)
(stroke (width 0) (type default))
(fill (type none))
)
(polyline
(pts
(xy 1.27 -1.27)
(xy 1.27 1.27)
(xy -1.27 0)
(xy 1.27 -1.27)
)
(stroke (width 0.254) (type default))
(fill (type none))
)
)
(symbol "LED_1_1"
(pin passive line (at -3.81 0 0) (length 2.54)
(name "K" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 3.81 0 180) (length 2.54)
(name "A" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
)
(symbol (lib_id "Device:R") (at -100 -100 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000001)
(property "Reference" "_TEMPLATE_R" (at -100 -102.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "R_TEMPLATE" (at -100 -100 90)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -100 90)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -100 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 00000000-0000-0000-0000-000000000010))
(pin "2" (uuid 00000000-0000-0000-0000-000000000011))
)
(symbol (lib_id "Device:C") (at -100 -110 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000002)
(property "Reference" "_TEMPLATE_C" (at -100 -107.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "C_TEMPLATE" (at -100 -112.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -110 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -110 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 00000000-0000-0000-0000-000000000020))
(pin "2" (uuid 00000000-0000-0000-0000-000000000021))
)
(symbol (lib_id "Device:LED") (at -100 -120 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000003)
(property "Reference" "_TEMPLATE_D" (at -100 -117.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "LED_TEMPLATE" (at -100 -122.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -120 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -120 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 00000000-0000-0000-0000-000000000030))
(pin "2" (uuid 00000000-0000-0000-0000-000000000031))
)
(sheet_instances
(path "/" (page "1"))
)
)
(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")
(uuid a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d)
(paper "A4")
(lib_symbols
(symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes)
(property "Reference" "R" (at 2.032 0 90)
(effects (font (size 1.27 1.27)))
)
(property "Value" "R" (at 0 0 90)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -1.778 0 90)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "R_0_1"
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
(stroke (width 0.254) (type default))
(fill (type none))
)
)
(symbol "R_1_1"
(pin passive line (at 0 3.81 270) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 0 -3.81 90) (length 1.27)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes)
(property "Reference" "C" (at 0.635 2.54 0)
(effects (font (size 1.27 1.27)) (justify left))
)
(property "Value" "C" (at 0.635 -2.54 0)
(effects (font (size 1.27 1.27)) (justify left))
)
(property "Footprint" "" (at 0.9652 -3.81 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "C_0_1"
(polyline
(pts
(xy -2.032 -0.762)
(xy 2.032 -0.762)
)
(stroke (width 0.508) (type default))
(fill (type none))
)
(polyline
(pts
(xy -2.032 0.762)
(xy 2.032 0.762)
)
(stroke (width 0.508) (type default))
(fill (type none))
)
)
(symbol "C_1_1"
(pin passive line (at 0 3.81 270) (length 2.794)
(name "~" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 0 -3.81 90) (length 2.794)
(name "~" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
(symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes)
(property "Reference" "D" (at 0 2.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "LED" (at 0 -2.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 0 0 0)
(effects (font (size 1.27 1.27)) hide)
)
(symbol "LED_0_1"
(polyline
(pts
(xy -1.27 -1.27)
(xy -1.27 1.27)
)
(stroke (width 0.254) (type default))
(fill (type none))
)
(polyline
(pts
(xy -1.27 0)
(xy 1.27 0)
)
(stroke (width 0) (type default))
(fill (type none))
)
(polyline
(pts
(xy 1.27 -1.27)
(xy 1.27 1.27)
(xy -1.27 0)
(xy 1.27 -1.27)
)
(stroke (width 0.254) (type default))
(fill (type none))
)
)
(symbol "LED_1_1"
(pin passive line (at -3.81 0 0) (length 2.54)
(name "K" (effects (font (size 1.27 1.27))))
(number "1" (effects (font (size 1.27 1.27))))
)
(pin passive line (at 3.81 0 180) (length 2.54)
(name "A" (effects (font (size 1.27 1.27))))
(number "2" (effects (font (size 1.27 1.27))))
)
)
)
)
(symbol (lib_id "Device:R") (at -100 -100 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000001)
(property "Reference" "_TEMPLATE_R" (at -100 -102.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "R_TEMPLATE" (at -100 -100 90)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -100 90)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -100 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 00000000-0000-0000-0000-000000000010))
(pin "2" (uuid 00000000-0000-0000-0000-000000000011))
)
(symbol (lib_id "Device:C") (at -100 -110 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000002)
(property "Reference" "_TEMPLATE_C" (at -100 -107.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "C_TEMPLATE" (at -100 -112.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -110 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -110 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 00000000-0000-0000-0000-000000000020))
(pin "2" (uuid 00000000-0000-0000-0000-000000000021))
)
(symbol (lib_id "Device:LED") (at -100 -120 0) (unit 1)
(in_bom no) (on_board no) (dnp yes)
(uuid 00000000-0000-0000-0000-000000000003)
(property "Reference" "_TEMPLATE_D" (at -100 -117.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "LED_TEMPLATE" (at -100 -122.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "" (at -100 -120 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at -100 -120 0)
(effects (font (size 1.27 1.27)) hide)
)
(pin "1" (uuid 00000000-0000-0000-0000-000000000030))
(pin "2" (uuid 00000000-0000-0000-0000-000000000031))
)
(sheet_instances
(path "/" (page "1"))
)
)

File diff suppressed because it is too large Load Diff

View File

@@ -1,319 +1,319 @@
#!/usr/bin/env python3
"""
Test script for KiCAD IPC Backend
This script tests the real-time UI synchronization capabilities
of the IPC backend. Run this while KiCAD is open with a board.
Prerequisites:
1. KiCAD 9.0+ must be running
2. IPC API must be enabled: Preferences > Plugins > Enable IPC API Server
3. A board should be open in the PCB editor
Usage:
./venv/bin/python python/test_ipc_backend.py
"""
import os
import sys
from typing import Any, Optional
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import logging
# Set up logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
def test_connection() -> Optional[Any]:
"""Test basic IPC connection to KiCAD."""
print("\n" + "=" * 60)
print("TEST 1: IPC Connection")
print("=" * 60)
try:
from kicad_api.ipc_backend import IPCBackend
backend = IPCBackend()
print("✓ IPCBackend created")
if backend.connect():
print(f"✓ Connected to KiCAD via IPC")
print(f" Version: {backend.get_version()}")
return backend
else:
print("✗ Failed to connect to KiCAD")
return None
except ImportError as e:
print(f"✗ kicad-python not installed: {e}")
print(" Install with: pip install kicad-python")
return None
except Exception as e:
print(f"✗ Connection failed: {e}")
print("\nMake sure:")
print(" 1. KiCAD is running")
print(" 2. IPC API is enabled (Preferences > Plugins > Enable IPC API Server)")
print(" 3. A board is open in the PCB editor")
return None
def test_board_access(backend: Any) -> Optional[Any]:
"""Test board access and component listing."""
print("\n" + "=" * 60)
print("TEST 2: Board Access")
print("=" * 60)
try:
board_api = backend.get_board()
print("✓ Got board API")
# List components
components = board_api.list_components()
print(f"✓ Found {len(components)} components on board")
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)
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
return board_api
except Exception as e:
print(f"✗ Failed to access board: {e}")
return None
def test_board_info(board_api: Any) -> bool:
"""Test getting board information."""
print("\n" + "=" * 60)
print("TEST 3: Board Information")
print("=" * 60)
try:
# Get board size
size = board_api.get_size()
print(f"✓ Board size: {size.get('width', 0):.2f} x {size.get('height', 0):.2f} mm")
# Get enabled layers
try:
layers = board_api.get_enabled_layers()
print(f"✓ Enabled layers: {len(layers)}")
if layers:
print(f" Layers: {', '.join(layers[:5])}...")
except Exception as e:
print(f" (Layer info not available: {e})")
# Get nets
nets = board_api.get_nets()
print(f"✓ Found {len(nets)} nets")
if nets:
print(f" First 5 nets: {', '.join([n.get('name', '') for n in nets[:5]])}")
# Get tracks
tracks = board_api.get_tracks()
print(f"✓ Found {len(tracks)} tracks")
# Get vias
vias = board_api.get_vias()
print(f"✓ Found {len(vias)} vias")
return True
except Exception as e:
print(f"✗ Failed to get board info: {e}")
return False
def test_realtime_track(board_api: Any, interactive: bool = False) -> bool:
"""Test adding a track in real-time (appears immediately in KiCAD UI)."""
print("\n" + "=" * 60)
print("TEST 4: Real-time Track Addition")
print("=" * 60)
print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.")
print("Watch the KiCAD window!")
if interactive:
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
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"
)
if success:
print("✓ Track added! Check the KiCAD window - it should appear at (100, 100) mm")
print(" Track: (100, 100) -> (120, 100) mm, width 0.25mm on F.Cu")
else:
print("✗ Failed to add track")
return success
except Exception as e:
print(f"✗ Error adding track: {e}")
return False
def test_realtime_via(board_api: Any, interactive: bool = False) -> bool:
"""Test adding a via in real-time (appears immediately in KiCAD UI)."""
print("\n" + "=" * 60)
print("TEST 5: Real-time Via Addition")
print("=" * 60)
print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.")
print("Watch the KiCAD window!")
if interactive:
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
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")
if success:
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
print(" Via: diameter 0.8mm, drill 0.4mm")
else:
print("✗ Failed to add via")
return success
except Exception as e:
print(f"✗ Error adding via: {e}")
return False
def test_realtime_text(board_api: Any, interactive: bool = False) -> bool:
"""Test adding text in real-time."""
print("\n" + "=" * 60)
print("TEST 6: Real-time Text Addition")
print("=" * 60)
print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.")
if interactive:
response = input("\nProceed with adding test text? [y/N]: ").strip().lower()
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)
if success:
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
else:
print("✗ Failed to add text")
return success
except Exception as e:
print(f"✗ Error adding text: {e}")
return False
def test_selection(board_api: Any, interactive: bool = False) -> bool:
"""Test getting the current selection from KiCAD UI."""
print("\n" + "=" * 60)
print("TEST 7: UI Selection")
print("=" * 60)
if interactive:
print("\nSelect some items in KiCAD, then press Enter...")
input()
else:
print("\nReading current selection...")
try:
selection = board_api.get_selection()
print(f"✓ Found {len(selection)} selected items")
for item in selection[:10]:
print(f" - {item.get('type', 'Unknown')} (ID: {item.get('id', 'N/A')})")
return True
except Exception as e:
print(f"✗ Failed to get selection: {e}")
return False
def run_all_tests(interactive: bool = False) -> bool:
"""Run all IPC backend tests."""
print("\n" + "=" * 60)
print("KiCAD IPC Backend Test Suite")
print("=" * 60)
print("\nThis script tests real-time communication with KiCAD via IPC API.")
print("Make sure KiCAD is running with a board open.\n")
# Test connection
backend = test_connection()
if not backend:
print("\n" + "=" * 60)
print("TESTS FAILED: Could not connect to KiCAD")
print("=" * 60)
return False
# Test board access
board_api = test_board_access(backend)
if not board_api:
print("\n" + "=" * 60)
print("TESTS FAILED: Could not access board")
print("=" * 60)
return False
# Test board info
test_board_info(board_api)
# Test real-time modifications
test_realtime_track(board_api, interactive)
test_realtime_via(board_api, interactive)
test_realtime_text(board_api, interactive)
# Test selection
test_selection(board_api, interactive)
print("\n" + "=" * 60)
print("TESTS COMPLETE")
print("=" * 60)
print("\nThe IPC backend is working! Changes appear in real-time.")
print("No manual reload required - this is the power of the IPC API!")
# Cleanup
backend.disconnect()
return True
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)",
)
args = parser.parse_args()
success = run_all_tests(interactive=args.interactive)
sys.exit(0 if success else 1)
#!/usr/bin/env python3
"""
Test script for KiCAD IPC Backend
This script tests the real-time UI synchronization capabilities
of the IPC backend. Run this while KiCAD is open with a board.
Prerequisites:
1. KiCAD 9.0+ must be running
2. IPC API must be enabled: Preferences > Plugins > Enable IPC API Server
3. A board should be open in the PCB editor
Usage:
./venv/bin/python python/test_ipc_backend.py
"""
import os
import sys
from typing import Any, Optional
# Add parent directory to path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import logging
# Set up logging
logging.basicConfig(
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
def test_connection() -> Optional[Any]:
"""Test basic IPC connection to KiCAD."""
print("\n" + "=" * 60)
print("TEST 1: IPC Connection")
print("=" * 60)
try:
from kicad_api.ipc_backend import IPCBackend
backend = IPCBackend()
print("✓ IPCBackend created")
if backend.connect():
print(f"✓ Connected to KiCAD via IPC")
print(f" Version: {backend.get_version()}")
return backend
else:
print("✗ Failed to connect to KiCAD")
return None
except ImportError as e:
print(f"✗ kicad-python not installed: {e}")
print(" Install with: pip install kicad-python")
return None
except Exception as e:
print(f"✗ Connection failed: {e}")
print("\nMake sure:")
print(" 1. KiCAD is running")
print(" 2. IPC API is enabled (Preferences > Plugins > Enable IPC API Server)")
print(" 3. A board is open in the PCB editor")
return None
def test_board_access(backend: Any) -> Optional[Any]:
"""Test board access and component listing."""
print("\n" + "=" * 60)
print("TEST 2: Board Access")
print("=" * 60)
try:
board_api = backend.get_board()
print("✓ Got board API")
# List components
components = board_api.list_components()
print(f"✓ Found {len(components)} components on board")
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)
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
return board_api
except Exception as e:
print(f"✗ Failed to access board: {e}")
return None
def test_board_info(board_api: Any) -> bool:
"""Test getting board information."""
print("\n" + "=" * 60)
print("TEST 3: Board Information")
print("=" * 60)
try:
# Get board size
size = board_api.get_size()
print(f"✓ Board size: {size.get('width', 0):.2f} x {size.get('height', 0):.2f} mm")
# Get enabled layers
try:
layers = board_api.get_enabled_layers()
print(f"✓ Enabled layers: {len(layers)}")
if layers:
print(f" Layers: {', '.join(layers[:5])}...")
except Exception as e:
print(f" (Layer info not available: {e})")
# Get nets
nets = board_api.get_nets()
print(f"✓ Found {len(nets)} nets")
if nets:
print(f" First 5 nets: {', '.join([n.get('name', '') for n in nets[:5]])}")
# Get tracks
tracks = board_api.get_tracks()
print(f"✓ Found {len(tracks)} tracks")
# Get vias
vias = board_api.get_vias()
print(f"✓ Found {len(vias)} vias")
return True
except Exception as e:
print(f"✗ Failed to get board info: {e}")
return False
def test_realtime_track(board_api: Any, interactive: bool = False) -> bool:
"""Test adding a track in real-time (appears immediately in KiCAD UI)."""
print("\n" + "=" * 60)
print("TEST 4: Real-time Track Addition")
print("=" * 60)
print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.")
print("Watch the KiCAD window!")
if interactive:
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
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"
)
if success:
print("✓ Track added! Check the KiCAD window - it should appear at (100, 100) mm")
print(" Track: (100, 100) -> (120, 100) mm, width 0.25mm on F.Cu")
else:
print("✗ Failed to add track")
return success
except Exception as e:
print(f"✗ Error adding track: {e}")
return False
def test_realtime_via(board_api: Any, interactive: bool = False) -> bool:
"""Test adding a via in real-time (appears immediately in KiCAD UI)."""
print("\n" + "=" * 60)
print("TEST 5: Real-time Via Addition")
print("=" * 60)
print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.")
print("Watch the KiCAD window!")
if interactive:
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
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")
if success:
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
print(" Via: diameter 0.8mm, drill 0.4mm")
else:
print("✗ Failed to add via")
return success
except Exception as e:
print(f"✗ Error adding via: {e}")
return False
def test_realtime_text(board_api: Any, interactive: bool = False) -> bool:
"""Test adding text in real-time."""
print("\n" + "=" * 60)
print("TEST 6: Real-time Text Addition")
print("=" * 60)
print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.")
if interactive:
response = input("\nProceed with adding test text? [y/N]: ").strip().lower()
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)
if success:
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
else:
print("✗ Failed to add text")
return success
except Exception as e:
print(f"✗ Error adding text: {e}")
return False
def test_selection(board_api: Any, interactive: bool = False) -> bool:
"""Test getting the current selection from KiCAD UI."""
print("\n" + "=" * 60)
print("TEST 7: UI Selection")
print("=" * 60)
if interactive:
print("\nSelect some items in KiCAD, then press Enter...")
input()
else:
print("\nReading current selection...")
try:
selection = board_api.get_selection()
print(f"✓ Found {len(selection)} selected items")
for item in selection[:10]:
print(f" - {item.get('type', 'Unknown')} (ID: {item.get('id', 'N/A')})")
return True
except Exception as e:
print(f"✗ Failed to get selection: {e}")
return False
def run_all_tests(interactive: bool = False) -> bool:
"""Run all IPC backend tests."""
print("\n" + "=" * 60)
print("KiCAD IPC Backend Test Suite")
print("=" * 60)
print("\nThis script tests real-time communication with KiCAD via IPC API.")
print("Make sure KiCAD is running with a board open.\n")
# Test connection
backend = test_connection()
if not backend:
print("\n" + "=" * 60)
print("TESTS FAILED: Could not connect to KiCAD")
print("=" * 60)
return False
# Test board access
board_api = test_board_access(backend)
if not board_api:
print("\n" + "=" * 60)
print("TESTS FAILED: Could not access board")
print("=" * 60)
return False
# Test board info
test_board_info(board_api)
# Test real-time modifications
test_realtime_track(board_api, interactive)
test_realtime_via(board_api, interactive)
test_realtime_text(board_api, interactive)
# Test selection
test_selection(board_api, interactive)
print("\n" + "=" * 60)
print("TESTS COMPLETE")
print("=" * 60)
print("\nThe IPC backend is working! Changes appear in real-time.")
print("No manual reload required - this is the power of the IPC API!")
# Cleanup
backend.disconnect()
return True
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)",
)
args = parser.parse_args()
success = run_all_tests(interactive=args.interactive)
sys.exit(0 if success else 1)

View File

@@ -1 +1 @@
"""Utility modules for KiCAD MCP Server"""
"""Utility modules for KiCAD MCP Server"""

View File

@@ -1,349 +1,349 @@
"""
KiCAD Process Management Utilities
Detects if KiCAD is running and provides auto-launch functionality.
"""
import ctypes
import logging
import os
import platform
import subprocess
import time
from ctypes import wintypes
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
class KiCADProcessManager:
"""Manages KiCAD process detection and launching"""
@staticmethod
def _windows_list_processes() -> List[dict]:
"""List running processes on Windows using Toolhelp API."""
processes: List[dict] = []
try:
TH32CS_SNAPPROCESS = 0x00000002
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
)
class PROCESSENTRY32W(ctypes.Structure):
_fields_ = [
("dwSize", wintypes.DWORD),
("cntUsage", wintypes.DWORD),
("th32ProcessID", wintypes.DWORD),
("th32DefaultHeapID", ulong_ptr),
("th32ModuleID", wintypes.DWORD),
("cntThreads", wintypes.DWORD),
("th32ParentProcessID", wintypes.DWORD),
("pcPriClassBase", wintypes.LONG),
("dwFlags", wintypes.DWORD),
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
]
CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot
Process32FirstW = ctypes.windll.kernel32.Process32FirstW
Process32NextW = ctypes.windll.kernel32.Process32NextW
CloseHandle = ctypes.windll.kernel32.CloseHandle
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
if snapshot == wintypes.HANDLE(-1).value:
return processes
entry = PROCESSENTRY32W()
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
if Process32FirstW(snapshot, ctypes.byref(entry)):
while True:
processes.append(
{
"pid": str(entry.th32ProcessID),
"name": entry.szExeFile,
"command": entry.szExeFile,
}
)
if not Process32NextW(snapshot, ctypes.byref(entry)):
break
CloseHandle(snapshot)
except Exception as e:
logger.error(f"Error listing Windows processes: {e}")
return processes
@staticmethod
def is_running() -> bool:
"""
Check if KiCAD is currently running
Returns:
True if KiCAD process found, False otherwise
"""
system = platform.system()
try:
if system == "Linux":
# 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
)
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
)
# Double-check it's not our own process
if result.returncode == 0:
pids = result.stdout.strip().split("\n")
for pid in pids:
try:
cmdline = subprocess.run(
["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
)
if "kicad_interface.py" not in cmdline.stdout:
return True
except:
pass
return False
elif system == "Darwin": # macOS
result = subprocess.run(
["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
)
return result.returncode == 0
elif system == "Windows":
processes = KiCADProcessManager._windows_list_processes()
for proc in processes:
name = (proc.get("name") or "").lower()
if name in ("pcbnew.exe", "kicad.exe"):
return True
return False
else:
logger.warning(f"Process detection not implemented for {system}")
return False
except Exception as e:
logger.error(f"Error checking if KiCAD is running: {e}")
return False
@staticmethod
def get_executable_path() -> Optional[Path]:
"""
Get path to KiCAD executable
Returns:
Path to pcbnew/kicad executable, or None if not found
"""
system = platform.system()
# Try to find executable in PATH first
for cmd in ["pcbnew", "kicad"]:
result = subprocess.run(
["which", cmd] if system != "Windows" else ["where", cmd],
capture_output=True,
text=True,
encoding="mbcs" if system == "Windows" else None,
errors="ignore" if system == "Windows" else None,
timeout=5 if system == "Windows" else None,
)
if result.returncode == 0:
exe_path = result.stdout.strip().split("\n")[0]
logger.info(f"Found KiCAD executable: {exe_path}")
return Path(exe_path)
# Platform-specific default paths
if system == "Linux":
candidates = [
Path("/usr/bin/pcbnew"),
Path("/usr/local/bin/pcbnew"),
Path("/usr/bin/kicad"),
]
elif system == "Darwin": # macOS
candidates = [
Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad"),
Path("/Applications/KiCad/pcbnew.app/Contents/MacOS/pcbnew"),
]
elif system == "Windows":
candidates = [
Path("C:/Program Files/KiCad/9.0/bin/pcbnew.exe"),
Path("C:/Program Files/KiCad/8.0/bin/pcbnew.exe"),
Path("C:/Program Files (x86)/KiCad/9.0/bin/pcbnew.exe"),
]
else:
candidates = []
for path in candidates:
if path.exists():
logger.info(f"Found KiCAD executable: {path}")
return path
logger.warning("Could not find KiCAD executable")
return None
@staticmethod
def launch(project_path: Optional[Path] = None, wait_for_start: bool = True) -> bool:
"""
Launch KiCAD PCB Editor
Args:
project_path: Optional path to .kicad_pcb file to open
wait_for_start: Wait for process to start before returning
Returns:
True if launch successful, False otherwise
"""
try:
# Check if already running
if KiCADProcessManager.is_running():
logger.info("KiCAD is already running")
return True
# Find executable
exe_path = KiCADProcessManager.get_executable_path()
if not exe_path:
logger.error("Cannot launch KiCAD: executable not found")
return False
# Build command
cmd = [str(exe_path)]
if project_path:
cmd.append(str(project_path))
logger.info(f"Launching KiCAD: {' '.join(cmd)}")
# Launch process in background
system = platform.system()
if system == "Windows":
# Windows: Use CREATE_NEW_PROCESS_GROUP to detach
subprocess.Popen(
cmd,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
# Unix: Use nohup or start in background
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
# Wait for process to start
if wait_for_start:
logger.info("Waiting for KiCAD to start...")
for i in range(10): # Wait up to 5 seconds
time.sleep(0.5)
if KiCADProcessManager.is_running():
logger.info("✓ KiCAD started successfully")
return True
logger.warning("KiCAD process not detected after launch")
# Return True anyway, it might be starting
return True
return True
except Exception as e:
logger.error(f"Error launching KiCAD: {e}")
return False
@staticmethod
def get_process_info() -> List[dict]:
"""
Get information about running KiCAD processes
Returns:
List of process info dicts with pid, name, and command
"""
system = platform.system()
processes = []
try:
if system in ["Linux", "Darwin"]:
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
):
# 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(
{
"pid": parts[1],
"name": parts[10],
"command": " ".join(parts[10:]),
}
)
elif system == "Windows":
for proc in KiCADProcessManager._windows_list_processes():
name = (proc.get("name") or "").lower()
if "pcbnew" in name or "kicad" in name:
processes.append(proc)
except Exception as e:
logger.error(f"Error getting process info: {e}")
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
Args:
project_path: Optional path to .kicad_pcb file to open
auto_launch: If True, launch KiCAD if not running
Returns:
Dict with status information
"""
manager = KiCADProcessManager()
is_running = manager.is_running()
if is_running:
processes = manager.get_process_info()
return {
"running": True,
"launched": False,
"processes": processes,
"message": "KiCAD is already running",
}
if not auto_launch:
return {
"running": False,
"launched": False,
"processes": [],
"message": "KiCAD is not running (auto-launch disabled)",
}
# Try to launch
logger.info("KiCAD not detected, attempting to launch...")
success = manager.launch(project_path)
return {
"running": success,
"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,
}
"""
KiCAD Process Management Utilities
Detects if KiCAD is running and provides auto-launch functionality.
"""
import ctypes
import logging
import os
import platform
import subprocess
import time
from ctypes import wintypes
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
class KiCADProcessManager:
"""Manages KiCAD process detection and launching"""
@staticmethod
def _windows_list_processes() -> List[dict]:
"""List running processes on Windows using Toolhelp API."""
processes: List[dict] = []
try:
TH32CS_SNAPPROCESS = 0x00000002
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
)
class PROCESSENTRY32W(ctypes.Structure):
_fields_ = [
("dwSize", wintypes.DWORD),
("cntUsage", wintypes.DWORD),
("th32ProcessID", wintypes.DWORD),
("th32DefaultHeapID", ulong_ptr),
("th32ModuleID", wintypes.DWORD),
("cntThreads", wintypes.DWORD),
("th32ParentProcessID", wintypes.DWORD),
("pcPriClassBase", wintypes.LONG),
("dwFlags", wintypes.DWORD),
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
]
CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot
Process32FirstW = ctypes.windll.kernel32.Process32FirstW
Process32NextW = ctypes.windll.kernel32.Process32NextW
CloseHandle = ctypes.windll.kernel32.CloseHandle
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
if snapshot == wintypes.HANDLE(-1).value:
return processes
entry = PROCESSENTRY32W()
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
if Process32FirstW(snapshot, ctypes.byref(entry)):
while True:
processes.append(
{
"pid": str(entry.th32ProcessID),
"name": entry.szExeFile,
"command": entry.szExeFile,
}
)
if not Process32NextW(snapshot, ctypes.byref(entry)):
break
CloseHandle(snapshot)
except Exception as e:
logger.error(f"Error listing Windows processes: {e}")
return processes
@staticmethod
def is_running() -> bool:
"""
Check if KiCAD is currently running
Returns:
True if KiCAD process found, False otherwise
"""
system = platform.system()
try:
if system == "Linux":
# 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
)
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
)
# Double-check it's not our own process
if result.returncode == 0:
pids = result.stdout.strip().split("\n")
for pid in pids:
try:
cmdline = subprocess.run(
["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
)
if "kicad_interface.py" not in cmdline.stdout:
return True
except:
pass
return False
elif system == "Darwin": # macOS
result = subprocess.run(
["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
)
return result.returncode == 0
elif system == "Windows":
processes = KiCADProcessManager._windows_list_processes()
for proc in processes:
name = (proc.get("name") or "").lower()
if name in ("pcbnew.exe", "kicad.exe"):
return True
return False
else:
logger.warning(f"Process detection not implemented for {system}")
return False
except Exception as e:
logger.error(f"Error checking if KiCAD is running: {e}")
return False
@staticmethod
def get_executable_path() -> Optional[Path]:
"""
Get path to KiCAD executable
Returns:
Path to pcbnew/kicad executable, or None if not found
"""
system = platform.system()
# Try to find executable in PATH first
for cmd in ["pcbnew", "kicad"]:
result = subprocess.run(
["which", cmd] if system != "Windows" else ["where", cmd],
capture_output=True,
text=True,
encoding="mbcs" if system == "Windows" else None,
errors="ignore" if system == "Windows" else None,
timeout=5 if system == "Windows" else None,
)
if result.returncode == 0:
exe_path = result.stdout.strip().split("\n")[0]
logger.info(f"Found KiCAD executable: {exe_path}")
return Path(exe_path)
# Platform-specific default paths
if system == "Linux":
candidates = [
Path("/usr/bin/pcbnew"),
Path("/usr/local/bin/pcbnew"),
Path("/usr/bin/kicad"),
]
elif system == "Darwin": # macOS
candidates = [
Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad"),
Path("/Applications/KiCad/pcbnew.app/Contents/MacOS/pcbnew"),
]
elif system == "Windows":
candidates = [
Path("C:/Program Files/KiCad/9.0/bin/pcbnew.exe"),
Path("C:/Program Files/KiCad/8.0/bin/pcbnew.exe"),
Path("C:/Program Files (x86)/KiCad/9.0/bin/pcbnew.exe"),
]
else:
candidates = []
for path in candidates:
if path.exists():
logger.info(f"Found KiCAD executable: {path}")
return path
logger.warning("Could not find KiCAD executable")
return None
@staticmethod
def launch(project_path: Optional[Path] = None, wait_for_start: bool = True) -> bool:
"""
Launch KiCAD PCB Editor
Args:
project_path: Optional path to .kicad_pcb file to open
wait_for_start: Wait for process to start before returning
Returns:
True if launch successful, False otherwise
"""
try:
# Check if already running
if KiCADProcessManager.is_running():
logger.info("KiCAD is already running")
return True
# Find executable
exe_path = KiCADProcessManager.get_executable_path()
if not exe_path:
logger.error("Cannot launch KiCAD: executable not found")
return False
# Build command
cmd = [str(exe_path)]
if project_path:
cmd.append(str(project_path))
logger.info(f"Launching KiCAD: {' '.join(cmd)}")
# Launch process in background
system = platform.system()
if system == "Windows":
# Windows: Use CREATE_NEW_PROCESS_GROUP to detach
subprocess.Popen(
cmd,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
# Unix: Use nohup or start in background
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
# Wait for process to start
if wait_for_start:
logger.info("Waiting for KiCAD to start...")
for i in range(10): # Wait up to 5 seconds
time.sleep(0.5)
if KiCADProcessManager.is_running():
logger.info("✓ KiCAD started successfully")
return True
logger.warning("KiCAD process not detected after launch")
# Return True anyway, it might be starting
return True
return True
except Exception as e:
logger.error(f"Error launching KiCAD: {e}")
return False
@staticmethod
def get_process_info() -> List[dict]:
"""
Get information about running KiCAD processes
Returns:
List of process info dicts with pid, name, and command
"""
system = platform.system()
processes = []
try:
if system in ["Linux", "Darwin"]:
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
):
# 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(
{
"pid": parts[1],
"name": parts[10],
"command": " ".join(parts[10:]),
}
)
elif system == "Windows":
for proc in KiCADProcessManager._windows_list_processes():
name = (proc.get("name") or "").lower()
if "pcbnew" in name or "kicad" in name:
processes.append(proc)
except Exception as e:
logger.error(f"Error getting process info: {e}")
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
Args:
project_path: Optional path to .kicad_pcb file to open
auto_launch: If True, launch KiCAD if not running
Returns:
Dict with status information
"""
manager = KiCADProcessManager()
is_running = manager.is_running()
if is_running:
processes = manager.get_process_info()
return {
"running": True,
"launched": False,
"processes": processes,
"message": "KiCAD is already running",
}
if not auto_launch:
return {
"running": False,
"launched": False,
"processes": [],
"message": "KiCAD is not running (auto-launch disabled)",
}
# Try to launch
logger.info("KiCAD not detected, attempting to launch...")
success = manager.launch(project_path)
return {
"running": success,
"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,
}

View File

@@ -1,325 +1,325 @@
"""
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 logging
import os
import platform
import sys
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
class PlatformHelper:
"""Platform detection and path resolution utilities"""
@staticmethod
def is_windows() -> bool:
"""Check if running on Windows"""
return platform.system() == "Windows"
@staticmethod
def is_linux() -> bool:
"""Check if running on Linux"""
return platform.system() == "Linux"
@staticmethod
def is_macos() -> bool:
"""Check if running on macOS"""
return platform.system() == "Darwin"
@staticmethod
def get_platform_name() -> str:
"""Get human-readable platform name"""
system = platform.system()
if system == "Darwin":
return "macOS"
return system
@staticmethod
def get_kicad_python_paths() -> List[Path]:
"""
Get potential KiCAD Python dist-packages paths for current platform
Returns:
List of potential paths to check (in priority order)
"""
paths = []
if PlatformHelper.is_windows():
# Windows: Check Program Files
program_files = [
Path("C:/Program Files/KiCad"),
Path("C:/Program Files (x86)/KiCad"),
]
for pf in program_files:
# Check multiple KiCAD versions
for version in ["9.0", "9.1", "10.0", "8.0"]:
path = pf / version / "lib" / "python3" / "dist-packages"
if path.exists():
paths.append(path)
elif PlatformHelper.is_linux():
# Linux: Check common installation paths
candidates = [
Path("/usr/lib/kicad/lib/python3/dist-packages"),
Path("/usr/share/kicad/scripting/plugins"),
Path("/usr/local/lib/kicad/lib/python3/dist-packages"),
Path.home() / ".local/lib/kicad/lib/python3/dist-packages",
]
# Also check based on Python version
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
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(
[
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()]
elif PlatformHelper.is_macos():
# macOS: Check multiple KiCAD application bundle locations
kicad_app_paths = [
Path("/Applications/KiCad/KiCad.app"),
Path("/Applications/KiCAD/KiCad.app"), # Alternative capitalization
Path.home() / "Applications" / "KiCad" / "KiCad.app", # User Applications
]
# Check Python framework paths in each KiCAD installation
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"
)
if path.exists():
paths.append(path)
# Also check Homebrew Python site-packages (if pcbnew installed via pip)
homebrew_paths = [
Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon
Path("/opt/homebrew/lib/python3.11/site-packages"),
Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac
Path("/usr/local/lib/python3.11/site-packages"),
]
for hp in homebrew_paths:
pcbnew_path = hp / "pcbnew.py"
if pcbnew_path.exists():
paths.append(hp)
if not paths:
logger.warning(f"No KiCAD Python paths found for {PlatformHelper.get_platform_name()}")
else:
logger.info(f"Found {len(paths)} potential KiCAD Python paths")
return paths
@staticmethod
def get_kicad_python_path() -> Optional[Path]:
"""
Get the first valid KiCAD Python path
Returns:
Path to KiCAD Python dist-packages, or None if not found
"""
paths = PlatformHelper.get_kicad_python_paths()
return paths[0] if paths else None
@staticmethod
def get_kicad_library_search_paths() -> List[str]:
"""
Get platform-appropriate KiCAD symbol library search paths
Returns:
List of glob patterns for finding .kicad_sym files
"""
patterns = []
if PlatformHelper.is_windows():
patterns = [
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym",
"C:/Program Files (x86)/KiCad/*/share/kicad/symbols/*.kicad_sym",
]
elif PlatformHelper.is_linux():
patterns = [
"/usr/share/kicad/symbols/*.kicad_sym",
"/usr/local/share/kicad/symbols/*.kicad_sym",
str(Path.home() / ".local/share/kicad/symbols/*.kicad_sym"),
]
elif PlatformHelper.is_macos():
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"
),
]
# Add user library paths for all platforms
patterns.append(str(Path.home() / "Documents" / "KiCad" / "*" / "symbols" / "*.kicad_sym"))
return patterns
@staticmethod
def get_config_dir() -> Path:
r"""
Get appropriate configuration directory for current platform
Follows platform conventions:
- Windows: %USERPROFILE%\.kicad-mcp
- Linux: $XDG_CONFIG_HOME/kicad-mcp or ~/.config/kicad-mcp
- macOS: ~/Library/Application Support/kicad-mcp
Returns:
Path to configuration directory
"""
if PlatformHelper.is_windows():
return Path.home() / ".kicad-mcp"
elif PlatformHelper.is_linux():
# Use XDG Base Directory specification
xdg_config = os.environ.get("XDG_CONFIG_HOME")
if xdg_config:
xdg_config_path = Path(xdg_config).expanduser()
if xdg_config_path.is_absolute():
return xdg_config_path / "kicad-mcp"
logger.warning("Ignoring relative XDG_CONFIG_HOME: %s", xdg_config)
return Path.home() / ".config" / "kicad-mcp"
elif PlatformHelper.is_macos():
return Path.home() / "Library" / "Application Support" / "kicad-mcp"
else:
# Fallback for unknown platforms
return Path.home() / ".kicad-mcp"
@staticmethod
def get_log_dir() -> Path:
"""
Get appropriate log directory for current platform
Returns:
Path to log directory
"""
config_dir = PlatformHelper.get_config_dir()
return config_dir / "logs"
@staticmethod
def get_cache_dir() -> Path:
r"""
Get appropriate cache directory for current platform
Follows platform conventions:
- Windows: %USERPROFILE%\.kicad-mcp\cache
- Linux: $XDG_CACHE_HOME/kicad-mcp or ~/.cache/kicad-mcp
- macOS: ~/Library/Caches/kicad-mcp
Returns:
Path to cache directory
"""
if PlatformHelper.is_windows():
return PlatformHelper.get_config_dir() / "cache"
elif PlatformHelper.is_linux():
xdg_cache = os.environ.get("XDG_CACHE_HOME")
if xdg_cache:
xdg_cache_path = Path(xdg_cache).expanduser()
if xdg_cache_path.is_absolute():
return xdg_cache_path / "kicad-mcp"
logger.warning("Ignoring relative XDG_CACHE_HOME: %s", xdg_cache)
return Path.home() / ".cache" / "kicad-mcp"
elif PlatformHelper.is_macos():
return Path.home() / "Library" / "Caches" / "kicad-mcp"
else:
return PlatformHelper.get_config_dir() / "cache"
@staticmethod
def ensure_directories() -> None:
"""Create all necessary directories if they don't exist"""
dirs_to_create = [
PlatformHelper.get_config_dir(),
PlatformHelper.get_log_dir(),
PlatformHelper.get_cache_dir(),
]
for directory in dirs_to_create:
directory.mkdir(parents=True, exist_ok=True)
logger.debug(f"Ensured directory exists: {directory}")
@staticmethod
def get_python_executable() -> Path:
"""Get path to current Python executable"""
return Path(sys.executable)
@staticmethod
def add_kicad_to_python_path() -> bool:
"""
Add KiCAD Python paths to sys.path
Returns:
True if at least one path was added, False otherwise
"""
paths_added = False
for path in PlatformHelper.get_kicad_python_paths():
if str(path) not in sys.path:
sys.path.insert(0, str(path))
logger.info(f"Added to Python path: {path}")
paths_added = True
return paths_added
# Convenience function for quick platform detection
def detect_platform() -> dict:
"""
Detect platform and return useful information
Returns:
Dictionary with platform information
"""
return {
"system": platform.system(),
"platform": PlatformHelper.get_platform_name(),
"is_windows": PlatformHelper.is_windows(),
"is_linux": PlatformHelper.is_linux(),
"is_macos": PlatformHelper.is_macos(),
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"python_executable": str(PlatformHelper.get_python_executable()),
"config_dir": str(PlatformHelper.get_config_dir()),
"log_dir": str(PlatformHelper.get_log_dir()),
"cache_dir": str(PlatformHelper.get_cache_dir()),
"kicad_python_paths": [str(p) for p in PlatformHelper.get_kicad_python_paths()],
}
if __name__ == "__main__":
# Quick test/diagnostic
import json
info = detect_platform()
print("Platform Information:")
print(json.dumps(info, indent=2))
"""
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 logging
import os
import platform
import sys
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
class PlatformHelper:
"""Platform detection and path resolution utilities"""
@staticmethod
def is_windows() -> bool:
"""Check if running on Windows"""
return platform.system() == "Windows"
@staticmethod
def is_linux() -> bool:
"""Check if running on Linux"""
return platform.system() == "Linux"
@staticmethod
def is_macos() -> bool:
"""Check if running on macOS"""
return platform.system() == "Darwin"
@staticmethod
def get_platform_name() -> str:
"""Get human-readable platform name"""
system = platform.system()
if system == "Darwin":
return "macOS"
return system
@staticmethod
def get_kicad_python_paths() -> List[Path]:
"""
Get potential KiCAD Python dist-packages paths for current platform
Returns:
List of potential paths to check (in priority order)
"""
paths = []
if PlatformHelper.is_windows():
# Windows: Check Program Files
program_files = [
Path("C:/Program Files/KiCad"),
Path("C:/Program Files (x86)/KiCad"),
]
for pf in program_files:
# Check multiple KiCAD versions
for version in ["9.0", "9.1", "10.0", "8.0"]:
path = pf / version / "lib" / "python3" / "dist-packages"
if path.exists():
paths.append(path)
elif PlatformHelper.is_linux():
# Linux: Check common installation paths
candidates = [
Path("/usr/lib/kicad/lib/python3/dist-packages"),
Path("/usr/share/kicad/scripting/plugins"),
Path("/usr/local/lib/kicad/lib/python3/dist-packages"),
Path.home() / ".local/lib/kicad/lib/python3/dist-packages",
]
# Also check based on Python version
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
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(
[
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()]
elif PlatformHelper.is_macos():
# macOS: Check multiple KiCAD application bundle locations
kicad_app_paths = [
Path("/Applications/KiCad/KiCad.app"),
Path("/Applications/KiCAD/KiCad.app"), # Alternative capitalization
Path.home() / "Applications" / "KiCad" / "KiCad.app", # User Applications
]
# Check Python framework paths in each KiCAD installation
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"
)
if path.exists():
paths.append(path)
# Also check Homebrew Python site-packages (if pcbnew installed via pip)
homebrew_paths = [
Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon
Path("/opt/homebrew/lib/python3.11/site-packages"),
Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac
Path("/usr/local/lib/python3.11/site-packages"),
]
for hp in homebrew_paths:
pcbnew_path = hp / "pcbnew.py"
if pcbnew_path.exists():
paths.append(hp)
if not paths:
logger.warning(f"No KiCAD Python paths found for {PlatformHelper.get_platform_name()}")
else:
logger.info(f"Found {len(paths)} potential KiCAD Python paths")
return paths
@staticmethod
def get_kicad_python_path() -> Optional[Path]:
"""
Get the first valid KiCAD Python path
Returns:
Path to KiCAD Python dist-packages, or None if not found
"""
paths = PlatformHelper.get_kicad_python_paths()
return paths[0] if paths else None
@staticmethod
def get_kicad_library_search_paths() -> List[str]:
"""
Get platform-appropriate KiCAD symbol library search paths
Returns:
List of glob patterns for finding .kicad_sym files
"""
patterns = []
if PlatformHelper.is_windows():
patterns = [
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym",
"C:/Program Files (x86)/KiCad/*/share/kicad/symbols/*.kicad_sym",
]
elif PlatformHelper.is_linux():
patterns = [
"/usr/share/kicad/symbols/*.kicad_sym",
"/usr/local/share/kicad/symbols/*.kicad_sym",
str(Path.home() / ".local/share/kicad/symbols/*.kicad_sym"),
]
elif PlatformHelper.is_macos():
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"
),
]
# Add user library paths for all platforms
patterns.append(str(Path.home() / "Documents" / "KiCad" / "*" / "symbols" / "*.kicad_sym"))
return patterns
@staticmethod
def get_config_dir() -> Path:
r"""
Get appropriate configuration directory for current platform
Follows platform conventions:
- Windows: %USERPROFILE%\.kicad-mcp
- Linux: $XDG_CONFIG_HOME/kicad-mcp or ~/.config/kicad-mcp
- macOS: ~/Library/Application Support/kicad-mcp
Returns:
Path to configuration directory
"""
if PlatformHelper.is_windows():
return Path.home() / ".kicad-mcp"
elif PlatformHelper.is_linux():
# Use XDG Base Directory specification
xdg_config = os.environ.get("XDG_CONFIG_HOME")
if xdg_config:
xdg_config_path = Path(xdg_config).expanduser()
if xdg_config_path.is_absolute():
return xdg_config_path / "kicad-mcp"
logger.warning("Ignoring relative XDG_CONFIG_HOME: %s", xdg_config)
return Path.home() / ".config" / "kicad-mcp"
elif PlatformHelper.is_macos():
return Path.home() / "Library" / "Application Support" / "kicad-mcp"
else:
# Fallback for unknown platforms
return Path.home() / ".kicad-mcp"
@staticmethod
def get_log_dir() -> Path:
"""
Get appropriate log directory for current platform
Returns:
Path to log directory
"""
config_dir = PlatformHelper.get_config_dir()
return config_dir / "logs"
@staticmethod
def get_cache_dir() -> Path:
r"""
Get appropriate cache directory for current platform
Follows platform conventions:
- Windows: %USERPROFILE%\.kicad-mcp\cache
- Linux: $XDG_CACHE_HOME/kicad-mcp or ~/.cache/kicad-mcp
- macOS: ~/Library/Caches/kicad-mcp
Returns:
Path to cache directory
"""
if PlatformHelper.is_windows():
return PlatformHelper.get_config_dir() / "cache"
elif PlatformHelper.is_linux():
xdg_cache = os.environ.get("XDG_CACHE_HOME")
if xdg_cache:
xdg_cache_path = Path(xdg_cache).expanduser()
if xdg_cache_path.is_absolute():
return xdg_cache_path / "kicad-mcp"
logger.warning("Ignoring relative XDG_CACHE_HOME: %s", xdg_cache)
return Path.home() / ".cache" / "kicad-mcp"
elif PlatformHelper.is_macos():
return Path.home() / "Library" / "Caches" / "kicad-mcp"
else:
return PlatformHelper.get_config_dir() / "cache"
@staticmethod
def ensure_directories() -> None:
"""Create all necessary directories if they don't exist"""
dirs_to_create = [
PlatformHelper.get_config_dir(),
PlatformHelper.get_log_dir(),
PlatformHelper.get_cache_dir(),
]
for directory in dirs_to_create:
directory.mkdir(parents=True, exist_ok=True)
logger.debug(f"Ensured directory exists: {directory}")
@staticmethod
def get_python_executable() -> Path:
"""Get path to current Python executable"""
return Path(sys.executable)
@staticmethod
def add_kicad_to_python_path() -> bool:
"""
Add KiCAD Python paths to sys.path
Returns:
True if at least one path was added, False otherwise
"""
paths_added = False
for path in PlatformHelper.get_kicad_python_paths():
if str(path) not in sys.path:
sys.path.insert(0, str(path))
logger.info(f"Added to Python path: {path}")
paths_added = True
return paths_added
# Convenience function for quick platform detection
def detect_platform() -> dict:
"""
Detect platform and return useful information
Returns:
Dictionary with platform information
"""
return {
"system": platform.system(),
"platform": PlatformHelper.get_platform_name(),
"is_windows": PlatformHelper.is_windows(),
"is_linux": PlatformHelper.is_linux(),
"is_macos": PlatformHelper.is_macos(),
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
"python_executable": str(PlatformHelper.get_python_executable()),
"config_dir": str(PlatformHelper.get_config_dir()),
"log_dir": str(PlatformHelper.get_log_dir()),
"cache_dir": str(PlatformHelper.get_cache_dir()),
"kicad_python_paths": [str(p) for p in PlatformHelper.get_kicad_python_paths()],
}
if __name__ == "__main__":
# Quick test/diagnostic
import json
info = detect_platform()
print("Platform Information:")
print(json.dumps(info, indent=2))