chore: normalize all tracked files to LF line endings

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

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

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

188
.gitignore vendored
View File

@@ -1,94 +1,94 @@
# Node.js # Node.js
node_modules/ node_modules/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
dist/ dist/
.npm .npm
.eslintcache .eslintcache
# Python # Python
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
*$py.class *$py.class
*.so *.so
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
eggs/ eggs/
.eggs/ .eggs/
lib/ lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/
var/ var/
wheels/ wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST MANIFEST
.pytest_cache/ .pytest_cache/
.coverage .coverage
htmlcov/ htmlcov/
.tox/ .tox/
.hypothesis/ .hypothesis/
*.cover *.cover
.mypy_cache/ .mypy_cache/
.dmypy.json .dmypy.json
dmypy.json dmypy.json
# Virtual Environments # Virtual Environments
venv/ venv/
.venv/ .venv/
env/ env/
ENV/ ENV/
# IDEs # IDEs
.vscode/ .vscode/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
*~ *~
.DS_Store .DS_Store
# Logs # Logs
logs/ logs/
*.log *.log
~/.kicad-mcp/ ~/.kicad-mcp/
# Environment # Environment
.env .env
.env.local .env.local
.env.*.local .env.*.local
# KiCAD # KiCAD
*.kicad_pcb-bak *.kicad_pcb-bak
*.kicad_pcb.bak *.kicad_pcb.bak
*.kicad_sch-bak *.kicad_sch-bak
*.kicad_sch.bak *.kicad_sch.bak
*.kicad_pro-bak *.kicad_pro-bak
*.kicad_pro.bak *.kicad_pro.bak
*.kicad_prl *.kicad_prl
*-backups/ *-backups/
fp-info-cache fp-info-cache
# Testing # Testing
test_output/ test_output/
schematic_test_output/ schematic_test_output/
coverage.xml coverage.xml
.coverage.* .coverage.*
# Data & Databases # Data & Databases
data/ data/
*.db *.db
*.db-journal *.db-journal
# OS # OS
Thumbs.db Thumbs.db
Desktop.ini Desktop.ini
# Generated local config files (contain machine-specific paths) # Generated local config files (contain machine-specific paths)
windows-mcp-config.json windows-mcp-config.json
# Personal notes / local contributions (not for upstream) # Personal notes / local contributions (not for upstream)
myContribution/ myContribution/

View File

@@ -1,52 +1,52 @@
[pytest] [pytest]
# Pytest configuration for KiCAD MCP Server # Pytest configuration for KiCAD MCP Server
# Test discovery patterns # Test discovery patterns
python_files = test_*.py *_test.py python_files = test_*.py *_test.py
python_classes = Test* python_classes = Test*
python_functions = test_* python_functions = test_*
# Test paths # Test paths
testpaths = tests testpaths = tests
# Minimum Python version # Minimum Python version
minversion = 6.0 minversion = 6.0
# Additional options # Additional options
addopts = addopts =
-ra -ra
--strict-markers --strict-markers
--strict-config --strict-config
--showlocals --showlocals
--tb=short --tb=short
--cov=python --cov=python
--cov-report=term-missing --cov-report=term-missing
--cov-report=html --cov-report=html
--cov-report=xml --cov-report=xml
--cov-branch --cov-branch
# Markers for organizing tests # Markers for organizing tests
markers = markers =
unit: Unit tests (fast, no external dependencies) unit: Unit tests (fast, no external dependencies)
integration: Integration tests (requires KiCAD) integration: Integration tests (requires KiCAD)
slow: Slow-running tests slow: Slow-running tests
linux: Linux-specific tests linux: Linux-specific tests
windows: Windows-specific tests windows: Windows-specific tests
macos: macOS-specific tests macos: macOS-specific tests
# Ignore patterns # Ignore patterns
norecursedirs = .git .tox dist build *.egg node_modules norecursedirs = .git .tox dist build *.egg node_modules
# Coverage settings # Coverage settings
[coverage:run] [coverage:run]
source = python source = python
omit = omit =
*/tests/* */tests/*
*/test_*.py */test_*.py
*/__pycache__/* */__pycache__/*
*/site-packages/* */site-packages/*
[coverage:report] [coverage:report]
precision = 2 precision = 2
show_missing = True show_missing = True
skip_covered = False skip_covered = False

View File

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

View File

@@ -1,11 +1,11 @@
""" """
Board-related command implementations for KiCAD interface Board-related command implementations for KiCAD interface
This file is maintained for backward compatibility. This file is maintained for backward compatibility.
It imports and re-exports the BoardCommands class from the board package. It imports and re-exports the BoardCommands class from the board package.
""" """
from commands.board import BoardCommands from commands.board import BoardCommands
# Re-export the BoardCommands class for backward compatibility # Re-export the BoardCommands class for backward compatibility
__all__ = ["BoardCommands"] __all__ = ["BoardCommands"]

View File

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

View File

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

View File

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

View File

@@ -1,70 +1,70 @@
""" """
Board size command implementations for KiCAD interface Board size command implementations for KiCAD interface
""" """
import logging import logging
from typing import Any, Dict, Optional from typing import Any, Dict, Optional
import pcbnew import pcbnew
logger = logging.getLogger("kicad_interface") logger = logging.getLogger("kicad_interface")
class BoardSizeCommands: class BoardSizeCommands:
"""Handles board size operations""" """Handles board size operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None): def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance""" """Initialize with optional board instance"""
self.board = board self.board = board
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]: def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Set the size of the PCB board by creating edge cuts outline""" """Set the size of the PCB board by creating edge cuts outline"""
try: try:
if not self.board: if not self.board:
return { return {
"success": False, "success": False,
"message": "No board is loaded", "message": "No board is loaded",
"errorDetails": "Load or create a board first", "errorDetails": "Load or create a board first",
} }
width = params.get("width") width = params.get("width")
height = params.get("height") height = params.get("height")
unit = params.get("unit", "mm") unit = params.get("unit", "mm")
if width is None or height is None: if width is None or height is None:
return { return {
"success": False, "success": False,
"message": "Missing dimensions", "message": "Missing dimensions",
"errorDetails": "Both width and height are required", "errorDetails": "Both width and height are required",
} }
# Create board outline using BoardOutlineCommands # Create board outline using BoardOutlineCommands
# This properly creates edge cuts on Edge.Cuts layer # This properly creates edge cuts on Edge.Cuts layer
from commands.board.outline import BoardOutlineCommands from commands.board.outline import BoardOutlineCommands
outline_commands = BoardOutlineCommands(self.board) outline_commands = BoardOutlineCommands(self.board)
# Create rectangular outline centered at origin # Create rectangular outline centered at origin
result = outline_commands.add_board_outline( result = outline_commands.add_board_outline(
{ {
"shape": "rectangle", "shape": "rectangle",
"centerX": width / 2, # Center X "centerX": width / 2, # Center X
"centerY": height / 2, # Center Y "centerY": height / 2, # Center Y
"width": width, "width": width,
"height": height, "height": height,
"unit": unit, "unit": unit,
} }
) )
if result.get("success"): if result.get("success"):
return { return {
"success": True, "success": True,
"message": f"Created board outline: {width}x{height} {unit}", "message": f"Created board outline: {width}x{height} {unit}",
"size": {"width": width, "height": height, "unit": unit}, "size": {"width": width, "height": height, "unit": unit},
} }
else: else:
return result return result
except Exception as e: except Exception as e:
logger.error(f"Error setting board size: {str(e)}") logger.error(f"Error setting board size: {str(e)}")
return {"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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,29 @@
# KiCAD MCP Server - Development Dependencies # KiCAD MCP Server - Development Dependencies
# Testing, linting, and development tools # Testing, linting, and development tools
# Include production dependencies # Include production dependencies
-r requirements.txt -r requirements.txt
# Testing framework # Testing framework
pytest>=7.4.0 pytest>=7.4.0
pytest-cov>=4.1.0 pytest-cov>=4.1.0
pytest-asyncio>=0.21.0 pytest-asyncio>=0.21.0
pytest-mock>=3.11.0 pytest-mock>=3.11.0
# Code quality # Code quality
black>=23.7.0 black>=23.7.0
mypy>=1.5.0 mypy>=1.5.0
pylint>=2.17.0 pylint>=2.17.0
flake8>=6.1.0 flake8>=6.1.0
isort>=5.12.0 isort>=5.12.0
# Type stubs # Type stubs
types-requests>=2.31.0 types-requests>=2.31.0
types-Pillow>=10.0.0 types-Pillow>=10.0.0
# Pre-commit hooks # Pre-commit hooks
pre-commit>=3.3.0 pre-commit>=3.3.0
# Development utilities # Development utilities
ipython>=8.14.0 ipython>=8.14.0
ipdb>=0.13.13 ipdb>=0.13.13

View File

@@ -1,26 +1,26 @@
# KiCAD MCP Server - Python Dependencies # KiCAD MCP Server - Python Dependencies
# Production dependencies only # Production dependencies only
# KiCAD Python API (IPC - for future migration) # KiCAD Python API (IPC - for future migration)
# kicad-python>=0.5.0 # Uncomment when migrating to IPC API # kicad-python>=0.5.0 # Uncomment when migrating to IPC API
# Schematic manipulation # Schematic manipulation
kicad-skip>=0.1.0 kicad-skip>=0.1.0
# Image processing for board rendering # Image processing for board rendering
Pillow>=9.0.0 Pillow>=9.0.0
# SVG rendering # SVG rendering
cairosvg>=2.7.0 cairosvg>=2.7.0
# Colored logging # Colored logging
colorlog>=6.7.0 colorlog>=6.7.0
# Data validation (for future features) # Data validation (for future features)
pydantic>=2.5.0 pydantic>=2.5.0
# HTTP requests (for JLCPCB/Digikey APIs - future) # HTTP requests (for JLCPCB/Digikey APIs - future)
requests>=2.32.5 requests>=2.32.5
# Environment variable management # Environment variable management
python-dotenv>=1.0.0 python-dotenv>=1.0.0

View File

@@ -1,165 +1,165 @@
#!/bin/bash #!/bin/bash
# KiCAD MCP Server - Linux Installation Script # KiCAD MCP Server - Linux Installation Script
# Supports Ubuntu/Debian-based distributions # Supports Ubuntu/Debian-based distributions
set -e # Exit on error set -e # Exit on error
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
YELLOW='\033[1;33m' YELLOW='\033[1;33m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# Print colored messages # Print colored messages
print_info() { echo -e "${BLUE}${NC} $1"; } print_info() { echo -e "${BLUE}${NC} $1"; }
print_success() { echo -e "${GREEN}${NC} $1"; } print_success() { echo -e "${GREEN}${NC} $1"; }
print_warning() { echo -e "${YELLOW}${NC} $1"; } print_warning() { echo -e "${YELLOW}${NC} $1"; }
print_error() { echo -e "${RED}${NC} $1"; } print_error() { echo -e "${RED}${NC} $1"; }
# Header # Header
echo "" echo ""
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ KiCAD MCP Server - Linux Installation ║" echo "║ KiCAD MCP Server - Linux Installation ║"
echo "║ ║" echo "║ ║"
echo "║ This script will install: ║" echo "║ This script will install: ║"
echo "║ - KiCAD 9.0 ║" echo "║ - KiCAD 9.0 ║"
echo "║ - Node.js 20.x ║" echo "║ - Node.js 20.x ║"
echo "║ - Python dependencies ║" echo "║ - Python dependencies ║"
echo "║ - Build the TypeScript server ║" echo "║ - Build the TypeScript server ║"
echo "╚═══════════════════════════════════════════════════════════════╝" echo "╚═══════════════════════════════════════════════════════════════╝"
echo "" echo ""
# Check if running on Linux # Check if running on Linux
if [[ "$OSTYPE" != "linux-gnu"* ]]; then if [[ "$OSTYPE" != "linux-gnu"* ]]; then
print_error "This script is for Linux only. Detected: $OSTYPE" print_error "This script is for Linux only. Detected: $OSTYPE"
exit 1 exit 1
fi fi
# Check for Ubuntu/Debian # Check for Ubuntu/Debian
if ! command -v apt-get &> /dev/null; then if ! command -v apt-get &> /dev/null; then
print_warning "This script is optimized for Ubuntu/Debian" print_warning "This script is optimized for Ubuntu/Debian"
print_warning "For other distributions, please install manually" print_warning "For other distributions, please install manually"
read -p "Continue anyway? (y/N) " -n 1 -r read -p "Continue anyway? (y/N) " -n 1 -r
echo echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1 exit 1
fi fi
fi fi
# Function to check if command exists # Function to check if command exists
command_exists() { command_exists() {
command -v "$1" &> /dev/null command -v "$1" &> /dev/null
} }
# Step 1: Install KiCAD 9.0 # Step 1: Install KiCAD 9.0
print_info "Step 1/5: Installing KiCAD 9.0..." print_info "Step 1/5: Installing KiCAD 9.0..."
if command_exists kicad; then if command_exists kicad; then
KICAD_VERSION=$(kicad-cli version 2>/dev/null | head -n 1 || echo "unknown") KICAD_VERSION=$(kicad-cli version 2>/dev/null | head -n 1 || echo "unknown")
print_success "KiCAD is already installed: $KICAD_VERSION" print_success "KiCAD is already installed: $KICAD_VERSION"
else else
print_info "Adding KiCAD PPA and installing..." print_info "Adding KiCAD PPA and installing..."
sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases
sudo apt-get update sudo apt-get update
sudo apt-get install -y kicad kicad-libraries sudo apt-get install -y kicad kicad-libraries
print_success "KiCAD 9.0 installed" print_success "KiCAD 9.0 installed"
fi fi
# Verify KiCAD Python module # Verify KiCAD Python module
print_info "Verifying KiCAD Python module..." print_info "Verifying KiCAD Python module..."
if python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" 2>/dev/null; then if python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" 2>/dev/null; then
PCBNEW_VERSION=$(python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())") PCBNEW_VERSION=$(python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())")
print_success "KiCAD Python module (pcbnew) found: $PCBNEW_VERSION" print_success "KiCAD Python module (pcbnew) found: $PCBNEW_VERSION"
else else
print_warning "KiCAD Python module (pcbnew) not found in default Python path" print_warning "KiCAD Python module (pcbnew) not found in default Python path"
print_warning "You may need to set PYTHONPATH manually" print_warning "You may need to set PYTHONPATH manually"
fi fi
# Step 2: Install Node.js # Step 2: Install Node.js
print_info "Step 2/5: Installing Node.js 20.x..." print_info "Step 2/5: Installing Node.js 20.x..."
if command_exists node; then if command_exists node; then
NODE_VERSION=$(node --version) NODE_VERSION=$(node --version)
MAJOR_VERSION=$(echo $NODE_VERSION | cut -d'.' -f1 | sed 's/v//') MAJOR_VERSION=$(echo $NODE_VERSION | cut -d'.' -f1 | sed 's/v//')
if [ "$MAJOR_VERSION" -ge 18 ]; then if [ "$MAJOR_VERSION" -ge 18 ]; then
print_success "Node.js is already installed: $NODE_VERSION" print_success "Node.js is already installed: $NODE_VERSION"
else else
print_warning "Node.js version is too old: $NODE_VERSION (need 18+)" print_warning "Node.js version is too old: $NODE_VERSION (need 18+)"
print_info "Installing Node.js 20.x..." print_info "Installing Node.js 20.x..."
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs sudo apt-get install -y nodejs
print_success "Node.js updated" print_success "Node.js updated"
fi fi
else else
print_info "Installing Node.js 20.x..." print_info "Installing Node.js 20.x..."
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs sudo apt-get install -y nodejs
print_success "Node.js installed: $(node --version)" print_success "Node.js installed: $(node --version)"
fi fi
# Step 3: Install Python dependencies # Step 3: Install Python dependencies
print_info "Step 3/5: Installing Python dependencies..." print_info "Step 3/5: Installing Python dependencies..."
if [ -f "requirements.txt" ]; then if [ -f "requirements.txt" ]; then
pip3 install --user -r requirements.txt pip3 install --user -r requirements.txt
print_success "Python dependencies installed" print_success "Python dependencies installed"
else else
print_warning "requirements.txt not found - skipping Python dependencies" print_warning "requirements.txt not found - skipping Python dependencies"
fi fi
# Step 4: Install Node.js dependencies # Step 4: Install Node.js dependencies
print_info "Step 4/5: Installing Node.js dependencies..." print_info "Step 4/5: Installing Node.js dependencies..."
if [ -f "package.json" ]; then if [ -f "package.json" ]; then
npm install npm install
print_success "Node.js dependencies installed" print_success "Node.js dependencies installed"
else else
print_error "package.json not found! Are you in the correct directory?" print_error "package.json not found! Are you in the correct directory?"
exit 1 exit 1
fi fi
# Step 5: Build TypeScript # Step 5: Build TypeScript
print_info "Step 5/5: Building TypeScript..." print_info "Step 5/5: Building TypeScript..."
npm run build npm run build
print_success "TypeScript build complete" print_success "TypeScript build complete"
# Final checks # Final checks
echo "" echo ""
print_info "Running final checks..." print_info "Running final checks..."
# Check if dist directory was created # Check if dist directory was created
if [ -d "dist" ]; then if [ -d "dist" ]; then
print_success "dist/ directory created" print_success "dist/ directory created"
else else
print_error "dist/ directory not found - build may have failed" print_error "dist/ directory not found - build may have failed"
exit 1 exit 1
fi fi
# Test platform helper # Test platform helper
print_info "Testing platform detection..." print_info "Testing platform detection..."
if python3 python/utils/platform_helper.py > /dev/null 2>&1; then if python3 python/utils/platform_helper.py > /dev/null 2>&1; then
print_success "Platform helper working" print_success "Platform helper working"
else else
print_warning "Platform helper test failed" print_warning "Platform helper test failed"
fi fi
# Installation complete # Installation complete
echo "" echo ""
echo "╔═══════════════════════════════════════════════════════════════╗" echo "╔═══════════════════════════════════════════════════════════════╗"
echo "║ 🎉 Installation Complete! 🎉 ║" echo "║ 🎉 Installation Complete! 🎉 ║"
echo "╚═══════════════════════════════════════════════════════════════╝" echo "╚═══════════════════════════════════════════════════════════════╝"
echo "" echo ""
print_success "KiCAD MCP Server is ready to use!" print_success "KiCAD MCP Server is ready to use!"
echo "" echo ""
print_info "Next steps:" print_info "Next steps:"
echo " 1. Configure Cline in VSCode with the path to dist/index.js" echo " 1. Configure Cline in VSCode with the path to dist/index.js"
echo " 2. Set PYTHONPATH in Cline config (see README.md)" echo " 2. Set PYTHONPATH in Cline config (see README.md)"
echo " 3. Restart VSCode" echo " 3. Restart VSCode"
echo " 4. Test with: 'Create a new KiCAD project named TestProject'" echo " 4. Test with: 'Create a new KiCAD project named TestProject'"
echo "" echo ""
print_info "For detailed configuration, see:" print_info "For detailed configuration, see:"
echo " - README.md (Linux section)" echo " - README.md (Linux section)"
echo " - config/linux-config.example.json" echo " - config/linux-config.example.json"
echo "" echo ""
print_info "To run tests:" print_info "To run tests:"
echo " pytest tests/" echo " pytest tests/"
echo "" echo ""
print_info "Need help? Check docs/LINUX_COMPATIBILITY_AUDIT.md" print_info "Need help? Check docs/LINUX_COMPATIBILITY_AUDIT.md"
echo "" echo ""

View File

@@ -1,410 +1,410 @@
<# <#
.SYNOPSIS .SYNOPSIS
KiCAD MCP Server - Windows Setup and Configuration Script KiCAD MCP Server - Windows Setup and Configuration Script
.DESCRIPTION .DESCRIPTION
This script automates the setup of KiCAD MCP Server on Windows by: This script automates the setup of KiCAD MCP Server on Windows by:
- Detecting KiCAD installation and version - Detecting KiCAD installation and version
- Verifying Python and Node.js installations - Verifying Python and Node.js installations
- Testing KiCAD Python module (pcbnew) - Testing KiCAD Python module (pcbnew)
- Installing required Python dependencies - Installing required Python dependencies
- Building the TypeScript project - Building the TypeScript project
- Generating Claude Desktop configuration - Generating Claude Desktop configuration
- Running diagnostic tests - Running diagnostic tests
.PARAMETER SkipBuild .PARAMETER SkipBuild
Skip the npm build step (useful if already built) Skip the npm build step (useful if already built)
.PARAMETER ClientType .PARAMETER ClientType
Type of MCP client to configure: 'claude-desktop', 'cline', or 'manual' Type of MCP client to configure: 'claude-desktop', 'cline', or 'manual'
Default: 'claude-desktop' Default: 'claude-desktop'
.EXAMPLE .EXAMPLE
.\setup-windows.ps1 .\setup-windows.ps1
Run the full setup with default options Run the full setup with default options
.EXAMPLE .EXAMPLE
.\setup-windows.ps1 -ClientType cline .\setup-windows.ps1 -ClientType cline
Setup for Cline VSCode extension Setup for Cline VSCode extension
.EXAMPLE .EXAMPLE
.\setup-windows.ps1 -SkipBuild .\setup-windows.ps1 -SkipBuild
Run setup without rebuilding the project Run setup without rebuilding the project
#> #>
param( param(
[switch]$SkipBuild, [switch]$SkipBuild,
[ValidateSet('claude-desktop', 'cline', 'manual')] [ValidateSet('claude-desktop', 'cline', 'manual')]
[string]$ClientType = 'claude-desktop' [string]$ClientType = 'claude-desktop'
) )
# Color output helpers # Color output helpers
function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green } function Write-Success { param([string]$Message) Write-Host "[OK] $Message" -ForegroundColor Green }
function Write-Error-Custom { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red } function Write-Error-Custom { param([string]$Message) Write-Host "[ERROR] $Message" -ForegroundColor Red }
function Write-Warning-Custom { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow } function Write-Warning-Custom { param([string]$Message) Write-Host "[WARN] $Message" -ForegroundColor Yellow }
function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan } function Write-Info { param([string]$Message) Write-Host "[INFO] $Message" -ForegroundColor Cyan }
function Write-Step { param([string]$Message) Write-Host "`n=== $Message ===" -ForegroundColor Magenta } function Write-Step { param([string]$Message) Write-Host "`n=== $Message ===" -ForegroundColor Magenta }
Write-Host @" Write-Host @"
KiCAD MCP Server - Windows Setup Script KiCAD MCP Server - Windows Setup Script
This script will configure KiCAD MCP for Windows This script will configure KiCAD MCP for Windows
"@ -ForegroundColor Cyan "@ -ForegroundColor Cyan
# Store results for final report # Store results for final report
$script:Results = @{ $script:Results = @{
KiCADFound = $false KiCADFound = $false
KiCADVersion = "" KiCADVersion = ""
KiCADPythonPath = "" KiCADPythonPath = ""
PythonFound = $false PythonFound = $false
PythonVersion = "" PythonVersion = ""
NodeFound = $false NodeFound = $false
NodeVersion = "" NodeVersion = ""
PcbnewImport = $false PcbnewImport = $false
DependenciesInstalled = $false DependenciesInstalled = $false
ProjectBuilt = $false ProjectBuilt = $false
ConfigGenerated = $false ConfigGenerated = $false
Errors = @() Errors = @()
} }
# Get script directory (project root) # Get script directory (project root)
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path $ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
Write-Step "Step 1: Detecting KiCAD Installation" Write-Step "Step 1: Detecting KiCAD Installation"
# Function to find KiCAD installation # Function to find KiCAD installation
function Find-KiCAD { function Find-KiCAD {
$possiblePaths = @( $possiblePaths = @(
"C:\Program Files\KiCad", "C:\Program Files\KiCad",
"C:\Program Files (x86)\KiCad" "C:\Program Files (x86)\KiCad"
"$env:USERPROFILE\AppData\Local\Programs\KiCad" "$env:USERPROFILE\AppData\Local\Programs\KiCad"
) )
$versions = @("9.0", "9.1", "10.0", "8.0") $versions = @("9.0", "9.1", "10.0", "8.0")
foreach ($basePath in $possiblePaths) { foreach ($basePath in $possiblePaths) {
foreach ($version in $versions) { foreach ($version in $versions) {
$kicadPath = Join-Path $basePath $version $kicadPath = Join-Path $basePath $version
$pythonExe = Join-Path $kicadPath "bin\python.exe" $pythonExe = Join-Path $kicadPath "bin\python.exe"
$pythonLib = Join-Path $kicadPath "lib\python3\dist-packages" $pythonLib = Join-Path $kicadPath "lib\python3\dist-packages"
if (Test-Path $pythonExe) { if (Test-Path $pythonExe) {
Write-Success "Found KiCAD $version at: $kicadPath" Write-Success "Found KiCAD $version at: $kicadPath"
return @{ return @{
Path = $kicadPath Path = $kicadPath
Version = $version Version = $version
PythonExe = $pythonExe PythonExe = $pythonExe
PythonLib = $pythonLib PythonLib = $pythonLib
} }
} }
} }
} }
return $null return $null
} }
$kicad = Find-KiCAD $kicad = Find-KiCAD
if ($kicad) { if ($kicad) {
$script:Results.KiCADFound = $true $script:Results.KiCADFound = $true
$script:Results.KiCADVersion = $kicad.Version $script:Results.KiCADVersion = $kicad.Version
$script:Results.KiCADPythonPath = $kicad.PythonLib $script:Results.KiCADPythonPath = $kicad.PythonLib
Write-Info "KiCAD Version: $($kicad.Version)" Write-Info "KiCAD Version: $($kicad.Version)"
Write-Info "Python Path: $($kicad.PythonLib)" Write-Info "Python Path: $($kicad.PythonLib)"
} else { } else {
Write-Error-Custom "KiCAD not found in standard locations" Write-Error-Custom "KiCAD not found in standard locations"
Write-Warning-Custom "Checked: C:\Program Files, C:\Program Files (x86), and $env:USERPROFILE\AppData\Local\Programs" Write-Warning-Custom "Checked: C:\Program Files, C:\Program Files (x86), and $env:USERPROFILE\AppData\Local\Programs"
Write-Warning-Custom "Please install KiCAD 9.0+ from https://www.kicad.org/download/windows/" Write-Warning-Custom "Please install KiCAD 9.0+ from https://www.kicad.org/download/windows/"
$script:Results.Errors += "KiCAD not found" $script:Results.Errors += "KiCAD not found"
} }
Write-Step "Step 2: Checking Node.js Installation" Write-Step "Step 2: Checking Node.js Installation"
try { try {
$nodeVersion = node --version 2>$null $nodeVersion = node --version 2>$null
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
Write-Success "Node.js found: $nodeVersion" Write-Success "Node.js found: $nodeVersion"
$script:Results.NodeFound = $true $script:Results.NodeFound = $true
$script:Results.NodeVersion = $nodeVersion $script:Results.NodeVersion = $nodeVersion
# Check if version is 18+ # Check if version is 18+
$versionNumber = [int]($nodeVersion -replace 'v(\d+)\..*', '$1') $versionNumber = [int]($nodeVersion -replace 'v(\d+)\..*', '$1')
if ($versionNumber -lt 18) { if ($versionNumber -lt 18) {
Write-Warning-Custom "Node.js version 18+ is recommended (you have $nodeVersion)" Write-Warning-Custom "Node.js version 18+ is recommended (you have $nodeVersion)"
} }
} }
} catch { } catch {
Write-Error-Custom "Node.js not found" Write-Error-Custom "Node.js not found"
Write-Warning-Custom "Please install Node.js 18+ from https://nodejs.org/" Write-Warning-Custom "Please install Node.js 18+ from https://nodejs.org/"
$script:Results.Errors += "Node.js not found" $script:Results.Errors += "Node.js not found"
} }
Write-Step "Step 3: Testing KiCAD Python Module" Write-Step "Step 3: Testing KiCAD Python Module"
if ($kicad) { if ($kicad) {
Write-Info "Testing pcbnew module import..." Write-Info "Testing pcbnew module import..."
$testScript = "import sys; import pcbnew; print(f'SUCCESS:{pcbnew.GetBuildVersion()}')" $testScript = "import sys; import pcbnew; print(f'SUCCESS:{pcbnew.GetBuildVersion()}')"
$result = & $kicad.PythonExe -c $testScript 2>&1 $result = & $kicad.PythonExe -c $testScript 2>&1
if ($result -match "SUCCESS:(.+)") { if ($result -match "SUCCESS:(.+)") {
$pcbnewVersion = $matches[1] $pcbnewVersion = $matches[1]
Write-Success "pcbnew module imported successfully: $pcbnewVersion" Write-Success "pcbnew module imported successfully: $pcbnewVersion"
$script:Results.PcbnewImport = $true $script:Results.PcbnewImport = $true
} else { } else {
Write-Error-Custom "Failed to import pcbnew module" Write-Error-Custom "Failed to import pcbnew module"
Write-Warning-Custom "Error: $result" Write-Warning-Custom "Error: $result"
Write-Info "This usually means KiCAD was not installed with Python support" Write-Info "This usually means KiCAD was not installed with Python support"
$script:Results.Errors += "pcbnew import failed: $result" $script:Results.Errors += "pcbnew import failed: $result"
} }
} else { } else {
Write-Warning-Custom "Skipping pcbnew test (KiCAD not found)" Write-Warning-Custom "Skipping pcbnew test (KiCAD not found)"
} }
Write-Step "Step 4: Checking Python Installation" Write-Step "Step 4: Checking Python Installation"
try { try {
$pythonVersion = python --version 2>&1 $pythonVersion = python --version 2>&1
if ($pythonVersion -match "Python (\d+\.\d+\.\d+)") { if ($pythonVersion -match "Python (\d+\.\d+\.\d+)") {
Write-Success "System Python found: $pythonVersion" Write-Success "System Python found: $pythonVersion"
$script:Results.PythonFound = $true $script:Results.PythonFound = $true
$script:Results.PythonVersion = $pythonVersion $script:Results.PythonVersion = $pythonVersion
} }
} catch { } catch {
Write-Warning-Custom "System Python not found (using KiCAD's Python)" Write-Warning-Custom "System Python not found (using KiCAD's Python)"
} }
Write-Step "Step 5: Installing Node.js Dependencies" Write-Step "Step 5: Installing Node.js Dependencies"
if ($script:Results.NodeFound) { if ($script:Results.NodeFound) {
Write-Info "Running npm install..." Write-Info "Running npm install..."
Push-Location $ProjectRoot Push-Location $ProjectRoot
try { try {
npm install 2>&1 | Out-Null npm install 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
Write-Success "Node.js dependencies installed" Write-Success "Node.js dependencies installed"
} else { } else {
Write-Error-Custom "npm install failed" Write-Error-Custom "npm install failed"
$script:Results.Errors += "npm install failed" $script:Results.Errors += "npm install failed"
} }
} finally { } finally {
Pop-Location Pop-Location
} }
} else { } else {
Write-Warning-Custom "Skipping npm install (Node.js not found)" Write-Warning-Custom "Skipping npm install (Node.js not found)"
} }
Write-Step "Step 6: Installing Python Dependencies" Write-Step "Step 6: Installing Python Dependencies"
if ($kicad) { if ($kicad) {
Write-Info "Installing Python packages..." Write-Info "Installing Python packages..."
Push-Location $ProjectRoot Push-Location $ProjectRoot
try { try {
$requirementsFile = Join-Path $ProjectRoot "requirements.txt" $requirementsFile = Join-Path $ProjectRoot "requirements.txt"
if (Test-Path $requirementsFile) { if (Test-Path $requirementsFile) {
& $kicad.PythonExe -m pip install -r $requirementsFile 2>&1 | Out-Null & $kicad.PythonExe -m pip install -r $requirementsFile 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
Write-Success "Python dependencies installed" Write-Success "Python dependencies installed"
$script:Results.DependenciesInstalled = $true $script:Results.DependenciesInstalled = $true
} else { } else {
Write-Warning-Custom "Some Python packages may have failed to install" Write-Warning-Custom "Some Python packages may have failed to install"
} }
} else { } else {
Write-Warning-Custom "requirements.txt not found" Write-Warning-Custom "requirements.txt not found"
} }
} finally { } finally {
Pop-Location Pop-Location
} }
} else { } else {
Write-Warning-Custom "Skipping Python dependencies (KiCAD Python not found)" Write-Warning-Custom "Skipping Python dependencies (KiCAD Python not found)"
} }
Write-Step "Step 7: Building TypeScript Project" Write-Step "Step 7: Building TypeScript Project"
if (-not $SkipBuild -and $script:Results.NodeFound) { if (-not $SkipBuild -and $script:Results.NodeFound) {
Write-Info "Running npm run build..." Write-Info "Running npm run build..."
Push-Location $ProjectRoot Push-Location $ProjectRoot
try { try {
npm run build 2>&1 | Out-Null npm run build 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { if ($LASTEXITCODE -eq 0) {
$distPath = Join-Path $ProjectRoot "dist\index.js" $distPath = Join-Path $ProjectRoot "dist\index.js"
if (Test-Path $distPath) { if (Test-Path $distPath) {
Write-Success "Project built successfully" Write-Success "Project built successfully"
$script:Results.ProjectBuilt = $true $script:Results.ProjectBuilt = $true
} else { } else {
Write-Error-Custom "Build completed but dist/index.js not found" Write-Error-Custom "Build completed but dist/index.js not found"
$script:Results.Errors += "Build output missing" $script:Results.Errors += "Build output missing"
} }
} else { } else {
Write-Error-Custom "Build failed" Write-Error-Custom "Build failed"
$script:Results.Errors += "TypeScript build failed" $script:Results.Errors += "TypeScript build failed"
} }
} finally { } finally {
Pop-Location Pop-Location
} }
} else { } else {
if ($SkipBuild) { if ($SkipBuild) {
Write-Info "Skipping build (--SkipBuild specified)" Write-Info "Skipping build (--SkipBuild specified)"
} else { } else {
Write-Warning-Custom "Skipping build (Node.js not found)" Write-Warning-Custom "Skipping build (Node.js not found)"
} }
} }
Write-Step "Step 8: Generating Configuration" Write-Step "Step 8: Generating Configuration"
if ($kicad -and $script:Results.ProjectBuilt) { if ($kicad -and $script:Results.ProjectBuilt) {
$distPath = Join-Path $ProjectRoot "dist\index.js" $distPath = Join-Path $ProjectRoot "dist\index.js"
$distPathEscaped = $distPath -replace '\\', '\\' $distPathEscaped = $distPath -replace '\\', '\\'
$pythonLibEscaped = $kicad.PythonLib -replace '\\', '\\' $pythonLibEscaped = $kicad.PythonLib -replace '\\', '\\'
$config = @" $config = @"
{ {
"mcpServers": { "mcpServers": {
"kicad": { "kicad": {
"command": "node", "command": "node",
"args": ["$distPathEscaped"], "args": ["$distPathEscaped"],
"env": { "env": {
"PYTHONPATH": "$pythonLibEscaped", "PYTHONPATH": "$pythonLibEscaped",
"NODE_ENV": "production", "NODE_ENV": "production",
"LOG_LEVEL": "info" "LOG_LEVEL": "info"
} }
} }
} }
} }
"@ "@
$configPath = Join-Path $ProjectRoot "windows-mcp-config.json" $configPath = Join-Path $ProjectRoot "windows-mcp-config.json"
$config | Out-File -FilePath $configPath -Encoding UTF8 $config | Out-File -FilePath $configPath -Encoding UTF8
Write-Success "Configuration generated: $configPath" Write-Success "Configuration generated: $configPath"
$script:Results.ConfigGenerated = $true $script:Results.ConfigGenerated = $true
Write-Info "`nConfiguration Preview:" Write-Info "`nConfiguration Preview:"
Write-Host $config -ForegroundColor Gray Write-Host $config -ForegroundColor Gray
# Provide instructions based on client type # Provide instructions based on client type
Write-Info "`nTo use this configuration:" Write-Info "`nTo use this configuration:"
if ($ClientType -eq 'claude-desktop') { if ($ClientType -eq 'claude-desktop') {
$claudeConfigPath = "$env:APPDATA\Claude\claude_desktop_config.json" $claudeConfigPath = "$env:APPDATA\Claude\claude_desktop_config.json"
Write-Host "`n1. Open Claude Desktop configuration:" -ForegroundColor Yellow Write-Host "`n1. Open Claude Desktop configuration:" -ForegroundColor Yellow
Write-Host " $claudeConfigPath" -ForegroundColor White Write-Host " $claudeConfigPath" -ForegroundColor White
Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow
Write-Host " $configPath" -ForegroundColor White Write-Host " $configPath" -ForegroundColor White
Write-Host "`n3. Restart Claude Desktop" -ForegroundColor Yellow Write-Host "`n3. Restart Claude Desktop" -ForegroundColor Yellow
} elseif ($ClientType -eq 'cline') { } elseif ($ClientType -eq 'cline') {
$clineConfigPath = "$env:APPDATA\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json" $clineConfigPath = "$env:APPDATA\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json"
Write-Host "`n1. Open Cline configuration:" -ForegroundColor Yellow Write-Host "`n1. Open Cline configuration:" -ForegroundColor Yellow
Write-Host " $clineConfigPath" -ForegroundColor White Write-Host " $clineConfigPath" -ForegroundColor White
Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow
Write-Host " $configPath" -ForegroundColor White Write-Host " $configPath" -ForegroundColor White
Write-Host "`n3. Restart VSCode" -ForegroundColor Yellow Write-Host "`n3. Restart VSCode" -ForegroundColor Yellow
} else { } else {
Write-Host "`n1. Configuration saved to:" -ForegroundColor Yellow Write-Host "`n1. Configuration saved to:" -ForegroundColor Yellow
Write-Host " $configPath" -ForegroundColor White Write-Host " $configPath" -ForegroundColor White
Write-Host "`n2. Copy to your MCP client configuration" -ForegroundColor Yellow Write-Host "`n2. Copy to your MCP client configuration" -ForegroundColor Yellow
} }
} else { } else {
Write-Warning-Custom "Skipping configuration generation (prerequisites not met)" Write-Warning-Custom "Skipping configuration generation (prerequisites not met)"
} }
Write-Step "Step 9: Running Diagnostic Test" Write-Step "Step 9: Running Diagnostic Test"
if ($kicad -and $script:Results.ProjectBuilt) { if ($kicad -and $script:Results.ProjectBuilt) {
Write-Info "Testing server startup..." Write-Info "Testing server startup..."
$env:PYTHONPATH = $kicad.PythonLib $env:PYTHONPATH = $kicad.PythonLib
$distPath = Join-Path $ProjectRoot "dist\index.js" $distPath = Join-Path $ProjectRoot "dist\index.js"
# Start the server process # Start the server process
$process = Start-Process -FilePath "node" ` $process = Start-Process -FilePath "node" `
-ArgumentList $distPath ` -ArgumentList $distPath `
-NoNewWindow ` -NoNewWindow `
-PassThru ` -PassThru `
-RedirectStandardError (Join-Path $env:TEMP "kicad-mcp-test-error.txt") ` -RedirectStandardError (Join-Path $env:TEMP "kicad-mcp-test-error.txt") `
-RedirectStandardOutput (Join-Path $env:TEMP "kicad-mcp-test-output.txt") -RedirectStandardOutput (Join-Path $env:TEMP "kicad-mcp-test-output.txt")
# Wait a moment for startup # Wait a moment for startup
Start-Sleep -Seconds 2 Start-Sleep -Seconds 2
if (-not $process.HasExited) { if (-not $process.HasExited) {
Write-Success "Server started successfully (PID: $($process.Id))" Write-Success "Server started successfully (PID: $($process.Id))"
Write-Info "Stopping test server..." Write-Info "Stopping test server..."
Stop-Process -Id $process.Id -Force Stop-Process -Id $process.Id -Force
} else { } else {
Write-Error-Custom "Server exited immediately (exit code: $($process.ExitCode))" Write-Error-Custom "Server exited immediately (exit code: $($process.ExitCode))"
$errorLog = Join-Path $env:TEMP "kicad-mcp-test-error.txt" $errorLog = Join-Path $env:TEMP "kicad-mcp-test-error.txt"
if (Test-Path $errorLog) { if (Test-Path $errorLog) {
$errorContent = Get-Content $errorLog -Raw $errorContent = Get-Content $errorLog -Raw
if ($errorContent) { if ($errorContent) {
Write-Warning-Custom "Error output:" Write-Warning-Custom "Error output:"
Write-Host $errorContent -ForegroundColor Red Write-Host $errorContent -ForegroundColor Red
} }
} }
$script:Results.Errors += "Server startup test failed" $script:Results.Errors += "Server startup test failed"
} }
} else { } else {
Write-Warning-Custom "Skipping diagnostic test (prerequisites not met)" Write-Warning-Custom "Skipping diagnostic test (prerequisites not met)"
} }
# Final Report # Final Report
Write-Step "Setup Summary" Write-Step "Setup Summary"
Write-Host "`nComponent Status:" -ForegroundColor Cyan Write-Host "`nComponent Status:" -ForegroundColor Cyan
Write-Host " KiCAD Installation: $(if ($script:Results.KiCADFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.KiCADFound) { 'Green' } else { 'Red' }) Write-Host " KiCAD Installation: $(if ($script:Results.KiCADFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.KiCADFound) { 'Green' } else { 'Red' })
if ($script:Results.KiCADVersion) { if ($script:Results.KiCADVersion) {
Write-Host " Version: $($script:Results.KiCADVersion)" -ForegroundColor Gray Write-Host " Version: $($script:Results.KiCADVersion)" -ForegroundColor Gray
} }
Write-Host " pcbnew Module: $(if ($script:Results.PcbnewImport) { '[OK] Working' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.PcbnewImport) { 'Green' } else { 'Red' }) Write-Host " pcbnew Module: $(if ($script:Results.PcbnewImport) { '[OK] Working' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.PcbnewImport) { 'Green' } else { 'Red' })
Write-Host " Node.js: $(if ($script:Results.NodeFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.NodeFound) { 'Green' } else { 'Red' }) Write-Host " Node.js: $(if ($script:Results.NodeFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.NodeFound) { 'Green' } else { 'Red' })
if ($script:Results.NodeVersion) { if ($script:Results.NodeVersion) {
Write-Host " Version: $($script:Results.NodeVersion)" -ForegroundColor Gray Write-Host " Version: $($script:Results.NodeVersion)" -ForegroundColor Gray
} }
Write-Host " Python Dependencies: $(if ($script:Results.DependenciesInstalled) { '[OK] Installed' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.DependenciesInstalled) { 'Green' } else { 'Red' }) Write-Host " Python Dependencies: $(if ($script:Results.DependenciesInstalled) { '[OK] Installed' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.DependenciesInstalled) { 'Green' } else { 'Red' })
Write-Host " Project Build: $(if ($script:Results.ProjectBuilt) { '[OK] Success' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.ProjectBuilt) { 'Green' } else { 'Red' }) Write-Host " Project Build: $(if ($script:Results.ProjectBuilt) { '[OK] Success' } else { '[ERROR] Failed' })" -ForegroundColor $(if ($script:Results.ProjectBuilt) { 'Green' } else { 'Red' })
Write-Host " Configuration: $(if ($script:Results.ConfigGenerated) { '[OK] Generated' } else { '[ERROR] Not Generated' })" -ForegroundColor $(if ($script:Results.ConfigGenerated) { 'Green' } else { 'Red' }) Write-Host " Configuration: $(if ($script:Results.ConfigGenerated) { '[OK] Generated' } else { '[ERROR] Not Generated' })" -ForegroundColor $(if ($script:Results.ConfigGenerated) { 'Green' } else { 'Red' })
if ($script:Results.Errors.Count -gt 0) { if ($script:Results.Errors.Count -gt 0) {
Write-Host "`nErrors Encountered:" -ForegroundColor Red Write-Host "`nErrors Encountered:" -ForegroundColor Red
foreach ($error in $script:Results.Errors) { foreach ($error in $script:Results.Errors) {
Write-Host "$error" -ForegroundColor Red Write-Host "$error" -ForegroundColor Red
} }
} }
# Check for log file # Check for log file
$logPath = "$env:USERPROFILE\.kicad-mcp\logs\kicad_interface.log" $logPath = "$env:USERPROFILE\.kicad-mcp\logs\kicad_interface.log"
if (Test-Path $logPath) { if (Test-Path $logPath) {
Write-Host "`nLog file location:" -ForegroundColor Cyan Write-Host "`nLog file location:" -ForegroundColor Cyan
Write-Host " $logPath" -ForegroundColor Gray Write-Host " $logPath" -ForegroundColor Gray
} }
# Success criteria # Success criteria
$isSuccess = $script:Results.KiCADFound -and $isSuccess = $script:Results.KiCADFound -and
$script:Results.PcbnewImport -and $script:Results.PcbnewImport -and
$script:Results.NodeFound -and $script:Results.NodeFound -and
$script:Results.ProjectBuilt $script:Results.ProjectBuilt
if ($isSuccess) { if ($isSuccess) {
Write-Host "`n============================================================" -ForegroundColor Green Write-Host "`n============================================================" -ForegroundColor Green
Write-Host " [OK] Setup completed successfully!" -ForegroundColor Green Write-Host " [OK] Setup completed successfully!" -ForegroundColor Green
Write-Host "" -ForegroundColor Green Write-Host "" -ForegroundColor Green
Write-Host " Next steps:" -ForegroundColor Green Write-Host " Next steps:" -ForegroundColor Green
Write-Host " 1. Copy the generated config to your MCP client" -ForegroundColor Green Write-Host " 1. Copy the generated config to your MCP client" -ForegroundColor Green
Write-Host " 2. Restart your MCP client (Claude Desktop/Cline)" -ForegroundColor Green Write-Host " 2. Restart your MCP client (Claude Desktop/Cline)" -ForegroundColor Green
Write-Host " 3. Try: 'Create a new KiCAD project'" -ForegroundColor Green Write-Host " 3. Try: 'Create a new KiCAD project'" -ForegroundColor Green
Write-Host "============================================================" -ForegroundColor Green Write-Host "============================================================" -ForegroundColor Green
} else { } else {
Write-Host "`n============================================================" -ForegroundColor Red Write-Host "`n============================================================" -ForegroundColor Red
Write-Host " [ERROR] Setup incomplete - issues detected" -ForegroundColor Red Write-Host " [ERROR] Setup incomplete - issues detected" -ForegroundColor Red
Write-Host "" -ForegroundColor Red Write-Host "" -ForegroundColor Red
Write-Host " Please resolve the errors above and run again" -ForegroundColor Red Write-Host " Please resolve the errors above and run again" -ForegroundColor Red
Write-Host "" -ForegroundColor Red Write-Host "" -ForegroundColor Red
Write-Host " For help:" -ForegroundColor Red Write-Host " For help:" -ForegroundColor Red
Write-Host " https://github.com/mixelpixx/KiCAD-MCP-Server/issues" -ForegroundColor Red Write-Host " https://github.com/mixelpixx/KiCAD-MCP-Server/issues" -ForegroundColor Red
Write-Host "============================================================" -ForegroundColor Red Write-Host "============================================================" -ForegroundColor Red
exit 1 exit 1
} }

View File

@@ -1 +1 @@
"""Tests for KiCAD MCP Server""" """Tests for KiCAD MCP Server"""

View File

@@ -1,193 +1,193 @@
""" """
Tests for platform_helper utility Tests for platform_helper utility
These are unit tests that work on all platforms. These are unit tests that work on all platforms.
""" """
import os import os
import platform import platform
import sys import sys
from pathlib import Path from pathlib import Path
import pytest import pytest
# Add parent directory to path to import utils # Add parent directory to path to import utils
sys.path.insert(0, str(Path(__file__).parent.parent / "python")) sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
from utils.platform_helper import PlatformHelper, detect_platform from utils.platform_helper import PlatformHelper, detect_platform
class TestPlatformDetection: class TestPlatformDetection:
"""Test platform detection functions""" """Test platform detection functions"""
def test_exactly_one_platform_detected(self): def test_exactly_one_platform_detected(self):
"""Ensure exactly one platform is detected""" """Ensure exactly one platform is detected"""
platforms = [ platforms = [
PlatformHelper.is_windows(), PlatformHelper.is_windows(),
PlatformHelper.is_linux(), PlatformHelper.is_linux(),
PlatformHelper.is_macos(), PlatformHelper.is_macos(),
] ]
assert sum(platforms) == 1, "Exactly one platform should be detected" assert sum(platforms) == 1, "Exactly one platform should be detected"
def test_platform_name_is_valid(self): def test_platform_name_is_valid(self):
"""Test platform name is human-readable""" """Test platform name is human-readable"""
name = PlatformHelper.get_platform_name() name = PlatformHelper.get_platform_name()
assert name in ["Windows", "Linux", "macOS"], f"Unknown platform: {name}" assert name in ["Windows", "Linux", "macOS"], f"Unknown platform: {name}"
def test_platform_name_matches_detection(self): def test_platform_name_matches_detection(self):
"""Ensure platform name matches detection functions""" """Ensure platform name matches detection functions"""
name = PlatformHelper.get_platform_name() name = PlatformHelper.get_platform_name()
if name == "Windows": if name == "Windows":
assert PlatformHelper.is_windows() assert PlatformHelper.is_windows()
elif name == "Linux": elif name == "Linux":
assert PlatformHelper.is_linux() assert PlatformHelper.is_linux()
elif name == "macOS": elif name == "macOS":
assert PlatformHelper.is_macos() assert PlatformHelper.is_macos()
class TestPathGeneration: class TestPathGeneration:
"""Test path generation functions""" """Test path generation functions"""
def test_config_dir_exists_after_ensure(self): def test_config_dir_exists_after_ensure(self):
"""Test that config directory is created""" """Test that config directory is created"""
PlatformHelper.ensure_directories() PlatformHelper.ensure_directories()
config_dir = PlatformHelper.get_config_dir() config_dir = PlatformHelper.get_config_dir()
assert config_dir.exists(), f"Config dir should exist: {config_dir}" assert config_dir.exists(), f"Config dir should exist: {config_dir}"
assert config_dir.is_dir(), f"Config dir should be a directory: {config_dir}" assert config_dir.is_dir(), f"Config dir should be a directory: {config_dir}"
def test_log_dir_exists_after_ensure(self): def test_log_dir_exists_after_ensure(self):
"""Test that log directory is created""" """Test that log directory is created"""
PlatformHelper.ensure_directories() PlatformHelper.ensure_directories()
log_dir = PlatformHelper.get_log_dir() log_dir = PlatformHelper.get_log_dir()
assert log_dir.exists(), f"Log dir should exist: {log_dir}" assert log_dir.exists(), f"Log dir should exist: {log_dir}"
assert log_dir.is_dir(), f"Log dir should be a directory: {log_dir}" assert log_dir.is_dir(), f"Log dir should be a directory: {log_dir}"
def test_cache_dir_exists_after_ensure(self): def test_cache_dir_exists_after_ensure(self):
"""Test that cache directory is created""" """Test that cache directory is created"""
PlatformHelper.ensure_directories() PlatformHelper.ensure_directories()
cache_dir = PlatformHelper.get_cache_dir() cache_dir = PlatformHelper.get_cache_dir()
assert cache_dir.exists(), f"Cache dir should exist: {cache_dir}" assert cache_dir.exists(), f"Cache dir should exist: {cache_dir}"
assert cache_dir.is_dir(), f"Cache dir should be a directory: {cache_dir}" assert cache_dir.is_dir(), f"Cache dir should be a directory: {cache_dir}"
def test_config_dir_is_platform_appropriate(self): def test_config_dir_is_platform_appropriate(self):
"""Test that config directory follows platform conventions""" """Test that config directory follows platform conventions"""
config_dir = PlatformHelper.get_config_dir() config_dir = PlatformHelper.get_config_dir()
if PlatformHelper.is_linux(): if PlatformHelper.is_linux():
# Should be ~/.config/kicad-mcp or $XDG_CONFIG_HOME/kicad-mcp # Should be ~/.config/kicad-mcp or $XDG_CONFIG_HOME/kicad-mcp
if "XDG_CONFIG_HOME" in os.environ: if "XDG_CONFIG_HOME" in os.environ:
expected = Path(os.environ["XDG_CONFIG_HOME"]) / "kicad-mcp" expected = Path(os.environ["XDG_CONFIG_HOME"]) / "kicad-mcp"
else: else:
expected = Path.home() / ".config" / "kicad-mcp" expected = Path.home() / ".config" / "kicad-mcp"
assert config_dir == expected assert config_dir == expected
elif PlatformHelper.is_windows(): elif PlatformHelper.is_windows():
# Should be %USERPROFILE%\.kicad-mcp # Should be %USERPROFILE%\.kicad-mcp
expected = Path.home() / ".kicad-mcp" expected = Path.home() / ".kicad-mcp"
assert config_dir == expected assert config_dir == expected
elif PlatformHelper.is_macos(): elif PlatformHelper.is_macos():
# Should be ~/Library/Application Support/kicad-mcp # Should be ~/Library/Application Support/kicad-mcp
expected = Path.home() / "Library" / "Application Support" / "kicad-mcp" expected = Path.home() / "Library" / "Application Support" / "kicad-mcp"
assert config_dir == expected assert config_dir == expected
def test_config_dir_ignores_relative_xdg_config_home(self, monkeypatch): def test_config_dir_ignores_relative_xdg_config_home(self, monkeypatch):
"""Relative XDG_CONFIG_HOME should be ignored on Linux.""" """Relative XDG_CONFIG_HOME should be ignored on Linux."""
monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True)) monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True))
monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False)) monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False))
monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False)) monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False))
monkeypatch.setenv("XDG_CONFIG_HOME", "relative/path") monkeypatch.setenv("XDG_CONFIG_HOME", "relative/path")
assert PlatformHelper.get_config_dir() == Path.home() / ".config" / "kicad-mcp" assert PlatformHelper.get_config_dir() == Path.home() / ".config" / "kicad-mcp"
def test_cache_dir_ignores_relative_xdg_cache_home(self, monkeypatch): def test_cache_dir_ignores_relative_xdg_cache_home(self, monkeypatch):
"""Relative XDG_CACHE_HOME should be ignored on Linux.""" """Relative XDG_CACHE_HOME should be ignored on Linux."""
monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True)) monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True))
monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False)) monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False))
monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False)) monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False))
monkeypatch.setenv("XDG_CACHE_HOME", "relative/cache") monkeypatch.setenv("XDG_CACHE_HOME", "relative/cache")
assert PlatformHelper.get_cache_dir() == Path.home() / ".cache" / "kicad-mcp" assert PlatformHelper.get_cache_dir() == Path.home() / ".cache" / "kicad-mcp"
def test_python_executable_is_valid(self): def test_python_executable_is_valid(self):
"""Test that Python executable path is valid""" """Test that Python executable path is valid"""
exe = PlatformHelper.get_python_executable() exe = PlatformHelper.get_python_executable()
assert exe.exists(), f"Python executable should exist: {exe}" assert exe.exists(), f"Python executable should exist: {exe}"
assert str(exe) == sys.executable assert str(exe) == sys.executable
def test_kicad_library_search_paths_returns_list(self): def test_kicad_library_search_paths_returns_list(self):
"""Test that library search paths returns a list""" """Test that library search paths returns a list"""
paths = PlatformHelper.get_kicad_library_search_paths() paths = PlatformHelper.get_kicad_library_search_paths()
assert isinstance(paths, list) assert isinstance(paths, list)
assert len(paths) > 0 assert len(paths) > 0
# All paths should be strings (glob patterns) # All paths should be strings (glob patterns)
assert all(isinstance(p, str) for p in paths) assert all(isinstance(p, str) for p in paths)
class TestDetectPlatform: class TestDetectPlatform:
"""Test the detect_platform convenience function""" """Test the detect_platform convenience function"""
def test_detect_platform_returns_dict(self): def test_detect_platform_returns_dict(self):
"""Test that detect_platform returns a dictionary""" """Test that detect_platform returns a dictionary"""
info = detect_platform() info = detect_platform()
assert isinstance(info, dict) assert isinstance(info, dict)
def test_detect_platform_has_required_keys(self): def test_detect_platform_has_required_keys(self):
"""Test that detect_platform includes all required keys""" """Test that detect_platform includes all required keys"""
info = detect_platform() info = detect_platform()
required_keys = [ required_keys = [
"system", "system",
"platform", "platform",
"is_windows", "is_windows",
"is_linux", "is_linux",
"is_macos", "is_macos",
"python_version", "python_version",
"python_executable", "python_executable",
"config_dir", "config_dir",
"log_dir", "log_dir",
"cache_dir", "cache_dir",
"kicad_python_paths", "kicad_python_paths",
] ]
for key in required_keys: for key in required_keys:
assert key in info, f"Missing key: {key}" assert key in info, f"Missing key: {key}"
def test_detect_platform_python_version_format(self): def test_detect_platform_python_version_format(self):
"""Test that Python version is in correct format""" """Test that Python version is in correct format"""
info = detect_platform() info = detect_platform()
version = info["python_version"] version = info["python_version"]
# Should be like "3.12.3" # Should be like "3.12.3"
parts = version.split(".") parts = version.split(".")
assert len(parts) == 3 assert len(parts) == 3
assert all(p.isdigit() for p in parts) assert all(p.isdigit() for p in parts)
@pytest.mark.integration @pytest.mark.integration
class TestKiCADPathDetection: class TestKiCADPathDetection:
"""Tests that require KiCAD to be installed""" """Tests that require KiCAD to be installed"""
def test_kicad_python_paths_exist(self): def test_kicad_python_paths_exist(self):
"""Test that at least one KiCAD Python path exists (if KiCAD is installed)""" """Test that at least one KiCAD Python path exists (if KiCAD is installed)"""
paths = PlatformHelper.get_kicad_python_paths() paths = PlatformHelper.get_kicad_python_paths()
# This test only makes sense if KiCAD is installed # This test only makes sense if KiCAD is installed
# In CI, KiCAD should be installed # In CI, KiCAD should be installed
if paths: if paths:
assert all(p.exists() for p in paths), "All returned paths should exist" assert all(p.exists() for p in paths), "All returned paths should exist"
def test_can_import_pcbnew_after_adding_paths(self): def test_can_import_pcbnew_after_adding_paths(self):
"""Test that pcbnew can be imported after adding KiCAD paths""" """Test that pcbnew can be imported after adding KiCAD paths"""
PlatformHelper.add_kicad_to_python_path() PlatformHelper.add_kicad_to_python_path()
try: try:
import pcbnew import pcbnew
# If we get here, pcbnew is available # If we get here, pcbnew is available
assert pcbnew is not None assert pcbnew is not None
version = pcbnew.GetBuildVersion() version = pcbnew.GetBuildVersion()
assert version is not None assert version is not None
print(f"Found KiCAD version: {version}") print(f"Found KiCAD version: {version}")
except ImportError: except ImportError:
pytest.skip("KiCAD pcbnew module not available (KiCAD not installed)") pytest.skip("KiCAD pcbnew module not available (KiCAD not installed)")
if __name__ == "__main__": if __name__ == "__main__":
# Run tests with pytest # Run tests with pytest
pytest.main([__file__, "-v"]) pytest.main([__file__, "-v"])