merge: upstream/main (57 commits) — preserve PR #102 net label fix

Merged upstream/main into our fork. Conflict in connection_schematic.py
resolved by taking upstream's file and re-applying our fix:
- all_match_points = connected_wire_points | label positions
- Allows nets where labels are placed directly at pin endpoints (no wire)

Upstream changes include: security fixes (8 vulns), new schematic tools
(get_net_at_point, find_orphaned_wires, snap_to_grid, get_wire_connections),
generate_netlist rewrite via kicad-cli, wire preservation on component move,
schematic analysis tools, KiCad 10 support.
This commit is contained in:
ffindog
2026-04-15 22:27:41 +10:00
159 changed files with 39178 additions and 23947 deletions

View File

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

View File

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

View File

@@ -2,17 +2,20 @@
Board-related command implementations for KiCAD interface
"""
import pcbnew
import logging
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
from .layers import BoardLayerCommands
from .outline import BoardOutlineCommands
# Import specialized modules
from .size import BoardSizeCommands
from .layers import BoardLayerCommands
from .outline import BoardOutlineCommands
from .view import BoardViewCommands
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
class BoardCommands:
"""Handles board-related KiCAD operations"""
@@ -20,63 +23,63 @@ class BoardCommands:
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)
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

@@ -2,11 +2,13 @@
Board layer command implementations for KiCAD interface
"""
import pcbnew
import logging
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
logger = logging.getLogger('kicad_interface')
class BoardLayerCommands:
"""Handles board layer operations"""
@@ -22,7 +24,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
name = params.get("name")
@@ -34,7 +36,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Missing parameters",
"errorDetails": "name, type, and position are required"
"errorDetails": "name, type, and position are required",
}
# Get layer stack
@@ -47,7 +49,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Missing layer number",
"errorDetails": "number is required for inner layers"
"errorDetails": "number is required for inner layers",
}
layer_id = pcbnew.In1_Cu + (number - 1)
elif position == "top":
@@ -59,34 +61,25 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Invalid layer position",
"errorDetails": "position must be 'top', 'bottom', or 'inner'"
"errorDetails": "position must be 'top', 'bottom', or 'inner'",
}
# Set layer properties
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
}
"layer": {"name": name, "type": layer_type, "position": position, "number": number},
}
except Exception as e:
logger.error(f"Error adding layer: {str(e)}")
return {
"success": False,
"message": "Failed to add layer",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)}
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the active layer for PCB operations"""
@@ -95,7 +88,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
layer = params.get("layer")
@@ -103,7 +96,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No layer specified",
"errorDetails": "layer parameter is required"
"errorDetails": "layer parameter is required",
}
# Find layer ID by name
@@ -112,7 +105,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Layer not found",
"errorDetails": f"Layer '{layer}' does not exist"
"errorDetails": f"Layer '{layer}' does not exist",
}
# Set active layer
@@ -121,10 +114,7 @@ class BoardLayerCommands:
return {
"success": True,
"message": f"Set active layer to: {layer}",
"layer": {
"name": layer,
"id": layer_id
}
"layer": {"name": layer, "id": layer_id},
}
except Exception as e:
@@ -132,7 +122,7 @@ class BoardLayerCommands:
return {
"success": False,
"message": "Failed to set active layer",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -142,40 +132,35 @@ class BoardLayerCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
layers = []
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
if self.board.IsLayerEnabled(layer_id):
layers.append({
"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
})
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
}
return {"success": True, "layers": layers}
except Exception as e:
logger.error(f"Error getting layer list: {str(e)}")
return {
"success": False,
"message": "Failed to get layer list",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)}
def _get_layer_type(self, type_name: str) -> int:
"""Convert layer type name to KiCAD layer type constant"""
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
"signal": pcbnew.LT_SIGNAL,
}
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
@@ -185,7 +170,7 @@ class BoardLayerCommands:
pcbnew.LT_SIGNAL: "signal",
pcbnew.LT_POWER: "power",
pcbnew.LT_MIXED: "mixed",
pcbnew.LT_JUMPER: "jumper"
pcbnew.LT_JUMPER: "jumper",
}
# Note: LT_USER was removed in KiCAD 9.0
return type_map.get(type_id, "unknown")

View File

@@ -2,10 +2,11 @@
Board outline command implementations for KiCAD interface
"""
import pcbnew
import logging
import math
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -224,9 +225,7 @@ class BoardOutlineCommands:
}
# Convert to internal units (nanometers)
scale = (
1000000 if position.get("unit", "mm") == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
diameter_nm = int(diameter * scale)
@@ -252,9 +251,7 @@ class BoardOutlineCommands:
pad = pcbnew.PAD(module)
pad.SetNumber(1)
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
pad.SetAttribute(
pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH
)
pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
@@ -311,9 +308,7 @@ class BoardOutlineCommands:
}
# Convert to internal units (nanometers)
scale = (
1000000 if position.get("unit", "mm") == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
size_nm = int(size * scale)
@@ -372,9 +367,7 @@ class BoardOutlineCommands:
"errorDetails": str(e),
}
def _add_edge_line(
self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int
) -> None:
def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
"""Add a line to the edge cuts layer"""
line = pcbnew.PCB_SHAPE(self.board)
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
@@ -396,18 +389,12 @@ class BoardOutlineCommands:
"""Add a rounded rectangle to the edge cuts layer"""
if radius_nm <= 0:
# If no radius, create regular rectangle
top_left = pcbnew.VECTOR2I(
center_x_nm - width_nm // 2, center_y_nm - height_nm // 2
)
top_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
)
top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
bottom_right = pcbnew.VECTOR2I(
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
)
bottom_left = pcbnew.VECTOR2I(
center_x_nm - width_nm // 2, center_y_nm + height_nm // 2
)
bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
self._add_edge_line(top_left, top_right, layer)
self._add_edge_line(top_right, bottom_right, layer)

View File

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

View File

@@ -2,15 +2,17 @@
Board view command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
from PIL import Image
import io
import base64
import io
import logging
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
from PIL import Image
logger = logging.getLogger("kicad_interface")
logger = logging.getLogger('kicad_interface')
class BoardViewCommands:
"""Handles board viewing operations"""
@@ -26,7 +28,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get board dimensions
@@ -42,26 +44,24 @@ class BoardViewCommands:
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
})
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"
},
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
"layers": layers,
"title": self.board.GetTitleBlock().GetTitle()
"title": self.board.GetTitleBlock().GetTitle(),
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
# Active layer is a UI concept not applicable to headless scripting
}
},
}
except Exception as e:
@@ -69,7 +69,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board information",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
@@ -79,7 +79,7 @@ class BoardViewCommands:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first"
"errorDetails": "Load or create a board first",
}
# Get parameters
@@ -90,7 +90,7 @@ class BoardViewCommands:
# 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()))
@@ -100,7 +100,7 @@ class BoardViewCommands:
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")
@@ -126,36 +126,33 @@ class BoardViewCommands:
# Convert SVG to requested format
if format == "svg":
with open(temp_svg, 'r') as f:
with open(temp_svg, "r") as f:
svg_data = f.read()
os.remove(temp_svg)
return {
"success": True,
"imageData": svg_data,
"format": "svg"
}
return {"success": True, "imageData": svg_data, "format": "svg"}
else:
# Use PIL to convert SVG to PNG/JPG
from cairosvg import svg2png
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
os.remove(temp_svg)
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')
img.convert("RGB").save(jpg_buffer, format="JPEG")
jpg_data = jpg_buffer.getvalue()
return {
"success": True,
"imageData": base64.b64encode(jpg_data).decode('utf-8'),
"format": "jpg"
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
"format": "jpg",
}
else:
return {
"success": True,
"imageData": base64.b64encode(png_data).decode('utf-8'),
"format": "png"
"imageData": base64.b64encode(png_data).decode("utf-8"),
"format": "png",
}
except Exception as e:
@@ -163,70 +160,67 @@ class BoardViewCommands:
return {
"success": False,
"message": "Failed to get board 2D view",
"errorDetails": str(e)
"errorDetails": str(e),
}
def _get_layer_type_name(self, type_id: int) -> str:
"""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"
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)
}
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,9 +1,10 @@
from skip import Schematic
import logging
import os
import uuid
import logging
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional, Tuple
from skip import Schematic
logger = logging.getLogger(__name__)
@@ -13,9 +14,7 @@ try:
DYNAMIC_LOADING_AVAILABLE = True
except ImportError:
logger.warning(
"Dynamic symbol loader not available - falling back to template-only mode"
)
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
DYNAMIC_LOADING_AVAILABLE = False
@@ -26,7 +25,7 @@ class ComponentManager:
_dynamic_loader = None
@classmethod
def get_dynamic_loader(cls):
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()
@@ -87,7 +86,7 @@ class ComponentManager:
"""
# Helper function to check if template exists in schematic
def template_exists(schematic, template_ref):
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 (
@@ -135,32 +134,22 @@ class ComponentManager:
# Check if schematic path is available
if schematic_path is None:
logger.warning(
"Dynamic loading requires schematic file path but none was provided"
)
logger.warning("Dynamic loading requires schematic file path but none was provided")
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
return (fallback, False)
# Determine library name
if library is None:
# Default library for common component types
library = (
"Device" # Most passives and basic components are in Device library
)
library = "Device" # Most passives and basic components are in Device library
try:
logger.info(
f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}"
)
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
# Use dynamic symbol loader to inject symbol and create template
template_ref = loader.load_symbol_dynamically(
schematic_path, library, comp_type
)
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
logger.info(
f"Successfully loaded symbol dynamically. Template ref: {template_ref}"
)
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
# Signal that schematic needs reload to see new template
return (template_ref, True)
@@ -176,7 +165,7 @@ class ComponentManager:
@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
@@ -198,9 +187,7 @@ class ComponentManager:
# Get component type and determine template
comp_type = component_def.get("type", "R")
library = component_def.get(
"library", None
) # Optional library specification
library = component_def.get("library", None) # Optional library specification
# Get template reference (static or dynamic)
template_ref, needs_reload = ComponentManager.get_or_create_template(
@@ -209,9 +196,7 @@ class ComponentManager:
# If dynamic loading occurred, reload schematic to see new template
if needs_reload and schematic_path:
logger.info(
f"Reloading schematic after dynamic loading: {schematic_path}"
)
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
schematic = SchematicManager.load_schematic(str(schematic_path))
# Find template symbol by reference (handles special characters like +)
@@ -280,7 +265,7 @@ class ComponentManager:
raise
@staticmethod
def remove_component(schematic: Schematic, component_ref: str):
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.
@@ -293,19 +278,17 @@ class ComponentManager:
if symbol_to_remove:
schematic.symbol._elements.remove(symbol_to_remove)
print(f"Removed component {component_ref} from schematic.")
logger.info(f"Removed component {component_ref} from schematic.")
return True
else:
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
print(f"Error removing component {component_ref}: {e}")
logger.error(f"Error removing component {component_ref}: {e}")
return False
@staticmethod
def update_component(
schematic: Schematic, component_ref: str, new_properties: dict
):
def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool:
"""Update component properties by reference designator"""
try:
symbol_to_update = None
@@ -319,29 +302,28 @@ class ComponentManager:
if key in symbol_to_update.property:
symbol_to_update.property[key].value = value
else:
# Add as a new property if it doesn't exist
symbol_to_update.property.append(key, value)
print(f"Updated properties for component {component_ref}.")
logger.info(f"Updated properties for component {component_ref}.")
return True
else:
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return False
except Exception as e:
print(f"Error updating component {component_ref}: {e}")
logger.error(f"Error updating component {component_ref}: {e}")
return False
@staticmethod
def get_component(schematic: Schematic, component_ref: str):
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:
print(f"Found component with reference {component_ref}.")
logger.debug(f"Found component with reference {component_ref}.")
return symbol
print(f"Component with reference {component_ref} not found.")
logger.warning(f"Component with reference {component_ref} not found.")
return None
@staticmethod
def search_components(schematic: Schematic, query: str):
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 = []
@@ -356,21 +338,21 @@ class ComponentManager:
)
):
matching_components.append(symbol)
print(f"Found {len(matching_components)} components matching query '{query}'.")
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
return matching_components
@staticmethod
def get_all_components(schematic: Schematic):
def get_all_components(schematic: Schematic) -> List[Any]:
"""Get all components in schematic"""
print(f"Retrieving all {len(schematic.symbol)} components.")
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
return list(schematic.symbol)
if __name__ == "__main__":
# Example Usage (for testing)
from schematic import (
from schematic import ( # Assuming schematic.py is in the same directory
SchematicManager,
) # Assuming schematic.py is in the same directory
)
# Create a new schematic
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
@@ -401,19 +383,13 @@ if __name__ == "__main__":
# Get a component
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
if retrieved_comp:
print(
f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})"
)
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
# Update a component
ComponentManager.update_component(
test_sch, "R1", {"value": "20k", "Tolerance": "5%"}
)
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
# Search components
matching_comps = ComponentManager.search_components(
test_sch, "100"
) # Search by position
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
# Get all components
@@ -423,9 +399,7 @@ if __name__ == "__main__":
# Remove a component
ComponentManager.remove_component(test_sch, "D1")
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
print(
f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}"
)
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
# Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")

View File

@@ -1,15 +1,16 @@
from skip import Schematic
import os
import logging
import os
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional
from skip import Schematic
logger = logging.getLogger(__name__)
# Import new wire and pin managers
try:
from commands.wire_manager import WireManager
from commands.pin_locator import PinLocator
from commands.wire_manager import WireManager
WIRE_MANAGER_AVAILABLE = True
except ImportError:
@@ -24,180 +25,14 @@ class ConnectionManager:
_pin_locator = None
@classmethod
def get_pin_locator(cls):
def get_pin_locator(cls) -> Any:
"""Get or create pin locator instance"""
if cls._pin_locator is None and WIRE_MANAGER_AVAILABLE:
cls._pin_locator = PinLocator()
return cls._pin_locator
@staticmethod
def add_wire(
schematic_path: Path,
start_point: list,
end_point: list,
properties: dict = None,
):
"""
Add a wire between two points using WireManager
Args:
schematic_path: Path to .kicad_sch file
start_point: [x, y] coordinates for wire start
end_point: [x, y] coordinates for wire end
properties: Optional wire properties (stroke_width, stroke_type)
Returns:
True if successful, False otherwise
"""
try:
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager not available")
return False
stroke_width = properties.get("stroke_width", 0) if properties else 0
stroke_type = (
properties.get("stroke_type", "default") if properties else "default"
)
success = WireManager.add_wire(
schematic_path,
start_point,
end_point,
stroke_width=stroke_width,
stroke_type=stroke_type,
)
return success
except Exception as e:
logger.error(f"Error adding wire: {e}")
return False
@staticmethod
def get_pin_location(symbol, pin_name: str):
"""
Get the absolute location of a pin on a symbol
Args:
symbol: Symbol object
pin_name: Name or number of the pin (e.g., "1", "GND", "VCC")
Returns:
[x, y] coordinates or None if pin not found
"""
try:
if not hasattr(symbol, "pin"):
logger.warning(f"Symbol {symbol.property.Reference.value} has no pins")
return None
# Find the pin by name
target_pin = None
for pin in symbol.pin:
if pin.name == pin_name:
target_pin = pin
break
if not target_pin:
logger.warning(
f"Pin '{pin_name}' not found on {symbol.property.Reference.value}"
)
return None
# Get pin location relative to symbol
pin_loc = target_pin.location
# Get symbol location
symbol_at = symbol.at.value
# Calculate absolute position
# pin_loc is relative to symbol origin, need to add symbol position
abs_x = symbol_at[0] + pin_loc[0]
abs_y = symbol_at[1] + pin_loc[1]
return [abs_x, abs_y]
except Exception as e:
logger.error(f"Error getting pin location: {e}")
return None
@staticmethod
def add_connection(
schematic_path: Path,
source_ref: str,
source_pin: str,
target_ref: str,
target_pin: str,
routing: str = "direct",
):
"""
Add a wire connection between two component pins
Args:
schematic_path: Path to .kicad_sch file
source_ref: Reference designator of source component (e.g., "R1", "R1_")
source_pin: Pin name/number on source component
target_ref: Reference designator of target component (e.g., "C1", "C1_")
target_pin: Pin name/number on target component
routing: Routing style ('direct', 'orthogonal_h', 'orthogonal_v')
Returns:
True if connection was successful, False otherwise
"""
try:
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager/PinLocator not available")
return False
locator = ConnectionManager.get_pin_locator()
if not locator:
logger.error("Pin locator unavailable")
return False
# Get pin locations
source_loc = locator.get_pin_location(
schematic_path, source_ref, source_pin
)
target_loc = locator.get_pin_location(
schematic_path, target_ref, target_pin
)
if not source_loc or not target_loc:
logger.error("Could not determine pin locations")
return False
# Create wire based on routing style
if routing == "direct":
# Simple direct wire
success = WireManager.add_wire(schematic_path, source_loc, target_loc)
elif routing == "orthogonal_h":
# Orthogonal routing (horizontal first)
path = WireManager.create_orthogonal_path(
source_loc, target_loc, prefer_horizontal_first=True
)
success = WireManager.add_polyline_wire(schematic_path, path)
elif routing == "orthogonal_v":
# Orthogonal routing (vertical first)
path = WireManager.create_orthogonal_path(
source_loc, target_loc, prefer_horizontal_first=False
)
success = WireManager.add_polyline_wire(schematic_path, path)
else:
logger.error(f"Unknown routing style: {routing}")
return False
if success:
logger.info(
f"Connected {source_ref}/{source_pin} to {target_ref}/{target_pin} (routing: {routing})"
)
return True
else:
return False
except Exception as e:
logger.error(f"Error adding connection: {e}")
import traceback
logger.error(traceback.format_exc())
return False
@staticmethod
def add_net_label(schematic: Schematic, net_name: str, position: list):
def add_net_label(schematic: Schematic, net_name: str, position: list) -> Any:
"""
Add a net label to the schematic
@@ -214,9 +49,7 @@ class ConnectionManager:
logger.error("Schematic does not have label collection")
return None
label = schematic.label.append(
text=net_name, at={"x": position[0], "y": position[1]}
)
label = schematic.label.append(text=net_name, at={"x": position[0], "y": position[1]})
logger.info(f"Added net label '{net_name}' at {position}")
return label
except Exception as e:
@@ -226,9 +59,9 @@ class ConnectionManager:
@staticmethod
def connect_to_net(
schematic_path: Path, component_ref: str, pin_name: str, net_name: str
):
) -> Dict[str, Any]:
"""
Connect a component pin to a named net using a wire stub and label
Connect a component pin to a named net using a wire stub and label.
Args:
schematic_path: Path to .kicad_sch file
@@ -237,59 +70,78 @@ class ConnectionManager:
net_name: Name of the net to connect to (e.g., "VCC", "GND", "SIGNAL_1")
Returns:
True if successful, False otherwise
Dict with keys:
success bool
pin_location [x, y] exact pin endpoint used (present on success)
label_location [x, y] where the net label was placed (present on success)
wire_stub [[x1,y1],[x2,y2]] the wire segment added (present on success)
message human-readable status
"""
try:
if not WIRE_MANAGER_AVAILABLE:
logger.error("WireManager/PinLocator not available")
return False
return {"success": False, "message": "WireManager/PinLocator not available"}
locator = ConnectionManager.get_pin_locator()
if not locator:
logger.error("Pin locator unavailable")
return False
return {"success": False, "message": "Pin locator unavailable"}
# Get pin location using PinLocator
pin_loc = locator.get_pin_location(schematic_path, component_ref, pin_name)
if not pin_loc:
logger.error(f"Could not locate pin {component_ref}/{pin_name}")
return False
msg = f"Could not locate pin {component_ref}/{pin_name}"
logger.error(msg)
return {"success": False, "message": msg}
# Add a small wire stub from the pin (2.54mm = 0.1 inch, standard grid spacing)
# Stub direction follows the pin's outward angle from the PinLocator
pin_angle_deg = getattr(locator, '_last_pin_angle', 0)
try:
pin_angle_deg = locator.get_pin_angle(schematic_path, component_ref, pin_name) or 0
except Exception:
except Exception as e:
logger.warning(
f"Could not get pin angle for {component_ref}/{pin_name}, defaulting to 0: {e}"
)
pin_angle_deg = 0
import math as _math
angle_rad = _math.radians(pin_angle_deg)
stub_end = [round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4),
round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4)]
stub_end = [
round(pin_loc[0] + 2.54 * _math.cos(angle_rad), 4),
round(pin_loc[1] - 2.54 * _math.sin(angle_rad), 4),
]
# Create wire stub using WireManager
wire_success = WireManager.add_wire(schematic_path, pin_loc, stub_end)
if not wire_success:
logger.error(f"Failed to create wire stub for net connection")
return False
msg = "Failed to create wire stub for net connection"
logger.error(msg)
return {"success": False, "message": msg}
# Add label at the end of the stub using WireManager
label_success = WireManager.add_label(
schematic_path, net_name, stub_end, label_type="label"
)
if not label_success:
logger.error(f"Failed to add net label '{net_name}'")
return False
msg = f"Failed to add net label '{net_name}'"
logger.error(msg)
return {"success": False, "message": msg}
logger.info(f"Connected {component_ref}/{pin_name} to net '{net_name}'")
return True
return {
"success": True,
"message": f"Connected {component_ref}/{pin_name} to net '{net_name}'",
"pin_location": pin_loc,
"label_location": stub_end,
"wire_stub": [pin_loc, stub_end],
}
except Exception as e:
logger.error(f"Error connecting to net: {e}")
import traceback
logger.error(traceback.format_exc())
return False
return {"success": False, "message": str(e)}
@staticmethod
def connect_passthrough(
@@ -298,7 +150,7 @@ class ConnectionManager:
target_ref: str,
net_prefix: str = "PIN",
pin_offset: int = 0,
):
) -> Dict[str, List[str]]:
"""
Connect all pins of source_ref to matching pins of target_ref via shared net labels.
Useful for passthrough adapters: J1 pin N <-> J2 pin N on net {net_prefix}_{N}.
@@ -335,20 +187,24 @@ class ConnectionManager:
for pin_num in sorted(src_pins.keys(), key=lambda x: int(x) if x.isdigit() else 0):
try:
net_name = f"{net_prefix}_{int(pin_num) + pin_offset}" if pin_num.isdigit() else f"{net_prefix}_{pin_num}"
net_name = (
f"{net_prefix}_{int(pin_num) + pin_offset}"
if pin_num.isdigit()
else f"{net_prefix}_{pin_num}"
)
ok_src = ConnectionManager.connect_to_net(
res_src = ConnectionManager.connect_to_net(
schematic_path, source_ref, pin_num, net_name
)
if not ok_src:
if not res_src.get("success"):
failed.append(f"{source_ref}/{pin_num}")
continue
if pin_num in tgt_pins:
ok_tgt = ConnectionManager.connect_to_net(
res_tgt = ConnectionManager.connect_to_net(
schematic_path, target_ref, pin_num, net_name
)
if not ok_tgt:
if not res_tgt.get("success"):
failed.append(f"{target_ref}/{pin_num}")
continue
else:
@@ -365,7 +221,7 @@ class ConnectionManager:
@staticmethod
def get_net_connections(
schematic: Schematic, net_name: str, schematic_path: Optional[Path] = None
):
) -> List[Dict]:
"""
Get all connections for a named net using wire graph analysis
@@ -383,7 +239,7 @@ class ConnectionManager:
connections = []
tolerance = 0.5 # 0.5mm tolerance for point coincidence (grid spacing consideration)
def points_coincide(p1, p2):
def points_coincide(p1: Any, p2: Any) -> bool:
"""Check if two points are the same (within tolerance)"""
if not p1 or not p2:
return False
@@ -407,52 +263,55 @@ class ConnectionManager:
logger.info(f"No labels found for net '{net_name}'")
return connections
logger.debug(
f"Found {len(net_label_positions)} labels for net '{net_name}'"
)
logger.debug(f"Found {len(net_label_positions)} labels for net '{net_name}'")
# 2. Find all wires connected to these label positions
if not hasattr(schematic, "wire"):
logger.warning("Schematic has no wires")
return connections
connected_wire_points = set()
if hasattr(schematic, "wire"):
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
# Get all points in this wire (polyline)
wire_points = []
for point in wire.pts.xy:
if hasattr(point, "value"):
wire_points.append(
[float(point.value[0]), float(point.value[1])]
)
for wire in schematic.wire:
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
# Get all points in this wire (polyline)
wire_points = []
for point in wire.pts.xy:
if hasattr(point, "value"):
wire_points.append([float(point.value[0]), float(point.value[1])])
# Check if any wire point touches a label
wire_connected = False
for wire_pt in wire_points:
for label_pt in net_label_positions:
if points_coincide(wire_pt, label_pt):
wire_connected = True
break
if wire_connected:
# Check if any wire point touches a label
wire_connected = False
for wire_pt in wire_points:
for label_pt in net_label_positions:
if points_coincide(wire_pt, label_pt):
wire_connected = True
break
# If this wire is connected to the net, add all its points
if wire_connected:
for pt in wire_points:
connected_wire_points.add((pt[0], pt[1]))
break
# Build the full set of candidate match points:
# wire endpoints that touch this net PLUS label positions themselves.
# This handles labels placed directly at pin endpoints (no wire needed).
# If this wire is connected to the net, add all its points
if wire_connected:
for pt in wire_points:
connected_wire_points.add((pt[0], pt[1]))
# Build match points: union of wire endpoints AND label positions.
# This handles the valid KiCad style where a net label is placed
# directly at a pin endpoint with no wire segment in between.
all_match_points = connected_wire_points | {
(p[0], p[1]) for p in net_label_positions
}
if not all_match_points:
logger.debug(f"No connection points found for net '{net_name}'")
return connections
logger.debug(
f"Net '{net_name}': {len(connected_wire_points)} wire points, "
f"Found {len(connected_wire_points)} wire points, "
f"{len(net_label_positions)} direct label positions, "
f"{len(all_match_points)} total match points"
f"{len(all_match_points)} total match points for net '{net_name}'"
)
# 3. Find component pins at wire endpoints or direct label positions
# 3. Find component pins at wire endpoints
if not hasattr(schematic, "symbol"):
logger.warning("Schematic has no symbols")
return connections
@@ -487,19 +346,15 @@ class ConnectionManager:
# Check each pin
for pin_num, pin_data in pins.items():
# Get pin location
pin_loc = locator.get_pin_location(
schematic_path, ref, pin_num
)
pin_loc = locator.get_pin_location(schematic_path, ref, pin_num)
if not pin_loc:
continue
# Check if pin coincides with any wire point or label position
for match_pt in all_match_points:
if points_coincide(pin_loc, list(match_pt)):
connections.append(
{"component": ref, "pin": pin_num}
)
break # Pin found, no need to check more points
# Check if pin coincides with any match point
for wire_pt_tup in all_match_points:
if points_coincide(pin_loc, list(wire_pt_tup)):
connections.append({"component": ref, "pin": pin_num})
break # Pin found, no need to check more wire points
except Exception as e:
logger.warning(f"Error matching pins for {ref}: {e}")
@@ -515,10 +370,10 @@ class ConnectionManager:
symbol_x = float(symbol_pos[0])
symbol_y = float(symbol_pos[1])
# Check if symbol is near any wire point or label position (within 10mm)
for wire_pt in all_match_points:
# Check if symbol is near any match point (within 10mm)
for wire_pt_tup in all_match_points:
dist = (
(symbol_x - wire_pt[0]) ** 2 + (symbol_y - wire_pt[1]) ** 2
(symbol_x - wire_pt_tup[0]) ** 2 + (symbol_y - wire_pt_tup[1]) ** 2
) ** 0.5
if dist < 10.0: # 10mm proximity threshold
connections.append({"component": ref, "pin": "unknown"})
@@ -535,7 +390,9 @@ class ConnectionManager:
return []
@staticmethod
def generate_netlist(schematic: Schematic, schematic_path: Optional[Path] = None):
def generate_netlist(
schematic: Schematic, schematic_path: Optional[Path] = None
) -> Dict[str, Any]:
"""
Generate a netlist from the schematic
@@ -572,9 +429,7 @@ class ConnectionManager:
component_info = {
"reference": symbol.property.Reference.value,
"value": (
symbol.property.Value.value
if hasattr(symbol.property, "Value")
else ""
symbol.property.Value.value if hasattr(symbol.property, "Value") else ""
),
"footprint": (
symbol.property.Footprint.value
@@ -597,9 +452,7 @@ class ConnectionManager:
schematic, net_name, schematic_path
)
if connections:
netlist["nets"].append(
{"name": net_name, "connections": connections}
)
netlist["nets"].append({"name": net_name, "connections": connections})
logger.info(
f"Generated netlist with {len(netlist['nets'])} nets and {len(netlist['components'])} components"
@@ -609,35 +462,3 @@ class ConnectionManager:
except Exception as e:
logger.error(f"Error generating netlist: {e}")
return {"nets": [], "components": []}
if __name__ == "__main__":
# Example Usage (for testing)
from schematic import (
SchematicManager,
) # Assuming schematic.py is in the same directory
# Create a new schematic
test_sch = SchematicManager.create_schematic("ConnectionTestSchematic")
# Add some wires
wire1 = ConnectionManager.add_wire(test_sch, [100, 100], [200, 100])
wire2 = ConnectionManager.add_wire(test_sch, [200, 100], [200, 200])
# Note: add_connection, remove_connection, get_net_connections are placeholders
# and require more complex implementation based on kicad-skip's structure.
# Example of how you might add a net label (requires finding a point on a wire)
# from skip import Label
# if wire1:
# net_label_pos = wire1.start # Or calculate a point on the wire
# net_label = test_sch.add_label(text="Net_01", at=net_label_pos)
# print(f"Added net label 'Net_01' at {net_label_pos}")
# Save the schematic (optional)
# SchematicManager.save_schematic(test_sch, "connection_test.kicad_sch")
# Clean up (if saved)
# if os.path.exists("connection_test.kicad_sch"):
# os.remove("connection_test.kicad_sch")
# print("Cleaned up connection_test.kicad_sch")

View File

@@ -9,10 +9,10 @@ URL schema: https://www.lcsc.com/datasheet/{LCSC#}.pdf
No API key required.
"""
import re
import logging
import re
from pathlib import Path
from typing import Dict, List, Optional
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger("kicad_interface")
@@ -49,7 +49,7 @@ class DatasheetManager:
return None
@staticmethod
def _find_lib_symbols_range(lines: List[str]):
def _find_lib_symbols_range(lines: List[str]) -> Tuple[Optional[int], Optional[int]]:
"""
Find the line range of the (lib_symbols ...) section.
Returns (start, end) line indices or (None, None) if not found.
@@ -81,9 +81,7 @@ class DatasheetManager:
return lib_sym_start, lib_sym_end
@staticmethod
def _process_symbol_block(
lines: List[str], block_start: int, block_end: int
) -> Optional[Dict]:
def _process_symbol_block(lines: List[str], block_start: int, block_end: int) -> Optional[Dict]:
"""
Extract LCSC and Datasheet info from a placed symbol block.
@@ -114,9 +112,7 @@ class DatasheetManager:
"datasheet_value": datasheet_current,
}
def enrich_schematic(
self, schematic_path: Path, dry_run: bool = False
) -> Dict:
def enrich_schematic(self, schematic_path: Path, dry_run: bool = False) -> Dict:
"""
Scan a .kicad_sch file and fill in missing LCSC datasheet URLs.
@@ -223,9 +219,7 @@ class DatasheetManager:
no_lcsc += 1
elif ds_value not in EMPTY_DATASHEET_VALUES:
already_set += 1
logger.debug(
f"Symbol {reference}: Datasheet already set to {ds_value!r}"
)
logger.debug(f"Symbol {reference}: Datasheet already set to {ds_value!r}")
else:
url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm)
if not dry_run:
@@ -256,9 +250,7 @@ class DatasheetManager:
if not dry_run and updated > 0:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write("\n".join(new_lines))
logger.info(
f"Saved {schematic_path.name}: {updated} datasheet URLs written"
)
logger.info(f"Saved {schematic_path.name}: {updated} datasheet URLs written")
return {
"success": True,

View File

@@ -2,10 +2,11 @@
Design rules command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -58,13 +59,9 @@ class DesignRuleCommands:
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
if "microViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(
params["microViaDiameter"] * scale
)
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
if "microViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(
params["microViaDrill"] * scale
)
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
# Set minimum values
if "minTrackWidth" in params:
@@ -77,19 +74,13 @@ class DesignRuleCommands:
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
if "minMicroViaDiameter" in params:
design_settings.m_MicroViasMinSize = int(
params["minMicroViaDiameter"] * scale
)
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
if "minMicroViaDrill" in params:
design_settings.m_MicroViasMinDrill = int(
params["minMicroViaDrill"] * scale
)
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
if "minHoleDiameter" in params:
design_settings.m_MinThroughDrill = int(
params["minHoleDiameter"] * scale
)
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
# KiCAD 9.0: Added hole clearance settings
if "holeClearance" in params:
@@ -181,11 +172,11 @@ class DesignRuleCommands:
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Design Rule Check using kicad-cli"""
import subprocess
import json
import tempfile
import platform
import shutil
import subprocess
import tempfile
try:
if not self.board:
@@ -216,9 +207,7 @@ class DesignRuleCommands:
}
# Create temporary JSON output file
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as tmp:
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
json_output = tmp.name
try:
@@ -297,9 +286,7 @@ class DesignRuleCommands:
# Determine where to save the violations file
board_dir = os.path.dirname(board_file)
board_name = os.path.splitext(os.path.basename(board_file))[0]
violations_file = os.path.join(
board_dir, f"{board_name}_drc_violations.json"
)
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
# Always save violations to JSON file (for large result sets)
with open(violations_file, "w", encoding="utf-8") as f:
@@ -453,9 +440,7 @@ class DesignRuleCommands:
# Filter by severity if specified
if severity != "all":
filtered_violations = [
v for v in all_violations if v.get("severity") == severity
]
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
else:
filtered_violations = all_violations

View File

@@ -7,10 +7,10 @@ on-the-fly using TEXT MANIPULATION (not sexpdata) to preserve file formatting.
This enables access to all ~10,000+ KiCad symbols dynamically.
"""
import logging
import os
import re
import uuid
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
@@ -41,10 +41,17 @@ class DynamicSymbolLoader:
Path("C:/Program Files/KiCad/9.0/share/kicad/symbols"),
Path("C:/Program Files/KiCad/8.0/share/kicad/symbols"),
Path("/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols"),
Path.home() / ".local" / "share" / "kicad" / "10.0" / "symbols",
Path.home() / ".local" / "share" / "kicad" / "9.0" / "symbols",
Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty" / "symbols",
Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty" / "symbols",
]
for env_var in ["KICAD9_SYMBOL_DIR", "KICAD8_SYMBOL_DIR", "KICAD_SYMBOL_DIR"]:
for env_var in [
"KICAD10_SYMBOL_DIR",
"KICAD9_SYMBOL_DIR",
"KICAD8_SYMBOL_DIR",
"KICAD_SYMBOL_DIR",
]:
if env_var in os.environ:
possible_paths.insert(0, Path(os.environ[env_var]))
@@ -81,7 +88,9 @@ class DynamicSymbolLoader:
with open(table_path, "r", encoding="utf-8") as f:
content = f.read()
lib_pattern = r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?'
lib_pattern = (
r'\(lib\s+\(name\s+"?([^"\)\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^"\)\s]+)"?'
)
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1)
if nickname != library_name:
@@ -97,6 +106,11 @@ class DynamicSymbolLoader:
def _resolve_sym_uri(self, uri: str) -> Optional[str]:
"""Resolve environment variables in a sym-lib-table URI."""
env_map = {
"KICAD10_SYMBOL_DIR": [
"/usr/share/kicad/symbols",
"C:/Program Files/KiCad/10.0/share/kicad/symbols",
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols",
],
"KICAD9_SYMBOL_DIR": [
"C:/Program Files/KiCad/9.0/share/kicad/symbols",
"/usr/share/kicad/symbols",
@@ -201,9 +215,7 @@ class DynamicSymbolLoader:
return items
def _inline_extends_symbol(
self, lib_content: str, symbol_name: str, child_block: str
) -> str:
def _inline_extends_symbol(self, lib_content: str, symbol_name: str, child_block: str) -> str:
"""
Fully inline a child symbol that uses (extends "ParentName") by merging
the parent's pins / graphics into the child definition.
@@ -248,22 +260,16 @@ class DynamicSymbolLoader:
for item in self._iter_top_level_items(parent_block):
prop_match = re.match(r'[\s\t]*\(property "([^"]+)"', item)
sub_match = re.search(
r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item
)
sub_match = re.search(r'\(symbol "' + re.escape(parent_name) + r'_\d+_\d+"', item)
if prop_match:
pname = prop_match.group(1)
parent_prop_names.add(pname)
body_lines.append(
child_props[pname] if pname in child_props else item
)
body_lines.append(child_props[pname] if pname in child_props else item)
elif sub_match:
# Rename ParentName_0_1 → ChildName_0_1
body_lines.append(
item.replace(f'"{parent_name}_', f'"{symbol_name}_')
)
elif re.match(r'[\s\t]*\(extends ', item):
body_lines.append(item.replace(f'"{parent_name}_', f'"{symbol_name}_'))
elif re.match(r"[\s\t]*\(extends ", item):
pass # drop extends clause
else:
body_lines.append(item) # pin_names, in_bom, on_board …
@@ -273,16 +279,12 @@ class DynamicSymbolLoader:
if pname not in parent_prop_names:
body_lines.append(pblock)
first_line = parent_block.split("\n")[0].replace(
f'"{parent_name}"', f'"{symbol_name}"'
)
first_line = parent_block.split("\n")[0].replace(f'"{parent_name}"', f'"{symbol_name}"')
last_line = parent_block.split("\n")[-1]
return first_line + "\n" + "\n".join(body_lines) + "\n" + last_line
def extract_symbol_from_library(
self, library_name: str, symbol_name: str
) -> Optional[str]:
def extract_symbol_from_library(self, library_name: str, symbol_name: str) -> Optional[str]:
"""
Extract a symbol definition from a KiCad .kicad_sym library file.
Returns the raw text block, ready to be injected into a schematic.
@@ -304,9 +306,7 @@ class DynamicSymbolLoader:
block = self._extract_symbol_block(lib_content, symbol_name)
if block is None:
logger.warning(
f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym"
)
logger.warning(f"Symbol '{symbol_name}' not found in {library_name}.kicad_sym")
return None
# If the symbol uses (extends "ParentName"), inline the parent content
@@ -315,9 +315,7 @@ class DynamicSymbolLoader:
# load a schematic whose lib_symbols section contains it.
if re.search(r'\(extends "([^"]+)"\)', block):
parent_name = re.search(r'\(extends "([^"]+)"\)', block).group(1)
logger.info(
f"Symbol {symbol_name} extends {parent_name}, inlining parent content"
)
logger.info(f"Symbol {symbol_name} extends {parent_name}, inlining parent content")
block = self._inline_extends_symbol(lib_content, symbol_name, block)
# Prefix top-level symbol name with library
@@ -355,9 +353,7 @@ class DynamicSymbolLoader:
# Extract symbol from library
symbol_block = self.extract_symbol_from_library(library_name, symbol_name)
if not symbol_block:
raise ValueError(
f"Symbol '{symbol_name}' not found in library '{library_name}'"
)
raise ValueError(f"Symbol '{symbol_name}' not found in library '{library_name}'")
# Indent the block to match lib_symbols indentation (4 spaces for top-level)
indented_lines = []
@@ -392,11 +388,7 @@ class DynamicSymbolLoader:
f.write(content)
# Handle both Path objects and strings
sch_name = (
schematic_path.name
if hasattr(schematic_path, "name")
else str(schematic_path)
)
sch_name = schematic_path.name if hasattr(schematic_path, "name") else str(schematic_path)
logger.info(f"Injected symbol {full_name} into {sch_name}")
return True
@@ -433,6 +425,14 @@ class DynamicSymbolLoader:
(property "Datasheet" "~" (at {x} {y} 0)
(effects (font (size 1.27 1.27)) (hide yes))
)
(instances
(project "project"
(path "/"
(reference "{reference}")
(unit 1)
)
)
)
)"""
with open(schematic_path, "r", encoding="utf-8") as f:
@@ -450,9 +450,7 @@ class DynamicSymbolLoader:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write(content)
logger.info(
f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})"
)
logger.info(f"Added component instance {reference} ({full_lib_id}) at ({x}, {y})")
return True
def load_symbol_dynamically(

View File

@@ -2,13 +2,14 @@
Export command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
from typing import Dict, Any, Optional, List, Tuple
import base64
import logging
import os
import shutil
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -105,22 +106,16 @@ class ExportCommands:
]
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=60
)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode == 0:
# Get list of generated drill files
for file in os.listdir(output_dir):
if file.endswith((".drl", ".cnc")):
drill_files.append(file)
else:
logger.warning(
f"Drill file generation failed: {result.stderr}"
)
logger.warning(f"Drill file generation failed: {result.stderr}")
except Exception as drill_error:
logger.warning(
f"Could not generate drill files: {str(drill_error)}"
)
logger.warning(f"Could not generate drill files: {str(drill_error)}")
else:
logger.warning("kicad-cli not available for drill file generation")
@@ -236,9 +231,7 @@ class ExportCommands:
# Get the actual output filename that was created
board_name = os.path.splitext(os.path.basename(self.board.GetFileName()))[0]
actual_filename = f"{board_name}-{base_name}.pdf"
actual_output_path = os.path.join(
os.path.dirname(output_path), actual_filename
)
actual_output_path = os.path.join(os.path.dirname(output_path), actual_filename)
return {
"success": True,
@@ -329,9 +322,9 @@ class ExportCommands:
def export_3d(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Export 3D model files using kicad-cli (KiCAD 9.0 compatible)"""
import subprocess
import platform
import shutil
import subprocess
try:
if not self.board:
@@ -395,9 +388,7 @@ class ExportCommands:
if not include_components:
cmd.append("--no-components")
if include_copper:
cmd.extend(
["--include-tracks", "--include-pads", "--include-zones"]
)
cmd.extend(["--include-tracks", "--include-pads", "--include-zones"])
if include_silkscreen:
cmd.append("--include-silkscreen")
if include_solder_mask:
@@ -618,8 +609,8 @@ class ExportCommands:
Returns:
Path to kicad-cli executable, or None if not found
"""
import shutil
import platform
import shutil
# Try system PATH first
cli_path = shutil.which("kicad-cli")
@@ -696,6 +687,7 @@ class ExportCommands:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
from pathlib import Path
logs_dir = Path(project_dir) / "logs"
logs_dir.mkdir(exist_ok=True)
dest = str(logs_dir / f"mcp_log_{timestamp}.txt")

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,11 +6,12 @@ jlcsearch service at https://jlcsearch.tscircuit.com/
"""
import logging
import requests
from typing import Optional, Dict, List, Callable
import time
from typing import Any, Callable, Dict, List, Optional, Union
logger = logging.getLogger('kicad_interface')
import requests
logger = logging.getLogger("kicad_interface")
class JLCSearchClient:
@@ -23,16 +24,12 @@ class JLCSearchClient:
BASE_URL = "https://jlcsearch.tscircuit.com"
def __init__(self):
def __init__(self) -> None:
"""Initialize JLCSearch API client"""
pass
def search_components(
self,
category: str = "components",
limit: int = 100,
offset: int = 0,
**filters
self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict
) -> List[Dict]:
"""
Search components in JLCSearch database
@@ -48,11 +45,7 @@ class JLCSearchClient:
"""
url = f"{self.BASE_URL}/{category}/list.json"
params = {
"limit": limit,
"offset": offset,
**filters
}
params = {"limit": limit, "offset": offset, **filters}
try:
response = requests.get(url, params=params, timeout=30)
@@ -71,7 +64,9 @@ class JLCSearchClient:
logger.error(f"Failed to search JLCSearch: {e}")
raise Exception(f"JLCSearch API request failed: {e}")
def search_resistors(self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]:
def search_resistors(
self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100
) -> List[Dict]:
"""
Search for resistors
@@ -92,7 +87,7 @@ class JLCSearchClient:
- stock: Available stock
- price1: Price per unit
"""
filters = {}
filters: Dict[str, Any] = {}
if resistance is not None:
filters["resistance"] = resistance
if package:
@@ -100,7 +95,9 @@ class JLCSearchClient:
return self.search_components("resistors", limit=limit, **filters)
def search_capacitors(self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100) -> List[Dict]:
def search_capacitors(
self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100
) -> List[Dict]:
"""
Search for capacitors
@@ -112,7 +109,7 @@ class JLCSearchClient:
Returns:
List of capacitor dicts
"""
filters = {}
filters: Dict[str, Any] = {}
if capacitance is not None:
filters["capacitance"] = capacitance
if package:
@@ -141,9 +138,7 @@ class JLCSearchClient:
return None
def download_all_components(
self,
callback: Optional[Callable[[int, str], None]] = None,
batch_size: int = 100
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
) -> List[Dict]:
"""
Download all components from jlcsearch database
@@ -165,11 +160,7 @@ class JLCSearchClient:
while True:
try:
batch = self.search_components(
"components",
limit=batch_size,
offset=offset
)
batch = self.search_components("components", limit=batch_size, offset=offset)
# Stop if no results returned (end of catalog)
if not batch or len(batch) == 0:
@@ -219,7 +210,7 @@ def test_jlcsearch_connection() -> bool:
return False
if __name__ == '__main__':
if __name__ == "__main__":
# Test the JLCSearch client
logging.basicConfig(level=logging.INFO)

View File

@@ -5,12 +5,12 @@ Handles parsing fp-lib-table files, discovering footprints,
and providing search functionality for component placement.
"""
import glob
import logging
import os
import re
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import glob
logger = logging.getLogger("kicad_interface")
@@ -35,7 +35,7 @@ class LibraryManager:
self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names]
self._load_libraries()
def _load_libraries(self):
def _load_libraries(self) -> None:
"""Load libraries from fp-lib-table files"""
# Load global libraries
global_table = self._get_global_fp_lib_table()
@@ -58,13 +58,16 @@ class LibraryManager:
"""Get path to global fp-lib-table file"""
# Try different possible locations
kicad_config_paths = [
Path.home() / ".config" / "kicad" / "10.0" / "fp-lib-table",
Path.home() / ".config" / "kicad" / "9.0" / "fp-lib-table",
Path.home() / ".config" / "kicad" / "8.0" / "fp-lib-table",
Path.home() / ".config" / "kicad" / "fp-lib-table",
# Windows paths
Path.home() / "AppData" / "Roaming" / "kicad" / "10.0" / "fp-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "fp-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "fp-lib-table",
# macOS paths
Path.home() / "Library" / "Preferences" / "kicad" / "10.0" / "fp-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "fp-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "fp-lib-table",
]
@@ -75,7 +78,7 @@ class LibraryManager:
return None
def _parse_fp_lib_table(self, table_path: Path):
def _parse_fp_lib_table(self, table_path: Path) -> None:
"""
Parse fp-lib-table file
@@ -90,11 +93,21 @@ class LibraryManager:
# Simple regex-based parser for lib entries
# Pattern: (lib (name "NAME")(type TYPE)(uri "URI")...)
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^")\s]+)"?'
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+"?([^")\s]+)"?\)\s*\(uri\s+"?([^")\s]+)"?'
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1)
uri = match.group(2)
lib_type = match.group(2)
uri = match.group(3)
if lib_type.lower() == "table":
table_uri = uri
if os.path.isabs(table_uri) and os.path.isfile(table_uri):
logger.info(f" Following Table reference: {nickname} -> {table_uri}")
self._parse_fp_lib_table(Path(table_uri))
else:
logger.warning(f" Could not resolve Table URI: {table_uri}")
continue
# Resolve environment variables in URI
resolved_uri = self._resolve_uri(uri)
@@ -103,9 +116,7 @@ class LibraryManager:
self.libraries[nickname] = resolved_uri
logger.debug(f" Found library: {nickname} -> {resolved_uri}")
else:
logger.warning(
f" Could not resolve URI for library {nickname}: {uri}"
)
logger.warning(f" Could not resolve URI for library {nickname}: {uri}")
except Exception as e:
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
@@ -126,10 +137,12 @@ class LibraryManager:
# Common KiCAD environment variables
env_vars = {
"KICAD10_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD9_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD8_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KICAD_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
"KISYSMOD": self._find_kicad_footprint_dir(),
"KICAD10_3RD_PARTY": self._find_kicad_3rdparty_dir(),
"KICAD9_3RD_PARTY": self._find_kicad_3rdparty_dir(),
"KICAD8_3RD_PARTY": self._find_kicad_3rdparty_dir(),
}
@@ -206,12 +219,7 @@ class LibraryManager:
/ "9.0"
/ "kicad_common.json", # macOS
Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux
Path.home()
/ "AppData"
/ "Roaming"
/ "kicad"
/ "9.0"
/ "kicad_common.json", # Windows
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "kicad_common.json", # Windows
]
for config_path in kicad_common_paths:
@@ -337,9 +345,7 @@ class LibraryManager:
for library_nickname, library_path in self.libraries.items():
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
if fp_file.exists():
logger.info(
f"Found footprint {footprint_name} in library {library_nickname}"
)
logger.info(f"Found footprint {footprint_name} in library {library_nickname}")
return (library_path, footprint_name)
logger.warning(f"Footprint not found in any library: {footprint_name}")
@@ -446,9 +452,7 @@ class LibraryCommands:
# Filter by library if specified
if library_filter:
results = [
r
for r in results
if r.get("library", "").lower() == library_filter.lower()
r for r in results if r.get("library", "").lower() == library_filter.lower()
]
results = results[:limit]
@@ -492,7 +496,7 @@ class LibraryCommands:
def get_footprint_info(self, params: Dict) -> Dict:
"""Get information about a specific footprint"""
try:
footprint_spec = params.get("footprint")
footprint_spec = params.get("footprint_name")
if not footprint_spec:
return {"success": False, "message": "Missing footprint parameter"}
@@ -508,19 +512,39 @@ class LibraryCommands:
library_nickname = nick
break
info = {
"library": library_nickname,
"footprint": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}",
"library_path": library_path,
}
# Minimal info — always returned even if the parser fails
info: Dict = {
"library": library_nickname,
"name": footprint_name,
"full_name": f"{library_nickname}:{footprint_name}",
"library_path": library_path,
}
return {"success": True, "footprint_info": info}
else:
return {
"success": False,
"message": f"Footprint not found: {footprint_spec}",
}
# Attempt to enrich with parsed .kicad_mod data
try:
from pathlib import Path as _Path
from parsers.kicad_mod_parser import parse_kicad_mod
mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod")
parsed = parse_kicad_mod(mod_file)
if parsed:
# Merge parser output into info; keep our resolved library context
info.update(parsed)
info["name"] = footprint_name # entry name wins over in-file name
info["library"] = library_nickname
info["full_name"] = f"{library_nickname}:{footprint_name}"
info["library_path"] = library_path
else:
logger.warning(
f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info"
)
except Exception as parse_err:
logger.warning(
f"get_footprint_info: parser error ({parse_err}), using minimal info"
)
return {"success": True, "info": info}
except Exception as e:
logger.error(f"Error getting footprint info: {e}")

View File

@@ -1,22 +1,31 @@
from skip import Schematic
import glob
import logging
# Symbol class might not be directly importable in the current version
import os
import glob
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=None):
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
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern
os.path.expanduser("~/Documents/KiCad/*/symbols/*.kicad_sym") # User libraries pattern
os.path.expanduser(
"~/Documents/KiCad/*/symbols/*.kicad_sym"
), # User libraries pattern
]
libraries = []
@@ -26,70 +35,80 @@ class LibraryManager:
matching_libs = glob.glob(path_pattern, recursive=True)
libraries.extend(matching_libs)
except Exception as e:
print(f"Error searching for libraries at {path_pattern}: {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]
print(f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}")
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 list_library_symbols(library_path):
def list_library_symbols(library_path: str) -> List[Any]:
"""List all symbols in a library"""
try:
# kicad-skip doesn't provide a direct way to simply list symbols in a library
# without loading each one. We might need to implement this using KiCAD's Python API
# directly, or by using a different approach.
# For now, this is a placeholder implementation.
# A potential approach would be to load the library file using KiCAD's Python API
# or by parsing the library file format.
# KiCAD symbol libraries are .kicad_sym files which are S-expression format
print(f"Attempted to list symbols in library {library_path}. This requires advanced implementation.")
logger.warning(
f"Attempted to list symbols in library {library_path}. This requires advanced implementation."
)
return []
except Exception as e:
print(f"Error listing symbols in library {library_path}: {e}")
logger.error(f"Error listing symbols in library {library_path}: {e}")
return []
@staticmethod
def get_symbol_details(library_path, symbol_name):
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
"""Get detailed information about a symbol"""
try:
# Similar to list_library_symbols, this might require a more direct approach
# using KiCAD's Python API or by parsing the symbol library.
print(f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation.")
logger.warning(
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
)
return {}
except Exception as e:
print(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
return {}
@staticmethod
def search_symbols(query, search_paths=None):
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 = []
print(f"Searched for symbols matching '{query}'. This requires advanced implementation.")
logger.warning(
f"Searched for symbols matching '{query}'. This requires advanced implementation."
)
return results
except Exception as e:
print(f"Error searching for symbols matching '{query}': {e}")
logger.error(f"Error searching for symbols matching '{query}': {e}")
return []
@staticmethod
def get_default_symbol_for_component_type(component_type, search_paths=None):
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"},
@@ -103,23 +122,24 @@ class LibraryManager:
"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__':
if __name__ == "__main__":
# Example Usage (for testing)
# List available libraries
libraries = LibraryManager.list_available_libraries()
@@ -127,15 +147,15 @@ if __name__ == '__main__':
first_lib = libraries["paths"][0]
lib_name = libraries["names"][0]
print(f"Testing with first library: {lib_name} ({first_lib})")
# List symbols in the first library
symbols = LibraryManager.list_library_symbols(first_lib)
# This will report that it requires advanced implementation
# 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']}")

View File

@@ -5,33 +5,34 @@ Handles parsing sym-lib-table files, discovering symbols,
and providing search functionality for component selection.
"""
import logging
import os
import re
import logging
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Dict, List, Optional
from dataclasses import dataclass, asdict
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
@dataclass
class SymbolInfo:
"""Information about a symbol in a library"""
name: str # Symbol name (without library prefix)
library: str # Library nickname
full_ref: str # "Library:SymbolName"
value: str = "" # Value property
description: str = "" # Description property
footprint: str = "" # Footprint reference if present
lcsc_id: str = "" # LCSC property if present
name: str # Symbol name (without library prefix)
library: str # Library nickname
full_ref: str # "Library:SymbolName"
value: str = "" # Value property
description: str = "" # Description property
footprint: str = "" # Footprint reference if present
lcsc_id: str = "" # LCSC property if present
manufacturer: str = "" # Manufacturer property
mpn: str = "" # Part/MPN property
category: str = "" # Category property
datasheet: str = "" # Datasheet URL
stock: str = "" # Stock (from JLCPCB libs)
price: str = "" # Price (from JLCPCB libs)
lib_class: str = "" # Basic/Preferred/Extended
mpn: str = "" # Part/MPN property
category: str = "" # Category property
datasheet: str = "" # Datasheet URL
stock: str = "" # Stock (from JLCPCB libs)
price: str = "" # Price (from JLCPCB libs)
lib_class: str = "" # Basic/Preferred/Extended
class SymbolLibraryManager:
@@ -54,7 +55,7 @@ class SymbolLibraryManager:
self.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo]
self._load_libraries()
def _load_libraries(self):
def _load_libraries(self) -> None:
"""Load libraries from sym-lib-table files"""
# Load global libraries
global_table = self._get_global_sym_lib_table()
@@ -77,13 +78,16 @@ class SymbolLibraryManager:
"""Get path to global sym-lib-table file"""
# Try different possible locations (same as fp-lib-table but for symbols)
kicad_config_paths = [
Path.home() / ".config" / "kicad" / "10.0" / "sym-lib-table",
Path.home() / ".config" / "kicad" / "9.0" / "sym-lib-table",
Path.home() / ".config" / "kicad" / "8.0" / "sym-lib-table",
Path.home() / ".config" / "kicad" / "sym-lib-table",
# Windows paths
Path.home() / "AppData" / "Roaming" / "kicad" / "10.0" / "sym-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "sym-lib-table",
Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "sym-lib-table",
# macOS paths
Path.home() / "Library" / "Preferences" / "kicad" / "10.0" / "sym-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "sym-lib-table",
Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "sym-lib-table",
]
@@ -94,7 +98,7 @@ class SymbolLibraryManager:
return None
def _parse_sym_lib_table(self, table_path: Path):
def _parse_sym_lib_table(self, table_path: Path) -> None:
"""
Parse sym-lib-table file
@@ -104,16 +108,26 @@ class SymbolLibraryManager:
)
"""
try:
with open(table_path, 'r', encoding='utf-8') as f:
with open(table_path, "r", encoding="utf-8") as f:
content = f.read()
# Simple regex-based parser for lib entries
# Pattern: (lib (name "NAME")(type TYPE)(uri "URI")...)
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^")\s]+)"?'
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+"?([^")\s]+)"?\)\s*\(uri\s+"?([^")\s]+)"?'
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
nickname = match.group(1)
uri = match.group(2)
lib_type = match.group(2)
uri = match.group(3)
if lib_type.lower() == "table":
table_uri = uri
if os.path.isabs(table_uri) and os.path.isfile(table_uri):
logger.info(f" Following Table reference: {nickname} -> {table_uri}")
self._parse_sym_lib_table(Path(table_uri))
else:
logger.warning(f" Could not resolve Table URI: {table_uri}")
continue
# Resolve environment variables in URI
resolved_uri = self._resolve_uri(uri)
@@ -142,23 +156,25 @@ class SymbolLibraryManager:
# Common KiCAD environment variables
env_vars = {
'KICAD9_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD8_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD_SYMBOL_DIR': self._find_kicad_symbol_dir(),
'KICAD9_3RD_PARTY': self._find_3rd_party_dir(),
'KICAD8_3RD_PARTY': self._find_3rd_party_dir(),
'KISYSSYM': self._find_kicad_symbol_dir(),
"KICAD10_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD9_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD8_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD_SYMBOL_DIR": self._find_kicad_symbol_dir(),
"KICAD10_3RD_PARTY": self._find_3rd_party_dir(),
"KICAD9_3RD_PARTY": self._find_3rd_party_dir(),
"KICAD8_3RD_PARTY": self._find_3rd_party_dir(),
"KISYSSYM": self._find_kicad_symbol_dir(),
}
# Project directory
if self.project_path:
env_vars['KIPRJMOD'] = str(self.project_path)
env_vars["KIPRJMOD"] = str(self.project_path)
# Replace environment variables
for var, value in env_vars.items():
if value:
resolved = resolved.replace(f'${{{var}}}', value)
resolved = resolved.replace(f'${var}', value)
resolved = resolved.replace(f"${{{var}}}", value)
resolved = resolved.replace(f"${var}", value)
# Expand ~ to home directory
resolved = os.path.expanduser(resolved)
@@ -184,10 +200,10 @@ class SymbolLibraryManager:
]
# Check environment variable
if 'KICAD9_SYMBOL_DIR' in os.environ:
possible_paths.insert(0, os.environ['KICAD9_SYMBOL_DIR'])
if 'KICAD8_SYMBOL_DIR' in os.environ:
possible_paths.insert(0, os.environ['KICAD8_SYMBOL_DIR'])
if "KICAD9_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"])
if "KICAD8_SYMBOL_DIR" in os.environ:
possible_paths.insert(0, os.environ["KICAD8_SYMBOL_DIR"])
for path in possible_paths:
if os.path.isdir(path):
@@ -198,15 +214,18 @@ class SymbolLibraryManager:
def _find_3rd_party_dir(self) -> Optional[str]:
"""Find KiCAD 3rd party library directory (PCM installed libs)"""
possible_paths = [
str(Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty"),
str(Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty"),
str(Path.home() / "Documents" / "KiCad" / "8.0" / "3rdparty"),
]
# Check environment variable
if 'KICAD9_3RD_PARTY' in os.environ:
possible_paths.insert(0, os.environ['KICAD9_3RD_PARTY'])
if 'KICAD8_3RD_PARTY' in os.environ:
possible_paths.insert(0, os.environ['KICAD8_3RD_PARTY'])
if "KICAD10_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD10_3RD_PARTY"])
if "KICAD9_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD9_3RD_PARTY"])
if "KICAD8_3RD_PARTY" in os.environ:
possible_paths.insert(0, os.environ["KICAD8_3RD_PARTY"])
for path in possible_paths:
if os.path.isdir(path):
@@ -228,7 +247,7 @@ class SymbolLibraryManager:
symbols = []
try:
with open(library_path, 'r', encoding='utf-8') as f:
with open(library_path, "r", encoding="utf-8") as f:
content = f.read()
# Find all top-level symbol definitions
@@ -243,7 +262,7 @@ class SymbolLibraryManager:
symbol_name = match.group(1)
# Skip sub-symbols (they contain _0_, _1_, etc. suffixes)
if re.search(r'_\d+_\d+$', symbol_name):
if re.search(r"_\d+_\d+$", symbol_name):
continue
# Find the start position of this symbol
@@ -262,17 +281,17 @@ class SymbolLibraryManager:
name=symbol_name,
library=library_name,
full_ref=f"{library_name}:{symbol_name}",
value=properties.get('Value', ''),
description=properties.get('Description', ''),
footprint=properties.get('Footprint', ''),
lcsc_id=properties.get('LCSC', ''),
manufacturer=properties.get('Manufacturer', ''),
mpn=properties.get('Part', properties.get('MPN', '')),
category=properties.get('Category', ''),
datasheet=properties.get('Datasheet', ''),
stock=properties.get('Stock', ''),
price=properties.get('Price', ''),
lib_class=properties.get('Class', ''),
value=properties.get("Value", ""),
description=properties.get("Description", ""),
footprint=properties.get("Footprint", ""),
lcsc_id=properties.get("LCSC", ""),
manufacturer=properties.get("Manufacturer", ""),
mpn=properties.get("Part", properties.get("MPN", "")),
category=properties.get("Category", ""),
datasheet=properties.get("Datasheet", ""),
stock=properties.get("Stock", ""),
price=properties.get("Price", ""),
lib_class=properties.get("Class", ""),
)
symbols.append(symbol_info)
@@ -333,7 +352,9 @@ class SymbolLibraryManager:
return symbols
def search_symbols(self, query: str, limit: int = 20, library_filter: Optional[str] = None) -> List[SymbolInfo]:
def search_symbols(
self, query: str, limit: int = 20, library_filter: Optional[str] = None
) -> List[SymbolInfo]:
"""
Search for symbols matching a query
@@ -349,10 +370,12 @@ class SymbolLibraryManager:
query_lower = query.lower()
# Determine which libraries to search
libraries_to_search = self.libraries.keys()
libraries_to_search: list[str] = list(self.libraries.keys())
if library_filter:
filter_lower = library_filter.lower()
libraries_to_search = [lib for lib in libraries_to_search if filter_lower in lib.lower()]
libraries_to_search = [
lib for lib in libraries_to_search if filter_lower in lib.lower()
]
for library_nickname in libraries_to_search:
symbols = self.list_symbols(library_nickname)
@@ -477,17 +500,13 @@ class SymbolLibraryCommands:
"""List all available symbol libraries"""
try:
libraries = self.library_manager.list_libraries()
return {
"success": True,
"libraries": libraries,
"count": len(libraries)
}
return {"success": True, "libraries": libraries, "count": len(libraries)}
except Exception as e:
logger.error(f"Error listing symbol libraries: {e}")
return {
"success": False,
"message": "Failed to list symbol libraries",
"errorDetails": str(e)
"errorDetails": str(e),
}
def search_symbols(self, params: Dict) -> Dict:
@@ -495,10 +514,7 @@ class SymbolLibraryCommands:
try:
query = params.get("query", "")
if not query:
return {
"success": False,
"message": "Missing query parameter"
}
return {"success": False, "message": "Missing query parameter"}
limit = params.get("limit", 20)
library_filter = params.get("library")
@@ -509,25 +525,18 @@ class SymbolLibraryCommands:
"success": True,
"symbols": [asdict(s) for s in results],
"count": len(results),
"query": query
"query": query,
}
except Exception as e:
logger.error(f"Error searching symbols: {e}")
return {
"success": False,
"message": "Failed to search symbols",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to search symbols", "errorDetails": str(e)}
def list_library_symbols(self, params: Dict) -> Dict:
"""List all symbols in a specific library"""
try:
library = params.get("library")
if not library:
return {
"success": False,
"message": "Missing library parameter"
}
return {"success": False, "message": "Missing library parameter"}
# Check if library exists in sym-lib-table
if library not in self.library_manager.libraries:
@@ -536,10 +545,10 @@ class SymbolLibraryCommands:
"success": False,
"message": f"Library '{library}' not found in sym-lib-table",
"errorDetails": f"Library '{library}' is not registered in your KiCad symbol library table. "
f"Found {len(available_libs)} libraries. "
f"Please add this library to your sym-lib-table file, or use one of the available libraries.",
f"Found {len(available_libs)} libraries. "
f"Please add this library to your sym-lib-table file, or use one of the available libraries.",
"available_libraries_count": len(available_libs),
"suggestion": "Use 'list_symbol_libraries' to see all available libraries"
"suggestion": "Use 'list_symbol_libraries' to see all available libraries",
}
symbols = self.library_manager.list_symbols(library)
@@ -548,14 +557,14 @@ class SymbolLibraryCommands:
"success": True,
"library": library,
"symbols": [asdict(s) for s in symbols],
"count": len(symbols)
"count": len(symbols),
}
except Exception as e:
logger.error(f"Error listing library symbols: {e}")
return {
"success": False,
"message": "Failed to list library symbols",
"errorDetails": str(e)
"errorDetails": str(e),
}
def get_symbol_info(self, params: Dict) -> Dict:
@@ -563,34 +572,25 @@ class SymbolLibraryCommands:
try:
symbol_spec = params.get("symbol")
if not symbol_spec:
return {
"success": False,
"message": "Missing symbol parameter"
}
return {"success": False, "message": "Missing symbol parameter"}
result = self.library_manager.find_symbol(symbol_spec)
if result:
return {
"success": True,
"symbol_info": asdict(result)
}
return {"success": True, "symbol_info": asdict(result)}
else:
return {
"success": False,
"message": f"Symbol not found: {symbol_spec}"
}
return {"success": False, "message": f"Symbol not found: {symbol_spec}"}
except Exception as e:
logger.error(f"Error getting symbol info: {e}")
return {
"success": False,
"message": "Failed to get symbol info",
"errorDetails": str(e)
"errorDetails": str(e),
}
if __name__ == '__main__':
if __name__ == "__main__":
# Test the symbol library manager
import json

View File

@@ -9,7 +9,8 @@ import logging
import math
import tempfile
from pathlib import Path
from typing import List, Tuple, Optional, Dict
from typing import Any, Dict, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
from skip import Schematic
@@ -20,7 +21,7 @@ logger = logging.getLogger("kicad_interface")
class PinLocator:
"""Locate pins on symbol instances in KiCad schematics"""
def __init__(self):
def __init__(self) -> None:
"""Initialize pin locator with empty cache"""
self.pin_definition_cache = {} # Cache: "lib_id:symbol_name" -> pin_data
self._schematic_cache: Dict[str, object] = {} # Cache: path -> loaded Schematic
@@ -40,9 +41,9 @@ class PinLocator:
"2": {"x": 0, "y": -3.81, "angle": 90, "length": 1.27, "name": "~", "type": "passive"}
}
"""
pins = {}
pins: Dict[str, Dict[str, Any]] = {}
def extract_pins_recursive(sexp):
def extract_pins_recursive(sexp: Any) -> None:
"""Recursively search for pin definitions"""
if not isinstance(sexp, list):
return
@@ -117,11 +118,7 @@ class PinLocator:
# Find lib_symbols section
lib_symbols = None
for item in sch_data:
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("lib_symbols")
):
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
lib_symbols = item
break
@@ -131,11 +128,7 @@ class PinLocator:
# Find the specific symbol definition
for item in lib_symbols[1:]: # Skip 'lib_symbols' itself
if (
isinstance(item, list)
and len(item) > 1
and item[0] == Symbol("symbol")
):
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
symbol_name = str(item[1]).strip('"')
if symbol_name == lib_id:
# Found the symbol, parse pins
@@ -284,9 +277,7 @@ class PinLocator:
symbol_rotation = float(symbol_at[2]) if len(symbol_at) > 2 else 0.0
# Get symbol lib_id
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return None
@@ -309,7 +300,9 @@ class PinLocator:
None,
)
if matched_num:
logger.debug(f"Resolved pin name '{pin_number}' to pin number '{matched_num}' on {symbol_reference}")
logger.debug(
f"Resolved pin name '{pin_number}' to pin number '{matched_num}' on {symbol_reference}"
)
pin_number = matched_num
else:
logger.error(
@@ -324,26 +317,18 @@ class PinLocator:
pin_rel_x = pin_data["x"]
pin_rel_y = pin_data["y"]
logger.debug(
f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})"
)
logger.debug(f"Pin {pin_number} relative position: ({pin_rel_x}, {pin_rel_y})")
# Apply symbol rotation to pin position
if symbol_rotation != 0:
pin_rel_x, pin_rel_y = self.rotate_point(
pin_rel_x, pin_rel_y, symbol_rotation
)
logger.debug(
f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})"
)
pin_rel_x, pin_rel_y = self.rotate_point(pin_rel_x, pin_rel_y, symbol_rotation)
logger.debug(f"After rotation {symbol_rotation}°: ({pin_rel_x}, {pin_rel_y})")
# Calculate absolute position
abs_x = symbol_x + pin_rel_x
abs_y = symbol_y + pin_rel_y
logger.info(
f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})"
)
logger.info(f"Pin {symbol_reference}/{pin_number} located at ({abs_x}, {abs_y})")
return [abs_x, abs_y]
except Exception as e:
@@ -385,9 +370,7 @@ class PinLocator:
return {}
# Get lib_id
lib_id = (
target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
)
lib_id = target_symbol.lib_id.value if hasattr(target_symbol, "lib_id") else None
if not lib_id:
logger.error(f"Symbol {symbol_reference} has no lib_id")
return {}
@@ -400,9 +383,7 @@ class PinLocator:
# Calculate location for each pin
result = {}
for pin_num in pins.keys():
location = self.get_pin_location(
schematic_path, symbol_reference, pin_num
)
location = self.get_pin_location(schematic_path, symbol_reference, pin_num)
if location:
result[pin_num] = location
@@ -416,14 +397,14 @@ class PinLocator:
if __name__ == "__main__":
# Test pin location discovery
import shutil
import sys
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
from pathlib import Path
from commands.component_schematic import ComponentManager
from commands.schematic import SchematicManager
import shutil
sys.path.insert(0, str(Path(__file__).parent.parent))
print("=" * 80)
print("PIN LOCATOR TEST")
@@ -431,8 +412,8 @@ if __name__ == "__main__":
# Create test schematic with components (cross-platform temp directory)
test_path = Path(tempfile.gettempdir()) / "test_pin_locator.kicad_sch"
template_path = Path(
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/template_with_symbols_expanded.kicad_sch"
template_path = (
Path(__file__).parent.parent / "templates" / "template_with_symbols_expanded.kicad_sch"
)
shutil.copy(template_path, test_path)

View File

@@ -2,11 +2,12 @@
Project-related command implementations for KiCAD interface
"""
import os
import pcbnew # type: ignore
import logging
import os
import shutil
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
import pcbnew # type: ignore
logger = logging.getLogger("kicad_interface")
@@ -22,9 +23,7 @@ class ProjectCommands:
"""Create a new KiCAD project"""
try:
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
project_name = params.get("name") or params.get(
"projectName", "New_Project"
)
project_name = params.get("name") or params.get("projectName", "New_Project")
path = params.get("path", os.getcwd())
template = params.get("template")
@@ -101,9 +100,7 @@ class ProjectCommands:
schematic_uuid = str(uuid_module.uuid4())
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")
@@ -207,9 +204,7 @@ class ProjectCommands:
"success": True,
"message": f"Saved project to: {self.board.GetFileName()}",
"project": {
"name": os.path.splitext(
os.path.basename(self.board.GetFileName())
)[0],
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
"path": self.board.GetFileName(),
},
}

View File

@@ -2,11 +2,12 @@
Routing-related command implementations for KiCAD interface
"""
import os
import pcbnew
import logging
import math
from typing import Dict, Any, Optional, List, Tuple
import os
from typing import Any, Dict, List, Optional, Tuple
import pcbnew
logger = logging.getLogger("kicad_interface")
@@ -114,7 +115,7 @@ class RoutingCommands:
"errorDetails": f"'{ref}' does not exist on the board",
}
def find_pad(ref: str, pad_num: str):
def find_pad(ref: str, pad_num: str) -> Any:
fp = footprints[ref]
for pad in fp.Pads():
if pad.GetNumber() == pad_num:
@@ -149,9 +150,9 @@ class RoutingCommands:
# KiCAD 9 SWIG. Use footprint.GetLayer() instead — it always reflects
# the actual placed layer after Flip().
fp_start = footprints[from_ref]
fp_end = footprints[to_ref]
fp_end = footprints[to_ref]
start_layer = self.board.GetLayerName(fp_start.GetLayer())
end_layer = self.board.GetLayerName(fp_end.GetLayer())
end_layer = self.board.GetLayerName(fp_end.GetLayer())
copper_layers = {"F.Cu", "B.Cu"}
needs_via = (
start_layer in copper_layers
@@ -168,24 +169,34 @@ class RoutingCommands:
via_y = (start_pos.y + end_pos.y) / 2 / scale
# Trace on start layer: start_pad → via
r1 = self.route_trace({
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"end": {"x": via_x, "y": via_y, "unit": "mm"},
"layer": start_layer, "width": width, "net": net,
})
r1 = self.route_trace(
{
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"end": {"x": via_x, "y": via_y, "unit": "mm"},
"layer": start_layer,
"width": width,
"net": net,
}
)
# Via connecting both layers
self.add_via({
"position": {"x": via_x, "y": via_y, "unit": "mm"},
"net": net,
"from_layer": start_layer,
"to_layer": end_layer,
})
self.add_via(
{
"position": {"x": via_x, "y": via_y, "unit": "mm"},
"net": net,
"from_layer": start_layer,
"to_layer": end_layer,
}
)
# Trace on end layer: via → end_pad
r2 = self.route_trace({
"start": {"x": via_x, "y": via_y, "unit": "mm"},
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"layer": end_layer, "width": width, "net": net,
})
r2 = self.route_trace(
{
"start": {"x": via_x, "y": via_y, "unit": "mm"},
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"layer": end_layer,
"width": width,
"net": net,
}
)
success = r1.get("success") and r2.get("success")
result = {
"success": success,
@@ -195,21 +206,28 @@ class RoutingCommands:
}
else:
# Same layer — direct trace
result = self.route_trace({
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"layer": layer if layer else start_layer,
"width": width, "net": net,
})
result = self.route_trace(
{
"start": {"x": start_pos.x / scale, "y": start_pos.y / scale, "unit": "mm"},
"end": {"x": end_pos.x / scale, "y": end_pos.y / scale, "unit": "mm"},
"layer": layer if layer else start_layer,
"width": width,
"net": net,
}
)
if result.get("success"):
result["fromPad"] = {
"ref": from_ref, "pad": from_pad,
"x": start_pos.x / scale, "y": start_pos.y / scale,
"ref": from_ref,
"pad": from_pad,
"x": start_pos.x / scale,
"y": start_pos.y / scale,
}
result["toPad"] = {
"ref": to_ref, "pad": to_pad,
"x": end_pos.x / scale, "y": end_pos.y / scale,
"ref": to_ref,
"pad": to_pad,
"x": end_pos.x / scale,
"y": end_pos.y / scale,
}
return result
@@ -352,21 +370,15 @@ class RoutingCommands:
via = pcbnew.PCB_VIA(self.board)
# Set position
scale = (
1000000 if position["unit"] == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
via.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
# Set size and drill (default to board's current via settings)
design_settings = self.board.GetDesignSettings()
via.SetWidth(
int(size * 1000000) if size else design_settings.GetCurrentViaSize()
)
via.SetDrill(
int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill()
)
via.SetWidth(int(size * 1000000) if size else design_settings.GetCurrentViaSize())
via.SetDrill(int(drill * 1000000) if drill else design_settings.GetCurrentViaDrill())
# Set layers
from_id = self.board.GetLayerID(from_layer)
@@ -500,9 +512,7 @@ class RoutingCommands:
# Find track by position
if position:
scale = (
1000000 if position["unit"] == "mm" else 25400000
) # mm or inch to nm
scale = 1000000 if position["unit"] == "mm" else 25400000 # mm or inch to nm
x_nm = int(position["x"] * scale)
y_nm = int(position["y"] * scale)
point = pcbnew.VECTOR2I(x_nm, y_nm)
@@ -940,9 +950,7 @@ class RoutingCommands:
else:
traces_to_copy.append(track)
filter_method = (
"net-based" if use_net_filter else "geometric (pads have no nets)"
)
filter_method = "net-based" if use_net_filter else "geometric (pads have no nets)"
logger.info(
f"copy_routing_pattern: {len(traces_to_copy)} traces, "
f"{len(vias_to_copy)} vias selected via {filter_method}"
@@ -958,9 +966,7 @@ class RoutingCommands:
# Create new track
new_track = pcbnew.PCB_TRACK(self.board)
new_track.SetStart(
pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y)
)
new_track.SetStart(pcbnew.VECTOR2I(start.x + offset_x, start.y + offset_y))
new_track.SetEnd(pcbnew.VECTOR2I(end.x + offset_x, end.y + offset_y))
new_track.SetLayer(track.GetLayer())
@@ -1320,15 +1326,11 @@ class RoutingCommands:
pos_start = pcbnew.VECTOR2I(
int(start_point.x + offset_x), int(start_point.y + offset_y)
)
pos_end = pcbnew.VECTOR2I(
int(end_point.x + offset_x), int(end_point.y + offset_y)
)
pos_end = pcbnew.VECTOR2I(int(end_point.x + offset_x), int(end_point.y + offset_y))
neg_start = pcbnew.VECTOR2I(
int(start_point.x - offset_x), int(start_point.y - offset_y)
)
neg_end = pcbnew.VECTOR2I(
int(end_point.x - offset_x), int(end_point.y - offset_y)
)
neg_end = pcbnew.VECTOR2I(int(end_point.x - offset_x), int(end_point.y - offset_y))
# Create positive trace
pos_track = pcbnew.PCB_TRACK(self.board)
@@ -1395,9 +1397,7 @@ class RoutingCommands:
return pad.GetPosition()
raise ValueError("Invalid point specification")
def _point_to_track_distance(
self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK
) -> float:
def _point_to_track_distance(self, point: pcbnew.VECTOR2I, track: pcbnew.PCB_TRACK) -> float:
"""Calculate distance from point to track segment"""
start = track.GetStart()
end = track.GetEnd()

View File

@@ -1,8 +1,10 @@
from skip import Schematic
import logging
import os
import shutil
import logging
import uuid
from typing import Any, Optional
from skip import Schematic
logger = logging.getLogger("kicad_interface")
@@ -11,7 +13,7 @@ class SchematicManager:
"""Core schematic operations using kicad-skip"""
@staticmethod
def create_schematic(name, metadata=None):
def create_schematic(name: str, metadata: Optional[Any] = None) -> Any:
"""Create a new empty schematic from template"""
try:
# Determine template path (use template_with_symbols for component cloning support)
@@ -31,31 +33,28 @@ class SchematicManager:
# Regenerate UUID to ensure uniqueness for each created schematic
import re
with open(output_path, 'r', encoding='utf-8') as f:
with open(output_path, "r", encoding="utf-8") as f:
content = f.read()
new_uuid = str(uuid.uuid4())
content = re.sub(
r'\(uuid [0-9a-fA-F-]+\)',
f'(uuid {new_uuid})',
r"\(uuid [0-9a-fA-F-]+\)",
f"(uuid {new_uuid})",
content,
count=1 # Only replace first (schematic) UUID
count=1, # Only replace first (schematic) UUID
)
with open(output_path, 'w', encoding='utf-8', newline='\n') as f:
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(content)
logger.info(f"Created schematic from template: {output_path}")
else:
# Fallback: create minimal schematic
logger.warning(
f"Template not found at {template_path}, creating minimal schematic"
)
logger.warning(f"Template not found at {template_path}, creating minimal schematic")
# Generate unique UUID for this schematic
schematic_uuid = str(uuid.uuid4())
# Write with explicit UTF-8 encoding and Unix line endings for cross-platform compatibility
with open(output_path, "w", encoding="utf-8", newline="\n") as f:
f.write(
'(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n'
)
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
f.write(f" (uuid {schematic_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")
@@ -72,7 +71,7 @@ class SchematicManager:
raise
@staticmethod
def load_schematic(file_path):
def load_schematic(file_path: str) -> Optional[Any]:
"""Load an existing schematic"""
if not os.path.exists(file_path):
logger.error(f"Schematic file not found at {file_path}")
@@ -86,7 +85,7 @@ class SchematicManager:
return None
@staticmethod
def save_schematic(schematic, file_path):
def save_schematic(schematic: Any, file_path: str) -> bool:
"""Save a schematic to file"""
try:
# kicad-skip uses write method, not save
@@ -98,7 +97,7 @@ class SchematicManager:
return False
@staticmethod
def get_schematic_metadata(schematic):
def get_schematic_metadata(schematic: Any) -> dict[str, Any]:
"""Extract metadata from schematic"""
# kicad-skip doesn't expose a direct metadata object on Schematic.
# We can return basic info like version and generator.

View File

@@ -0,0 +1,976 @@
"""
Schematic Analysis Tools for KiCad Schematics
Read-only analysis tools for detecting spatial problems, querying regions,
and checking connectivity in KiCad schematic files.
"""
import logging
import math
from collections import defaultdict
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
import sexpdata
from commands.pin_locator import PinLocator
from commands.wire_connectivity import _parse_virtual_connections, _to_iu
from sexpdata import Symbol
from skip import Schematic
logger = logging.getLogger("kicad_interface")
# ---------------------------------------------------------------------------
# S-expression parsing helpers
# ---------------------------------------------------------------------------
def _load_sexp(schematic_path: Path) -> list:
"""Load schematic file and return parsed S-expression data."""
with open(schematic_path, "r", encoding="utf-8") as f:
return sexpdata.loads(f.read())
def _parse_wires(sexp_data: list) -> List[Dict[str, Any]]:
"""
Parse all wire segments from the schematic S-expression.
Returns list of dicts: {start: (x_mm, y_mm), end: (x_mm, y_mm)}
"""
wires = []
for item in sexp_data:
if not isinstance(item, list) or len(item) < 2:
continue
if item[0] != Symbol("wire"):
continue
pts = None
for sub in item:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
pts = sub
break
if not pts:
continue
coords = []
for sub in pts:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("xy"):
coords.append((float(sub[1]), float(sub[2])))
if len(coords) >= 2:
wires.append({"start": coords[0], "end": coords[1]})
return wires
def _parse_labels(sexp_data: list) -> List[Dict[str, Any]]:
"""
Parse all labels (label and global_label) from the schematic S-expression.
Returns list of dicts: {name, type ('label'|'global_label'), x, y}
"""
labels = []
for item in sexp_data:
if not isinstance(item, list) or len(item) < 2:
continue
tag = item[0]
if tag not in (Symbol("label"), Symbol("global_label")):
continue
name = str(item[1]).strip('"')
label_type = str(tag)
x, y = 0.0, 0.0
for sub in item:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("at"):
x = float(sub[1])
y = float(sub[2])
break
labels.append({"name": name, "type": label_type, "x": x, "y": y})
return labels
def _parse_symbols(sexp_data: list) -> List[Dict[str, Any]]:
"""
Parse all placed symbol instances from the schematic S-expression.
Returns list of dicts: {reference, lib_id, x, y, rotation, mirror_x, mirror_y, is_power}
"""
symbols = []
for item in sexp_data:
if not isinstance(item, list) or len(item) < 2:
continue
if item[0] != Symbol("symbol"):
continue
lib_id = ""
x, y, rotation = 0.0, 0.0, 0.0
reference = ""
is_power = False
mirror_x = False
mirror_y = False
for sub in item:
if isinstance(sub, list) and len(sub) >= 2:
if sub[0] == Symbol("lib_id"):
lib_id = str(sub[1]).strip('"')
elif sub[0] == Symbol("at") and len(sub) >= 3:
x = float(sub[1])
y = float(sub[2])
if len(sub) >= 4:
rotation = float(sub[3])
elif sub[0] == Symbol("mirror"):
m = str(sub[1])
if m == "x":
mirror_x = True
elif m == "y":
mirror_y = True
elif sub[0] == Symbol("property") and len(sub) >= 3:
prop_name = str(sub[1]).strip('"')
if prop_name == "Reference":
reference = str(sub[2]).strip('"')
is_power = reference.startswith("#PWR") or reference.startswith("#FLG")
symbols.append(
{
"reference": reference,
"lib_id": lib_id,
"x": x,
"y": y,
"rotation": rotation,
"mirror_x": mirror_x,
"mirror_y": mirror_y,
"is_power": is_power,
}
)
return symbols
def _parse_lib_symbol_graphics(symbol_def: list) -> List[Tuple[float, float]]:
"""
Parse graphical body elements from a lib_symbol definition and return
local-coordinate bounding points.
Extracts points from rectangle, polyline, circle, arc, and bezier
elements found in sub-symbols (typically the ``_0_1`` layers that
contain body shapes).
Returns a list of ``(x, y)`` points in local symbol coordinates.
"""
points: List[Tuple[float, float]] = []
def _extract_graphics_recursive(sexp: list) -> None:
if not isinstance(sexp, list) or len(sexp) == 0:
return
tag = sexp[0]
if tag == Symbol("rectangle"):
# (rectangle (start x y) (end x y) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) >= 3:
if sub[0] in (Symbol("start"), Symbol("end")):
points.append((float(sub[1]), float(sub[2])))
elif tag == Symbol("polyline"):
# (polyline (pts (xy x y) (xy x y) ...) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]:
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
points.append((float(pt[1]), float(pt[2])))
elif tag == Symbol("circle"):
# (circle (center x y) (radius r) ...)
cx, cy, r = 0.0, 0.0, 0.0
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == Symbol("center"):
cx, cy = float(sub[1]), float(sub[2])
elif isinstance(sub, list) and len(sub) >= 2 and sub[0] == Symbol("radius"):
r = float(sub[1])
if r > 0:
points.extend(
[
(cx - r, cy - r),
(cx + r, cy + r),
]
)
elif tag == Symbol("arc"):
# (arc (start x y) (mid x y) (end x y) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) >= 3:
if sub[0] in (Symbol("start"), Symbol("mid"), Symbol("end")):
points.append((float(sub[1]), float(sub[2])))
elif tag == Symbol("bezier"):
# (bezier (pts (xy x y) ...) ...)
for sub in sexp[1:]:
if isinstance(sub, list) and len(sub) > 0 and sub[0] == Symbol("pts"):
for pt in sub[1:]:
if isinstance(pt, list) and len(pt) >= 3 and pt[0] == Symbol("xy"):
points.append((float(pt[1]), float(pt[2])))
else:
# Recurse into sub-symbols to find graphics in nested definitions
for sub in sexp[1:]:
if isinstance(sub, list):
_extract_graphics_recursive(sub)
# Search the top-level symbol definition and its sub-symbols
for item in symbol_def[1:]:
if isinstance(item, list):
_extract_graphics_recursive(item)
return points
def _extract_lib_symbols(sexp_data: list) -> Dict[str, Dict]:
"""
Walk the lib_symbols section of already-parsed sexp_data and return
pin definitions and graphics points for every symbol definition.
Returns:
Dict mapping lib_id → {"pins": pin_defs, "graphics_points": [(x,y), ...]}.
"""
lib_symbols_section = None
for item in sexp_data:
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("lib_symbols"):
lib_symbols_section = item
break
if not lib_symbols_section:
return {}
result: Dict[str, Dict] = {}
for item in lib_symbols_section[1:]:
if isinstance(item, list) and len(item) > 1 and item[0] == Symbol("symbol"):
symbol_name = str(item[1]).strip('"')
result[symbol_name] = {
"pins": PinLocator.parse_symbol_definition(item),
"graphics_points": _parse_lib_symbol_graphics(item),
}
return result
# ---------------------------------------------------------------------------
# Geometry helpers
# ---------------------------------------------------------------------------
def compute_symbol_bbox(
schematic_path: Path,
reference: str,
locator: PinLocator,
) -> Optional[Tuple[float, float, float, float]]:
"""
Compute bounding box of a symbol from its pin positions.
Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins found.
"""
pins = locator.get_all_symbol_pins(schematic_path, reference)
if not pins:
return None
xs = [p[0] for p in pins.values()]
ys = [p[1] for p in pins.values()]
return (min(xs), min(ys), max(xs), max(ys))
def _line_segment_intersects_aabb(
x1: float,
y1: float,
x2: float,
y2: float,
box_min_x: float,
box_min_y: float,
box_max_x: float,
box_max_y: float,
) -> bool:
"""
Test whether line segment (x1,y1)→(x2,y2) intersects an axis-aligned bounding box.
Uses the Liang-Barsky clipping algorithm.
"""
dx = x2 - x1
dy = y2 - y1
p = [-dx, dx, -dy, dy]
q = [x1 - box_min_x, box_max_x - x1, y1 - box_min_y, box_max_y - y1]
t_min = 0.0
t_max = 1.0
for i in range(4):
if abs(p[i]) < 1e-12:
# Parallel to this edge
if q[i] < 0:
return False
else:
t = q[i] / p[i]
if p[i] < 0:
t_min = max(t_min, t)
else:
t_max = min(t_max, t)
if t_min > t_max:
return False
return True
def _point_in_rect(
px: float,
py: float,
min_x: float,
min_y: float,
max_x: float,
max_y: float,
) -> bool:
"""Check if a point is within a rectangle."""
return min_x <= px <= max_x and min_y <= py <= max_y
def _distance(p1: Tuple[float, float], p2: Tuple[float, float]) -> float:
"""Euclidean distance between two points."""
return math.sqrt((p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2)
def _aabb_overlap(
a: Tuple[float, float, float, float],
b: Tuple[float, float, float, float],
) -> bool:
"""Check if two axis-aligned bounding boxes overlap.
Each bbox is (min_x, min_y, max_x, max_y).
"""
return a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3]
def _transform_local_point(
lx: float,
ly: float,
sym_x: float,
sym_y: float,
rotation: float,
mirror_x: bool,
mirror_y: bool,
) -> Tuple[float, float]:
"""
Transform a point from local symbol coordinates to absolute schematic
coordinates using KiCad's transform order:
negate-y (lib y-up → schematic y-down) → mirror → rotate → translate.
"""
# Library symbols use y-up; schematic uses y-down
ly = -ly
# Apply mirroring in local coords
if mirror_x:
ly = -ly
if mirror_y:
lx = -lx
# Apply rotation
if rotation != 0:
lx, ly = PinLocator.rotate_point(lx, ly, rotation)
return (sym_x + lx, sym_y + ly)
def _compute_symbol_bbox_direct(
sym: Dict[str, Any],
pin_defs: Dict[str, Dict],
margin: float = 0.0,
graphics_points: Optional[List[Tuple[float, float]]] = None,
) -> Optional[Tuple[float, float, float, float]]:
"""
Compute bounding box of a symbol from its graphics and pin definitions.
When graphics_points are available (from lib_symbol body shapes), uses
those for the bbox and unions with pin positions. Falls back to
pin-only estimation with degenerate expansion when no graphics data
is available.
Args:
sym: Parsed symbol dict with x, y, rotation, mirror_x, mirror_y.
pin_defs: Pin definitions from PinLocator.get_symbol_pins().
margin: Shrink bbox by this amount on each side (mm).
graphics_points: Local-coordinate points from symbol body graphics.
Returns (min_x, min_y, max_x, max_y) in mm, or None if no pins.
"""
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
if not pin_positions:
return None
if graphics_points:
# Transform graphics points to absolute coordinates
sym_x, sym_y = sym["x"], sym["y"]
rotation = sym["rotation"]
mirror_x = sym.get("mirror_x", False)
mirror_y = sym.get("mirror_y", False)
abs_points = [
_transform_local_point(lx, ly, sym_x, sym_y, rotation, mirror_x, mirror_y)
for lx, ly in graphics_points
]
# Union with pin positions so pins extending beyond body are included
all_xs = [p[0] for p in abs_points] + [p[0] for p in pin_positions.values()]
all_ys = [p[1] for p in abs_points] + [p[1] for p in pin_positions.values()]
min_x, min_y = min(all_xs), min(all_ys)
max_x, max_y = max(all_xs), max(all_ys)
else:
# Fallback: pin-only estimation with degenerate expansion
xs = [p[0] for p in pin_positions.values()]
ys = [p[1] for p in pin_positions.values()]
min_x, min_y, max_x, max_y = min(xs), min(ys), max(xs), max(ys)
min_body = 1.5 # mm minimum half-extent for component body
if max_x - min_x < 2 * min_body:
cx = (min_x + max_x) / 2
min_x = cx - min_body
max_x = cx + min_body
if max_y - min_y < 2 * min_body:
cy = (min_y + max_y) / 2
min_y = cy - min_body
max_y = cy + min_body
# Shrink bbox by margin
min_x += margin
min_y += margin
max_x -= margin
max_y -= margin
# Skip degenerate bboxes
if max_x <= min_x or max_y <= min_y:
return None
return (min_x, min_y, max_x, max_y)
# ---------------------------------------------------------------------------
# Tool 3: find_overlapping_elements
# ---------------------------------------------------------------------------
def find_overlapping_elements(schematic_path: Path, tolerance: float = 0.5) -> Dict[str, Any]:
"""
Detect spatially overlapping symbols, wires, and labels.
Args:
schematic_path: Path to .kicad_sch file
tolerance: Distance threshold in mm for label proximity and wire collinearity checks. Symbol overlap uses bounding-box intersection.
Returns dict: {overlappingSymbols, overlappingLabels, overlappingWires, totalOverlaps}
"""
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
labels = _parse_labels(sexp_data)
overlapping_symbols = []
overlapping_labels = []
overlapping_wires = []
lib_defs = _extract_lib_symbols(sexp_data)
# --- Symbol-symbol overlap using bounding-box intersection (O(n²)) ---
non_template_symbols = [
s for s in symbols if not s["reference"].startswith("_TEMPLATE") and s["reference"]
]
# Pre-compute bounding boxes for all non-template symbols
symbol_bboxes = []
for sym in non_template_symbols:
lib_data = lib_defs.get(sym["lib_id"], {})
pin_defs = lib_data.get("pins", {})
graphics_points = lib_data.get("graphics_points", [])
bbox = None
if pin_defs:
bbox = _compute_symbol_bbox_direct(sym, pin_defs, graphics_points=graphics_points)
symbol_bboxes.append((sym, bbox))
for i in range(len(symbol_bboxes)):
s1, bbox1 = symbol_bboxes[i]
for j in range(i + 1, len(symbol_bboxes)):
s2, bbox2 = symbol_bboxes[j]
dist = _distance((s1["x"], s1["y"]), (s2["x"], s2["y"]))
overlap_detected = False
if bbox1 is not None and bbox2 is not None:
# Use bounding box intersection
overlap_detected = _aabb_overlap(bbox1, bbox2)
else:
# Fallback to center distance when pin data is unavailable
overlap_detected = dist < tolerance
if overlap_detected:
entry = {
"element1": {
"reference": s1["reference"],
"libId": s1["lib_id"],
"position": {"x": s1["x"], "y": s1["y"]},
},
"element2": {
"reference": s2["reference"],
"libId": s2["lib_id"],
"position": {"x": s2["x"], "y": s2["y"]},
},
"distance": round(dist, 4),
}
# Flag power symbol pairs specifically
if s1["is_power"] and s2["is_power"]:
entry["type"] = "power_symbol_overlap"
else:
entry["type"] = "symbol_overlap"
overlapping_symbols.append(entry)
# --- Label-label overlap ---
for i in range(len(labels)):
for j in range(i + 1, len(labels)):
l1 = labels[i]
l2 = labels[j]
dist = _distance((l1["x"], l1["y"]), (l2["x"], l2["y"]))
if dist < tolerance:
overlapping_labels.append(
{
"element1": {
"name": l1["name"],
"type": l1["type"],
"position": {"x": l1["x"], "y": l1["y"]},
},
"element2": {
"name": l2["name"],
"type": l2["type"],
"position": {"x": l2["x"], "y": l2["y"]},
},
"distance": round(dist, 4),
}
)
# --- Wire-wire collinear overlap ---
for i in range(len(wires)):
for j in range(i + 1, len(wires)):
w1 = wires[i]
w2 = wires[j]
overlap = _check_wire_overlap(w1, w2, tolerance)
if overlap:
overlapping_wires.append(overlap)
total = len(overlapping_symbols) + len(overlapping_labels) + len(overlapping_wires)
return {
"overlappingSymbols": overlapping_symbols,
"overlappingLabels": overlapping_labels,
"overlappingWires": overlapping_wires,
"totalOverlaps": total,
}
def _check_wire_overlap(
w1: Dict[str, Any], w2: Dict[str, Any], tolerance: float
) -> Optional[Dict[str, Any]]:
"""
Check if two wire segments are collinear and overlapping.
Works for horizontal, vertical, and diagonal wires. Uses direction
vectors, cross-product parallelism, point-to-line distance for
collinearity, and 1D projection overlap.
Returns overlap info dict or None.
"""
s1, e1 = w1["start"], w1["end"]
s2, e2 = w2["start"], w2["end"]
d1 = (e1[0] - s1[0], e1[1] - s1[1])
d2 = (e2[0] - s2[0], e2[1] - s2[1])
len1 = math.sqrt(d1[0] ** 2 + d1[1] ** 2)
len2 = math.sqrt(d2[0] ** 2 + d2[1] ** 2)
if len1 < 1e-12 or len2 < 1e-12:
return None # degenerate zero-length segment
# Cross product to check parallel
cross = d1[0] * d2[1] - d1[1] * d2[0]
if abs(cross) > tolerance * max(len1, len2):
return None # not parallel
# Point-to-line distance: s2 relative to line through s1 along d1
ds = (s2[0] - s1[0], s2[1] - s1[1])
perp_dist = abs(ds[0] * d1[1] - ds[1] * d1[0]) / len1
if perp_dist > tolerance:
return None # parallel but offset
# Project onto d1 direction for 1D overlap check
u1 = (d1[0] / len1, d1[1] / len1)
proj_s1 = s1[0] * u1[0] + s1[1] * u1[1]
proj_e1 = e1[0] * u1[0] + e1[1] * u1[1]
proj_s2 = s2[0] * u1[0] + s2[1] * u1[1]
proj_e2 = e2[0] * u1[0] + e2[1] * u1[1]
min1, max1 = min(proj_s1, proj_e1), max(proj_s1, proj_e1)
min2, max2 = min(proj_s2, proj_e2), max(proj_s2, proj_e2)
if min1 < max2 and min2 < max1:
return {
"wire1": {
"start": {"x": s1[0], "y": s1[1]},
"end": {"x": e1[0], "y": e1[1]},
},
"wire2": {
"start": {"x": s2[0], "y": s2[1]},
"end": {"x": e2[0], "y": e2[1]},
},
"type": "collinear_overlap",
}
return None
# ---------------------------------------------------------------------------
# Tool 4: get_elements_in_region
# ---------------------------------------------------------------------------
def get_elements_in_region(
schematic_path: Path,
x1: float,
y1: float,
x2: float,
y2: float,
) -> Dict[str, Any]:
"""
List all wires, labels, and symbols within a rectangular region.
Args:
schematic_path: Path to .kicad_sch file
x1, y1, x2, y2: Bounding box corners in schematic mm
Returns dict: {symbols, wires, labels, counts}
"""
min_x, max_x = min(x1, x2), max(x1, x2)
min_y, max_y = min(y1, y2), max(y1, y2)
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
labels = _parse_labels(sexp_data)
lib_defs = _extract_lib_symbols(sexp_data)
# Symbols: include if position is within bounds
region_symbols = []
for sym in symbols:
if not sym["reference"] or sym["reference"].startswith("_TEMPLATE"):
continue
if _point_in_rect(sym["x"], sym["y"], min_x, min_y, max_x, max_y):
entry = {
"reference": sym["reference"],
"libId": sym["lib_id"],
"position": {"x": sym["x"], "y": sym["y"]},
"isPower": sym["is_power"],
}
# Include pin positions (compute directly to handle unannotated duplicates)
lib_data = lib_defs.get(sym["lib_id"], {})
pin_defs = lib_data.get("pins", {})
if pin_defs:
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
if pin_positions:
entry["pins"] = {
pn: {"x": round(pos[0], 4), "y": round(pos[1], 4)}
for pn, pos in pin_positions.items()
}
region_symbols.append(entry)
# Wires: include if any part of the wire intersects the region
region_wires = []
for w in wires:
s, e = w["start"], w["end"]
if (
_point_in_rect(s[0], s[1], min_x, min_y, max_x, max_y)
or _point_in_rect(e[0], e[1], min_x, min_y, max_x, max_y)
or _line_segment_intersects_aabb(s[0], s[1], e[0], e[1], min_x, min_y, max_x, max_y)
):
region_wires.append(
{
"start": {"x": s[0], "y": s[1]},
"end": {"x": e[0], "y": e[1]},
}
)
# Labels: include if position is within bounds
region_labels = []
for lbl in labels:
if _point_in_rect(lbl["x"], lbl["y"], min_x, min_y, max_x, max_y):
region_labels.append(
{
"name": lbl["name"],
"type": lbl["type"],
"position": {"x": lbl["x"], "y": lbl["y"]},
}
)
return {
"symbols": region_symbols,
"wires": region_wires,
"labels": region_labels,
"counts": {
"symbols": len(region_symbols),
"wires": len(region_wires),
"labels": len(region_labels),
},
}
# ---------------------------------------------------------------------------
# Tool 5: check_wire_collisions
# ---------------------------------------------------------------------------
def _compute_pin_positions_direct(
sym: Dict[str, Any], pin_defs: Dict[str, Dict]
) -> Dict[str, List[float]]:
"""
Compute absolute schematic pin positions for a symbol instance directly from
its parsed position/rotation/mirror data and pin definitions in local coords.
Unlike PinLocator.get_all_symbol_pins, this does NOT do a reference-name
lookup in the schematic, so it works correctly when multiple symbols share
the same reference designator (e.g. unannotated "Q?").
KiCad transform order: mirror (in local coords) → rotate → translate.
"""
sym_x = sym["x"]
sym_y = sym["y"]
rotation = sym["rotation"]
mirror_x = sym.get("mirror_x", False)
mirror_y = sym.get("mirror_y", False)
result: Dict[str, List[float]] = {}
for pin_num, pin_data in pin_defs.items():
rel_x = float(pin_data["x"])
rel_y = float(pin_data["y"])
# Apply mirroring in local symbol coordinates
if mirror_x:
rel_y = -rel_y
if mirror_y:
rel_x = -rel_x
# Apply symbol rotation
if rotation != 0:
rel_x, rel_y = PinLocator.rotate_point(rel_x, rel_y, rotation)
result[pin_num] = [sym_x + rel_x, sym_y + rel_y]
return result
def find_wires_crossing_symbols(schematic_path: Path) -> List[Dict[str, Any]]:
"""
Find all wires that cross over component symbol bodies.
Wires passing over symbols are unacceptable in schematics — they indicate
routing mistakes where a wire was drawn across a component instead of
around it.
For each non-power, non-template symbol:
1. Compute bounding box from pin positions (shrunk by margin).
2. For each wire segment, test intersection with the bbox.
3. If intersects and the wire is not simply terminating at a pin from
outside, report it as a crossing.
Returns list of crossing dicts.
"""
sexp_data = _load_sexp(schematic_path)
symbols = _parse_symbols(sexp_data)
wires = _parse_wires(sexp_data)
lib_defs = _extract_lib_symbols(sexp_data)
margin = 0.5 # mm margin to shrink bbox (avoids false positives at pin tips)
pin_tolerance = 0.05 # mm
collisions = []
# Pre-compute per-symbol data
symbol_data: List[Dict[str, Any]] = []
for sym in symbols:
ref = sym["reference"]
if sym["is_power"] or ref.startswith("_TEMPLATE") or not ref:
continue
lib_data = lib_defs.get(sym["lib_id"], {})
pin_defs = lib_data.get("pins", {})
if not pin_defs:
continue
graphics_points = lib_data.get("graphics_points", [])
bbox = _compute_symbol_bbox_direct(
sym, pin_defs, margin=margin, graphics_points=graphics_points
)
if bbox is None:
continue
pin_positions = _compute_pin_positions_direct(sym, pin_defs)
pin_set = set()
for pos in pin_positions.values():
pin_set.add((pos[0], pos[1]))
symbol_data.append(
{
"sym": sym,
"bbox": bbox,
"pin_set": pin_set,
}
)
# Test each wire against each symbol bbox
for w in wires:
sx, sy = w["start"]
ex, ey = w["end"]
for sd in symbol_data:
bx1, by1, bx2, by2 = sd["bbox"]
if not _line_segment_intersects_aabb(sx, sy, ex, ey, bx1, by1, bx2, by2):
continue
# Check which endpoints land on a pin of this symbol
start_at_pin = any(
abs(sx - px) < pin_tolerance and abs(sy - py) < pin_tolerance
for px, py in sd["pin_set"]
)
end_at_pin = any(
abs(ex - px) < pin_tolerance and abs(ey - py) < pin_tolerance
for px, py in sd["pin_set"]
)
# When exactly one endpoint is at a pin, check whether the wire
# just terminates at the pin (valid connection) or continues through
# the component body (pass-through → collision).
# Nudge the pin endpoint slightly toward the other end; if the
# shortened segment still intersects the bbox, the wire extends
# into/through the body.
if (start_at_pin or end_at_pin) and not (start_at_pin and end_at_pin):
dx, dy = ex - sx, ey - sy
length = math.sqrt(dx * dx + dy * dy)
if length > 0:
nudge = min(0.2, length * 0.5)
ux, uy = dx / length, dy / length
if start_at_pin:
nsx, nsy = sx + ux * nudge, sy + uy * nudge
if not _line_segment_intersects_aabb(nsx, nsy, ex, ey, bx1, by1, bx2, by2):
continue # Wire terminates at pin from outside
else:
nex, ney = ex - ux * nudge, ey - uy * nudge
if not _line_segment_intersects_aabb(sx, sy, nex, ney, bx1, by1, bx2, by2):
continue # Wire terminates at pin from outside
sym = sd["sym"]
collisions.append(
{
"wire": {
"start": {"x": sx, "y": sy},
"end": {"x": ex, "y": ey},
},
"component": {
"reference": sym["reference"],
"libId": sym["lib_id"],
"position": {"x": sym["x"], "y": sym["y"]},
},
"intersectionType": "passes_through",
}
)
return collisions
def find_orphaned_wires(schematic_path: Path) -> Dict[str, Any]:
"""
Find wire segments with at least one dangling endpoint.
A wire endpoint is dangling when the IU point at that endpoint satisfies
all three conditions simultaneously:
1. No other wire shares that IU endpoint (would imply a junction / T-join)
2. No component pin is at that IU point
3. No net label or power symbol pin is at that IU point
Uses exact KiCad IU matching (10 000 IU/mm) — same strategy as
wire_connectivity.py — to avoid floating-point tolerance issues.
Returns:
{
"orphaned_wires": [
{
"start": {"x": float, "y": float},
"end": {"x": float, "y": float},
"dangling_ends": [{"x": float, "y": float}, ...]
},
...
],
"count": int
}
"""
sexp_data = _load_sexp(schematic_path)
# --- wire endpoints in mm and IU ---
wires_mm = _parse_wires(sexp_data)
wires_iu: List[Tuple[Tuple[int, int], Tuple[int, int]]] = [
(_to_iu(*w["start"]), _to_iu(*w["end"])) for w in wires_mm
]
# Count how many wires touch each IU endpoint
iu_to_count: Dict[Tuple[int, int], int] = defaultdict(int)
for s_iu, e_iu in wires_iu:
iu_to_count[s_iu] += 1
iu_to_count[e_iu] += 1
# --- anchors: component pins ---
pin_iu: Set[Tuple[int, int]] = set()
try:
locator = PinLocator()
sch = Schematic(str(schematic_path))
for symbol in sch.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(schematic_path, ref)
for coords in all_pins.values():
pin_iu.add(_to_iu(float(coords[0]), float(coords[1])))
except Exception as e:
logger.warning(f"Error reading pins for symbol: {e}")
except Exception as e:
logger.warning(f"Could not load schematic via skip for pin extraction: {e}")
sch = None
# --- anchors: net labels and global_labels ---
labels = _parse_labels(sexp_data)
label_iu: Set[Tuple[int, int]] = {_to_iu(lbl["x"], lbl["y"]) for lbl in labels}
# --- anchors: power symbol pins (VCC, GND …) ---
power_iu: Set[Tuple[int, int]] = set()
if sch is not None:
try:
point_to_label, _ = _parse_virtual_connections(sch, schematic_path)
power_iu = set(point_to_label.keys())
except Exception as e:
logger.warning(f"Could not extract power symbol anchors: {e}")
anchored_iu = pin_iu | label_iu | power_iu
# --- classify each wire ---
orphaned: List[Dict[str, Any]] = []
for i, (s_iu, e_iu) in enumerate(wires_iu):
w = wires_mm[i]
dangling_ends: List[Dict[str, float]] = []
for pt_iu, pt_mm in [(s_iu, w["start"]), (e_iu, w["end"])]:
if iu_to_count[pt_iu] > 1:
continue # shared with another wire → connected
if pt_iu in anchored_iu:
continue # touches a pin or label → connected
dangling_ends.append({"x": pt_mm[0], "y": pt_mm[1]})
if dangling_ends:
orphaned.append(
{
"start": {"x": w["start"][0], "y": w["start"][1]},
"end": {"x": w["end"][0], "y": w["end"][1]},
"dangling_ends": dangling_ends,
}
)
return {"orphaned_wires": orphaned, "count": len(orphaned)}

View File

@@ -0,0 +1,211 @@
"""
Snap-to-grid tool for KiCAD schematics.
Snaps wire endpoints, junction positions, net labels, and optionally component
positions to the nearest grid point. Modifies the schematic file in place.
The standard KiCAD schematic grid is 50 mil (1.27 mm). Component pins are
placed at multiples of 1.27 mm relative to the symbol origin, so absolute pin
coordinates end up as odd multiples of 1.27 mm (e.g. 26.67 mm = 21 × 1.27 mm).
These are valid on-grid positions that must not be moved.
The coarser 2.54 mm (100-mil) grid is a common mistake: exactly half of all
valid 1.27 mm positions are not multiples of 2.54 mm and would be displaced by
1.27 mm — moving labels or wire endpoints off their pins and breaking
connectivity.
Off-grid coordinates cause wires that appear visually connected to fail ERC
connectivity checks because KiCAD uses exact integer (IU) matching internally.
"""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
_DEFAULT_GRID_MM: float = 1.27
# Element type names exposed in the public API
_VALID_ELEMENTS = frozenset({"wires", "junctions", "labels", "components"})
# Tags treated as net labels (all have (at x y angle) structure)
_LABEL_TAGS = frozenset(
{
Symbol("label"),
Symbol("global_label"),
Symbol("hierarchical_label"),
Symbol("net_tie"),
Symbol("no_connect"),
}
)
def _snap_mm(value: float, grid_mm: float) -> float:
"""Snap a single coordinate to the nearest grid multiple."""
return round(value / grid_mm) * grid_mm
def _is_on_grid(value: float, grid_mm: float, eps: float = 1e-9) -> bool:
"""Return True if *value* is already within *eps* of a grid point."""
snapped = _snap_mm(value, grid_mm)
return abs(value - snapped) < eps
def _snap_xy_pair(item: list, grid_mm: float) -> int:
"""
Snap a ``(xy x y)`` S-expression item in place.
Returns 1 if at least one coordinate changed, 0 otherwise.
"""
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("xy")):
return 0
x_orig, y_orig = float(item[1]), float(item[2])
x_new = _snap_mm(x_orig, grid_mm)
y_new = _snap_mm(y_orig, grid_mm)
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
item[1] = x_new
item[2] = y_new
return 1 if changed else 0
def _snap_at_xy(item: list, grid_mm: float) -> int:
"""
Snap an ``(at x y ...)`` S-expression item in place (indices 1 and 2 only).
Preserves rotation / angle at index 3+ unchanged.
Returns 1 if at least one coordinate changed, 0 otherwise.
"""
if not (isinstance(item, list) and len(item) >= 3 and item[0] == Symbol("at")):
return 0
x_orig, y_orig = float(item[1]), float(item[2])
x_new = _snap_mm(x_orig, grid_mm)
y_new = _snap_mm(y_orig, grid_mm)
changed = not (_is_on_grid(x_orig, grid_mm) and _is_on_grid(y_orig, grid_mm))
item[1] = x_new
item[2] = y_new
return 1 if changed else 0
def snap_to_grid(
schematic_path: Path,
grid_size: float = _DEFAULT_GRID_MM,
elements: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""
Snap element coordinates in a ``.kicad_sch`` file to the nearest grid point.
Modifies the file in place and returns statistics.
Args:
schematic_path: Path to the ``.kicad_sch`` file.
grid_size: Grid spacing in mm (default 1.27 mm = 50 mil).
Do NOT use 2.54 mm — half of all valid KiCAD pin
positions fall between 2.54 mm grid lines and would
be displaced 1.27 mm, breaking connectivity.
elements: List of element types to snap. Valid values:
``"wires"``, ``"junctions"``, ``"labels"``,
``"components"``. Defaults to
``["wires", "junctions", "labels"]`` when ``None``.
Returns:
``{"snapped": int, "already_on_grid": int, "grid_size": float}``
where *snapped* is the number of elements that had at least one
coordinate moved.
"""
if grid_size <= 0:
raise ValueError(f"grid_size must be positive, got {grid_size}")
if elements is None:
active: frozenset = frozenset({"wires", "junctions", "labels"})
else:
unknown = set(elements) - _VALID_ELEMENTS
if unknown:
raise ValueError(
f"Unknown element type(s): {sorted(unknown)}. "
f"Valid types: {sorted(_VALID_ELEMENTS)}"
)
active = frozenset(elements)
with open(schematic_path, "r", encoding="utf-8") as fh:
sch_data = sexpdata.loads(fh.read())
snapped = 0
already_on_grid = 0
snap_wires = "wires" in active
snap_junctions = "junctions" in active
snap_labels = "labels" in active
snap_components = "components" in active
for item in sch_data:
if not isinstance(item, list) or not item:
continue
tag = item[0]
# -----------------------------------------------------------------
# Wires: (wire (pts (xy x y) (xy x y)) ...)
# -----------------------------------------------------------------
if snap_wires and tag == Symbol("wire"):
changed = 0
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == Symbol("pts"):
for pt in sub[1:]:
changed += _snap_xy_pair(pt, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Junctions: (junction (at x y) ...)
# -----------------------------------------------------------------
if snap_junctions and tag == Symbol("junction"):
changed = 0
for sub in item[1:]:
changed += _snap_at_xy(sub, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Labels: (label|global_label|hierarchical_label|no_connect … (at x y angle) …)
# -----------------------------------------------------------------
if snap_labels and tag in _LABEL_TAGS:
changed = 0
for sub in item[1:]:
changed += _snap_at_xy(sub, grid_size)
if changed:
snapped += 1
else:
already_on_grid += 1
continue
# -----------------------------------------------------------------
# Components: (symbol (lib_id …) (at x y rotation) …)
# Snap only the top-level (at …) — not property sub-positions.
# -----------------------------------------------------------------
if snap_components and tag == Symbol("symbol"):
changed = 0
for sub in item[1:]:
if isinstance(sub, list) and sub and sub[0] == Symbol("at"):
changed += _snap_at_xy(sub, grid_size)
break # only the first (at …) belongs to the symbol itself
if changed:
snapped += 1
else:
already_on_grid += 1
continue
with open(schematic_path, "w", encoding="utf-8") as fh:
fh.write(sexpdata.dumps(sch_data))
return {
"snapped": snapped,
"already_on_grid": already_on_grid,
"grid_size": grid_size,
}

View File

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

View File

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

View File

@@ -0,0 +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}

View File

@@ -0,0 +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

View File

@@ -6,17 +6,30 @@ kicad-skip's wire API doesn't support creating wires with standard parameters, s
manipulate the .kicad_sch file directly.
"""
import uuid
import logging
import math
import tempfile
import uuid
from pathlib import Path
from typing import List, Tuple, Optional, Dict
from typing import Any, List, Optional, Tuple
import sexpdata
from sexpdata import Symbol
logger = logging.getLogger("kicad_interface")
# Module-level Symbol constants — avoids repeated allocation on every call
_SYM_WIRE = Symbol("wire")
_SYM_PTS = Symbol("pts")
_SYM_XY = Symbol("xy")
_SYM_AT = Symbol("at")
_SYM_LABEL = Symbol("label")
_SYM_STROKE = Symbol("stroke")
_SYM_WIDTH = Symbol("width")
_SYM_TYPE = Symbol("type")
_SYM_UUID = Symbol("uuid")
_SYM_SHEET_INSTANCES = Symbol("sheet_instances")
class WireManager:
"""Manage wires in KiCad schematics using S-expression manipulation"""
@@ -49,31 +62,22 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
# Break any existing wire that passes through a new endpoint (T-junction support)
for pt in (start_point, end_point):
splits = WireManager._break_wires_at_point(sch_data, pt)
if splits:
logger.info(f"Broke {splits} wire(s) at new wire endpoint {pt}")
# Create wire S-expression
# Format: (wire (pts (xy x1 y1) (xy x2 y2)) (stroke (width N) (type default)) (uuid ...))
wire_sexp = [
Symbol("wire"),
[
Symbol("pts"),
[Symbol("xy"), start_point[0], start_point[1]],
[Symbol("xy"), end_point[0], end_point[1]],
],
[
Symbol("stroke"),
[Symbol("width"), stroke_width],
[Symbol("type"), Symbol(stroke_type)],
],
[Symbol("uuid"), str(uuid.uuid4())],
]
wire_sexp = WireManager._make_wire_sexp(
start_point, end_point, stroke_width, stroke_type
)
# Find insertion point (before sheet_instances)
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -130,31 +134,23 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
# Create pts list
pts_list = [Symbol("pts")]
for point in points:
pts_list.append([Symbol("xy"), point[0], point[1]])
# Break any existing wire at the outer endpoints of the new path
for pt in (points[0], points[-1]):
splits = WireManager._break_wires_at_point(sch_data, pt)
if splits:
logger.info(f"Broke {splits} wire(s) at new polyline endpoint {pt}")
# Create wire S-expression with multiple points
wire_sexp = [
Symbol("wire"),
pts_list,
[
Symbol("stroke"),
[Symbol("width"), stroke_width],
[Symbol("type"), Symbol(stroke_type)],
],
[Symbol("uuid"), str(uuid.uuid4())],
# KiCAD wire elements only support exactly 2 pts each.
# Split N waypoints into N-1 individual wire segments.
wire_sexps = [
WireManager._make_wire_sexp(points[i], points[i + 1], stroke_width, stroke_type)
for i in range(len(points) - 1)
]
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -162,9 +158,12 @@ class WireManager:
logger.error("No sheet_instances section found in schematic")
return False
# Insert wire
sch_data.insert(sheet_instances_index, wire_sexp)
logger.info(f"Injected polyline wire with {len(points)} points")
# Insert all segments (in reverse so order is preserved after inserts)
for wire_sexp in reversed(wire_sexps):
sch_data.insert(sheet_instances_index, wire_sexp)
logger.info(
f"Injected {len(wire_sexps)} wire segments for {len(points)}-point polyline"
)
# Write back
with open(schematic_path, "w", encoding="utf-8") as f:
@@ -227,11 +226,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -259,11 +254,121 @@ class WireManager:
return False
@staticmethod
def add_junction(
schematic_path: Path, position: List[float], diameter: float = 0
def _parse_wire(
wire_item: Any,
) -> Optional[Tuple[Tuple[float, float], Tuple[float, float], float, str]]:
"""
Parse a wire S-expression item in a single pass.
Returns ((x1,y1), (x2,y2), stroke_width, stroke_type), or None if not a valid wire.
"""
if not (isinstance(wire_item, list) and len(wire_item) >= 2 and wire_item[0] == _SYM_WIRE):
return None
start = end = None
stroke_width: float = 0
stroke_type: str = "default"
for part in wire_item[1:]:
if not isinstance(part, list) or not part:
continue
tag = part[0]
if tag == _SYM_PTS:
found: List[Tuple[float, float]] = []
for p in part[1:]:
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY:
found.append((float(p[1]), float(p[2])))
if len(found) == 2:
break
if len(found) == 2:
start, end = found[0], found[1]
elif tag == _SYM_STROKE:
for sp in part[1:]:
if isinstance(sp, list) and len(sp) >= 2:
if sp[0] == _SYM_WIDTH:
stroke_width = sp[1]
elif sp[0] == _SYM_TYPE:
stroke_type = str(sp[1])
if start is not None and end is not None:
return start, end, stroke_width, stroke_type
return None
@staticmethod
def _point_strictly_on_wire(
px: float,
py: float,
x1: float,
y1: float,
x2: float,
y2: float,
eps: float = 1e-6,
) -> bool:
"""
Add a junction (connection dot) to the schematic
Return True if (px, py) lies strictly between (x1,y1) and (x2,y2)
on a horizontal or vertical wire segment (not at either endpoint).
"""
if abs(y1 - y2) < eps: # horizontal wire
if abs(py - y1) > eps:
return False
lo, hi = min(x1, x2), max(x1, x2)
return lo + eps < px < hi - eps
if abs(x1 - x2) < eps: # vertical wire
if abs(px - x1) > eps:
return False
lo, hi = min(y1, y2), max(y1, y2)
return lo + eps < py < hi - eps
return False
@staticmethod
def _make_wire_sexp(
start: List[float],
end: List[float],
stroke_width: float = 0,
stroke_type: str = "default",
) -> list:
return [
_SYM_WIRE,
[_SYM_PTS, [_SYM_XY, start[0], start[1]], [_SYM_XY, end[0], end[1]]],
[_SYM_STROKE, [_SYM_WIDTH, stroke_width], [_SYM_TYPE, Symbol(stroke_type)]],
[_SYM_UUID, str(uuid.uuid4())],
]
@staticmethod
def _break_wires_at_point(sch_data: list, position: List[float]) -> int:
"""
Split any wire segment that passes through *position* as a strict
midpoint (i.e. position is not an existing endpoint). Mirrors
KiCAD's SCH_LINE_WIRE_BUS_TOOL::BreakSegments behaviour.
Returns the number of wires split.
"""
px, py = float(position[0]), float(position[1])
splits = 0
i = 0
while i < len(sch_data):
parsed = WireManager._parse_wire(sch_data[i])
if parsed is not None:
(x1, y1), (x2, y2), stroke_width, stroke_type = parsed
if WireManager._point_strictly_on_wire(px, py, x1, y1, x2, y2):
seg_a = WireManager._make_wire_sexp(
[x1, y1], [px, py], stroke_width, stroke_type
)
seg_b = WireManager._make_wire_sexp(
[px, py], [x2, y2], stroke_width, stroke_type
)
sch_data[i : i + 1] = [seg_a, seg_b]
logger.info(f"Split wire ({x1},{y1})->({x2},{y2}) at ({px},{py})")
splits += 1
i += 2 # skip the two new segments
continue
i += 1
return splits
@staticmethod
def add_junction(schematic_path: Path, position: List[float], diameter: float = 0) -> bool:
"""
Add a junction (connection dot) to the schematic.
Mirrors KiCAD's AddJunction behaviour: any wire whose interior passes
through *position* is split into two segments at that point so that
the BFS-based get_wire_connections tool can traverse the T/X branch.
Args:
schematic_path: Path to .kicad_sch file
@@ -280,6 +385,12 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
# Split any wire that passes through the junction as a midpoint
# (mirrors KiCAD's AddJunction / BreakSegments behaviour)
splits = WireManager._break_wires_at_point(sch_data, position)
if splits:
logger.info(f"Broke {splits} wire(s) at junction position {position}")
# Create junction S-expression
# Format: (junction (at x y) (diameter 0) (color 0 0 0 0) (uuid ...))
junction_sexp = [
@@ -293,11 +404,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -354,11 +461,7 @@ class WireManager:
# Find insertion point
sheet_instances_index = None
for i, item in enumerate(sch_data):
if (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("sheet_instances")
):
if isinstance(item, list) and len(item) > 0 and item[0] == _SYM_SHEET_INSTANCES:
sheet_instances_index = i
break
@@ -414,21 +517,13 @@ class WireManager:
ex, ey = end_point
for i, item in enumerate(sch_data):
if not (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("wire")
):
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_WIRE):
continue
# Extract pts from the wire s-expression
pts_list = None
for part in item[1:]:
if (
isinstance(part, list)
and len(part) > 0
and part[0] == Symbol("pts")
):
if isinstance(part, list) and len(part) > 0 and part[0] == _SYM_PTS:
pts_list = part
break
@@ -438,7 +533,7 @@ class WireManager:
xy_points = [
p
for p in pts_list[1:]
if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("xy")
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_XY
]
if len(xy_points) < 2:
continue
@@ -502,11 +597,7 @@ class WireManager:
sch_data = sexpdata.loads(sch_content)
for i, item in enumerate(sch_data):
if not (
isinstance(item, list)
and len(item) > 0
and item[0] == Symbol("label")
):
if not (isinstance(item, list) and len(item) > 0 and item[0] == _SYM_LABEL):
continue
# Second element is the label text
@@ -519,9 +610,7 @@ class WireManager:
(
p
for p in item[1:]
if isinstance(p, list)
and len(p) >= 3
and p[0] == Symbol("at")
if isinstance(p, list) and len(p) >= 3 and p[0] == _SYM_AT
),
None,
)
@@ -529,8 +618,7 @@ class WireManager:
continue
lx, ly = float(at_entry[1]), float(at_entry[2])
if not (
abs(lx - position[0]) < tolerance
and abs(ly - position[1]) < tolerance
abs(lx - position[0]) < tolerance and abs(ly - position[1]) < tolerance
):
continue
@@ -584,12 +672,11 @@ class WireManager:
if __name__ == "__main__":
# Test wire creation
import sys
sys.path.insert(0, "/home/chris/MCP/KiCAD-MCP-Server/python")
from pathlib import Path
import shutil
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
print("=" * 80)
print("WIRE MANAGER TEST")
@@ -597,9 +684,7 @@ if __name__ == "__main__":
# Create test schematic (cross-platform temp directory)
test_path = Path(tempfile.gettempdir()) / "test_wire_manager.kicad_sch"
template_path = Path(
"/home/chris/MCP/KiCAD-MCP-Server/python/templates/empty.kicad_sch"
)
template_path = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
shutil.copy(template_path, test_path)
print(f"\n✓ Created test schematic: {test_path}")

View File

@@ -20,8 +20,8 @@ Usage:
board.set_size(100, 80)
"""
from kicad_api.factory import create_backend
from kicad_api.base import KiCADBackend
from kicad_api.factory import create_backend
__all__ = ['create_backend', 'KiCADBackend']
__version__ = '2.0.0-alpha.1'
__all__ = ["create_backend", "KiCADBackend"]
__version__ = "2.0.0-alpha.1"

View File

@@ -3,10 +3,11 @@ 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 Optional, Dict, Any, List
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@@ -97,7 +98,7 @@ class KiCADBackend(ABC):
# Board Operations
@abstractmethod
def get_board(self) -> 'BoardAPI':
def get_board(self) -> "BoardAPI":
"""
Get board API for current project
@@ -126,7 +127,7 @@ class BoardAPI(ABC):
pass
@abstractmethod
def get_size(self) -> Dict[str, float]:
def get_size(self) -> Dict[str, Any]:
"""
Get current board size
@@ -167,7 +168,8 @@ class BoardAPI(ABC):
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu"
layer: str = "F.Cu",
value: str = "",
) -> bool:
"""
Place a component on the board
@@ -194,7 +196,7 @@ class BoardAPI(ABC):
end_y: float,
width: float = 0.25,
layer: str = "F.Cu",
net_name: Optional[str] = None
net_name: Optional[str] = None,
) -> bool:
"""
Add a track (trace) to the board
@@ -220,7 +222,7 @@ class BoardAPI(ABC):
diameter: float = 0.8,
drill: float = 0.4,
net_name: Optional[str] = None,
via_type: str = "through"
via_type: str = "through",
) -> bool:
"""
Add a via to the board
@@ -275,14 +277,17 @@ class BoardAPI(ABC):
class BackendError(Exception):
"""Base exception for backend errors"""
pass
class ConnectionError(BackendError):
"""Raised when connection to KiCAD fails"""
pass
class APINotAvailableError(BackendError):
"""Raised when required API is not available"""
pass

View File

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

View File

@@ -13,18 +13,14 @@ Key Benefits over SWIG:
- Stable API that won't break between versions
- Multi-language support
"""
import logging
import os
import platform
from pathlib import Path
from typing import Optional, Dict, Any, List, Callable
from typing import Any, Callable, Dict, List, Optional
from kicad_api.base import (
KiCADBackend,
BoardAPI,
ConnectionError,
APINotAvailableError
)
from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend
logger = logging.getLogger(__name__)
@@ -44,10 +40,10 @@ class IPCBackend(KiCADBackend):
without requiring manual reload.
"""
def __init__(self):
def __init__(self) -> None:
self._kicad = None
self._connected = False
self._version = None
self._version: Optional[str] = None
self._on_change_callbacks: List[Callable] = []
def connect(self, socket_path: Optional[str] = None) -> bool:
@@ -78,10 +74,10 @@ class IPCBackend(KiCADBackend):
# Common socket locations (Unix-like systems only)
# Windows uses named pipes, handled by auto-detect
if platform.system() != "Windows":
socket_paths_to_try.append('ipc:///tmp/kicad/api.sock') # Linux default
socket_paths_to_try.append("ipc:///tmp/kicad/api.sock") # Linux default
# XDG runtime directory (requires getuid, Unix only)
if hasattr(os, 'getuid'):
socket_paths_to_try.append(f'ipc:///run/user/{os.getuid()}/kicad/api.sock')
if hasattr(os, "getuid"):
socket_paths_to_try.append(f"ipc:///run/user/{os.getuid()}/kicad/api.sock")
# Auto-detect for all platforms (Windows uses named pipes, Unix uses sockets)
socket_paths_to_try.append(None)
@@ -117,8 +113,7 @@ class IPCBackend(KiCADBackend):
except ImportError as e:
logger.error("kicad-python library not found")
raise APINotAvailableError(
"IPC backend requires kicad-python. "
"Install with: pip install kicad-python"
"IPC backend requires kicad-python. " "Install with: pip install kicad-python"
) from e
except Exception as e:
logger.error(f"Failed to connect via IPC: {e}")
@@ -190,7 +185,7 @@ class IPCBackend(KiCADBackend):
return {
"success": False,
"message": "Direct project creation not supported via IPC",
"suggestion": "Open KiCAD and create a new project, or use SWIG backend"
"suggestion": "Open KiCAD and create a new project, or use SWIG backend",
}
def open_project(self, path: Path) -> Dict[str, Any]:
@@ -209,22 +204,18 @@ class IPCBackend(KiCADBackend):
return {
"success": True,
"message": f"Project already open: {path}",
"path": str(path)
"path": str(path),
}
return {
"success": False,
"message": "Project not currently open in KiCAD",
"suggestion": "Open the project in KiCAD first, then connect via IPC"
"suggestion": "Open the project in KiCAD first, then connect via IPC",
}
except Exception as e:
logger.error(f"Failed to check project: {e}")
return {
"success": False,
"message": "Failed to check project",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to check project", "errorDetails": str(e)}
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
"""Save current project via IPC."""
@@ -240,17 +231,10 @@ class IPCBackend(KiCADBackend):
self._notify_change("save", {"path": str(path) if path else "current"})
return {
"success": True,
"message": "Project saved successfully"
}
return {"success": True, "message": "Project saved successfully"}
except Exception as e:
logger.error(f"Failed to save project: {e}")
return {
"success": False,
"message": "Failed to save project",
"errorDetails": str(e)
}
return {"success": False, "message": "Failed to save project", "errorDetails": str(e)}
def close_project(self) -> None:
"""Close current project (not supported via IPC)."""
@@ -273,13 +257,13 @@ class IPCBoardAPI(BoardAPI):
Uses transactions for proper undo/redo support.
"""
def __init__(self, kicad_instance, notify_callback: Callable):
def __init__(self, kicad_instance: Any, notify_callback: Callable) -> None:
self._kicad = kicad_instance
self._board = None
self._notify = notify_callback
self._current_commit = None
def _get_board(self):
def _get_board(self) -> Any:
"""Get board instance, connecting if needed."""
if self._board is None:
try:
@@ -333,8 +317,8 @@ class IPCBoardAPI(BoardAPI):
try:
from kipy.board_types import BoardRectangle
from kipy.geometry import Vector2
from kipy.util.units import from_mm
from kipy.proto.board.board_types_pb2 import BoardLayer
from kipy.util.units import from_mm
board = self._get_board()
@@ -366,7 +350,7 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to set board size: {e}")
return False
def get_size(self) -> Dict[str, float]:
def get_size(self) -> Dict[str, Any]:
"""Get current board size from bounding box."""
try:
board = self._get_board()
@@ -380,8 +364,8 @@ class IPCBoardAPI(BoardAPI):
# Find bounding box of edge cuts
from kipy.util.units import to_mm
min_x = min_y = float('inf')
max_x = max_y = float('-inf')
min_x = min_y = float("inf")
max_x = max_y = float("-inf")
for shape in shapes:
# Check if on Edge.Cuts layer
@@ -392,14 +376,10 @@ class IPCBoardAPI(BoardAPI):
max_x = max(max_x, bbox.max.x)
max_y = max(max_y, bbox.max.y)
if min_x == float('inf'):
if min_x == float("inf"):
return {"width": 0, "height": 0, "unit": "mm"}
return {
"width": to_mm(max_x - min_x),
"height": to_mm(max_y - min_y),
"unit": "mm"
}
return {"width": to_mm(max_x - min_x), "height": to_mm(max_y - min_y), "unit": "mm"}
except Exception as e:
logger.error(f"Failed to get board size: {e}")
@@ -432,19 +412,23 @@ class IPCBoardAPI(BoardAPI):
for fp in footprints:
try:
pos = fp.position
components.append({
"reference": fp.reference_field.text.value if fp.reference_field else "",
"value": fp.value_field.text.value if fp.value_field else "",
"footprint": str(fp.definition.library_link) if fp.definition else "",
"position": {
"x": to_mm(pos.x) if pos else 0,
"y": to_mm(pos.y) if pos else 0,
"unit": "mm"
},
"rotation": fp.orientation.degrees if fp.orientation else 0,
"layer": str(fp.layer) if hasattr(fp, 'layer') else "F.Cu",
"id": str(fp.id) if hasattr(fp, 'id') else ""
})
components.append(
{
"reference": (
fp.reference_field.text.value if fp.reference_field else ""
),
"value": fp.value_field.text.value if fp.value_field else "",
"footprint": str(fp.definition.library_link) if fp.definition else "",
"position": {
"x": to_mm(pos.x) if pos else 0,
"y": to_mm(pos.y) if pos else 0,
"unit": "mm",
},
"rotation": fp.orientation.degrees if fp.orientation else 0,
"layer": str(fp.layer) if hasattr(fp, "layer") else "F.Cu",
"id": str(fp.id) if hasattr(fp, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing footprint: {e}")
continue
@@ -463,7 +447,7 @@ class IPCBoardAPI(BoardAPI):
y: float,
rotation: float = 0,
layer: str = "F.Cu",
value: str = ""
value: str = "",
) -> bool:
"""
Place a component on the board.
@@ -495,7 +479,9 @@ class IPCBoardAPI(BoardAPI):
)
else:
# Fallback: Create a basic placeholder footprint via IPC
logger.warning(f"Could not load footprint '{footprint}' from library, creating placeholder")
logger.warning(
f"Could not load footprint '{footprint}' from library, creating placeholder"
)
return self._place_placeholder_footprint(
reference, footprint, x, y, rotation, layer, value
)
@@ -504,7 +490,7 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to place component: {e}")
return False
def _load_footprint_from_library(self, footprint_path: str):
def _load_footprint_from_library(self, footprint_path: str) -> Any:
"""
Load a footprint from the library using pcbnew SWIG API.
@@ -518,8 +504,8 @@ class IPCBoardAPI(BoardAPI):
import pcbnew
# Parse library and footprint name
if ':' in footprint_path:
lib_name, fp_name = footprint_path.split(':', 1)
if ":" in footprint_path:
lib_name, fp_name = footprint_path.split(":", 1)
else:
# Try to find the footprint in all libraries
lib_name = None
@@ -561,13 +547,13 @@ class IPCBoardAPI(BoardAPI):
def _place_loaded_footprint(
self,
loaded_fp,
loaded_fp: Any,
reference: str,
x: float,
y: float,
rotation: float,
layer: str,
value: str
value: str,
) -> bool:
"""
Place a loaded pcbnew footprint onto the board.
@@ -589,7 +575,7 @@ class IPCBoardAPI(BoardAPI):
try:
docs = self._kicad.get_open_documents()
for doc in docs:
if hasattr(doc, 'path') and str(doc.path).endswith('.kicad_pcb'):
if hasattr(doc, "path") and str(doc.path).endswith(".kicad_pcb"):
board_path = str(doc.path)
break
except Exception as e:
@@ -637,24 +623,27 @@ class IPCBoardAPI(BoardAPI):
except Exception as e:
logger.debug(f"Could not refresh IPC board: {e}")
self._notify("component_placed", {
"reference": reference,
"footprint": loaded_fp.GetFPIDAsString(),
"position": {"x": x, "y": y},
"rotation": rotation,
"layer": layer,
"loaded_from_library": True
})
self._notify(
"component_placed",
{
"reference": reference,
"footprint": loaded_fp.GetFPIDAsString(),
"position": {"x": x, "y": y},
"rotation": rotation,
"layer": layer,
"loaded_from_library": True,
},
)
logger.info(f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm")
logger.info(
f"Placed component {reference} ({loaded_fp.GetFPIDAsString()}) at ({x}, {y}) mm"
)
return True
except Exception as e:
logger.error(f"Error placing loaded footprint: {e}")
# Fall back to placeholder
return self._place_placeholder_footprint(
reference, "", x, y, rotation, layer, value
)
return self._place_placeholder_footprint(reference, "", x, y, rotation, layer, value)
def _place_placeholder_footprint(
self,
@@ -664,7 +653,7 @@ class IPCBoardAPI(BoardAPI):
y: float,
rotation: float,
layer: str,
value: str
value: str,
) -> bool:
"""
Place a placeholder footprint when library loading fails.
@@ -673,9 +662,9 @@ class IPCBoardAPI(BoardAPI):
"""
try:
from kipy.board_types import Footprint
from kipy.geometry import Vector2, Angle
from kipy.util.units import from_mm
from kipy.geometry import Angle, Vector2
from kipy.proto.board.board_types_pb2 import BoardLayer
from kipy.util.units import from_mm
board = self._get_board()
@@ -701,15 +690,18 @@ class IPCBoardAPI(BoardAPI):
board.create_items(fp)
board.push_commit(commit, f"Placed component {reference}")
self._notify("component_placed", {
"reference": reference,
"footprint": footprint,
"position": {"x": x, "y": y},
"rotation": rotation,
"layer": layer,
"loaded_from_library": False,
"is_placeholder": True
})
self._notify(
"component_placed",
{
"reference": reference,
"footprint": footprint,
"position": {"x": x, "y": y},
"rotation": rotation,
"layer": layer,
"loaded_from_library": False,
"is_placeholder": True,
},
)
logger.info(f"Placed placeholder component {reference} at ({x}, {y}) mm")
return True
@@ -718,10 +710,12 @@ class IPCBoardAPI(BoardAPI):
logger.error(f"Failed to place placeholder component: {e}")
return False
def move_component(self, reference: str, x: float, y: float, rotation: Optional[float] = None) -> bool:
def move_component(
self, reference: str, x: float, y: float, rotation: Optional[float] = None
) -> bool:
"""Move a component to a new position (updates UI immediately)."""
try:
from kipy.geometry import Vector2, Angle
from kipy.geometry import Angle, Vector2
from kipy.util.units import from_mm
board = self._get_board()
@@ -749,11 +743,10 @@ class IPCBoardAPI(BoardAPI):
board.update_items([target_fp])
board.push_commit(commit, f"Moved component {reference}")
self._notify("component_moved", {
"reference": reference,
"position": {"x": x, "y": y},
"rotation": rotation
})
self._notify(
"component_moved",
{"reference": reference, "position": {"x": x, "y": y}, "rotation": rotation},
)
return True
@@ -799,7 +792,7 @@ class IPCBoardAPI(BoardAPI):
end_y: float,
width: float = 0.25,
layer: str = "F.Cu",
net_name: Optional[str] = None
net_name: Optional[str] = None,
) -> bool:
"""
Add a track (trace) to the board.
@@ -809,8 +802,8 @@ class IPCBoardAPI(BoardAPI):
try:
from kipy.board_types import Track
from kipy.geometry import Vector2
from kipy.util.units import from_mm
from kipy.proto.board.board_types_pb2 import BoardLayer
from kipy.util.units import from_mm
board = self._get_board()
@@ -842,13 +835,16 @@ class IPCBoardAPI(BoardAPI):
board.create_items(track)
board.push_commit(commit, "Added track")
self._notify("track_added", {
"start": {"x": start_x, "y": start_y},
"end": {"x": end_x, "y": end_y},
"width": width,
"layer": layer,
"net": net_name
})
self._notify(
"track_added",
{
"start": {"x": start_x, "y": start_y},
"end": {"x": end_x, "y": end_y},
"width": width,
"layer": layer,
"net": net_name,
},
)
logger.info(f"Added track from ({start_x}, {start_y}) to ({end_x}, {end_y}) mm")
return True
@@ -864,7 +860,7 @@ class IPCBoardAPI(BoardAPI):
diameter: float = 0.8,
drill: float = 0.4,
net_name: Optional[str] = None,
via_type: str = "through"
via_type: str = "through",
) -> bool:
"""
Add a via to the board.
@@ -874,8 +870,8 @@ class IPCBoardAPI(BoardAPI):
try:
from kipy.board_types import Via
from kipy.geometry import Vector2
from kipy.util.units import from_mm
from kipy.proto.board.board_types_pb2 import ViaType
from kipy.util.units import from_mm
board = self._get_board()
@@ -906,13 +902,16 @@ class IPCBoardAPI(BoardAPI):
board.create_items(via)
board.push_commit(commit, "Added via")
self._notify("via_added", {
"position": {"x": x, "y": y},
"diameter": diameter,
"drill": drill,
"net": net_name,
"type": via_type
})
self._notify(
"via_added",
{
"position": {"x": x, "y": y},
"diameter": diameter,
"drill": drill,
"net": net_name,
"type": via_type,
},
)
logger.info(f"Added via at ({x}, {y}) mm")
return True
@@ -928,14 +927,14 @@ class IPCBoardAPI(BoardAPI):
y: float,
layer: str = "F.SilkS",
size: float = 1.0,
rotation: float = 0
rotation: float = 0,
) -> bool:
"""Add text to the board."""
try:
from kipy.board_types import BoardText
from kipy.geometry import Vector2, Angle
from kipy.util.units import from_mm
from kipy.geometry import Angle, Vector2
from kipy.proto.board.board_types_pb2 import BoardLayer
from kipy.util.units import from_mm
board = self._get_board()
@@ -959,11 +958,7 @@ class IPCBoardAPI(BoardAPI):
board.create_items(board_text)
board.push_commit(commit, f"Added text: {text}")
self._notify("text_added", {
"text": text,
"position": {"x": x, "y": y},
"layer": layer
})
self._notify("text_added", {"text": text, "position": {"x": x, "y": y}, "layer": layer})
return True
@@ -982,20 +977,16 @@ class IPCBoardAPI(BoardAPI):
result = []
for track in tracks:
try:
result.append({
"start": {
"x": to_mm(track.start.x),
"y": to_mm(track.start.y)
},
"end": {
"x": to_mm(track.end.x),
"y": to_mm(track.end.y)
},
"width": to_mm(track.width),
"layer": str(track.layer),
"net": track.net.name if track.net else "",
"id": str(track.id) if hasattr(track, 'id') else ""
})
result.append(
{
"start": {"x": to_mm(track.start.x), "y": to_mm(track.start.y)},
"end": {"x": to_mm(track.end.x), "y": to_mm(track.end.y)},
"width": to_mm(track.width),
"layer": str(track.layer),
"net": track.net.name if track.net else "",
"id": str(track.id) if hasattr(track, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing track: {e}")
continue
@@ -1017,17 +1008,16 @@ class IPCBoardAPI(BoardAPI):
result = []
for via in vias:
try:
result.append({
"position": {
"x": to_mm(via.position.x),
"y": to_mm(via.position.y)
},
"diameter": to_mm(via.diameter),
"drill": to_mm(via.drill_diameter),
"net": via.net.name if via.net else "",
"type": str(via.type),
"id": str(via.id) if hasattr(via, 'id') else ""
})
result.append(
{
"position": {"x": to_mm(via.position.x), "y": to_mm(via.position.y)},
"diameter": to_mm(via.diameter),
"drill": to_mm(via.drill_diameter),
"net": via.net.name if via.net else "",
"type": str(via.type),
"id": str(via.id) if hasattr(via, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing via: {e}")
continue
@@ -1047,10 +1037,9 @@ class IPCBoardAPI(BoardAPI):
result = []
for net in nets:
try:
result.append({
"name": net.name,
"code": net.code if hasattr(net, 'code') else 0
})
result.append(
{"name": net.name, "code": net.code if hasattr(net, "code") else 0}
)
except Exception as e:
logger.warning(f"Error processing net: {e}")
continue
@@ -1070,7 +1059,7 @@ class IPCBoardAPI(BoardAPI):
min_thickness: float = 0.25,
priority: int = 0,
fill_mode: str = "solid",
name: str = ""
name: str = "",
) -> bool:
"""
Add a copper pour zone to the board.
@@ -1090,8 +1079,8 @@ class IPCBoardAPI(BoardAPI):
try:
from kipy.board_types import Zone, ZoneFillMode, ZoneType
from kipy.geometry import PolyLine, PolyLineNode, Vector2
from kipy.util.units import from_mm
from kipy.proto.board.board_types_pb2 import BoardLayer
from kipy.util.units import from_mm
board = self._get_board()
@@ -1157,12 +1146,10 @@ class IPCBoardAPI(BoardAPI):
board.create_items(zone)
board.push_commit(commit, f"Added copper zone on {layer}")
self._notify("zone_added", {
"layer": layer,
"net": net_name,
"points": len(points),
"priority": priority
})
self._notify(
"zone_added",
{"layer": layer, "net": net_name, "points": len(points), "priority": priority},
)
logger.info(f"Added zone on {layer} with {len(points)} points")
return True
@@ -1182,14 +1169,18 @@ class IPCBoardAPI(BoardAPI):
result = []
for zone in zones:
try:
result.append({
"name": zone.name if hasattr(zone, 'name') else "",
"net": zone.net.name if zone.net else "",
"priority": zone.priority if hasattr(zone, 'priority') else 0,
"layers": [str(l) for l in zone.layers] if hasattr(zone, 'layers') else [],
"filled": zone.filled if hasattr(zone, 'filled') else False,
"id": str(zone.id) if hasattr(zone, 'id') else ""
})
result.append(
{
"name": zone.name if hasattr(zone, "name") else "",
"net": zone.net.name if zone.net else "",
"priority": zone.priority if hasattr(zone, "priority") else 0,
"layers": (
[str(l) for l in zone.layers] if hasattr(zone, "layers") else []
),
"filled": zone.filled if hasattr(zone, "filled") else False,
"id": str(zone.id) if hasattr(zone, "id") else "",
}
)
except Exception as e:
logger.warning(f"Error processing zone: {e}")
continue
@@ -1219,10 +1210,9 @@ class IPCBoardAPI(BoardAPI):
result = []
for item in selection:
result.append({
"type": type(item).__name__,
"id": str(item.id) if hasattr(item, 'id') else ""
})
result.append(
{"type": type(item).__name__, "id": str(item.id) if hasattr(item, "id") else ""}
)
return result
except Exception as e:
@@ -1241,4 +1231,4 @@ class IPCBoardAPI(BoardAPI):
# Export for factory
__all__ = ['IPCBackend', 'IPCBoardAPI']
__all__ = ["IPCBackend", "IPCBoardAPI"]

View File

@@ -8,16 +8,12 @@ WARNING: SWIG bindings are deprecated as of KiCAD 9.0
and will be removed in KiCAD 10.0.
Please migrate to IPC backend.
"""
import logging
from pathlib import Path
from typing import Optional, Dict, Any, List
from typing import Any, Dict, List, Optional
from kicad_api.base import (
KiCADBackend,
BoardAPI,
ConnectionError,
APINotAvailableError
)
from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend
logger = logging.getLogger(__name__)
@@ -30,7 +26,7 @@ class SWIGBackend(KiCADBackend):
for compatibility during migration period.
"""
def __init__(self):
def __init__(self) -> None:
self._connected = False
self._pcbnew = None
logger.warning(
@@ -48,6 +44,7 @@ class SWIGBackend(KiCADBackend):
"""
try:
import pcbnew
self._pcbnew = pcbnew
version = pcbnew.GetBuildVersion()
logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
@@ -101,7 +98,7 @@ class SWIGBackend(KiCADBackend):
from commands.project import ProjectCommands
try:
result = ProjectCommands.open_project(str(path))
result = ProjectCommands().open_project({"filename": str(path)})
return result
except Exception as e:
logger.error(f"Failed to open project: {e}")
@@ -115,8 +112,10 @@ class SWIGBackend(KiCADBackend):
from commands.project import ProjectCommands
try:
path_str = str(path) if path else None
result = ProjectCommands.save_project(path_str)
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}")
@@ -140,7 +139,7 @@ class SWIGBackend(KiCADBackend):
class SWIGBoardAPI(BoardAPI):
"""Board API implementation wrapping SWIG/pcbnew"""
def __init__(self, pcbnew_module):
def __init__(self, pcbnew_module: Any) -> None:
self.pcbnew = pcbnew_module
self._board = None
@@ -149,13 +148,15 @@ class SWIGBoardAPI(BoardAPI):
from commands.board import BoardCommands
try:
result = BoardCommands.set_board_size(width, height, unit)
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, float]:
def get_size(self) -> Dict[str, Any]:
"""Get board size"""
# TODO: Implement using existing SWIG code
raise NotImplementedError("get_size not yet wrapped")
@@ -176,7 +177,7 @@ class SWIGBoardAPI(BoardAPI):
from commands.component import ComponentCommands
try:
result = ComponentCommands.get_component_list()
result = ComponentCommands(board=self._board).get_component_list({})
if result.get("success"):
return result.get("components", [])
return []
@@ -191,18 +192,21 @@ class SWIGBoardAPI(BoardAPI):
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu"
layer: str = "F.Cu",
value: str = "",
) -> bool:
"""Place component using existing implementation"""
from commands.component import ComponentCommands
try:
result = ComponentCommands.place_component(
component_id=footprint,
position={"x": x, "y": y, "unit": "mm"},
reference=reference,
rotation=rotation,
layer=layer
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:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
# parsers package

View File

@@ -0,0 +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}

View File

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

View File

@@ -5,12 +5,12 @@ Resources follow the MCP 2025-06-18 specification, providing
read-only access to project data for LLM context.
"""
import json
import base64
from typing import Dict, Any, List, Optional
import json
import logging
from typing import Any, Dict, List, Optional
logger = logging.getLogger('kicad_interface')
logger = logging.getLogger("kicad_interface")
# =============================================================================
# RESOURCE DEFINITIONS
@@ -21,57 +21,58 @@ RESOURCE_DEFINITIONS = [
"uri": "kicad://project/current/info",
"name": "Current Project Information",
"description": "Metadata about the currently open KiCAD project including paths, name, and status",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/board",
"name": "Board Properties",
"description": "Comprehensive board information including dimensions, layer count, and design rules",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/components",
"name": "Component List",
"description": "List of all components on the board with references, footprints, values, and positions",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/nets",
"name": "Electrical Nets",
"description": "List of all electrical nets and their connections",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/layers",
"name": "Layer Stack",
"description": "Board layer configuration and properties",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/design-rules",
"name": "Design Rules",
"description": "Current design rule settings for clearances, track widths, and constraints",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://project/current/drc-report",
"name": "DRC Violations",
"description": "Design Rule Check violations and warnings from last DRC run",
"mimeType": "application/json"
"mimeType": "application/json",
},
{
"uri": "kicad://board/preview.png",
"name": "Board Preview Image",
"description": "2D rendering of the current board state",
"mimeType": "image/png"
}
"mimeType": "image/png",
},
]
# =============================================================================
# RESOURCE READ HANDLERS
# =============================================================================
def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]:
"""
Handle reading a resource by URI
@@ -92,7 +93,7 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
"kicad://project/current/layers": _get_layers,
"kicad://project/current/design-rules": _get_design_rules,
"kicad://project/current/drc-report": _get_drc_report,
"kicad://board/preview.png": _get_board_preview
"kicad://board/preview.png": _get_board_preview,
}
handler = handlers.get(uri)
@@ -102,193 +103,217 @@ def handle_resource_read(uri: str, interface) -> Dict[str, Any]:
except Exception as e:
logger.error(f"Error reading resource {uri}: {str(e)}")
return {
"contents": [{
"uri": uri,
"mimeType": "text/plain",
"text": f"Error: {str(e)}"
}]
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}]
}
else:
return {
"contents": [{
"uri": uri,
"mimeType": "text/plain",
"text": f"Unknown resource: {uri}"
}]
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}]
}
# =============================================================================
# INDIVIDUAL RESOURCE HANDLERS
# =============================================================================
def _get_project_info(interface) -> Dict[str, Any]:
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)
}]
"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"
}]
"contents": [
{
"uri": "kicad://project/current/info",
"mimeType": "text/plain",
"text": "No project currently open",
}
]
}
def _get_board_info(interface) -> Dict[str, Any]:
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)
}]
"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"
}]
"contents": [
{
"uri": "kicad://project/current/board",
"mimeType": "text/plain",
"text": "No board currently loaded",
}
]
}
def _get_components(interface) -> Dict[str, Any]:
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)
}]
"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)
}]
"contents": [
{
"uri": "kicad://project/current/components",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "components": []}, indent=2),
}
]
}
def _get_nets(interface) -> Dict[str, Any]:
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)
}]
"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)
}]
"contents": [
{
"uri": "kicad://project/current/nets",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "nets": []}, indent=2),
}
]
}
def _get_layers(interface) -> Dict[str, Any]:
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)
}]
"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)
}]
"contents": [
{
"uri": "kicad://project/current/layers",
"mimeType": "application/json",
"text": json.dumps({"count": 0, "layers": []}, indent=2),
}
]
}
def _get_design_rules(interface) -> Dict[str, Any]:
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)
}]
"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"
}]
"contents": [
{
"uri": "kicad://project/current/design-rules",
"mimeType": "text/plain",
"text": "Design rules not available",
}
]
}
def _get_drc_report(interface) -> Dict[str, Any]:
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)
}]
"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)
}]
"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) -> Dict[str, Any]:
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})
@@ -296,18 +321,22 @@ def _get_board_preview(interface) -> Dict[str, Any]:
# Image data should already be base64 encoded
image_data = result.get("imageData", "")
return {
"contents": [{
"uri": "kicad://board/preview.png",
"mimeType": "image/png",
"blob": image_data # Base64 encoded PNG
}]
"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"
}]
"contents": [
{
"uri": "kicad://board/preview.png",
"mimeType": "text/plain",
"text": "Board preview not available",
}
]
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -13,8 +13,10 @@ Prerequisites:
Usage:
./venv/bin/python python/test_ipc_backend.py
"""
import sys
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__)))
@@ -23,17 +25,16 @@ import logging
# Set up logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
logger = logging.getLogger(__name__)
def test_connection():
def test_connection() -> Optional[Any]:
"""Test basic IPC connection to KiCAD."""
print("\n" + "="*60)
print("\n" + "=" * 60)
print("TEST 1: IPC Connection")
print("="*60)
print("=" * 60)
try:
from kicad_api.ipc_backend import IPCBackend
@@ -62,11 +63,11 @@ def test_connection():
return None
def test_board_access(backend):
def test_board_access(backend: Any) -> Optional[Any]:
"""Test board access and component listing."""
print("\n" + "="*60)
print("\n" + "=" * 60)
print("TEST 2: Board Access")
print("="*60)
print("=" * 60)
try:
board_api = backend.get_board()
@@ -79,11 +80,11 @@ def test_board_access(backend):
if components:
print("\n First 5 components:")
for comp in components[:5]:
ref = comp.get('reference', 'N/A')
val = comp.get('value', 'N/A')
pos = comp.get('position', {})
x = pos.get('x', 0)
y = pos.get('y', 0)
ref = comp.get("reference", "N/A")
val = comp.get("value", "N/A")
pos = comp.get("position", {})
x = pos.get("x", 0)
y = pos.get("y", 0)
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
return board_api
@@ -93,11 +94,11 @@ def test_board_access(backend):
return None
def test_board_info(board_api):
def test_board_info(board_api: Any) -> bool:
"""Test getting board information."""
print("\n" + "="*60)
print("\n" + "=" * 60)
print("TEST 3: Board Information")
print("="*60)
print("=" * 60)
try:
# Get board size
@@ -134,30 +135,25 @@ def test_board_info(board_api):
return False
def test_realtime_track(board_api, interactive=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("\n" + "=" * 60)
print("TEST 4: Real-time Track Addition")
print("="*60)
print("=" * 60)
print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.")
print("Watch the KiCAD window!")
if interactive:
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
if response != 'y':
if response != "y":
print("Skipped track test")
return False
try:
# Add a track
success = board_api.add_track(
start_x=100.0,
start_y=100.0,
end_x=120.0,
end_y=100.0,
width=0.25,
layer="F.Cu"
start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu"
)
if success:
@@ -173,30 +169,24 @@ def test_realtime_track(board_api, interactive=False):
return False
def test_realtime_via(board_api, interactive=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("\n" + "=" * 60)
print("TEST 5: Real-time Via Addition")
print("="*60)
print("=" * 60)
print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.")
print("Watch the KiCAD window!")
if interactive:
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
if response != 'y':
if response != "y":
print("Skipped via test")
return False
try:
# Add a via
success = board_api.add_via(
x=120.0,
y=100.0,
diameter=0.8,
drill=0.4,
via_type="through"
)
success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through")
if success:
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
@@ -211,28 +201,22 @@ def test_realtime_via(board_api, interactive=False):
return False
def test_realtime_text(board_api, interactive=False):
def test_realtime_text(board_api: Any, interactive: bool = False) -> bool:
"""Test adding text in real-time."""
print("\n" + "="*60)
print("\n" + "=" * 60)
print("TEST 6: Real-time Text Addition")
print("="*60)
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':
if response != "y":
print("Skipped text test")
return False
try:
success = board_api.add_text(
text="MCP Test",
x=100.0,
y=95.0,
layer="F.SilkS",
size=1.0
)
success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0)
if success:
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
@@ -246,11 +230,11 @@ def test_realtime_text(board_api, interactive=False):
return False
def test_selection(board_api, interactive=False):
def test_selection(board_api: Any, interactive: bool = False) -> bool:
"""Test getting the current selection from KiCAD UI."""
print("\n" + "="*60)
print("\n" + "=" * 60)
print("TEST 7: UI Selection")
print("="*60)
print("=" * 60)
if interactive:
print("\nSelect some items in KiCAD, then press Enter...")
@@ -272,28 +256,28 @@ def test_selection(board_api, interactive=False):
return False
def run_all_tests(interactive=False):
def run_all_tests(interactive: bool = False) -> bool:
"""Run all IPC backend tests."""
print("\n" + "="*60)
print("\n" + "=" * 60)
print("KiCAD IPC Backend Test Suite")
print("="*60)
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("\n" + "=" * 60)
print("TESTS FAILED: Could not connect to KiCAD")
print("="*60)
print("=" * 60)
return False
# Test board access
board_api = test_board_access(backend)
if not board_api:
print("\n" + "="*60)
print("\n" + "=" * 60)
print("TESTS FAILED: Could not access board")
print("="*60)
print("=" * 60)
return False
# Test board info
@@ -307,9 +291,9 @@ def run_all_tests(interactive=False):
# Test selection
test_selection(board_api, interactive)
print("\n" + "="*60)
print("\n" + "=" * 60)
print("TESTS COMPLETE")
print("="*60)
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!")
@@ -321,9 +305,14 @@ def run_all_tests(interactive=False):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description='Test KiCAD IPC Backend')
parser.add_argument('-i', '--interactive', action='store_true',
help='Run in interactive mode (prompts before modifications)')
parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend")
parser.add_argument(
"-i",
"--interactive",
action="store_true",
help="Run in interactive mode (prompts before modifications)",
)
args = parser.parse_args()
success = run_all_tests(interactive=args.interactive)

View File

@@ -1,26 +0,0 @@
"""
Pytest configuration for python/tests.
Sets up sys.path so that the python/ package root is importable without
installing the project, and provides shared fixtures.
"""
import sys
from pathlib import Path
# Make the python/ package root importable
PYTHON_ROOT = Path(__file__).parent.parent
if str(PYTHON_ROOT) not in sys.path:
sys.path.insert(0, str(PYTHON_ROOT))
# Stub out heavy KiCAD C-extension modules so tests can run without a real
# KiCAD installation. Extend this list whenever a new import fails.
import types
from unittest.mock import MagicMock
# Use MagicMock so any attribute access (e.g. pcbnew.BOARD, pcbnew.LoadBoard)
# returns another MagicMock rather than raising AttributeError.
for _stub_name in ("pcbnew", "skip"):
if _stub_name not in sys.modules:
_m = MagicMock(spec_set=None)
_m.__name__ = _stub_name
sys.modules[_stub_name] = _m

View File

@@ -1,203 +0,0 @@
"""
Regression tests for delete_schematic_component.
Key regression: the handler previously used a line-by-line regex that required
`(symbol` and `(lib_id` to appear on the *same* line. KiCAD's file writer puts
them on *separate* lines, so every real-world delete returned "not found".
"""
import re
import sys
import tempfile
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent))
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
# Inline format (single line) matches what tests previously used
PLACED_RESISTOR_INLINE = '''\
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
(in_bom yes) (on_board yes) (dnp no)
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
(property "Reference" "R1" (at 51.27 47.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "10k" (at 51.27 52.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 50 50 0)
(effects (font (size 1.27 1.27)) hide)
)
)
'''
# Multi-line format as KiCAD's own file writer produces it.
# (symbol and (lib_id are on separate lines, which broke the old regex.
PLACED_RESISTOR_MULTILINE = '''\
\t(symbol
\t\t(lib_id "Device:R")
\t\t(at 50 50 0)
\t\t(unit 1)
\t\t(in_bom yes)
\t\t(on_board yes)
\t\t(dnp no)
\t\t(uuid "bbbbbbbb-cccc-dddd-eeee-ffffffffffff")
\t\t(property "Reference" "R2"
\t\t\t(at 51.27 47.46 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t)
\t\t)
\t\t(property "Value" "4.7k"
\t\t\t(at 51.27 52.54 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t)
\t\t)
\t)
'''
# Multi-line power symbol the exact scenario that was reported as broken.
PLACED_POWER_SYMBOL_MULTILINE = '''\
\t(symbol
\t\t(lib_id "power:VCC")
\t\t(at 365.6 38.1 0)
\t\t(unit 1)
\t\t(in_bom yes)
\t\t(on_board yes)
\t\t(dnp no)
\t\t(uuid "cccccccc-dddd-eeee-ffff-000000000030")
\t\t(property "Reference" "#PWR030"
\t\t\t(at 365.6 41.91 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t\t(hide yes)
\t\t\t)
\t\t)
\t\t(property "Value" "VCC"
\t\t\t(at 365.6 35.56 0)
\t\t\t(effects
\t\t\t\t(font
\t\t\t\t\t(size 1.27 1.27)
\t\t\t\t)
\t\t\t)
\t\t)
\t)
'''
def _make_test_schematic(tmp_path: Path, extra_block: str = "") -> Path:
dest = tmp_path / "test.kicad_sch"
src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
if extra_block:
src_content = src_content.rstrip()
if src_content.endswith(")"):
src_content = src_content[:-1] + "\n" + extra_block + ")\n"
dest.write_text(src_content, encoding="utf-8")
return dest
# ---------------------------------------------------------------------------
# Unit tests regression proof for the old regex vs the new approach
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeleteDetectionRegex:
"""Verify that the new content-string pattern finds blocks in both formats."""
OLD_PATTERN = re.compile(r"^\s*\(symbol\s+\(lib_id\s+\"", re.MULTILINE)
NEW_PATTERN = re.compile(r'\(symbol\s+\(lib_id\s+"')
def test_old_regex_fails_on_multiline_format(self):
"""Regression: old line-by-line regex must NOT match the multi-line format."""
# The old code used re.match on individual lines; simulate that here.
lines = PLACED_RESISTOR_MULTILINE.split("\n")
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
assert matches == [], "Old regex should not match multi-line KiCAD format"
def test_old_regex_matches_inline_format(self):
"""Old regex did work on single-line (inline) format."""
lines = PLACED_RESISTOR_INLINE.split("\n")
matches = [l for l in lines if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", l)]
assert len(matches) == 1
def test_new_pattern_matches_multiline_format(self):
"""New content-string pattern must find blocks in multi-line format."""
assert self.NEW_PATTERN.search(PLACED_RESISTOR_MULTILINE) is not None
def test_new_pattern_matches_inline_format(self):
"""New content-string pattern also works on inline format."""
assert self.NEW_PATTERN.search(PLACED_RESISTOR_INLINE) is not None
def test_new_pattern_matches_power_symbol_multiline(self):
"""New pattern must find #PWR030 power symbol in multi-line format."""
assert self.NEW_PATTERN.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
def test_reference_extraction_from_multiline_block(self):
"""Reference property can be found inside a multi-line block."""
ref_pattern = re.compile(r'\(property\s+"Reference"\s+"#PWR030"')
assert ref_pattern.search(PLACED_POWER_SYMBOL_MULTILINE) is not None
# ---------------------------------------------------------------------------
# Integration tests real file I/O using the handler
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDeleteSchematicComponentIntegration:
def _get_handler(self):
from kicad_interface import KiCADInterface
iface = KiCADInterface.__new__(KiCADInterface)
return iface._handle_delete_schematic_component
def test_delete_inline_format_succeeds(self, tmp_path):
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "R1"})
assert result["success"] is True
assert result["deleted_count"] == 1
def test_delete_multiline_format_succeeds(self, tmp_path):
"""Regression: must succeed when KiCAD writes (symbol and (lib_id on separate lines."""
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_MULTILINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "R2"})
assert result["success"] is True
assert result["deleted_count"] == 1
def test_delete_power_symbol_multiline_succeeds(self, tmp_path):
"""Regression: #PWR030 multi-line power symbol must be deletable."""
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
assert result["success"] is True
assert result["deleted_count"] == 1
def test_component_absent_after_delete(self, tmp_path):
sch = _make_test_schematic(tmp_path, PLACED_POWER_SYMBOL_MULTILINE)
self._get_handler()({"schematicPath": str(sch), "reference": "#PWR030"})
remaining = sch.read_text(encoding="utf-8")
assert '"#PWR030"' not in remaining
def test_unknown_reference_returns_failure(self, tmp_path):
sch = _make_test_schematic(tmp_path, PLACED_RESISTOR_INLINE)
result = self._get_handler()({"schematicPath": str(sch), "reference": "U99"})
assert result["success"] is False
assert "not found" in result["message"]
def test_missing_schematic_path_returns_failure(self, tmp_path):
result = self._get_handler()({"reference": "R1"})
assert result["success"] is False
def test_missing_reference_returns_failure(self, tmp_path):
sch = _make_test_schematic(tmp_path)
result = self._get_handler()({"schematicPath": str(sch)})
assert result["success"] is False

View File

@@ -1,541 +0,0 @@
"""
Tests for the Freerouting autoroute integration.
Covers:
- FreeroutingCommands.check_freerouting (dependency detection)
- FreeroutingCommands.export_dsn (DSN export via pcbnew)
- FreeroutingCommands.import_ses (SES import via pcbnew)
- FreeroutingCommands.autoroute (full pipeline, direct + docker)
- Error handling: missing board, no runtime, missing JAR, timeouts
- _find_java, _docker_available, _java_version_ok helpers
"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from commands.freerouting import (
FreeroutingCommands,
_find_java,
_find_docker,
_docker_available,
_java_version_ok,
)
# pcbnew mock from conftest
pcbnew_mock = sys.modules["pcbnew"]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def reset_pcbnew_mock():
"""Reset pcbnew mock before each test."""
pcbnew_mock.reset_mock()
pcbnew_mock.ExportSpecctraDSN.side_effect = None
pcbnew_mock.ExportSpecctraDSN.return_value = MagicMock()
pcbnew_mock.ImportSpecctraSES.side_effect = None
pcbnew_mock.ImportSpecctraSES.return_value = MagicMock()
yield
@pytest.fixture
def mock_board():
board = MagicMock()
board.GetFileName.return_value = (
"/tmp/test_project/test.kicad_pcb"
)
board.GetTracks.return_value = []
return board
@pytest.fixture
def cmds(mock_board):
return FreeroutingCommands(board=mock_board)
@pytest.fixture
def cmds_no_board():
return FreeroutingCommands(board=None)
def _patch_direct_java():
"""Patch to simulate Java 21+ available locally."""
return patch.object(
FreeroutingCommands,
"_resolve_execution_mode",
return_value={"mode": "direct", "use_docker": False},
)
def _patch_docker_mode():
"""Patch to simulate Docker execution mode."""
return patch.object(
FreeroutingCommands,
"_resolve_execution_mode",
return_value={"mode": "docker", "use_docker": True},
)
def _patch_no_runtime():
"""Patch to simulate no Java and no Docker."""
return patch.object(
FreeroutingCommands,
"_resolve_execution_mode",
return_value={
"mode": "error",
"error": "Neither Java 21+ nor Docker found.",
},
)
# ---------------------------------------------------------------------------
# check_freerouting
# ---------------------------------------------------------------------------
class TestCheckFreerouting:
def test_no_java_no_docker(self, cmds):
with patch(
"commands.freerouting._find_java", return_value=None
), patch(
"commands.freerouting._docker_available",
return_value=False,
):
result = cmds.check_freerouting(
{"freeroutingJar": "/nonexistent.jar"}
)
assert result["success"] is True
assert result["java"]["found"] is False
assert result["docker"]["available"] is False
assert result["ready"] is False
assert result["execution_mode"] == "none"
def test_java_too_old_docker_available(self, cmds, tmp_path):
jar = tmp_path / "freerouting.jar"
jar.touch()
with patch(
"commands.freerouting._find_java",
return_value="/usr/bin/java",
), patch(
"commands.freerouting._java_version_ok",
return_value=False,
), patch(
"commands.freerouting._docker_available",
return_value=True,
), patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "17.0.1"', stdout=""
)
result = cmds.check_freerouting(
{"freeroutingJar": str(jar)}
)
assert result["ready"] is True
assert result["execution_mode"] == "docker"
def test_java_21_direct(self, cmds, tmp_path):
jar = tmp_path / "freerouting.jar"
jar.touch()
with patch(
"commands.freerouting._find_java",
return_value="/usr/bin/java",
), patch(
"commands.freerouting._java_version_ok",
return_value=True,
), patch(
"commands.freerouting._docker_available",
return_value=False,
), patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "21.0.1"', stdout=""
)
result = cmds.check_freerouting(
{"freeroutingJar": str(jar)}
)
assert result["ready"] is True
assert result["execution_mode"] == "direct"
# ---------------------------------------------------------------------------
# export_dsn
# ---------------------------------------------------------------------------
class TestExportDsn:
def test_no_board(self, cmds_no_board):
result = cmds_no_board.export_dsn({})
assert result["success"] is False
assert "No board" in result["message"]
def test_export_success(self, cmds, tmp_path):
board_path = str(tmp_path / "test.kicad_pcb")
dsn_path = str(tmp_path / "test.dsn")
cmds.board.GetFileName.return_value = board_path
pcbnew_mock.ExportSpecctraDSN.return_value = True
Path(dsn_path).write_text("(pcb test)")
result = cmds.export_dsn({})
assert result["success"] is True
assert result["path"] == dsn_path
def test_export_custom_path(self, cmds, tmp_path):
output = str(tmp_path / "custom.dsn")
pcbnew_mock.ExportSpecctraDSN.return_value = True
Path(output).write_text("(pcb test)")
result = cmds.export_dsn({"outputPath": output})
assert result["success"] is True
assert result["path"] == output
def test_export_failure(self, cmds):
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception(
"DSN error"
)
result = cmds.export_dsn({})
assert result["success"] is False
assert "DSN error" in result["errorDetails"]
# ---------------------------------------------------------------------------
# import_ses
# ---------------------------------------------------------------------------
class TestImportSes:
def test_no_board(self, cmds_no_board):
result = cmds_no_board.import_ses(
{"sesPath": "/tmp/test.ses"}
)
assert result["success"] is False
assert "No board" in result["message"]
def test_missing_ses_path(self, cmds):
result = cmds.import_ses({})
assert result["success"] is False
assert "Missing sesPath" in result["message"]
def test_ses_file_not_found(self, cmds):
result = cmds.import_ses(
{"sesPath": "/nonexistent/test.ses"}
)
assert result["success"] is False
assert "not found" in result["message"]
def test_import_success(self, cmds, tmp_path):
ses_file = tmp_path / "test.ses"
ses_file.write_text("(session test)")
pcbnew_mock.ImportSpecctraSES.return_value = True
cmds.board.GetTracks.return_value = []
result = cmds.import_ses({"sesPath": str(ses_file)})
assert result["success"] is True
def test_import_failure(self, cmds, tmp_path):
ses_file = tmp_path / "test.ses"
ses_file.write_text("(session test)")
pcbnew_mock.ImportSpecctraSES.side_effect = Exception(
"SES error"
)
result = cmds.import_ses({"sesPath": str(ses_file)})
assert result["success"] is False
assert "SES error" in result["errorDetails"]
# ---------------------------------------------------------------------------
# autoroute (full pipeline)
# ---------------------------------------------------------------------------
class TestAutoroute:
def test_no_board(self, cmds_no_board):
result = cmds_no_board.autoroute({})
assert result["success"] is False
assert "No board" in result["message"]
def test_no_runtime(self, cmds, tmp_path):
jar = tmp_path / "freerouting.jar"
jar.touch()
with _patch_no_runtime():
result = cmds.autoroute(
{"freeroutingJar": str(jar)}
)
assert result["success"] is False
assert "No suitable Java runtime" in result["message"]
def test_no_jar(self, cmds):
result = cmds.autoroute(
{"freeroutingJar": "/nonexistent/freerouting.jar"}
)
assert result["success"] is False
assert "JAR not found" in result["message"]
@patch("commands.freerouting.subprocess.run")
def test_dsn_export_fails(self, mock_run, cmds, tmp_path):
jar = tmp_path / "freerouting.jar"
jar.touch()
pcbnew_mock.ExportSpecctraDSN.side_effect = Exception(
"export fail"
)
with _patch_direct_java():
result = cmds.autoroute(
{"freeroutingJar": str(jar)}
)
assert result["success"] is False
assert "DSN export failed" in result["message"]
@patch("commands.freerouting.subprocess.run")
def test_freerouting_timeout(
self, mock_run, cmds, tmp_path
):
import subprocess
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
)
mock_run.side_effect = subprocess.TimeoutExpired(
cmd="", timeout=10
)
with _patch_direct_java():
result = cmds.autoroute(
{"freeroutingJar": str(jar), "timeout": 10}
)
assert result["success"] is False
assert "timed out" in result["message"]
@patch("commands.freerouting.subprocess.run")
def test_full_success_direct(
self, mock_run, cmds, tmp_path
):
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
ses_file = board_dir / "test.ses"
cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
)
mock_run.return_value = MagicMock(
returncode=0, stdout="Routing completed", stderr=""
)
ses_file.write_text("(session)")
pcbnew_mock.ImportSpecctraSES.return_value = True
track = MagicMock()
track.GetClass.return_value = "PCB_TRACK"
via = MagicMock()
via.GetClass.return_value = "PCB_VIA"
cmds.board.GetTracks.return_value = [track, track, via]
with _patch_direct_java():
result = cmds.autoroute(
{"freeroutingJar": str(jar)}
)
assert result["success"] is True
assert result["mode"] == "direct"
assert result["board_stats"]["tracks"] == 2
assert result["board_stats"]["vias"] == 1
assert "elapsed_seconds" in result
@patch("commands.freerouting.subprocess.run")
def test_full_success_docker(
self, mock_run, cmds, tmp_path
):
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
ses_file = board_dir / "test.ses"
cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
)
mock_run.return_value = MagicMock(
returncode=0, stdout="Routing completed", stderr=""
)
ses_file.write_text("(session)")
pcbnew_mock.ImportSpecctraSES.return_value = True
cmds.board.GetTracks.return_value = [MagicMock()]
with _patch_docker_mode(), patch(
"commands.freerouting._find_docker",
return_value="/usr/bin/docker",
):
result = cmds.autoroute(
{"freeroutingJar": str(jar)}
)
assert result["success"] is True
assert result["mode"] == "docker"
# Verify a container runtime was invoked
call_args = mock_run.call_args[0][0]
assert "run" in call_args
assert "--rm" in call_args
@patch("commands.freerouting.subprocess.run")
def test_freerouting_nonzero_exit(
self, mock_run, cmds, tmp_path
):
jar = tmp_path / "freerouting.jar"
jar.touch()
board_dir = tmp_path / "project"
board_dir.mkdir()
board_file = board_dir / "test.kicad_pcb"
board_file.touch()
dsn_file = board_dir / "test.dsn"
cmds.board.GetFileName.return_value = str(board_file)
pcbnew_mock.ExportSpecctraDSN.side_effect = (
lambda b, p: (dsn_file.write_text("(pcb)"), True)[1]
)
mock_run.return_value = MagicMock(
returncode=1, stdout="", stderr="OutOfMemoryError"
)
with _patch_direct_java():
result = cmds.autoroute(
{"freeroutingJar": str(jar)}
)
assert result["success"] is False
assert "exited with code 1" in result["message"]
# ---------------------------------------------------------------------------
# Helper functions
# ---------------------------------------------------------------------------
class TestFindJava:
def test_finds_via_which(self):
with patch(
"commands.freerouting.shutil.which",
return_value="/usr/bin/java",
):
assert _find_java() == "/usr/bin/java"
def test_none_when_not_found(self):
with patch(
"commands.freerouting.shutil.which",
return_value=None,
), patch("os.path.isfile", return_value=False):
assert _find_java() is None
class TestFindDocker:
def test_finds_docker(self):
with patch(
"commands.freerouting.shutil.which",
side_effect=lambda x: "/usr/bin/docker"
if x == "docker"
else None,
):
assert _find_docker() == "/usr/bin/docker"
def test_finds_podman(self):
with patch(
"commands.freerouting.shutil.which",
side_effect=lambda x: "/usr/bin/podman"
if x == "podman"
else None,
):
assert _find_docker() == "/usr/bin/podman"
def test_none_when_not_found(self):
with patch(
"commands.freerouting.shutil.which",
return_value=None,
):
assert _find_docker() is None
class TestDockerAvailable:
def test_docker_found(self):
with patch(
"commands.freerouting._find_docker",
return_value="/usr/bin/docker",
), patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(returncode=0)
assert _docker_available() is True
def test_docker_not_installed(self):
with patch(
"commands.freerouting._find_docker",
return_value=None,
):
assert _docker_available() is False
def test_docker_not_running(self):
with patch(
"commands.freerouting._find_docker",
return_value="/usr/bin/docker",
), patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(returncode=1)
assert _docker_available() is False
class TestJavaVersionOk:
def test_java_21(self):
with patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "21.0.1"', stdout=""
)
assert _java_version_ok("/usr/bin/java") is True
def test_java_17(self):
with patch(
"commands.freerouting.subprocess.run"
) as mock_run:
mock_run.return_value = MagicMock(
stderr='openjdk version "17.0.18"', stdout=""
)
assert _java_version_ok("/usr/bin/java") is False
def test_java_error(self):
with patch(
"commands.freerouting.subprocess.run",
side_effect=Exception("not found"),
):
assert _java_version_ok("/usr/bin/java") is False

View File

@@ -1,369 +0,0 @@
"""
Tests for get_schematic_component and edit_schematic_component fieldPositions support.
"""
import re
import sys
import shutil
import tempfile
from pathlib import Path
import pytest
# Ensure python/ directory is on path so kicad_interface can be imported
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python"))
# ---------------------------------------------------------------------------
# Helpers shared across tests
# ---------------------------------------------------------------------------
TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch"
# Minimal placed-symbol block we can embed into a schematic for testing
PLACED_RESISTOR_BLOCK = """\
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
(in_bom yes) (on_board yes) (dnp no)
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
(property "Reference" "R1" (at 51.27 47.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "10k" (at 51.27 52.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 50 50 0)
(effects (font (size 1.27 1.27)) hide)
)
)
"""
def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path:
"""Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block."""
dest = tmp_dir / "test.kicad_sch"
src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
# Insert placed symbol block before the closing paren of the top-level form
if extra_block:
src_content = src_content.rstrip()
if src_content.endswith(")"):
src_content = src_content[:-1] + "\n" + extra_block + ")\n"
dest.write_text(src_content, encoding="utf-8")
return dest
# ---------------------------------------------------------------------------
# Unit tests regex / parsing logic only (no file I/O, no KiCAD imports)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetSchematicComponentParsing:
"""Unit tests for the regex logic used by _handle_get_schematic_component."""
def _parse_fields(self, block_text: str) -> dict:
"""Mirrors the regex used in _handle_get_schematic_component."""
prop_pattern = re.compile(
r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)'
)
fields = {}
for m in prop_pattern.finditer(block_text):
name, value, x, y, angle = (
m.group(1),
m.group(2),
m.group(3),
m.group(4),
m.group(5),
)
fields[name] = {
"value": value,
"x": float(x),
"y": float(y),
"angle": float(angle),
}
return fields
def _parse_comp_pos(self, block_text: str):
"""Mirrors the regex used to extract symbol position."""
m = re.search(
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)',
block_text,
)
if m:
return {
"x": float(m.group(1)),
"y": float(m.group(2)),
"angle": float(m.group(3)),
}
return None
def test_parses_reference_field(self):
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert "Reference" in fields
assert fields["Reference"]["value"] == "R1"
assert fields["Reference"]["x"] == pytest.approx(51.27)
assert fields["Reference"]["y"] == pytest.approx(47.46)
assert fields["Reference"]["angle"] == pytest.approx(0.0)
def test_parses_value_field(self):
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert "Value" in fields
assert fields["Value"]["value"] == "10k"
assert fields["Value"]["x"] == pytest.approx(51.27)
assert fields["Value"]["y"] == pytest.approx(52.54)
def test_parses_all_four_standard_fields(self):
fields = self._parse_fields(PLACED_RESISTOR_BLOCK)
assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"}
def test_parses_component_position(self):
pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK)
assert pos is not None
assert pos["x"] == pytest.approx(50.0)
assert pos["y"] == pytest.approx(50.0)
assert pos["angle"] == pytest.approx(0.0)
def test_field_position_regex_replaces_correctly(self):
"""Mirrors the regex used in _handle_edit_schematic_component for fieldPositions."""
field_name = "Reference"
new_x, new_y, new_angle = 99.0, 88.0, 0
block = PLACED_RESISTOR_BLOCK
block = re.sub(
r'(\(property\s+"'
+ re.escape(field_name)
+ r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
rf"\1(at {new_x} {new_y} {new_angle})",
block,
)
fields = self._parse_fields(block)
assert fields["Reference"]["x"] == pytest.approx(99.0)
assert fields["Reference"]["y"] == pytest.approx(88.0)
# Value should be unchanged
assert fields["Value"]["x"] == pytest.approx(51.27)
def test_field_position_regex_preserves_value(self):
"""Replacing position must not change the field value string."""
block = PLACED_RESISTOR_BLOCK
block = re.sub(
r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)',
r"\1(at 0.0 0.0 0)",
block,
)
fields = self._parse_fields(block)
assert fields["Value"]["value"] == "10k"
# ---------------------------------------------------------------------------
# Integration tests real file I/O using the empty.kicad_sch template
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestGetSchematicComponentIntegration:
"""Integration tests: write a real .kicad_sch and call the handler."""
@pytest.fixture
def sch_with_r1(self, tmp_path):
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _get_interface(self):
"""Lazily import KiCADInterface to avoid pcbnew import at collection time."""
from kicad_interface import KiCADInterface
return KiCADInterface()
def test_get_returns_success(self, sch_with_r1):
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
},
)
assert result["success"] is True
def test_get_returns_correct_reference(self, sch_with_r1):
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
},
)
assert result["reference"] == "R1"
def test_get_returns_component_position(self, sch_with_r1):
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
},
)
assert result["position"] is not None
assert result["position"]["x"] == pytest.approx(50.0)
assert result["position"]["y"] == pytest.approx(50.0)
def test_get_returns_reference_field_position(self, sch_with_r1):
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
},
)
ref_field = result["fields"]["Reference"]
assert ref_field["value"] == "R1"
assert ref_field["x"] == pytest.approx(51.27)
assert ref_field["y"] == pytest.approx(47.46)
def test_get_returns_value_field(self, sch_with_r1):
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
},
)
val_field = result["fields"]["Value"]
assert val_field["value"] == "10k"
assert val_field["x"] == pytest.approx(51.27)
assert val_field["y"] == pytest.approx(52.54)
def test_get_unknown_reference_returns_failure(self, sch_with_r1):
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R99",
},
)
assert result["success"] is False
assert "R99" in result["message"]
def test_get_missing_path_returns_failure(self):
iface = self._get_interface()
result = iface.handle_command(
"get_schematic_component",
{
"reference": "R1",
},
)
assert result["success"] is False
@pytest.mark.integration
class TestEditSchematicComponentFieldPositions:
"""Integration tests for the new fieldPositions parameter."""
@pytest.fixture
def sch_with_r1(self, tmp_path):
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _get_interface(self):
from kicad_interface import KiCADInterface
return KiCADInterface()
def test_reposition_reference_label(self, sch_with_r1):
iface = self._get_interface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}},
},
)
assert result["success"] is True
# Verify the position was actually written
get_result = iface.handle_command(
"get_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
},
)
assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0)
assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0)
def test_reposition_does_not_change_value(self, sch_with_r1):
iface = self._get_interface()
iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}},
},
)
get_result = iface.handle_command(
"get_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
},
)
# Value field position must be unchanged
assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27)
assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54)
def test_reposition_multiple_fields(self, sch_with_r1):
iface = self._get_interface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"fieldPositions": {
"Reference": {"x": 10.0, "y": 20.0, "angle": 0},
"Value": {"x": 10.0, "y": 30.0, "angle": 0},
},
},
)
assert result["success"] is True
get_result = iface.handle_command(
"get_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
},
)
assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0)
assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0)
def test_fieldpositions_alone_is_valid(self, sch_with_r1):
"""fieldPositions without value/footprint/newReference should succeed."""
iface = self._get_interface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"fieldPositions": {"Value": {"x": 55.0, "y": 60.0}},
},
)
assert result["success"] is True
def test_no_params_still_fails(self, sch_with_r1):
"""Providing no update params should return an error."""
iface = self._get_interface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
},
)
assert result["success"] is False

View File

@@ -1,498 +0,0 @@
"""
Tests for schematic inspection and editing tools added in the schematic_tools branch.
Covers:
- WireManager.delete_wire (unit + integration)
- WireManager.delete_label (unit + integration)
- Handler-level parameter validation for the 11 new KiCADInterface handlers
(tested by calling _handle_* methods on a lightweight stub that avoids
importing the full kicad_interface module).
"""
import shutil
import tempfile
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import sexpdata
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
TEMPLATES_DIR = Path(__file__).parent.parent / "templates"
EMPTY_SCH = TEMPLATES_DIR / "empty.kicad_sch"
# Minimal schematic content used by integration tests
_WIRE_SCH = """\
(kicad_sch (version 20250114) (generator "test")
(uuid aaaaaaaa-0000-0000-0000-000000000000)
(paper "A4")
(wire (pts (xy 10 20) (xy 30 20))
(stroke (width 0) (type default))
(uuid bbbbbbbb-0000-0000-0000-000000000001)
)
(label "VCC" (at 50 50 0)
(effects (font (size 1.27 1.27)) (justify left bottom))
(uuid cccccccc-0000-0000-0000-000000000002)
)
(sheet_instances (path "/" (page "1")))
)
"""
def _write_temp_sch(content: str) -> Path:
"""Write *content* to a temp file and return its Path."""
tmp = tempfile.NamedTemporaryFile(
suffix=".kicad_sch", delete=False, mode="w", encoding="utf-8"
)
tmp.write(content)
tmp.close()
return Path(tmp.name)
# ---------------------------------------------------------------------------
# Unit tests WireManager.delete_wire
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeleteWireUnit:
"""Unit-level tests for WireManager.delete_wire."""
def setup_method(self):
from commands.wire_manager import WireManager
self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path):
result = self.WireManager.delete_wire(
tmp_path / "nope.kicad_sch", [0, 0], [10, 10]
)
assert result is False
def test_no_matching_wire_returns_false(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_wire(sch, [99, 99], [100, 100])
assert result is False
def test_tolerance_argument_accepted(self, tmp_path):
"""Ensure the tolerance kwarg doesn't raise a TypeError."""
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_wire(sch, [0, 0], [1, 1], tolerance=0.1)
assert result is False # no wire in empty sch
# ---------------------------------------------------------------------------
# Unit tests WireManager.delete_label
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeleteLabelUnit:
"""Unit-level tests for WireManager.delete_label."""
def setup_method(self):
from commands.wire_manager import WireManager
self.WireManager = WireManager
def test_nonexistent_file_returns_false(self, tmp_path):
result = self.WireManager.delete_label(
tmp_path / "nope.kicad_sch", "VCC"
)
assert result is False
def test_missing_label_returns_false(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_label(sch, "NONEXISTENT")
assert result is False
def test_position_kwarg_accepted(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
shutil.copy(EMPTY_SCH, sch)
result = self.WireManager.delete_label(
sch, "VCC", position=[10.0, 20.0], tolerance=0.5
)
assert result is False
# ---------------------------------------------------------------------------
# Integration tests WireManager.delete_wire
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDeleteWireIntegration:
"""Integration tests that read/write real .kicad_sch files."""
def setup_method(self):
from commands.wire_manager import WireManager
self.WireManager = WireManager
def test_exact_match_deletes_wire(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
assert result is True
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
wire_items = [
item
for item in data
if isinstance(item, list)
and item
and item[0] == sexpdata.Symbol("wire")
]
assert wire_items == [], "Wire should have been removed from the file"
def test_reverse_direction_match_deletes_wire(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
# Pass end/start swapped should still match
result = self.WireManager.delete_wire(sch, [30.0, 20.0], [10.0, 20.0])
assert result is True
def test_within_tolerance_deletes_wire(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
# Coordinates differ by 0.3 mm — within default tolerance of 0.5
result = self.WireManager.delete_wire(
sch, [10.3, 20.3], [30.3, 20.3], tolerance=0.5
)
assert result is True
def test_outside_tolerance_no_delete(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_wire(
sch, [10.0, 20.0], [30.0, 20.0], tolerance=0.0
)
# tolerance=0.0 means exact float equality — may still match on most
# platforms, but the key thing is that a *distant* miss is rejected
sch2 = tmp_path / "test2.kicad_sch"
sch2.write_text(_WIRE_SCH, encoding="utf-8")
result2 = self.WireManager.delete_wire(sch2, [10.6, 20.0], [30.0, 20.0], tolerance=0.5)
assert result2 is False, "Coordinate differs by 0.6 mm — outside 0.5 mm tolerance"
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
# Must parse without exception
sexpdata.loads(sch.read_text(encoding="utf-8"))
def test_label_preserved_after_wire_deletion(self, tmp_path):
"""Deleting a wire must not remove unrelated elements."""
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
self.WireManager.delete_wire(sch, [10.0, 20.0], [30.0, 20.0])
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
labels = [
item
for item in data
if isinstance(item, list)
and item
and item[0] == sexpdata.Symbol("label")
]
assert len(labels) == 1
# ---------------------------------------------------------------------------
# Integration tests WireManager.delete_label
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestDeleteLabelIntegration:
def setup_method(self):
from commands.wire_manager import WireManager
self.WireManager = WireManager
def test_deletes_label_by_name(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_label(sch, "VCC")
assert result is True
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
labels = [
item
for item in data
if isinstance(item, list)
and item
and item[0] == sexpdata.Symbol("label")
]
assert labels == [], "Label should have been removed"
def test_deletes_label_with_matching_position(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_label(sch, "VCC", position=[50.0, 50.0])
assert result is True
def test_position_mismatch_no_delete(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
result = self.WireManager.delete_label(
sch, "VCC", position=[99.0, 99.0], tolerance=0.5
)
assert result is False
def test_wire_preserved_after_label_deletion(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
self.WireManager.delete_label(sch, "VCC")
data = sexpdata.loads(sch.read_text(encoding="utf-8"))
wires = [
item
for item in data
if isinstance(item, list)
and item
and item[0] == sexpdata.Symbol("wire")
]
assert len(wires) == 1
def test_file_is_valid_sexp_after_deletion(self, tmp_path):
sch = tmp_path / "test.kicad_sch"
sch.write_text(_WIRE_SCH, encoding="utf-8")
self.WireManager.delete_label(sch, "VCC")
sexpdata.loads(sch.read_text(encoding="utf-8"))
# ---------------------------------------------------------------------------
# Unit tests handler parameter validation (via lightweight handler stubs)
# ---------------------------------------------------------------------------
# We test the validation logic of the new _handle_* methods without importing
# the full kicad_interface module (which pulls in pcbnew and calls sys.exit).
# Each handler is extracted as a standalone function for testing.
def _make_handler_under_test(handler_name: str):
"""
Return the unbound handler method from kicad_interface by importing only
that method's source via exec, bypassing module-level side effects.
This works because every _handle_* method starts with a params dict check
before doing any file I/O or heavy imports.
"""
import importlib.util, types
# We monkey-patch sys.modules to avoid pcbnew/skip side effects
stubs = {}
for mod in ("pcbnew", "skip", "commands.schematic"):
stubs[mod] = types.ModuleType(mod)
# Provide a minimal SchematicManager stub so attribute lookups don't fail
schema_stub = types.ModuleType("commands.schematic")
schema_stub.SchematicManager = MagicMock()
stubs["commands.schematic"] = schema_stub
with patch.dict("sys.modules", stubs):
# Import just the handlers module in isolation isn't feasible for
# kicad_interface.py (module-level sys.exit). Instead, we directly
# call the method on a MagicMock instance, binding the real function.
pass
return None # Not used; see TestHandlerParamValidation below
@pytest.mark.unit
class TestHandlerParamValidation:
"""
Verify that each new handler returns success=False with an informative
message when required parameters are missing, without needing real files.
We call the handler functions directly after building minimal stub objects
that satisfy the dependency chain up to the first parameter-check branch.
"""
def _make_iface_stub(self):
"""Return a stub that exposes only the handler methods under test."""
import types
import importlib
# Build a minimal namespace that satisfies the imports inside each handler
stub_mod = types.ModuleType("_handler_stubs")
stub_mod.os = __import__("os")
class _Stub:
pass
return _Stub()
# --- delete_schematic_wire ---
def test_delete_wire_missing_schematic_path(self):
from commands.wire_manager import WireManager
with patch.object(WireManager, "delete_wire", return_value=False):
# Simulate the handler logic inline
params = {"start": {"x": 0, "y": 0}, "end": {"x": 10, "y": 10}}
schematic_path = params.get("schematicPath")
assert schematic_path is None
# Handler should short-circuit before calling WireManager
result = (
{"success": False, "message": "schematicPath is required"}
if not schematic_path
else {}
)
assert result["success"] is False
assert "schematicPath" in result["message"]
# --- delete_schematic_net_label ---
def test_delete_label_missing_net_name(self):
params = {"schematicPath": "/some/file.kicad_sch"}
net_name = params.get("netName")
result = (
{
"success": False,
"message": "schematicPath and netName are required",
}
if not net_name
else {}
)
assert result["success"] is False
def test_delete_label_missing_schematic_path(self):
params = {"netName": "VCC"}
schematic_path = params.get("schematicPath")
result = (
{
"success": False,
"message": "schematicPath and netName are required",
}
if not schematic_path
else {}
)
assert result["success"] is False
# --- list_schematic_components ---
def test_list_components_missing_path(self):
params = {}
schematic_path = params.get("schematicPath")
result = (
{"success": False, "message": "schematicPath is required"}
if not schematic_path
else {}
)
assert result["success"] is False
# --- list_schematic_nets ---
def test_list_nets_missing_path(self):
params = {}
result = (
{"success": False, "message": "schematicPath is required"}
if not params.get("schematicPath")
else {}
)
assert result["success"] is False
# --- list_schematic_wires ---
def test_list_wires_missing_path(self):
params = {}
result = (
{"success": False, "message": "schematicPath is required"}
if not params.get("schematicPath")
else {}
)
assert result["success"] is False
# --- list_schematic_labels ---
def test_list_labels_missing_path(self):
params = {}
result = (
{"success": False, "message": "schematicPath is required"}
if not params.get("schematicPath")
else {}
)
assert result["success"] is False
# --- move_schematic_component ---
def test_move_component_missing_reference(self):
params = {
"schematicPath": "/some/file.kicad_sch",
"position": {"x": 10, "y": 20},
}
result = (
{
"success": False,
"message": "schematicPath and reference are required",
}
if not params.get("reference")
else {}
)
assert result["success"] is False
def test_move_component_missing_position(self):
params = {
"schematicPath": "/some/file.kicad_sch",
"reference": "R1",
"position": {},
}
new_x = params["position"].get("x")
new_y = params["position"].get("y")
result = (
{"success": False, "message": "position with x and y is required"}
if new_x is None or new_y is None
else {}
)
assert result["success"] is False
# --- rotate_schematic_component ---
def test_rotate_component_missing_reference(self):
params = {"schematicPath": "/some/file.kicad_sch"}
result = (
{
"success": False,
"message": "schematicPath and reference are required",
}
if not params.get("reference")
else {}
)
assert result["success"] is False
# --- annotate_schematic ---
def test_annotate_missing_path(self):
params = {}
result = (
{"success": False, "message": "schematicPath is required"}
if not params.get("schematicPath")
else {}
)
assert result["success"] is False
# --- export_schematic_svg ---
def test_export_svg_missing_output_path(self):
params = {"schematicPath": "/some/file.kicad_sch"}
result = (
{
"success": False,
"message": "schematicPath and outputPath are required",
}
if not params.get("outputPath")
else {}
)
assert result["success"] is False

View File

@@ -3,77 +3,82 @@ KiCAD Process Management Utilities
Detects if KiCAD is running and provides auto-launch functionality.
"""
import os
import subprocess
import logging
import platform
import time
import ctypes
from ctypes import wintypes
from pathlib import Path
from typing import Optional, List
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:
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
@@ -87,27 +92,21 @@ class KiCADProcessManager:
# Check for actual pcbnew/kicad binaries (not python scripts)
# Use exact process name matching to avoid matching our own kicad_interface.py
result = subprocess.run(
["pgrep", "-x", "pcbnew|kicad"],
capture_output=True,
text=True
["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True
)
if result.returncode == 0:
return True
# Also check with -f for full path matching, but exclude our script
result = subprocess.run(
["pgrep", "-f", "/pcbnew|/kicad"],
capture_output=True,
text=True
["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True
)
# Double-check it's not our own process
if result.returncode == 0:
pids = result.stdout.strip().split('\n')
pids = result.stdout.strip().split("\n")
for pid in pids:
try:
cmdline = subprocess.run(
["ps", "-p", pid, "-o", "command="],
capture_output=True,
text=True
["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
)
if "kicad_interface.py" not in cmdline.stdout:
return True
@@ -117,18 +116,16 @@ class KiCADProcessManager:
elif system == "Darwin": # macOS
result = subprocess.run(
["pgrep", "-f", "KiCad|pcbnew"],
capture_output=True,
text=True
["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
)
return result.returncode == 0
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
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:
@@ -151,18 +148,18 @@ class KiCADProcessManager:
# 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
)
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:
path = result.stdout.strip().split("\n")[0]
logger.info(f"Found KiCAD executable: {path}")
return Path(path)
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":
@@ -232,7 +229,7 @@ class KiCADProcessManager:
cmd,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
stderr=subprocess.DEVNULL,
)
else:
# Unix: Use nohup or start in background
@@ -240,7 +237,7 @@ class KiCADProcessManager:
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
start_new_session=True,
)
# Wait for process to start
@@ -275,28 +272,30 @@ class KiCADProcessManager:
try:
if system in ["Linux", "Darwin"]:
result = subprocess.run(
["ps", "aux"],
capture_output=True,
text=True
)
result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
for line in result.stdout.split("\n"):
# Only match actual KiCAD binaries, not our MCP server processes
if ("pcbnew" in line.lower() or "kicad" in line.lower()) and "kicad_interface.py" not in line and "grep" not in line:
if (
("pcbnew" in line.lower() or "kicad" in line.lower())
and "kicad_interface.py" not in line
and "grep" not in line
):
# More specific check: must have /pcbnew or /kicad in the path
if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line:
parts = line.split()
if len(parts) >= 11:
processes.append({
"pid": parts[1],
"name": parts[10],
"command": " ".join(parts[10:])
})
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:
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:
@@ -304,6 +303,7 @@ class KiCADProcessManager:
return processes
def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict:
"""
Check if KiCAD is running and optionally launch it
@@ -325,7 +325,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
"running": True,
"launched": False,
"processes": processes,
"message": "KiCAD is already running"
"message": "KiCAD is already running",
}
if not auto_launch:
@@ -333,7 +333,7 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
"running": False,
"launched": False,
"processes": [],
"message": "KiCAD is not running (auto-launch disabled)"
"message": "KiCAD is not running (auto-launch disabled)",
}
# Try to launch
@@ -345,5 +345,5 @@ def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: boo
"launched": success,
"processes": manager.get_process_info() if success else [],
"message": "KiCAD launched successfully" if success else "Failed to launch KiCAD",
"project": str(project_path) if project_path else None
"project": str(project_path) if project_path else None,
}

View File

@@ -4,12 +4,13 @@ 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
import logging
logger = logging.getLogger(__name__)
@@ -74,19 +75,23 @@ class PlatformHelper:
# Also check based on Python version
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
candidates.extend([
Path(f"/usr/lib/python{py_version}/dist-packages/kicad"),
Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"),
])
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"),
])
candidates.extend(
[
Path(f"/usr/lib/python3/dist-packages"),
Path(f"/usr/lib/python{py_version}/dist-packages"),
Path(f"/usr/local/lib/python3/dist-packages"),
Path(f"/usr/local/lib/python{py_version}/dist-packages"),
]
)
paths = [p for p in candidates if p.exists()]
@@ -102,7 +107,17 @@ class PlatformHelper:
for kicad_app in kicad_app_paths:
if kicad_app.exists():
for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]:
path = kicad_app / "Contents" / "Frameworks" / "Python.framework" / "Versions" / version / "lib" / f"python{version}" / "site-packages"
path = (
kicad_app
/ "Contents"
/ "Frameworks"
/ "Python.framework"
/ "Versions"
/ version
/ "lib"
/ f"python{version}"
/ "site-packages"
)
if path.exists():
paths.append(path)
@@ -110,7 +125,7 @@ class PlatformHelper:
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.12/site-packages"), # Intel Mac
Path("/usr/local/lib/python3.11/site-packages"),
]
for hp in homebrew_paths:
@@ -161,7 +176,10 @@ class PlatformHelper:
patterns = [
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
"/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
str(Path.home() / "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"),
str(
Path.home()
/ "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"
),
]
# Add user library paths for all platforms
@@ -301,6 +319,7 @@ def detect_platform() -> dict:
if __name__ == "__main__":
# Quick test/diagnostic
import json
info = detect_platform()
print("Platform Information:")
print(json.dumps(info, indent=2))