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:
188
.gitignore
vendored
188
.gitignore
vendored
@@ -1,94 +1,94 @@
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
dist/
|
||||
.npm
|
||||
.eslintcache
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.hypothesis/
|
||||
*.cover
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Virtual Environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
~/.kicad-mcp/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# KiCAD
|
||||
*.kicad_pcb-bak
|
||||
*.kicad_pcb.bak
|
||||
*.kicad_sch-bak
|
||||
*.kicad_sch.bak
|
||||
*.kicad_pro-bak
|
||||
*.kicad_pro.bak
|
||||
*.kicad_prl
|
||||
*-backups/
|
||||
fp-info-cache
|
||||
|
||||
# Testing
|
||||
test_output/
|
||||
schematic_test_output/
|
||||
coverage.xml
|
||||
.coverage.*
|
||||
|
||||
# Data & Databases
|
||||
data/
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Generated local config files (contain machine-specific paths)
|
||||
windows-mcp-config.json
|
||||
|
||||
# Personal notes / local contributions (not for upstream)
|
||||
myContribution/
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
dist/
|
||||
.npm
|
||||
.eslintcache
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.hypothesis/
|
||||
*.cover
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Virtual Environments
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
~/.kicad-mcp/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# KiCAD
|
||||
*.kicad_pcb-bak
|
||||
*.kicad_pcb.bak
|
||||
*.kicad_sch-bak
|
||||
*.kicad_sch.bak
|
||||
*.kicad_pro-bak
|
||||
*.kicad_pro.bak
|
||||
*.kicad_prl
|
||||
*-backups/
|
||||
fp-info-cache
|
||||
|
||||
# Testing
|
||||
test_output/
|
||||
schematic_test_output/
|
||||
coverage.xml
|
||||
.coverage.*
|
||||
|
||||
# Data & Databases
|
||||
data/
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# OS
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Generated local config files (contain machine-specific paths)
|
||||
windows-mcp-config.json
|
||||
|
||||
# Personal notes / local contributions (not for upstream)
|
||||
myContribution/
|
||||
|
||||
104
pytest.ini
104
pytest.ini
@@ -1,52 +1,52 @@
|
||||
[pytest]
|
||||
# Pytest configuration for KiCAD MCP Server
|
||||
|
||||
# Test discovery patterns
|
||||
python_files = test_*.py *_test.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
|
||||
# Test paths
|
||||
testpaths = tests
|
||||
|
||||
# Minimum Python version
|
||||
minversion = 6.0
|
||||
|
||||
# Additional options
|
||||
addopts =
|
||||
-ra
|
||||
--strict-markers
|
||||
--strict-config
|
||||
--showlocals
|
||||
--tb=short
|
||||
--cov=python
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
--cov-report=xml
|
||||
--cov-branch
|
||||
|
||||
# Markers for organizing tests
|
||||
markers =
|
||||
unit: Unit tests (fast, no external dependencies)
|
||||
integration: Integration tests (requires KiCAD)
|
||||
slow: Slow-running tests
|
||||
linux: Linux-specific tests
|
||||
windows: Windows-specific tests
|
||||
macos: macOS-specific tests
|
||||
|
||||
# Ignore patterns
|
||||
norecursedirs = .git .tox dist build *.egg node_modules
|
||||
|
||||
# Coverage settings
|
||||
[coverage:run]
|
||||
source = python
|
||||
omit =
|
||||
*/tests/*
|
||||
*/test_*.py
|
||||
*/__pycache__/*
|
||||
*/site-packages/*
|
||||
|
||||
[coverage:report]
|
||||
precision = 2
|
||||
show_missing = True
|
||||
skip_covered = False
|
||||
[pytest]
|
||||
# Pytest configuration for KiCAD MCP Server
|
||||
|
||||
# Test discovery patterns
|
||||
python_files = test_*.py *_test.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
|
||||
# Test paths
|
||||
testpaths = tests
|
||||
|
||||
# Minimum Python version
|
||||
minversion = 6.0
|
||||
|
||||
# Additional options
|
||||
addopts =
|
||||
-ra
|
||||
--strict-markers
|
||||
--strict-config
|
||||
--showlocals
|
||||
--tb=short
|
||||
--cov=python
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
--cov-report=xml
|
||||
--cov-branch
|
||||
|
||||
# Markers for organizing tests
|
||||
markers =
|
||||
unit: Unit tests (fast, no external dependencies)
|
||||
integration: Integration tests (requires KiCAD)
|
||||
slow: Slow-running tests
|
||||
linux: Linux-specific tests
|
||||
windows: Windows-specific tests
|
||||
macos: macOS-specific tests
|
||||
|
||||
# Ignore patterns
|
||||
norecursedirs = .git .tox dist build *.egg node_modules
|
||||
|
||||
# Coverage settings
|
||||
[coverage:run]
|
||||
source = python
|
||||
omit =
|
||||
*/tests/*
|
||||
*/test_*.py
|
||||
*/__pycache__/*
|
||||
*/site-packages/*
|
||||
|
||||
[coverage:report]
|
||||
precision = 2
|
||||
show_missing = True
|
||||
skip_covered = False
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
"""
|
||||
KiCAD command implementations package
|
||||
"""
|
||||
|
||||
from .board import BoardCommands
|
||||
from .component import ComponentCommands
|
||||
from .design_rules import DesignRuleCommands
|
||||
from .export import ExportCommands
|
||||
from .project import ProjectCommands
|
||||
from .routing import RoutingCommands
|
||||
|
||||
__all__ = [
|
||||
"ProjectCommands",
|
||||
"BoardCommands",
|
||||
"ComponentCommands",
|
||||
"RoutingCommands",
|
||||
"DesignRuleCommands",
|
||||
"ExportCommands",
|
||||
]
|
||||
"""
|
||||
KiCAD command implementations package
|
||||
"""
|
||||
|
||||
from .board import BoardCommands
|
||||
from .component import ComponentCommands
|
||||
from .design_rules import DesignRuleCommands
|
||||
from .export import ExportCommands
|
||||
from .project import ProjectCommands
|
||||
from .routing import RoutingCommands
|
||||
|
||||
__all__ = [
|
||||
"ProjectCommands",
|
||||
"BoardCommands",
|
||||
"ComponentCommands",
|
||||
"RoutingCommands",
|
||||
"DesignRuleCommands",
|
||||
"ExportCommands",
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"""
|
||||
Board-related command implementations for KiCAD interface
|
||||
|
||||
This file is maintained for backward compatibility.
|
||||
It imports and re-exports the BoardCommands class from the board package.
|
||||
"""
|
||||
|
||||
from commands.board import BoardCommands
|
||||
|
||||
# Re-export the BoardCommands class for backward compatibility
|
||||
__all__ = ["BoardCommands"]
|
||||
"""
|
||||
Board-related command implementations for KiCAD interface
|
||||
|
||||
This file is maintained for backward compatibility.
|
||||
It imports and re-exports the BoardCommands class from the board package.
|
||||
"""
|
||||
|
||||
from commands.board import BoardCommands
|
||||
|
||||
# Re-export the BoardCommands class for backward compatibility
|
||||
__all__ = ["BoardCommands"]
|
||||
|
||||
@@ -1,85 +1,85 @@
|
||||
"""
|
||||
Board-related command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
from .layers import BoardLayerCommands
|
||||
from .outline import BoardOutlineCommands
|
||||
|
||||
# Import specialized modules
|
||||
from .size import BoardSizeCommands
|
||||
from .view import BoardViewCommands
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardCommands:
|
||||
"""Handles board-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
# Initialize specialized command classes
|
||||
self.size_commands = BoardSizeCommands(board)
|
||||
self.layer_commands = BoardLayerCommands(board)
|
||||
self.outline_commands = BoardOutlineCommands(board)
|
||||
self.view_commands = BoardViewCommands(board)
|
||||
|
||||
# Delegate board size commands
|
||||
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the size of the PCB board"""
|
||||
self.size_commands.board = self.board
|
||||
return self.size_commands.set_board_size(params)
|
||||
|
||||
# Delegate layer commands
|
||||
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a new layer to the PCB"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.add_layer(params)
|
||||
|
||||
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the active layer for PCB operations"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.set_active_layer(params)
|
||||
|
||||
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a list of all layers in the PCB"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.get_layer_list(params)
|
||||
|
||||
# Delegate board outline commands
|
||||
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a board outline to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_board_outline(params)
|
||||
|
||||
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a mounting hole to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_mounting_hole(params)
|
||||
|
||||
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add text annotation to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_text(params)
|
||||
|
||||
# Delegate view commands
|
||||
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current board"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_info(params)
|
||||
|
||||
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a 2D image of the PCB"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_2d_view(params)
|
||||
|
||||
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the bounding box extents of the board"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_extents(params)
|
||||
"""
|
||||
Board-related command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
from .layers import BoardLayerCommands
|
||||
from .outline import BoardOutlineCommands
|
||||
|
||||
# Import specialized modules
|
||||
from .size import BoardSizeCommands
|
||||
from .view import BoardViewCommands
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardCommands:
|
||||
"""Handles board-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
# Initialize specialized command classes
|
||||
self.size_commands = BoardSizeCommands(board)
|
||||
self.layer_commands = BoardLayerCommands(board)
|
||||
self.outline_commands = BoardOutlineCommands(board)
|
||||
self.view_commands = BoardViewCommands(board)
|
||||
|
||||
# Delegate board size commands
|
||||
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the size of the PCB board"""
|
||||
self.size_commands.board = self.board
|
||||
return self.size_commands.set_board_size(params)
|
||||
|
||||
# Delegate layer commands
|
||||
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a new layer to the PCB"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.add_layer(params)
|
||||
|
||||
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the active layer for PCB operations"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.set_active_layer(params)
|
||||
|
||||
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a list of all layers in the PCB"""
|
||||
self.layer_commands.board = self.board
|
||||
return self.layer_commands.get_layer_list(params)
|
||||
|
||||
# Delegate board outline commands
|
||||
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a board outline to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_board_outline(params)
|
||||
|
||||
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a mounting hole to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_mounting_hole(params)
|
||||
|
||||
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add text annotation to the PCB"""
|
||||
self.outline_commands.board = self.board
|
||||
return self.outline_commands.add_text(params)
|
||||
|
||||
# Delegate view commands
|
||||
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current board"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_info(params)
|
||||
|
||||
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a 2D image of the PCB"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_2d_view(params)
|
||||
|
||||
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the bounding box extents of the board"""
|
||||
self.view_commands.board = self.board
|
||||
return self.view_commands.get_board_extents(params)
|
||||
|
||||
@@ -1,176 +1,176 @@
|
||||
"""
|
||||
Board layer command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardLayerCommands:
|
||||
"""Handles board layer operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a new layer to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
name = params.get("name")
|
||||
layer_type = params.get("type")
|
||||
position = params.get("position")
|
||||
number = params.get("number")
|
||||
|
||||
if not name or not layer_type or not position:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "name, type, and position are required",
|
||||
}
|
||||
|
||||
# Get layer stack
|
||||
layer_stack = self.board.GetLayerStack()
|
||||
|
||||
# Determine layer ID based on position and number
|
||||
layer_id = None
|
||||
if position == "inner":
|
||||
if number is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing layer number",
|
||||
"errorDetails": "number is required for inner layers",
|
||||
}
|
||||
layer_id = pcbnew.In1_Cu + (number - 1)
|
||||
elif position == "top":
|
||||
layer_id = pcbnew.F_Cu
|
||||
elif position == "bottom":
|
||||
layer_id = pcbnew.B_Cu
|
||||
|
||||
if layer_id is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid layer position",
|
||||
"errorDetails": "position must be 'top', 'bottom', or 'inner'",
|
||||
}
|
||||
|
||||
# Set layer properties
|
||||
layer_stack.SetLayerName(layer_id, name)
|
||||
layer_stack.SetLayerType(layer_id, self._get_layer_type(layer_type))
|
||||
|
||||
# Enable the layer
|
||||
self.board.SetLayerEnabled(layer_id, True)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Added layer: {name}",
|
||||
"layer": {"name": name, "type": layer_type, "position": position, "number": number},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding layer: {str(e)}")
|
||||
return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)}
|
||||
|
||||
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the active layer for PCB operations"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
layer = params.get("layer")
|
||||
if not layer:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No layer specified",
|
||||
"errorDetails": "layer parameter is required",
|
||||
}
|
||||
|
||||
# Find layer ID by name
|
||||
layer_id = self.board.GetLayerID(layer)
|
||||
if layer_id < 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Layer not found",
|
||||
"errorDetails": f"Layer '{layer}' does not exist",
|
||||
}
|
||||
|
||||
# Set active layer
|
||||
self.board.SetActiveLayer(layer_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Set active layer to: {layer}",
|
||||
"layer": {"name": layer, "id": layer_id},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting active layer: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to set active layer",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a list of all layers in the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
layers = []
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
layers.append(
|
||||
{
|
||||
"name": self.board.GetLayerName(layer_id),
|
||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||
"id": layer_id,
|
||||
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||
# Active layer is a UI concept not applicable to headless scripting
|
||||
}
|
||||
)
|
||||
|
||||
return {"success": True, "layers": layers}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting layer list: {str(e)}")
|
||||
return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)}
|
||||
|
||||
def _get_layer_type(self, type_name: str) -> int:
|
||||
"""Convert layer type name to KiCAD layer type constant"""
|
||||
type_map = {
|
||||
"copper": pcbnew.LT_SIGNAL,
|
||||
"technical": pcbnew.LT_SIGNAL,
|
||||
"user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead
|
||||
"signal": pcbnew.LT_SIGNAL,
|
||||
}
|
||||
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
|
||||
|
||||
def _get_layer_type_name(self, type_id: int) -> str:
|
||||
"""Convert KiCAD layer type constant to name"""
|
||||
type_map = {
|
||||
pcbnew.LT_SIGNAL: "signal",
|
||||
pcbnew.LT_POWER: "power",
|
||||
pcbnew.LT_MIXED: "mixed",
|
||||
pcbnew.LT_JUMPER: "jumper",
|
||||
}
|
||||
# Note: LT_USER was removed in KiCAD 9.0
|
||||
return type_map.get(type_id, "unknown")
|
||||
"""
|
||||
Board layer command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardLayerCommands:
|
||||
"""Handles board layer operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def add_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a new layer to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
name = params.get("name")
|
||||
layer_type = params.get("type")
|
||||
position = params.get("position")
|
||||
number = params.get("number")
|
||||
|
||||
if not name or not layer_type or not position:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "name, type, and position are required",
|
||||
}
|
||||
|
||||
# Get layer stack
|
||||
layer_stack = self.board.GetLayerStack()
|
||||
|
||||
# Determine layer ID based on position and number
|
||||
layer_id = None
|
||||
if position == "inner":
|
||||
if number is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing layer number",
|
||||
"errorDetails": "number is required for inner layers",
|
||||
}
|
||||
layer_id = pcbnew.In1_Cu + (number - 1)
|
||||
elif position == "top":
|
||||
layer_id = pcbnew.F_Cu
|
||||
elif position == "bottom":
|
||||
layer_id = pcbnew.B_Cu
|
||||
|
||||
if layer_id is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid layer position",
|
||||
"errorDetails": "position must be 'top', 'bottom', or 'inner'",
|
||||
}
|
||||
|
||||
# Set layer properties
|
||||
layer_stack.SetLayerName(layer_id, name)
|
||||
layer_stack.SetLayerType(layer_id, self._get_layer_type(layer_type))
|
||||
|
||||
# Enable the layer
|
||||
self.board.SetLayerEnabled(layer_id, True)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Added layer: {name}",
|
||||
"layer": {"name": name, "type": layer_type, "position": position, "number": number},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding layer: {str(e)}")
|
||||
return {"success": False, "message": "Failed to add layer", "errorDetails": str(e)}
|
||||
|
||||
def set_active_layer(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the active layer for PCB operations"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
layer = params.get("layer")
|
||||
if not layer:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No layer specified",
|
||||
"errorDetails": "layer parameter is required",
|
||||
}
|
||||
|
||||
# Find layer ID by name
|
||||
layer_id = self.board.GetLayerID(layer)
|
||||
if layer_id < 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Layer not found",
|
||||
"errorDetails": f"Layer '{layer}' does not exist",
|
||||
}
|
||||
|
||||
# Set active layer
|
||||
self.board.SetActiveLayer(layer_id)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Set active layer to: {layer}",
|
||||
"layer": {"name": layer, "id": layer_id},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting active layer: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to set active layer",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_layer_list(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a list of all layers in the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
layers = []
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
layers.append(
|
||||
{
|
||||
"name": self.board.GetLayerName(layer_id),
|
||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||
"id": layer_id,
|
||||
# Note: isActive removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||
# Active layer is a UI concept not applicable to headless scripting
|
||||
}
|
||||
)
|
||||
|
||||
return {"success": True, "layers": layers}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting layer list: {str(e)}")
|
||||
return {"success": False, "message": "Failed to get layer list", "errorDetails": str(e)}
|
||||
|
||||
def _get_layer_type(self, type_name: str) -> int:
|
||||
"""Convert layer type name to KiCAD layer type constant"""
|
||||
type_map = {
|
||||
"copper": pcbnew.LT_SIGNAL,
|
||||
"technical": pcbnew.LT_SIGNAL,
|
||||
"user": pcbnew.LT_SIGNAL, # LT_USER removed in KiCAD 9.0, use LT_SIGNAL instead
|
||||
"signal": pcbnew.LT_SIGNAL,
|
||||
}
|
||||
return type_map.get(type_name.lower(), pcbnew.LT_SIGNAL)
|
||||
|
||||
def _get_layer_type_name(self, type_id: int) -> str:
|
||||
"""Convert KiCAD layer type constant to name"""
|
||||
type_map = {
|
||||
pcbnew.LT_SIGNAL: "signal",
|
||||
pcbnew.LT_POWER: "power",
|
||||
pcbnew.LT_MIXED: "mixed",
|
||||
pcbnew.LT_JUMPER: "jumper",
|
||||
}
|
||||
# Note: LT_USER was removed in KiCAD 9.0
|
||||
return type_map.get(type_id, "unknown")
|
||||
|
||||
@@ -1,484 +1,484 @@
|
||||
"""
|
||||
Board outline command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardOutlineCommands:
|
||||
"""Handles board outline operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a board outline to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Claude sends dimensions nested inside a "params" key:
|
||||
# {"shape": "rectangle", "params": {"x": 0, "y": 0, "width": 38, ...}}
|
||||
# Unwrap the inner dict if present so we read dimensions from the right level.
|
||||
inner = params.get("params", params)
|
||||
|
||||
shape = params.get("shape", "rectangle")
|
||||
width = inner.get("width")
|
||||
height = inner.get("height")
|
||||
radius = inner.get("radius")
|
||||
# Accept both "cornerRadius" and "radius" regardless of shape name.
|
||||
# The AI often sends shape="rectangle" with radius=2.5 — we treat that as rounded_rectangle.
|
||||
corner_radius = inner.get("cornerRadius", inner.get("radius", 0))
|
||||
if shape == "rectangle" and corner_radius > 0:
|
||||
shape = "rounded_rectangle"
|
||||
points = inner.get("points", [])
|
||||
unit = inner.get("unit", "mm")
|
||||
|
||||
# Position: accept top-left corner (x/y) or center (centerX/centerY).
|
||||
# Default: top-left at (0,0) so the board occupies positive coordinate space
|
||||
# and is consistent with component placement coordinates.
|
||||
x = inner.get("x")
|
||||
y = inner.get("y")
|
||||
if x is not None or y is not None:
|
||||
ox = x if x is not None else 0.0
|
||||
oy = y if y is not None else 0.0
|
||||
center_x = ox + (width or 0) / 2.0
|
||||
center_y = oy + (height or 0) / 2.0
|
||||
else:
|
||||
raw_cx = inner.get("centerX")
|
||||
raw_cy = inner.get("centerY")
|
||||
if raw_cx is not None or raw_cy is not None:
|
||||
center_x = raw_cx if raw_cx is not None else 0.0
|
||||
center_y = raw_cy if raw_cy is not None else 0.0
|
||||
else:
|
||||
# No position given → place top-left at (0,0)
|
||||
center_x = (width or 0) / 2.0
|
||||
center_y = (height or 0) / 2.0
|
||||
|
||||
if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid shape",
|
||||
"errorDetails": f"Shape '{shape}' not supported",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm
|
||||
|
||||
# Create drawing for edge cuts
|
||||
edge_layer = self.board.GetLayerID("Edge.Cuts")
|
||||
|
||||
if shape == "rectangle":
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required for rectangle",
|
||||
}
|
||||
|
||||
width_nm = int(width * scale)
|
||||
height_nm = int(height * scale)
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
|
||||
# Create rectangle
|
||||
top_left = pcbnew.VECTOR2I(
|
||||
center_x_nm - width_nm // 2, center_y_nm - height_nm // 2
|
||||
)
|
||||
top_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
|
||||
)
|
||||
bottom_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
bottom_left = pcbnew.VECTOR2I(
|
||||
center_x_nm - width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
|
||||
# Add lines for rectangle
|
||||
self._add_edge_line(top_left, top_right, edge_layer)
|
||||
self._add_edge_line(top_right, bottom_right, edge_layer)
|
||||
self._add_edge_line(bottom_right, bottom_left, edge_layer)
|
||||
self._add_edge_line(bottom_left, top_left, edge_layer)
|
||||
|
||||
elif shape == "rounded_rectangle":
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required for rounded rectangle",
|
||||
}
|
||||
|
||||
width_nm = int(width * scale)
|
||||
height_nm = int(height * scale)
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
corner_radius_nm = int(corner_radius * scale)
|
||||
|
||||
# Create rounded rectangle
|
||||
self._add_rounded_rect(
|
||||
center_x_nm,
|
||||
center_y_nm,
|
||||
width_nm,
|
||||
height_nm,
|
||||
corner_radius_nm,
|
||||
edge_layer,
|
||||
)
|
||||
|
||||
elif shape == "circle":
|
||||
if radius is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing radius",
|
||||
"errorDetails": "Radius is required for circle",
|
||||
}
|
||||
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
radius_nm = int(radius * scale)
|
||||
|
||||
# Create circle
|
||||
circle = pcbnew.PCB_SHAPE(self.board)
|
||||
circle.SetShape(pcbnew.SHAPE_T_CIRCLE)
|
||||
circle.SetCenter(pcbnew.VECTOR2I(center_x_nm, center_y_nm))
|
||||
circle.SetEnd(pcbnew.VECTOR2I(center_x_nm + radius_nm, center_y_nm))
|
||||
circle.SetLayer(edge_layer)
|
||||
circle.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(circle)
|
||||
|
||||
elif shape == "polygon":
|
||||
if not points or len(points) < 3:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing points",
|
||||
"errorDetails": "At least 3 points are required for polygon",
|
||||
}
|
||||
|
||||
# Convert points to nm
|
||||
polygon_points = []
|
||||
for point in points:
|
||||
x_nm = int(point["x"] * scale)
|
||||
y_nm = int(point["y"] * scale)
|
||||
polygon_points.append(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
|
||||
# Add lines for polygon
|
||||
for i in range(len(polygon_points)):
|
||||
self._add_edge_line(
|
||||
polygon_points[i],
|
||||
polygon_points[(i + 1) % len(polygon_points)],
|
||||
edge_layer,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Added board outline: {shape}",
|
||||
"outline": {
|
||||
"shape": shape,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"center": {"x": center_x, "y": center_y, "unit": unit},
|
||||
"radius": radius,
|
||||
"cornerRadius": corner_radius,
|
||||
"points": points,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding board outline: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add board outline",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a mounting hole to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
position = params.get("position")
|
||||
diameter = params.get("diameter")
|
||||
pad_diameter = params.get("padDiameter")
|
||||
plated = params.get("plated", False)
|
||||
|
||||
if not position or not diameter:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "position and diameter are required",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
|
||||
x_nm = int(position["x"] * scale)
|
||||
y_nm = int(position["y"] * scale)
|
||||
diameter_nm = int(diameter * scale)
|
||||
pad_diameter_nm = (
|
||||
int(pad_diameter * scale) if pad_diameter else diameter_nm + scale
|
||||
) # 1mm larger by default
|
||||
|
||||
# Create footprint for mounting hole with unique reference
|
||||
existing_mh = [
|
||||
fp.GetReference()
|
||||
for fp in self.board.GetFootprints()
|
||||
if fp.GetReference().startswith("MH")
|
||||
]
|
||||
next_num = 1
|
||||
while f"MH{next_num}" in existing_mh:
|
||||
next_num += 1
|
||||
|
||||
module = pcbnew.FOOTPRINT(self.board)
|
||||
module.SetReference(f"MH{next_num}")
|
||||
module.SetValue(f"MountingHole_{diameter}mm")
|
||||
|
||||
# Create the pad for the hole
|
||||
pad = pcbnew.PAD(module)
|
||||
pad.SetNumber(1)
|
||||
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
|
||||
pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
|
||||
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
|
||||
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
|
||||
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
|
||||
module.Add(pad)
|
||||
|
||||
# Position the mounting hole
|
||||
module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
|
||||
# Add to board
|
||||
self.board.Add(module)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Added mounting hole",
|
||||
"mountingHole": {
|
||||
"position": position,
|
||||
"diameter": diameter,
|
||||
"padDiameter": pad_diameter or diameter + 1,
|
||||
"plated": plated,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding mounting hole: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add mounting hole",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add text annotation to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
text = params.get("text")
|
||||
position = params.get("position")
|
||||
layer = params.get("layer", "F.SilkS")
|
||||
size = params.get("size", 1.0)
|
||||
thickness = params.get("thickness", 0.15)
|
||||
rotation = params.get("rotation", 0)
|
||||
mirror = params.get("mirror", False)
|
||||
|
||||
if not text or not position:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "text and position are required",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
|
||||
x_nm = int(position["x"] * scale)
|
||||
y_nm = int(position["y"] * scale)
|
||||
size_nm = int(size * scale)
|
||||
thickness_nm = int(thickness * scale)
|
||||
|
||||
# Get layer ID
|
||||
layer_id = self.board.GetLayerID(layer)
|
||||
if layer_id < 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid layer",
|
||||
"errorDetails": f"Layer '{layer}' does not exist",
|
||||
}
|
||||
|
||||
# Create text
|
||||
pcb_text = pcbnew.PCB_TEXT(self.board)
|
||||
pcb_text.SetText(text)
|
||||
pcb_text.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
pcb_text.SetLayer(layer_id)
|
||||
pcb_text.SetTextSize(pcbnew.VECTOR2I(size_nm, size_nm))
|
||||
pcb_text.SetTextThickness(thickness_nm)
|
||||
|
||||
# Set rotation angle - KiCAD 9.0 uses EDA_ANGLE
|
||||
try:
|
||||
# Try KiCAD 9.0+ API (EDA_ANGLE)
|
||||
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
|
||||
pcb_text.SetTextAngle(angle)
|
||||
except (AttributeError, TypeError):
|
||||
# Fall back to older API (decidegrees as integer)
|
||||
pcb_text.SetTextAngle(int(rotation * 10))
|
||||
|
||||
pcb_text.SetMirrored(mirror)
|
||||
|
||||
# Add to board
|
||||
self.board.Add(pcb_text)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Added text annotation",
|
||||
"text": {
|
||||
"text": text,
|
||||
"position": position,
|
||||
"layer": layer,
|
||||
"size": size,
|
||||
"thickness": thickness,
|
||||
"rotation": rotation,
|
||||
"mirror": mirror,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding text: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add text",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
|
||||
"""Add a line to the edge cuts layer"""
|
||||
line = pcbnew.PCB_SHAPE(self.board)
|
||||
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
|
||||
line.SetStart(start)
|
||||
line.SetEnd(end)
|
||||
line.SetLayer(layer)
|
||||
line.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(line)
|
||||
|
||||
def _add_rounded_rect(
|
||||
self,
|
||||
center_x_nm: int,
|
||||
center_y_nm: int,
|
||||
width_nm: int,
|
||||
height_nm: int,
|
||||
radius_nm: int,
|
||||
layer: int,
|
||||
) -> None:
|
||||
"""Add a rounded rectangle to the edge cuts layer"""
|
||||
if radius_nm <= 0:
|
||||
# If no radius, create regular rectangle
|
||||
top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
|
||||
top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
|
||||
bottom_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
|
||||
|
||||
self._add_edge_line(top_left, top_right, layer)
|
||||
self._add_edge_line(top_right, bottom_right, layer)
|
||||
self._add_edge_line(bottom_right, bottom_left, layer)
|
||||
self._add_edge_line(bottom_left, top_left, layer)
|
||||
return
|
||||
|
||||
# Calculate corner centers
|
||||
half_width = width_nm // 2
|
||||
half_height = height_nm // 2
|
||||
|
||||
# Ensure radius is not larger than half the smallest dimension
|
||||
max_radius = min(half_width, half_height)
|
||||
if radius_nm > max_radius:
|
||||
radius_nm = max_radius
|
||||
|
||||
# Calculate corner centers
|
||||
top_left_center = pcbnew.VECTOR2I(
|
||||
center_x_nm - half_width + radius_nm, center_y_nm - half_height + radius_nm
|
||||
)
|
||||
top_right_center = pcbnew.VECTOR2I(
|
||||
center_x_nm + half_width - radius_nm, center_y_nm - half_height + radius_nm
|
||||
)
|
||||
bottom_right_center = pcbnew.VECTOR2I(
|
||||
center_x_nm + half_width - radius_nm, center_y_nm + half_height - radius_nm
|
||||
)
|
||||
bottom_left_center = pcbnew.VECTOR2I(
|
||||
center_x_nm - half_width + radius_nm, center_y_nm + half_height - radius_nm
|
||||
)
|
||||
|
||||
# Add arcs for corners
|
||||
self._add_corner_arc(top_left_center, radius_nm, 180, 270, layer)
|
||||
self._add_corner_arc(top_right_center, radius_nm, 270, 0, layer)
|
||||
self._add_corner_arc(bottom_right_center, radius_nm, 0, 90, layer)
|
||||
self._add_corner_arc(bottom_left_center, radius_nm, 90, 180, layer)
|
||||
|
||||
# Add lines for straight edges
|
||||
# Top edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(top_left_center.x, top_left_center.y - radius_nm),
|
||||
pcbnew.VECTOR2I(top_right_center.x, top_right_center.y - radius_nm),
|
||||
layer,
|
||||
)
|
||||
# Right edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(top_right_center.x + radius_nm, top_right_center.y),
|
||||
pcbnew.VECTOR2I(bottom_right_center.x + radius_nm, bottom_right_center.y),
|
||||
layer,
|
||||
)
|
||||
# Bottom edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(bottom_right_center.x, bottom_right_center.y + radius_nm),
|
||||
pcbnew.VECTOR2I(bottom_left_center.x, bottom_left_center.y + radius_nm),
|
||||
layer,
|
||||
)
|
||||
# Left edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(bottom_left_center.x - radius_nm, bottom_left_center.y),
|
||||
pcbnew.VECTOR2I(top_left_center.x - radius_nm, top_left_center.y),
|
||||
layer,
|
||||
)
|
||||
|
||||
def _add_corner_arc(
|
||||
self,
|
||||
center: pcbnew.VECTOR2I,
|
||||
radius: int,
|
||||
start_angle: float,
|
||||
end_angle: float,
|
||||
layer: int,
|
||||
) -> None:
|
||||
"""Add an arc for a rounded corner"""
|
||||
# Create arc for corner
|
||||
arc = pcbnew.PCB_SHAPE(self.board)
|
||||
arc.SetShape(pcbnew.SHAPE_T_ARC)
|
||||
arc.SetCenter(center)
|
||||
|
||||
# Calculate start and end points
|
||||
start_x = center.x + int(radius * math.cos(math.radians(start_angle)))
|
||||
start_y = center.y + int(radius * math.sin(math.radians(start_angle)))
|
||||
end_x = center.x + int(radius * math.cos(math.radians(end_angle)))
|
||||
end_y = center.y + int(radius * math.sin(math.radians(end_angle)))
|
||||
|
||||
arc.SetStart(pcbnew.VECTOR2I(start_x, start_y))
|
||||
arc.SetEnd(pcbnew.VECTOR2I(end_x, end_y))
|
||||
arc.SetLayer(layer)
|
||||
arc.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(arc)
|
||||
"""
|
||||
Board outline command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardOutlineCommands:
|
||||
"""Handles board outline operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def add_board_outline(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a board outline to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Claude sends dimensions nested inside a "params" key:
|
||||
# {"shape": "rectangle", "params": {"x": 0, "y": 0, "width": 38, ...}}
|
||||
# Unwrap the inner dict if present so we read dimensions from the right level.
|
||||
inner = params.get("params", params)
|
||||
|
||||
shape = params.get("shape", "rectangle")
|
||||
width = inner.get("width")
|
||||
height = inner.get("height")
|
||||
radius = inner.get("radius")
|
||||
# Accept both "cornerRadius" and "radius" regardless of shape name.
|
||||
# The AI often sends shape="rectangle" with radius=2.5 — we treat that as rounded_rectangle.
|
||||
corner_radius = inner.get("cornerRadius", inner.get("radius", 0))
|
||||
if shape == "rectangle" and corner_radius > 0:
|
||||
shape = "rounded_rectangle"
|
||||
points = inner.get("points", [])
|
||||
unit = inner.get("unit", "mm")
|
||||
|
||||
# Position: accept top-left corner (x/y) or center (centerX/centerY).
|
||||
# Default: top-left at (0,0) so the board occupies positive coordinate space
|
||||
# and is consistent with component placement coordinates.
|
||||
x = inner.get("x")
|
||||
y = inner.get("y")
|
||||
if x is not None or y is not None:
|
||||
ox = x if x is not None else 0.0
|
||||
oy = y if y is not None else 0.0
|
||||
center_x = ox + (width or 0) / 2.0
|
||||
center_y = oy + (height or 0) / 2.0
|
||||
else:
|
||||
raw_cx = inner.get("centerX")
|
||||
raw_cy = inner.get("centerY")
|
||||
if raw_cx is not None or raw_cy is not None:
|
||||
center_x = raw_cx if raw_cx is not None else 0.0
|
||||
center_y = raw_cy if raw_cy is not None else 0.0
|
||||
else:
|
||||
# No position given → place top-left at (0,0)
|
||||
center_x = (width or 0) / 2.0
|
||||
center_y = (height or 0) / 2.0
|
||||
|
||||
if shape not in ["rectangle", "circle", "polygon", "rounded_rectangle"]:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid shape",
|
||||
"errorDetails": f"Shape '{shape}' not supported",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if unit == "mm" else 25400000 # mm or inch to nm
|
||||
|
||||
# Create drawing for edge cuts
|
||||
edge_layer = self.board.GetLayerID("Edge.Cuts")
|
||||
|
||||
if shape == "rectangle":
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required for rectangle",
|
||||
}
|
||||
|
||||
width_nm = int(width * scale)
|
||||
height_nm = int(height * scale)
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
|
||||
# Create rectangle
|
||||
top_left = pcbnew.VECTOR2I(
|
||||
center_x_nm - width_nm // 2, center_y_nm - height_nm // 2
|
||||
)
|
||||
top_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm - height_nm // 2
|
||||
)
|
||||
bottom_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
bottom_left = pcbnew.VECTOR2I(
|
||||
center_x_nm - width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
|
||||
# Add lines for rectangle
|
||||
self._add_edge_line(top_left, top_right, edge_layer)
|
||||
self._add_edge_line(top_right, bottom_right, edge_layer)
|
||||
self._add_edge_line(bottom_right, bottom_left, edge_layer)
|
||||
self._add_edge_line(bottom_left, top_left, edge_layer)
|
||||
|
||||
elif shape == "rounded_rectangle":
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required for rounded rectangle",
|
||||
}
|
||||
|
||||
width_nm = int(width * scale)
|
||||
height_nm = int(height * scale)
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
corner_radius_nm = int(corner_radius * scale)
|
||||
|
||||
# Create rounded rectangle
|
||||
self._add_rounded_rect(
|
||||
center_x_nm,
|
||||
center_y_nm,
|
||||
width_nm,
|
||||
height_nm,
|
||||
corner_radius_nm,
|
||||
edge_layer,
|
||||
)
|
||||
|
||||
elif shape == "circle":
|
||||
if radius is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing radius",
|
||||
"errorDetails": "Radius is required for circle",
|
||||
}
|
||||
|
||||
center_x_nm = int(center_x * scale)
|
||||
center_y_nm = int(center_y * scale)
|
||||
radius_nm = int(radius * scale)
|
||||
|
||||
# Create circle
|
||||
circle = pcbnew.PCB_SHAPE(self.board)
|
||||
circle.SetShape(pcbnew.SHAPE_T_CIRCLE)
|
||||
circle.SetCenter(pcbnew.VECTOR2I(center_x_nm, center_y_nm))
|
||||
circle.SetEnd(pcbnew.VECTOR2I(center_x_nm + radius_nm, center_y_nm))
|
||||
circle.SetLayer(edge_layer)
|
||||
circle.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(circle)
|
||||
|
||||
elif shape == "polygon":
|
||||
if not points or len(points) < 3:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing points",
|
||||
"errorDetails": "At least 3 points are required for polygon",
|
||||
}
|
||||
|
||||
# Convert points to nm
|
||||
polygon_points = []
|
||||
for point in points:
|
||||
x_nm = int(point["x"] * scale)
|
||||
y_nm = int(point["y"] * scale)
|
||||
polygon_points.append(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
|
||||
# Add lines for polygon
|
||||
for i in range(len(polygon_points)):
|
||||
self._add_edge_line(
|
||||
polygon_points[i],
|
||||
polygon_points[(i + 1) % len(polygon_points)],
|
||||
edge_layer,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Added board outline: {shape}",
|
||||
"outline": {
|
||||
"shape": shape,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"center": {"x": center_x, "y": center_y, "unit": unit},
|
||||
"radius": radius,
|
||||
"cornerRadius": corner_radius,
|
||||
"points": points,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding board outline: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add board outline",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def add_mounting_hole(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add a mounting hole to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
position = params.get("position")
|
||||
diameter = params.get("diameter")
|
||||
pad_diameter = params.get("padDiameter")
|
||||
plated = params.get("plated", False)
|
||||
|
||||
if not position or not diameter:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "position and diameter are required",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
|
||||
x_nm = int(position["x"] * scale)
|
||||
y_nm = int(position["y"] * scale)
|
||||
diameter_nm = int(diameter * scale)
|
||||
pad_diameter_nm = (
|
||||
int(pad_diameter * scale) if pad_diameter else diameter_nm + scale
|
||||
) # 1mm larger by default
|
||||
|
||||
# Create footprint for mounting hole with unique reference
|
||||
existing_mh = [
|
||||
fp.GetReference()
|
||||
for fp in self.board.GetFootprints()
|
||||
if fp.GetReference().startswith("MH")
|
||||
]
|
||||
next_num = 1
|
||||
while f"MH{next_num}" in existing_mh:
|
||||
next_num += 1
|
||||
|
||||
module = pcbnew.FOOTPRINT(self.board)
|
||||
module.SetReference(f"MH{next_num}")
|
||||
module.SetValue(f"MountingHole_{diameter}mm")
|
||||
|
||||
# Create the pad for the hole
|
||||
pad = pcbnew.PAD(module)
|
||||
pad.SetNumber(1)
|
||||
pad.SetShape(pcbnew.PAD_SHAPE_CIRCLE)
|
||||
pad.SetAttribute(pcbnew.PAD_ATTRIB_PTH if plated else pcbnew.PAD_ATTRIB_NPTH)
|
||||
pad.SetSize(pcbnew.VECTOR2I(pad_diameter_nm, pad_diameter_nm))
|
||||
pad.SetDrillSize(pcbnew.VECTOR2I(diameter_nm, diameter_nm))
|
||||
pad.SetPosition(pcbnew.VECTOR2I(0, 0)) # Position relative to module
|
||||
module.Add(pad)
|
||||
|
||||
# Position the mounting hole
|
||||
module.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
|
||||
# Add to board
|
||||
self.board.Add(module)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Added mounting hole",
|
||||
"mountingHole": {
|
||||
"position": position,
|
||||
"diameter": diameter,
|
||||
"padDiameter": pad_diameter or diameter + 1,
|
||||
"plated": plated,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding mounting hole: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add mounting hole",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def add_text(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add text annotation to the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
text = params.get("text")
|
||||
position = params.get("position")
|
||||
layer = params.get("layer", "F.SilkS")
|
||||
size = params.get("size", 1.0)
|
||||
thickness = params.get("thickness", 0.15)
|
||||
rotation = params.get("rotation", 0)
|
||||
mirror = params.get("mirror", False)
|
||||
|
||||
if not text or not position:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing parameters",
|
||||
"errorDetails": "text and position are required",
|
||||
}
|
||||
|
||||
# Convert to internal units (nanometers)
|
||||
scale = 1000000 if position.get("unit", "mm") == "mm" else 25400000 # mm or inch to nm
|
||||
x_nm = int(position["x"] * scale)
|
||||
y_nm = int(position["y"] * scale)
|
||||
size_nm = int(size * scale)
|
||||
thickness_nm = int(thickness * scale)
|
||||
|
||||
# Get layer ID
|
||||
layer_id = self.board.GetLayerID(layer)
|
||||
if layer_id < 0:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid layer",
|
||||
"errorDetails": f"Layer '{layer}' does not exist",
|
||||
}
|
||||
|
||||
# Create text
|
||||
pcb_text = pcbnew.PCB_TEXT(self.board)
|
||||
pcb_text.SetText(text)
|
||||
pcb_text.SetPosition(pcbnew.VECTOR2I(x_nm, y_nm))
|
||||
pcb_text.SetLayer(layer_id)
|
||||
pcb_text.SetTextSize(pcbnew.VECTOR2I(size_nm, size_nm))
|
||||
pcb_text.SetTextThickness(thickness_nm)
|
||||
|
||||
# Set rotation angle - KiCAD 9.0 uses EDA_ANGLE
|
||||
try:
|
||||
# Try KiCAD 9.0+ API (EDA_ANGLE)
|
||||
angle = pcbnew.EDA_ANGLE(rotation, pcbnew.DEGREES_T)
|
||||
pcb_text.SetTextAngle(angle)
|
||||
except (AttributeError, TypeError):
|
||||
# Fall back to older API (decidegrees as integer)
|
||||
pcb_text.SetTextAngle(int(rotation * 10))
|
||||
|
||||
pcb_text.SetMirrored(mirror)
|
||||
|
||||
# Add to board
|
||||
self.board.Add(pcb_text)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Added text annotation",
|
||||
"text": {
|
||||
"text": text,
|
||||
"position": position,
|
||||
"layer": layer,
|
||||
"size": size,
|
||||
"thickness": thickness,
|
||||
"rotation": rotation,
|
||||
"mirror": mirror,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding text: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to add text",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _add_edge_line(self, start: pcbnew.VECTOR2I, end: pcbnew.VECTOR2I, layer: int) -> None:
|
||||
"""Add a line to the edge cuts layer"""
|
||||
line = pcbnew.PCB_SHAPE(self.board)
|
||||
line.SetShape(pcbnew.SHAPE_T_SEGMENT)
|
||||
line.SetStart(start)
|
||||
line.SetEnd(end)
|
||||
line.SetLayer(layer)
|
||||
line.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(line)
|
||||
|
||||
def _add_rounded_rect(
|
||||
self,
|
||||
center_x_nm: int,
|
||||
center_y_nm: int,
|
||||
width_nm: int,
|
||||
height_nm: int,
|
||||
radius_nm: int,
|
||||
layer: int,
|
||||
) -> None:
|
||||
"""Add a rounded rectangle to the edge cuts layer"""
|
||||
if radius_nm <= 0:
|
||||
# If no radius, create regular rectangle
|
||||
top_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm - height_nm // 2)
|
||||
top_right = pcbnew.VECTOR2I(center_x_nm + width_nm // 2, center_y_nm - height_nm // 2)
|
||||
bottom_right = pcbnew.VECTOR2I(
|
||||
center_x_nm + width_nm // 2, center_y_nm + height_nm // 2
|
||||
)
|
||||
bottom_left = pcbnew.VECTOR2I(center_x_nm - width_nm // 2, center_y_nm + height_nm // 2)
|
||||
|
||||
self._add_edge_line(top_left, top_right, layer)
|
||||
self._add_edge_line(top_right, bottom_right, layer)
|
||||
self._add_edge_line(bottom_right, bottom_left, layer)
|
||||
self._add_edge_line(bottom_left, top_left, layer)
|
||||
return
|
||||
|
||||
# Calculate corner centers
|
||||
half_width = width_nm // 2
|
||||
half_height = height_nm // 2
|
||||
|
||||
# Ensure radius is not larger than half the smallest dimension
|
||||
max_radius = min(half_width, half_height)
|
||||
if radius_nm > max_radius:
|
||||
radius_nm = max_radius
|
||||
|
||||
# Calculate corner centers
|
||||
top_left_center = pcbnew.VECTOR2I(
|
||||
center_x_nm - half_width + radius_nm, center_y_nm - half_height + radius_nm
|
||||
)
|
||||
top_right_center = pcbnew.VECTOR2I(
|
||||
center_x_nm + half_width - radius_nm, center_y_nm - half_height + radius_nm
|
||||
)
|
||||
bottom_right_center = pcbnew.VECTOR2I(
|
||||
center_x_nm + half_width - radius_nm, center_y_nm + half_height - radius_nm
|
||||
)
|
||||
bottom_left_center = pcbnew.VECTOR2I(
|
||||
center_x_nm - half_width + radius_nm, center_y_nm + half_height - radius_nm
|
||||
)
|
||||
|
||||
# Add arcs for corners
|
||||
self._add_corner_arc(top_left_center, radius_nm, 180, 270, layer)
|
||||
self._add_corner_arc(top_right_center, radius_nm, 270, 0, layer)
|
||||
self._add_corner_arc(bottom_right_center, radius_nm, 0, 90, layer)
|
||||
self._add_corner_arc(bottom_left_center, radius_nm, 90, 180, layer)
|
||||
|
||||
# Add lines for straight edges
|
||||
# Top edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(top_left_center.x, top_left_center.y - radius_nm),
|
||||
pcbnew.VECTOR2I(top_right_center.x, top_right_center.y - radius_nm),
|
||||
layer,
|
||||
)
|
||||
# Right edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(top_right_center.x + radius_nm, top_right_center.y),
|
||||
pcbnew.VECTOR2I(bottom_right_center.x + radius_nm, bottom_right_center.y),
|
||||
layer,
|
||||
)
|
||||
# Bottom edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(bottom_right_center.x, bottom_right_center.y + radius_nm),
|
||||
pcbnew.VECTOR2I(bottom_left_center.x, bottom_left_center.y + radius_nm),
|
||||
layer,
|
||||
)
|
||||
# Left edge
|
||||
self._add_edge_line(
|
||||
pcbnew.VECTOR2I(bottom_left_center.x - radius_nm, bottom_left_center.y),
|
||||
pcbnew.VECTOR2I(top_left_center.x - radius_nm, top_left_center.y),
|
||||
layer,
|
||||
)
|
||||
|
||||
def _add_corner_arc(
|
||||
self,
|
||||
center: pcbnew.VECTOR2I,
|
||||
radius: int,
|
||||
start_angle: float,
|
||||
end_angle: float,
|
||||
layer: int,
|
||||
) -> None:
|
||||
"""Add an arc for a rounded corner"""
|
||||
# Create arc for corner
|
||||
arc = pcbnew.PCB_SHAPE(self.board)
|
||||
arc.SetShape(pcbnew.SHAPE_T_ARC)
|
||||
arc.SetCenter(center)
|
||||
|
||||
# Calculate start and end points
|
||||
start_x = center.x + int(radius * math.cos(math.radians(start_angle)))
|
||||
start_y = center.y + int(radius * math.sin(math.radians(start_angle)))
|
||||
end_x = center.x + int(radius * math.cos(math.radians(end_angle)))
|
||||
end_y = center.y + int(radius * math.sin(math.radians(end_angle)))
|
||||
|
||||
arc.SetStart(pcbnew.VECTOR2I(start_x, start_y))
|
||||
arc.SetEnd(pcbnew.VECTOR2I(end_x, end_y))
|
||||
arc.SetLayer(layer)
|
||||
arc.SetWidth(0) # Zero width for edge cuts
|
||||
self.board.Add(arc)
|
||||
|
||||
@@ -1,70 +1,70 @@
|
||||
"""
|
||||
Board size command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardSizeCommands:
|
||||
"""Handles board size operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the size of the PCB board by creating edge cuts outline"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
width = params.get("width")
|
||||
height = params.get("height")
|
||||
unit = params.get("unit", "mm")
|
||||
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required",
|
||||
}
|
||||
|
||||
# Create board outline using BoardOutlineCommands
|
||||
# This properly creates edge cuts on Edge.Cuts layer
|
||||
from commands.board.outline import BoardOutlineCommands
|
||||
|
||||
outline_commands = BoardOutlineCommands(self.board)
|
||||
|
||||
# Create rectangular outline centered at origin
|
||||
result = outline_commands.add_board_outline(
|
||||
{
|
||||
"shape": "rectangle",
|
||||
"centerX": width / 2, # Center X
|
||||
"centerY": height / 2, # Center Y
|
||||
"width": width,
|
||||
"height": height,
|
||||
"unit": unit,
|
||||
}
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Created board outline: {width}x{height} {unit}",
|
||||
"size": {"width": width, "height": height, "unit": unit},
|
||||
}
|
||||
else:
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting board size: {str(e)}")
|
||||
return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)}
|
||||
"""
|
||||
Board size command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardSizeCommands:
|
||||
"""Handles board size operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def set_board_size(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set the size of the PCB board by creating edge cuts outline"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
width = params.get("width")
|
||||
height = params.get("height")
|
||||
unit = params.get("unit", "mm")
|
||||
|
||||
if width is None or height is None:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Missing dimensions",
|
||||
"errorDetails": "Both width and height are required",
|
||||
}
|
||||
|
||||
# Create board outline using BoardOutlineCommands
|
||||
# This properly creates edge cuts on Edge.Cuts layer
|
||||
from commands.board.outline import BoardOutlineCommands
|
||||
|
||||
outline_commands = BoardOutlineCommands(self.board)
|
||||
|
||||
# Create rectangular outline centered at origin
|
||||
result = outline_commands.add_board_outline(
|
||||
{
|
||||
"shape": "rectangle",
|
||||
"centerX": width / 2, # Center X
|
||||
"centerY": height / 2, # Center Y
|
||||
"width": width,
|
||||
"height": height,
|
||||
"unit": unit,
|
||||
}
|
||||
)
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Created board outline: {width}x{height} {unit}",
|
||||
"size": {"width": width, "height": height, "unit": unit},
|
||||
}
|
||||
else:
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting board size: {str(e)}")
|
||||
return {"success": False, "message": "Failed to set board size", "errorDetails": str(e)}
|
||||
|
||||
@@ -1,226 +1,226 @@
|
||||
"""
|
||||
Board view command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pcbnew
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardViewCommands:
|
||||
"""Handles board viewing operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current board"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get board dimensions
|
||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||
width_nm = board_box.GetWidth()
|
||||
height_nm = board_box.GetHeight()
|
||||
|
||||
# Convert to mm
|
||||
width_mm = width_nm / 1000000
|
||||
height_mm = height_nm / 1000000
|
||||
|
||||
# Get layer information
|
||||
layers = []
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
layers.append(
|
||||
{
|
||||
"name": self.board.GetLayerName(layer_id),
|
||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||
"id": layer_id,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"board": {
|
||||
"filename": self.board.GetFileName(),
|
||||
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
|
||||
"layers": layers,
|
||||
"title": self.board.GetTitleBlock().GetTitle(),
|
||||
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||
# Active layer is a UI concept not applicable to headless scripting
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board info: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board information",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a 2D image of the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get parameters
|
||||
width = params.get("width", 800)
|
||||
height = params.get("height", 600)
|
||||
format = params.get("format", "png")
|
||||
layers = params.get("layers", [])
|
||||
|
||||
# Create plot controller
|
||||
plotter = pcbnew.PLOT_CONTROLLER(self.board)
|
||||
|
||||
# Set up plot options
|
||||
plot_opts = plotter.GetPlotOptions()
|
||||
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
|
||||
plot_opts.SetScale(1)
|
||||
plot_opts.SetMirror(False)
|
||||
# Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers
|
||||
plot_opts.SetPlotFrameRef(False)
|
||||
plot_opts.SetPlotValue(True)
|
||||
plot_opts.SetPlotReference(True)
|
||||
|
||||
# Plot to SVG first (for vector output)
|
||||
# Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path
|
||||
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
|
||||
|
||||
# Plot specified layers or all enabled layers
|
||||
# Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer()
|
||||
if layers:
|
||||
for layer_name in layers:
|
||||
layer_id = self.board.GetLayerID(layer_name)
|
||||
if layer_id >= 0 and self.board.IsLayerEnabled(layer_id):
|
||||
plotter.SetLayer(layer_id)
|
||||
plotter.PlotLayer()
|
||||
else:
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
plotter.SetLayer(layer_id)
|
||||
plotter.PlotLayer()
|
||||
|
||||
# Get the actual filename that was created (includes project name prefix)
|
||||
temp_svg = plotter.GetPlotFileName()
|
||||
|
||||
plotter.ClosePlot()
|
||||
|
||||
# Convert SVG to requested format
|
||||
if format == "svg":
|
||||
with open(temp_svg, "r") as f:
|
||||
svg_data = f.read()
|
||||
os.remove(temp_svg)
|
||||
return {"success": True, "imageData": svg_data, "format": "svg"}
|
||||
else:
|
||||
# Use PIL to convert SVG to PNG/JPG
|
||||
from cairosvg import svg2png
|
||||
|
||||
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
|
||||
os.remove(temp_svg)
|
||||
|
||||
if format == "jpg":
|
||||
# Convert PNG to JPG
|
||||
img = Image.open(io.BytesIO(png_data))
|
||||
jpg_buffer = io.BytesIO()
|
||||
img.convert("RGB").save(jpg_buffer, format="JPEG")
|
||||
jpg_data = jpg_buffer.getvalue()
|
||||
return {
|
||||
"success": True,
|
||||
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
|
||||
"format": "jpg",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
||||
"format": "png",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board 2D view: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board 2D view",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _get_layer_type_name(self, type_id: int) -> str:
|
||||
"""Convert KiCAD layer type constant to name"""
|
||||
type_map = {
|
||||
pcbnew.LT_SIGNAL: "signal",
|
||||
pcbnew.LT_POWER: "power",
|
||||
pcbnew.LT_MIXED: "mixed",
|
||||
pcbnew.LT_JUMPER: "jumper",
|
||||
}
|
||||
# Note: LT_USER was removed in KiCAD 9.0
|
||||
return type_map.get(type_id, "unknown")
|
||||
|
||||
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the bounding box extents of the board"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get unit preference (default to mm)
|
||||
unit = params.get("unit", "mm")
|
||||
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
|
||||
|
||||
# Get board bounding box
|
||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||
|
||||
# Extract bounds in nanometers, then convert
|
||||
left = board_box.GetLeft() / scale
|
||||
top = board_box.GetTop() / scale
|
||||
right = board_box.GetRight() / scale
|
||||
bottom = board_box.GetBottom() / scale
|
||||
width = board_box.GetWidth() / scale
|
||||
height = board_box.GetHeight() / scale
|
||||
|
||||
# Get center point
|
||||
center_x = board_box.GetCenter().x / scale
|
||||
center_y = board_box.GetCenter().y / scale
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"extents": {
|
||||
"left": left,
|
||||
"top": top,
|
||||
"right": right,
|
||||
"bottom": bottom,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"center": {"x": center_x, "y": center_y},
|
||||
"unit": unit,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board extents: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board extents",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
"""
|
||||
Board view command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pcbnew
|
||||
from PIL import Image
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class BoardViewCommands:
|
||||
"""Handles board viewing operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def get_board_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current board"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get board dimensions
|
||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||
width_nm = board_box.GetWidth()
|
||||
height_nm = board_box.GetHeight()
|
||||
|
||||
# Convert to mm
|
||||
width_mm = width_nm / 1000000
|
||||
height_mm = height_nm / 1000000
|
||||
|
||||
# Get layer information
|
||||
layers = []
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
layers.append(
|
||||
{
|
||||
"name": self.board.GetLayerName(layer_id),
|
||||
"type": self._get_layer_type_name(self.board.GetLayerType(layer_id)),
|
||||
"id": layer_id,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"board": {
|
||||
"filename": self.board.GetFileName(),
|
||||
"size": {"width": width_mm, "height": height_mm, "unit": "mm"},
|
||||
"layers": layers,
|
||||
"title": self.board.GetTitleBlock().GetTitle(),
|
||||
# Note: activeLayer removed - GetActiveLayer() doesn't exist in KiCAD 9.0
|
||||
# Active layer is a UI concept not applicable to headless scripting
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board info: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board information",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_board_2d_view(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get a 2D image of the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get parameters
|
||||
width = params.get("width", 800)
|
||||
height = params.get("height", 600)
|
||||
format = params.get("format", "png")
|
||||
layers = params.get("layers", [])
|
||||
|
||||
# Create plot controller
|
||||
plotter = pcbnew.PLOT_CONTROLLER(self.board)
|
||||
|
||||
# Set up plot options
|
||||
plot_opts = plotter.GetPlotOptions()
|
||||
plot_opts.SetOutputDirectory(os.path.dirname(self.board.GetFileName()))
|
||||
plot_opts.SetScale(1)
|
||||
plot_opts.SetMirror(False)
|
||||
# Note: SetExcludeEdgeLayer() removed in KiCAD 9.0 - default behavior includes all layers
|
||||
plot_opts.SetPlotFrameRef(False)
|
||||
plot_opts.SetPlotValue(True)
|
||||
plot_opts.SetPlotReference(True)
|
||||
|
||||
# Plot to SVG first (for vector output)
|
||||
# Note: KiCAD 9.0 prepends the project name to the filename, so we use GetPlotFileName() to get the actual path
|
||||
plotter.OpenPlotfile("temp_view", pcbnew.PLOT_FORMAT_SVG, "Temporary View")
|
||||
|
||||
# Plot specified layers or all enabled layers
|
||||
# Note: In KiCAD 9.0, SetLayer() must be called before PlotLayer()
|
||||
if layers:
|
||||
for layer_name in layers:
|
||||
layer_id = self.board.GetLayerID(layer_name)
|
||||
if layer_id >= 0 and self.board.IsLayerEnabled(layer_id):
|
||||
plotter.SetLayer(layer_id)
|
||||
plotter.PlotLayer()
|
||||
else:
|
||||
for layer_id in range(pcbnew.PCB_LAYER_ID_COUNT):
|
||||
if self.board.IsLayerEnabled(layer_id):
|
||||
plotter.SetLayer(layer_id)
|
||||
plotter.PlotLayer()
|
||||
|
||||
# Get the actual filename that was created (includes project name prefix)
|
||||
temp_svg = plotter.GetPlotFileName()
|
||||
|
||||
plotter.ClosePlot()
|
||||
|
||||
# Convert SVG to requested format
|
||||
if format == "svg":
|
||||
with open(temp_svg, "r") as f:
|
||||
svg_data = f.read()
|
||||
os.remove(temp_svg)
|
||||
return {"success": True, "imageData": svg_data, "format": "svg"}
|
||||
else:
|
||||
# Use PIL to convert SVG to PNG/JPG
|
||||
from cairosvg import svg2png
|
||||
|
||||
png_data = svg2png(url=temp_svg, output_width=width, output_height=height)
|
||||
os.remove(temp_svg)
|
||||
|
||||
if format == "jpg":
|
||||
# Convert PNG to JPG
|
||||
img = Image.open(io.BytesIO(png_data))
|
||||
jpg_buffer = io.BytesIO()
|
||||
img.convert("RGB").save(jpg_buffer, format="JPEG")
|
||||
jpg_data = jpg_buffer.getvalue()
|
||||
return {
|
||||
"success": True,
|
||||
"imageData": base64.b64encode(jpg_data).decode("utf-8"),
|
||||
"format": "jpg",
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"success": True,
|
||||
"imageData": base64.b64encode(png_data).decode("utf-8"),
|
||||
"format": "png",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board 2D view: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board 2D view",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _get_layer_type_name(self, type_id: int) -> str:
|
||||
"""Convert KiCAD layer type constant to name"""
|
||||
type_map = {
|
||||
pcbnew.LT_SIGNAL: "signal",
|
||||
pcbnew.LT_POWER: "power",
|
||||
pcbnew.LT_MIXED: "mixed",
|
||||
pcbnew.LT_JUMPER: "jumper",
|
||||
}
|
||||
# Note: LT_USER was removed in KiCAD 9.0
|
||||
return type_map.get(type_id, "unknown")
|
||||
|
||||
def get_board_extents(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get the bounding box extents of the board"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
# Get unit preference (default to mm)
|
||||
unit = params.get("unit", "mm")
|
||||
scale = 1000000 if unit == "mm" else 25400000 # nm to mm or inch
|
||||
|
||||
# Get board bounding box
|
||||
board_box = self.board.GetBoardEdgesBoundingBox()
|
||||
|
||||
# Extract bounds in nanometers, then convert
|
||||
left = board_box.GetLeft() / scale
|
||||
top = board_box.GetTop() / scale
|
||||
right = board_box.GetRight() / scale
|
||||
bottom = board_box.GetBottom() / scale
|
||||
width = board_box.GetWidth() / scale
|
||||
height = board_box.GetHeight() / scale
|
||||
|
||||
# Get center point
|
||||
center_x = board_box.GetCenter().x / scale
|
||||
center_y = board_box.GetCenter().y / scale
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"extents": {
|
||||
"left": left,
|
||||
"top": top,
|
||||
"right": right,
|
||||
"bottom": bottom,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"center": {"x": center_x, "y": center_y},
|
||||
"unit": unit,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting board extents: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get board extents",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,410 +1,410 @@
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import dynamic symbol loader
|
||||
try:
|
||||
from commands.dynamic_symbol_loader import DynamicSymbolLoader
|
||||
|
||||
DYNAMIC_LOADING_AVAILABLE = True
|
||||
except ImportError:
|
||||
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
|
||||
DYNAMIC_LOADING_AVAILABLE = False
|
||||
|
||||
|
||||
class ComponentManager:
|
||||
"""Manage components in a schematic"""
|
||||
|
||||
# Initialize dynamic loader (class variable, shared across instances)
|
||||
_dynamic_loader = None
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_loader(cls) -> Any:
|
||||
"""Get or create dynamic symbol loader instance"""
|
||||
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
|
||||
cls._dynamic_loader = DynamicSymbolLoader()
|
||||
return cls._dynamic_loader
|
||||
|
||||
# Template symbol references mapping component type to template reference
|
||||
TEMPLATE_MAP = {
|
||||
# Passives
|
||||
"R": "_TEMPLATE_R",
|
||||
"C": "_TEMPLATE_C",
|
||||
"L": "_TEMPLATE_L",
|
||||
"Y": "_TEMPLATE_Y",
|
||||
"Crystal": "_TEMPLATE_Y",
|
||||
# Semiconductors
|
||||
"D": "_TEMPLATE_D",
|
||||
"LED": "_TEMPLATE_LED",
|
||||
"Q": "_TEMPLATE_Q_NPN",
|
||||
"Q_NPN": "_TEMPLATE_Q_NPN",
|
||||
"Q_NMOS": "_TEMPLATE_Q_NMOS",
|
||||
"MOSFET": "_TEMPLATE_Q_NMOS",
|
||||
# ICs
|
||||
"U": "_TEMPLATE_U_OPAMP",
|
||||
"OpAmp": "_TEMPLATE_U_OPAMP",
|
||||
"IC": "_TEMPLATE_U_OPAMP",
|
||||
"U_REG": "_TEMPLATE_U_REG",
|
||||
"Regulator": "_TEMPLATE_U_REG",
|
||||
# Connectors
|
||||
"J": "_TEMPLATE_J2",
|
||||
"J2": "_TEMPLATE_J2",
|
||||
"J4": "_TEMPLATE_J4",
|
||||
"Conn_2": "_TEMPLATE_J2",
|
||||
"Conn_4": "_TEMPLATE_J4",
|
||||
# Misc
|
||||
"SW": "_TEMPLATE_SW",
|
||||
"Button": "_TEMPLATE_SW",
|
||||
"Switch": "_TEMPLATE_SW",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_or_create_template(
|
||||
cls,
|
||||
schematic: Schematic,
|
||||
comp_type: str,
|
||||
library: Optional[str] = None,
|
||||
schematic_path: Optional[Path] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Get template reference for a component type, creating it dynamically if needed
|
||||
|
||||
Args:
|
||||
schematic: Schematic object
|
||||
comp_type: Component type (e.g., 'R', 'LED', 'STM32F103C8Tx')
|
||||
library: Optional library name (defaults to 'Device' for common types)
|
||||
schematic_path: Optional path to schematic file (required for dynamic loading)
|
||||
|
||||
Returns:
|
||||
Tuple of (template_ref, needs_reload) where needs_reload indicates if schematic must be reloaded
|
||||
"""
|
||||
|
||||
# Helper function to check if template exists in schematic
|
||||
def template_exists(schematic: Any, template_ref: str) -> bool:
|
||||
"""Check if template exists by iterating symbols (handles special characters)"""
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
hasattr(symbol.property, "Reference")
|
||||
and symbol.property.Reference.value == template_ref
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
# 1. Check static template map first
|
||||
if comp_type in cls.TEMPLATE_MAP:
|
||||
template_ref = cls.TEMPLATE_MAP[comp_type]
|
||||
# Verify template exists in schematic
|
||||
if template_exists(schematic, template_ref):
|
||||
logger.debug(f"Using static template: {template_ref}")
|
||||
return (template_ref, False)
|
||||
|
||||
# 2. Check if dynamically loaded template already exists
|
||||
# Build potential template reference names
|
||||
potential_refs = []
|
||||
if library:
|
||||
potential_refs.append(f"_TEMPLATE_{library}_{comp_type}")
|
||||
potential_refs.append(f"_TEMPLATE_{comp_type}")
|
||||
if comp_type in cls.TEMPLATE_MAP:
|
||||
potential_refs.append(cls.TEMPLATE_MAP[comp_type])
|
||||
|
||||
# Check each potential reference
|
||||
for template_ref in potential_refs:
|
||||
if template_exists(schematic, template_ref):
|
||||
logger.debug(f"Found existing template: {template_ref}")
|
||||
return (template_ref, False)
|
||||
|
||||
# 3. Try dynamic loading
|
||||
if not DYNAMIC_LOADING_AVAILABLE:
|
||||
logger.warning(
|
||||
f"Component type '{comp_type}' not in static templates and dynamic loading unavailable"
|
||||
)
|
||||
# Fall back to basic resistor template
|
||||
return ("_TEMPLATE_R", False)
|
||||
|
||||
loader = cls.get_dynamic_loader()
|
||||
if not loader:
|
||||
logger.warning("Dynamic loader unavailable, using fallback template")
|
||||
return ("_TEMPLATE_R", False)
|
||||
|
||||
# Check if schematic path is available
|
||||
if schematic_path is None:
|
||||
logger.warning("Dynamic loading requires schematic file path but none was provided")
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||
return (fallback, False)
|
||||
|
||||
# Determine library name
|
||||
if library is None:
|
||||
# Default library for common component types
|
||||
library = "Device" # Most passives and basic components are in Device library
|
||||
|
||||
try:
|
||||
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
|
||||
|
||||
# Use dynamic symbol loader to inject symbol and create template
|
||||
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
|
||||
|
||||
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
|
||||
# Signal that schematic needs reload to see new template
|
||||
return (template_ref, True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Dynamic loading failed: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
# Fall back to static template if available
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||
return (fallback, False)
|
||||
|
||||
@staticmethod
|
||||
def add_component(
|
||||
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Add a component to the schematic by cloning from template
|
||||
|
||||
Args:
|
||||
schematic: Schematic object to add component to
|
||||
component_def: Component definition dictionary
|
||||
schematic_path: Optional path to schematic file (enables dynamic symbol loading)
|
||||
|
||||
Returns:
|
||||
Tuple of (new_symbol, needs_reload) where needs_reload indicates if caller should reload schematic
|
||||
"""
|
||||
try:
|
||||
from commands.schematic import SchematicManager
|
||||
|
||||
logger.info(
|
||||
f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}"
|
||||
)
|
||||
logger.debug(f"Full component_def: {component_def}")
|
||||
|
||||
# Get component type and determine template
|
||||
comp_type = component_def.get("type", "R")
|
||||
library = component_def.get("library", None) # Optional library specification
|
||||
|
||||
# Get template reference (static or dynamic)
|
||||
template_ref, needs_reload = ComponentManager.get_or_create_template(
|
||||
schematic, comp_type, library, schematic_path
|
||||
)
|
||||
|
||||
# If dynamic loading occurred, reload schematic to see new template
|
||||
if needs_reload and schematic_path:
|
||||
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
|
||||
schematic = SchematicManager.load_schematic(str(schematic_path))
|
||||
|
||||
# Find template symbol by reference (handles special characters like +)
|
||||
template_symbol = None
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
hasattr(symbol.property, "Reference")
|
||||
and symbol.property.Reference.value == template_ref
|
||||
):
|
||||
template_symbol = symbol
|
||||
break
|
||||
|
||||
if not template_symbol:
|
||||
logger.error(
|
||||
f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch"
|
||||
)
|
||||
|
||||
# Clone the template symbol
|
||||
new_symbol = template_symbol.clone()
|
||||
logger.debug(f"Cloned template symbol {template_ref}")
|
||||
|
||||
# Set reference
|
||||
reference = component_def.get("reference", "R?")
|
||||
new_symbol.property.Reference.value = reference
|
||||
logger.debug(f"Set reference to {reference}")
|
||||
|
||||
# Set value
|
||||
if "value" in component_def:
|
||||
new_symbol.property.Value.value = component_def["value"]
|
||||
logger.debug(f"Set value to {component_def['value']}")
|
||||
|
||||
# Set footprint
|
||||
if "footprint" in component_def:
|
||||
new_symbol.property.Footprint.value = component_def["footprint"]
|
||||
logger.debug(f"Set footprint to {component_def['footprint']}")
|
||||
|
||||
# Set datasheet
|
||||
if "datasheet" in component_def:
|
||||
new_symbol.property.Datasheet.value = component_def["datasheet"]
|
||||
|
||||
# Set position
|
||||
x = component_def.get("x", 0)
|
||||
y = component_def.get("y", 0)
|
||||
rotation = component_def.get("rotation", 0)
|
||||
new_symbol.at.value = [x, y, rotation]
|
||||
logger.debug(f"Set position to ({x}, {y}, {rotation})")
|
||||
|
||||
# Set BOM and board flags
|
||||
new_symbol.in_bom.value = component_def.get("in_bom", True)
|
||||
new_symbol.on_board.value = component_def.get("on_board", True)
|
||||
new_symbol.dnp.value = component_def.get("dnp", False)
|
||||
|
||||
# Generate new UUID
|
||||
new_symbol.uuid.value = str(uuid.uuid4())
|
||||
|
||||
# Append to schematic
|
||||
schematic.symbol.append(new_symbol)
|
||||
logger.info(f"Successfully added component {reference} to schematic")
|
||||
|
||||
return new_symbol
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding component: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def remove_component(schematic: Schematic, component_ref: str) -> bool:
|
||||
"""Remove a component from the schematic by reference designator"""
|
||||
try:
|
||||
# kicad-skip doesn't have a direct remove_symbol method by reference.
|
||||
# We need to find the symbol and then remove it from the symbols list.
|
||||
symbol_to_remove = None
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
symbol_to_remove = symbol
|
||||
break
|
||||
|
||||
if symbol_to_remove:
|
||||
schematic.symbol._elements.remove(symbol_to_remove)
|
||||
logger.info(f"Removed component {component_ref} from schematic.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool:
|
||||
"""Update component properties by reference designator"""
|
||||
try:
|
||||
symbol_to_update = None
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
symbol_to_update = symbol
|
||||
break
|
||||
|
||||
if symbol_to_update:
|
||||
for key, value in new_properties.items():
|
||||
if key in symbol_to_update.property:
|
||||
symbol_to_update.property[key].value = value
|
||||
else:
|
||||
symbol_to_update.property.append(key, value)
|
||||
logger.info(f"Updated properties for component {component_ref}.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_component(schematic: Schematic, component_ref: str) -> Any:
|
||||
"""Get a component by reference designator"""
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
logger.debug(f"Found component with reference {component_ref}.")
|
||||
return symbol
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def search_components(schematic: Schematic, query: str) -> List[Any]:
|
||||
"""Search for components matching criteria (basic implementation)"""
|
||||
# This is a basic search, could be expanded to use regex or more complex logic
|
||||
matching_components = []
|
||||
query_lower = query.lower()
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
query_lower in symbol.reference.lower()
|
||||
or query_lower in symbol.name.lower()
|
||||
or (
|
||||
hasattr(symbol.property, "Value")
|
||||
and query_lower in symbol.property.Value.value.lower()
|
||||
)
|
||||
):
|
||||
matching_components.append(symbol)
|
||||
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
|
||||
return matching_components
|
||||
|
||||
@staticmethod
|
||||
def get_all_components(schematic: Schematic) -> List[Any]:
|
||||
"""Get all components in schematic"""
|
||||
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
|
||||
return list(schematic.symbol)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage (for testing)
|
||||
from schematic import ( # Assuming schematic.py is in the same directory
|
||||
SchematicManager,
|
||||
)
|
||||
|
||||
# Create a new schematic
|
||||
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
|
||||
|
||||
# Add components
|
||||
comp1_def = {"type": "R", "reference": "R1", "value": "10k", "x": 100, "y": 100}
|
||||
comp2_def = {
|
||||
"type": "C",
|
||||
"reference": "C1",
|
||||
"value": "0.1uF",
|
||||
"x": 200,
|
||||
"y": 100,
|
||||
"library": "Device",
|
||||
}
|
||||
comp3_def = {
|
||||
"type": "LED",
|
||||
"reference": "D1",
|
||||
"x": 300,
|
||||
"y": 100,
|
||||
"library": "Device",
|
||||
"properties": {"Color": "Red"},
|
||||
}
|
||||
|
||||
comp1 = ComponentManager.add_component(test_sch, comp1_def)
|
||||
comp2 = ComponentManager.add_component(test_sch, comp2_def)
|
||||
comp3 = ComponentManager.add_component(test_sch, comp3_def)
|
||||
|
||||
# Get a component
|
||||
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
|
||||
if retrieved_comp:
|
||||
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
|
||||
|
||||
# Update a component
|
||||
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
|
||||
|
||||
# Search components
|
||||
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
|
||||
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
|
||||
|
||||
# Get all components
|
||||
all_comps = ComponentManager.get_all_components(test_sch)
|
||||
print(f"All components: {[c.reference for c in all_comps]}")
|
||||
|
||||
# Remove a component
|
||||
ComponentManager.remove_component(test_sch, "D1")
|
||||
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
|
||||
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
|
||||
|
||||
# Save the schematic (optional)
|
||||
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")
|
||||
|
||||
# Clean up (if saved)
|
||||
# if os.path.exists("component_test.kicad_sch"):
|
||||
# os.remove("component_test.kicad_sch")
|
||||
# print("Cleaned up component_test.kicad_sch")
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import dynamic symbol loader
|
||||
try:
|
||||
from commands.dynamic_symbol_loader import DynamicSymbolLoader
|
||||
|
||||
DYNAMIC_LOADING_AVAILABLE = True
|
||||
except ImportError:
|
||||
logger.warning("Dynamic symbol loader not available - falling back to template-only mode")
|
||||
DYNAMIC_LOADING_AVAILABLE = False
|
||||
|
||||
|
||||
class ComponentManager:
|
||||
"""Manage components in a schematic"""
|
||||
|
||||
# Initialize dynamic loader (class variable, shared across instances)
|
||||
_dynamic_loader = None
|
||||
|
||||
@classmethod
|
||||
def get_dynamic_loader(cls) -> Any:
|
||||
"""Get or create dynamic symbol loader instance"""
|
||||
if cls._dynamic_loader is None and DYNAMIC_LOADING_AVAILABLE:
|
||||
cls._dynamic_loader = DynamicSymbolLoader()
|
||||
return cls._dynamic_loader
|
||||
|
||||
# Template symbol references mapping component type to template reference
|
||||
TEMPLATE_MAP = {
|
||||
# Passives
|
||||
"R": "_TEMPLATE_R",
|
||||
"C": "_TEMPLATE_C",
|
||||
"L": "_TEMPLATE_L",
|
||||
"Y": "_TEMPLATE_Y",
|
||||
"Crystal": "_TEMPLATE_Y",
|
||||
# Semiconductors
|
||||
"D": "_TEMPLATE_D",
|
||||
"LED": "_TEMPLATE_LED",
|
||||
"Q": "_TEMPLATE_Q_NPN",
|
||||
"Q_NPN": "_TEMPLATE_Q_NPN",
|
||||
"Q_NMOS": "_TEMPLATE_Q_NMOS",
|
||||
"MOSFET": "_TEMPLATE_Q_NMOS",
|
||||
# ICs
|
||||
"U": "_TEMPLATE_U_OPAMP",
|
||||
"OpAmp": "_TEMPLATE_U_OPAMP",
|
||||
"IC": "_TEMPLATE_U_OPAMP",
|
||||
"U_REG": "_TEMPLATE_U_REG",
|
||||
"Regulator": "_TEMPLATE_U_REG",
|
||||
# Connectors
|
||||
"J": "_TEMPLATE_J2",
|
||||
"J2": "_TEMPLATE_J2",
|
||||
"J4": "_TEMPLATE_J4",
|
||||
"Conn_2": "_TEMPLATE_J2",
|
||||
"Conn_4": "_TEMPLATE_J4",
|
||||
# Misc
|
||||
"SW": "_TEMPLATE_SW",
|
||||
"Button": "_TEMPLATE_SW",
|
||||
"Switch": "_TEMPLATE_SW",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_or_create_template(
|
||||
cls,
|
||||
schematic: Schematic,
|
||||
comp_type: str,
|
||||
library: Optional[str] = None,
|
||||
schematic_path: Optional[Path] = None,
|
||||
) -> tuple:
|
||||
"""
|
||||
Get template reference for a component type, creating it dynamically if needed
|
||||
|
||||
Args:
|
||||
schematic: Schematic object
|
||||
comp_type: Component type (e.g., 'R', 'LED', 'STM32F103C8Tx')
|
||||
library: Optional library name (defaults to 'Device' for common types)
|
||||
schematic_path: Optional path to schematic file (required for dynamic loading)
|
||||
|
||||
Returns:
|
||||
Tuple of (template_ref, needs_reload) where needs_reload indicates if schematic must be reloaded
|
||||
"""
|
||||
|
||||
# Helper function to check if template exists in schematic
|
||||
def template_exists(schematic: Any, template_ref: str) -> bool:
|
||||
"""Check if template exists by iterating symbols (handles special characters)"""
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
hasattr(symbol.property, "Reference")
|
||||
and symbol.property.Reference.value == template_ref
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
# 1. Check static template map first
|
||||
if comp_type in cls.TEMPLATE_MAP:
|
||||
template_ref = cls.TEMPLATE_MAP[comp_type]
|
||||
# Verify template exists in schematic
|
||||
if template_exists(schematic, template_ref):
|
||||
logger.debug(f"Using static template: {template_ref}")
|
||||
return (template_ref, False)
|
||||
|
||||
# 2. Check if dynamically loaded template already exists
|
||||
# Build potential template reference names
|
||||
potential_refs = []
|
||||
if library:
|
||||
potential_refs.append(f"_TEMPLATE_{library}_{comp_type}")
|
||||
potential_refs.append(f"_TEMPLATE_{comp_type}")
|
||||
if comp_type in cls.TEMPLATE_MAP:
|
||||
potential_refs.append(cls.TEMPLATE_MAP[comp_type])
|
||||
|
||||
# Check each potential reference
|
||||
for template_ref in potential_refs:
|
||||
if template_exists(schematic, template_ref):
|
||||
logger.debug(f"Found existing template: {template_ref}")
|
||||
return (template_ref, False)
|
||||
|
||||
# 3. Try dynamic loading
|
||||
if not DYNAMIC_LOADING_AVAILABLE:
|
||||
logger.warning(
|
||||
f"Component type '{comp_type}' not in static templates and dynamic loading unavailable"
|
||||
)
|
||||
# Fall back to basic resistor template
|
||||
return ("_TEMPLATE_R", False)
|
||||
|
||||
loader = cls.get_dynamic_loader()
|
||||
if not loader:
|
||||
logger.warning("Dynamic loader unavailable, using fallback template")
|
||||
return ("_TEMPLATE_R", False)
|
||||
|
||||
# Check if schematic path is available
|
||||
if schematic_path is None:
|
||||
logger.warning("Dynamic loading requires schematic file path but none was provided")
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||
return (fallback, False)
|
||||
|
||||
# Determine library name
|
||||
if library is None:
|
||||
# Default library for common component types
|
||||
library = "Device" # Most passives and basic components are in Device library
|
||||
|
||||
try:
|
||||
logger.info(f"Attempting dynamic load: {library}:{comp_type} from {schematic_path}")
|
||||
|
||||
# Use dynamic symbol loader to inject symbol and create template
|
||||
template_ref = loader.load_symbol_dynamically(schematic_path, library, comp_type)
|
||||
|
||||
logger.info(f"Successfully loaded symbol dynamically. Template ref: {template_ref}")
|
||||
# Signal that schematic needs reload to see new template
|
||||
return (template_ref, True)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Dynamic loading failed: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
# Fall back to static template if available
|
||||
fallback = cls.TEMPLATE_MAP.get(comp_type, "_TEMPLATE_R")
|
||||
return (fallback, False)
|
||||
|
||||
@staticmethod
|
||||
def add_component(
|
||||
schematic: Schematic, component_def: dict, schematic_path: Optional[Path] = None
|
||||
) -> Any:
|
||||
"""
|
||||
Add a component to the schematic by cloning from template
|
||||
|
||||
Args:
|
||||
schematic: Schematic object to add component to
|
||||
component_def: Component definition dictionary
|
||||
schematic_path: Optional path to schematic file (enables dynamic symbol loading)
|
||||
|
||||
Returns:
|
||||
Tuple of (new_symbol, needs_reload) where needs_reload indicates if caller should reload schematic
|
||||
"""
|
||||
try:
|
||||
from commands.schematic import SchematicManager
|
||||
|
||||
logger.info(
|
||||
f"Adding component: type={component_def.get('type')}, ref={component_def.get('reference')}"
|
||||
)
|
||||
logger.debug(f"Full component_def: {component_def}")
|
||||
|
||||
# Get component type and determine template
|
||||
comp_type = component_def.get("type", "R")
|
||||
library = component_def.get("library", None) # Optional library specification
|
||||
|
||||
# Get template reference (static or dynamic)
|
||||
template_ref, needs_reload = ComponentManager.get_or_create_template(
|
||||
schematic, comp_type, library, schematic_path
|
||||
)
|
||||
|
||||
# If dynamic loading occurred, reload schematic to see new template
|
||||
if needs_reload and schematic_path:
|
||||
logger.info(f"Reloading schematic after dynamic loading: {schematic_path}")
|
||||
schematic = SchematicManager.load_schematic(str(schematic_path))
|
||||
|
||||
# Find template symbol by reference (handles special characters like +)
|
||||
template_symbol = None
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
hasattr(symbol.property, "Reference")
|
||||
and symbol.property.Reference.value == template_ref
|
||||
):
|
||||
template_symbol = symbol
|
||||
break
|
||||
|
||||
if not template_symbol:
|
||||
logger.error(
|
||||
f"Template symbol {template_ref} not found in schematic. Available symbols: {[str(s.property.Reference.value) for s in schematic.symbol]}"
|
||||
)
|
||||
raise ValueError(
|
||||
f"Template symbol {template_ref} not found. The schematic must be created from template_with_symbols.kicad_sch"
|
||||
)
|
||||
|
||||
# Clone the template symbol
|
||||
new_symbol = template_symbol.clone()
|
||||
logger.debug(f"Cloned template symbol {template_ref}")
|
||||
|
||||
# Set reference
|
||||
reference = component_def.get("reference", "R?")
|
||||
new_symbol.property.Reference.value = reference
|
||||
logger.debug(f"Set reference to {reference}")
|
||||
|
||||
# Set value
|
||||
if "value" in component_def:
|
||||
new_symbol.property.Value.value = component_def["value"]
|
||||
logger.debug(f"Set value to {component_def['value']}")
|
||||
|
||||
# Set footprint
|
||||
if "footprint" in component_def:
|
||||
new_symbol.property.Footprint.value = component_def["footprint"]
|
||||
logger.debug(f"Set footprint to {component_def['footprint']}")
|
||||
|
||||
# Set datasheet
|
||||
if "datasheet" in component_def:
|
||||
new_symbol.property.Datasheet.value = component_def["datasheet"]
|
||||
|
||||
# Set position
|
||||
x = component_def.get("x", 0)
|
||||
y = component_def.get("y", 0)
|
||||
rotation = component_def.get("rotation", 0)
|
||||
new_symbol.at.value = [x, y, rotation]
|
||||
logger.debug(f"Set position to ({x}, {y}, {rotation})")
|
||||
|
||||
# Set BOM and board flags
|
||||
new_symbol.in_bom.value = component_def.get("in_bom", True)
|
||||
new_symbol.on_board.value = component_def.get("on_board", True)
|
||||
new_symbol.dnp.value = component_def.get("dnp", False)
|
||||
|
||||
# Generate new UUID
|
||||
new_symbol.uuid.value = str(uuid.uuid4())
|
||||
|
||||
# Append to schematic
|
||||
schematic.symbol.append(new_symbol)
|
||||
logger.info(f"Successfully added component {reference} to schematic")
|
||||
|
||||
return new_symbol
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding component: {e}", exc_info=True)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def remove_component(schematic: Schematic, component_ref: str) -> bool:
|
||||
"""Remove a component from the schematic by reference designator"""
|
||||
try:
|
||||
# kicad-skip doesn't have a direct remove_symbol method by reference.
|
||||
# We need to find the symbol and then remove it from the symbols list.
|
||||
symbol_to_remove = None
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
symbol_to_remove = symbol
|
||||
break
|
||||
|
||||
if symbol_to_remove:
|
||||
schematic.symbol._elements.remove(symbol_to_remove)
|
||||
logger.info(f"Removed component {component_ref} from schematic.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def update_component(schematic: Schematic, component_ref: str, new_properties: dict) -> bool:
|
||||
"""Update component properties by reference designator"""
|
||||
try:
|
||||
symbol_to_update = None
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
symbol_to_update = symbol
|
||||
break
|
||||
|
||||
if symbol_to_update:
|
||||
for key, value in new_properties.items():
|
||||
if key in symbol_to_update.property:
|
||||
symbol_to_update.property[key].value = value
|
||||
else:
|
||||
symbol_to_update.property.append(key, value)
|
||||
logger.info(f"Updated properties for component {component_ref}.")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating component {component_ref}: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_component(schematic: Schematic, component_ref: str) -> Any:
|
||||
"""Get a component by reference designator"""
|
||||
for symbol in schematic.symbol:
|
||||
if symbol.reference == component_ref:
|
||||
logger.debug(f"Found component with reference {component_ref}.")
|
||||
return symbol
|
||||
logger.warning(f"Component with reference {component_ref} not found.")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def search_components(schematic: Schematic, query: str) -> List[Any]:
|
||||
"""Search for components matching criteria (basic implementation)"""
|
||||
# This is a basic search, could be expanded to use regex or more complex logic
|
||||
matching_components = []
|
||||
query_lower = query.lower()
|
||||
for symbol in schematic.symbol:
|
||||
if (
|
||||
query_lower in symbol.reference.lower()
|
||||
or query_lower in symbol.name.lower()
|
||||
or (
|
||||
hasattr(symbol.property, "Value")
|
||||
and query_lower in symbol.property.Value.value.lower()
|
||||
)
|
||||
):
|
||||
matching_components.append(symbol)
|
||||
logger.debug(f"Found {len(matching_components)} components matching query '{query}'.")
|
||||
return matching_components
|
||||
|
||||
@staticmethod
|
||||
def get_all_components(schematic: Schematic) -> List[Any]:
|
||||
"""Get all components in schematic"""
|
||||
logger.debug(f"Retrieving all {len(schematic.symbol)} components.")
|
||||
return list(schematic.symbol)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage (for testing)
|
||||
from schematic import ( # Assuming schematic.py is in the same directory
|
||||
SchematicManager,
|
||||
)
|
||||
|
||||
# Create a new schematic
|
||||
test_sch = SchematicManager.create_schematic("ComponentTestSchematic")
|
||||
|
||||
# Add components
|
||||
comp1_def = {"type": "R", "reference": "R1", "value": "10k", "x": 100, "y": 100}
|
||||
comp2_def = {
|
||||
"type": "C",
|
||||
"reference": "C1",
|
||||
"value": "0.1uF",
|
||||
"x": 200,
|
||||
"y": 100,
|
||||
"library": "Device",
|
||||
}
|
||||
comp3_def = {
|
||||
"type": "LED",
|
||||
"reference": "D1",
|
||||
"x": 300,
|
||||
"y": 100,
|
||||
"library": "Device",
|
||||
"properties": {"Color": "Red"},
|
||||
}
|
||||
|
||||
comp1 = ComponentManager.add_component(test_sch, comp1_def)
|
||||
comp2 = ComponentManager.add_component(test_sch, comp2_def)
|
||||
comp3 = ComponentManager.add_component(test_sch, comp3_def)
|
||||
|
||||
# Get a component
|
||||
retrieved_comp = ComponentManager.get_component(test_sch, "C1")
|
||||
if retrieved_comp:
|
||||
print(f"Retrieved component: {retrieved_comp.reference} ({retrieved_comp.value})")
|
||||
|
||||
# Update a component
|
||||
ComponentManager.update_component(test_sch, "R1", {"value": "20k", "Tolerance": "5%"})
|
||||
|
||||
# Search components
|
||||
matching_comps = ComponentManager.search_components(test_sch, "100") # Search by position
|
||||
print(f"Search results for '100': {[c.reference for c in matching_comps]}")
|
||||
|
||||
# Get all components
|
||||
all_comps = ComponentManager.get_all_components(test_sch)
|
||||
print(f"All components: {[c.reference for c in all_comps]}")
|
||||
|
||||
# Remove a component
|
||||
ComponentManager.remove_component(test_sch, "D1")
|
||||
all_comps_after_remove = ComponentManager.get_all_components(test_sch)
|
||||
print(f"Components after removing D1: {[c.reference for c in all_comps_after_remove]}")
|
||||
|
||||
# Save the schematic (optional)
|
||||
# SchematicManager.save_schematic(test_sch, "component_test.kicad_sch")
|
||||
|
||||
# Clean up (if saved)
|
||||
# if os.path.exists("component_test.kicad_sch"):
|
||||
# os.remove("component_test.kicad_sch")
|
||||
# print("Cleaned up component_test.kicad_sch")
|
||||
|
||||
@@ -1,459 +1,459 @@
|
||||
"""
|
||||
Design rules command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class DesignRuleCommands:
|
||||
"""Handles design rule checking and configuration"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set design rules for the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
design_settings = self.board.GetDesignSettings()
|
||||
|
||||
# Convert mm to nanometers for KiCAD internal units
|
||||
scale = 1000000 # mm to nm
|
||||
|
||||
# Set clearance
|
||||
if "clearance" in params:
|
||||
design_settings.m_MinClearance = int(params["clearance"] * scale)
|
||||
|
||||
# KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed)
|
||||
# Track if we set any custom track/via values
|
||||
custom_values_set = False
|
||||
|
||||
if "trackWidth" in params:
|
||||
design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale))
|
||||
custom_values_set = True
|
||||
|
||||
# Via settings
|
||||
if "viaDiameter" in params:
|
||||
design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale))
|
||||
custom_values_set = True
|
||||
if "viaDrill" in params:
|
||||
design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale))
|
||||
custom_values_set = True
|
||||
|
||||
# KiCAD 9.0: Activate custom track/via values so they become the current values
|
||||
if custom_values_set:
|
||||
design_settings.UseCustomTrackViaSize(True)
|
||||
|
||||
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
|
||||
if "microViaDiameter" in params:
|
||||
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
|
||||
if "microViaDrill" in params:
|
||||
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
|
||||
|
||||
# Set minimum values
|
||||
if "minTrackWidth" in params:
|
||||
design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
|
||||
if "minViaDiameter" in params:
|
||||
design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
|
||||
|
||||
# KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead
|
||||
if "minViaDrill" in params:
|
||||
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
|
||||
|
||||
if "minMicroViaDiameter" in params:
|
||||
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
|
||||
if "minMicroViaDrill" in params:
|
||||
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
|
||||
|
||||
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
|
||||
if "minHoleDiameter" in params:
|
||||
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
|
||||
|
||||
# KiCAD 9.0: Added hole clearance settings
|
||||
if "holeClearance" in params:
|
||||
design_settings.m_HoleClearance = int(params["holeClearance"] * scale)
|
||||
if "holeToHoleMin" in params:
|
||||
design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale)
|
||||
|
||||
# Build response with KiCAD 9.0 compatible properties
|
||||
# After UseCustomTrackViaSize(True), GetCurrent* returns the custom values
|
||||
response_rules = {
|
||||
"clearance": design_settings.m_MinClearance / scale,
|
||||
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
||||
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
||||
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
||||
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
||||
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
||||
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
||||
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"holeClearance": design_settings.m_HoleClearance / scale,
|
||||
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
||||
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Updated design rules",
|
||||
"rules": response_rules,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting design rules: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to set design rules",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get current design rules - KiCAD 9.0 compatible"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
design_settings = self.board.GetDesignSettings()
|
||||
scale = 1000000 # nm to mm
|
||||
|
||||
# Build rules dict with KiCAD 9.0 compatible properties
|
||||
rules = {
|
||||
# Core clearance and track settings
|
||||
"clearance": design_settings.m_MinClearance / scale,
|
||||
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
||||
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
||||
# Via settings (current values from methods)
|
||||
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
||||
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
||||
# Via minimum values
|
||||
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
||||
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
||||
# Micro via settings
|
||||
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
# KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
|
||||
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
||||
"holeClearance": design_settings.m_HoleClearance / scale,
|
||||
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
||||
# Other constraints
|
||||
"copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
|
||||
"silkClearance": design_settings.m_SilkClearance / scale,
|
||||
}
|
||||
|
||||
return {"success": True, "rules": rules}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting design rules: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get design rules",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run Design Rule Check using kicad-cli"""
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
report_path = params.get("reportPath")
|
||||
|
||||
# Get the board file path
|
||||
board_file = self.board.GetFileName()
|
||||
if not board_file or not os.path.exists(board_file):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Board file not found",
|
||||
"errorDetails": "Cannot run DRC without a saved board file",
|
||||
}
|
||||
|
||||
# Find kicad-cli executable
|
||||
kicad_cli = self._find_kicad_cli()
|
||||
if not kicad_cli:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "kicad-cli not found",
|
||||
"errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.",
|
||||
}
|
||||
|
||||
# Create temporary JSON output file
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
||||
json_output = tmp.name
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = [
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"drc",
|
||||
"--format",
|
||||
"json",
|
||||
"--output",
|
||||
json_output,
|
||||
"--units",
|
||||
"mm",
|
||||
board_file,
|
||||
]
|
||||
|
||||
logger.info(f"Running DRC command: {' '.join(cmd)}")
|
||||
|
||||
# Run DRC
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600, # 10 minute timeout for large boards (21MB PCB needs time)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"DRC command failed: {result.stderr}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "DRC command failed",
|
||||
"errorDetails": result.stderr,
|
||||
}
|
||||
|
||||
# Read JSON output
|
||||
with open(json_output, "r", encoding="utf-8") as f:
|
||||
drc_data = json.load(f)
|
||||
|
||||
# Parse violations from kicad-cli output
|
||||
violations = []
|
||||
violation_counts: dict[str, int] = {}
|
||||
severity_counts = {"error": 0, "warning": 0, "info": 0}
|
||||
|
||||
for violation in drc_data.get("violations", []):
|
||||
vtype = violation.get("type", "unknown")
|
||||
vseverity = violation.get("severity", "error")
|
||||
|
||||
# Extract location from first item's pos (kicad-cli JSON format)
|
||||
items = violation.get("items", [])
|
||||
loc_x, loc_y = 0, 0
|
||||
if items and "pos" in items[0]:
|
||||
loc_x = items[0]["pos"].get("x", 0)
|
||||
loc_y = items[0]["pos"].get("y", 0)
|
||||
|
||||
violations.append(
|
||||
{
|
||||
"type": vtype,
|
||||
"severity": vseverity,
|
||||
"message": violation.get("description", ""),
|
||||
"location": {
|
||||
"x": loc_x,
|
||||
"y": loc_y,
|
||||
"unit": "mm",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Count violations by type
|
||||
violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
|
||||
|
||||
# Count by severity
|
||||
if vseverity in severity_counts:
|
||||
severity_counts[vseverity] += 1
|
||||
|
||||
# Determine where to save the violations file
|
||||
board_dir = os.path.dirname(board_file)
|
||||
board_name = os.path.splitext(os.path.basename(board_file))[0]
|
||||
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
|
||||
|
||||
# Always save violations to JSON file (for large result sets)
|
||||
with open(violations_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"board": board_file,
|
||||
"timestamp": drc_data.get("date", "unknown"),
|
||||
"total_violations": len(violations),
|
||||
"violation_counts": violation_counts,
|
||||
"severity_counts": severity_counts,
|
||||
"violations": violations,
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Save text report if requested
|
||||
if report_path:
|
||||
report_path = os.path.abspath(os.path.expanduser(report_path))
|
||||
cmd_report = [
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"drc",
|
||||
"--format",
|
||||
"report",
|
||||
"--output",
|
||||
report_path,
|
||||
"--units",
|
||||
"mm",
|
||||
board_file,
|
||||
]
|
||||
subprocess.run(cmd_report, capture_output=True, timeout=600)
|
||||
|
||||
# Return summary only (not full violations list)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Found {len(violations)} DRC violations",
|
||||
"summary": {
|
||||
"total": len(violations),
|
||||
"by_severity": severity_counts,
|
||||
"by_type": violation_counts,
|
||||
},
|
||||
"violationsFile": violations_file,
|
||||
"reportPath": report_path if report_path else None,
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temp JSON file
|
||||
if os.path.exists(json_output):
|
||||
os.unlink(json_output)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("DRC command timed out")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "DRC command timed out",
|
||||
"errorDetails": "Command took longer than 600 seconds (10 minutes)",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error running DRC: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to run DRC",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _find_kicad_cli(self) -> Optional[str]:
|
||||
"""Find kicad-cli executable"""
|
||||
import platform
|
||||
import shutil
|
||||
|
||||
# Try system PATH first
|
||||
cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli"
|
||||
cli_path = shutil.which(cli_name)
|
||||
if cli_path:
|
||||
return cli_path
|
||||
|
||||
# Try common installation paths (version-specific)
|
||||
if platform.system() == "Windows":
|
||||
common_paths = [
|
||||
r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\bin\kicad-cli.exe",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
elif platform.system() == "Darwin": # macOS
|
||||
common_paths = [
|
||||
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
|
||||
"/usr/local/bin/kicad-cli",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
else: # Linux
|
||||
common_paths = [
|
||||
"/usr/bin/kicad-cli",
|
||||
"/usr/local/bin/kicad-cli",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Get list of DRC violations
|
||||
|
||||
Note: This command internally uses run_drc() which calls kicad-cli.
|
||||
The old BOARD.GetDRCMarkers() API was removed in KiCAD 9.0.
|
||||
This implementation provides backward compatibility by parsing kicad-cli output.
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
severity = params.get("severity", "all")
|
||||
|
||||
# Run DRC using kicad-cli (this saves violations to JSON file)
|
||||
drc_result = self.run_drc({})
|
||||
|
||||
if not drc_result.get("success"):
|
||||
return drc_result # Return the error from run_drc
|
||||
|
||||
# Read violations from the saved JSON file
|
||||
violations_file = drc_result.get("violationsFile")
|
||||
if not violations_file or not os.path.exists(violations_file):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Violations file not found",
|
||||
"errorDetails": "run_drc did not create violations file",
|
||||
}
|
||||
|
||||
# Load violations from file
|
||||
with open(violations_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
all_violations = data.get("violations", [])
|
||||
|
||||
# Filter by severity if specified
|
||||
if severity != "all":
|
||||
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
|
||||
else:
|
||||
filtered_violations = all_violations
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"violations": filtered_violations,
|
||||
"violationsFile": violations_file, # Include file path for reference
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting DRC violations: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get DRC violations",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
"""
|
||||
Design rules command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import pcbnew
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class DesignRuleCommands:
|
||||
"""Handles design rule checking and configuration"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def set_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Set design rules for the PCB"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
design_settings = self.board.GetDesignSettings()
|
||||
|
||||
# Convert mm to nanometers for KiCAD internal units
|
||||
scale = 1000000 # mm to nm
|
||||
|
||||
# Set clearance
|
||||
if "clearance" in params:
|
||||
design_settings.m_MinClearance = int(params["clearance"] * scale)
|
||||
|
||||
# KiCAD 9.0: Use SetCustom* methods instead of SetCurrent* (which were removed)
|
||||
# Track if we set any custom track/via values
|
||||
custom_values_set = False
|
||||
|
||||
if "trackWidth" in params:
|
||||
design_settings.SetCustomTrackWidth(int(params["trackWidth"] * scale))
|
||||
custom_values_set = True
|
||||
|
||||
# Via settings
|
||||
if "viaDiameter" in params:
|
||||
design_settings.SetCustomViaSize(int(params["viaDiameter"] * scale))
|
||||
custom_values_set = True
|
||||
if "viaDrill" in params:
|
||||
design_settings.SetCustomViaDrill(int(params["viaDrill"] * scale))
|
||||
custom_values_set = True
|
||||
|
||||
# KiCAD 9.0: Activate custom track/via values so they become the current values
|
||||
if custom_values_set:
|
||||
design_settings.UseCustomTrackViaSize(True)
|
||||
|
||||
# Set micro via settings (use properties - methods removed in KiCAD 9.0)
|
||||
if "microViaDiameter" in params:
|
||||
design_settings.m_MicroViasMinSize = int(params["microViaDiameter"] * scale)
|
||||
if "microViaDrill" in params:
|
||||
design_settings.m_MicroViasMinDrill = int(params["microViaDrill"] * scale)
|
||||
|
||||
# Set minimum values
|
||||
if "minTrackWidth" in params:
|
||||
design_settings.m_TrackMinWidth = int(params["minTrackWidth"] * scale)
|
||||
if "minViaDiameter" in params:
|
||||
design_settings.m_ViasMinSize = int(params["minViaDiameter"] * scale)
|
||||
|
||||
# KiCAD 9.0: m_ViasMinDrill removed - use m_MinThroughDrill instead
|
||||
if "minViaDrill" in params:
|
||||
design_settings.m_MinThroughDrill = int(params["minViaDrill"] * scale)
|
||||
|
||||
if "minMicroViaDiameter" in params:
|
||||
design_settings.m_MicroViasMinSize = int(params["minMicroViaDiameter"] * scale)
|
||||
if "minMicroViaDrill" in params:
|
||||
design_settings.m_MicroViasMinDrill = int(params["minMicroViaDrill"] * scale)
|
||||
|
||||
# KiCAD 9.0: m_MinHoleDiameter removed - use m_MinThroughDrill
|
||||
if "minHoleDiameter" in params:
|
||||
design_settings.m_MinThroughDrill = int(params["minHoleDiameter"] * scale)
|
||||
|
||||
# KiCAD 9.0: Added hole clearance settings
|
||||
if "holeClearance" in params:
|
||||
design_settings.m_HoleClearance = int(params["holeClearance"] * scale)
|
||||
if "holeToHoleMin" in params:
|
||||
design_settings.m_HoleToHoleMin = int(params["holeToHoleMin"] * scale)
|
||||
|
||||
# Build response with KiCAD 9.0 compatible properties
|
||||
# After UseCustomTrackViaSize(True), GetCurrent* returns the custom values
|
||||
response_rules = {
|
||||
"clearance": design_settings.m_MinClearance / scale,
|
||||
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
||||
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
||||
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
||||
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
||||
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
||||
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
||||
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"holeClearance": design_settings.m_HoleClearance / scale,
|
||||
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
||||
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "Updated design rules",
|
||||
"rules": response_rules,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting design rules: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to set design rules",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_design_rules(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get current design rules - KiCAD 9.0 compatible"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
design_settings = self.board.GetDesignSettings()
|
||||
scale = 1000000 # nm to mm
|
||||
|
||||
# Build rules dict with KiCAD 9.0 compatible properties
|
||||
rules = {
|
||||
# Core clearance and track settings
|
||||
"clearance": design_settings.m_MinClearance / scale,
|
||||
"trackWidth": design_settings.GetCurrentTrackWidth() / scale,
|
||||
"minTrackWidth": design_settings.m_TrackMinWidth / scale,
|
||||
# Via settings (current values from methods)
|
||||
"viaDiameter": design_settings.GetCurrentViaSize() / scale,
|
||||
"viaDrill": design_settings.GetCurrentViaDrill() / scale,
|
||||
# Via minimum values
|
||||
"minViaDiameter": design_settings.m_ViasMinSize / scale,
|
||||
"viasMinAnnularWidth": design_settings.m_ViasMinAnnularWidth / scale,
|
||||
# Micro via settings
|
||||
"microViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"microViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
"minMicroViaDiameter": design_settings.m_MicroViasMinSize / scale,
|
||||
"minMicroViaDrill": design_settings.m_MicroViasMinDrill / scale,
|
||||
# KiCAD 9.0: Hole and drill settings (replaces removed m_ViasMinDrill and m_MinHoleDiameter)
|
||||
"minThroughDrill": design_settings.m_MinThroughDrill / scale,
|
||||
"holeClearance": design_settings.m_HoleClearance / scale,
|
||||
"holeToHoleMin": design_settings.m_HoleToHoleMin / scale,
|
||||
# Other constraints
|
||||
"copperEdgeClearance": design_settings.m_CopperEdgeClearance / scale,
|
||||
"silkClearance": design_settings.m_SilkClearance / scale,
|
||||
}
|
||||
|
||||
return {"success": True, "rules": rules}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting design rules: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get design rules",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def run_drc(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Run Design Rule Check using kicad-cli"""
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
report_path = params.get("reportPath")
|
||||
|
||||
# Get the board file path
|
||||
board_file = self.board.GetFileName()
|
||||
if not board_file or not os.path.exists(board_file):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Board file not found",
|
||||
"errorDetails": "Cannot run DRC without a saved board file",
|
||||
}
|
||||
|
||||
# Find kicad-cli executable
|
||||
kicad_cli = self._find_kicad_cli()
|
||||
if not kicad_cli:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "kicad-cli not found",
|
||||
"errorDetails": "KiCAD CLI tool not found in system. Install KiCAD 8.0+ or set PATH.",
|
||||
}
|
||||
|
||||
# Create temporary JSON output file
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as tmp:
|
||||
json_output = tmp.name
|
||||
|
||||
try:
|
||||
# Build command
|
||||
cmd = [
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"drc",
|
||||
"--format",
|
||||
"json",
|
||||
"--output",
|
||||
json_output,
|
||||
"--units",
|
||||
"mm",
|
||||
board_file,
|
||||
]
|
||||
|
||||
logger.info(f"Running DRC command: {' '.join(cmd)}")
|
||||
|
||||
# Run DRC
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=600, # 10 minute timeout for large boards (21MB PCB needs time)
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"DRC command failed: {result.stderr}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "DRC command failed",
|
||||
"errorDetails": result.stderr,
|
||||
}
|
||||
|
||||
# Read JSON output
|
||||
with open(json_output, "r", encoding="utf-8") as f:
|
||||
drc_data = json.load(f)
|
||||
|
||||
# Parse violations from kicad-cli output
|
||||
violations = []
|
||||
violation_counts: dict[str, int] = {}
|
||||
severity_counts = {"error": 0, "warning": 0, "info": 0}
|
||||
|
||||
for violation in drc_data.get("violations", []):
|
||||
vtype = violation.get("type", "unknown")
|
||||
vseverity = violation.get("severity", "error")
|
||||
|
||||
# Extract location from first item's pos (kicad-cli JSON format)
|
||||
items = violation.get("items", [])
|
||||
loc_x, loc_y = 0, 0
|
||||
if items and "pos" in items[0]:
|
||||
loc_x = items[0]["pos"].get("x", 0)
|
||||
loc_y = items[0]["pos"].get("y", 0)
|
||||
|
||||
violations.append(
|
||||
{
|
||||
"type": vtype,
|
||||
"severity": vseverity,
|
||||
"message": violation.get("description", ""),
|
||||
"location": {
|
||||
"x": loc_x,
|
||||
"y": loc_y,
|
||||
"unit": "mm",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
# Count violations by type
|
||||
violation_counts[vtype] = violation_counts.get(vtype, 0) + 1
|
||||
|
||||
# Count by severity
|
||||
if vseverity in severity_counts:
|
||||
severity_counts[vseverity] += 1
|
||||
|
||||
# Determine where to save the violations file
|
||||
board_dir = os.path.dirname(board_file)
|
||||
board_name = os.path.splitext(os.path.basename(board_file))[0]
|
||||
violations_file = os.path.join(board_dir, f"{board_name}_drc_violations.json")
|
||||
|
||||
# Always save violations to JSON file (for large result sets)
|
||||
with open(violations_file, "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{
|
||||
"board": board_file,
|
||||
"timestamp": drc_data.get("date", "unknown"),
|
||||
"total_violations": len(violations),
|
||||
"violation_counts": violation_counts,
|
||||
"severity_counts": severity_counts,
|
||||
"violations": violations,
|
||||
},
|
||||
f,
|
||||
indent=2,
|
||||
)
|
||||
|
||||
# Save text report if requested
|
||||
if report_path:
|
||||
report_path = os.path.abspath(os.path.expanduser(report_path))
|
||||
cmd_report = [
|
||||
kicad_cli,
|
||||
"pcb",
|
||||
"drc",
|
||||
"--format",
|
||||
"report",
|
||||
"--output",
|
||||
report_path,
|
||||
"--units",
|
||||
"mm",
|
||||
board_file,
|
||||
]
|
||||
subprocess.run(cmd_report, capture_output=True, timeout=600)
|
||||
|
||||
# Return summary only (not full violations list)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Found {len(violations)} DRC violations",
|
||||
"summary": {
|
||||
"total": len(violations),
|
||||
"by_severity": severity_counts,
|
||||
"by_type": violation_counts,
|
||||
},
|
||||
"violationsFile": violations_file,
|
||||
"reportPath": report_path if report_path else None,
|
||||
}
|
||||
|
||||
finally:
|
||||
# Clean up temp JSON file
|
||||
if os.path.exists(json_output):
|
||||
os.unlink(json_output)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("DRC command timed out")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "DRC command timed out",
|
||||
"errorDetails": "Command took longer than 600 seconds (10 minutes)",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error running DRC: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to run DRC",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def _find_kicad_cli(self) -> Optional[str]:
|
||||
"""Find kicad-cli executable"""
|
||||
import platform
|
||||
import shutil
|
||||
|
||||
# Try system PATH first
|
||||
cli_name = "kicad-cli.exe" if platform.system() == "Windows" else "kicad-cli"
|
||||
cli_path = shutil.which(cli_name)
|
||||
if cli_path:
|
||||
return cli_path
|
||||
|
||||
# Try common installation paths (version-specific)
|
||||
if platform.system() == "Windows":
|
||||
common_paths = [
|
||||
r"C:\Program Files\KiCad\10.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\9.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\8.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\10.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\9.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files (x86)\KiCad\8.0\bin\kicad-cli.exe",
|
||||
r"C:\Program Files\KiCad\bin\kicad-cli.exe",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
elif platform.system() == "Darwin": # macOS
|
||||
common_paths = [
|
||||
"/Applications/KiCad/KiCad.app/Contents/MacOS/kicad-cli",
|
||||
"/usr/local/bin/kicad-cli",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
else: # Linux
|
||||
common_paths = [
|
||||
"/usr/bin/kicad-cli",
|
||||
"/usr/local/bin/kicad-cli",
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
def get_drc_violations(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Get list of DRC violations
|
||||
|
||||
Note: This command internally uses run_drc() which calls kicad-cli.
|
||||
The old BOARD.GetDRCMarkers() API was removed in KiCAD 9.0.
|
||||
This implementation provides backward compatibility by parsing kicad-cli output.
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
severity = params.get("severity", "all")
|
||||
|
||||
# Run DRC using kicad-cli (this saves violations to JSON file)
|
||||
drc_result = self.run_drc({})
|
||||
|
||||
if not drc_result.get("success"):
|
||||
return drc_result # Return the error from run_drc
|
||||
|
||||
# Read violations from the saved JSON file
|
||||
violations_file = drc_result.get("violationsFile")
|
||||
if not violations_file or not os.path.exists(violations_file):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Violations file not found",
|
||||
"errorDetails": "run_drc did not create violations file",
|
||||
}
|
||||
|
||||
# Load violations from file
|
||||
with open(violations_file, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
all_violations = data.get("violations", [])
|
||||
|
||||
# Filter by severity if specified
|
||||
if severity != "all":
|
||||
filtered_violations = [v for v in all_violations if v.get("severity") == severity]
|
||||
else:
|
||||
filtered_violations = all_violations
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"violations": filtered_violations,
|
||||
"violationsFile": violations_file, # Include file path for reference
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting DRC violations: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get DRC violations",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,303 +1,303 @@
|
||||
"""
|
||||
JLCPCB API client for fetching parts data
|
||||
|
||||
Handles authentication and downloading the JLCPCB parts library
|
||||
for integration with KiCAD component selection.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class JLCPCBClient:
|
||||
"""
|
||||
Client for JLCPCB API
|
||||
|
||||
Handles HMAC-SHA256 signature-based authentication and fetching
|
||||
the complete parts library from JLCPCB's external API.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://jlcpcb.com/external"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: Optional[str] = None,
|
||||
access_key: Optional[str] = None,
|
||||
secret_key: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize JLCPCB API client
|
||||
|
||||
Args:
|
||||
app_id: JLCPCB App ID (or reads from JLCPCB_APP_ID env var)
|
||||
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
|
||||
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
|
||||
"""
|
||||
self.app_id = app_id or os.getenv("JLCPCB_APP_ID")
|
||||
self.access_key = access_key or os.getenv("JLCPCB_API_KEY")
|
||||
self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET")
|
||||
|
||||
if not self.app_id or not self.access_key or not self.secret_key:
|
||||
logger.warning(
|
||||
"JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_nonce() -> str:
|
||||
"""Generate a 32-character random nonce"""
|
||||
chars = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(chars) for _ in range(32))
|
||||
|
||||
def _build_signature_string(
|
||||
self, method: str, path: str, timestamp: int, nonce: str, body: str
|
||||
) -> str:
|
||||
"""
|
||||
Build the signature string according to JLCPCB spec
|
||||
|
||||
Format:
|
||||
<HTTP Method>\n
|
||||
<Request Path>\n
|
||||
<Timestamp>\n
|
||||
<Nonce>\n
|
||||
<Request Body>\n
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: Request path with query params
|
||||
timestamp: Unix timestamp in seconds
|
||||
nonce: 32-character random string
|
||||
body: Request body (empty string for GET)
|
||||
|
||||
Returns:
|
||||
Signature string
|
||||
"""
|
||||
return f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n"
|
||||
|
||||
def _sign(self, signature_string: str) -> str:
|
||||
"""
|
||||
Sign the signature string with HMAC-SHA256
|
||||
|
||||
Args:
|
||||
signature_string: The string to sign
|
||||
|
||||
Returns:
|
||||
Base64-encoded signature
|
||||
"""
|
||||
signature_bytes = hmac.new(
|
||||
self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
|
||||
).digest()
|
||||
return base64.b64encode(signature_bytes).decode("utf-8")
|
||||
|
||||
def _get_auth_header(self, method: str, path: str, body: str = "") -> str:
|
||||
"""
|
||||
Generate the Authorization header for JLCPCB API requests
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: Request path with query params
|
||||
body: Request body JSON string (empty for GET)
|
||||
|
||||
Returns:
|
||||
Authorization header value
|
||||
"""
|
||||
if not self.app_id or not self.access_key or not self.secret_key:
|
||||
raise Exception(
|
||||
"JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
|
||||
)
|
||||
|
||||
nonce = self._generate_nonce()
|
||||
timestamp = int(time.time())
|
||||
|
||||
signature_string = self._build_signature_string(method, path, timestamp, nonce, body)
|
||||
signature = self._sign(signature_string)
|
||||
|
||||
logger.debug(f"Signature string:\n{repr(signature_string)}")
|
||||
logger.debug(f"Signature: {signature}")
|
||||
logger.debug(
|
||||
f'Auth header: JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
|
||||
)
|
||||
|
||||
return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
|
||||
|
||||
def fetch_parts_page(self, last_key: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Fetch one page of parts from JLCPCB API
|
||||
|
||||
Args:
|
||||
last_key: Pagination key from previous response (None for first page)
|
||||
|
||||
Returns:
|
||||
Response dict with parts data and pagination info
|
||||
"""
|
||||
path = "/component/getComponentInfos"
|
||||
|
||||
payload = {}
|
||||
if last_key:
|
||||
payload["lastKey"] = last_key
|
||||
|
||||
# Convert payload to JSON string for signing
|
||||
# For POST requests, we always send JSON, even if empty dict
|
||||
body_str = json.dumps(payload, separators=(",", ":"))
|
||||
|
||||
# Generate authorization header
|
||||
auth_header = self._get_auth_header("POST", path, body_str)
|
||||
|
||||
headers = {"Authorization": auth_header, "Content-Type": "application/json"}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
|
||||
)
|
||||
|
||||
logger.debug(f"Response status: {response.status_code}")
|
||||
logger.debug(f"Response headers: {response.headers}")
|
||||
logger.debug(f"Response text: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise Exception(
|
||||
f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}"
|
||||
)
|
||||
|
||||
return data["data"]
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to fetch parts page: {e}")
|
||||
raise Exception(f"JLCPCB API request failed: {e}")
|
||||
|
||||
def download_full_database(
|
||||
self, callback: Optional[Callable[[int, int, str], None]] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Download entire parts library from JLCPCB
|
||||
|
||||
Args:
|
||||
callback: Optional progress callback function(current_page, total_parts, status_msg)
|
||||
|
||||
Returns:
|
||||
List of all parts
|
||||
"""
|
||||
all_parts = []
|
||||
last_key = None
|
||||
page = 0
|
||||
|
||||
logger.info("Starting full JLCPCB parts database download...")
|
||||
|
||||
while True:
|
||||
page += 1
|
||||
|
||||
try:
|
||||
data = self.fetch_parts_page(last_key)
|
||||
|
||||
parts = data.get("componentInfos", [])
|
||||
all_parts.extend(parts)
|
||||
|
||||
last_key = data.get("lastKey")
|
||||
|
||||
if callback:
|
||||
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
|
||||
else:
|
||||
logger.info(f"Page {page}: Downloaded {len(all_parts)} parts so far...")
|
||||
|
||||
# Check if there are more pages
|
||||
if not last_key or len(parts) == 0:
|
||||
break
|
||||
|
||||
# Rate limiting - be nice to the API
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading parts at page {page}: {e}")
|
||||
if len(all_parts) > 0:
|
||||
logger.warning(f"Partial download available: {len(all_parts)} parts")
|
||||
return all_parts
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
|
||||
return all_parts
|
||||
|
||||
def get_part_by_lcsc(self, lcsc_number: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get detailed information for a specific LCSC part number
|
||||
|
||||
Note: This uses the same endpoint as fetching parts, as JLCPCB doesn't
|
||||
have a dedicated single-part endpoint. In practice, you should use
|
||||
the local database after initial download.
|
||||
|
||||
Args:
|
||||
lcsc_number: LCSC part number (e.g., "C25804")
|
||||
|
||||
Returns:
|
||||
Part info dict or None if not found
|
||||
"""
|
||||
# For now, this would require searching through pages
|
||||
# In practice, you'd use the local database
|
||||
logger.warning("get_part_by_lcsc should use local database, not API")
|
||||
return None
|
||||
|
||||
|
||||
def test_jlcpcb_connection(
|
||||
app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Test JLCPCB API connection
|
||||
|
||||
Args:
|
||||
app_id: Optional App ID (uses env var if not provided)
|
||||
access_key: Optional Access Key (uses env var if not provided)
|
||||
secret_key: Optional Secret Key (uses env var if not provided)
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = JLCPCBClient(app_id, access_key, secret_key)
|
||||
# Test by fetching first page
|
||||
data = client.fetch_parts_page()
|
||||
logger.info("JLCPCB API connection test successful")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"JLCPCB API connection test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the JLCPCB client
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
print("Testing JLCPCB API connection...")
|
||||
if test_jlcpcb_connection():
|
||||
print("✓ Connection successful!")
|
||||
|
||||
client = JLCPCBClient()
|
||||
print("\nFetching first page of parts...")
|
||||
data = client.fetch_parts_page()
|
||||
parts = data.get("componentInfos", [])
|
||||
print(f"✓ Retrieved {len(parts)} parts in first page")
|
||||
|
||||
if parts:
|
||||
print(f"\nExample part:")
|
||||
part = parts[0]
|
||||
print(f" LCSC: {part.get('componentCode')}")
|
||||
print(f" MFR Part: {part.get('componentModelEn')}")
|
||||
print(f" Category: {part.get('firstSortName')} / {part.get('secondSortName')}")
|
||||
print(f" Package: {part.get('componentSpecificationEn')}")
|
||||
print(f" Stock: {part.get('stockCount')}")
|
||||
else:
|
||||
print("✗ Connection failed. Check your API credentials.")
|
||||
"""
|
||||
JLCPCB API client for fetching parts data
|
||||
|
||||
Handles authentication and downloading the JLCPCB parts library
|
||||
for integration with KiCAD component selection.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import string
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class JLCPCBClient:
|
||||
"""
|
||||
Client for JLCPCB API
|
||||
|
||||
Handles HMAC-SHA256 signature-based authentication and fetching
|
||||
the complete parts library from JLCPCB's external API.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://jlcpcb.com/external"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: Optional[str] = None,
|
||||
access_key: Optional[str] = None,
|
||||
secret_key: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize JLCPCB API client
|
||||
|
||||
Args:
|
||||
app_id: JLCPCB App ID (or reads from JLCPCB_APP_ID env var)
|
||||
access_key: JLCPCB Access Key (or reads from JLCPCB_API_KEY env var)
|
||||
secret_key: JLCPCB Secret Key (or reads from JLCPCB_API_SECRET env var)
|
||||
"""
|
||||
self.app_id = app_id or os.getenv("JLCPCB_APP_ID")
|
||||
self.access_key = access_key or os.getenv("JLCPCB_API_KEY")
|
||||
self.secret_key = secret_key or os.getenv("JLCPCB_API_SECRET")
|
||||
|
||||
if not self.app_id or not self.access_key or not self.secret_key:
|
||||
logger.warning(
|
||||
"JLCPCB API credentials not found. Set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _generate_nonce() -> str:
|
||||
"""Generate a 32-character random nonce"""
|
||||
chars = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(chars) for _ in range(32))
|
||||
|
||||
def _build_signature_string(
|
||||
self, method: str, path: str, timestamp: int, nonce: str, body: str
|
||||
) -> str:
|
||||
"""
|
||||
Build the signature string according to JLCPCB spec
|
||||
|
||||
Format:
|
||||
<HTTP Method>\n
|
||||
<Request Path>\n
|
||||
<Timestamp>\n
|
||||
<Nonce>\n
|
||||
<Request Body>\n
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: Request path with query params
|
||||
timestamp: Unix timestamp in seconds
|
||||
nonce: 32-character random string
|
||||
body: Request body (empty string for GET)
|
||||
|
||||
Returns:
|
||||
Signature string
|
||||
"""
|
||||
return f"{method}\n{path}\n{timestamp}\n{nonce}\n{body}\n"
|
||||
|
||||
def _sign(self, signature_string: str) -> str:
|
||||
"""
|
||||
Sign the signature string with HMAC-SHA256
|
||||
|
||||
Args:
|
||||
signature_string: The string to sign
|
||||
|
||||
Returns:
|
||||
Base64-encoded signature
|
||||
"""
|
||||
signature_bytes = hmac.new(
|
||||
self.secret_key.encode("utf-8"), signature_string.encode("utf-8"), hashlib.sha256
|
||||
).digest()
|
||||
return base64.b64encode(signature_bytes).decode("utf-8")
|
||||
|
||||
def _get_auth_header(self, method: str, path: str, body: str = "") -> str:
|
||||
"""
|
||||
Generate the Authorization header for JLCPCB API requests
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, etc.)
|
||||
path: Request path with query params
|
||||
body: Request body JSON string (empty for GET)
|
||||
|
||||
Returns:
|
||||
Authorization header value
|
||||
"""
|
||||
if not self.app_id or not self.access_key or not self.secret_key:
|
||||
raise Exception(
|
||||
"JLCPCB API credentials not configured. Please set JLCPCB_APP_ID, JLCPCB_API_KEY, and JLCPCB_API_SECRET environment variables."
|
||||
)
|
||||
|
||||
nonce = self._generate_nonce()
|
||||
timestamp = int(time.time())
|
||||
|
||||
signature_string = self._build_signature_string(method, path, timestamp, nonce, body)
|
||||
signature = self._sign(signature_string)
|
||||
|
||||
logger.debug(f"Signature string:\n{repr(signature_string)}")
|
||||
logger.debug(f"Signature: {signature}")
|
||||
logger.debug(
|
||||
f'Auth header: JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
|
||||
)
|
||||
|
||||
return f'JOP appid="{self.app_id}",accesskey="{self.access_key}",nonce="{nonce}",timestamp="{timestamp}",signature="{signature}"'
|
||||
|
||||
def fetch_parts_page(self, last_key: Optional[str] = None) -> Dict:
|
||||
"""
|
||||
Fetch one page of parts from JLCPCB API
|
||||
|
||||
Args:
|
||||
last_key: Pagination key from previous response (None for first page)
|
||||
|
||||
Returns:
|
||||
Response dict with parts data and pagination info
|
||||
"""
|
||||
path = "/component/getComponentInfos"
|
||||
|
||||
payload = {}
|
||||
if last_key:
|
||||
payload["lastKey"] = last_key
|
||||
|
||||
# Convert payload to JSON string for signing
|
||||
# For POST requests, we always send JSON, even if empty dict
|
||||
body_str = json.dumps(payload, separators=(",", ":"))
|
||||
|
||||
# Generate authorization header
|
||||
auth_header = self._get_auth_header("POST", path, body_str)
|
||||
|
||||
headers = {"Authorization": auth_header, "Content-Type": "application/json"}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{self.BASE_URL}{path}", headers=headers, json=payload, timeout=60
|
||||
)
|
||||
|
||||
logger.debug(f"Response status: {response.status_code}")
|
||||
logger.debug(f"Response headers: {response.headers}")
|
||||
logger.debug(f"Response text: {response.text}")
|
||||
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("code") != 200:
|
||||
raise Exception(
|
||||
f"API request failed (code {data.get('code')}): {data.get('msg', 'Unknown error')} - Full response: {data}"
|
||||
)
|
||||
|
||||
return data["data"]
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to fetch parts page: {e}")
|
||||
raise Exception(f"JLCPCB API request failed: {e}")
|
||||
|
||||
def download_full_database(
|
||||
self, callback: Optional[Callable[[int, int, str], None]] = None
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Download entire parts library from JLCPCB
|
||||
|
||||
Args:
|
||||
callback: Optional progress callback function(current_page, total_parts, status_msg)
|
||||
|
||||
Returns:
|
||||
List of all parts
|
||||
"""
|
||||
all_parts = []
|
||||
last_key = None
|
||||
page = 0
|
||||
|
||||
logger.info("Starting full JLCPCB parts database download...")
|
||||
|
||||
while True:
|
||||
page += 1
|
||||
|
||||
try:
|
||||
data = self.fetch_parts_page(last_key)
|
||||
|
||||
parts = data.get("componentInfos", [])
|
||||
all_parts.extend(parts)
|
||||
|
||||
last_key = data.get("lastKey")
|
||||
|
||||
if callback:
|
||||
callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...")
|
||||
else:
|
||||
logger.info(f"Page {page}: Downloaded {len(all_parts)} parts so far...")
|
||||
|
||||
# Check if there are more pages
|
||||
if not last_key or len(parts) == 0:
|
||||
break
|
||||
|
||||
# Rate limiting - be nice to the API
|
||||
time.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading parts at page {page}: {e}")
|
||||
if len(all_parts) > 0:
|
||||
logger.warning(f"Partial download available: {len(all_parts)} parts")
|
||||
return all_parts
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
|
||||
return all_parts
|
||||
|
||||
def get_part_by_lcsc(self, lcsc_number: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get detailed information for a specific LCSC part number
|
||||
|
||||
Note: This uses the same endpoint as fetching parts, as JLCPCB doesn't
|
||||
have a dedicated single-part endpoint. In practice, you should use
|
||||
the local database after initial download.
|
||||
|
||||
Args:
|
||||
lcsc_number: LCSC part number (e.g., "C25804")
|
||||
|
||||
Returns:
|
||||
Part info dict or None if not found
|
||||
"""
|
||||
# For now, this would require searching through pages
|
||||
# In practice, you'd use the local database
|
||||
logger.warning("get_part_by_lcsc should use local database, not API")
|
||||
return None
|
||||
|
||||
|
||||
def test_jlcpcb_connection(
|
||||
app_id: Optional[str] = None, access_key: Optional[str] = None, secret_key: Optional[str] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Test JLCPCB API connection
|
||||
|
||||
Args:
|
||||
app_id: Optional App ID (uses env var if not provided)
|
||||
access_key: Optional Access Key (uses env var if not provided)
|
||||
secret_key: Optional Secret Key (uses env var if not provided)
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = JLCPCBClient(app_id, access_key, secret_key)
|
||||
# Test by fetching first page
|
||||
data = client.fetch_parts_page()
|
||||
logger.info("JLCPCB API connection test successful")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"JLCPCB API connection test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the JLCPCB client
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
print("Testing JLCPCB API connection...")
|
||||
if test_jlcpcb_connection():
|
||||
print("✓ Connection successful!")
|
||||
|
||||
client = JLCPCBClient()
|
||||
print("\nFetching first page of parts...")
|
||||
data = client.fetch_parts_page()
|
||||
parts = data.get("componentInfos", [])
|
||||
print(f"✓ Retrieved {len(parts)} parts in first page")
|
||||
|
||||
if parts:
|
||||
print(f"\nExample part:")
|
||||
part = parts[0]
|
||||
print(f" LCSC: {part.get('componentCode')}")
|
||||
print(f" MFR Part: {part.get('componentModelEn')}")
|
||||
print(f" Category: {part.get('firstSortName')} / {part.get('secondSortName')}")
|
||||
print(f" Package: {part.get('componentSpecificationEn')}")
|
||||
print(f" Stock: {part.get('stockCount')}")
|
||||
else:
|
||||
print("✗ Connection failed. Check your API credentials.")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,240 +1,240 @@
|
||||
"""
|
||||
JLCSearch API client (public, no authentication required)
|
||||
|
||||
Alternative to official JLCPCB API using the community-maintained
|
||||
jlcsearch service at https://jlcsearch.tscircuit.com/
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class JLCSearchClient:
|
||||
"""
|
||||
Client for JLCSearch public API (tscircuit)
|
||||
|
||||
Provides access to JLCPCB parts database without authentication
|
||||
via the community-maintained jlcsearch service.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://jlcsearch.tscircuit.com"
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize JLCSearch API client"""
|
||||
pass
|
||||
|
||||
def search_components(
|
||||
self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search components in JLCSearch database
|
||||
|
||||
Args:
|
||||
category: Component category (e.g., "resistors", "capacitors", "components")
|
||||
limit: Maximum number of results
|
||||
offset: Offset for pagination
|
||||
**filters: Additional filters (e.g., package="0603", resistance=1000)
|
||||
|
||||
Returns:
|
||||
List of component dicts
|
||||
"""
|
||||
url = f"{self.BASE_URL}/{category}/list.json"
|
||||
|
||||
params = {"limit": limit, "offset": offset, **filters}
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# The response has the category name as key
|
||||
# e.g., {"resistors": [...]} or {"components": [...]}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
|
||||
return []
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to search JLCSearch: {e}")
|
||||
raise Exception(f"JLCSearch API request failed: {e}")
|
||||
|
||||
def search_resistors(
|
||||
self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search for resistors
|
||||
|
||||
Args:
|
||||
resistance: Resistance value in ohms
|
||||
package: Package type (e.g., "0603", "0805")
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of resistor dicts with fields:
|
||||
- lcsc: LCSC number (integer)
|
||||
- mfr: Manufacturer part number
|
||||
- package: Package size
|
||||
- is_basic: True if basic library part
|
||||
- resistance: Resistance in ohms
|
||||
- tolerance_fraction: Tolerance (0.01 = 1%)
|
||||
- power_watts: Power rating in mW
|
||||
- stock: Available stock
|
||||
- price1: Price per unit
|
||||
"""
|
||||
filters: Dict[str, Any] = {}
|
||||
if resistance is not None:
|
||||
filters["resistance"] = resistance
|
||||
if package:
|
||||
filters["package"] = package
|
||||
|
||||
return self.search_components("resistors", limit=limit, **filters)
|
||||
|
||||
def search_capacitors(
|
||||
self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search for capacitors
|
||||
|
||||
Args:
|
||||
capacitance: Capacitance value in farads
|
||||
package: Package type
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of capacitor dicts
|
||||
"""
|
||||
filters: Dict[str, Any] = {}
|
||||
if capacitance is not None:
|
||||
filters["capacitance"] = capacitance
|
||||
if package:
|
||||
filters["package"] = package
|
||||
|
||||
return self.search_components("capacitors", limit=limit, **filters)
|
||||
|
||||
def get_part_by_lcsc(self, lcsc_number: int) -> Optional[Dict]:
|
||||
"""
|
||||
Get part details by LCSC number
|
||||
|
||||
Args:
|
||||
lcsc_number: LCSC number (integer, without 'C' prefix)
|
||||
|
||||
Returns:
|
||||
Part dict or None if not found
|
||||
"""
|
||||
# Search across all components filtering by LCSC
|
||||
# Note: jlcsearch doesn't have a dedicated single-part endpoint
|
||||
# so we search and filter
|
||||
try:
|
||||
results = self.search_components("components", limit=1, lcsc=lcsc_number)
|
||||
return results[0] if results else None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get part C{lcsc_number}: {e}")
|
||||
return None
|
||||
|
||||
def download_all_components(
|
||||
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Download all components from jlcsearch database
|
||||
|
||||
Note: tscircuit API has a hard-coded 100 result limit per request.
|
||||
Full catalog download requires ~25,000 paginated requests (~40-60 minutes).
|
||||
|
||||
Args:
|
||||
callback: Optional progress callback function(parts_count, status_msg)
|
||||
batch_size: Number of parts per batch (max 100 due to API limit)
|
||||
|
||||
Returns:
|
||||
List of all parts
|
||||
"""
|
||||
all_parts = []
|
||||
offset = 0
|
||||
|
||||
logger.info("Starting full jlcsearch parts database download...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
batch = self.search_components("components", limit=batch_size, offset=offset)
|
||||
|
||||
# Stop if no results returned (end of catalog)
|
||||
if not batch or len(batch) == 0:
|
||||
break
|
||||
|
||||
all_parts.extend(batch)
|
||||
offset += len(batch)
|
||||
|
||||
if callback:
|
||||
callback(len(all_parts), f"Downloaded {len(all_parts)} parts...")
|
||||
else:
|
||||
logger.info(f"Downloaded {len(all_parts)} parts so far...")
|
||||
|
||||
# Continue pagination - API returns exactly 100 results per page until exhausted
|
||||
# Only stop when we get 0 results (handled above)
|
||||
|
||||
# Rate limiting - be nice to the API
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading parts at offset {offset}: {e}")
|
||||
if len(all_parts) > 0:
|
||||
logger.warning(f"Partial download available: {len(all_parts)} parts")
|
||||
return all_parts
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
|
||||
return all_parts
|
||||
|
||||
|
||||
def test_jlcsearch_connection() -> bool:
|
||||
"""
|
||||
Test JLCSearch API connection
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = JLCSearchClient()
|
||||
# Test by searching for 1k resistors
|
||||
results = client.search_resistors(resistance=1000, limit=5)
|
||||
logger.info(f"JLCSearch API connection test successful - found {len(results)} resistors")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"JLCSearch API connection test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the JLCSearch client
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
print("Testing JLCSearch API connection...")
|
||||
if test_jlcsearch_connection():
|
||||
print("✓ Connection successful!")
|
||||
|
||||
client = JLCSearchClient()
|
||||
|
||||
print("\nSearching for 1k 0603 resistors...")
|
||||
resistors = client.search_resistors(resistance=1000, package="0603", limit=5)
|
||||
print(f"✓ Found {len(resistors)} resistors")
|
||||
|
||||
if resistors:
|
||||
print(f"\nExample resistor:")
|
||||
r = resistors[0]
|
||||
print(f" LCSC: C{r.get('lcsc')}")
|
||||
print(f" MFR: {r.get('mfr')}")
|
||||
print(f" Package: {r.get('package')}")
|
||||
print(f" Resistance: {r.get('resistance')}Ω")
|
||||
print(f" Tolerance: {r.get('tolerance_fraction', 0) * 100}%")
|
||||
print(f" Power: {r.get('power_watts')}mW")
|
||||
print(f" Stock: {r.get('stock')}")
|
||||
print(f" Price: ${r.get('price1')}")
|
||||
print(f" Basic Library: {'Yes' if r.get('is_basic') else 'No'}")
|
||||
else:
|
||||
print("✗ Connection failed")
|
||||
"""
|
||||
JLCSearch API client (public, no authentication required)
|
||||
|
||||
Alternative to official JLCPCB API using the community-maintained
|
||||
jlcsearch service at https://jlcsearch.tscircuit.com/
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Callable, Dict, List, Optional, Union
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class JLCSearchClient:
|
||||
"""
|
||||
Client for JLCSearch public API (tscircuit)
|
||||
|
||||
Provides access to JLCPCB parts database without authentication
|
||||
via the community-maintained jlcsearch service.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://jlcsearch.tscircuit.com"
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize JLCSearch API client"""
|
||||
pass
|
||||
|
||||
def search_components(
|
||||
self, category: str = "components", limit: int = 100, offset: int = 0, **filters: Dict
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search components in JLCSearch database
|
||||
|
||||
Args:
|
||||
category: Component category (e.g., "resistors", "capacitors", "components")
|
||||
limit: Maximum number of results
|
||||
offset: Offset for pagination
|
||||
**filters: Additional filters (e.g., package="0603", resistance=1000)
|
||||
|
||||
Returns:
|
||||
List of component dicts
|
||||
"""
|
||||
url = f"{self.BASE_URL}/{category}/list.json"
|
||||
|
||||
params = {"limit": limit, "offset": offset, **filters}
|
||||
|
||||
try:
|
||||
response = requests.get(url, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# The response has the category name as key
|
||||
# e.g., {"resistors": [...]} or {"components": [...]}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
|
||||
return []
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to search JLCSearch: {e}")
|
||||
raise Exception(f"JLCSearch API request failed: {e}")
|
||||
|
||||
def search_resistors(
|
||||
self, resistance: Optional[int] = None, package: Optional[str] = None, limit: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search for resistors
|
||||
|
||||
Args:
|
||||
resistance: Resistance value in ohms
|
||||
package: Package type (e.g., "0603", "0805")
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of resistor dicts with fields:
|
||||
- lcsc: LCSC number (integer)
|
||||
- mfr: Manufacturer part number
|
||||
- package: Package size
|
||||
- is_basic: True if basic library part
|
||||
- resistance: Resistance in ohms
|
||||
- tolerance_fraction: Tolerance (0.01 = 1%)
|
||||
- power_watts: Power rating in mW
|
||||
- stock: Available stock
|
||||
- price1: Price per unit
|
||||
"""
|
||||
filters: Dict[str, Any] = {}
|
||||
if resistance is not None:
|
||||
filters["resistance"] = resistance
|
||||
if package:
|
||||
filters["package"] = package
|
||||
|
||||
return self.search_components("resistors", limit=limit, **filters)
|
||||
|
||||
def search_capacitors(
|
||||
self, capacitance: Optional[float] = None, package: Optional[str] = None, limit: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Search for capacitors
|
||||
|
||||
Args:
|
||||
capacitance: Capacitance value in farads
|
||||
package: Package type
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of capacitor dicts
|
||||
"""
|
||||
filters: Dict[str, Any] = {}
|
||||
if capacitance is not None:
|
||||
filters["capacitance"] = capacitance
|
||||
if package:
|
||||
filters["package"] = package
|
||||
|
||||
return self.search_components("capacitors", limit=limit, **filters)
|
||||
|
||||
def get_part_by_lcsc(self, lcsc_number: int) -> Optional[Dict]:
|
||||
"""
|
||||
Get part details by LCSC number
|
||||
|
||||
Args:
|
||||
lcsc_number: LCSC number (integer, without 'C' prefix)
|
||||
|
||||
Returns:
|
||||
Part dict or None if not found
|
||||
"""
|
||||
# Search across all components filtering by LCSC
|
||||
# Note: jlcsearch doesn't have a dedicated single-part endpoint
|
||||
# so we search and filter
|
||||
try:
|
||||
results = self.search_components("components", limit=1, lcsc=lcsc_number)
|
||||
return results[0] if results else None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get part C{lcsc_number}: {e}")
|
||||
return None
|
||||
|
||||
def download_all_components(
|
||||
self, callback: Optional[Callable[[int, str], None]] = None, batch_size: int = 100
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
Download all components from jlcsearch database
|
||||
|
||||
Note: tscircuit API has a hard-coded 100 result limit per request.
|
||||
Full catalog download requires ~25,000 paginated requests (~40-60 minutes).
|
||||
|
||||
Args:
|
||||
callback: Optional progress callback function(parts_count, status_msg)
|
||||
batch_size: Number of parts per batch (max 100 due to API limit)
|
||||
|
||||
Returns:
|
||||
List of all parts
|
||||
"""
|
||||
all_parts = []
|
||||
offset = 0
|
||||
|
||||
logger.info("Starting full jlcsearch parts database download...")
|
||||
|
||||
while True:
|
||||
try:
|
||||
batch = self.search_components("components", limit=batch_size, offset=offset)
|
||||
|
||||
# Stop if no results returned (end of catalog)
|
||||
if not batch or len(batch) == 0:
|
||||
break
|
||||
|
||||
all_parts.extend(batch)
|
||||
offset += len(batch)
|
||||
|
||||
if callback:
|
||||
callback(len(all_parts), f"Downloaded {len(all_parts)} parts...")
|
||||
else:
|
||||
logger.info(f"Downloaded {len(all_parts)} parts so far...")
|
||||
|
||||
# Continue pagination - API returns exactly 100 results per page until exhausted
|
||||
# Only stop when we get 0 results (handled above)
|
||||
|
||||
# Rate limiting - be nice to the API
|
||||
time.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error downloading parts at offset {offset}: {e}")
|
||||
if len(all_parts) > 0:
|
||||
logger.warning(f"Partial download available: {len(all_parts)} parts")
|
||||
return all_parts
|
||||
else:
|
||||
raise
|
||||
|
||||
logger.info(f"Download complete: {len(all_parts)} parts retrieved")
|
||||
return all_parts
|
||||
|
||||
|
||||
def test_jlcsearch_connection() -> bool:
|
||||
"""
|
||||
Test JLCSearch API connection
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
client = JLCSearchClient()
|
||||
# Test by searching for 1k resistors
|
||||
results = client.search_resistors(resistance=1000, limit=5)
|
||||
logger.info(f"JLCSearch API connection test successful - found {len(results)} resistors")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"JLCSearch API connection test failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test the JLCSearch client
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
print("Testing JLCSearch API connection...")
|
||||
if test_jlcsearch_connection():
|
||||
print("✓ Connection successful!")
|
||||
|
||||
client = JLCSearchClient()
|
||||
|
||||
print("\nSearching for 1k 0603 resistors...")
|
||||
resistors = client.search_resistors(resistance=1000, package="0603", limit=5)
|
||||
print(f"✓ Found {len(resistors)} resistors")
|
||||
|
||||
if resistors:
|
||||
print(f"\nExample resistor:")
|
||||
r = resistors[0]
|
||||
print(f" LCSC: C{r.get('lcsc')}")
|
||||
print(f" MFR: {r.get('mfr')}")
|
||||
print(f" Package: {r.get('package')}")
|
||||
print(f" Resistance: {r.get('resistance')}Ω")
|
||||
print(f" Tolerance: {r.get('tolerance_fraction', 0) * 100}%")
|
||||
print(f" Power: {r.get('power_watts')}mW")
|
||||
print(f" Stock: {r.get('stock')}")
|
||||
print(f" Price: ${r.get('price1')}")
|
||||
print(f" Basic Library: {'Yes' if r.get('is_basic') else 'No'}")
|
||||
else:
|
||||
print("✗ Connection failed")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,130 +1,130 @@
|
||||
import glob
|
||||
import logging
|
||||
|
||||
# Symbol class might not be directly importable in the current version
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibraryManager:
|
||||
"""Manage symbol libraries"""
|
||||
|
||||
@staticmethod
|
||||
def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]:
|
||||
"""List all available symbol libraries"""
|
||||
if search_paths is None:
|
||||
# Default library paths based on common KiCAD installations
|
||||
# This would need to be configured for the specific environment
|
||||
search_paths = [
|
||||
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern
|
||||
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
|
||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern
|
||||
os.path.expanduser(
|
||||
"~/Documents/KiCad/*/symbols/*.kicad_sym"
|
||||
), # User libraries pattern
|
||||
]
|
||||
|
||||
libraries = []
|
||||
for path_pattern in search_paths:
|
||||
try:
|
||||
# Use glob to find all matching files
|
||||
matching_libs = glob.glob(path_pattern, recursive=True)
|
||||
libraries.extend(matching_libs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for libraries at {path_pattern}: {e}")
|
||||
|
||||
# Extract library names from paths
|
||||
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
|
||||
logger.info(
|
||||
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
|
||||
)
|
||||
|
||||
# Return both full paths and library names
|
||||
return {"paths": libraries, "names": library_names}
|
||||
|
||||
@staticmethod
|
||||
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
|
||||
"""Get detailed information about a symbol"""
|
||||
try:
|
||||
logger.warning(
|
||||
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
|
||||
)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]:
|
||||
"""Search for symbols matching criteria"""
|
||||
try:
|
||||
# This would typically involve:
|
||||
# 1. Getting a list of all libraries using list_available_libraries
|
||||
# 2. For each library, getting a list of all symbols
|
||||
# 3. Filtering symbols based on the query
|
||||
|
||||
# For now, this is a placeholder implementation
|
||||
libraries = LibraryManager.list_available_libraries(search_paths)
|
||||
|
||||
results = []
|
||||
logger.warning(
|
||||
f"Searched for symbols matching '{query}'. This requires advanced implementation."
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for symbols matching '{query}': {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_default_symbol_for_component_type(
|
||||
component_type: str, search_paths: Optional[List[str]] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Get a recommended default symbol for a given component type"""
|
||||
# This method provides a simplified way to get a symbol for common component types
|
||||
# It's useful when the user doesn't specify a particular library/symbol
|
||||
|
||||
# Define common mappings from component type to library/symbol
|
||||
common_mappings = {
|
||||
"resistor": {"library": "Device", "symbol": "R"},
|
||||
"capacitor": {"library": "Device", "symbol": "C"},
|
||||
"inductor": {"library": "Device", "symbol": "L"},
|
||||
"diode": {"library": "Device", "symbol": "D"},
|
||||
"led": {"library": "Device", "symbol": "LED"},
|
||||
"transistor_npn": {"library": "Device", "symbol": "Q_NPN_BCE"},
|
||||
"transistor_pnp": {"library": "Device", "symbol": "Q_PNP_BCE"},
|
||||
"opamp": {"library": "Amplifier_Operational", "symbol": "OpAmp_Dual_Generic"},
|
||||
"microcontroller": {"library": "MCU_Module", "symbol": "Arduino_UNO_R3"},
|
||||
# Add more common components as needed
|
||||
}
|
||||
|
||||
# Normalize input to lowercase
|
||||
component_type_lower = component_type.lower()
|
||||
|
||||
# Try direct match first
|
||||
if component_type_lower in common_mappings:
|
||||
return common_mappings[component_type_lower]
|
||||
|
||||
# Try partial matches
|
||||
for key, value in common_mappings.items():
|
||||
if component_type_lower in key or key in component_type_lower:
|
||||
return value
|
||||
|
||||
# Default fallback
|
||||
return {"library": "Device", "symbol": "R"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage (for testing)
|
||||
# List available libraries
|
||||
libraries = LibraryManager.list_available_libraries()
|
||||
# Get default symbol for a component type
|
||||
resistor_sym = LibraryManager.get_default_symbol_for_component_type("resistor")
|
||||
print(f"Default symbol for resistor: {resistor_sym['library']}/{resistor_sym['symbol']}")
|
||||
|
||||
# Try a partial match
|
||||
cap_sym = LibraryManager.get_default_symbol_for_component_type("cap")
|
||||
print(f"Default symbol for 'cap': {cap_sym['library']}/{cap_sym['symbol']}")
|
||||
import glob
|
||||
import logging
|
||||
|
||||
# Symbol class might not be directly importable in the current version
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from skip import Schematic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LibraryManager:
|
||||
"""Manage symbol libraries"""
|
||||
|
||||
@staticmethod
|
||||
def list_available_libraries(search_paths: Optional[List[str]] = None) -> Dict[str, List[str]]:
|
||||
"""List all available symbol libraries"""
|
||||
if search_paths is None:
|
||||
# Default library paths based on common KiCAD installations
|
||||
# This would need to be configured for the specific environment
|
||||
search_paths = [
|
||||
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym", # Windows path pattern
|
||||
"/usr/share/kicad/symbols/*.kicad_sym", # Linux path pattern
|
||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym", # macOS path pattern
|
||||
os.path.expanduser(
|
||||
"~/Documents/KiCad/*/symbols/*.kicad_sym"
|
||||
), # User libraries pattern
|
||||
]
|
||||
|
||||
libraries = []
|
||||
for path_pattern in search_paths:
|
||||
try:
|
||||
# Use glob to find all matching files
|
||||
matching_libs = glob.glob(path_pattern, recursive=True)
|
||||
libraries.extend(matching_libs)
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for libraries at {path_pattern}: {e}")
|
||||
|
||||
# Extract library names from paths
|
||||
library_names = [os.path.splitext(os.path.basename(lib))[0] for lib in libraries]
|
||||
logger.info(
|
||||
f"Found {len(library_names)} libraries: {', '.join(library_names[:10])}{'...' if len(library_names) > 10 else ''}"
|
||||
)
|
||||
|
||||
# Return both full paths and library names
|
||||
return {"paths": libraries, "names": library_names}
|
||||
|
||||
@staticmethod
|
||||
def get_symbol_details(library_path: str, symbol_name: str) -> Dict[str, Any]:
|
||||
"""Get detailed information about a symbol"""
|
||||
try:
|
||||
logger.warning(
|
||||
f"Attempted to get details for symbol {symbol_name} in library {library_path}. This requires advanced implementation."
|
||||
)
|
||||
return {}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting symbol details for {symbol_name} in {library_path}: {e}")
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def search_symbols(query: str, search_paths: Optional[List[str]] = None) -> List[Any]:
|
||||
"""Search for symbols matching criteria"""
|
||||
try:
|
||||
# This would typically involve:
|
||||
# 1. Getting a list of all libraries using list_available_libraries
|
||||
# 2. For each library, getting a list of all symbols
|
||||
# 3. Filtering symbols based on the query
|
||||
|
||||
# For now, this is a placeholder implementation
|
||||
libraries = LibraryManager.list_available_libraries(search_paths)
|
||||
|
||||
results = []
|
||||
logger.warning(
|
||||
f"Searched for symbols matching '{query}'. This requires advanced implementation."
|
||||
)
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching for symbols matching '{query}': {e}")
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def get_default_symbol_for_component_type(
|
||||
component_type: str, search_paths: Optional[List[str]] = None
|
||||
) -> Dict[str, str]:
|
||||
"""Get a recommended default symbol for a given component type"""
|
||||
# This method provides a simplified way to get a symbol for common component types
|
||||
# It's useful when the user doesn't specify a particular library/symbol
|
||||
|
||||
# Define common mappings from component type to library/symbol
|
||||
common_mappings = {
|
||||
"resistor": {"library": "Device", "symbol": "R"},
|
||||
"capacitor": {"library": "Device", "symbol": "C"},
|
||||
"inductor": {"library": "Device", "symbol": "L"},
|
||||
"diode": {"library": "Device", "symbol": "D"},
|
||||
"led": {"library": "Device", "symbol": "LED"},
|
||||
"transistor_npn": {"library": "Device", "symbol": "Q_NPN_BCE"},
|
||||
"transistor_pnp": {"library": "Device", "symbol": "Q_PNP_BCE"},
|
||||
"opamp": {"library": "Amplifier_Operational", "symbol": "OpAmp_Dual_Generic"},
|
||||
"microcontroller": {"library": "MCU_Module", "symbol": "Arduino_UNO_R3"},
|
||||
# Add more common components as needed
|
||||
}
|
||||
|
||||
# Normalize input to lowercase
|
||||
component_type_lower = component_type.lower()
|
||||
|
||||
# Try direct match first
|
||||
if component_type_lower in common_mappings:
|
||||
return common_mappings[component_type_lower]
|
||||
|
||||
# Try partial matches
|
||||
for key, value in common_mappings.items():
|
||||
if component_type_lower in key or key in component_type_lower:
|
||||
return value
|
||||
|
||||
# Default fallback
|
||||
return {"library": "Device", "symbol": "R"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example Usage (for testing)
|
||||
# List available libraries
|
||||
libraries = LibraryManager.list_available_libraries()
|
||||
# Get default symbol for a component type
|
||||
resistor_sym = LibraryManager.get_default_symbol_for_component_type("resistor")
|
||||
print(f"Default symbol for resistor: {resistor_sym['library']}/{resistor_sym['symbol']}")
|
||||
|
||||
# Try a partial match
|
||||
cap_sym = LibraryManager.get_default_symbol_for_component_type("cap")
|
||||
print(f"Default symbol for 'cap': {cap_sym['library']}/{cap_sym['symbol']}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,255 +1,255 @@
|
||||
"""
|
||||
Project-related command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew # type: ignore
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class ProjectCommands:
|
||||
"""Handles project-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new KiCAD project"""
|
||||
try:
|
||||
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
|
||||
project_name = params.get("name") or params.get("projectName", "New_Project")
|
||||
path = params.get("path", os.getcwd())
|
||||
template = params.get("template")
|
||||
|
||||
# Generate the full project path
|
||||
project_path = os.path.join(path, project_name)
|
||||
if not project_path.endswith(".kicad_pro"):
|
||||
project_path += ".kicad_pro"
|
||||
|
||||
# Create project directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(project_path), exist_ok=True)
|
||||
|
||||
# Create a new board
|
||||
board = pcbnew.BOARD()
|
||||
|
||||
# Set project properties
|
||||
board.GetTitleBlock().SetTitle(project_name)
|
||||
|
||||
# Set current date with proper parameter
|
||||
from datetime import datetime
|
||||
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
board.GetTitleBlock().SetDate(current_date)
|
||||
|
||||
# If template is specified, try to load it
|
||||
if template:
|
||||
template_path = os.path.expanduser(template)
|
||||
if os.path.exists(template_path):
|
||||
template_board = pcbnew.LoadBoard(template_path)
|
||||
# Copy settings from template
|
||||
board.SetDesignSettings(template_board.GetDesignSettings())
|
||||
board.SetLayerStack(template_board.GetLayerStack())
|
||||
|
||||
# Save the board
|
||||
board_path = project_path.replace(".kicad_pro", ".kicad_pcb")
|
||||
board.SetFileName(board_path)
|
||||
pcbnew.SaveBoard(board_path, board)
|
||||
|
||||
# Create schematic from template (use expanded template with symbol definitions)
|
||||
schematic_path = project_path.replace(".kicad_pro", ".kicad_sch")
|
||||
template_sch_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..",
|
||||
"templates",
|
||||
"template_with_symbols_expanded.kicad_sch",
|
||||
)
|
||||
|
||||
if os.path.exists(template_sch_path):
|
||||
# Copy template schematic
|
||||
shutil.copy(template_sch_path, schematic_path)
|
||||
|
||||
# Regenerate UUID to ensure uniqueness for each created project
|
||||
import re
|
||||
import uuid as uuid_module
|
||||
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
new_uuid = str(uuid_module.uuid4())
|
||||
content = re.sub(
|
||||
r"\(uuid [0-9a-fA-F-]+\)",
|
||||
f"(uuid {new_uuid})",
|
||||
content,
|
||||
count=1, # Only replace first (schematic) UUID
|
||||
)
|
||||
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info(f"Created schematic from template: {schematic_path}")
|
||||
else:
|
||||
# Fallback: create minimal schematic
|
||||
logger.warning(
|
||||
f"Template not found at {template_sch_path}, creating minimal schematic"
|
||||
)
|
||||
import uuid as uuid_module
|
||||
|
||||
schematic_uuid = str(uuid_module.uuid4())
|
||||
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
|
||||
f.write(f" (uuid {schematic_uuid})\n\n")
|
||||
f.write(' (paper "A4")\n\n')
|
||||
f.write(" (lib_symbols\n )\n\n")
|
||||
f.write(' (sheet_instances\n (path "/" (page "1"))\n )\n')
|
||||
f.write(")\n")
|
||||
|
||||
# Create project file with schematic reference
|
||||
with open(project_path, "w") as f:
|
||||
f.write("{\n")
|
||||
f.write(' "board": {\n')
|
||||
f.write(f' "filename": "{os.path.basename(board_path)}"\n')
|
||||
f.write(" },\n")
|
||||
f.write(' "sheets": [\n')
|
||||
f.write(f' ["root", "{os.path.basename(schematic_path)}"]\n')
|
||||
f.write(" ]\n")
|
||||
f.write("}\n")
|
||||
|
||||
self.board = board
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Created project: {project_name}",
|
||||
"project": {
|
||||
"name": project_name,
|
||||
"path": project_path,
|
||||
"boardPath": board_path,
|
||||
"schematicPath": schematic_path,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to create project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def open_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Open an existing KiCAD project"""
|
||||
try:
|
||||
filename = params.get("filename")
|
||||
if not filename:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No filename provided",
|
||||
"errorDetails": "The filename parameter is required",
|
||||
}
|
||||
|
||||
# Expand user path and make absolute
|
||||
filename = os.path.abspath(os.path.expanduser(filename))
|
||||
|
||||
# If it's a project file, get the board file
|
||||
if filename.endswith(".kicad_pro"):
|
||||
board_path = filename.replace(".kicad_pro", ".kicad_pcb")
|
||||
else:
|
||||
board_path = filename
|
||||
|
||||
# Load the board
|
||||
board = pcbnew.LoadBoard(board_path)
|
||||
self.board = board
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Opened project: {os.path.basename(board_path)}",
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(board_path))[0],
|
||||
"path": filename,
|
||||
"boardPath": board_path,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to open project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def save_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Save the current KiCAD project"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
filename = params.get("filename")
|
||||
if filename:
|
||||
# Save to new location
|
||||
filename = os.path.abspath(os.path.expanduser(filename))
|
||||
self.board.SetFileName(filename)
|
||||
|
||||
# Save the board
|
||||
pcbnew.SaveBoard(self.board.GetFileName(), self.board)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Saved project to: {self.board.GetFileName()}",
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
|
||||
"path": self.board.GetFileName(),
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to save project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current project"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
title_block = self.board.GetTitleBlock()
|
||||
filename = self.board.GetFileName()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(filename))[0],
|
||||
"path": filename,
|
||||
"title": title_block.GetTitle(),
|
||||
"date": title_block.GetDate(),
|
||||
"revision": title_block.GetRevision(),
|
||||
"company": title_block.GetCompany(),
|
||||
"comment1": title_block.GetComment(0),
|
||||
"comment2": title_block.GetComment(1),
|
||||
"comment3": title_block.GetComment(2),
|
||||
"comment4": title_block.GetComment(3),
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting project info: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get project information",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
"""
|
||||
Project-related command implementations for KiCAD interface
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import pcbnew # type: ignore
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
class ProjectCommands:
|
||||
"""Handles project-related KiCAD operations"""
|
||||
|
||||
def __init__(self, board: Optional[pcbnew.BOARD] = None):
|
||||
"""Initialize with optional board instance"""
|
||||
self.board = board
|
||||
|
||||
def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new KiCAD project"""
|
||||
try:
|
||||
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
|
||||
project_name = params.get("name") or params.get("projectName", "New_Project")
|
||||
path = params.get("path", os.getcwd())
|
||||
template = params.get("template")
|
||||
|
||||
# Generate the full project path
|
||||
project_path = os.path.join(path, project_name)
|
||||
if not project_path.endswith(".kicad_pro"):
|
||||
project_path += ".kicad_pro"
|
||||
|
||||
# Create project directory if it doesn't exist
|
||||
os.makedirs(os.path.dirname(project_path), exist_ok=True)
|
||||
|
||||
# Create a new board
|
||||
board = pcbnew.BOARD()
|
||||
|
||||
# Set project properties
|
||||
board.GetTitleBlock().SetTitle(project_name)
|
||||
|
||||
# Set current date with proper parameter
|
||||
from datetime import datetime
|
||||
|
||||
current_date = datetime.now().strftime("%Y-%m-%d")
|
||||
board.GetTitleBlock().SetDate(current_date)
|
||||
|
||||
# If template is specified, try to load it
|
||||
if template:
|
||||
template_path = os.path.expanduser(template)
|
||||
if os.path.exists(template_path):
|
||||
template_board = pcbnew.LoadBoard(template_path)
|
||||
# Copy settings from template
|
||||
board.SetDesignSettings(template_board.GetDesignSettings())
|
||||
board.SetLayerStack(template_board.GetLayerStack())
|
||||
|
||||
# Save the board
|
||||
board_path = project_path.replace(".kicad_pro", ".kicad_pcb")
|
||||
board.SetFileName(board_path)
|
||||
pcbnew.SaveBoard(board_path, board)
|
||||
|
||||
# Create schematic from template (use expanded template with symbol definitions)
|
||||
schematic_path = project_path.replace(".kicad_pro", ".kicad_sch")
|
||||
template_sch_path = os.path.join(
|
||||
os.path.dirname(os.path.abspath(__file__)),
|
||||
"..",
|
||||
"templates",
|
||||
"template_with_symbols_expanded.kicad_sch",
|
||||
)
|
||||
|
||||
if os.path.exists(template_sch_path):
|
||||
# Copy template schematic
|
||||
shutil.copy(template_sch_path, schematic_path)
|
||||
|
||||
# Regenerate UUID to ensure uniqueness for each created project
|
||||
import re
|
||||
import uuid as uuid_module
|
||||
|
||||
with open(schematic_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
new_uuid = str(uuid_module.uuid4())
|
||||
content = re.sub(
|
||||
r"\(uuid [0-9a-fA-F-]+\)",
|
||||
f"(uuid {new_uuid})",
|
||||
content,
|
||||
count=1, # Only replace first (schematic) UUID
|
||||
)
|
||||
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info(f"Created schematic from template: {schematic_path}")
|
||||
else:
|
||||
# Fallback: create minimal schematic
|
||||
logger.warning(
|
||||
f"Template not found at {template_sch_path}, creating minimal schematic"
|
||||
)
|
||||
import uuid as uuid_module
|
||||
|
||||
schematic_uuid = str(uuid_module.uuid4())
|
||||
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write('(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")\n\n')
|
||||
f.write(f" (uuid {schematic_uuid})\n\n")
|
||||
f.write(' (paper "A4")\n\n')
|
||||
f.write(" (lib_symbols\n )\n\n")
|
||||
f.write(' (sheet_instances\n (path "/" (page "1"))\n )\n')
|
||||
f.write(")\n")
|
||||
|
||||
# Create project file with schematic reference
|
||||
with open(project_path, "w") as f:
|
||||
f.write("{\n")
|
||||
f.write(' "board": {\n')
|
||||
f.write(f' "filename": "{os.path.basename(board_path)}"\n')
|
||||
f.write(" },\n")
|
||||
f.write(' "sheets": [\n')
|
||||
f.write(f' ["root", "{os.path.basename(schematic_path)}"]\n')
|
||||
f.write(" ]\n")
|
||||
f.write("}\n")
|
||||
|
||||
self.board = board
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Created project: {project_name}",
|
||||
"project": {
|
||||
"name": project_name,
|
||||
"path": project_path,
|
||||
"boardPath": board_path,
|
||||
"schematicPath": schematic_path,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to create project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def open_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Open an existing KiCAD project"""
|
||||
try:
|
||||
filename = params.get("filename")
|
||||
if not filename:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No filename provided",
|
||||
"errorDetails": "The filename parameter is required",
|
||||
}
|
||||
|
||||
# Expand user path and make absolute
|
||||
filename = os.path.abspath(os.path.expanduser(filename))
|
||||
|
||||
# If it's a project file, get the board file
|
||||
if filename.endswith(".kicad_pro"):
|
||||
board_path = filename.replace(".kicad_pro", ".kicad_pcb")
|
||||
else:
|
||||
board_path = filename
|
||||
|
||||
# Load the board
|
||||
board = pcbnew.LoadBoard(board_path)
|
||||
self.board = board
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Opened project: {os.path.basename(board_path)}",
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(board_path))[0],
|
||||
"path": filename,
|
||||
"boardPath": board_path,
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to open project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def save_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Save the current KiCAD project"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
filename = params.get("filename")
|
||||
if filename:
|
||||
# Save to new location
|
||||
filename = os.path.abspath(os.path.expanduser(filename))
|
||||
self.board.SetFileName(filename)
|
||||
|
||||
# Save the board
|
||||
pcbnew.SaveBoard(self.board.GetFileName(), self.board)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Saved project to: {self.board.GetFileName()}",
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
|
||||
"path": self.board.GetFileName(),
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving project: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to save project",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get information about the current project"""
|
||||
try:
|
||||
if not self.board:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "No board is loaded",
|
||||
"errorDetails": "Load or create a board first",
|
||||
}
|
||||
|
||||
title_block = self.board.GetTitleBlock()
|
||||
filename = self.board.GetFileName()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"project": {
|
||||
"name": os.path.splitext(os.path.basename(filename))[0],
|
||||
"path": filename,
|
||||
"title": title_block.GetTitle(),
|
||||
"date": title_block.GetDate(),
|
||||
"revision": title_block.GetRevision(),
|
||||
"company": title_block.GetCompany(),
|
||||
"comment1": title_block.GetComment(0),
|
||||
"comment2": title_block.GetComment(1),
|
||||
"comment3": title_block.GetComment(2),
|
||||
"comment4": title_block.GetComment(3),
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting project info: {str(e)}")
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Failed to get project information",
|
||||
"errorDetails": str(e),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,494 +1,494 @@
|
||||
"""
|
||||
Wire Connectivity Analysis for KiCad Schematics
|
||||
|
||||
Traces wire networks from a point and finds connected component pins.
|
||||
Uses KiCad's internal integer unit system (10,000 IU per mm) for exact
|
||||
coordinate matching, mirroring KiCad's own connectivity algorithm.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter
|
||||
|
||||
|
||||
def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
|
||||
"""Convert mm coordinates to KiCad internal units (integer)."""
|
||||
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
|
||||
|
||||
|
||||
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
|
||||
"""Extract wire endpoints from a schematic object as IU tuples."""
|
||||
all_wires = []
|
||||
for wire in schematic.wire:
|
||||
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
|
||||
pts = []
|
||||
for point in wire.pts.xy:
|
||||
if hasattr(point, "value"):
|
||||
pts.append(_to_iu(float(point.value[0]), float(point.value[1])))
|
||||
if len(pts) >= 2:
|
||||
all_wires.append(pts)
|
||||
return all_wires
|
||||
|
||||
|
||||
def _build_adjacency(
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]:
|
||||
"""Build wire adjacency using exact IU coordinate matching.
|
||||
|
||||
Wires that share an endpoint are adjacent — this naturally handles
|
||||
junctions since all wires meeting at the same point get connected.
|
||||
|
||||
Returns a tuple of:
|
||||
- adjacency: list of sets, one per wire, containing adjacent wire indices
|
||||
- iu_to_wires: dict mapping each IU endpoint to the set of wire indices
|
||||
that have an endpoint at that exact coordinate (used for seed queries)
|
||||
"""
|
||||
# Map each IU endpoint to all wire indices that touch it
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]] = {}
|
||||
for i, pts in enumerate(all_wires):
|
||||
for pt in pts:
|
||||
iu_to_wires.setdefault(pt, set()).add(i)
|
||||
|
||||
# Wires that share an IU endpoint are adjacent
|
||||
adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))]
|
||||
for wire_set in iu_to_wires.values():
|
||||
wire_list = list(wire_set)
|
||||
for a in wire_list:
|
||||
for b in wire_list:
|
||||
if a != b:
|
||||
adjacency[a].add(b)
|
||||
|
||||
return adjacency, iu_to_wires
|
||||
|
||||
|
||||
def _parse_virtual_connections(
|
||||
schematic: Any, schematic_path: Any
|
||||
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
|
||||
"""Return virtual connectivity from net labels and power symbols.
|
||||
|
||||
Returns a tuple of:
|
||||
- point_to_label: Dict[Tuple[int,int], str] — IU position → label name
|
||||
- label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions
|
||||
"""
|
||||
point_to_label: Dict[Tuple[int, int], str] = {}
|
||||
label_to_points: Dict[str, List[Tuple[int, int]]] = {}
|
||||
|
||||
if hasattr(schematic, "label"):
|
||||
for label in schematic.label:
|
||||
try:
|
||||
if not hasattr(label, "value"):
|
||||
continue
|
||||
name = label.value
|
||||
if not hasattr(label, "at") or not hasattr(label.at, "value"):
|
||||
continue
|
||||
coords = label.at.value
|
||||
pt = _to_iu(float(coords[0]), float(coords[1]))
|
||||
point_to_label[pt] = name
|
||||
label_to_points.setdefault(name, []).append(pt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing net label: {e}")
|
||||
|
||||
if hasattr(schematic, "symbol"):
|
||||
locator = PinLocator()
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if not ref.startswith("#PWR"):
|
||||
continue
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
if not hasattr(symbol.property, "Value"):
|
||||
continue
|
||||
name = symbol.property.Value.value
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins or "1" not in all_pins:
|
||||
continue
|
||||
pin_data = all_pins["1"]
|
||||
pt = _to_iu(float(pin_data[0]), float(pin_data[1]))
|
||||
point_to_label[pt] = name
|
||||
label_to_points.setdefault(name, []).append(pt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing power symbol: {e}")
|
||||
|
||||
return point_to_label, label_to_points
|
||||
|
||||
|
||||
def _find_connected_wires(
|
||||
x_mm: float,
|
||||
y_mm: float,
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]],
|
||||
adjacency: List[Set[int]],
|
||||
point_to_label: Optional[Dict[Tuple[int, int], str]] = None,
|
||||
label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None,
|
||||
) -> Tuple:
|
||||
"""BFS from query point. Returns (visited wire indices, net IU points) or (None, None).
|
||||
|
||||
Requires query point (x_mm, y_mm) to be exactly on a wire endpoint (exact IU match).
|
||||
"""
|
||||
query_iu = _to_iu(x_mm, y_mm)
|
||||
|
||||
# Find seed wires: exact IU match on the query endpoint
|
||||
seed_set = iu_to_wires.get(query_iu)
|
||||
if not seed_set:
|
||||
return (None, None)
|
||||
seed_indices: Set[int] = set(seed_set)
|
||||
|
||||
# BFS flood-fill using pre-compiled adjacency
|
||||
visited: Set[int] = set(seed_indices)
|
||||
queue = list(seed_indices)
|
||||
net_points: Set[Tuple[int, int]] = set()
|
||||
for i in seed_indices:
|
||||
net_points.update(all_wires[i])
|
||||
|
||||
seen_labels: Set[str] = set()
|
||||
while queue:
|
||||
wire_idx = queue.pop()
|
||||
for neighbor_idx in adjacency[wire_idx]:
|
||||
if neighbor_idx not in visited:
|
||||
visited.add(neighbor_idx)
|
||||
queue.append(neighbor_idx)
|
||||
net_points.update(all_wires[neighbor_idx])
|
||||
|
||||
if point_to_label and label_to_points:
|
||||
for pt in all_wires[wire_idx]:
|
||||
label_name = point_to_label.get(pt)
|
||||
if label_name and label_name not in seen_labels:
|
||||
seen_labels.add(label_name)
|
||||
for other_pt in label_to_points.get(label_name, []):
|
||||
if other_pt == pt:
|
||||
continue
|
||||
for idx in iu_to_wires.get(other_pt, set()):
|
||||
if idx not in visited:
|
||||
visited.add(idx)
|
||||
queue.append(idx)
|
||||
net_points.update(all_wires[idx])
|
||||
|
||||
return (visited, net_points)
|
||||
|
||||
|
||||
def _find_pins_on_net(
|
||||
net_points: Set[Tuple[int, int]],
|
||||
schematic_path: Any,
|
||||
schematic: Any,
|
||||
) -> List[Dict]:
|
||||
"""Find component pins that land on net points using exact IU matching.
|
||||
|
||||
Returns a list of {"component": ref, "pin": pin_num} dicts.
|
||||
"""
|
||||
|
||||
def _on_net(px_mm: float, py_mm: float) -> bool:
|
||||
return _to_iu(px_mm, py_mm) in net_points
|
||||
|
||||
locator = PinLocator()
|
||||
pins = []
|
||||
seen: Set[Tuple] = set()
|
||||
|
||||
ref = None
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_num, pin_data in all_pins.items():
|
||||
if _on_net(pin_data[0], pin_data[1]):
|
||||
key = (ref, pin_num)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
pins.append({"component": ref, "pin": pin_num})
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
|
||||
)
|
||||
|
||||
return pins
|
||||
|
||||
|
||||
def get_wire_connections(
|
||||
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||
) -> Optional[Dict]:
|
||||
"""Find the net name and all component pins reachable from a point via connected wires.
|
||||
|
||||
The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match).
|
||||
Interior (mid-segment) points are not matched —
|
||||
use wire endpoint coordinates obtained from the schematic data.
|
||||
|
||||
Net labels and power symbols are traversed: wires on the same named net are
|
||||
treated as connected even when they are not geometrically adjacent.
|
||||
|
||||
Returns dict with keys:
|
||||
- "net": str or None (net label/power name, None if unnamed)
|
||||
- "pins": list of {"component": str, "pin": str}
|
||||
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
|
||||
- "query_point": {"x": float, "y": float}
|
||||
Or None if no wire endpoint found within tolerance of the query point.
|
||||
"""
|
||||
all_wires = _parse_wires(schematic)
|
||||
query_point = {"x": x_mm, "y": y_mm}
|
||||
if not all_wires:
|
||||
return {"net": None, "pins": [], "wires": [], "query_point": query_point}
|
||||
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
|
||||
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
if visited is None:
|
||||
return None
|
||||
|
||||
# Resolve net name: first label anchor that falls on this net's IU points
|
||||
net: Optional[str] = None
|
||||
for pt in net_points:
|
||||
label = point_to_label.get(pt)
|
||||
if label is not None:
|
||||
net = label
|
||||
break
|
||||
|
||||
wires_out = [
|
||||
{
|
||||
"start": {
|
||||
"x": all_wires[i][0][0] / _IU_PER_MM,
|
||||
"y": all_wires[i][0][1] / _IU_PER_MM,
|
||||
},
|
||||
"end": {
|
||||
"x": all_wires[i][-1][0] / _IU_PER_MM,
|
||||
"y": all_wires[i][-1][1] / _IU_PER_MM,
|
||||
},
|
||||
}
|
||||
for i in visited
|
||||
]
|
||||
|
||||
if not hasattr(schematic, "symbol"):
|
||||
return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
|
||||
|
||||
pins = _find_pins_on_net(net_points, schematic_path, schematic)
|
||||
return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
|
||||
|
||||
|
||||
def count_pins_on_net(
|
||||
schematic: Any,
|
||||
schematic_path: str,
|
||||
net_name: str,
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]],
|
||||
adjacency: List[Set[int]],
|
||||
point_to_label: Dict[Tuple[int, int], str],
|
||||
label_to_points: Dict[str, List[Tuple[int, int]]],
|
||||
) -> int:
|
||||
"""Count the number of component pins connected to the named net.
|
||||
|
||||
A pin is counted if its IU coordinate falls on the wire-network reachable
|
||||
from any label anchor for *net_name*, or directly on a label anchor of that
|
||||
net (pin directly touching a label with no intervening wire).
|
||||
|
||||
Returns the count of distinct (component, pin_num) pairs on this net.
|
||||
"""
|
||||
label_positions = label_to_points.get(net_name, [])
|
||||
if not label_positions:
|
||||
return 0
|
||||
|
||||
# Collect the union of all net-points across all label positions for this net
|
||||
all_net_points: Set[Tuple[int, int]] = set()
|
||||
for lx, ly in label_positions:
|
||||
# Include the label anchor itself so pins directly at the label count
|
||||
all_net_points.add((lx, ly))
|
||||
# Trace from this label position into the wire graph
|
||||
x_mm = lx / _IU_PER_MM
|
||||
y_mm = ly / _IU_PER_MM
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
if net_points:
|
||||
all_net_points |= net_points
|
||||
|
||||
if not hasattr(schematic, "symbol"):
|
||||
return 0
|
||||
|
||||
locator = PinLocator()
|
||||
seen: Set[Tuple[str, str]] = set()
|
||||
ref = None
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_num, pin_data in all_pins.items():
|
||||
pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1]))
|
||||
if pin_iu in all_net_points:
|
||||
key = (ref, pin_num)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
|
||||
)
|
||||
|
||||
return len(seen)
|
||||
|
||||
|
||||
def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]:
|
||||
"""Return net labels that are not connected to any component pin.
|
||||
|
||||
A label is "floating" when no component pin's IU coordinate falls on the
|
||||
wire-network reachable from the label's anchor position. These labels are
|
||||
likely placed off-grid or incorrectly positioned and will cause ERC errors.
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
- "name": str — the net label text
|
||||
- "x": float — label X position in mm
|
||||
- "y": float — label Y position in mm
|
||||
- "type": str — "label" or "global_label"
|
||||
"""
|
||||
all_wires = _parse_wires(schematic)
|
||||
if all_wires:
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
else:
|
||||
adjacency = []
|
||||
iu_to_wires = {}
|
||||
|
||||
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
# Build a set of all pin IU positions for fast lookup
|
||||
pin_iu_set: Set[Tuple[int, int]] = set()
|
||||
if hasattr(schematic, "symbol"):
|
||||
locator = PinLocator()
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_data in all_pins.values():
|
||||
pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1])))
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading pins for floating-label check: {e}")
|
||||
|
||||
floating: List[Dict[str, Any]] = []
|
||||
|
||||
if not hasattr(schematic, "label"):
|
||||
return floating
|
||||
|
||||
for label in schematic.label:
|
||||
try:
|
||||
if not hasattr(label, "value"):
|
||||
continue
|
||||
name = label.value
|
||||
if not hasattr(label, "at") or not hasattr(label.at, "value"):
|
||||
continue
|
||||
coords = label.at.value
|
||||
lx_mm = float(coords[0])
|
||||
ly_mm = float(coords[1])
|
||||
label_iu = _to_iu(lx_mm, ly_mm)
|
||||
|
||||
# Check if the label anchor itself is a pin position
|
||||
if label_iu in pin_iu_set:
|
||||
continue
|
||||
|
||||
# Trace the wire-network from this label and check for pins
|
||||
if all_wires:
|
||||
_, net_points = _find_connected_wires(
|
||||
lx_mm,
|
||||
ly_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
else:
|
||||
net_points = None
|
||||
|
||||
if net_points is not None and net_points & pin_iu_set:
|
||||
continue # at least one pin on this net
|
||||
|
||||
floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking label for floating status: {e}")
|
||||
|
||||
return floating
|
||||
|
||||
|
||||
def get_net_at_point(
|
||||
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||
) -> Dict[str, Any]:
|
||||
"""Return the net name at the given coordinate, or null if none found.
|
||||
|
||||
Checks net label positions first (exact IU match within tolerance), then
|
||||
wire endpoints. Returns a dict with keys:
|
||||
- "net_name": str or None
|
||||
- "position": {"x": float, "y": float}
|
||||
- "source": "net_label" | "wire_endpoint" | None
|
||||
"""
|
||||
query_iu = _to_iu(x_mm, y_mm)
|
||||
position = {"x": x_mm, "y": y_mm}
|
||||
|
||||
# Build label map from schematic
|
||||
point_to_label, _ = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
# Check if query point is exactly on a net label / power symbol position
|
||||
label_name = point_to_label.get(query_iu)
|
||||
if label_name is not None:
|
||||
return {"net_name": label_name, "position": position, "source": "net_label"}
|
||||
|
||||
# Check if query point is on a wire endpoint
|
||||
all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else []
|
||||
if all_wires:
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
if query_iu in iu_to_wires:
|
||||
# Found a wire endpoint — trace the net to get the name
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=None,
|
||||
)
|
||||
if visited is not None:
|
||||
net: Optional[str] = None
|
||||
if net_points:
|
||||
for pt in net_points:
|
||||
net = point_to_label.get(pt)
|
||||
if net is not None:
|
||||
break
|
||||
return {"net_name": net, "position": position, "source": "wire_endpoint"}
|
||||
|
||||
return {"net_name": None, "position": position, "source": None}
|
||||
"""
|
||||
Wire Connectivity Analysis for KiCad Schematics
|
||||
|
||||
Traces wire networks from a point and finds connected component pins.
|
||||
Uses KiCad's internal integer unit system (10,000 IU per mm) for exact
|
||||
coordinate matching, mirroring KiCad's own connectivity algorithm.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
_IU_PER_MM = 10000 # KiCad schematic internal units per millimeter
|
||||
|
||||
|
||||
def _to_iu(x_mm: float, y_mm: float) -> Tuple[int, int]:
|
||||
"""Convert mm coordinates to KiCad internal units (integer)."""
|
||||
return (round(x_mm * _IU_PER_MM), round(y_mm * _IU_PER_MM))
|
||||
|
||||
|
||||
def _parse_wires(schematic: Any) -> List[List[Tuple[int, int]]]:
|
||||
"""Extract wire endpoints from a schematic object as IU tuples."""
|
||||
all_wires = []
|
||||
for wire in schematic.wire:
|
||||
if hasattr(wire, "pts") and hasattr(wire.pts, "xy"):
|
||||
pts = []
|
||||
for point in wire.pts.xy:
|
||||
if hasattr(point, "value"):
|
||||
pts.append(_to_iu(float(point.value[0]), float(point.value[1])))
|
||||
if len(pts) >= 2:
|
||||
all_wires.append(pts)
|
||||
return all_wires
|
||||
|
||||
|
||||
def _build_adjacency(
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
) -> Tuple[List[Set[int]], Dict[Tuple[int, int], Set[int]]]:
|
||||
"""Build wire adjacency using exact IU coordinate matching.
|
||||
|
||||
Wires that share an endpoint are adjacent — this naturally handles
|
||||
junctions since all wires meeting at the same point get connected.
|
||||
|
||||
Returns a tuple of:
|
||||
- adjacency: list of sets, one per wire, containing adjacent wire indices
|
||||
- iu_to_wires: dict mapping each IU endpoint to the set of wire indices
|
||||
that have an endpoint at that exact coordinate (used for seed queries)
|
||||
"""
|
||||
# Map each IU endpoint to all wire indices that touch it
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]] = {}
|
||||
for i, pts in enumerate(all_wires):
|
||||
for pt in pts:
|
||||
iu_to_wires.setdefault(pt, set()).add(i)
|
||||
|
||||
# Wires that share an IU endpoint are adjacent
|
||||
adjacency: List[Set[int]] = [set() for _ in range(len(all_wires))]
|
||||
for wire_set in iu_to_wires.values():
|
||||
wire_list = list(wire_set)
|
||||
for a in wire_list:
|
||||
for b in wire_list:
|
||||
if a != b:
|
||||
adjacency[a].add(b)
|
||||
|
||||
return adjacency, iu_to_wires
|
||||
|
||||
|
||||
def _parse_virtual_connections(
|
||||
schematic: Any, schematic_path: Any
|
||||
) -> Tuple[Dict[Tuple[int, int], str], Dict[str, List[Tuple[int, int]]]]:
|
||||
"""Return virtual connectivity from net labels and power symbols.
|
||||
|
||||
Returns a tuple of:
|
||||
- point_to_label: Dict[Tuple[int,int], str] — IU position → label name
|
||||
- label_to_points: Dict[str, List[Tuple[int,int]]] — label name → list of IU positions
|
||||
"""
|
||||
point_to_label: Dict[Tuple[int, int], str] = {}
|
||||
label_to_points: Dict[str, List[Tuple[int, int]]] = {}
|
||||
|
||||
if hasattr(schematic, "label"):
|
||||
for label in schematic.label:
|
||||
try:
|
||||
if not hasattr(label, "value"):
|
||||
continue
|
||||
name = label.value
|
||||
if not hasattr(label, "at") or not hasattr(label.at, "value"):
|
||||
continue
|
||||
coords = label.at.value
|
||||
pt = _to_iu(float(coords[0]), float(coords[1]))
|
||||
point_to_label[pt] = name
|
||||
label_to_points.setdefault(name, []).append(pt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing net label: {e}")
|
||||
|
||||
if hasattr(schematic, "symbol"):
|
||||
locator = PinLocator()
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if not ref.startswith("#PWR"):
|
||||
continue
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
if not hasattr(symbol.property, "Value"):
|
||||
continue
|
||||
name = symbol.property.Value.value
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins or "1" not in all_pins:
|
||||
continue
|
||||
pin_data = all_pins["1"]
|
||||
pt = _to_iu(float(pin_data[0]), float(pin_data[1]))
|
||||
point_to_label[pt] = name
|
||||
label_to_points.setdefault(name, []).append(pt)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error parsing power symbol: {e}")
|
||||
|
||||
return point_to_label, label_to_points
|
||||
|
||||
|
||||
def _find_connected_wires(
|
||||
x_mm: float,
|
||||
y_mm: float,
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]],
|
||||
adjacency: List[Set[int]],
|
||||
point_to_label: Optional[Dict[Tuple[int, int], str]] = None,
|
||||
label_to_points: Optional[Dict[str, List[Tuple[int, int]]]] = None,
|
||||
) -> Tuple:
|
||||
"""BFS from query point. Returns (visited wire indices, net IU points) or (None, None).
|
||||
|
||||
Requires query point (x_mm, y_mm) to be exactly on a wire endpoint (exact IU match).
|
||||
"""
|
||||
query_iu = _to_iu(x_mm, y_mm)
|
||||
|
||||
# Find seed wires: exact IU match on the query endpoint
|
||||
seed_set = iu_to_wires.get(query_iu)
|
||||
if not seed_set:
|
||||
return (None, None)
|
||||
seed_indices: Set[int] = set(seed_set)
|
||||
|
||||
# BFS flood-fill using pre-compiled adjacency
|
||||
visited: Set[int] = set(seed_indices)
|
||||
queue = list(seed_indices)
|
||||
net_points: Set[Tuple[int, int]] = set()
|
||||
for i in seed_indices:
|
||||
net_points.update(all_wires[i])
|
||||
|
||||
seen_labels: Set[str] = set()
|
||||
while queue:
|
||||
wire_idx = queue.pop()
|
||||
for neighbor_idx in adjacency[wire_idx]:
|
||||
if neighbor_idx not in visited:
|
||||
visited.add(neighbor_idx)
|
||||
queue.append(neighbor_idx)
|
||||
net_points.update(all_wires[neighbor_idx])
|
||||
|
||||
if point_to_label and label_to_points:
|
||||
for pt in all_wires[wire_idx]:
|
||||
label_name = point_to_label.get(pt)
|
||||
if label_name and label_name not in seen_labels:
|
||||
seen_labels.add(label_name)
|
||||
for other_pt in label_to_points.get(label_name, []):
|
||||
if other_pt == pt:
|
||||
continue
|
||||
for idx in iu_to_wires.get(other_pt, set()):
|
||||
if idx not in visited:
|
||||
visited.add(idx)
|
||||
queue.append(idx)
|
||||
net_points.update(all_wires[idx])
|
||||
|
||||
return (visited, net_points)
|
||||
|
||||
|
||||
def _find_pins_on_net(
|
||||
net_points: Set[Tuple[int, int]],
|
||||
schematic_path: Any,
|
||||
schematic: Any,
|
||||
) -> List[Dict]:
|
||||
"""Find component pins that land on net points using exact IU matching.
|
||||
|
||||
Returns a list of {"component": ref, "pin": pin_num} dicts.
|
||||
"""
|
||||
|
||||
def _on_net(px_mm: float, py_mm: float) -> bool:
|
||||
return _to_iu(px_mm, py_mm) in net_points
|
||||
|
||||
locator = PinLocator()
|
||||
pins = []
|
||||
seen: Set[Tuple] = set()
|
||||
|
||||
ref = None
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_num, pin_data in all_pins.items():
|
||||
if _on_net(pin_data[0], pin_data[1]):
|
||||
key = (ref, pin_num)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
pins.append({"component": ref, "pin": pin_num})
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
|
||||
)
|
||||
|
||||
return pins
|
||||
|
||||
|
||||
def get_wire_connections(
|
||||
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||
) -> Optional[Dict]:
|
||||
"""Find the net name and all component pins reachable from a point via connected wires.
|
||||
|
||||
The query point (x_mm, y_mm) must be exactly on a wire endpoint or junction (exact IU match).
|
||||
Interior (mid-segment) points are not matched —
|
||||
use wire endpoint coordinates obtained from the schematic data.
|
||||
|
||||
Net labels and power symbols are traversed: wires on the same named net are
|
||||
treated as connected even when they are not geometrically adjacent.
|
||||
|
||||
Returns dict with keys:
|
||||
- "net": str or None (net label/power name, None if unnamed)
|
||||
- "pins": list of {"component": str, "pin": str}
|
||||
- "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm
|
||||
- "query_point": {"x": float, "y": float}
|
||||
Or None if no wire endpoint found within tolerance of the query point.
|
||||
"""
|
||||
all_wires = _parse_wires(schematic)
|
||||
query_point = {"x": x_mm, "y": y_mm}
|
||||
if not all_wires:
|
||||
return {"net": None, "pins": [], "wires": [], "query_point": query_point}
|
||||
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
|
||||
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
if visited is None:
|
||||
return None
|
||||
|
||||
# Resolve net name: first label anchor that falls on this net's IU points
|
||||
net: Optional[str] = None
|
||||
for pt in net_points:
|
||||
label = point_to_label.get(pt)
|
||||
if label is not None:
|
||||
net = label
|
||||
break
|
||||
|
||||
wires_out = [
|
||||
{
|
||||
"start": {
|
||||
"x": all_wires[i][0][0] / _IU_PER_MM,
|
||||
"y": all_wires[i][0][1] / _IU_PER_MM,
|
||||
},
|
||||
"end": {
|
||||
"x": all_wires[i][-1][0] / _IU_PER_MM,
|
||||
"y": all_wires[i][-1][1] / _IU_PER_MM,
|
||||
},
|
||||
}
|
||||
for i in visited
|
||||
]
|
||||
|
||||
if not hasattr(schematic, "symbol"):
|
||||
return {"net": net, "pins": [], "wires": wires_out, "query_point": query_point}
|
||||
|
||||
pins = _find_pins_on_net(net_points, schematic_path, schematic)
|
||||
return {"net": net, "pins": pins, "wires": wires_out, "query_point": query_point}
|
||||
|
||||
|
||||
def count_pins_on_net(
|
||||
schematic: Any,
|
||||
schematic_path: str,
|
||||
net_name: str,
|
||||
all_wires: List[List[Tuple[int, int]]],
|
||||
iu_to_wires: Dict[Tuple[int, int], Set[int]],
|
||||
adjacency: List[Set[int]],
|
||||
point_to_label: Dict[Tuple[int, int], str],
|
||||
label_to_points: Dict[str, List[Tuple[int, int]]],
|
||||
) -> int:
|
||||
"""Count the number of component pins connected to the named net.
|
||||
|
||||
A pin is counted if its IU coordinate falls on the wire-network reachable
|
||||
from any label anchor for *net_name*, or directly on a label anchor of that
|
||||
net (pin directly touching a label with no intervening wire).
|
||||
|
||||
Returns the count of distinct (component, pin_num) pairs on this net.
|
||||
"""
|
||||
label_positions = label_to_points.get(net_name, [])
|
||||
if not label_positions:
|
||||
return 0
|
||||
|
||||
# Collect the union of all net-points across all label positions for this net
|
||||
all_net_points: Set[Tuple[int, int]] = set()
|
||||
for lx, ly in label_positions:
|
||||
# Include the label anchor itself so pins directly at the label count
|
||||
all_net_points.add((lx, ly))
|
||||
# Trace from this label position into the wire graph
|
||||
x_mm = lx / _IU_PER_MM
|
||||
y_mm = ly / _IU_PER_MM
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
if net_points:
|
||||
all_net_points |= net_points
|
||||
|
||||
if not hasattr(schematic, "symbol"):
|
||||
return 0
|
||||
|
||||
locator = PinLocator()
|
||||
seen: Set[Tuple[str, str]] = set()
|
||||
ref = None
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_num, pin_data in all_pins.items():
|
||||
pin_iu = _to_iu(float(pin_data[0]), float(pin_data[1]))
|
||||
if pin_iu in all_net_points:
|
||||
key = (ref, pin_num)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error checking pins for {ref if ref is not None else '<unknown>'}: {e}"
|
||||
)
|
||||
|
||||
return len(seen)
|
||||
|
||||
|
||||
def list_floating_labels(schematic: Any, schematic_path: str) -> List[Dict[str, Any]]:
|
||||
"""Return net labels that are not connected to any component pin.
|
||||
|
||||
A label is "floating" when no component pin's IU coordinate falls on the
|
||||
wire-network reachable from the label's anchor position. These labels are
|
||||
likely placed off-grid or incorrectly positioned and will cause ERC errors.
|
||||
|
||||
Returns a list of dicts with keys:
|
||||
- "name": str — the net label text
|
||||
- "x": float — label X position in mm
|
||||
- "y": float — label Y position in mm
|
||||
- "type": str — "label" or "global_label"
|
||||
"""
|
||||
all_wires = _parse_wires(schematic)
|
||||
if all_wires:
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
else:
|
||||
adjacency = []
|
||||
iu_to_wires = {}
|
||||
|
||||
point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
# Build a set of all pin IU positions for fast lookup
|
||||
pin_iu_set: Set[Tuple[int, int]] = set()
|
||||
if hasattr(schematic, "symbol"):
|
||||
locator = PinLocator()
|
||||
for symbol in schematic.symbol:
|
||||
try:
|
||||
if not hasattr(symbol, "property") or not hasattr(symbol.property, "Reference"):
|
||||
continue
|
||||
ref = symbol.property.Reference.value
|
||||
if ref.startswith("_TEMPLATE"):
|
||||
continue
|
||||
all_pins = locator.get_all_symbol_pins(Path(schematic_path), ref)
|
||||
if not all_pins:
|
||||
continue
|
||||
for pin_data in all_pins.values():
|
||||
pin_iu_set.add(_to_iu(float(pin_data[0]), float(pin_data[1])))
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading pins for floating-label check: {e}")
|
||||
|
||||
floating: List[Dict[str, Any]] = []
|
||||
|
||||
if not hasattr(schematic, "label"):
|
||||
return floating
|
||||
|
||||
for label in schematic.label:
|
||||
try:
|
||||
if not hasattr(label, "value"):
|
||||
continue
|
||||
name = label.value
|
||||
if not hasattr(label, "at") or not hasattr(label.at, "value"):
|
||||
continue
|
||||
coords = label.at.value
|
||||
lx_mm = float(coords[0])
|
||||
ly_mm = float(coords[1])
|
||||
label_iu = _to_iu(lx_mm, ly_mm)
|
||||
|
||||
# Check if the label anchor itself is a pin position
|
||||
if label_iu in pin_iu_set:
|
||||
continue
|
||||
|
||||
# Trace the wire-network from this label and check for pins
|
||||
if all_wires:
|
||||
_, net_points = _find_connected_wires(
|
||||
lx_mm,
|
||||
ly_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=label_to_points,
|
||||
)
|
||||
else:
|
||||
net_points = None
|
||||
|
||||
if net_points is not None and net_points & pin_iu_set:
|
||||
continue # at least one pin on this net
|
||||
|
||||
floating.append({"name": name, "x": lx_mm, "y": ly_mm, "type": "label"})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking label for floating status: {e}")
|
||||
|
||||
return floating
|
||||
|
||||
|
||||
def get_net_at_point(
|
||||
schematic: Any, schematic_path: str, x_mm: float, y_mm: float
|
||||
) -> Dict[str, Any]:
|
||||
"""Return the net name at the given coordinate, or null if none found.
|
||||
|
||||
Checks net label positions first (exact IU match within tolerance), then
|
||||
wire endpoints. Returns a dict with keys:
|
||||
- "net_name": str or None
|
||||
- "position": {"x": float, "y": float}
|
||||
- "source": "net_label" | "wire_endpoint" | None
|
||||
"""
|
||||
query_iu = _to_iu(x_mm, y_mm)
|
||||
position = {"x": x_mm, "y": y_mm}
|
||||
|
||||
# Build label map from schematic
|
||||
point_to_label, _ = _parse_virtual_connections(schematic, schematic_path)
|
||||
|
||||
# Check if query point is exactly on a net label / power symbol position
|
||||
label_name = point_to_label.get(query_iu)
|
||||
if label_name is not None:
|
||||
return {"net_name": label_name, "position": position, "source": "net_label"}
|
||||
|
||||
# Check if query point is on a wire endpoint
|
||||
all_wires = _parse_wires(schematic) if hasattr(schematic, "wire") else []
|
||||
if all_wires:
|
||||
adjacency, iu_to_wires = _build_adjacency(all_wires)
|
||||
if query_iu in iu_to_wires:
|
||||
# Found a wire endpoint — trace the net to get the name
|
||||
visited, net_points = _find_connected_wires(
|
||||
x_mm,
|
||||
y_mm,
|
||||
all_wires,
|
||||
iu_to_wires,
|
||||
adjacency,
|
||||
point_to_label=point_to_label,
|
||||
label_to_points=None,
|
||||
)
|
||||
if visited is not None:
|
||||
net: Optional[str] = None
|
||||
if net_points:
|
||||
for pt in net_points:
|
||||
net = point_to_label.get(pt)
|
||||
if net is not None:
|
||||
break
|
||||
return {"net_name": net, "position": position, "source": "wire_endpoint"}
|
||||
|
||||
return {"net_name": None, "position": position, "source": None}
|
||||
|
||||
@@ -1,439 +1,439 @@
|
||||
"""
|
||||
WireDragger — drag connected wires when a schematic component is moved.
|
||||
|
||||
All methods operate on in-memory sexpdata lists (no disk I/O).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
# Module-level Symbol constants
|
||||
_K = {
|
||||
name: Symbol(name)
|
||||
for name in [
|
||||
"symbol",
|
||||
"at",
|
||||
"lib_id",
|
||||
"mirror",
|
||||
"lib_symbols",
|
||||
"pts",
|
||||
"xy",
|
||||
"wire",
|
||||
"junction",
|
||||
"property",
|
||||
"stroke",
|
||||
"width",
|
||||
"type",
|
||||
"uuid",
|
||||
]
|
||||
}
|
||||
|
||||
EPS = 1e-4 # mm — coordinate match tolerance
|
||||
|
||||
|
||||
def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]:
|
||||
"""Rotate (x, y) around the origin by angle_deg degrees (CCW)."""
|
||||
if angle_deg == 0:
|
||||
return x, y
|
||||
rad = math.radians(angle_deg)
|
||||
c, s = math.cos(rad), math.sin(rad)
|
||||
return x * c - y * s, x * s + y * c
|
||||
|
||||
|
||||
def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool:
|
||||
return abs(ax - bx) < eps and abs(ay - by) < eps
|
||||
|
||||
|
||||
class WireDragger:
|
||||
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
|
||||
|
||||
@staticmethod
|
||||
def find_symbol(sch_data: list, reference: str) -> Any:
|
||||
"""
|
||||
Find a placed symbol by reference designator.
|
||||
|
||||
Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y)
|
||||
or None if the reference is not found.
|
||||
|
||||
mirror_x=True means the symbol has (mirror x) — flips the X local axis.
|
||||
mirror_y=True means the symbol has (mirror y) — flips the Y local axis.
|
||||
"""
|
||||
sym_k = _K["symbol"]
|
||||
prop_k = _K["property"]
|
||||
at_k = _K["at"]
|
||||
lib_id_k = _K["lib_id"]
|
||||
mirror_k = _K["mirror"]
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == sym_k):
|
||||
continue
|
||||
|
||||
# Check Reference property
|
||||
ref_val = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
|
||||
if str(sub[1]).strip('"') == "Reference":
|
||||
ref_val = str(sub[2]).strip('"')
|
||||
break
|
||||
if ref_val != reference:
|
||||
continue
|
||||
|
||||
old_x = old_y = rotation = 0.0
|
||||
lib_id = ""
|
||||
mirror_x = mirror_y = False
|
||||
|
||||
for sub in item[1:]:
|
||||
if not isinstance(sub, list) or not sub:
|
||||
continue
|
||||
tag = sub[0]
|
||||
if tag == at_k:
|
||||
if len(sub) >= 3:
|
||||
old_x = float(sub[1])
|
||||
old_y = float(sub[2])
|
||||
if len(sub) >= 4:
|
||||
rotation = float(sub[3])
|
||||
elif tag == lib_id_k and len(sub) >= 2:
|
||||
lib_id = str(sub[1]).strip('"')
|
||||
elif tag == mirror_k and len(sub) >= 2:
|
||||
mv = str(sub[1])
|
||||
if mv == "x":
|
||||
mirror_x = True
|
||||
elif mv == "y":
|
||||
mirror_y = True
|
||||
|
||||
return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_pin_defs(sch_data: list, lib_id: str) -> Dict:
|
||||
"""
|
||||
Get pin definitions from lib_symbols for the given lib_id.
|
||||
|
||||
Returns the same dict format as PinLocator.parse_symbol_definition:
|
||||
{pin_num: {"x": ..., "y": ..., ...}}.
|
||||
"""
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
lib_sym_k = _K["lib_symbols"]
|
||||
symbol_k = _K["symbol"]
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == lib_sym_k):
|
||||
continue
|
||||
for sym_def in item[1:]:
|
||||
if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k):
|
||||
continue
|
||||
if len(sym_def) < 2:
|
||||
continue
|
||||
name = str(sym_def[1]).strip('"')
|
||||
if name == lib_id:
|
||||
return PinLocator.parse_symbol_definition(sym_def)
|
||||
break # only one lib_symbols section
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def pin_world_xy(
|
||||
px: float,
|
||||
py: float,
|
||||
sym_x: float,
|
||||
sym_y: float,
|
||||
rotation: float,
|
||||
mirror_x: bool,
|
||||
mirror_y: bool,
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Compute the world coordinate of a pin given the symbol transform.
|
||||
|
||||
KiCAD applies mirror first (in local space), then rotation, then translation.
|
||||
mirror_x negates the local X axis; mirror_y negates the local Y axis.
|
||||
"""
|
||||
lx, ly = px, py
|
||||
if mirror_x:
|
||||
lx = -lx
|
||||
if mirror_y:
|
||||
ly = -ly
|
||||
rx, ry = _rotate(lx, ly, rotation)
|
||||
return sym_x + rx, sym_y + ry
|
||||
|
||||
@staticmethod
|
||||
def compute_pin_positions(
|
||||
sch_data: list,
|
||||
reference: str,
|
||||
new_x: float,
|
||||
new_y: float,
|
||||
) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||||
"""
|
||||
Compute world pin positions before and after a component move.
|
||||
|
||||
Returns {pin_num: (old_world_xy, new_world_xy)}.
|
||||
old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y).
|
||||
"""
|
||||
found = WireDragger.find_symbol(sch_data, reference)
|
||||
if found is None:
|
||||
return {}
|
||||
_, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found
|
||||
|
||||
pins = WireDragger.get_pin_defs(sch_data, lib_id)
|
||||
result: Dict[str, Tuple] = {}
|
||||
for pin_num, pin in pins.items():
|
||||
px, py = pin["x"], pin["y"]
|
||||
old_wx, old_wy = WireDragger.pin_world_xy(
|
||||
px, py, old_x, old_y, rotation, mirror_x, mirror_y
|
||||
)
|
||||
new_wx, new_wy = WireDragger.pin_world_xy(
|
||||
px, py, new_x, new_y, rotation, mirror_x, mirror_y
|
||||
)
|
||||
result[pin_num] = (
|
||||
(round(old_wx, 6), round(old_wy, 6)),
|
||||
(round(new_wx, 6), round(new_wy, 6)),
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def drag_wires(
|
||||
sch_data: list,
|
||||
old_to_new: Dict[Tuple[float, float], Tuple[float, float]],
|
||||
eps: float = EPS,
|
||||
) -> Dict:
|
||||
"""
|
||||
Move wire endpoints and junctions from old positions to new positions.
|
||||
Removes zero-length wires that result from the move.
|
||||
Modifies sch_data in place.
|
||||
|
||||
old_to_new: {(old_x, old_y): (new_x, new_y)}
|
||||
|
||||
Returns {'endpoints_moved': N, 'wires_removed': M}.
|
||||
"""
|
||||
wire_k = _K["wire"]
|
||||
pts_k = _K["pts"]
|
||||
xy_k = _K["xy"]
|
||||
junction_k = _K["junction"]
|
||||
at_k = _K["at"]
|
||||
|
||||
def find_new(x: float, y: float) -> Optional[Tuple[float, float]]:
|
||||
for (ox, oy), (nx, ny) in old_to_new.items():
|
||||
if _coords_match(x, y, ox, oy, eps):
|
||||
return nx, ny
|
||||
return None
|
||||
|
||||
endpoints_moved = 0
|
||||
zero_length_indices = []
|
||||
|
||||
# First pass: update wire endpoints
|
||||
for idx, item in enumerate(sch_data):
|
||||
if not (isinstance(item, list) and item and item[0] == wire_k):
|
||||
continue
|
||||
|
||||
pts_sub = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == pts_k:
|
||||
pts_sub = sub
|
||||
break
|
||||
if pts_sub is None:
|
||||
continue
|
||||
|
||||
xy_items = [
|
||||
p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k
|
||||
]
|
||||
for xy_item in xy_items:
|
||||
nc = find_new(float(xy_item[1]), float(xy_item[2]))
|
||||
if nc is not None:
|
||||
xy_item[1] = nc[0]
|
||||
xy_item[2] = nc[1]
|
||||
endpoints_moved += 1
|
||||
|
||||
# Check if this wire is now zero-length
|
||||
if len(xy_items) >= 2:
|
||||
x1, y1 = float(xy_items[0][1]), float(xy_items[0][2])
|
||||
x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2])
|
||||
if _coords_match(x1, y1, x2, y2, eps):
|
||||
zero_length_indices.append(idx)
|
||||
|
||||
# Remove zero-length wires (backwards to preserve indices)
|
||||
for idx in reversed(zero_length_indices):
|
||||
del sch_data[idx]
|
||||
|
||||
# Second pass: update junctions
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == junction_k):
|
||||
continue
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
|
||||
nc = find_new(float(sub[1]), float(sub[2]))
|
||||
if nc is not None:
|
||||
sub[1] = nc[0]
|
||||
sub[2] = nc[1]
|
||||
break
|
||||
|
||||
return {
|
||||
"endpoints_moved": endpoints_moved,
|
||||
"wires_removed": len(zero_length_indices),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool:
|
||||
"""
|
||||
Update the (at x y rot) of the named symbol in sch_data.
|
||||
Returns True if the symbol was found and updated.
|
||||
"""
|
||||
found = WireDragger.find_symbol(sch_data, reference)
|
||||
if found is None:
|
||||
return False
|
||||
item = found[0]
|
||||
at_k = _K["at"]
|
||||
prop_k = _K["property"]
|
||||
|
||||
# Find current position and compute delta
|
||||
old_x = old_y = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
|
||||
old_x, old_y = sub[1], sub[2]
|
||||
sub[1] = new_x
|
||||
sub[2] = new_y
|
||||
break
|
||||
if old_x is None or old_y is None:
|
||||
return False
|
||||
|
||||
dx = new_x - old_x
|
||||
dy = new_y - old_y
|
||||
|
||||
# Shift all property label positions by the same delta
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == prop_k:
|
||||
for psub in sub[1:]:
|
||||
if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3:
|
||||
psub[1] += dx
|
||||
psub[2] += dy
|
||||
break
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list:
|
||||
"""Build a wire s-expression list in KiCAD schematic format."""
|
||||
wire_uuid = str(uuid.uuid4())
|
||||
return [
|
||||
_K["wire"],
|
||||
[_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]],
|
||||
[_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]],
|
||||
[_K["uuid"], wire_uuid],
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_all_stationary_pin_positions(
|
||||
sch_data: list,
|
||||
moved_reference: str,
|
||||
) -> Dict[Tuple[float, float], str]:
|
||||
"""
|
||||
Return a map of {world_xy: reference} for every pin of every symbol
|
||||
in sch_data *except* moved_reference.
|
||||
|
||||
This is used to detect pins of stationary components that coincide
|
||||
with pins of the moved component (touching-pin connections).
|
||||
"""
|
||||
sym_k = _K["symbol"]
|
||||
prop_k = _K["property"]
|
||||
result: Dict[Tuple[float, float], str] = {}
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == sym_k):
|
||||
continue
|
||||
# Determine reference
|
||||
ref_val = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
|
||||
if str(sub[1]).strip('"') == "Reference":
|
||||
ref_val = str(sub[2]).strip('"')
|
||||
break
|
||||
if ref_val is None or ref_val == moved_reference:
|
||||
continue
|
||||
# Skip template / power symbols whose references start with special chars
|
||||
# but we still want to handle them — no filtering needed here.
|
||||
|
||||
# Find lib_id and position for this symbol
|
||||
found = WireDragger.find_symbol(sch_data, ref_val)
|
||||
if found is None:
|
||||
continue
|
||||
_, sx, sy, rotation, lib_id, mirror_x, mirror_y = found
|
||||
pins = WireDragger.get_pin_defs(sch_data, lib_id)
|
||||
for pin_num, pin in pins.items():
|
||||
wx, wy = WireDragger.pin_world_xy(
|
||||
pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y
|
||||
)
|
||||
key = (round(wx, 6), round(wy, 6))
|
||||
result[key] = ref_val
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def synthesize_touching_pin_wires(
|
||||
sch_data: list,
|
||||
moved_reference: str,
|
||||
pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]],
|
||||
eps: float = EPS,
|
||||
) -> int:
|
||||
"""
|
||||
Detect touching-pin connections and synthesize wire segments to bridge gaps
|
||||
created by moving a component.
|
||||
|
||||
For each pin of *moved_reference* whose old world position coincides with
|
||||
a pin of a stationary component:
|
||||
- If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy.
|
||||
- If the pin now lands on another stationary pin's position, skip (they touch again).
|
||||
- If old_xy == new_xy, do nothing (no gap was created).
|
||||
|
||||
Modifies sch_data in place.
|
||||
Returns the number of wire segments synthesized.
|
||||
"""
|
||||
if not pin_positions:
|
||||
return 0
|
||||
|
||||
stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference)
|
||||
if not stationary_pins:
|
||||
return 0
|
||||
|
||||
synthesized = 0
|
||||
|
||||
for pin_num, (old_xy, new_xy) in pin_positions.items():
|
||||
# Check if a stationary pin touches this pin's old position
|
||||
touching = any(
|
||||
_coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
|
||||
)
|
||||
if not touching:
|
||||
continue
|
||||
|
||||
# The pin has moved — check if it actually separated
|
||||
if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps):
|
||||
# Pin didn't actually move; no gap
|
||||
continue
|
||||
|
||||
# Check if the pin's new position happens to touch another stationary pin
|
||||
# (component moved into a different touching position — no wire needed)
|
||||
rejoining = any(
|
||||
_coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
|
||||
)
|
||||
if rejoining:
|
||||
logger.debug(
|
||||
f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} "
|
||||
f"and rejoins another stationary pin; no wire synthesized"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Synthesizing wire for touching-pin connection: "
|
||||
f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}"
|
||||
)
|
||||
wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1])
|
||||
# Insert before the last item (sheet_instances) to keep file tidy,
|
||||
# but appending is also valid — just append.
|
||||
sch_data.append(wire)
|
||||
synthesized += 1
|
||||
|
||||
return synthesized
|
||||
"""
|
||||
WireDragger — drag connected wires when a schematic component is moved.
|
||||
|
||||
All methods operate on in-memory sexpdata lists (no disk I/O).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import sexpdata
|
||||
from sexpdata import Symbol
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
# Module-level Symbol constants
|
||||
_K = {
|
||||
name: Symbol(name)
|
||||
for name in [
|
||||
"symbol",
|
||||
"at",
|
||||
"lib_id",
|
||||
"mirror",
|
||||
"lib_symbols",
|
||||
"pts",
|
||||
"xy",
|
||||
"wire",
|
||||
"junction",
|
||||
"property",
|
||||
"stroke",
|
||||
"width",
|
||||
"type",
|
||||
"uuid",
|
||||
]
|
||||
}
|
||||
|
||||
EPS = 1e-4 # mm — coordinate match tolerance
|
||||
|
||||
|
||||
def _rotate(x: float, y: float, angle_deg: float) -> Tuple[float, float]:
|
||||
"""Rotate (x, y) around the origin by angle_deg degrees (CCW)."""
|
||||
if angle_deg == 0:
|
||||
return x, y
|
||||
rad = math.radians(angle_deg)
|
||||
c, s = math.cos(rad), math.sin(rad)
|
||||
return x * c - y * s, x * s + y * c
|
||||
|
||||
|
||||
def _coords_match(ax: float, ay: float, bx: float, by: float, eps: float = EPS) -> bool:
|
||||
return abs(ax - bx) < eps and abs(ay - by) < eps
|
||||
|
||||
|
||||
class WireDragger:
|
||||
"""Pure-logic helpers for wire-endpoint dragging during component moves."""
|
||||
|
||||
@staticmethod
|
||||
def find_symbol(sch_data: list, reference: str) -> Any:
|
||||
"""
|
||||
Find a placed symbol by reference designator.
|
||||
|
||||
Returns (symbol_item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y)
|
||||
or None if the reference is not found.
|
||||
|
||||
mirror_x=True means the symbol has (mirror x) — flips the X local axis.
|
||||
mirror_y=True means the symbol has (mirror y) — flips the Y local axis.
|
||||
"""
|
||||
sym_k = _K["symbol"]
|
||||
prop_k = _K["property"]
|
||||
at_k = _K["at"]
|
||||
lib_id_k = _K["lib_id"]
|
||||
mirror_k = _K["mirror"]
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == sym_k):
|
||||
continue
|
||||
|
||||
# Check Reference property
|
||||
ref_val = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
|
||||
if str(sub[1]).strip('"') == "Reference":
|
||||
ref_val = str(sub[2]).strip('"')
|
||||
break
|
||||
if ref_val != reference:
|
||||
continue
|
||||
|
||||
old_x = old_y = rotation = 0.0
|
||||
lib_id = ""
|
||||
mirror_x = mirror_y = False
|
||||
|
||||
for sub in item[1:]:
|
||||
if not isinstance(sub, list) or not sub:
|
||||
continue
|
||||
tag = sub[0]
|
||||
if tag == at_k:
|
||||
if len(sub) >= 3:
|
||||
old_x = float(sub[1])
|
||||
old_y = float(sub[2])
|
||||
if len(sub) >= 4:
|
||||
rotation = float(sub[3])
|
||||
elif tag == lib_id_k and len(sub) >= 2:
|
||||
lib_id = str(sub[1]).strip('"')
|
||||
elif tag == mirror_k and len(sub) >= 2:
|
||||
mv = str(sub[1])
|
||||
if mv == "x":
|
||||
mirror_x = True
|
||||
elif mv == "y":
|
||||
mirror_y = True
|
||||
|
||||
return item, old_x, old_y, rotation, lib_id, mirror_x, mirror_y
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_pin_defs(sch_data: list, lib_id: str) -> Dict:
|
||||
"""
|
||||
Get pin definitions from lib_symbols for the given lib_id.
|
||||
|
||||
Returns the same dict format as PinLocator.parse_symbol_definition:
|
||||
{pin_num: {"x": ..., "y": ..., ...}}.
|
||||
"""
|
||||
from commands.pin_locator import PinLocator
|
||||
|
||||
lib_sym_k = _K["lib_symbols"]
|
||||
symbol_k = _K["symbol"]
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == lib_sym_k):
|
||||
continue
|
||||
for sym_def in item[1:]:
|
||||
if not (isinstance(sym_def, list) and sym_def and sym_def[0] == symbol_k):
|
||||
continue
|
||||
if len(sym_def) < 2:
|
||||
continue
|
||||
name = str(sym_def[1]).strip('"')
|
||||
if name == lib_id:
|
||||
return PinLocator.parse_symbol_definition(sym_def)
|
||||
break # only one lib_symbols section
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def pin_world_xy(
|
||||
px: float,
|
||||
py: float,
|
||||
sym_x: float,
|
||||
sym_y: float,
|
||||
rotation: float,
|
||||
mirror_x: bool,
|
||||
mirror_y: bool,
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Compute the world coordinate of a pin given the symbol transform.
|
||||
|
||||
KiCAD applies mirror first (in local space), then rotation, then translation.
|
||||
mirror_x negates the local X axis; mirror_y negates the local Y axis.
|
||||
"""
|
||||
lx, ly = px, py
|
||||
if mirror_x:
|
||||
lx = -lx
|
||||
if mirror_y:
|
||||
ly = -ly
|
||||
rx, ry = _rotate(lx, ly, rotation)
|
||||
return sym_x + rx, sym_y + ry
|
||||
|
||||
@staticmethod
|
||||
def compute_pin_positions(
|
||||
sch_data: list,
|
||||
reference: str,
|
||||
new_x: float,
|
||||
new_y: float,
|
||||
) -> Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]]:
|
||||
"""
|
||||
Compute world pin positions before and after a component move.
|
||||
|
||||
Returns {pin_num: (old_world_xy, new_world_xy)}.
|
||||
old_world_xy uses the symbol's current position; new_world_xy uses (new_x, new_y).
|
||||
"""
|
||||
found = WireDragger.find_symbol(sch_data, reference)
|
||||
if found is None:
|
||||
return {}
|
||||
_, old_x, old_y, rotation, lib_id, mirror_x, mirror_y = found
|
||||
|
||||
pins = WireDragger.get_pin_defs(sch_data, lib_id)
|
||||
result: Dict[str, Tuple] = {}
|
||||
for pin_num, pin in pins.items():
|
||||
px, py = pin["x"], pin["y"]
|
||||
old_wx, old_wy = WireDragger.pin_world_xy(
|
||||
px, py, old_x, old_y, rotation, mirror_x, mirror_y
|
||||
)
|
||||
new_wx, new_wy = WireDragger.pin_world_xy(
|
||||
px, py, new_x, new_y, rotation, mirror_x, mirror_y
|
||||
)
|
||||
result[pin_num] = (
|
||||
(round(old_wx, 6), round(old_wy, 6)),
|
||||
(round(new_wx, 6), round(new_wy, 6)),
|
||||
)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def drag_wires(
|
||||
sch_data: list,
|
||||
old_to_new: Dict[Tuple[float, float], Tuple[float, float]],
|
||||
eps: float = EPS,
|
||||
) -> Dict:
|
||||
"""
|
||||
Move wire endpoints and junctions from old positions to new positions.
|
||||
Removes zero-length wires that result from the move.
|
||||
Modifies sch_data in place.
|
||||
|
||||
old_to_new: {(old_x, old_y): (new_x, new_y)}
|
||||
|
||||
Returns {'endpoints_moved': N, 'wires_removed': M}.
|
||||
"""
|
||||
wire_k = _K["wire"]
|
||||
pts_k = _K["pts"]
|
||||
xy_k = _K["xy"]
|
||||
junction_k = _K["junction"]
|
||||
at_k = _K["at"]
|
||||
|
||||
def find_new(x: float, y: float) -> Optional[Tuple[float, float]]:
|
||||
for (ox, oy), (nx, ny) in old_to_new.items():
|
||||
if _coords_match(x, y, ox, oy, eps):
|
||||
return nx, ny
|
||||
return None
|
||||
|
||||
endpoints_moved = 0
|
||||
zero_length_indices = []
|
||||
|
||||
# First pass: update wire endpoints
|
||||
for idx, item in enumerate(sch_data):
|
||||
if not (isinstance(item, list) and item and item[0] == wire_k):
|
||||
continue
|
||||
|
||||
pts_sub = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == pts_k:
|
||||
pts_sub = sub
|
||||
break
|
||||
if pts_sub is None:
|
||||
continue
|
||||
|
||||
xy_items = [
|
||||
p for p in pts_sub[1:] if isinstance(p, list) and len(p) >= 3 and p[0] == xy_k
|
||||
]
|
||||
for xy_item in xy_items:
|
||||
nc = find_new(float(xy_item[1]), float(xy_item[2]))
|
||||
if nc is not None:
|
||||
xy_item[1] = nc[0]
|
||||
xy_item[2] = nc[1]
|
||||
endpoints_moved += 1
|
||||
|
||||
# Check if this wire is now zero-length
|
||||
if len(xy_items) >= 2:
|
||||
x1, y1 = float(xy_items[0][1]), float(xy_items[0][2])
|
||||
x2, y2 = float(xy_items[-1][1]), float(xy_items[-1][2])
|
||||
if _coords_match(x1, y1, x2, y2, eps):
|
||||
zero_length_indices.append(idx)
|
||||
|
||||
# Remove zero-length wires (backwards to preserve indices)
|
||||
for idx in reversed(zero_length_indices):
|
||||
del sch_data[idx]
|
||||
|
||||
# Second pass: update junctions
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == junction_k):
|
||||
continue
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
|
||||
nc = find_new(float(sub[1]), float(sub[2]))
|
||||
if nc is not None:
|
||||
sub[1] = nc[0]
|
||||
sub[2] = nc[1]
|
||||
break
|
||||
|
||||
return {
|
||||
"endpoints_moved": endpoints_moved,
|
||||
"wires_removed": len(zero_length_indices),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def update_symbol_position(sch_data: list, reference: str, new_x: float, new_y: float) -> bool:
|
||||
"""
|
||||
Update the (at x y rot) of the named symbol in sch_data.
|
||||
Returns True if the symbol was found and updated.
|
||||
"""
|
||||
found = WireDragger.find_symbol(sch_data, reference)
|
||||
if found is None:
|
||||
return False
|
||||
item = found[0]
|
||||
at_k = _K["at"]
|
||||
prop_k = _K["property"]
|
||||
|
||||
# Find current position and compute delta
|
||||
old_x = old_y = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == at_k and len(sub) >= 3:
|
||||
old_x, old_y = sub[1], sub[2]
|
||||
sub[1] = new_x
|
||||
sub[2] = new_y
|
||||
break
|
||||
if old_x is None or old_y is None:
|
||||
return False
|
||||
|
||||
dx = new_x - old_x
|
||||
dy = new_y - old_y
|
||||
|
||||
# Shift all property label positions by the same delta
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and sub and sub[0] == prop_k:
|
||||
for psub in sub[1:]:
|
||||
if isinstance(psub, list) and psub and psub[0] == at_k and len(psub) >= 3:
|
||||
psub[1] += dx
|
||||
psub[2] += dy
|
||||
break
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _make_wire_sexp(x1: float, y1: float, x2: float, y2: float) -> list:
|
||||
"""Build a wire s-expression list in KiCAD schematic format."""
|
||||
wire_uuid = str(uuid.uuid4())
|
||||
return [
|
||||
_K["wire"],
|
||||
[_K["pts"], [_K["xy"], x1, y1], [_K["xy"], x2, y2]],
|
||||
[_K["stroke"], [_K["width"], 0], [_K["type"], Symbol("default")]],
|
||||
[_K["uuid"], wire_uuid],
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def get_all_stationary_pin_positions(
|
||||
sch_data: list,
|
||||
moved_reference: str,
|
||||
) -> Dict[Tuple[float, float], str]:
|
||||
"""
|
||||
Return a map of {world_xy: reference} for every pin of every symbol
|
||||
in sch_data *except* moved_reference.
|
||||
|
||||
This is used to detect pins of stationary components that coincide
|
||||
with pins of the moved component (touching-pin connections).
|
||||
"""
|
||||
sym_k = _K["symbol"]
|
||||
prop_k = _K["property"]
|
||||
result: Dict[Tuple[float, float], str] = {}
|
||||
|
||||
for item in sch_data:
|
||||
if not (isinstance(item, list) and item and item[0] == sym_k):
|
||||
continue
|
||||
# Determine reference
|
||||
ref_val = None
|
||||
for sub in item[1:]:
|
||||
if isinstance(sub, list) and len(sub) >= 3 and sub[0] == prop_k:
|
||||
if str(sub[1]).strip('"') == "Reference":
|
||||
ref_val = str(sub[2]).strip('"')
|
||||
break
|
||||
if ref_val is None or ref_val == moved_reference:
|
||||
continue
|
||||
# Skip template / power symbols whose references start with special chars
|
||||
# but we still want to handle them — no filtering needed here.
|
||||
|
||||
# Find lib_id and position for this symbol
|
||||
found = WireDragger.find_symbol(sch_data, ref_val)
|
||||
if found is None:
|
||||
continue
|
||||
_, sx, sy, rotation, lib_id, mirror_x, mirror_y = found
|
||||
pins = WireDragger.get_pin_defs(sch_data, lib_id)
|
||||
for pin_num, pin in pins.items():
|
||||
wx, wy = WireDragger.pin_world_xy(
|
||||
pin["x"], pin["y"], sx, sy, rotation, mirror_x, mirror_y
|
||||
)
|
||||
key = (round(wx, 6), round(wy, 6))
|
||||
result[key] = ref_val
|
||||
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def synthesize_touching_pin_wires(
|
||||
sch_data: list,
|
||||
moved_reference: str,
|
||||
pin_positions: Dict[str, Tuple[Tuple[float, float], Tuple[float, float]]],
|
||||
eps: float = EPS,
|
||||
) -> int:
|
||||
"""
|
||||
Detect touching-pin connections and synthesize wire segments to bridge gaps
|
||||
created by moving a component.
|
||||
|
||||
For each pin of *moved_reference* whose old world position coincides with
|
||||
a pin of a stationary component:
|
||||
- If the pin moved (old_xy != new_xy), insert a wire from old_xy to new_xy.
|
||||
- If the pin now lands on another stationary pin's position, skip (they touch again).
|
||||
- If old_xy == new_xy, do nothing (no gap was created).
|
||||
|
||||
Modifies sch_data in place.
|
||||
Returns the number of wire segments synthesized.
|
||||
"""
|
||||
if not pin_positions:
|
||||
return 0
|
||||
|
||||
stationary_pins = WireDragger.get_all_stationary_pin_positions(sch_data, moved_reference)
|
||||
if not stationary_pins:
|
||||
return 0
|
||||
|
||||
synthesized = 0
|
||||
|
||||
for pin_num, (old_xy, new_xy) in pin_positions.items():
|
||||
# Check if a stationary pin touches this pin's old position
|
||||
touching = any(
|
||||
_coords_match(old_xy[0], old_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
|
||||
)
|
||||
if not touching:
|
||||
continue
|
||||
|
||||
# The pin has moved — check if it actually separated
|
||||
if _coords_match(old_xy[0], old_xy[1], new_xy[0], new_xy[1], eps):
|
||||
# Pin didn't actually move; no gap
|
||||
continue
|
||||
|
||||
# Check if the pin's new position happens to touch another stationary pin
|
||||
# (component moved into a different touching position — no wire needed)
|
||||
rejoining = any(
|
||||
_coords_match(new_xy[0], new_xy[1], sx, sy, eps) for (sx, sy) in stationary_pins
|
||||
)
|
||||
if rejoining:
|
||||
logger.debug(
|
||||
f"Pin {moved_reference}/{pin_num} moved from {old_xy} to {new_xy} "
|
||||
f"and rejoins another stationary pin; no wire synthesized"
|
||||
)
|
||||
continue
|
||||
|
||||
logger.info(
|
||||
f"Synthesizing wire for touching-pin connection: "
|
||||
f"{moved_reference}/{pin_num} moved from {old_xy} to {new_xy}"
|
||||
)
|
||||
wire = WireDragger._make_wire_sexp(old_xy[0], old_xy[1], new_xy[0], new_xy[1])
|
||||
# Insert before the last item (sheet_instances) to keep file tidy,
|
||||
# but appending is also valid — just append.
|
||||
sch_data.append(wire)
|
||||
synthesized += 1
|
||||
|
||||
return synthesized
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,27 @@
|
||||
"""
|
||||
KiCAD API Abstraction Layer
|
||||
|
||||
This module provides a unified interface to KiCAD's Python APIs,
|
||||
supporting both the legacy SWIG bindings and the new IPC API.
|
||||
|
||||
Usage:
|
||||
from kicad_api import create_backend
|
||||
|
||||
# Auto-detect best available backend
|
||||
backend = create_backend()
|
||||
|
||||
# Or specify explicitly
|
||||
backend = create_backend('ipc') # Use IPC API
|
||||
backend = create_backend('swig') # Use legacy SWIG
|
||||
|
||||
# Connect and use
|
||||
if backend.connect():
|
||||
board = backend.get_board()
|
||||
board.set_size(100, 80)
|
||||
"""
|
||||
|
||||
from kicad_api.base import KiCADBackend
|
||||
from kicad_api.factory import create_backend
|
||||
|
||||
__all__ = ["create_backend", "KiCADBackend"]
|
||||
__version__ = "2.0.0-alpha.1"
|
||||
"""
|
||||
KiCAD API Abstraction Layer
|
||||
|
||||
This module provides a unified interface to KiCAD's Python APIs,
|
||||
supporting both the legacy SWIG bindings and the new IPC API.
|
||||
|
||||
Usage:
|
||||
from kicad_api import create_backend
|
||||
|
||||
# Auto-detect best available backend
|
||||
backend = create_backend()
|
||||
|
||||
# Or specify explicitly
|
||||
backend = create_backend('ipc') # Use IPC API
|
||||
backend = create_backend('swig') # Use legacy SWIG
|
||||
|
||||
# Connect and use
|
||||
if backend.connect():
|
||||
board = backend.get_board()
|
||||
board.set_size(100, 80)
|
||||
"""
|
||||
|
||||
from kicad_api.base import KiCADBackend
|
||||
from kicad_api.factory import create_backend
|
||||
|
||||
__all__ = ["create_backend", "KiCADBackend"]
|
||||
__version__ = "2.0.0-alpha.1"
|
||||
|
||||
@@ -1,293 +1,293 @@
|
||||
"""
|
||||
Abstract base class for KiCAD API backends
|
||||
|
||||
Defines the interface that all KiCAD backends must implement.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KiCADBackend(ABC):
|
||||
"""Abstract base class for KiCAD API backends"""
|
||||
|
||||
@abstractmethod
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
Connect to KiCAD
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from KiCAD and clean up resources"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_connected(self) -> bool:
|
||||
"""
|
||||
Check if currently connected to KiCAD
|
||||
|
||||
Returns:
|
||||
True if connected, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_version(self) -> str:
|
||||
"""
|
||||
Get KiCAD version
|
||||
|
||||
Returns:
|
||||
Version string (e.g., "9.0.0")
|
||||
"""
|
||||
pass
|
||||
|
||||
# Project Operations
|
||||
@abstractmethod
|
||||
def create_project(self, path: Path, name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new KiCAD project
|
||||
|
||||
Args:
|
||||
path: Directory path for the project
|
||||
name: Project name
|
||||
|
||||
Returns:
|
||||
Dictionary with project info
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def open_project(self, path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Open an existing KiCAD project
|
||||
|
||||
Args:
|
||||
path: Path to .kicad_pro file
|
||||
|
||||
Returns:
|
||||
Dictionary with project info
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Save the current project
|
||||
|
||||
Args:
|
||||
path: Optional new path to save to
|
||||
|
||||
Returns:
|
||||
Dictionary with save status
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def close_project(self) -> None:
|
||||
"""Close the current project"""
|
||||
pass
|
||||
|
||||
# Board Operations
|
||||
@abstractmethod
|
||||
def get_board(self) -> "BoardAPI":
|
||||
"""
|
||||
Get board API for current project
|
||||
|
||||
Returns:
|
||||
BoardAPI instance
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class BoardAPI(ABC):
|
||||
"""Abstract interface for board operations"""
|
||||
|
||||
@abstractmethod
|
||||
def set_size(self, width: float, height: float, unit: str = "mm") -> bool:
|
||||
"""
|
||||
Set board size
|
||||
|
||||
Args:
|
||||
width: Board width
|
||||
height: Board height
|
||||
unit: Unit of measurement ("mm" or "in")
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_size(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current board size
|
||||
|
||||
Returns:
|
||||
Dictionary with width, height, unit
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_layer(self, layer_name: str, layer_type: str) -> bool:
|
||||
"""
|
||||
Add a layer to the board
|
||||
|
||||
Args:
|
||||
layer_name: Name of the layer
|
||||
layer_type: Type ("copper", "technical", "user")
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_components(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all components on the board
|
||||
|
||||
Returns:
|
||||
List of component dictionaries
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def place_component(
|
||||
self,
|
||||
reference: str,
|
||||
footprint: str,
|
||||
x: float,
|
||||
y: float,
|
||||
rotation: float = 0,
|
||||
layer: str = "F.Cu",
|
||||
value: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Place a component on the board
|
||||
|
||||
Args:
|
||||
reference: Component reference (e.g., "R1")
|
||||
footprint: Footprint library path
|
||||
x: X position (mm)
|
||||
y: Y position (mm)
|
||||
rotation: Rotation angle (degrees)
|
||||
layer: Layer name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
# Routing Operations
|
||||
def add_track(
|
||||
self,
|
||||
start_x: float,
|
||||
start_y: float,
|
||||
end_x: float,
|
||||
end_y: float,
|
||||
width: float = 0.25,
|
||||
layer: str = "F.Cu",
|
||||
net_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Add a track (trace) to the board
|
||||
|
||||
Args:
|
||||
start_x: Start X position (mm)
|
||||
start_y: Start Y position (mm)
|
||||
end_x: End X position (mm)
|
||||
end_y: End Y position (mm)
|
||||
width: Track width (mm)
|
||||
layer: Layer name
|
||||
net_name: Optional net name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def add_via(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
diameter: float = 0.8,
|
||||
drill: float = 0.4,
|
||||
net_name: Optional[str] = None,
|
||||
via_type: str = "through",
|
||||
) -> bool:
|
||||
"""
|
||||
Add a via to the board
|
||||
|
||||
Args:
|
||||
x: X position (mm)
|
||||
y: Y position (mm)
|
||||
diameter: Via diameter (mm)
|
||||
drill: Drill diameter (mm)
|
||||
net_name: Optional net name
|
||||
via_type: Via type ("through", "blind", "micro")
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
# Transaction support for undo/redo
|
||||
def begin_transaction(self, description: str = "MCP Operation") -> None:
|
||||
"""Begin a transaction for grouping operations."""
|
||||
pass # Optional - not all backends support this
|
||||
|
||||
def commit_transaction(self, description: str = "MCP Operation") -> None:
|
||||
"""Commit the current transaction."""
|
||||
pass # Optional
|
||||
|
||||
def rollback_transaction(self) -> None:
|
||||
"""Roll back the current transaction."""
|
||||
pass # Optional
|
||||
|
||||
def save(self) -> bool:
|
||||
"""Save the board."""
|
||||
raise NotImplementedError()
|
||||
|
||||
# Query operations
|
||||
def get_tracks(self) -> List[Dict[str, Any]]:
|
||||
"""Get all tracks on the board."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_vias(self) -> List[Dict[str, Any]]:
|
||||
"""Get all vias on the board."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_nets(self) -> List[Dict[str, Any]]:
|
||||
"""Get all nets on the board."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_selection(self) -> List[Dict[str, Any]]:
|
||||
"""Get currently selected items."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class BackendError(Exception):
|
||||
"""Base exception for backend errors"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConnectionError(BackendError):
|
||||
"""Raised when connection to KiCAD fails"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class APINotAvailableError(BackendError):
|
||||
"""Raised when required API is not available"""
|
||||
|
||||
pass
|
||||
"""
|
||||
Abstract base class for KiCAD API backends
|
||||
|
||||
Defines the interface that all KiCAD backends must implement.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KiCADBackend(ABC):
|
||||
"""Abstract base class for KiCAD API backends"""
|
||||
|
||||
@abstractmethod
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
Connect to KiCAD
|
||||
|
||||
Returns:
|
||||
True if connection successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from KiCAD and clean up resources"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def is_connected(self) -> bool:
|
||||
"""
|
||||
Check if currently connected to KiCAD
|
||||
|
||||
Returns:
|
||||
True if connected, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_version(self) -> str:
|
||||
"""
|
||||
Get KiCAD version
|
||||
|
||||
Returns:
|
||||
Version string (e.g., "9.0.0")
|
||||
"""
|
||||
pass
|
||||
|
||||
# Project Operations
|
||||
@abstractmethod
|
||||
def create_project(self, path: Path, name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a new KiCAD project
|
||||
|
||||
Args:
|
||||
path: Directory path for the project
|
||||
name: Project name
|
||||
|
||||
Returns:
|
||||
Dictionary with project info
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def open_project(self, path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Open an existing KiCAD project
|
||||
|
||||
Args:
|
||||
path: Path to .kicad_pro file
|
||||
|
||||
Returns:
|
||||
Dictionary with project info
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Save the current project
|
||||
|
||||
Args:
|
||||
path: Optional new path to save to
|
||||
|
||||
Returns:
|
||||
Dictionary with save status
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def close_project(self) -> None:
|
||||
"""Close the current project"""
|
||||
pass
|
||||
|
||||
# Board Operations
|
||||
@abstractmethod
|
||||
def get_board(self) -> "BoardAPI":
|
||||
"""
|
||||
Get board API for current project
|
||||
|
||||
Returns:
|
||||
BoardAPI instance
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class BoardAPI(ABC):
|
||||
"""Abstract interface for board operations"""
|
||||
|
||||
@abstractmethod
|
||||
def set_size(self, width: float, height: float, unit: str = "mm") -> bool:
|
||||
"""
|
||||
Set board size
|
||||
|
||||
Args:
|
||||
width: Board width
|
||||
height: Board height
|
||||
unit: Unit of measurement ("mm" or "in")
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_size(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current board size
|
||||
|
||||
Returns:
|
||||
Dictionary with width, height, unit
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_layer(self, layer_name: str, layer_type: str) -> bool:
|
||||
"""
|
||||
Add a layer to the board
|
||||
|
||||
Args:
|
||||
layer_name: Name of the layer
|
||||
layer_type: Type ("copper", "technical", "user")
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_components(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List all components on the board
|
||||
|
||||
Returns:
|
||||
List of component dictionaries
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def place_component(
|
||||
self,
|
||||
reference: str,
|
||||
footprint: str,
|
||||
x: float,
|
||||
y: float,
|
||||
rotation: float = 0,
|
||||
layer: str = "F.Cu",
|
||||
value: str = "",
|
||||
) -> bool:
|
||||
"""
|
||||
Place a component on the board
|
||||
|
||||
Args:
|
||||
reference: Component reference (e.g., "R1")
|
||||
footprint: Footprint library path
|
||||
x: X position (mm)
|
||||
y: Y position (mm)
|
||||
rotation: Rotation angle (degrees)
|
||||
layer: Layer name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
pass
|
||||
|
||||
# Routing Operations
|
||||
def add_track(
|
||||
self,
|
||||
start_x: float,
|
||||
start_y: float,
|
||||
end_x: float,
|
||||
end_y: float,
|
||||
width: float = 0.25,
|
||||
layer: str = "F.Cu",
|
||||
net_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Add a track (trace) to the board
|
||||
|
||||
Args:
|
||||
start_x: Start X position (mm)
|
||||
start_y: Start Y position (mm)
|
||||
end_x: End X position (mm)
|
||||
end_y: End Y position (mm)
|
||||
width: Track width (mm)
|
||||
layer: Layer name
|
||||
net_name: Optional net name
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def add_via(
|
||||
self,
|
||||
x: float,
|
||||
y: float,
|
||||
diameter: float = 0.8,
|
||||
drill: float = 0.4,
|
||||
net_name: Optional[str] = None,
|
||||
via_type: str = "through",
|
||||
) -> bool:
|
||||
"""
|
||||
Add a via to the board
|
||||
|
||||
Args:
|
||||
x: X position (mm)
|
||||
y: Y position (mm)
|
||||
diameter: Via diameter (mm)
|
||||
drill: Drill diameter (mm)
|
||||
net_name: Optional net name
|
||||
via_type: Via type ("through", "blind", "micro")
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
# Transaction support for undo/redo
|
||||
def begin_transaction(self, description: str = "MCP Operation") -> None:
|
||||
"""Begin a transaction for grouping operations."""
|
||||
pass # Optional - not all backends support this
|
||||
|
||||
def commit_transaction(self, description: str = "MCP Operation") -> None:
|
||||
"""Commit the current transaction."""
|
||||
pass # Optional
|
||||
|
||||
def rollback_transaction(self) -> None:
|
||||
"""Roll back the current transaction."""
|
||||
pass # Optional
|
||||
|
||||
def save(self) -> bool:
|
||||
"""Save the board."""
|
||||
raise NotImplementedError()
|
||||
|
||||
# Query operations
|
||||
def get_tracks(self) -> List[Dict[str, Any]]:
|
||||
"""Get all tracks on the board."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_vias(self) -> List[Dict[str, Any]]:
|
||||
"""Get all vias on the board."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_nets(self) -> List[Dict[str, Any]]:
|
||||
"""Get all nets on the board."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_selection(self) -> List[Dict[str, Any]]:
|
||||
"""Get currently selected items."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class BackendError(Exception):
|
||||
"""Base exception for backend errors"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConnectionError(BackendError):
|
||||
"""Raised when connection to KiCAD fails"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class APINotAvailableError(BackendError):
|
||||
"""Raised when required API is not available"""
|
||||
|
||||
pass
|
||||
|
||||
@@ -1,195 +1,195 @@
|
||||
"""
|
||||
Backend factory for creating appropriate KiCAD API backend
|
||||
|
||||
Auto-detects available backends and provides fallback mechanism.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from kicad_api.base import APINotAvailableError, KiCADBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
|
||||
"""
|
||||
Create appropriate KiCAD backend
|
||||
|
||||
Args:
|
||||
backend_type: Backend to use:
|
||||
- 'ipc': Use IPC API (recommended)
|
||||
- 'swig': Use legacy SWIG bindings
|
||||
- None or 'auto': Auto-detect (try IPC first, fall back to SWIG)
|
||||
|
||||
Returns:
|
||||
KiCADBackend instance
|
||||
|
||||
Raises:
|
||||
APINotAvailableError: If no backend is available
|
||||
|
||||
Environment Variables:
|
||||
KICAD_BACKEND: Override backend selection ('ipc', 'swig', or 'auto')
|
||||
"""
|
||||
# Check environment variable override
|
||||
if backend_type is None:
|
||||
backend_type = os.environ.get("KICAD_BACKEND", "auto").lower()
|
||||
|
||||
logger.info(f"Requested backend: {backend_type}")
|
||||
|
||||
# Try specific backend if requested
|
||||
if backend_type == "ipc":
|
||||
return _create_ipc_backend()
|
||||
elif backend_type == "swig":
|
||||
return _create_swig_backend()
|
||||
elif backend_type == "auto":
|
||||
return _auto_detect_backend()
|
||||
else:
|
||||
raise ValueError(f"Unknown backend type: {backend_type}")
|
||||
|
||||
|
||||
def _create_ipc_backend() -> KiCADBackend:
|
||||
"""
|
||||
Create IPC backend
|
||||
|
||||
Returns:
|
||||
IPCBackend instance
|
||||
|
||||
Raises:
|
||||
APINotAvailableError: If kicad-python not available
|
||||
"""
|
||||
try:
|
||||
from kicad_api.ipc_backend import IPCBackend
|
||||
|
||||
logger.info("Creating IPC backend")
|
||||
return IPCBackend()
|
||||
except ImportError as e:
|
||||
logger.error(f"IPC backend not available: {e}")
|
||||
raise APINotAvailableError(
|
||||
"IPC backend requires 'kicad-python' package. " "Install with: pip install kicad-python"
|
||||
) from e
|
||||
|
||||
|
||||
def _create_swig_backend() -> KiCADBackend:
|
||||
"""
|
||||
Create SWIG backend
|
||||
|
||||
Returns:
|
||||
SWIGBackend instance
|
||||
|
||||
Raises:
|
||||
APINotAvailableError: If pcbnew not available
|
||||
"""
|
||||
try:
|
||||
from kicad_api.swig_backend import SWIGBackend
|
||||
|
||||
logger.info("Creating SWIG backend")
|
||||
logger.warning(
|
||||
"SWIG backend is DEPRECATED and will be removed in KiCAD 10.0. "
|
||||
"Please migrate to IPC backend."
|
||||
)
|
||||
return SWIGBackend()
|
||||
except ImportError as e:
|
||||
logger.error(f"SWIG backend not available: {e}")
|
||||
raise APINotAvailableError(
|
||||
"SWIG backend requires 'pcbnew' module. " "Ensure KiCAD Python module is in PYTHONPATH."
|
||||
) from e
|
||||
|
||||
|
||||
def _auto_detect_backend() -> KiCADBackend:
|
||||
"""
|
||||
Auto-detect best available backend
|
||||
|
||||
Priority:
|
||||
1. IPC API (if kicad-python available and KiCAD running)
|
||||
2. SWIG API (if pcbnew available)
|
||||
|
||||
Returns:
|
||||
Best available KiCADBackend
|
||||
|
||||
Raises:
|
||||
APINotAvailableError: If no backend available
|
||||
"""
|
||||
logger.info("Auto-detecting available KiCAD backend...")
|
||||
|
||||
# Try IPC first (preferred)
|
||||
try:
|
||||
backend = _create_ipc_backend()
|
||||
# Test connection
|
||||
if backend.connect():
|
||||
logger.info("✓ IPC backend available and connected")
|
||||
return backend
|
||||
else:
|
||||
logger.warning("IPC backend available but connection failed")
|
||||
except (ImportError, APINotAvailableError) as e:
|
||||
logger.debug(f"IPC backend not available: {e}")
|
||||
|
||||
# Fall back to SWIG
|
||||
try:
|
||||
backend = _create_swig_backend()
|
||||
logger.warning(
|
||||
"Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running."
|
||||
)
|
||||
return backend
|
||||
except (ImportError, APINotAvailableError) as e:
|
||||
logger.error(f"SWIG backend not available: {e}")
|
||||
|
||||
# No backend available
|
||||
raise APINotAvailableError(
|
||||
"No KiCAD backend available. Please install either:\n"
|
||||
" - kicad-python (recommended): pip install kicad-python\n"
|
||||
" - Ensure KiCAD Python module (pcbnew) is in PYTHONPATH"
|
||||
)
|
||||
|
||||
|
||||
def get_available_backends() -> dict:
|
||||
"""
|
||||
Check which backends are available
|
||||
|
||||
Returns:
|
||||
Dictionary with backend availability:
|
||||
{
|
||||
'ipc': {'available': bool, 'version': str or None},
|
||||
'swig': {'available': bool, 'version': str or None}
|
||||
}
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Check IPC (kicad-python uses 'kipy' module name)
|
||||
try:
|
||||
import kipy
|
||||
|
||||
results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")}
|
||||
except ImportError:
|
||||
results["ipc"] = {"available": False, "version": None}
|
||||
|
||||
# Check SWIG
|
||||
try:
|
||||
import pcbnew
|
||||
|
||||
results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()}
|
||||
except ImportError:
|
||||
results["swig"] = {"available": False, "version": None}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick diagnostic
|
||||
import json
|
||||
|
||||
print("KiCAD Backend Availability:")
|
||||
print(json.dumps(get_available_backends(), indent=2))
|
||||
|
||||
print("\nAttempting to create backend...")
|
||||
try:
|
||||
backend = create_backend()
|
||||
print(f"✓ Created backend: {type(backend).__name__}")
|
||||
if backend.connect():
|
||||
print(f"✓ Connected to KiCAD: {backend.get_version()}")
|
||||
else:
|
||||
print("✗ Failed to connect to KiCAD")
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {e}")
|
||||
"""
|
||||
Backend factory for creating appropriate KiCAD API backend
|
||||
|
||||
Auto-detects available backends and provides fallback mechanism.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from kicad_api.base import APINotAvailableError, KiCADBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_backend(backend_type: Optional[str] = None) -> KiCADBackend:
|
||||
"""
|
||||
Create appropriate KiCAD backend
|
||||
|
||||
Args:
|
||||
backend_type: Backend to use:
|
||||
- 'ipc': Use IPC API (recommended)
|
||||
- 'swig': Use legacy SWIG bindings
|
||||
- None or 'auto': Auto-detect (try IPC first, fall back to SWIG)
|
||||
|
||||
Returns:
|
||||
KiCADBackend instance
|
||||
|
||||
Raises:
|
||||
APINotAvailableError: If no backend is available
|
||||
|
||||
Environment Variables:
|
||||
KICAD_BACKEND: Override backend selection ('ipc', 'swig', or 'auto')
|
||||
"""
|
||||
# Check environment variable override
|
||||
if backend_type is None:
|
||||
backend_type = os.environ.get("KICAD_BACKEND", "auto").lower()
|
||||
|
||||
logger.info(f"Requested backend: {backend_type}")
|
||||
|
||||
# Try specific backend if requested
|
||||
if backend_type == "ipc":
|
||||
return _create_ipc_backend()
|
||||
elif backend_type == "swig":
|
||||
return _create_swig_backend()
|
||||
elif backend_type == "auto":
|
||||
return _auto_detect_backend()
|
||||
else:
|
||||
raise ValueError(f"Unknown backend type: {backend_type}")
|
||||
|
||||
|
||||
def _create_ipc_backend() -> KiCADBackend:
|
||||
"""
|
||||
Create IPC backend
|
||||
|
||||
Returns:
|
||||
IPCBackend instance
|
||||
|
||||
Raises:
|
||||
APINotAvailableError: If kicad-python not available
|
||||
"""
|
||||
try:
|
||||
from kicad_api.ipc_backend import IPCBackend
|
||||
|
||||
logger.info("Creating IPC backend")
|
||||
return IPCBackend()
|
||||
except ImportError as e:
|
||||
logger.error(f"IPC backend not available: {e}")
|
||||
raise APINotAvailableError(
|
||||
"IPC backend requires 'kicad-python' package. " "Install with: pip install kicad-python"
|
||||
) from e
|
||||
|
||||
|
||||
def _create_swig_backend() -> KiCADBackend:
|
||||
"""
|
||||
Create SWIG backend
|
||||
|
||||
Returns:
|
||||
SWIGBackend instance
|
||||
|
||||
Raises:
|
||||
APINotAvailableError: If pcbnew not available
|
||||
"""
|
||||
try:
|
||||
from kicad_api.swig_backend import SWIGBackend
|
||||
|
||||
logger.info("Creating SWIG backend")
|
||||
logger.warning(
|
||||
"SWIG backend is DEPRECATED and will be removed in KiCAD 10.0. "
|
||||
"Please migrate to IPC backend."
|
||||
)
|
||||
return SWIGBackend()
|
||||
except ImportError as e:
|
||||
logger.error(f"SWIG backend not available: {e}")
|
||||
raise APINotAvailableError(
|
||||
"SWIG backend requires 'pcbnew' module. " "Ensure KiCAD Python module is in PYTHONPATH."
|
||||
) from e
|
||||
|
||||
|
||||
def _auto_detect_backend() -> KiCADBackend:
|
||||
"""
|
||||
Auto-detect best available backend
|
||||
|
||||
Priority:
|
||||
1. IPC API (if kicad-python available and KiCAD running)
|
||||
2. SWIG API (if pcbnew available)
|
||||
|
||||
Returns:
|
||||
Best available KiCADBackend
|
||||
|
||||
Raises:
|
||||
APINotAvailableError: If no backend available
|
||||
"""
|
||||
logger.info("Auto-detecting available KiCAD backend...")
|
||||
|
||||
# Try IPC first (preferred)
|
||||
try:
|
||||
backend = _create_ipc_backend()
|
||||
# Test connection
|
||||
if backend.connect():
|
||||
logger.info("✓ IPC backend available and connected")
|
||||
return backend
|
||||
else:
|
||||
logger.warning("IPC backend available but connection failed")
|
||||
except (ImportError, APINotAvailableError) as e:
|
||||
logger.debug(f"IPC backend not available: {e}")
|
||||
|
||||
# Fall back to SWIG
|
||||
try:
|
||||
backend = _create_swig_backend()
|
||||
logger.warning(
|
||||
"Using deprecated SWIG backend. " "For best results, use IPC API with KiCAD running."
|
||||
)
|
||||
return backend
|
||||
except (ImportError, APINotAvailableError) as e:
|
||||
logger.error(f"SWIG backend not available: {e}")
|
||||
|
||||
# No backend available
|
||||
raise APINotAvailableError(
|
||||
"No KiCAD backend available. Please install either:\n"
|
||||
" - kicad-python (recommended): pip install kicad-python\n"
|
||||
" - Ensure KiCAD Python module (pcbnew) is in PYTHONPATH"
|
||||
)
|
||||
|
||||
|
||||
def get_available_backends() -> dict:
|
||||
"""
|
||||
Check which backends are available
|
||||
|
||||
Returns:
|
||||
Dictionary with backend availability:
|
||||
{
|
||||
'ipc': {'available': bool, 'version': str or None},
|
||||
'swig': {'available': bool, 'version': str or None}
|
||||
}
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Check IPC (kicad-python uses 'kipy' module name)
|
||||
try:
|
||||
import kipy
|
||||
|
||||
results["ipc"] = {"available": True, "version": getattr(kipy, "__version__", "unknown")}
|
||||
except ImportError:
|
||||
results["ipc"] = {"available": False, "version": None}
|
||||
|
||||
# Check SWIG
|
||||
try:
|
||||
import pcbnew
|
||||
|
||||
results["swig"] = {"available": True, "version": pcbnew.GetBuildVersion()}
|
||||
except ImportError:
|
||||
results["swig"] = {"available": False, "version": None}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick diagnostic
|
||||
import json
|
||||
|
||||
print("KiCAD Backend Availability:")
|
||||
print(json.dumps(get_available_backends(), indent=2))
|
||||
|
||||
print("\nAttempting to create backend...")
|
||||
try:
|
||||
backend = create_backend()
|
||||
print(f"✓ Created backend: {type(backend).__name__}")
|
||||
if backend.connect():
|
||||
print(f"✓ Connected to KiCAD: {backend.get_version()}")
|
||||
else:
|
||||
print("✗ Failed to connect to KiCAD")
|
||||
except Exception as e:
|
||||
print(f"✗ Error: {e}")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,218 +1,218 @@
|
||||
"""
|
||||
SWIG Backend (Legacy - DEPRECATED)
|
||||
|
||||
Uses the legacy SWIG-based pcbnew Python bindings.
|
||||
This backend wraps the existing implementation for backward compatibility.
|
||||
|
||||
WARNING: SWIG bindings are deprecated as of KiCAD 9.0
|
||||
and will be removed in KiCAD 10.0.
|
||||
Please migrate to IPC backend.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SWIGBackend(KiCADBackend):
|
||||
"""
|
||||
Legacy SWIG-based backend
|
||||
|
||||
Wraps existing commands/project.py, commands/component.py, etc.
|
||||
for compatibility during migration period.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._connected = False
|
||||
self._pcbnew = None
|
||||
logger.warning(
|
||||
"⚠️ Using DEPRECATED SWIG backend. "
|
||||
"This will be removed in KiCAD 10.0. "
|
||||
"Please migrate to IPC API."
|
||||
)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
'Connect' to SWIG API (just validates pcbnew import)
|
||||
|
||||
Returns:
|
||||
True if pcbnew module available
|
||||
"""
|
||||
try:
|
||||
import pcbnew
|
||||
|
||||
self._pcbnew = pcbnew
|
||||
version = pcbnew.GetBuildVersion()
|
||||
logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
|
||||
self._connected = True
|
||||
return True
|
||||
except ImportError as e:
|
||||
logger.error("pcbnew module not found")
|
||||
raise APINotAvailableError(
|
||||
"SWIG backend requires pcbnew module. "
|
||||
"Ensure KiCAD Python module is in PYTHONPATH."
|
||||
) from e
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from SWIG API (no-op)"""
|
||||
self._connected = False
|
||||
self._pcbnew = None
|
||||
logger.info("Disconnected from SWIG backend")
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected"""
|
||||
return self._connected
|
||||
|
||||
def get_version(self) -> str:
|
||||
"""Get KiCAD version"""
|
||||
if not self.is_connected():
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
return self._pcbnew.GetBuildVersion()
|
||||
|
||||
# Project Operations
|
||||
def create_project(self, path: Path, name: str) -> Dict[str, Any]:
|
||||
"""Create project using existing SWIG implementation"""
|
||||
if not self.is_connected():
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
# Import existing implementation
|
||||
from commands.project import ProjectCommands
|
||||
|
||||
try:
|
||||
result = ProjectCommands.create_project(str(path), name)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create project: {e}")
|
||||
raise
|
||||
|
||||
def open_project(self, path: Path) -> Dict[str, Any]:
|
||||
"""Open project using existing SWIG implementation"""
|
||||
if not self.is_connected():
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
from commands.project import ProjectCommands
|
||||
|
||||
try:
|
||||
result = ProjectCommands().open_project({"filename": str(path)})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open project: {e}")
|
||||
raise
|
||||
|
||||
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
|
||||
"""Save project using existing SWIG implementation"""
|
||||
if not self.is_connected():
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
from commands.project import ProjectCommands
|
||||
|
||||
try:
|
||||
params: Dict[str, Any] = {}
|
||||
if path:
|
||||
params["filename"] = str(path)
|
||||
result = ProjectCommands().save_project(params)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save project: {e}")
|
||||
raise
|
||||
|
||||
def close_project(self) -> None:
|
||||
"""Close project (SWIG doesn't have explicit close)"""
|
||||
logger.info("Closing project (SWIG backend)")
|
||||
# SWIG backend doesn't maintain project state,
|
||||
# so this is essentially a no-op
|
||||
|
||||
# Board Operations
|
||||
def get_board(self) -> BoardAPI:
|
||||
"""Get board API"""
|
||||
if not self.is_connected():
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
return SWIGBoardAPI(self._pcbnew)
|
||||
|
||||
|
||||
class SWIGBoardAPI(BoardAPI):
|
||||
"""Board API implementation wrapping SWIG/pcbnew"""
|
||||
|
||||
def __init__(self, pcbnew_module: Any) -> None:
|
||||
self.pcbnew = pcbnew_module
|
||||
self._board = None
|
||||
|
||||
def set_size(self, width: float, height: float, unit: str = "mm") -> bool:
|
||||
"""Set board size using existing implementation"""
|
||||
from commands.board import BoardCommands
|
||||
|
||||
try:
|
||||
result = BoardCommands(board=self._board).set_board_size(
|
||||
{"width": width, "height": height, "unit": unit}
|
||||
)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set board size: {e}")
|
||||
return False
|
||||
|
||||
def get_size(self) -> Dict[str, Any]:
|
||||
"""Get board size"""
|
||||
# TODO: Implement using existing SWIG code
|
||||
raise NotImplementedError("get_size not yet wrapped")
|
||||
|
||||
def add_layer(self, layer_name: str, layer_type: str) -> bool:
|
||||
"""Add layer using existing implementation"""
|
||||
from commands.board import BoardCommands
|
||||
|
||||
try:
|
||||
result = BoardCommands.add_layer(layer_name, layer_type)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add layer: {e}")
|
||||
return False
|
||||
|
||||
def list_components(self) -> List[Dict[str, Any]]:
|
||||
"""List components using existing implementation"""
|
||||
from commands.component import ComponentCommands
|
||||
|
||||
try:
|
||||
result = ComponentCommands(board=self._board).get_component_list({})
|
||||
if result.get("success"):
|
||||
return result.get("components", [])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list components: {e}")
|
||||
return []
|
||||
|
||||
def place_component(
|
||||
self,
|
||||
reference: str,
|
||||
footprint: str,
|
||||
x: float,
|
||||
y: float,
|
||||
rotation: float = 0,
|
||||
layer: str = "F.Cu",
|
||||
value: str = "",
|
||||
) -> bool:
|
||||
"""Place component using existing implementation"""
|
||||
from commands.component import ComponentCommands
|
||||
|
||||
try:
|
||||
result = ComponentCommands(board=self._board).place_component(
|
||||
{
|
||||
"componentId": footprint,
|
||||
"position": {"x": x, "y": y, "unit": "mm"},
|
||||
"reference": reference,
|
||||
"rotation": rotation,
|
||||
"layer": layer,
|
||||
}
|
||||
)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to place component: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# This backend serves as a wrapper during the migration period.
|
||||
# Once IPC backend is fully implemented, this can be deprecated.
|
||||
"""
|
||||
SWIG Backend (Legacy - DEPRECATED)
|
||||
|
||||
Uses the legacy SWIG-based pcbnew Python bindings.
|
||||
This backend wraps the existing implementation for backward compatibility.
|
||||
|
||||
WARNING: SWIG bindings are deprecated as of KiCAD 9.0
|
||||
and will be removed in KiCAD 10.0.
|
||||
Please migrate to IPC backend.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from kicad_api.base import APINotAvailableError, BoardAPI, ConnectionError, KiCADBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SWIGBackend(KiCADBackend):
|
||||
"""
|
||||
Legacy SWIG-based backend
|
||||
|
||||
Wraps existing commands/project.py, commands/component.py, etc.
|
||||
for compatibility during migration period.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._connected = False
|
||||
self._pcbnew = None
|
||||
logger.warning(
|
||||
"⚠️ Using DEPRECATED SWIG backend. "
|
||||
"This will be removed in KiCAD 10.0. "
|
||||
"Please migrate to IPC API."
|
||||
)
|
||||
|
||||
def connect(self) -> bool:
|
||||
"""
|
||||
'Connect' to SWIG API (just validates pcbnew import)
|
||||
|
||||
Returns:
|
||||
True if pcbnew module available
|
||||
"""
|
||||
try:
|
||||
import pcbnew
|
||||
|
||||
self._pcbnew = pcbnew
|
||||
version = pcbnew.GetBuildVersion()
|
||||
logger.info(f"✓ Connected to pcbnew (SWIG): {version}")
|
||||
self._connected = True
|
||||
return True
|
||||
except ImportError as e:
|
||||
logger.error("pcbnew module not found")
|
||||
raise APINotAvailableError(
|
||||
"SWIG backend requires pcbnew module. "
|
||||
"Ensure KiCAD Python module is in PYTHONPATH."
|
||||
) from e
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""Disconnect from SWIG API (no-op)"""
|
||||
self._connected = False
|
||||
self._pcbnew = None
|
||||
logger.info("Disconnected from SWIG backend")
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""Check if connected"""
|
||||
return self._connected
|
||||
|
||||
def get_version(self) -> str:
|
||||
"""Get KiCAD version"""
|
||||
if not self.is_connected():
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
return self._pcbnew.GetBuildVersion()
|
||||
|
||||
# Project Operations
|
||||
def create_project(self, path: Path, name: str) -> Dict[str, Any]:
|
||||
"""Create project using existing SWIG implementation"""
|
||||
if not self.is_connected():
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
# Import existing implementation
|
||||
from commands.project import ProjectCommands
|
||||
|
||||
try:
|
||||
result = ProjectCommands.create_project(str(path), name)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create project: {e}")
|
||||
raise
|
||||
|
||||
def open_project(self, path: Path) -> Dict[str, Any]:
|
||||
"""Open project using existing SWIG implementation"""
|
||||
if not self.is_connected():
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
from commands.project import ProjectCommands
|
||||
|
||||
try:
|
||||
result = ProjectCommands().open_project({"filename": str(path)})
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to open project: {e}")
|
||||
raise
|
||||
|
||||
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
|
||||
"""Save project using existing SWIG implementation"""
|
||||
if not self.is_connected():
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
from commands.project import ProjectCommands
|
||||
|
||||
try:
|
||||
params: Dict[str, Any] = {}
|
||||
if path:
|
||||
params["filename"] = str(path)
|
||||
result = ProjectCommands().save_project(params)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save project: {e}")
|
||||
raise
|
||||
|
||||
def close_project(self) -> None:
|
||||
"""Close project (SWIG doesn't have explicit close)"""
|
||||
logger.info("Closing project (SWIG backend)")
|
||||
# SWIG backend doesn't maintain project state,
|
||||
# so this is essentially a no-op
|
||||
|
||||
# Board Operations
|
||||
def get_board(self) -> BoardAPI:
|
||||
"""Get board API"""
|
||||
if not self.is_connected():
|
||||
raise ConnectionError("Not connected")
|
||||
|
||||
return SWIGBoardAPI(self._pcbnew)
|
||||
|
||||
|
||||
class SWIGBoardAPI(BoardAPI):
|
||||
"""Board API implementation wrapping SWIG/pcbnew"""
|
||||
|
||||
def __init__(self, pcbnew_module: Any) -> None:
|
||||
self.pcbnew = pcbnew_module
|
||||
self._board = None
|
||||
|
||||
def set_size(self, width: float, height: float, unit: str = "mm") -> bool:
|
||||
"""Set board size using existing implementation"""
|
||||
from commands.board import BoardCommands
|
||||
|
||||
try:
|
||||
result = BoardCommands(board=self._board).set_board_size(
|
||||
{"width": width, "height": height, "unit": unit}
|
||||
)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set board size: {e}")
|
||||
return False
|
||||
|
||||
def get_size(self) -> Dict[str, Any]:
|
||||
"""Get board size"""
|
||||
# TODO: Implement using existing SWIG code
|
||||
raise NotImplementedError("get_size not yet wrapped")
|
||||
|
||||
def add_layer(self, layer_name: str, layer_type: str) -> bool:
|
||||
"""Add layer using existing implementation"""
|
||||
from commands.board import BoardCommands
|
||||
|
||||
try:
|
||||
result = BoardCommands.add_layer(layer_name, layer_type)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add layer: {e}")
|
||||
return False
|
||||
|
||||
def list_components(self) -> List[Dict[str, Any]]:
|
||||
"""List components using existing implementation"""
|
||||
from commands.component import ComponentCommands
|
||||
|
||||
try:
|
||||
result = ComponentCommands(board=self._board).get_component_list({})
|
||||
if result.get("success"):
|
||||
return result.get("components", [])
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list components: {e}")
|
||||
return []
|
||||
|
||||
def place_component(
|
||||
self,
|
||||
reference: str,
|
||||
footprint: str,
|
||||
x: float,
|
||||
y: float,
|
||||
rotation: float = 0,
|
||||
layer: str = "F.Cu",
|
||||
value: str = "",
|
||||
) -> bool:
|
||||
"""Place component using existing implementation"""
|
||||
from commands.component import ComponentCommands
|
||||
|
||||
try:
|
||||
result = ComponentCommands(board=self._board).place_component(
|
||||
{
|
||||
"componentId": footprint,
|
||||
"position": {"x": x, "y": y, "unit": "mm"},
|
||||
"reference": reference,
|
||||
"rotation": rotation,
|
||||
"layer": layer,
|
||||
}
|
||||
)
|
||||
return result.get("success", False)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to place component: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# This backend serves as a wrapper during the migration period.
|
||||
# Once IPC backend is fully implemented, this can be deprecated.
|
||||
|
||||
@@ -1 +1 @@
|
||||
# parsers package
|
||||
# parsers package
|
||||
|
||||
@@ -1,250 +1,250 @@
|
||||
"""
|
||||
Parser for KiCad .kicad_mod footprint files.
|
||||
|
||||
Extracts the fields that the MCP get_footprint_info tool exposes to clients:
|
||||
name – footprint name (str)
|
||||
library – library nickname, injected by caller (str)
|
||||
description – (descr "…") token (str | None)
|
||||
keywords – (tags "…") token (str | None)
|
||||
pads – list of pad objects: [{number, type, shape}, …] (list[dict])
|
||||
layers – sorted unique list of canonical layer names used (list[str])
|
||||
courtyard – {"width": float, "height": float} from F.CrtYd geometry (dict | None)
|
||||
attributes – {"type": str, "board_only": bool, …} (dict | None)
|
||||
|
||||
KiCad S-expression file format reference:
|
||||
https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Parse a .kicad_mod file and return a dict whose keys match the fields
|
||||
expected by the TypeScript MCP tool handler (src/tools/library.ts).
|
||||
|
||||
Returns None if the file does not exist or cannot be read.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.debug(f"parse_kicad_mod: file not found: {file_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}")
|
||||
return None
|
||||
|
||||
logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)")
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Footprint name: (footprint "NAME" …
|
||||
# Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix).
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE)
|
||||
if not m:
|
||||
# Older / unquoted format
|
||||
m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE)
|
||||
result["name"] = _unescape(m.group(1)) if m else path.stem
|
||||
logger.debug(f"parse_kicad_mod: name={result['name']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Description: (descr "…")
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content)
|
||||
result["description"] = _unescape(m.group(1)) if m else None
|
||||
logger.debug(f"parse_kicad_mod: description={result['description']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Keywords / tags: (tags "…")
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content)
|
||||
result["keywords"] = _unescape(m.group(1)) if m else None
|
||||
logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom])
|
||||
# TYPE is smd | through_hole (no quotes)
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r"\(attr\s+([^)]+)\)", content)
|
||||
if m:
|
||||
tokens = m.group(1).split()
|
||||
result["attributes"] = {
|
||||
"type": tokens[0] if tokens else "unspecified",
|
||||
"board_only": "board_only" in tokens,
|
||||
"exclude_from_pos_files": "exclude_from_pos_files" in tokens,
|
||||
"exclude_from_bom": "exclude_from_bom" in tokens,
|
||||
}
|
||||
else:
|
||||
result["attributes"] = None
|
||||
logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pads: (pad "NUMBER" TYPE SHAPE …)
|
||||
# Return each pad as an object; deduplicate by number (first wins).
|
||||
# ------------------------------------------------------------------
|
||||
result["pads"] = _extract_pads(content)
|
||||
logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Layers: all unique canonical layer names across the whole file.
|
||||
# Sources:
|
||||
# (layer "NAME") – single-layer items (fp_line, fp_text, …)
|
||||
# (layers "A" "B" …) – pad layer lists
|
||||
# ------------------------------------------------------------------
|
||||
layers: set = set()
|
||||
for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content):
|
||||
layers.add(m.group(1))
|
||||
for m in re.finditer(r"\(layers\s+([^)]+)\)", content):
|
||||
for lyr in re.findall(r'"([^"]+)"', m.group(1)):
|
||||
layers.add(lyr)
|
||||
result["layers"] = sorted(layers)
|
||||
logger.debug(f"parse_kicad_mod: layers={result['layers']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Courtyard: derive bounding box from F.CrtYd geometry.
|
||||
# Prefer fp_rect (most common for standard footprints), fall back to
|
||||
# fp_line segments.
|
||||
# ------------------------------------------------------------------
|
||||
result["courtyard"] = _extract_courtyard(content)
|
||||
logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_pads(content: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse all (pad …) blocks and return a list of pad objects.
|
||||
|
||||
Each object has:
|
||||
number – pad number string, e.g. "1", "A1", "GND"
|
||||
type – thru_hole | smd | np_thru_hole | connect
|
||||
shape – rect | circle | oval | roundrect | trapezoid | custom
|
||||
|
||||
Pads are deduplicated by number (first occurrence wins) so that the
|
||||
list represents the logical pads of the footprint, not duplicated
|
||||
copper entries.
|
||||
"""
|
||||
pads: List[Dict[str, Any]] = []
|
||||
seen_numbers: dict = {}
|
||||
|
||||
# KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …)
|
||||
quoted_pattern = re.compile(
|
||||
r'\(pad\s+"([^"]*)"\s+'
|
||||
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||
)
|
||||
for m in quoted_pattern.finditer(content):
|
||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||
if number not in seen_numbers:
|
||||
seen_numbers[number] = True
|
||||
pads.append({"number": number, "type": ptype, "shape": shape})
|
||||
|
||||
if not pads:
|
||||
# Older / unquoted format: (pad NUMBER TYPE SHAPE …)
|
||||
unquoted_pattern = re.compile(
|
||||
r"\(pad\s+(\S+)\s+"
|
||||
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||
)
|
||||
for m in unquoted_pattern.finditer(content):
|
||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||
if number not in seen_numbers:
|
||||
seen_numbers[number] = True
|
||||
pads.append({"number": number, "type": ptype, "shape": shape})
|
||||
|
||||
return pads
|
||||
|
||||
|
||||
def _unescape(s: str) -> str:
|
||||
"""Reverse KiCad S-expression string escaping."""
|
||||
return s.replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
|
||||
def _extract_blocks(content: str, token: str) -> List[str]:
|
||||
"""
|
||||
Return all S-expression blocks that start with `(token ` by tracking
|
||||
parenthesis depth. This correctly handles nested parens inside blocks.
|
||||
"""
|
||||
blocks: List[str] = []
|
||||
pattern = re.compile(r"\(" + re.escape(token) + r"\b")
|
||||
|
||||
for match in pattern.finditer(content):
|
||||
start = match.start()
|
||||
depth = 0
|
||||
i = start
|
||||
while i < len(content):
|
||||
ch = content[i]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
blocks.append(content[start : i + 1])
|
||||
break
|
||||
i += 1
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
Compute the courtyard bounding box from F.CrtYd geometry.
|
||||
|
||||
Strategy:
|
||||
1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end.
|
||||
2. Fall back to fp_line segments on F.CrtYd — compute bounding box of
|
||||
all endpoints.
|
||||
"""
|
||||
xs: List[float] = []
|
||||
ys: List[float] = []
|
||||
|
||||
# --- fp_rect pass ---
|
||||
for block in _extract_blocks(content, "fp_rect"):
|
||||
if "F.CrtYd" not in block:
|
||||
continue
|
||||
s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||
e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||
if s and e:
|
||||
xs += [float(s.group(1)), float(e.group(1))]
|
||||
ys += [float(s.group(2)), float(e.group(2))]
|
||||
logger.debug(
|
||||
f"_extract_courtyard: fp_rect F.CrtYd "
|
||||
f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})"
|
||||
)
|
||||
|
||||
# --- fp_line pass (only if fp_rect found nothing) ---
|
||||
if not xs:
|
||||
for block in _extract_blocks(content, "fp_line"):
|
||||
if "F.CrtYd" not in block:
|
||||
continue
|
||||
for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block):
|
||||
xs.append(float(m.group(1)))
|
||||
ys.append(float(m.group(2)))
|
||||
|
||||
if not xs:
|
||||
logger.debug("_extract_courtyard: no F.CrtYd geometry found")
|
||||
return None
|
||||
|
||||
width = round(abs(max(xs) - min(xs)), 6)
|
||||
height = round(abs(max(ys) - min(ys)), 6)
|
||||
logger.debug(f"_extract_courtyard: result width={width} height={height}")
|
||||
return {"width": width, "height": height}
|
||||
"""
|
||||
Parser for KiCad .kicad_mod footprint files.
|
||||
|
||||
Extracts the fields that the MCP get_footprint_info tool exposes to clients:
|
||||
name – footprint name (str)
|
||||
library – library nickname, injected by caller (str)
|
||||
description – (descr "…") token (str | None)
|
||||
keywords – (tags "…") token (str | None)
|
||||
pads – list of pad objects: [{number, type, shape}, …] (list[dict])
|
||||
layers – sorted unique list of canonical layer names used (list[str])
|
||||
courtyard – {"width": float, "height": float} from F.CrtYd geometry (dict | None)
|
||||
attributes – {"type": str, "board_only": bool, …} (dict | None)
|
||||
|
||||
KiCad S-expression file format reference:
|
||||
https://dev-docs.kicad.org/en/file-formats/sexpr-intro/index.html#_footprint
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_kicad_mod(file_path: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Parse a .kicad_mod file and return a dict whose keys match the fields
|
||||
expected by the TypeScript MCP tool handler (src/tools/library.ts).
|
||||
|
||||
Returns None if the file does not exist or cannot be read.
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
logger.debug(f"parse_kicad_mod: file not found: {file_path}")
|
||||
return None
|
||||
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except OSError as e:
|
||||
logger.warning(f"parse_kicad_mod: cannot read {file_path}: {e}")
|
||||
return None
|
||||
|
||||
logger.debug(f"parse_kicad_mod: parsing {path.name} ({len(content)} chars)")
|
||||
|
||||
result: Dict[str, Any] = {}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Footprint name: (footprint "NAME" …
|
||||
# Per spec, in a library file the name is the ENTRY_NAME only (no lib prefix).
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'^\s*\(footprint\s+"((?:[^"\\]|\\.)*)"', content, re.MULTILINE)
|
||||
if not m:
|
||||
# Older / unquoted format
|
||||
m = re.search(r"^\s*\(footprint\s+(\S+)", content, re.MULTILINE)
|
||||
result["name"] = _unescape(m.group(1)) if m else path.stem
|
||||
logger.debug(f"parse_kicad_mod: name={result['name']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Description: (descr "…")
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'\(descr\s+"((?:[^"\\]|\\.)*)"\)', content)
|
||||
result["description"] = _unescape(m.group(1)) if m else None
|
||||
logger.debug(f"parse_kicad_mod: description={result['description']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Keywords / tags: (tags "…")
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r'\(tags\s+"((?:[^"\\]|\\.)*)"\)', content)
|
||||
result["keywords"] = _unescape(m.group(1)) if m else None
|
||||
logger.debug(f"parse_kicad_mod: keywords={result['keywords']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attributes: (attr TYPE [board_only] [exclude_from_pos_files] [exclude_from_bom])
|
||||
# TYPE is smd | through_hole (no quotes)
|
||||
# ------------------------------------------------------------------
|
||||
m = re.search(r"\(attr\s+([^)]+)\)", content)
|
||||
if m:
|
||||
tokens = m.group(1).split()
|
||||
result["attributes"] = {
|
||||
"type": tokens[0] if tokens else "unspecified",
|
||||
"board_only": "board_only" in tokens,
|
||||
"exclude_from_pos_files": "exclude_from_pos_files" in tokens,
|
||||
"exclude_from_bom": "exclude_from_bom" in tokens,
|
||||
}
|
||||
else:
|
||||
result["attributes"] = None
|
||||
logger.debug(f"parse_kicad_mod: attributes={result['attributes']!r}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pads: (pad "NUMBER" TYPE SHAPE …)
|
||||
# Return each pad as an object; deduplicate by number (first wins).
|
||||
# ------------------------------------------------------------------
|
||||
result["pads"] = _extract_pads(content)
|
||||
logger.debug(f"parse_kicad_mod: pads count={len(result['pads'])}, pads={result['pads']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Layers: all unique canonical layer names across the whole file.
|
||||
# Sources:
|
||||
# (layer "NAME") – single-layer items (fp_line, fp_text, …)
|
||||
# (layers "A" "B" …) – pad layer lists
|
||||
# ------------------------------------------------------------------
|
||||
layers: set = set()
|
||||
for m in re.finditer(r'\(layer\s+"([^"]+)"\)', content):
|
||||
layers.add(m.group(1))
|
||||
for m in re.finditer(r"\(layers\s+([^)]+)\)", content):
|
||||
for lyr in re.findall(r'"([^"]+)"', m.group(1)):
|
||||
layers.add(lyr)
|
||||
result["layers"] = sorted(layers)
|
||||
logger.debug(f"parse_kicad_mod: layers={result['layers']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Courtyard: derive bounding box from F.CrtYd geometry.
|
||||
# Prefer fp_rect (most common for standard footprints), fall back to
|
||||
# fp_line segments.
|
||||
# ------------------------------------------------------------------
|
||||
result["courtyard"] = _extract_courtyard(content)
|
||||
logger.debug(f"parse_kicad_mod: courtyard={result['courtyard']!r}")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_pads(content: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Parse all (pad …) blocks and return a list of pad objects.
|
||||
|
||||
Each object has:
|
||||
number – pad number string, e.g. "1", "A1", "GND"
|
||||
type – thru_hole | smd | np_thru_hole | connect
|
||||
shape – rect | circle | oval | roundrect | trapezoid | custom
|
||||
|
||||
Pads are deduplicated by number (first occurrence wins) so that the
|
||||
list represents the logical pads of the footprint, not duplicated
|
||||
copper entries.
|
||||
"""
|
||||
pads: List[Dict[str, Any]] = []
|
||||
seen_numbers: dict = {}
|
||||
|
||||
# KiCad 6+ quoted format: (pad "NUMBER" TYPE SHAPE …)
|
||||
quoted_pattern = re.compile(
|
||||
r'\(pad\s+"([^"]*)"\s+'
|
||||
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||
)
|
||||
for m in quoted_pattern.finditer(content):
|
||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||
if number not in seen_numbers:
|
||||
seen_numbers[number] = True
|
||||
pads.append({"number": number, "type": ptype, "shape": shape})
|
||||
|
||||
if not pads:
|
||||
# Older / unquoted format: (pad NUMBER TYPE SHAPE …)
|
||||
unquoted_pattern = re.compile(
|
||||
r"\(pad\s+(\S+)\s+"
|
||||
r"(thru_hole|smd|np_thru_hole|connect)\s+"
|
||||
r"(rect|circle|oval|roundrect|trapezoid|custom)\b"
|
||||
)
|
||||
for m in unquoted_pattern.finditer(content):
|
||||
number, ptype, shape = m.group(1), m.group(2), m.group(3)
|
||||
if number not in seen_numbers:
|
||||
seen_numbers[number] = True
|
||||
pads.append({"number": number, "type": ptype, "shape": shape})
|
||||
|
||||
return pads
|
||||
|
||||
|
||||
def _unescape(s: str) -> str:
|
||||
"""Reverse KiCad S-expression string escaping."""
|
||||
return s.replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
|
||||
def _extract_blocks(content: str, token: str) -> List[str]:
|
||||
"""
|
||||
Return all S-expression blocks that start with `(token ` by tracking
|
||||
parenthesis depth. This correctly handles nested parens inside blocks.
|
||||
"""
|
||||
blocks: List[str] = []
|
||||
pattern = re.compile(r"\(" + re.escape(token) + r"\b")
|
||||
|
||||
for match in pattern.finditer(content):
|
||||
start = match.start()
|
||||
depth = 0
|
||||
i = start
|
||||
while i < len(content):
|
||||
ch = content[i]
|
||||
if ch == "(":
|
||||
depth += 1
|
||||
elif ch == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
blocks.append(content[start : i + 1])
|
||||
break
|
||||
i += 1
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def _extract_courtyard(content: str) -> Optional[Dict[str, float]]:
|
||||
"""
|
||||
Compute the courtyard bounding box from F.CrtYd geometry.
|
||||
|
||||
Strategy:
|
||||
1. Try fp_rect blocks on F.CrtYd — derive width/height from start/end.
|
||||
2. Fall back to fp_line segments on F.CrtYd — compute bounding box of
|
||||
all endpoints.
|
||||
"""
|
||||
xs: List[float] = []
|
||||
ys: List[float] = []
|
||||
|
||||
# --- fp_rect pass ---
|
||||
for block in _extract_blocks(content, "fp_rect"):
|
||||
if "F.CrtYd" not in block:
|
||||
continue
|
||||
s = re.search(r"\(start\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||
e = re.search(r"\(end\s+([-\d.]+)\s+([-\d.]+)\)", block)
|
||||
if s and e:
|
||||
xs += [float(s.group(1)), float(e.group(1))]
|
||||
ys += [float(s.group(2)), float(e.group(2))]
|
||||
logger.debug(
|
||||
f"_extract_courtyard: fp_rect F.CrtYd "
|
||||
f"start=({s.group(1)},{s.group(2)}) end=({e.group(1)},{e.group(2)})"
|
||||
)
|
||||
|
||||
# --- fp_line pass (only if fp_rect found nothing) ---
|
||||
if not xs:
|
||||
for block in _extract_blocks(content, "fp_line"):
|
||||
if "F.CrtYd" not in block:
|
||||
continue
|
||||
for m in re.finditer(r"\((?:start|end)\s+([-\d.]+)\s+([-\d.]+)\)", block):
|
||||
xs.append(float(m.group(1)))
|
||||
ys.append(float(m.group(2)))
|
||||
|
||||
if not xs:
|
||||
logger.debug("_extract_courtyard: no F.CrtYd geometry found")
|
||||
return None
|
||||
|
||||
width = round(abs(max(xs) - min(xs)), 6)
|
||||
height = round(abs(max(ys) - min(ys)), 6)
|
||||
logger.debug(f"_extract_courtyard: result width={width} height={height}")
|
||||
return {"width": width, "height": height}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# KiCAD MCP Python Interface Requirements
|
||||
|
||||
# Image processing
|
||||
Pillow>=9.0.0
|
||||
cairosvg>=2.7.0
|
||||
|
||||
# Type hints
|
||||
typing-extensions>=4.0.0
|
||||
|
||||
# Logging
|
||||
colorlog>=6.7.0
|
||||
|
||||
kicad-skip
|
||||
# KiCAD MCP Python Interface Requirements
|
||||
|
||||
# Image processing
|
||||
Pillow>=9.0.0
|
||||
cairosvg>=2.7.0
|
||||
|
||||
# Type hints
|
||||
typing-extensions>=4.0.0
|
||||
|
||||
# Logging
|
||||
colorlog>=6.7.0
|
||||
|
||||
kicad-skip
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Resource definitions for KiCAD MCP Server
|
||||
"""
|
||||
|
||||
from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
|
||||
|
||||
__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"]
|
||||
"""
|
||||
Resource definitions for KiCAD MCP Server
|
||||
"""
|
||||
|
||||
from .resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
|
||||
|
||||
__all__ = ["RESOURCE_DEFINITIONS", "handle_resource_read"]
|
||||
|
||||
@@ -1,342 +1,342 @@
|
||||
"""
|
||||
Resource definitions for exposing KiCAD project state via MCP
|
||||
|
||||
Resources follow the MCP 2025-06-18 specification, providing
|
||||
read-only access to project data for LLM context.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
# =============================================================================
|
||||
# RESOURCE DEFINITIONS
|
||||
# =============================================================================
|
||||
|
||||
RESOURCE_DEFINITIONS = [
|
||||
{
|
||||
"uri": "kicad://project/current/info",
|
||||
"name": "Current Project Information",
|
||||
"description": "Metadata about the currently open KiCAD project including paths, name, and status",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/board",
|
||||
"name": "Board Properties",
|
||||
"description": "Comprehensive board information including dimensions, layer count, and design rules",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/components",
|
||||
"name": "Component List",
|
||||
"description": "List of all components on the board with references, footprints, values, and positions",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"name": "Electrical Nets",
|
||||
"description": "List of all electrical nets and their connections",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"name": "Layer Stack",
|
||||
"description": "Board layer configuration and properties",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"name": "Design Rules",
|
||||
"description": "Current design rule settings for clearances, track widths, and constraints",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"name": "DRC Violations",
|
||||
"description": "Design Rule Check violations and warnings from last DRC run",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"name": "Board Preview Image",
|
||||
"description": "2D rendering of the current board state",
|
||||
"mimeType": "image/png",
|
||||
},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# RESOURCE READ HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle reading a resource by URI
|
||||
|
||||
Args:
|
||||
uri: Resource URI to read
|
||||
interface: KiCADInterface instance with access to board state
|
||||
|
||||
Returns:
|
||||
Dict with resource contents following MCP spec
|
||||
"""
|
||||
logger.info(f"Reading resource: {uri}")
|
||||
|
||||
handlers = {
|
||||
"kicad://project/current/info": _get_project_info,
|
||||
"kicad://project/current/board": _get_board_info,
|
||||
"kicad://project/current/components": _get_components,
|
||||
"kicad://project/current/nets": _get_nets,
|
||||
"kicad://project/current/layers": _get_layers,
|
||||
"kicad://project/current/design-rules": _get_design_rules,
|
||||
"kicad://project/current/drc-report": _get_drc_report,
|
||||
"kicad://board/preview.png": _get_board_preview,
|
||||
}
|
||||
|
||||
handler = handlers.get(uri)
|
||||
if handler:
|
||||
try:
|
||||
return handler(interface)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading resource {uri}: {str(e)}")
|
||||
return {
|
||||
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}]
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INDIVIDUAL RESOURCE HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_project_info(interface: Any) -> Dict[str, Any]:
|
||||
"""Get current project information"""
|
||||
result = interface.project_commands.get_project_info({})
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/info",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("project", {}), indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/info",
|
||||
"mimeType": "text/plain",
|
||||
"text": "No project currently open",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_board_info(interface: Any) -> Dict[str, Any]:
|
||||
"""Get board properties and metadata"""
|
||||
result = interface.board_commands.get_board_info({})
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/board",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("board", {}), indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/board",
|
||||
"mimeType": "text/plain",
|
||||
"text": "No board currently loaded",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_components(interface: Any) -> Dict[str, Any]:
|
||||
"""Get list of all components"""
|
||||
result = interface.component_commands.get_component_list({})
|
||||
|
||||
if result.get("success"):
|
||||
components = result.get("components", [])
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/components",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(
|
||||
{"count": len(components), "components": components}, indent=2
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/components",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "components": []}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_nets(interface: Any) -> Dict[str, Any]:
|
||||
"""Get list of electrical nets"""
|
||||
result = interface.routing_commands.get_nets_list({})
|
||||
|
||||
if result.get("success"):
|
||||
nets = result.get("nets", [])
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": len(nets), "nets": nets}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "nets": []}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_layers(interface: Any) -> Dict[str, Any]:
|
||||
"""Get layer stack information"""
|
||||
result = interface.board_commands.get_layer_list({})
|
||||
|
||||
if result.get("success"):
|
||||
layers = result.get("layers", [])
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": len(layers), "layers": layers}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "layers": []}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_design_rules(interface: Any) -> Dict[str, Any]:
|
||||
"""Get design rule settings"""
|
||||
result = interface.design_rule_commands.get_design_rules({})
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("rules", {}), indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"mimeType": "text/plain",
|
||||
"text": "Design rules not available",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_drc_report(interface: Any) -> Dict[str, Any]:
|
||||
"""Get DRC violations"""
|
||||
result = interface.design_rule_commands.get_drc_violations({})
|
||||
|
||||
if result.get("success"):
|
||||
violations = result.get("violations", [])
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(
|
||||
{"count": len(violations), "violations": violations}, indent=2
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(
|
||||
{
|
||||
"count": 0,
|
||||
"violations": [],
|
||||
"message": "Run DRC first to get violations",
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_board_preview(interface: Any) -> Dict[str, Any]:
|
||||
"""Get board preview as PNG image"""
|
||||
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})
|
||||
|
||||
if result.get("success") and "imageData" in result:
|
||||
# Image data should already be base64 encoded
|
||||
image_data = result.get("imageData", "")
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"mimeType": "image/png",
|
||||
"blob": image_data, # Base64 encoded PNG
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
# Return a placeholder message
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"mimeType": "text/plain",
|
||||
"text": "Board preview not available",
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
Resource definitions for exposing KiCAD project state via MCP
|
||||
|
||||
Resources follow the MCP 2025-06-18 specification, providing
|
||||
read-only access to project data for LLM context.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("kicad_interface")
|
||||
|
||||
# =============================================================================
|
||||
# RESOURCE DEFINITIONS
|
||||
# =============================================================================
|
||||
|
||||
RESOURCE_DEFINITIONS = [
|
||||
{
|
||||
"uri": "kicad://project/current/info",
|
||||
"name": "Current Project Information",
|
||||
"description": "Metadata about the currently open KiCAD project including paths, name, and status",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/board",
|
||||
"name": "Board Properties",
|
||||
"description": "Comprehensive board information including dimensions, layer count, and design rules",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/components",
|
||||
"name": "Component List",
|
||||
"description": "List of all components on the board with references, footprints, values, and positions",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"name": "Electrical Nets",
|
||||
"description": "List of all electrical nets and their connections",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"name": "Layer Stack",
|
||||
"description": "Board layer configuration and properties",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"name": "Design Rules",
|
||||
"description": "Current design rule settings for clearances, track widths, and constraints",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"name": "DRC Violations",
|
||||
"description": "Design Rule Check violations and warnings from last DRC run",
|
||||
"mimeType": "application/json",
|
||||
},
|
||||
{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"name": "Board Preview Image",
|
||||
"description": "2D rendering of the current board state",
|
||||
"mimeType": "image/png",
|
||||
},
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# RESOURCE READ HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def handle_resource_read(uri: str, interface: Any) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle reading a resource by URI
|
||||
|
||||
Args:
|
||||
uri: Resource URI to read
|
||||
interface: KiCADInterface instance with access to board state
|
||||
|
||||
Returns:
|
||||
Dict with resource contents following MCP spec
|
||||
"""
|
||||
logger.info(f"Reading resource: {uri}")
|
||||
|
||||
handlers = {
|
||||
"kicad://project/current/info": _get_project_info,
|
||||
"kicad://project/current/board": _get_board_info,
|
||||
"kicad://project/current/components": _get_components,
|
||||
"kicad://project/current/nets": _get_nets,
|
||||
"kicad://project/current/layers": _get_layers,
|
||||
"kicad://project/current/design-rules": _get_design_rules,
|
||||
"kicad://project/current/drc-report": _get_drc_report,
|
||||
"kicad://board/preview.png": _get_board_preview,
|
||||
}
|
||||
|
||||
handler = handlers.get(uri)
|
||||
if handler:
|
||||
try:
|
||||
return handler(interface)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading resource {uri}: {str(e)}")
|
||||
return {
|
||||
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Error: {str(e)}"}]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [{"uri": uri, "mimeType": "text/plain", "text": f"Unknown resource: {uri}"}]
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# INDIVIDUAL RESOURCE HANDLERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _get_project_info(interface: Any) -> Dict[str, Any]:
|
||||
"""Get current project information"""
|
||||
result = interface.project_commands.get_project_info({})
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/info",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("project", {}), indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/info",
|
||||
"mimeType": "text/plain",
|
||||
"text": "No project currently open",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_board_info(interface: Any) -> Dict[str, Any]:
|
||||
"""Get board properties and metadata"""
|
||||
result = interface.board_commands.get_board_info({})
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/board",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("board", {}), indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/board",
|
||||
"mimeType": "text/plain",
|
||||
"text": "No board currently loaded",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_components(interface: Any) -> Dict[str, Any]:
|
||||
"""Get list of all components"""
|
||||
result = interface.component_commands.get_component_list({})
|
||||
|
||||
if result.get("success"):
|
||||
components = result.get("components", [])
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/components",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(
|
||||
{"count": len(components), "components": components}, indent=2
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/components",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "components": []}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_nets(interface: Any) -> Dict[str, Any]:
|
||||
"""Get list of electrical nets"""
|
||||
result = interface.routing_commands.get_nets_list({})
|
||||
|
||||
if result.get("success"):
|
||||
nets = result.get("nets", [])
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": len(nets), "nets": nets}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/nets",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "nets": []}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_layers(interface: Any) -> Dict[str, Any]:
|
||||
"""Get layer stack information"""
|
||||
result = interface.board_commands.get_layer_list({})
|
||||
|
||||
if result.get("success"):
|
||||
layers = result.get("layers", [])
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": len(layers), "layers": layers}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/layers",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps({"count": 0, "layers": []}, indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_design_rules(interface: Any) -> Dict[str, Any]:
|
||||
"""Get design rule settings"""
|
||||
result = interface.design_rule_commands.get_design_rules({})
|
||||
|
||||
if result.get("success"):
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(result.get("rules", {}), indent=2),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/design-rules",
|
||||
"mimeType": "text/plain",
|
||||
"text": "Design rules not available",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_drc_report(interface: Any) -> Dict[str, Any]:
|
||||
"""Get DRC violations"""
|
||||
result = interface.design_rule_commands.get_drc_violations({})
|
||||
|
||||
if result.get("success"):
|
||||
violations = result.get("violations", [])
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(
|
||||
{"count": len(violations), "violations": violations}, indent=2
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://project/current/drc-report",
|
||||
"mimeType": "application/json",
|
||||
"text": json.dumps(
|
||||
{
|
||||
"count": 0,
|
||||
"violations": [],
|
||||
"message": "Run DRC first to get violations",
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _get_board_preview(interface: Any) -> Dict[str, Any]:
|
||||
"""Get board preview as PNG image"""
|
||||
result = interface.board_commands.get_board_2d_view({"width": 800, "height": 600})
|
||||
|
||||
if result.get("success") and "imageData" in result:
|
||||
# Image data should already be base64 encoded
|
||||
image_data = result.get("imageData", "")
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"mimeType": "image/png",
|
||||
"blob": image_data, # Base64 encoded PNG
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
# Return a placeholder message
|
||||
return {
|
||||
"contents": [
|
||||
{
|
||||
"uri": "kicad://board/preview.png",
|
||||
"mimeType": "text/plain",
|
||||
"text": "Board preview not available",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Tool schema definitions for KiCAD MCP Server
|
||||
"""
|
||||
|
||||
from .tool_schemas import TOOL_SCHEMAS
|
||||
|
||||
__all__ = ["TOOL_SCHEMAS"]
|
||||
"""
|
||||
Tool schema definitions for KiCAD MCP Server
|
||||
"""
|
||||
|
||||
from .tool_schemas import TOOL_SCHEMAS
|
||||
|
||||
__all__ = ["TOOL_SCHEMAS"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,137 +1,137 @@
|
||||
(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")
|
||||
|
||||
(uuid b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e)
|
||||
|
||||
(paper "A4")
|
||||
|
||||
(lib_symbols
|
||||
(symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "R" (at 2.032 0 90)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "R" (at 0 0 90)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at -1.778 0 90)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "R_0_1"
|
||||
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "R_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 0 -3.81 90) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
(symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "C" (at 0.635 2.54 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left))
|
||||
)
|
||||
(property "Value" "C" (at 0.635 -2.54 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left))
|
||||
)
|
||||
(property "Footprint" "" (at 0.9652 -3.81 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "C_0_1"
|
||||
(polyline
|
||||
(pts
|
||||
(xy -2.032 -0.762)
|
||||
(xy 2.032 -0.762)
|
||||
)
|
||||
(stroke (width 0.508) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy -2.032 0.762)
|
||||
(xy 2.032 0.762)
|
||||
)
|
||||
(stroke (width 0.508) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "C_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 0 -3.81 90) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
(symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "D" (at 0 2.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "LED" (at 0 -2.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "LED_0_1"
|
||||
(polyline
|
||||
(pts
|
||||
(xy -1.27 -1.27)
|
||||
(xy -1.27 1.27)
|
||||
)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy -1.27 0)
|
||||
(xy 1.27 0)
|
||||
)
|
||||
(stroke (width 0) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy 1.27 -1.27)
|
||||
(xy 1.27 1.27)
|
||||
(xy -1.27 0)
|
||||
(xy 1.27 -1.27)
|
||||
)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "LED_1_1"
|
||||
(pin passive line (at -3.81 0 0) (length 2.54)
|
||||
(name "K" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 3.81 0 180) (length 2.54)
|
||||
(name "A" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
(sheet_instances
|
||||
(path "/" (page "1"))
|
||||
)
|
||||
)
|
||||
(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")
|
||||
|
||||
(uuid b2c3d4e5-f6a7-4b8c-9d0e-1f2a3b4c5d6e)
|
||||
|
||||
(paper "A4")
|
||||
|
||||
(lib_symbols
|
||||
(symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "R" (at 2.032 0 90)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "R" (at 0 0 90)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at -1.778 0 90)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "R_0_1"
|
||||
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "R_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 0 -3.81 90) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
(symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "C" (at 0.635 2.54 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left))
|
||||
)
|
||||
(property "Value" "C" (at 0.635 -2.54 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left))
|
||||
)
|
||||
(property "Footprint" "" (at 0.9652 -3.81 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "C_0_1"
|
||||
(polyline
|
||||
(pts
|
||||
(xy -2.032 -0.762)
|
||||
(xy 2.032 -0.762)
|
||||
)
|
||||
(stroke (width 0.508) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy -2.032 0.762)
|
||||
(xy 2.032 0.762)
|
||||
)
|
||||
(stroke (width 0.508) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "C_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 0 -3.81 90) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
(symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "D" (at 0 2.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "LED" (at 0 -2.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "LED_0_1"
|
||||
(polyline
|
||||
(pts
|
||||
(xy -1.27 -1.27)
|
||||
(xy -1.27 1.27)
|
||||
)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy -1.27 0)
|
||||
(xy 1.27 0)
|
||||
)
|
||||
(stroke (width 0) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy 1.27 -1.27)
|
||||
(xy 1.27 1.27)
|
||||
(xy -1.27 0)
|
||||
(xy 1.27 -1.27)
|
||||
)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "LED_1_1"
|
||||
(pin passive line (at -3.81 0 0) (length 2.54)
|
||||
(name "K" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 3.81 0 180) (length 2.54)
|
||||
(name "A" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
(sheet_instances
|
||||
(path "/" (page "1"))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,194 +1,194 @@
|
||||
(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")
|
||||
|
||||
(uuid a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d)
|
||||
|
||||
(paper "A4")
|
||||
|
||||
(lib_symbols
|
||||
(symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "R" (at 2.032 0 90)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "R" (at 0 0 90)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at -1.778 0 90)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "R_0_1"
|
||||
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "R_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 0 -3.81 90) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
(symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "C" (at 0.635 2.54 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left))
|
||||
)
|
||||
(property "Value" "C" (at 0.635 -2.54 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left))
|
||||
)
|
||||
(property "Footprint" "" (at 0.9652 -3.81 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "C_0_1"
|
||||
(polyline
|
||||
(pts
|
||||
(xy -2.032 -0.762)
|
||||
(xy 2.032 -0.762)
|
||||
)
|
||||
(stroke (width 0.508) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy -2.032 0.762)
|
||||
(xy 2.032 0.762)
|
||||
)
|
||||
(stroke (width 0.508) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "C_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 0 -3.81 90) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
(symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "D" (at 0 2.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "LED" (at 0 -2.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "LED_0_1"
|
||||
(polyline
|
||||
(pts
|
||||
(xy -1.27 -1.27)
|
||||
(xy -1.27 1.27)
|
||||
)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy -1.27 0)
|
||||
(xy 1.27 0)
|
||||
)
|
||||
(stroke (width 0) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy 1.27 -1.27)
|
||||
(xy 1.27 1.27)
|
||||
(xy -1.27 0)
|
||||
(xy 1.27 -1.27)
|
||||
)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "LED_1_1"
|
||||
(pin passive line (at -3.81 0 0) (length 2.54)
|
||||
(name "K" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 3.81 0 180) (length 2.54)
|
||||
(name "A" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
(symbol (lib_id "Device:R") (at -100 -100 0) (unit 1)
|
||||
(in_bom no) (on_board no) (dnp yes)
|
||||
(uuid 00000000-0000-0000-0000-000000000001)
|
||||
(property "Reference" "_TEMPLATE_R" (at -100 -102.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "R_TEMPLATE" (at -100 -100 90)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at -100 -100 90)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at -100 -100 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(pin "1" (uuid 00000000-0000-0000-0000-000000000010))
|
||||
(pin "2" (uuid 00000000-0000-0000-0000-000000000011))
|
||||
)
|
||||
|
||||
(symbol (lib_id "Device:C") (at -100 -110 0) (unit 1)
|
||||
(in_bom no) (on_board no) (dnp yes)
|
||||
(uuid 00000000-0000-0000-0000-000000000002)
|
||||
(property "Reference" "_TEMPLATE_C" (at -100 -107.46 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "C_TEMPLATE" (at -100 -112.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at -100 -110 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at -100 -110 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(pin "1" (uuid 00000000-0000-0000-0000-000000000020))
|
||||
(pin "2" (uuid 00000000-0000-0000-0000-000000000021))
|
||||
)
|
||||
|
||||
(symbol (lib_id "Device:LED") (at -100 -120 0) (unit 1)
|
||||
(in_bom no) (on_board no) (dnp yes)
|
||||
(uuid 00000000-0000-0000-0000-000000000003)
|
||||
(property "Reference" "_TEMPLATE_D" (at -100 -117.46 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "LED_TEMPLATE" (at -100 -122.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at -100 -120 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at -100 -120 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(pin "1" (uuid 00000000-0000-0000-0000-000000000030))
|
||||
(pin "2" (uuid 00000000-0000-0000-0000-000000000031))
|
||||
)
|
||||
|
||||
(sheet_instances
|
||||
(path "/" (page "1"))
|
||||
)
|
||||
)
|
||||
(kicad_sch (version 20250114) (generator "KiCAD-MCP-Server")
|
||||
|
||||
(uuid a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d)
|
||||
|
||||
(paper "A4")
|
||||
|
||||
(lib_symbols
|
||||
(symbol "Device:R" (pin_numbers hide) (pin_names (offset 0)) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "R" (at 2.032 0 90)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "R" (at 0 0 90)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at -1.778 0 90)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "R_0_1"
|
||||
(rectangle (start -1.016 -2.54) (end 1.016 2.54)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "R_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 0 -3.81 90) (length 1.27)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
(symbol "Device:C" (pin_numbers hide) (pin_names (offset 0.254)) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "C" (at 0.635 2.54 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left))
|
||||
)
|
||||
(property "Value" "C" (at 0.635 -2.54 0)
|
||||
(effects (font (size 1.27 1.27)) (justify left))
|
||||
)
|
||||
(property "Footprint" "" (at 0.9652 -3.81 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "C_0_1"
|
||||
(polyline
|
||||
(pts
|
||||
(xy -2.032 -0.762)
|
||||
(xy 2.032 -0.762)
|
||||
)
|
||||
(stroke (width 0.508) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy -2.032 0.762)
|
||||
(xy 2.032 0.762)
|
||||
)
|
||||
(stroke (width 0.508) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "C_1_1"
|
||||
(pin passive line (at 0 3.81 270) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 0 -3.81 90) (length 2.794)
|
||||
(name "~" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
(symbol "Device:LED" (pin_numbers hide) (pin_names (offset 1.016) hide) (in_bom yes) (on_board yes)
|
||||
(property "Reference" "D" (at 0 2.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "LED" (at 0 -2.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at 0 0 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(symbol "LED_0_1"
|
||||
(polyline
|
||||
(pts
|
||||
(xy -1.27 -1.27)
|
||||
(xy -1.27 1.27)
|
||||
)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy -1.27 0)
|
||||
(xy 1.27 0)
|
||||
)
|
||||
(stroke (width 0) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
(polyline
|
||||
(pts
|
||||
(xy 1.27 -1.27)
|
||||
(xy 1.27 1.27)
|
||||
(xy -1.27 0)
|
||||
(xy 1.27 -1.27)
|
||||
)
|
||||
(stroke (width 0.254) (type default))
|
||||
(fill (type none))
|
||||
)
|
||||
)
|
||||
(symbol "LED_1_1"
|
||||
(pin passive line (at -3.81 0 0) (length 2.54)
|
||||
(name "K" (effects (font (size 1.27 1.27))))
|
||||
(number "1" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
(pin passive line (at 3.81 0 180) (length 2.54)
|
||||
(name "A" (effects (font (size 1.27 1.27))))
|
||||
(number "2" (effects (font (size 1.27 1.27))))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
(symbol (lib_id "Device:R") (at -100 -100 0) (unit 1)
|
||||
(in_bom no) (on_board no) (dnp yes)
|
||||
(uuid 00000000-0000-0000-0000-000000000001)
|
||||
(property "Reference" "_TEMPLATE_R" (at -100 -102.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "R_TEMPLATE" (at -100 -100 90)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at -100 -100 90)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at -100 -100 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(pin "1" (uuid 00000000-0000-0000-0000-000000000010))
|
||||
(pin "2" (uuid 00000000-0000-0000-0000-000000000011))
|
||||
)
|
||||
|
||||
(symbol (lib_id "Device:C") (at -100 -110 0) (unit 1)
|
||||
(in_bom no) (on_board no) (dnp yes)
|
||||
(uuid 00000000-0000-0000-0000-000000000002)
|
||||
(property "Reference" "_TEMPLATE_C" (at -100 -107.46 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "C_TEMPLATE" (at -100 -112.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at -100 -110 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at -100 -110 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(pin "1" (uuid 00000000-0000-0000-0000-000000000020))
|
||||
(pin "2" (uuid 00000000-0000-0000-0000-000000000021))
|
||||
)
|
||||
|
||||
(symbol (lib_id "Device:LED") (at -100 -120 0) (unit 1)
|
||||
(in_bom no) (on_board no) (dnp yes)
|
||||
(uuid 00000000-0000-0000-0000-000000000003)
|
||||
(property "Reference" "_TEMPLATE_D" (at -100 -117.46 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Value" "LED_TEMPLATE" (at -100 -122.54 0)
|
||||
(effects (font (size 1.27 1.27)))
|
||||
)
|
||||
(property "Footprint" "" (at -100 -120 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(property "Datasheet" "~" (at -100 -120 0)
|
||||
(effects (font (size 1.27 1.27)) hide)
|
||||
)
|
||||
(pin "1" (uuid 00000000-0000-0000-0000-000000000030))
|
||||
(pin "2" (uuid 00000000-0000-0000-0000-000000000031))
|
||||
)
|
||||
|
||||
(sheet_instances
|
||||
(path "/" (page "1"))
|
||||
)
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,319 +1,319 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for KiCAD IPC Backend
|
||||
|
||||
This script tests the real-time UI synchronization capabilities
|
||||
of the IPC backend. Run this while KiCAD is open with a board.
|
||||
|
||||
Prerequisites:
|
||||
1. KiCAD 9.0+ must be running
|
||||
2. IPC API must be enabled: Preferences > Plugins > Enable IPC API Server
|
||||
3. A board should be open in the PCB editor
|
||||
|
||||
Usage:
|
||||
./venv/bin/python python/test_ipc_backend.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import logging
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_connection() -> Optional[Any]:
|
||||
"""Test basic IPC connection to KiCAD."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 1: IPC Connection")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from kicad_api.ipc_backend import IPCBackend
|
||||
|
||||
backend = IPCBackend()
|
||||
print("✓ IPCBackend created")
|
||||
|
||||
if backend.connect():
|
||||
print(f"✓ Connected to KiCAD via IPC")
|
||||
print(f" Version: {backend.get_version()}")
|
||||
return backend
|
||||
else:
|
||||
print("✗ Failed to connect to KiCAD")
|
||||
return None
|
||||
|
||||
except ImportError as e:
|
||||
print(f"✗ kicad-python not installed: {e}")
|
||||
print(" Install with: pip install kicad-python")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"✗ Connection failed: {e}")
|
||||
print("\nMake sure:")
|
||||
print(" 1. KiCAD is running")
|
||||
print(" 2. IPC API is enabled (Preferences > Plugins > Enable IPC API Server)")
|
||||
print(" 3. A board is open in the PCB editor")
|
||||
return None
|
||||
|
||||
|
||||
def test_board_access(backend: Any) -> Optional[Any]:
|
||||
"""Test board access and component listing."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 2: Board Access")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
board_api = backend.get_board()
|
||||
print("✓ Got board API")
|
||||
|
||||
# List components
|
||||
components = board_api.list_components()
|
||||
print(f"✓ Found {len(components)} components on board")
|
||||
|
||||
if components:
|
||||
print("\n First 5 components:")
|
||||
for comp in components[:5]:
|
||||
ref = comp.get("reference", "N/A")
|
||||
val = comp.get("value", "N/A")
|
||||
pos = comp.get("position", {})
|
||||
x = pos.get("x", 0)
|
||||
y = pos.get("y", 0)
|
||||
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
|
||||
|
||||
return board_api
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to access board: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def test_board_info(board_api: Any) -> bool:
|
||||
"""Test getting board information."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 3: Board Information")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# Get board size
|
||||
size = board_api.get_size()
|
||||
print(f"✓ Board size: {size.get('width', 0):.2f} x {size.get('height', 0):.2f} mm")
|
||||
|
||||
# Get enabled layers
|
||||
try:
|
||||
layers = board_api.get_enabled_layers()
|
||||
print(f"✓ Enabled layers: {len(layers)}")
|
||||
if layers:
|
||||
print(f" Layers: {', '.join(layers[:5])}...")
|
||||
except Exception as e:
|
||||
print(f" (Layer info not available: {e})")
|
||||
|
||||
# Get nets
|
||||
nets = board_api.get_nets()
|
||||
print(f"✓ Found {len(nets)} nets")
|
||||
if nets:
|
||||
print(f" First 5 nets: {', '.join([n.get('name', '') for n in nets[:5]])}")
|
||||
|
||||
# Get tracks
|
||||
tracks = board_api.get_tracks()
|
||||
print(f"✓ Found {len(tracks)} tracks")
|
||||
|
||||
# Get vias
|
||||
vias = board_api.get_vias()
|
||||
print(f"✓ Found {len(vias)} vias")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get board info: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_track(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding a track in real-time (appears immediately in KiCAD UI)."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 4: Real-time Track Addition")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.")
|
||||
print("Watch the KiCAD window!")
|
||||
|
||||
if interactive:
|
||||
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
|
||||
if response != "y":
|
||||
print("Skipped track test")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Add a track
|
||||
success = board_api.add_track(
|
||||
start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu"
|
||||
)
|
||||
|
||||
if success:
|
||||
print("✓ Track added! Check the KiCAD window - it should appear at (100, 100) mm")
|
||||
print(" Track: (100, 100) -> (120, 100) mm, width 0.25mm on F.Cu")
|
||||
else:
|
||||
print("✗ Failed to add track")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error adding track: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_via(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding a via in real-time (appears immediately in KiCAD UI)."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 5: Real-time Via Addition")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.")
|
||||
print("Watch the KiCAD window!")
|
||||
|
||||
if interactive:
|
||||
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
|
||||
if response != "y":
|
||||
print("Skipped via test")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Add a via
|
||||
success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through")
|
||||
|
||||
if success:
|
||||
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
|
||||
print(" Via: diameter 0.8mm, drill 0.4mm")
|
||||
else:
|
||||
print("✗ Failed to add via")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error adding via: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_text(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding text in real-time."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 6: Real-time Text Addition")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.")
|
||||
|
||||
if interactive:
|
||||
response = input("\nProceed with adding test text? [y/N]: ").strip().lower()
|
||||
if response != "y":
|
||||
print("Skipped text test")
|
||||
return False
|
||||
|
||||
try:
|
||||
success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0)
|
||||
|
||||
if success:
|
||||
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
|
||||
else:
|
||||
print("✗ Failed to add text")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error adding text: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_selection(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test getting the current selection from KiCAD UI."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 7: UI Selection")
|
||||
print("=" * 60)
|
||||
|
||||
if interactive:
|
||||
print("\nSelect some items in KiCAD, then press Enter...")
|
||||
input()
|
||||
else:
|
||||
print("\nReading current selection...")
|
||||
|
||||
try:
|
||||
selection = board_api.get_selection()
|
||||
print(f"✓ Found {len(selection)} selected items")
|
||||
|
||||
for item in selection[:10]:
|
||||
print(f" - {item.get('type', 'Unknown')} (ID: {item.get('id', 'N/A')})")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get selection: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run_all_tests(interactive: bool = False) -> bool:
|
||||
"""Run all IPC backend tests."""
|
||||
print("\n" + "=" * 60)
|
||||
print("KiCAD IPC Backend Test Suite")
|
||||
print("=" * 60)
|
||||
print("\nThis script tests real-time communication with KiCAD via IPC API.")
|
||||
print("Make sure KiCAD is running with a board open.\n")
|
||||
|
||||
# Test connection
|
||||
backend = test_connection()
|
||||
if not backend:
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTS FAILED: Could not connect to KiCAD")
|
||||
print("=" * 60)
|
||||
return False
|
||||
|
||||
# Test board access
|
||||
board_api = test_board_access(backend)
|
||||
if not board_api:
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTS FAILED: Could not access board")
|
||||
print("=" * 60)
|
||||
return False
|
||||
|
||||
# Test board info
|
||||
test_board_info(board_api)
|
||||
|
||||
# Test real-time modifications
|
||||
test_realtime_track(board_api, interactive)
|
||||
test_realtime_via(board_api, interactive)
|
||||
test_realtime_text(board_api, interactive)
|
||||
|
||||
# Test selection
|
||||
test_selection(board_api, interactive)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTS COMPLETE")
|
||||
print("=" * 60)
|
||||
print("\nThe IPC backend is working! Changes appear in real-time.")
|
||||
print("No manual reload required - this is the power of the IPC API!")
|
||||
|
||||
# Cleanup
|
||||
backend.disconnect()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend")
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
help="Run in interactive mode (prompts before modifications)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
success = run_all_tests(interactive=args.interactive)
|
||||
sys.exit(0 if success else 1)
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for KiCAD IPC Backend
|
||||
|
||||
This script tests the real-time UI synchronization capabilities
|
||||
of the IPC backend. Run this while KiCAD is open with a board.
|
||||
|
||||
Prerequisites:
|
||||
1. KiCAD 9.0+ must be running
|
||||
2. IPC API must be enabled: Preferences > Plugins > Enable IPC API Server
|
||||
3. A board should be open in the PCB editor
|
||||
|
||||
Usage:
|
||||
./venv/bin/python python/test_ipc_backend.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import logging
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def test_connection() -> Optional[Any]:
|
||||
"""Test basic IPC connection to KiCAD."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 1: IPC Connection")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
from kicad_api.ipc_backend import IPCBackend
|
||||
|
||||
backend = IPCBackend()
|
||||
print("✓ IPCBackend created")
|
||||
|
||||
if backend.connect():
|
||||
print(f"✓ Connected to KiCAD via IPC")
|
||||
print(f" Version: {backend.get_version()}")
|
||||
return backend
|
||||
else:
|
||||
print("✗ Failed to connect to KiCAD")
|
||||
return None
|
||||
|
||||
except ImportError as e:
|
||||
print(f"✗ kicad-python not installed: {e}")
|
||||
print(" Install with: pip install kicad-python")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"✗ Connection failed: {e}")
|
||||
print("\nMake sure:")
|
||||
print(" 1. KiCAD is running")
|
||||
print(" 2. IPC API is enabled (Preferences > Plugins > Enable IPC API Server)")
|
||||
print(" 3. A board is open in the PCB editor")
|
||||
return None
|
||||
|
||||
|
||||
def test_board_access(backend: Any) -> Optional[Any]:
|
||||
"""Test board access and component listing."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 2: Board Access")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
board_api = backend.get_board()
|
||||
print("✓ Got board API")
|
||||
|
||||
# List components
|
||||
components = board_api.list_components()
|
||||
print(f"✓ Found {len(components)} components on board")
|
||||
|
||||
if components:
|
||||
print("\n First 5 components:")
|
||||
for comp in components[:5]:
|
||||
ref = comp.get("reference", "N/A")
|
||||
val = comp.get("value", "N/A")
|
||||
pos = comp.get("position", {})
|
||||
x = pos.get("x", 0)
|
||||
y = pos.get("y", 0)
|
||||
print(f" - {ref}: {val} @ ({x:.2f}, {y:.2f}) mm")
|
||||
|
||||
return board_api
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to access board: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def test_board_info(board_api: Any) -> bool:
|
||||
"""Test getting board information."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 3: Board Information")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
# Get board size
|
||||
size = board_api.get_size()
|
||||
print(f"✓ Board size: {size.get('width', 0):.2f} x {size.get('height', 0):.2f} mm")
|
||||
|
||||
# Get enabled layers
|
||||
try:
|
||||
layers = board_api.get_enabled_layers()
|
||||
print(f"✓ Enabled layers: {len(layers)}")
|
||||
if layers:
|
||||
print(f" Layers: {', '.join(layers[:5])}...")
|
||||
except Exception as e:
|
||||
print(f" (Layer info not available: {e})")
|
||||
|
||||
# Get nets
|
||||
nets = board_api.get_nets()
|
||||
print(f"✓ Found {len(nets)} nets")
|
||||
if nets:
|
||||
print(f" First 5 nets: {', '.join([n.get('name', '') for n in nets[:5]])}")
|
||||
|
||||
# Get tracks
|
||||
tracks = board_api.get_tracks()
|
||||
print(f"✓ Found {len(tracks)} tracks")
|
||||
|
||||
# Get vias
|
||||
vias = board_api.get_vias()
|
||||
print(f"✓ Found {len(vias)} vias")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get board info: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_track(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding a track in real-time (appears immediately in KiCAD UI)."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 4: Real-time Track Addition")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nThis test will add a track that appears IMMEDIATELY in KiCAD UI.")
|
||||
print("Watch the KiCAD window!")
|
||||
|
||||
if interactive:
|
||||
response = input("\nProceed with adding a test track? [y/N]: ").strip().lower()
|
||||
if response != "y":
|
||||
print("Skipped track test")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Add a track
|
||||
success = board_api.add_track(
|
||||
start_x=100.0, start_y=100.0, end_x=120.0, end_y=100.0, width=0.25, layer="F.Cu"
|
||||
)
|
||||
|
||||
if success:
|
||||
print("✓ Track added! Check the KiCAD window - it should appear at (100, 100) mm")
|
||||
print(" Track: (100, 100) -> (120, 100) mm, width 0.25mm on F.Cu")
|
||||
else:
|
||||
print("✗ Failed to add track")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error adding track: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_via(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding a via in real-time (appears immediately in KiCAD UI)."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 5: Real-time Via Addition")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nThis test will add a via that appears IMMEDIATELY in KiCAD UI.")
|
||||
print("Watch the KiCAD window!")
|
||||
|
||||
if interactive:
|
||||
response = input("\nProceed with adding a test via? [y/N]: ").strip().lower()
|
||||
if response != "y":
|
||||
print("Skipped via test")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Add a via
|
||||
success = board_api.add_via(x=120.0, y=100.0, diameter=0.8, drill=0.4, via_type="through")
|
||||
|
||||
if success:
|
||||
print("✓ Via added! Check the KiCAD window - it should appear at (120, 100) mm")
|
||||
print(" Via: diameter 0.8mm, drill 0.4mm")
|
||||
else:
|
||||
print("✗ Failed to add via")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error adding via: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_realtime_text(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test adding text in real-time."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 6: Real-time Text Addition")
|
||||
print("=" * 60)
|
||||
|
||||
print("\nThis test will add text that appears IMMEDIATELY in KiCAD UI.")
|
||||
|
||||
if interactive:
|
||||
response = input("\nProceed with adding test text? [y/N]: ").strip().lower()
|
||||
if response != "y":
|
||||
print("Skipped text test")
|
||||
return False
|
||||
|
||||
try:
|
||||
success = board_api.add_text(text="MCP Test", x=100.0, y=95.0, layer="F.SilkS", size=1.0)
|
||||
|
||||
if success:
|
||||
print("✓ Text added! Check the KiCAD window - should show 'MCP Test' at (100, 95) mm")
|
||||
else:
|
||||
print("✗ Failed to add text")
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error adding text: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_selection(board_api: Any, interactive: bool = False) -> bool:
|
||||
"""Test getting the current selection from KiCAD UI."""
|
||||
print("\n" + "=" * 60)
|
||||
print("TEST 7: UI Selection")
|
||||
print("=" * 60)
|
||||
|
||||
if interactive:
|
||||
print("\nSelect some items in KiCAD, then press Enter...")
|
||||
input()
|
||||
else:
|
||||
print("\nReading current selection...")
|
||||
|
||||
try:
|
||||
selection = board_api.get_selection()
|
||||
print(f"✓ Found {len(selection)} selected items")
|
||||
|
||||
for item in selection[:10]:
|
||||
print(f" - {item.get('type', 'Unknown')} (ID: {item.get('id', 'N/A')})")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Failed to get selection: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run_all_tests(interactive: bool = False) -> bool:
|
||||
"""Run all IPC backend tests."""
|
||||
print("\n" + "=" * 60)
|
||||
print("KiCAD IPC Backend Test Suite")
|
||||
print("=" * 60)
|
||||
print("\nThis script tests real-time communication with KiCAD via IPC API.")
|
||||
print("Make sure KiCAD is running with a board open.\n")
|
||||
|
||||
# Test connection
|
||||
backend = test_connection()
|
||||
if not backend:
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTS FAILED: Could not connect to KiCAD")
|
||||
print("=" * 60)
|
||||
return False
|
||||
|
||||
# Test board access
|
||||
board_api = test_board_access(backend)
|
||||
if not board_api:
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTS FAILED: Could not access board")
|
||||
print("=" * 60)
|
||||
return False
|
||||
|
||||
# Test board info
|
||||
test_board_info(board_api)
|
||||
|
||||
# Test real-time modifications
|
||||
test_realtime_track(board_api, interactive)
|
||||
test_realtime_via(board_api, interactive)
|
||||
test_realtime_text(board_api, interactive)
|
||||
|
||||
# Test selection
|
||||
test_selection(board_api, interactive)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("TESTS COMPLETE")
|
||||
print("=" * 60)
|
||||
print("\nThe IPC backend is working! Changes appear in real-time.")
|
||||
print("No manual reload required - this is the power of the IPC API!")
|
||||
|
||||
# Cleanup
|
||||
backend.disconnect()
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Test KiCAD IPC Backend")
|
||||
parser.add_argument(
|
||||
"-i",
|
||||
"--interactive",
|
||||
action="store_true",
|
||||
help="Run in interactive mode (prompts before modifications)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
success = run_all_tests(interactive=args.interactive)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Utility modules for KiCAD MCP Server"""
|
||||
"""Utility modules for KiCAD MCP Server"""
|
||||
|
||||
@@ -1,349 +1,349 @@
|
||||
"""
|
||||
KiCAD Process Management Utilities
|
||||
|
||||
Detects if KiCAD is running and provides auto-launch functionality.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
from ctypes import wintypes
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KiCADProcessManager:
|
||||
"""Manages KiCAD process detection and launching"""
|
||||
|
||||
@staticmethod
|
||||
def _windows_list_processes() -> List[dict]:
|
||||
"""List running processes on Windows using Toolhelp API."""
|
||||
processes: List[dict] = []
|
||||
try:
|
||||
TH32CS_SNAPPROCESS = 0x00000002
|
||||
try:
|
||||
ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
ulong_ptr = (
|
||||
ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong
|
||||
)
|
||||
|
||||
class PROCESSENTRY32W(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwSize", wintypes.DWORD),
|
||||
("cntUsage", wintypes.DWORD),
|
||||
("th32ProcessID", wintypes.DWORD),
|
||||
("th32DefaultHeapID", ulong_ptr),
|
||||
("th32ModuleID", wintypes.DWORD),
|
||||
("cntThreads", wintypes.DWORD),
|
||||
("th32ParentProcessID", wintypes.DWORD),
|
||||
("pcPriClassBase", wintypes.LONG),
|
||||
("dwFlags", wintypes.DWORD),
|
||||
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
|
||||
]
|
||||
|
||||
CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot
|
||||
Process32FirstW = ctypes.windll.kernel32.Process32FirstW
|
||||
Process32NextW = ctypes.windll.kernel32.Process32NextW
|
||||
CloseHandle = ctypes.windll.kernel32.CloseHandle
|
||||
|
||||
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||
if snapshot == wintypes.HANDLE(-1).value:
|
||||
return processes
|
||||
|
||||
entry = PROCESSENTRY32W()
|
||||
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
|
||||
|
||||
if Process32FirstW(snapshot, ctypes.byref(entry)):
|
||||
while True:
|
||||
processes.append(
|
||||
{
|
||||
"pid": str(entry.th32ProcessID),
|
||||
"name": entry.szExeFile,
|
||||
"command": entry.szExeFile,
|
||||
}
|
||||
)
|
||||
if not Process32NextW(snapshot, ctypes.byref(entry)):
|
||||
break
|
||||
|
||||
CloseHandle(snapshot)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing Windows processes: {e}")
|
||||
|
||||
return processes
|
||||
|
||||
@staticmethod
|
||||
def is_running() -> bool:
|
||||
"""
|
||||
Check if KiCAD is currently running
|
||||
|
||||
Returns:
|
||||
True if KiCAD process found, False otherwise
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
try:
|
||||
if system == "Linux":
|
||||
# Check for actual pcbnew/kicad binaries (not python scripts)
|
||||
# Use exact process name matching to avoid matching our own kicad_interface.py
|
||||
result = subprocess.run(
|
||||
["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
# Also check with -f for full path matching, but exclude our script
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True
|
||||
)
|
||||
# Double-check it's not our own process
|
||||
if result.returncode == 0:
|
||||
pids = result.stdout.strip().split("\n")
|
||||
for pid in pids:
|
||||
try:
|
||||
cmdline = subprocess.run(
|
||||
["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
|
||||
)
|
||||
if "kicad_interface.py" not in cmdline.stdout:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
elif system == "Darwin": # macOS
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
elif system == "Windows":
|
||||
processes = KiCADProcessManager._windows_list_processes()
|
||||
for proc in processes:
|
||||
name = (proc.get("name") or "").lower()
|
||||
if name in ("pcbnew.exe", "kicad.exe"):
|
||||
return True
|
||||
return False
|
||||
|
||||
else:
|
||||
logger.warning(f"Process detection not implemented for {system}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if KiCAD is running: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_executable_path() -> Optional[Path]:
|
||||
"""
|
||||
Get path to KiCAD executable
|
||||
|
||||
Returns:
|
||||
Path to pcbnew/kicad executable, or None if not found
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
# Try to find executable in PATH first
|
||||
for cmd in ["pcbnew", "kicad"]:
|
||||
result = subprocess.run(
|
||||
["which", cmd] if system != "Windows" else ["where", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="mbcs" if system == "Windows" else None,
|
||||
errors="ignore" if system == "Windows" else None,
|
||||
timeout=5 if system == "Windows" else None,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
exe_path = result.stdout.strip().split("\n")[0]
|
||||
logger.info(f"Found KiCAD executable: {exe_path}")
|
||||
return Path(exe_path)
|
||||
|
||||
# Platform-specific default paths
|
||||
if system == "Linux":
|
||||
candidates = [
|
||||
Path("/usr/bin/pcbnew"),
|
||||
Path("/usr/local/bin/pcbnew"),
|
||||
Path("/usr/bin/kicad"),
|
||||
]
|
||||
elif system == "Darwin": # macOS
|
||||
candidates = [
|
||||
Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad"),
|
||||
Path("/Applications/KiCad/pcbnew.app/Contents/MacOS/pcbnew"),
|
||||
]
|
||||
elif system == "Windows":
|
||||
candidates = [
|
||||
Path("C:/Program Files/KiCad/9.0/bin/pcbnew.exe"),
|
||||
Path("C:/Program Files/KiCad/8.0/bin/pcbnew.exe"),
|
||||
Path("C:/Program Files (x86)/KiCad/9.0/bin/pcbnew.exe"),
|
||||
]
|
||||
else:
|
||||
candidates = []
|
||||
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
logger.info(f"Found KiCAD executable: {path}")
|
||||
return path
|
||||
|
||||
logger.warning("Could not find KiCAD executable")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def launch(project_path: Optional[Path] = None, wait_for_start: bool = True) -> bool:
|
||||
"""
|
||||
Launch KiCAD PCB Editor
|
||||
|
||||
Args:
|
||||
project_path: Optional path to .kicad_pcb file to open
|
||||
wait_for_start: Wait for process to start before returning
|
||||
|
||||
Returns:
|
||||
True if launch successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if already running
|
||||
if KiCADProcessManager.is_running():
|
||||
logger.info("KiCAD is already running")
|
||||
return True
|
||||
|
||||
# Find executable
|
||||
exe_path = KiCADProcessManager.get_executable_path()
|
||||
if not exe_path:
|
||||
logger.error("Cannot launch KiCAD: executable not found")
|
||||
return False
|
||||
|
||||
# Build command
|
||||
cmd = [str(exe_path)]
|
||||
if project_path:
|
||||
cmd.append(str(project_path))
|
||||
|
||||
logger.info(f"Launching KiCAD: {' '.join(cmd)}")
|
||||
|
||||
# Launch process in background
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
# Windows: Use CREATE_NEW_PROCESS_GROUP to detach
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
else:
|
||||
# Unix: Use nohup or start in background
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
# Wait for process to start
|
||||
if wait_for_start:
|
||||
logger.info("Waiting for KiCAD to start...")
|
||||
for i in range(10): # Wait up to 5 seconds
|
||||
time.sleep(0.5)
|
||||
if KiCADProcessManager.is_running():
|
||||
logger.info("✓ KiCAD started successfully")
|
||||
return True
|
||||
|
||||
logger.warning("KiCAD process not detected after launch")
|
||||
# Return True anyway, it might be starting
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error launching KiCAD: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_process_info() -> List[dict]:
|
||||
"""
|
||||
Get information about running KiCAD processes
|
||||
|
||||
Returns:
|
||||
List of process info dicts with pid, name, and command
|
||||
"""
|
||||
system = platform.system()
|
||||
processes = []
|
||||
|
||||
try:
|
||||
if system in ["Linux", "Darwin"]:
|
||||
result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
|
||||
for line in result.stdout.split("\n"):
|
||||
# Only match actual KiCAD binaries, not our MCP server processes
|
||||
if (
|
||||
("pcbnew" in line.lower() or "kicad" in line.lower())
|
||||
and "kicad_interface.py" not in line
|
||||
and "grep" not in line
|
||||
):
|
||||
# More specific check: must have /pcbnew or /kicad in the path
|
||||
if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 11:
|
||||
processes.append(
|
||||
{
|
||||
"pid": parts[1],
|
||||
"name": parts[10],
|
||||
"command": " ".join(parts[10:]),
|
||||
}
|
||||
)
|
||||
|
||||
elif system == "Windows":
|
||||
for proc in KiCADProcessManager._windows_list_processes():
|
||||
name = (proc.get("name") or "").lower()
|
||||
if "pcbnew" in name or "kicad" in name:
|
||||
processes.append(proc)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting process info: {e}")
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict:
|
||||
"""
|
||||
Check if KiCAD is running and optionally launch it
|
||||
|
||||
Args:
|
||||
project_path: Optional path to .kicad_pcb file to open
|
||||
auto_launch: If True, launch KiCAD if not running
|
||||
|
||||
Returns:
|
||||
Dict with status information
|
||||
"""
|
||||
manager = KiCADProcessManager()
|
||||
|
||||
is_running = manager.is_running()
|
||||
|
||||
if is_running:
|
||||
processes = manager.get_process_info()
|
||||
return {
|
||||
"running": True,
|
||||
"launched": False,
|
||||
"processes": processes,
|
||||
"message": "KiCAD is already running",
|
||||
}
|
||||
|
||||
if not auto_launch:
|
||||
return {
|
||||
"running": False,
|
||||
"launched": False,
|
||||
"processes": [],
|
||||
"message": "KiCAD is not running (auto-launch disabled)",
|
||||
}
|
||||
|
||||
# Try to launch
|
||||
logger.info("KiCAD not detected, attempting to launch...")
|
||||
success = manager.launch(project_path)
|
||||
|
||||
return {
|
||||
"running": success,
|
||||
"launched": success,
|
||||
"processes": manager.get_process_info() if success else [],
|
||||
"message": "KiCAD launched successfully" if success else "Failed to launch KiCAD",
|
||||
"project": str(project_path) if project_path else None,
|
||||
}
|
||||
"""
|
||||
KiCAD Process Management Utilities
|
||||
|
||||
Detects if KiCAD is running and provides auto-launch functionality.
|
||||
"""
|
||||
|
||||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import time
|
||||
from ctypes import wintypes
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class KiCADProcessManager:
|
||||
"""Manages KiCAD process detection and launching"""
|
||||
|
||||
@staticmethod
|
||||
def _windows_list_processes() -> List[dict]:
|
||||
"""List running processes on Windows using Toolhelp API."""
|
||||
processes: List[dict] = []
|
||||
try:
|
||||
TH32CS_SNAPPROCESS = 0x00000002
|
||||
try:
|
||||
ulong_ptr = wintypes.ULONG_PTR # type: ignore[attr-defined]
|
||||
except AttributeError:
|
||||
ulong_ptr = (
|
||||
ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong
|
||||
)
|
||||
|
||||
class PROCESSENTRY32W(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwSize", wintypes.DWORD),
|
||||
("cntUsage", wintypes.DWORD),
|
||||
("th32ProcessID", wintypes.DWORD),
|
||||
("th32DefaultHeapID", ulong_ptr),
|
||||
("th32ModuleID", wintypes.DWORD),
|
||||
("cntThreads", wintypes.DWORD),
|
||||
("th32ParentProcessID", wintypes.DWORD),
|
||||
("pcPriClassBase", wintypes.LONG),
|
||||
("dwFlags", wintypes.DWORD),
|
||||
("szExeFile", wintypes.WCHAR * wintypes.MAX_PATH),
|
||||
]
|
||||
|
||||
CreateToolhelp32Snapshot = ctypes.windll.kernel32.CreateToolhelp32Snapshot
|
||||
Process32FirstW = ctypes.windll.kernel32.Process32FirstW
|
||||
Process32NextW = ctypes.windll.kernel32.Process32NextW
|
||||
CloseHandle = ctypes.windll.kernel32.CloseHandle
|
||||
|
||||
snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
|
||||
if snapshot == wintypes.HANDLE(-1).value:
|
||||
return processes
|
||||
|
||||
entry = PROCESSENTRY32W()
|
||||
entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
|
||||
|
||||
if Process32FirstW(snapshot, ctypes.byref(entry)):
|
||||
while True:
|
||||
processes.append(
|
||||
{
|
||||
"pid": str(entry.th32ProcessID),
|
||||
"name": entry.szExeFile,
|
||||
"command": entry.szExeFile,
|
||||
}
|
||||
)
|
||||
if not Process32NextW(snapshot, ctypes.byref(entry)):
|
||||
break
|
||||
|
||||
CloseHandle(snapshot)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing Windows processes: {e}")
|
||||
|
||||
return processes
|
||||
|
||||
@staticmethod
|
||||
def is_running() -> bool:
|
||||
"""
|
||||
Check if KiCAD is currently running
|
||||
|
||||
Returns:
|
||||
True if KiCAD process found, False otherwise
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
try:
|
||||
if system == "Linux":
|
||||
# Check for actual pcbnew/kicad binaries (not python scripts)
|
||||
# Use exact process name matching to avoid matching our own kicad_interface.py
|
||||
result = subprocess.run(
|
||||
["pgrep", "-x", "pcbnew|kicad"], capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return True
|
||||
# Also check with -f for full path matching, but exclude our script
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "/pcbnew|/kicad"], capture_output=True, text=True
|
||||
)
|
||||
# Double-check it's not our own process
|
||||
if result.returncode == 0:
|
||||
pids = result.stdout.strip().split("\n")
|
||||
for pid in pids:
|
||||
try:
|
||||
cmdline = subprocess.run(
|
||||
["ps", "-p", pid, "-o", "command="], capture_output=True, text=True
|
||||
)
|
||||
if "kicad_interface.py" not in cmdline.stdout:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
elif system == "Darwin": # macOS
|
||||
result = subprocess.run(
|
||||
["pgrep", "-f", "KiCad|pcbnew"], capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
elif system == "Windows":
|
||||
processes = KiCADProcessManager._windows_list_processes()
|
||||
for proc in processes:
|
||||
name = (proc.get("name") or "").lower()
|
||||
if name in ("pcbnew.exe", "kicad.exe"):
|
||||
return True
|
||||
return False
|
||||
|
||||
else:
|
||||
logger.warning(f"Process detection not implemented for {system}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if KiCAD is running: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_executable_path() -> Optional[Path]:
|
||||
"""
|
||||
Get path to KiCAD executable
|
||||
|
||||
Returns:
|
||||
Path to pcbnew/kicad executable, or None if not found
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
# Try to find executable in PATH first
|
||||
for cmd in ["pcbnew", "kicad"]:
|
||||
result = subprocess.run(
|
||||
["which", cmd] if system != "Windows" else ["where", cmd],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="mbcs" if system == "Windows" else None,
|
||||
errors="ignore" if system == "Windows" else None,
|
||||
timeout=5 if system == "Windows" else None,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
exe_path = result.stdout.strip().split("\n")[0]
|
||||
logger.info(f"Found KiCAD executable: {exe_path}")
|
||||
return Path(exe_path)
|
||||
|
||||
# Platform-specific default paths
|
||||
if system == "Linux":
|
||||
candidates = [
|
||||
Path("/usr/bin/pcbnew"),
|
||||
Path("/usr/local/bin/pcbnew"),
|
||||
Path("/usr/bin/kicad"),
|
||||
]
|
||||
elif system == "Darwin": # macOS
|
||||
candidates = [
|
||||
Path("/Applications/KiCad/KiCad.app/Contents/MacOS/kicad"),
|
||||
Path("/Applications/KiCad/pcbnew.app/Contents/MacOS/pcbnew"),
|
||||
]
|
||||
elif system == "Windows":
|
||||
candidates = [
|
||||
Path("C:/Program Files/KiCad/9.0/bin/pcbnew.exe"),
|
||||
Path("C:/Program Files/KiCad/8.0/bin/pcbnew.exe"),
|
||||
Path("C:/Program Files (x86)/KiCad/9.0/bin/pcbnew.exe"),
|
||||
]
|
||||
else:
|
||||
candidates = []
|
||||
|
||||
for path in candidates:
|
||||
if path.exists():
|
||||
logger.info(f"Found KiCAD executable: {path}")
|
||||
return path
|
||||
|
||||
logger.warning("Could not find KiCAD executable")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def launch(project_path: Optional[Path] = None, wait_for_start: bool = True) -> bool:
|
||||
"""
|
||||
Launch KiCAD PCB Editor
|
||||
|
||||
Args:
|
||||
project_path: Optional path to .kicad_pcb file to open
|
||||
wait_for_start: Wait for process to start before returning
|
||||
|
||||
Returns:
|
||||
True if launch successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if already running
|
||||
if KiCADProcessManager.is_running():
|
||||
logger.info("KiCAD is already running")
|
||||
return True
|
||||
|
||||
# Find executable
|
||||
exe_path = KiCADProcessManager.get_executable_path()
|
||||
if not exe_path:
|
||||
logger.error("Cannot launch KiCAD: executable not found")
|
||||
return False
|
||||
|
||||
# Build command
|
||||
cmd = [str(exe_path)]
|
||||
if project_path:
|
||||
cmd.append(str(project_path))
|
||||
|
||||
logger.info(f"Launching KiCAD: {' '.join(cmd)}")
|
||||
|
||||
# Launch process in background
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
# Windows: Use CREATE_NEW_PROCESS_GROUP to detach
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
else:
|
||||
# Unix: Use nohup or start in background
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
# Wait for process to start
|
||||
if wait_for_start:
|
||||
logger.info("Waiting for KiCAD to start...")
|
||||
for i in range(10): # Wait up to 5 seconds
|
||||
time.sleep(0.5)
|
||||
if KiCADProcessManager.is_running():
|
||||
logger.info("✓ KiCAD started successfully")
|
||||
return True
|
||||
|
||||
logger.warning("KiCAD process not detected after launch")
|
||||
# Return True anyway, it might be starting
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error launching KiCAD: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def get_process_info() -> List[dict]:
|
||||
"""
|
||||
Get information about running KiCAD processes
|
||||
|
||||
Returns:
|
||||
List of process info dicts with pid, name, and command
|
||||
"""
|
||||
system = platform.system()
|
||||
processes = []
|
||||
|
||||
try:
|
||||
if system in ["Linux", "Darwin"]:
|
||||
result = subprocess.run(["ps", "aux"], capture_output=True, text=True)
|
||||
for line in result.stdout.split("\n"):
|
||||
# Only match actual KiCAD binaries, not our MCP server processes
|
||||
if (
|
||||
("pcbnew" in line.lower() or "kicad" in line.lower())
|
||||
and "kicad_interface.py" not in line
|
||||
and "grep" not in line
|
||||
):
|
||||
# More specific check: must have /pcbnew or /kicad in the path
|
||||
if "/pcbnew" in line or "/kicad" in line or "KiCad.app" in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 11:
|
||||
processes.append(
|
||||
{
|
||||
"pid": parts[1],
|
||||
"name": parts[10],
|
||||
"command": " ".join(parts[10:]),
|
||||
}
|
||||
)
|
||||
|
||||
elif system == "Windows":
|
||||
for proc in KiCADProcessManager._windows_list_processes():
|
||||
name = (proc.get("name") or "").lower()
|
||||
if "pcbnew" in name or "kicad" in name:
|
||||
processes.append(proc)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting process info: {e}")
|
||||
|
||||
return processes
|
||||
|
||||
|
||||
def check_and_launch_kicad(project_path: Optional[Path] = None, auto_launch: bool = True) -> dict:
|
||||
"""
|
||||
Check if KiCAD is running and optionally launch it
|
||||
|
||||
Args:
|
||||
project_path: Optional path to .kicad_pcb file to open
|
||||
auto_launch: If True, launch KiCAD if not running
|
||||
|
||||
Returns:
|
||||
Dict with status information
|
||||
"""
|
||||
manager = KiCADProcessManager()
|
||||
|
||||
is_running = manager.is_running()
|
||||
|
||||
if is_running:
|
||||
processes = manager.get_process_info()
|
||||
return {
|
||||
"running": True,
|
||||
"launched": False,
|
||||
"processes": processes,
|
||||
"message": "KiCAD is already running",
|
||||
}
|
||||
|
||||
if not auto_launch:
|
||||
return {
|
||||
"running": False,
|
||||
"launched": False,
|
||||
"processes": [],
|
||||
"message": "KiCAD is not running (auto-launch disabled)",
|
||||
}
|
||||
|
||||
# Try to launch
|
||||
logger.info("KiCAD not detected, attempting to launch...")
|
||||
success = manager.launch(project_path)
|
||||
|
||||
return {
|
||||
"running": success,
|
||||
"launched": success,
|
||||
"processes": manager.get_process_info() if success else [],
|
||||
"message": "KiCAD launched successfully" if success else "Failed to launch KiCAD",
|
||||
"project": str(project_path) if project_path else None,
|
||||
}
|
||||
|
||||
@@ -1,325 +1,325 @@
|
||||
"""
|
||||
Platform detection and path utilities for cross-platform compatibility
|
||||
|
||||
This module provides helpers for detecting the current platform and
|
||||
getting appropriate paths for KiCAD, configuration, logs, etc.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlatformHelper:
|
||||
"""Platform detection and path resolution utilities"""
|
||||
|
||||
@staticmethod
|
||||
def is_windows() -> bool:
|
||||
"""Check if running on Windows"""
|
||||
return platform.system() == "Windows"
|
||||
|
||||
@staticmethod
|
||||
def is_linux() -> bool:
|
||||
"""Check if running on Linux"""
|
||||
return platform.system() == "Linux"
|
||||
|
||||
@staticmethod
|
||||
def is_macos() -> bool:
|
||||
"""Check if running on macOS"""
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
@staticmethod
|
||||
def get_platform_name() -> str:
|
||||
"""Get human-readable platform name"""
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
return "macOS"
|
||||
return system
|
||||
|
||||
@staticmethod
|
||||
def get_kicad_python_paths() -> List[Path]:
|
||||
"""
|
||||
Get potential KiCAD Python dist-packages paths for current platform
|
||||
|
||||
Returns:
|
||||
List of potential paths to check (in priority order)
|
||||
"""
|
||||
paths = []
|
||||
|
||||
if PlatformHelper.is_windows():
|
||||
# Windows: Check Program Files
|
||||
program_files = [
|
||||
Path("C:/Program Files/KiCad"),
|
||||
Path("C:/Program Files (x86)/KiCad"),
|
||||
]
|
||||
for pf in program_files:
|
||||
# Check multiple KiCAD versions
|
||||
for version in ["9.0", "9.1", "10.0", "8.0"]:
|
||||
path = pf / version / "lib" / "python3" / "dist-packages"
|
||||
if path.exists():
|
||||
paths.append(path)
|
||||
|
||||
elif PlatformHelper.is_linux():
|
||||
# Linux: Check common installation paths
|
||||
candidates = [
|
||||
Path("/usr/lib/kicad/lib/python3/dist-packages"),
|
||||
Path("/usr/share/kicad/scripting/plugins"),
|
||||
Path("/usr/local/lib/kicad/lib/python3/dist-packages"),
|
||||
Path.home() / ".local/lib/kicad/lib/python3/dist-packages",
|
||||
]
|
||||
|
||||
# Also check based on Python version
|
||||
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
candidates.extend(
|
||||
[
|
||||
Path(f"/usr/lib/python{py_version}/dist-packages/kicad"),
|
||||
Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"),
|
||||
]
|
||||
)
|
||||
|
||||
# Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian)
|
||||
# This is where pcbnew.py typically lives on modern systems
|
||||
candidates.extend(
|
||||
[
|
||||
Path(f"/usr/lib/python3/dist-packages"),
|
||||
Path(f"/usr/lib/python{py_version}/dist-packages"),
|
||||
Path(f"/usr/local/lib/python3/dist-packages"),
|
||||
Path(f"/usr/local/lib/python{py_version}/dist-packages"),
|
||||
]
|
||||
)
|
||||
|
||||
paths = [p for p in candidates if p.exists()]
|
||||
|
||||
elif PlatformHelper.is_macos():
|
||||
# macOS: Check multiple KiCAD application bundle locations
|
||||
kicad_app_paths = [
|
||||
Path("/Applications/KiCad/KiCad.app"),
|
||||
Path("/Applications/KiCAD/KiCad.app"), # Alternative capitalization
|
||||
Path.home() / "Applications" / "KiCad" / "KiCad.app", # User Applications
|
||||
]
|
||||
|
||||
# Check Python framework paths in each KiCAD installation
|
||||
for kicad_app in kicad_app_paths:
|
||||
if kicad_app.exists():
|
||||
for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]:
|
||||
path = (
|
||||
kicad_app
|
||||
/ "Contents"
|
||||
/ "Frameworks"
|
||||
/ "Python.framework"
|
||||
/ "Versions"
|
||||
/ version
|
||||
/ "lib"
|
||||
/ f"python{version}"
|
||||
/ "site-packages"
|
||||
)
|
||||
if path.exists():
|
||||
paths.append(path)
|
||||
|
||||
# Also check Homebrew Python site-packages (if pcbnew installed via pip)
|
||||
homebrew_paths = [
|
||||
Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon
|
||||
Path("/opt/homebrew/lib/python3.11/site-packages"),
|
||||
Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac
|
||||
Path("/usr/local/lib/python3.11/site-packages"),
|
||||
]
|
||||
for hp in homebrew_paths:
|
||||
pcbnew_path = hp / "pcbnew.py"
|
||||
if pcbnew_path.exists():
|
||||
paths.append(hp)
|
||||
|
||||
if not paths:
|
||||
logger.warning(f"No KiCAD Python paths found for {PlatformHelper.get_platform_name()}")
|
||||
else:
|
||||
logger.info(f"Found {len(paths)} potential KiCAD Python paths")
|
||||
|
||||
return paths
|
||||
|
||||
@staticmethod
|
||||
def get_kicad_python_path() -> Optional[Path]:
|
||||
"""
|
||||
Get the first valid KiCAD Python path
|
||||
|
||||
Returns:
|
||||
Path to KiCAD Python dist-packages, or None if not found
|
||||
"""
|
||||
paths = PlatformHelper.get_kicad_python_paths()
|
||||
return paths[0] if paths else None
|
||||
|
||||
@staticmethod
|
||||
def get_kicad_library_search_paths() -> List[str]:
|
||||
"""
|
||||
Get platform-appropriate KiCAD symbol library search paths
|
||||
|
||||
Returns:
|
||||
List of glob patterns for finding .kicad_sym files
|
||||
"""
|
||||
patterns = []
|
||||
|
||||
if PlatformHelper.is_windows():
|
||||
patterns = [
|
||||
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym",
|
||||
"C:/Program Files (x86)/KiCad/*/share/kicad/symbols/*.kicad_sym",
|
||||
]
|
||||
elif PlatformHelper.is_linux():
|
||||
patterns = [
|
||||
"/usr/share/kicad/symbols/*.kicad_sym",
|
||||
"/usr/local/share/kicad/symbols/*.kicad_sym",
|
||||
str(Path.home() / ".local/share/kicad/symbols/*.kicad_sym"),
|
||||
]
|
||||
elif PlatformHelper.is_macos():
|
||||
patterns = [
|
||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
|
||||
"/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
|
||||
str(
|
||||
Path.home()
|
||||
/ "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"
|
||||
),
|
||||
]
|
||||
|
||||
# Add user library paths for all platforms
|
||||
patterns.append(str(Path.home() / "Documents" / "KiCad" / "*" / "symbols" / "*.kicad_sym"))
|
||||
|
||||
return patterns
|
||||
|
||||
@staticmethod
|
||||
def get_config_dir() -> Path:
|
||||
r"""
|
||||
Get appropriate configuration directory for current platform
|
||||
|
||||
Follows platform conventions:
|
||||
- Windows: %USERPROFILE%\.kicad-mcp
|
||||
- Linux: $XDG_CONFIG_HOME/kicad-mcp or ~/.config/kicad-mcp
|
||||
- macOS: ~/Library/Application Support/kicad-mcp
|
||||
|
||||
Returns:
|
||||
Path to configuration directory
|
||||
"""
|
||||
if PlatformHelper.is_windows():
|
||||
return Path.home() / ".kicad-mcp"
|
||||
elif PlatformHelper.is_linux():
|
||||
# Use XDG Base Directory specification
|
||||
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
||||
if xdg_config:
|
||||
xdg_config_path = Path(xdg_config).expanduser()
|
||||
if xdg_config_path.is_absolute():
|
||||
return xdg_config_path / "kicad-mcp"
|
||||
logger.warning("Ignoring relative XDG_CONFIG_HOME: %s", xdg_config)
|
||||
return Path.home() / ".config" / "kicad-mcp"
|
||||
elif PlatformHelper.is_macos():
|
||||
return Path.home() / "Library" / "Application Support" / "kicad-mcp"
|
||||
else:
|
||||
# Fallback for unknown platforms
|
||||
return Path.home() / ".kicad-mcp"
|
||||
|
||||
@staticmethod
|
||||
def get_log_dir() -> Path:
|
||||
"""
|
||||
Get appropriate log directory for current platform
|
||||
|
||||
Returns:
|
||||
Path to log directory
|
||||
"""
|
||||
config_dir = PlatformHelper.get_config_dir()
|
||||
return config_dir / "logs"
|
||||
|
||||
@staticmethod
|
||||
def get_cache_dir() -> Path:
|
||||
r"""
|
||||
Get appropriate cache directory for current platform
|
||||
|
||||
Follows platform conventions:
|
||||
- Windows: %USERPROFILE%\.kicad-mcp\cache
|
||||
- Linux: $XDG_CACHE_HOME/kicad-mcp or ~/.cache/kicad-mcp
|
||||
- macOS: ~/Library/Caches/kicad-mcp
|
||||
|
||||
Returns:
|
||||
Path to cache directory
|
||||
"""
|
||||
if PlatformHelper.is_windows():
|
||||
return PlatformHelper.get_config_dir() / "cache"
|
||||
elif PlatformHelper.is_linux():
|
||||
xdg_cache = os.environ.get("XDG_CACHE_HOME")
|
||||
if xdg_cache:
|
||||
xdg_cache_path = Path(xdg_cache).expanduser()
|
||||
if xdg_cache_path.is_absolute():
|
||||
return xdg_cache_path / "kicad-mcp"
|
||||
logger.warning("Ignoring relative XDG_CACHE_HOME: %s", xdg_cache)
|
||||
return Path.home() / ".cache" / "kicad-mcp"
|
||||
elif PlatformHelper.is_macos():
|
||||
return Path.home() / "Library" / "Caches" / "kicad-mcp"
|
||||
else:
|
||||
return PlatformHelper.get_config_dir() / "cache"
|
||||
|
||||
@staticmethod
|
||||
def ensure_directories() -> None:
|
||||
"""Create all necessary directories if they don't exist"""
|
||||
dirs_to_create = [
|
||||
PlatformHelper.get_config_dir(),
|
||||
PlatformHelper.get_log_dir(),
|
||||
PlatformHelper.get_cache_dir(),
|
||||
]
|
||||
|
||||
for directory in dirs_to_create:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug(f"Ensured directory exists: {directory}")
|
||||
|
||||
@staticmethod
|
||||
def get_python_executable() -> Path:
|
||||
"""Get path to current Python executable"""
|
||||
return Path(sys.executable)
|
||||
|
||||
@staticmethod
|
||||
def add_kicad_to_python_path() -> bool:
|
||||
"""
|
||||
Add KiCAD Python paths to sys.path
|
||||
|
||||
Returns:
|
||||
True if at least one path was added, False otherwise
|
||||
"""
|
||||
paths_added = False
|
||||
|
||||
for path in PlatformHelper.get_kicad_python_paths():
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
logger.info(f"Added to Python path: {path}")
|
||||
paths_added = True
|
||||
|
||||
return paths_added
|
||||
|
||||
|
||||
# Convenience function for quick platform detection
|
||||
def detect_platform() -> dict:
|
||||
"""
|
||||
Detect platform and return useful information
|
||||
|
||||
Returns:
|
||||
Dictionary with platform information
|
||||
"""
|
||||
return {
|
||||
"system": platform.system(),
|
||||
"platform": PlatformHelper.get_platform_name(),
|
||||
"is_windows": PlatformHelper.is_windows(),
|
||||
"is_linux": PlatformHelper.is_linux(),
|
||||
"is_macos": PlatformHelper.is_macos(),
|
||||
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||||
"python_executable": str(PlatformHelper.get_python_executable()),
|
||||
"config_dir": str(PlatformHelper.get_config_dir()),
|
||||
"log_dir": str(PlatformHelper.get_log_dir()),
|
||||
"cache_dir": str(PlatformHelper.get_cache_dir()),
|
||||
"kicad_python_paths": [str(p) for p in PlatformHelper.get_kicad_python_paths()],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test/diagnostic
|
||||
import json
|
||||
|
||||
info = detect_platform()
|
||||
print("Platform Information:")
|
||||
print(json.dumps(info, indent=2))
|
||||
"""
|
||||
Platform detection and path utilities for cross-platform compatibility
|
||||
|
||||
This module provides helpers for detecting the current platform and
|
||||
getting appropriate paths for KiCAD, configuration, logs, etc.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PlatformHelper:
|
||||
"""Platform detection and path resolution utilities"""
|
||||
|
||||
@staticmethod
|
||||
def is_windows() -> bool:
|
||||
"""Check if running on Windows"""
|
||||
return platform.system() == "Windows"
|
||||
|
||||
@staticmethod
|
||||
def is_linux() -> bool:
|
||||
"""Check if running on Linux"""
|
||||
return platform.system() == "Linux"
|
||||
|
||||
@staticmethod
|
||||
def is_macos() -> bool:
|
||||
"""Check if running on macOS"""
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
@staticmethod
|
||||
def get_platform_name() -> str:
|
||||
"""Get human-readable platform name"""
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
return "macOS"
|
||||
return system
|
||||
|
||||
@staticmethod
|
||||
def get_kicad_python_paths() -> List[Path]:
|
||||
"""
|
||||
Get potential KiCAD Python dist-packages paths for current platform
|
||||
|
||||
Returns:
|
||||
List of potential paths to check (in priority order)
|
||||
"""
|
||||
paths = []
|
||||
|
||||
if PlatformHelper.is_windows():
|
||||
# Windows: Check Program Files
|
||||
program_files = [
|
||||
Path("C:/Program Files/KiCad"),
|
||||
Path("C:/Program Files (x86)/KiCad"),
|
||||
]
|
||||
for pf in program_files:
|
||||
# Check multiple KiCAD versions
|
||||
for version in ["9.0", "9.1", "10.0", "8.0"]:
|
||||
path = pf / version / "lib" / "python3" / "dist-packages"
|
||||
if path.exists():
|
||||
paths.append(path)
|
||||
|
||||
elif PlatformHelper.is_linux():
|
||||
# Linux: Check common installation paths
|
||||
candidates = [
|
||||
Path("/usr/lib/kicad/lib/python3/dist-packages"),
|
||||
Path("/usr/share/kicad/scripting/plugins"),
|
||||
Path("/usr/local/lib/kicad/lib/python3/dist-packages"),
|
||||
Path.home() / ".local/lib/kicad/lib/python3/dist-packages",
|
||||
]
|
||||
|
||||
# Also check based on Python version
|
||||
py_version = f"{sys.version_info.major}.{sys.version_info.minor}"
|
||||
candidates.extend(
|
||||
[
|
||||
Path(f"/usr/lib/python{py_version}/dist-packages/kicad"),
|
||||
Path(f"/usr/local/lib/python{py_version}/dist-packages/kicad"),
|
||||
]
|
||||
)
|
||||
|
||||
# Check system Python dist-packages (modern KiCAD 9+ on Ubuntu/Debian)
|
||||
# This is where pcbnew.py typically lives on modern systems
|
||||
candidates.extend(
|
||||
[
|
||||
Path(f"/usr/lib/python3/dist-packages"),
|
||||
Path(f"/usr/lib/python{py_version}/dist-packages"),
|
||||
Path(f"/usr/local/lib/python3/dist-packages"),
|
||||
Path(f"/usr/local/lib/python{py_version}/dist-packages"),
|
||||
]
|
||||
)
|
||||
|
||||
paths = [p for p in candidates if p.exists()]
|
||||
|
||||
elif PlatformHelper.is_macos():
|
||||
# macOS: Check multiple KiCAD application bundle locations
|
||||
kicad_app_paths = [
|
||||
Path("/Applications/KiCad/KiCad.app"),
|
||||
Path("/Applications/KiCAD/KiCad.app"), # Alternative capitalization
|
||||
Path.home() / "Applications" / "KiCad" / "KiCad.app", # User Applications
|
||||
]
|
||||
|
||||
# Check Python framework paths in each KiCAD installation
|
||||
for kicad_app in kicad_app_paths:
|
||||
if kicad_app.exists():
|
||||
for version in ["3.9", "3.10", "3.11", "3.12", "3.13"]:
|
||||
path = (
|
||||
kicad_app
|
||||
/ "Contents"
|
||||
/ "Frameworks"
|
||||
/ "Python.framework"
|
||||
/ "Versions"
|
||||
/ version
|
||||
/ "lib"
|
||||
/ f"python{version}"
|
||||
/ "site-packages"
|
||||
)
|
||||
if path.exists():
|
||||
paths.append(path)
|
||||
|
||||
# Also check Homebrew Python site-packages (if pcbnew installed via pip)
|
||||
homebrew_paths = [
|
||||
Path("/opt/homebrew/lib/python3.12/site-packages"), # Apple Silicon
|
||||
Path("/opt/homebrew/lib/python3.11/site-packages"),
|
||||
Path("/usr/local/lib/python3.12/site-packages"), # Intel Mac
|
||||
Path("/usr/local/lib/python3.11/site-packages"),
|
||||
]
|
||||
for hp in homebrew_paths:
|
||||
pcbnew_path = hp / "pcbnew.py"
|
||||
if pcbnew_path.exists():
|
||||
paths.append(hp)
|
||||
|
||||
if not paths:
|
||||
logger.warning(f"No KiCAD Python paths found for {PlatformHelper.get_platform_name()}")
|
||||
else:
|
||||
logger.info(f"Found {len(paths)} potential KiCAD Python paths")
|
||||
|
||||
return paths
|
||||
|
||||
@staticmethod
|
||||
def get_kicad_python_path() -> Optional[Path]:
|
||||
"""
|
||||
Get the first valid KiCAD Python path
|
||||
|
||||
Returns:
|
||||
Path to KiCAD Python dist-packages, or None if not found
|
||||
"""
|
||||
paths = PlatformHelper.get_kicad_python_paths()
|
||||
return paths[0] if paths else None
|
||||
|
||||
@staticmethod
|
||||
def get_kicad_library_search_paths() -> List[str]:
|
||||
"""
|
||||
Get platform-appropriate KiCAD symbol library search paths
|
||||
|
||||
Returns:
|
||||
List of glob patterns for finding .kicad_sym files
|
||||
"""
|
||||
patterns = []
|
||||
|
||||
if PlatformHelper.is_windows():
|
||||
patterns = [
|
||||
"C:/Program Files/KiCad/*/share/kicad/symbols/*.kicad_sym",
|
||||
"C:/Program Files (x86)/KiCad/*/share/kicad/symbols/*.kicad_sym",
|
||||
]
|
||||
elif PlatformHelper.is_linux():
|
||||
patterns = [
|
||||
"/usr/share/kicad/symbols/*.kicad_sym",
|
||||
"/usr/local/share/kicad/symbols/*.kicad_sym",
|
||||
str(Path.home() / ".local/share/kicad/symbols/*.kicad_sym"),
|
||||
]
|
||||
elif PlatformHelper.is_macos():
|
||||
patterns = [
|
||||
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
|
||||
"/Applications/KiCAD/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym",
|
||||
str(
|
||||
Path.home()
|
||||
/ "Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols/*.kicad_sym"
|
||||
),
|
||||
]
|
||||
|
||||
# Add user library paths for all platforms
|
||||
patterns.append(str(Path.home() / "Documents" / "KiCad" / "*" / "symbols" / "*.kicad_sym"))
|
||||
|
||||
return patterns
|
||||
|
||||
@staticmethod
|
||||
def get_config_dir() -> Path:
|
||||
r"""
|
||||
Get appropriate configuration directory for current platform
|
||||
|
||||
Follows platform conventions:
|
||||
- Windows: %USERPROFILE%\.kicad-mcp
|
||||
- Linux: $XDG_CONFIG_HOME/kicad-mcp or ~/.config/kicad-mcp
|
||||
- macOS: ~/Library/Application Support/kicad-mcp
|
||||
|
||||
Returns:
|
||||
Path to configuration directory
|
||||
"""
|
||||
if PlatformHelper.is_windows():
|
||||
return Path.home() / ".kicad-mcp"
|
||||
elif PlatformHelper.is_linux():
|
||||
# Use XDG Base Directory specification
|
||||
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
||||
if xdg_config:
|
||||
xdg_config_path = Path(xdg_config).expanduser()
|
||||
if xdg_config_path.is_absolute():
|
||||
return xdg_config_path / "kicad-mcp"
|
||||
logger.warning("Ignoring relative XDG_CONFIG_HOME: %s", xdg_config)
|
||||
return Path.home() / ".config" / "kicad-mcp"
|
||||
elif PlatformHelper.is_macos():
|
||||
return Path.home() / "Library" / "Application Support" / "kicad-mcp"
|
||||
else:
|
||||
# Fallback for unknown platforms
|
||||
return Path.home() / ".kicad-mcp"
|
||||
|
||||
@staticmethod
|
||||
def get_log_dir() -> Path:
|
||||
"""
|
||||
Get appropriate log directory for current platform
|
||||
|
||||
Returns:
|
||||
Path to log directory
|
||||
"""
|
||||
config_dir = PlatformHelper.get_config_dir()
|
||||
return config_dir / "logs"
|
||||
|
||||
@staticmethod
|
||||
def get_cache_dir() -> Path:
|
||||
r"""
|
||||
Get appropriate cache directory for current platform
|
||||
|
||||
Follows platform conventions:
|
||||
- Windows: %USERPROFILE%\.kicad-mcp\cache
|
||||
- Linux: $XDG_CACHE_HOME/kicad-mcp or ~/.cache/kicad-mcp
|
||||
- macOS: ~/Library/Caches/kicad-mcp
|
||||
|
||||
Returns:
|
||||
Path to cache directory
|
||||
"""
|
||||
if PlatformHelper.is_windows():
|
||||
return PlatformHelper.get_config_dir() / "cache"
|
||||
elif PlatformHelper.is_linux():
|
||||
xdg_cache = os.environ.get("XDG_CACHE_HOME")
|
||||
if xdg_cache:
|
||||
xdg_cache_path = Path(xdg_cache).expanduser()
|
||||
if xdg_cache_path.is_absolute():
|
||||
return xdg_cache_path / "kicad-mcp"
|
||||
logger.warning("Ignoring relative XDG_CACHE_HOME: %s", xdg_cache)
|
||||
return Path.home() / ".cache" / "kicad-mcp"
|
||||
elif PlatformHelper.is_macos():
|
||||
return Path.home() / "Library" / "Caches" / "kicad-mcp"
|
||||
else:
|
||||
return PlatformHelper.get_config_dir() / "cache"
|
||||
|
||||
@staticmethod
|
||||
def ensure_directories() -> None:
|
||||
"""Create all necessary directories if they don't exist"""
|
||||
dirs_to_create = [
|
||||
PlatformHelper.get_config_dir(),
|
||||
PlatformHelper.get_log_dir(),
|
||||
PlatformHelper.get_cache_dir(),
|
||||
]
|
||||
|
||||
for directory in dirs_to_create:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug(f"Ensured directory exists: {directory}")
|
||||
|
||||
@staticmethod
|
||||
def get_python_executable() -> Path:
|
||||
"""Get path to current Python executable"""
|
||||
return Path(sys.executable)
|
||||
|
||||
@staticmethod
|
||||
def add_kicad_to_python_path() -> bool:
|
||||
"""
|
||||
Add KiCAD Python paths to sys.path
|
||||
|
||||
Returns:
|
||||
True if at least one path was added, False otherwise
|
||||
"""
|
||||
paths_added = False
|
||||
|
||||
for path in PlatformHelper.get_kicad_python_paths():
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
logger.info(f"Added to Python path: {path}")
|
||||
paths_added = True
|
||||
|
||||
return paths_added
|
||||
|
||||
|
||||
# Convenience function for quick platform detection
|
||||
def detect_platform() -> dict:
|
||||
"""
|
||||
Detect platform and return useful information
|
||||
|
||||
Returns:
|
||||
Dictionary with platform information
|
||||
"""
|
||||
return {
|
||||
"system": platform.system(),
|
||||
"platform": PlatformHelper.get_platform_name(),
|
||||
"is_windows": PlatformHelper.is_windows(),
|
||||
"is_linux": PlatformHelper.is_linux(),
|
||||
"is_macos": PlatformHelper.is_macos(),
|
||||
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
||||
"python_executable": str(PlatformHelper.get_python_executable()),
|
||||
"config_dir": str(PlatformHelper.get_config_dir()),
|
||||
"log_dir": str(PlatformHelper.get_log_dir()),
|
||||
"cache_dir": str(PlatformHelper.get_cache_dir()),
|
||||
"kicad_python_paths": [str(p) for p in PlatformHelper.get_kicad_python_paths()],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick test/diagnostic
|
||||
import json
|
||||
|
||||
info = detect_platform()
|
||||
print("Platform Information:")
|
||||
print(json.dumps(info, indent=2))
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
# KiCAD MCP Server - Development Dependencies
|
||||
# Testing, linting, and development tools
|
||||
|
||||
# Include production dependencies
|
||||
-r requirements.txt
|
||||
|
||||
# Testing framework
|
||||
pytest>=7.4.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-asyncio>=0.21.0
|
||||
pytest-mock>=3.11.0
|
||||
|
||||
# Code quality
|
||||
black>=23.7.0
|
||||
mypy>=1.5.0
|
||||
pylint>=2.17.0
|
||||
flake8>=6.1.0
|
||||
isort>=5.12.0
|
||||
|
||||
# Type stubs
|
||||
types-requests>=2.31.0
|
||||
types-Pillow>=10.0.0
|
||||
|
||||
# Pre-commit hooks
|
||||
pre-commit>=3.3.0
|
||||
|
||||
# Development utilities
|
||||
ipython>=8.14.0
|
||||
ipdb>=0.13.13
|
||||
# KiCAD MCP Server - Development Dependencies
|
||||
# Testing, linting, and development tools
|
||||
|
||||
# Include production dependencies
|
||||
-r requirements.txt
|
||||
|
||||
# Testing framework
|
||||
pytest>=7.4.0
|
||||
pytest-cov>=4.1.0
|
||||
pytest-asyncio>=0.21.0
|
||||
pytest-mock>=3.11.0
|
||||
|
||||
# Code quality
|
||||
black>=23.7.0
|
||||
mypy>=1.5.0
|
||||
pylint>=2.17.0
|
||||
flake8>=6.1.0
|
||||
isort>=5.12.0
|
||||
|
||||
# Type stubs
|
||||
types-requests>=2.31.0
|
||||
types-Pillow>=10.0.0
|
||||
|
||||
# Pre-commit hooks
|
||||
pre-commit>=3.3.0
|
||||
|
||||
# Development utilities
|
||||
ipython>=8.14.0
|
||||
ipdb>=0.13.13
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
# KiCAD MCP Server - Python Dependencies
|
||||
# Production dependencies only
|
||||
|
||||
# KiCAD Python API (IPC - for future migration)
|
||||
# kicad-python>=0.5.0 # Uncomment when migrating to IPC API
|
||||
|
||||
# Schematic manipulation
|
||||
kicad-skip>=0.1.0
|
||||
|
||||
# Image processing for board rendering
|
||||
Pillow>=9.0.0
|
||||
|
||||
# SVG rendering
|
||||
cairosvg>=2.7.0
|
||||
|
||||
# Colored logging
|
||||
colorlog>=6.7.0
|
||||
|
||||
# Data validation (for future features)
|
||||
pydantic>=2.5.0
|
||||
|
||||
# HTTP requests (for JLCPCB/Digikey APIs - future)
|
||||
requests>=2.32.5
|
||||
|
||||
# Environment variable management
|
||||
python-dotenv>=1.0.0
|
||||
# KiCAD MCP Server - Python Dependencies
|
||||
# Production dependencies only
|
||||
|
||||
# KiCAD Python API (IPC - for future migration)
|
||||
# kicad-python>=0.5.0 # Uncomment when migrating to IPC API
|
||||
|
||||
# Schematic manipulation
|
||||
kicad-skip>=0.1.0
|
||||
|
||||
# Image processing for board rendering
|
||||
Pillow>=9.0.0
|
||||
|
||||
# SVG rendering
|
||||
cairosvg>=2.7.0
|
||||
|
||||
# Colored logging
|
||||
colorlog>=6.7.0
|
||||
|
||||
# Data validation (for future features)
|
||||
pydantic>=2.5.0
|
||||
|
||||
# HTTP requests (for JLCPCB/Digikey APIs - future)
|
||||
requests>=2.32.5
|
||||
|
||||
# Environment variable management
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
@@ -1,165 +1,165 @@
|
||||
#!/bin/bash
|
||||
# KiCAD MCP Server - Linux Installation Script
|
||||
# Supports Ubuntu/Debian-based distributions
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Print colored messages
|
||||
print_info() { echo -e "${BLUE}ℹ${NC} $1"; }
|
||||
print_success() { echo -e "${GREEN}✓${NC} $1"; }
|
||||
print_warning() { echo -e "${YELLOW}⚠${NC} $1"; }
|
||||
print_error() { echo -e "${RED}✗${NC} $1"; }
|
||||
|
||||
# Header
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ KiCAD MCP Server - Linux Installation ║"
|
||||
echo "║ ║"
|
||||
echo "║ This script will install: ║"
|
||||
echo "║ - KiCAD 9.0 ║"
|
||||
echo "║ - Node.js 20.x ║"
|
||||
echo "║ - Python dependencies ║"
|
||||
echo "║ - Build the TypeScript server ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Check if running on Linux
|
||||
if [[ "$OSTYPE" != "linux-gnu"* ]]; then
|
||||
print_error "This script is for Linux only. Detected: $OSTYPE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for Ubuntu/Debian
|
||||
if ! command -v apt-get &> /dev/null; then
|
||||
print_warning "This script is optimized for Ubuntu/Debian"
|
||||
print_warning "For other distributions, please install manually"
|
||||
read -p "Continue anyway? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Function to check if command exists
|
||||
command_exists() {
|
||||
command -v "$1" &> /dev/null
|
||||
}
|
||||
|
||||
# Step 1: Install KiCAD 9.0
|
||||
print_info "Step 1/5: Installing KiCAD 9.0..."
|
||||
if command_exists kicad; then
|
||||
KICAD_VERSION=$(kicad-cli version 2>/dev/null | head -n 1 || echo "unknown")
|
||||
print_success "KiCAD is already installed: $KICAD_VERSION"
|
||||
else
|
||||
print_info "Adding KiCAD PPA and installing..."
|
||||
sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y kicad kicad-libraries
|
||||
print_success "KiCAD 9.0 installed"
|
||||
fi
|
||||
|
||||
# Verify KiCAD Python module
|
||||
print_info "Verifying KiCAD Python module..."
|
||||
if python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" 2>/dev/null; then
|
||||
PCBNEW_VERSION=$(python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())")
|
||||
print_success "KiCAD Python module (pcbnew) found: $PCBNEW_VERSION"
|
||||
else
|
||||
print_warning "KiCAD Python module (pcbnew) not found in default Python path"
|
||||
print_warning "You may need to set PYTHONPATH manually"
|
||||
fi
|
||||
|
||||
# Step 2: Install Node.js
|
||||
print_info "Step 2/5: Installing Node.js 20.x..."
|
||||
if command_exists node; then
|
||||
NODE_VERSION=$(node --version)
|
||||
MAJOR_VERSION=$(echo $NODE_VERSION | cut -d'.' -f1 | sed 's/v//')
|
||||
if [ "$MAJOR_VERSION" -ge 18 ]; then
|
||||
print_success "Node.js is already installed: $NODE_VERSION"
|
||||
else
|
||||
print_warning "Node.js version is too old: $NODE_VERSION (need 18+)"
|
||||
print_info "Installing Node.js 20.x..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
print_success "Node.js updated"
|
||||
fi
|
||||
else
|
||||
print_info "Installing Node.js 20.x..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
print_success "Node.js installed: $(node --version)"
|
||||
fi
|
||||
|
||||
# Step 3: Install Python dependencies
|
||||
print_info "Step 3/5: Installing Python dependencies..."
|
||||
if [ -f "requirements.txt" ]; then
|
||||
pip3 install --user -r requirements.txt
|
||||
print_success "Python dependencies installed"
|
||||
else
|
||||
print_warning "requirements.txt not found - skipping Python dependencies"
|
||||
fi
|
||||
|
||||
# Step 4: Install Node.js dependencies
|
||||
print_info "Step 4/5: Installing Node.js dependencies..."
|
||||
if [ -f "package.json" ]; then
|
||||
npm install
|
||||
print_success "Node.js dependencies installed"
|
||||
else
|
||||
print_error "package.json not found! Are you in the correct directory?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 5: Build TypeScript
|
||||
print_info "Step 5/5: Building TypeScript..."
|
||||
npm run build
|
||||
print_success "TypeScript build complete"
|
||||
|
||||
# Final checks
|
||||
echo ""
|
||||
print_info "Running final checks..."
|
||||
|
||||
# Check if dist directory was created
|
||||
if [ -d "dist" ]; then
|
||||
print_success "dist/ directory created"
|
||||
else
|
||||
print_error "dist/ directory not found - build may have failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test platform helper
|
||||
print_info "Testing platform detection..."
|
||||
if python3 python/utils/platform_helper.py > /dev/null 2>&1; then
|
||||
print_success "Platform helper working"
|
||||
else
|
||||
print_warning "Platform helper test failed"
|
||||
fi
|
||||
|
||||
# Installation complete
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 🎉 Installation Complete! 🎉 ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
print_success "KiCAD MCP Server is ready to use!"
|
||||
echo ""
|
||||
print_info "Next steps:"
|
||||
echo " 1. Configure Cline in VSCode with the path to dist/index.js"
|
||||
echo " 2. Set PYTHONPATH in Cline config (see README.md)"
|
||||
echo " 3. Restart VSCode"
|
||||
echo " 4. Test with: 'Create a new KiCAD project named TestProject'"
|
||||
echo ""
|
||||
print_info "For detailed configuration, see:"
|
||||
echo " - README.md (Linux section)"
|
||||
echo " - config/linux-config.example.json"
|
||||
echo ""
|
||||
print_info "To run tests:"
|
||||
echo " pytest tests/"
|
||||
echo ""
|
||||
print_info "Need help? Check docs/LINUX_COMPATIBILITY_AUDIT.md"
|
||||
echo ""
|
||||
#!/bin/bash
|
||||
# KiCAD MCP Server - Linux Installation Script
|
||||
# Supports Ubuntu/Debian-based distributions
|
||||
|
||||
set -e # Exit on error
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Print colored messages
|
||||
print_info() { echo -e "${BLUE}ℹ${NC} $1"; }
|
||||
print_success() { echo -e "${GREEN}✓${NC} $1"; }
|
||||
print_warning() { echo -e "${YELLOW}⚠${NC} $1"; }
|
||||
print_error() { echo -e "${RED}✗${NC} $1"; }
|
||||
|
||||
# Header
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ KiCAD MCP Server - Linux Installation ║"
|
||||
echo "║ ║"
|
||||
echo "║ This script will install: ║"
|
||||
echo "║ - KiCAD 9.0 ║"
|
||||
echo "║ - Node.js 20.x ║"
|
||||
echo "║ - Python dependencies ║"
|
||||
echo "║ - Build the TypeScript server ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Check if running on Linux
|
||||
if [[ "$OSTYPE" != "linux-gnu"* ]]; then
|
||||
print_error "This script is for Linux only. Detected: $OSTYPE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for Ubuntu/Debian
|
||||
if ! command -v apt-get &> /dev/null; then
|
||||
print_warning "This script is optimized for Ubuntu/Debian"
|
||||
print_warning "For other distributions, please install manually"
|
||||
read -p "Continue anyway? (y/N) " -n 1 -r
|
||||
echo
|
||||
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Function to check if command exists
|
||||
command_exists() {
|
||||
command -v "$1" &> /dev/null
|
||||
}
|
||||
|
||||
# Step 1: Install KiCAD 9.0
|
||||
print_info "Step 1/5: Installing KiCAD 9.0..."
|
||||
if command_exists kicad; then
|
||||
KICAD_VERSION=$(kicad-cli version 2>/dev/null | head -n 1 || echo "unknown")
|
||||
print_success "KiCAD is already installed: $KICAD_VERSION"
|
||||
else
|
||||
print_info "Adding KiCAD PPA and installing..."
|
||||
sudo add-apt-repository --yes ppa:kicad/kicad-9.0-releases
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y kicad kicad-libraries
|
||||
print_success "KiCAD 9.0 installed"
|
||||
fi
|
||||
|
||||
# Verify KiCAD Python module
|
||||
print_info "Verifying KiCAD Python module..."
|
||||
if python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())" 2>/dev/null; then
|
||||
PCBNEW_VERSION=$(python3 -c "import pcbnew; print(pcbnew.GetBuildVersion())")
|
||||
print_success "KiCAD Python module (pcbnew) found: $PCBNEW_VERSION"
|
||||
else
|
||||
print_warning "KiCAD Python module (pcbnew) not found in default Python path"
|
||||
print_warning "You may need to set PYTHONPATH manually"
|
||||
fi
|
||||
|
||||
# Step 2: Install Node.js
|
||||
print_info "Step 2/5: Installing Node.js 20.x..."
|
||||
if command_exists node; then
|
||||
NODE_VERSION=$(node --version)
|
||||
MAJOR_VERSION=$(echo $NODE_VERSION | cut -d'.' -f1 | sed 's/v//')
|
||||
if [ "$MAJOR_VERSION" -ge 18 ]; then
|
||||
print_success "Node.js is already installed: $NODE_VERSION"
|
||||
else
|
||||
print_warning "Node.js version is too old: $NODE_VERSION (need 18+)"
|
||||
print_info "Installing Node.js 20.x..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
print_success "Node.js updated"
|
||||
fi
|
||||
else
|
||||
print_info "Installing Node.js 20.x..."
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
print_success "Node.js installed: $(node --version)"
|
||||
fi
|
||||
|
||||
# Step 3: Install Python dependencies
|
||||
print_info "Step 3/5: Installing Python dependencies..."
|
||||
if [ -f "requirements.txt" ]; then
|
||||
pip3 install --user -r requirements.txt
|
||||
print_success "Python dependencies installed"
|
||||
else
|
||||
print_warning "requirements.txt not found - skipping Python dependencies"
|
||||
fi
|
||||
|
||||
# Step 4: Install Node.js dependencies
|
||||
print_info "Step 4/5: Installing Node.js dependencies..."
|
||||
if [ -f "package.json" ]; then
|
||||
npm install
|
||||
print_success "Node.js dependencies installed"
|
||||
else
|
||||
print_error "package.json not found! Are you in the correct directory?"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 5: Build TypeScript
|
||||
print_info "Step 5/5: Building TypeScript..."
|
||||
npm run build
|
||||
print_success "TypeScript build complete"
|
||||
|
||||
# Final checks
|
||||
echo ""
|
||||
print_info "Running final checks..."
|
||||
|
||||
# Check if dist directory was created
|
||||
if [ -d "dist" ]; then
|
||||
print_success "dist/ directory created"
|
||||
else
|
||||
print_error "dist/ directory not found - build may have failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test platform helper
|
||||
print_info "Testing platform detection..."
|
||||
if python3 python/utils/platform_helper.py > /dev/null 2>&1; then
|
||||
print_success "Platform helper working"
|
||||
else
|
||||
print_warning "Platform helper test failed"
|
||||
fi
|
||||
|
||||
# Installation complete
|
||||
echo ""
|
||||
echo "╔═══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ 🎉 Installation Complete! 🎉 ║"
|
||||
echo "╚═══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
print_success "KiCAD MCP Server is ready to use!"
|
||||
echo ""
|
||||
print_info "Next steps:"
|
||||
echo " 1. Configure Cline in VSCode with the path to dist/index.js"
|
||||
echo " 2. Set PYTHONPATH in Cline config (see README.md)"
|
||||
echo " 3. Restart VSCode"
|
||||
echo " 4. Test with: 'Create a new KiCAD project named TestProject'"
|
||||
echo ""
|
||||
print_info "For detailed configuration, see:"
|
||||
echo " - README.md (Linux section)"
|
||||
echo " - config/linux-config.example.json"
|
||||
echo ""
|
||||
print_info "To run tests:"
|
||||
echo " pytest tests/"
|
||||
echo ""
|
||||
print_info "Need help? Check docs/LINUX_COMPATIBILITY_AUDIT.md"
|
||||
echo ""
|
||||
|
||||
@@ -1,410 +1,410 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
KiCAD MCP Server - Windows Setup and Configuration Script
|
||||
|
||||
.DESCRIPTION
|
||||
This script automates the setup of KiCAD MCP Server on Windows by:
|
||||
- Detecting KiCAD installation and version
|
||||
- Verifying Python and Node.js installations
|
||||
- Testing KiCAD Python module (pcbnew)
|
||||
- Installing required Python dependencies
|
||||
- Building the TypeScript project
|
||||
- Generating Claude Desktop configuration
|
||||
- Running diagnostic tests
|
||||
|
||||
.PARAMETER SkipBuild
|
||||
Skip the npm build step (useful if already built)
|
||||
|
||||
.PARAMETER ClientType
|
||||
Type of MCP client to configure: 'claude-desktop', 'cline', or 'manual'
|
||||
Default: 'claude-desktop'
|
||||
|
||||
.EXAMPLE
|
||||
.\setup-windows.ps1
|
||||
Run the full setup with default options
|
||||
|
||||
.EXAMPLE
|
||||
.\setup-windows.ps1 -ClientType cline
|
||||
Setup for Cline VSCode extension
|
||||
|
||||
.EXAMPLE
|
||||
.\setup-windows.ps1 -SkipBuild
|
||||
Run setup without rebuilding the project
|
||||
#>
|
||||
|
||||
param(
|
||||
[switch]$SkipBuild,
|
||||
[ValidateSet('claude-desktop', 'cline', 'manual')]
|
||||
[string]$ClientType = 'claude-desktop'
|
||||
)
|
||||
|
||||
# Color output helpers
|
||||
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-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-Step { param([string]$Message) Write-Host "`n=== $Message ===" -ForegroundColor Magenta }
|
||||
|
||||
Write-Host @"
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ KiCAD MCP Server - Windows Setup Script ║
|
||||
║ ║
|
||||
║ This script will configure KiCAD MCP for Windows ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
"@ -ForegroundColor Cyan
|
||||
|
||||
# Store results for final report
|
||||
$script:Results = @{
|
||||
KiCADFound = $false
|
||||
KiCADVersion = ""
|
||||
KiCADPythonPath = ""
|
||||
PythonFound = $false
|
||||
PythonVersion = ""
|
||||
NodeFound = $false
|
||||
NodeVersion = ""
|
||||
PcbnewImport = $false
|
||||
DependenciesInstalled = $false
|
||||
ProjectBuilt = $false
|
||||
ConfigGenerated = $false
|
||||
Errors = @()
|
||||
}
|
||||
|
||||
# Get script directory (project root)
|
||||
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
Write-Step "Step 1: Detecting KiCAD Installation"
|
||||
|
||||
# Function to find KiCAD installation
|
||||
function Find-KiCAD {
|
||||
$possiblePaths = @(
|
||||
"C:\Program Files\KiCad",
|
||||
"C:\Program Files (x86)\KiCad"
|
||||
"$env:USERPROFILE\AppData\Local\Programs\KiCad"
|
||||
)
|
||||
|
||||
$versions = @("9.0", "9.1", "10.0", "8.0")
|
||||
|
||||
foreach ($basePath in $possiblePaths) {
|
||||
foreach ($version in $versions) {
|
||||
$kicadPath = Join-Path $basePath $version
|
||||
$pythonExe = Join-Path $kicadPath "bin\python.exe"
|
||||
$pythonLib = Join-Path $kicadPath "lib\python3\dist-packages"
|
||||
|
||||
if (Test-Path $pythonExe) {
|
||||
Write-Success "Found KiCAD $version at: $kicadPath"
|
||||
return @{
|
||||
Path = $kicadPath
|
||||
Version = $version
|
||||
PythonExe = $pythonExe
|
||||
PythonLib = $pythonLib
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$kicad = Find-KiCAD
|
||||
|
||||
if ($kicad) {
|
||||
$script:Results.KiCADFound = $true
|
||||
$script:Results.KiCADVersion = $kicad.Version
|
||||
$script:Results.KiCADPythonPath = $kicad.PythonLib
|
||||
Write-Info "KiCAD Version: $($kicad.Version)"
|
||||
Write-Info "Python Path: $($kicad.PythonLib)"
|
||||
} else {
|
||||
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 "Please install KiCAD 9.0+ from https://www.kicad.org/download/windows/"
|
||||
$script:Results.Errors += "KiCAD not found"
|
||||
}
|
||||
|
||||
Write-Step "Step 2: Checking Node.js Installation"
|
||||
|
||||
try {
|
||||
$nodeVersion = node --version 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "Node.js found: $nodeVersion"
|
||||
$script:Results.NodeFound = $true
|
||||
$script:Results.NodeVersion = $nodeVersion
|
||||
|
||||
# Check if version is 18+
|
||||
$versionNumber = [int]($nodeVersion -replace 'v(\d+)\..*', '$1')
|
||||
if ($versionNumber -lt 18) {
|
||||
Write-Warning-Custom "Node.js version 18+ is recommended (you have $nodeVersion)"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Error-Custom "Node.js not found"
|
||||
Write-Warning-Custom "Please install Node.js 18+ from https://nodejs.org/"
|
||||
$script:Results.Errors += "Node.js not found"
|
||||
}
|
||||
|
||||
Write-Step "Step 3: Testing KiCAD Python Module"
|
||||
|
||||
if ($kicad) {
|
||||
Write-Info "Testing pcbnew module import..."
|
||||
|
||||
$testScript = "import sys; import pcbnew; print(f'SUCCESS:{pcbnew.GetBuildVersion()}')"
|
||||
$result = & $kicad.PythonExe -c $testScript 2>&1
|
||||
|
||||
if ($result -match "SUCCESS:(.+)") {
|
||||
$pcbnewVersion = $matches[1]
|
||||
Write-Success "pcbnew module imported successfully: $pcbnewVersion"
|
||||
$script:Results.PcbnewImport = $true
|
||||
} else {
|
||||
Write-Error-Custom "Failed to import pcbnew module"
|
||||
Write-Warning-Custom "Error: $result"
|
||||
Write-Info "This usually means KiCAD was not installed with Python support"
|
||||
$script:Results.Errors += "pcbnew import failed: $result"
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping pcbnew test (KiCAD not found)"
|
||||
}
|
||||
|
||||
Write-Step "Step 4: Checking Python Installation"
|
||||
|
||||
try {
|
||||
$pythonVersion = python --version 2>&1
|
||||
if ($pythonVersion -match "Python (\d+\.\d+\.\d+)") {
|
||||
Write-Success "System Python found: $pythonVersion"
|
||||
$script:Results.PythonFound = $true
|
||||
$script:Results.PythonVersion = $pythonVersion
|
||||
}
|
||||
} catch {
|
||||
Write-Warning-Custom "System Python not found (using KiCAD's Python)"
|
||||
}
|
||||
|
||||
Write-Step "Step 5: Installing Node.js Dependencies"
|
||||
|
||||
if ($script:Results.NodeFound) {
|
||||
Write-Info "Running npm install..."
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
npm install 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "Node.js dependencies installed"
|
||||
} else {
|
||||
Write-Error-Custom "npm install failed"
|
||||
$script:Results.Errors += "npm install failed"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping npm install (Node.js not found)"
|
||||
}
|
||||
|
||||
Write-Step "Step 6: Installing Python Dependencies"
|
||||
|
||||
if ($kicad) {
|
||||
Write-Info "Installing Python packages..."
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
$requirementsFile = Join-Path $ProjectRoot "requirements.txt"
|
||||
if (Test-Path $requirementsFile) {
|
||||
& $kicad.PythonExe -m pip install -r $requirementsFile 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "Python dependencies installed"
|
||||
$script:Results.DependenciesInstalled = $true
|
||||
} else {
|
||||
Write-Warning-Custom "Some Python packages may have failed to install"
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom "requirements.txt not found"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping Python dependencies (KiCAD Python not found)"
|
||||
}
|
||||
|
||||
Write-Step "Step 7: Building TypeScript Project"
|
||||
|
||||
if (-not $SkipBuild -and $script:Results.NodeFound) {
|
||||
Write-Info "Running npm run build..."
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
npm run build 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$distPath = Join-Path $ProjectRoot "dist\index.js"
|
||||
if (Test-Path $distPath) {
|
||||
Write-Success "Project built successfully"
|
||||
$script:Results.ProjectBuilt = $true
|
||||
} else {
|
||||
Write-Error-Custom "Build completed but dist/index.js not found"
|
||||
$script:Results.Errors += "Build output missing"
|
||||
}
|
||||
} else {
|
||||
Write-Error-Custom "Build failed"
|
||||
$script:Results.Errors += "TypeScript build failed"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
if ($SkipBuild) {
|
||||
Write-Info "Skipping build (--SkipBuild specified)"
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping build (Node.js not found)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Step 8: Generating Configuration"
|
||||
|
||||
if ($kicad -and $script:Results.ProjectBuilt) {
|
||||
$distPath = Join-Path $ProjectRoot "dist\index.js"
|
||||
$distPathEscaped = $distPath -replace '\\', '\\'
|
||||
$pythonLibEscaped = $kicad.PythonLib -replace '\\', '\\'
|
||||
|
||||
$config = @"
|
||||
{
|
||||
"mcpServers": {
|
||||
"kicad": {
|
||||
"command": "node",
|
||||
"args": ["$distPathEscaped"],
|
||||
"env": {
|
||||
"PYTHONPATH": "$pythonLibEscaped",
|
||||
"NODE_ENV": "production",
|
||||
"LOG_LEVEL": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
$configPath = Join-Path $ProjectRoot "windows-mcp-config.json"
|
||||
$config | Out-File -FilePath $configPath -Encoding UTF8
|
||||
Write-Success "Configuration generated: $configPath"
|
||||
$script:Results.ConfigGenerated = $true
|
||||
|
||||
Write-Info "`nConfiguration Preview:"
|
||||
Write-Host $config -ForegroundColor Gray
|
||||
|
||||
# Provide instructions based on client type
|
||||
Write-Info "`nTo use this configuration:"
|
||||
|
||||
if ($ClientType -eq 'claude-desktop') {
|
||||
$claudeConfigPath = "$env:APPDATA\Claude\claude_desktop_config.json"
|
||||
Write-Host "`n1. Open Claude Desktop configuration:" -ForegroundColor Yellow
|
||||
Write-Host " $claudeConfigPath" -ForegroundColor White
|
||||
Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow
|
||||
Write-Host " $configPath" -ForegroundColor White
|
||||
Write-Host "`n3. Restart Claude Desktop" -ForegroundColor Yellow
|
||||
} elseif ($ClientType -eq 'cline') {
|
||||
$clineConfigPath = "$env:APPDATA\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json"
|
||||
Write-Host "`n1. Open Cline configuration:" -ForegroundColor Yellow
|
||||
Write-Host " $clineConfigPath" -ForegroundColor White
|
||||
Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow
|
||||
Write-Host " $configPath" -ForegroundColor White
|
||||
Write-Host "`n3. Restart VSCode" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "`n1. Configuration saved to:" -ForegroundColor Yellow
|
||||
Write-Host " $configPath" -ForegroundColor White
|
||||
Write-Host "`n2. Copy to your MCP client configuration" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping configuration generation (prerequisites not met)"
|
||||
}
|
||||
|
||||
Write-Step "Step 9: Running Diagnostic Test"
|
||||
|
||||
if ($kicad -and $script:Results.ProjectBuilt) {
|
||||
Write-Info "Testing server startup..."
|
||||
|
||||
$env:PYTHONPATH = $kicad.PythonLib
|
||||
$distPath = Join-Path $ProjectRoot "dist\index.js"
|
||||
|
||||
# Start the server process
|
||||
$process = Start-Process -FilePath "node" `
|
||||
-ArgumentList $distPath `
|
||||
-NoNewWindow `
|
||||
-PassThru `
|
||||
-RedirectStandardError (Join-Path $env:TEMP "kicad-mcp-test-error.txt") `
|
||||
-RedirectStandardOutput (Join-Path $env:TEMP "kicad-mcp-test-output.txt")
|
||||
|
||||
# Wait a moment for startup
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
if (-not $process.HasExited) {
|
||||
Write-Success "Server started successfully (PID: $($process.Id))"
|
||||
Write-Info "Stopping test server..."
|
||||
Stop-Process -Id $process.Id -Force
|
||||
} else {
|
||||
Write-Error-Custom "Server exited immediately (exit code: $($process.ExitCode))"
|
||||
|
||||
$errorLog = Join-Path $env:TEMP "kicad-mcp-test-error.txt"
|
||||
if (Test-Path $errorLog) {
|
||||
$errorContent = Get-Content $errorLog -Raw
|
||||
if ($errorContent) {
|
||||
Write-Warning-Custom "Error output:"
|
||||
Write-Host $errorContent -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
$script:Results.Errors += "Server startup test failed"
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping diagnostic test (prerequisites not met)"
|
||||
}
|
||||
|
||||
# Final Report
|
||||
Write-Step "Setup Summary"
|
||||
|
||||
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' })
|
||||
if ($script:Results.KiCADVersion) {
|
||||
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 " Node.js: $(if ($script:Results.NodeFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.NodeFound) { 'Green' } else { 'Red' })
|
||||
if ($script:Results.NodeVersion) {
|
||||
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 " 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' })
|
||||
|
||||
if ($script:Results.Errors.Count -gt 0) {
|
||||
Write-Host "`nErrors Encountered:" -ForegroundColor Red
|
||||
foreach ($error in $script:Results.Errors) {
|
||||
Write-Host " • $error" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
# Check for log file
|
||||
$logPath = "$env:USERPROFILE\.kicad-mcp\logs\kicad_interface.log"
|
||||
if (Test-Path $logPath) {
|
||||
Write-Host "`nLog file location:" -ForegroundColor Cyan
|
||||
Write-Host " $logPath" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# Success criteria
|
||||
$isSuccess = $script:Results.KiCADFound -and
|
||||
$script:Results.PcbnewImport -and
|
||||
$script:Results.NodeFound -and
|
||||
$script:Results.ProjectBuilt
|
||||
|
||||
if ($isSuccess) {
|
||||
Write-Host "`n============================================================" -ForegroundColor Green
|
||||
Write-Host " [OK] Setup completed successfully!" -ForegroundColor Green
|
||||
Write-Host "" -ForegroundColor Green
|
||||
Write-Host " Next steps:" -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 " 3. Try: 'Create a new KiCAD project'" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "`n============================================================" -ForegroundColor Red
|
||||
Write-Host " [ERROR] Setup incomplete - issues detected" -ForegroundColor Red
|
||||
Write-Host "" -ForegroundColor Red
|
||||
Write-Host " Please resolve the errors above and run again" -ForegroundColor Red
|
||||
Write-Host "" -ForegroundColor Red
|
||||
Write-Host " For help:" -ForegroundColor Red
|
||||
Write-Host " https://github.com/mixelpixx/KiCAD-MCP-Server/issues" -ForegroundColor Red
|
||||
Write-Host "============================================================" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
<#
|
||||
.SYNOPSIS
|
||||
KiCAD MCP Server - Windows Setup and Configuration Script
|
||||
|
||||
.DESCRIPTION
|
||||
This script automates the setup of KiCAD MCP Server on Windows by:
|
||||
- Detecting KiCAD installation and version
|
||||
- Verifying Python and Node.js installations
|
||||
- Testing KiCAD Python module (pcbnew)
|
||||
- Installing required Python dependencies
|
||||
- Building the TypeScript project
|
||||
- Generating Claude Desktop configuration
|
||||
- Running diagnostic tests
|
||||
|
||||
.PARAMETER SkipBuild
|
||||
Skip the npm build step (useful if already built)
|
||||
|
||||
.PARAMETER ClientType
|
||||
Type of MCP client to configure: 'claude-desktop', 'cline', or 'manual'
|
||||
Default: 'claude-desktop'
|
||||
|
||||
.EXAMPLE
|
||||
.\setup-windows.ps1
|
||||
Run the full setup with default options
|
||||
|
||||
.EXAMPLE
|
||||
.\setup-windows.ps1 -ClientType cline
|
||||
Setup for Cline VSCode extension
|
||||
|
||||
.EXAMPLE
|
||||
.\setup-windows.ps1 -SkipBuild
|
||||
Run setup without rebuilding the project
|
||||
#>
|
||||
|
||||
param(
|
||||
[switch]$SkipBuild,
|
||||
[ValidateSet('claude-desktop', 'cline', 'manual')]
|
||||
[string]$ClientType = 'claude-desktop'
|
||||
)
|
||||
|
||||
# Color output helpers
|
||||
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-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-Step { param([string]$Message) Write-Host "`n=== $Message ===" -ForegroundColor Magenta }
|
||||
|
||||
Write-Host @"
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ KiCAD MCP Server - Windows Setup Script ║
|
||||
║ ║
|
||||
║ This script will configure KiCAD MCP for Windows ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
"@ -ForegroundColor Cyan
|
||||
|
||||
# Store results for final report
|
||||
$script:Results = @{
|
||||
KiCADFound = $false
|
||||
KiCADVersion = ""
|
||||
KiCADPythonPath = ""
|
||||
PythonFound = $false
|
||||
PythonVersion = ""
|
||||
NodeFound = $false
|
||||
NodeVersion = ""
|
||||
PcbnewImport = $false
|
||||
DependenciesInstalled = $false
|
||||
ProjectBuilt = $false
|
||||
ConfigGenerated = $false
|
||||
Errors = @()
|
||||
}
|
||||
|
||||
# Get script directory (project root)
|
||||
$ProjectRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
|
||||
Write-Step "Step 1: Detecting KiCAD Installation"
|
||||
|
||||
# Function to find KiCAD installation
|
||||
function Find-KiCAD {
|
||||
$possiblePaths = @(
|
||||
"C:\Program Files\KiCad",
|
||||
"C:\Program Files (x86)\KiCad"
|
||||
"$env:USERPROFILE\AppData\Local\Programs\KiCad"
|
||||
)
|
||||
|
||||
$versions = @("9.0", "9.1", "10.0", "8.0")
|
||||
|
||||
foreach ($basePath in $possiblePaths) {
|
||||
foreach ($version in $versions) {
|
||||
$kicadPath = Join-Path $basePath $version
|
||||
$pythonExe = Join-Path $kicadPath "bin\python.exe"
|
||||
$pythonLib = Join-Path $kicadPath "lib\python3\dist-packages"
|
||||
|
||||
if (Test-Path $pythonExe) {
|
||||
Write-Success "Found KiCAD $version at: $kicadPath"
|
||||
return @{
|
||||
Path = $kicadPath
|
||||
Version = $version
|
||||
PythonExe = $pythonExe
|
||||
PythonLib = $pythonLib
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$kicad = Find-KiCAD
|
||||
|
||||
if ($kicad) {
|
||||
$script:Results.KiCADFound = $true
|
||||
$script:Results.KiCADVersion = $kicad.Version
|
||||
$script:Results.KiCADPythonPath = $kicad.PythonLib
|
||||
Write-Info "KiCAD Version: $($kicad.Version)"
|
||||
Write-Info "Python Path: $($kicad.PythonLib)"
|
||||
} else {
|
||||
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 "Please install KiCAD 9.0+ from https://www.kicad.org/download/windows/"
|
||||
$script:Results.Errors += "KiCAD not found"
|
||||
}
|
||||
|
||||
Write-Step "Step 2: Checking Node.js Installation"
|
||||
|
||||
try {
|
||||
$nodeVersion = node --version 2>$null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "Node.js found: $nodeVersion"
|
||||
$script:Results.NodeFound = $true
|
||||
$script:Results.NodeVersion = $nodeVersion
|
||||
|
||||
# Check if version is 18+
|
||||
$versionNumber = [int]($nodeVersion -replace 'v(\d+)\..*', '$1')
|
||||
if ($versionNumber -lt 18) {
|
||||
Write-Warning-Custom "Node.js version 18+ is recommended (you have $nodeVersion)"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-Error-Custom "Node.js not found"
|
||||
Write-Warning-Custom "Please install Node.js 18+ from https://nodejs.org/"
|
||||
$script:Results.Errors += "Node.js not found"
|
||||
}
|
||||
|
||||
Write-Step "Step 3: Testing KiCAD Python Module"
|
||||
|
||||
if ($kicad) {
|
||||
Write-Info "Testing pcbnew module import..."
|
||||
|
||||
$testScript = "import sys; import pcbnew; print(f'SUCCESS:{pcbnew.GetBuildVersion()}')"
|
||||
$result = & $kicad.PythonExe -c $testScript 2>&1
|
||||
|
||||
if ($result -match "SUCCESS:(.+)") {
|
||||
$pcbnewVersion = $matches[1]
|
||||
Write-Success "pcbnew module imported successfully: $pcbnewVersion"
|
||||
$script:Results.PcbnewImport = $true
|
||||
} else {
|
||||
Write-Error-Custom "Failed to import pcbnew module"
|
||||
Write-Warning-Custom "Error: $result"
|
||||
Write-Info "This usually means KiCAD was not installed with Python support"
|
||||
$script:Results.Errors += "pcbnew import failed: $result"
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping pcbnew test (KiCAD not found)"
|
||||
}
|
||||
|
||||
Write-Step "Step 4: Checking Python Installation"
|
||||
|
||||
try {
|
||||
$pythonVersion = python --version 2>&1
|
||||
if ($pythonVersion -match "Python (\d+\.\d+\.\d+)") {
|
||||
Write-Success "System Python found: $pythonVersion"
|
||||
$script:Results.PythonFound = $true
|
||||
$script:Results.PythonVersion = $pythonVersion
|
||||
}
|
||||
} catch {
|
||||
Write-Warning-Custom "System Python not found (using KiCAD's Python)"
|
||||
}
|
||||
|
||||
Write-Step "Step 5: Installing Node.js Dependencies"
|
||||
|
||||
if ($script:Results.NodeFound) {
|
||||
Write-Info "Running npm install..."
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
npm install 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "Node.js dependencies installed"
|
||||
} else {
|
||||
Write-Error-Custom "npm install failed"
|
||||
$script:Results.Errors += "npm install failed"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping npm install (Node.js not found)"
|
||||
}
|
||||
|
||||
Write-Step "Step 6: Installing Python Dependencies"
|
||||
|
||||
if ($kicad) {
|
||||
Write-Info "Installing Python packages..."
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
$requirementsFile = Join-Path $ProjectRoot "requirements.txt"
|
||||
if (Test-Path $requirementsFile) {
|
||||
& $kicad.PythonExe -m pip install -r $requirementsFile 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Success "Python dependencies installed"
|
||||
$script:Results.DependenciesInstalled = $true
|
||||
} else {
|
||||
Write-Warning-Custom "Some Python packages may have failed to install"
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom "requirements.txt not found"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping Python dependencies (KiCAD Python not found)"
|
||||
}
|
||||
|
||||
Write-Step "Step 7: Building TypeScript Project"
|
||||
|
||||
if (-not $SkipBuild -and $script:Results.NodeFound) {
|
||||
Write-Info "Running npm run build..."
|
||||
Push-Location $ProjectRoot
|
||||
try {
|
||||
npm run build 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
$distPath = Join-Path $ProjectRoot "dist\index.js"
|
||||
if (Test-Path $distPath) {
|
||||
Write-Success "Project built successfully"
|
||||
$script:Results.ProjectBuilt = $true
|
||||
} else {
|
||||
Write-Error-Custom "Build completed but dist/index.js not found"
|
||||
$script:Results.Errors += "Build output missing"
|
||||
}
|
||||
} else {
|
||||
Write-Error-Custom "Build failed"
|
||||
$script:Results.Errors += "TypeScript build failed"
|
||||
}
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
} else {
|
||||
if ($SkipBuild) {
|
||||
Write-Info "Skipping build (--SkipBuild specified)"
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping build (Node.js not found)"
|
||||
}
|
||||
}
|
||||
|
||||
Write-Step "Step 8: Generating Configuration"
|
||||
|
||||
if ($kicad -and $script:Results.ProjectBuilt) {
|
||||
$distPath = Join-Path $ProjectRoot "dist\index.js"
|
||||
$distPathEscaped = $distPath -replace '\\', '\\'
|
||||
$pythonLibEscaped = $kicad.PythonLib -replace '\\', '\\'
|
||||
|
||||
$config = @"
|
||||
{
|
||||
"mcpServers": {
|
||||
"kicad": {
|
||||
"command": "node",
|
||||
"args": ["$distPathEscaped"],
|
||||
"env": {
|
||||
"PYTHONPATH": "$pythonLibEscaped",
|
||||
"NODE_ENV": "production",
|
||||
"LOG_LEVEL": "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
$configPath = Join-Path $ProjectRoot "windows-mcp-config.json"
|
||||
$config | Out-File -FilePath $configPath -Encoding UTF8
|
||||
Write-Success "Configuration generated: $configPath"
|
||||
$script:Results.ConfigGenerated = $true
|
||||
|
||||
Write-Info "`nConfiguration Preview:"
|
||||
Write-Host $config -ForegroundColor Gray
|
||||
|
||||
# Provide instructions based on client type
|
||||
Write-Info "`nTo use this configuration:"
|
||||
|
||||
if ($ClientType -eq 'claude-desktop') {
|
||||
$claudeConfigPath = "$env:APPDATA\Claude\claude_desktop_config.json"
|
||||
Write-Host "`n1. Open Claude Desktop configuration:" -ForegroundColor Yellow
|
||||
Write-Host " $claudeConfigPath" -ForegroundColor White
|
||||
Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow
|
||||
Write-Host " $configPath" -ForegroundColor White
|
||||
Write-Host "`n3. Restart Claude Desktop" -ForegroundColor Yellow
|
||||
} elseif ($ClientType -eq 'cline') {
|
||||
$clineConfigPath = "$env:APPDATA\Code\User\globalStorage\saoudrizwan.claude-dev\settings\cline_mcp_settings.json"
|
||||
Write-Host "`n1. Open Cline configuration:" -ForegroundColor Yellow
|
||||
Write-Host " $clineConfigPath" -ForegroundColor White
|
||||
Write-Host "`n2. Copy the contents from:" -ForegroundColor Yellow
|
||||
Write-Host " $configPath" -ForegroundColor White
|
||||
Write-Host "`n3. Restart VSCode" -ForegroundColor Yellow
|
||||
} else {
|
||||
Write-Host "`n1. Configuration saved to:" -ForegroundColor Yellow
|
||||
Write-Host " $configPath" -ForegroundColor White
|
||||
Write-Host "`n2. Copy to your MCP client configuration" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping configuration generation (prerequisites not met)"
|
||||
}
|
||||
|
||||
Write-Step "Step 9: Running Diagnostic Test"
|
||||
|
||||
if ($kicad -and $script:Results.ProjectBuilt) {
|
||||
Write-Info "Testing server startup..."
|
||||
|
||||
$env:PYTHONPATH = $kicad.PythonLib
|
||||
$distPath = Join-Path $ProjectRoot "dist\index.js"
|
||||
|
||||
# Start the server process
|
||||
$process = Start-Process -FilePath "node" `
|
||||
-ArgumentList $distPath `
|
||||
-NoNewWindow `
|
||||
-PassThru `
|
||||
-RedirectStandardError (Join-Path $env:TEMP "kicad-mcp-test-error.txt") `
|
||||
-RedirectStandardOutput (Join-Path $env:TEMP "kicad-mcp-test-output.txt")
|
||||
|
||||
# Wait a moment for startup
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
if (-not $process.HasExited) {
|
||||
Write-Success "Server started successfully (PID: $($process.Id))"
|
||||
Write-Info "Stopping test server..."
|
||||
Stop-Process -Id $process.Id -Force
|
||||
} else {
|
||||
Write-Error-Custom "Server exited immediately (exit code: $($process.ExitCode))"
|
||||
|
||||
$errorLog = Join-Path $env:TEMP "kicad-mcp-test-error.txt"
|
||||
if (Test-Path $errorLog) {
|
||||
$errorContent = Get-Content $errorLog -Raw
|
||||
if ($errorContent) {
|
||||
Write-Warning-Custom "Error output:"
|
||||
Write-Host $errorContent -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
$script:Results.Errors += "Server startup test failed"
|
||||
}
|
||||
} else {
|
||||
Write-Warning-Custom "Skipping diagnostic test (prerequisites not met)"
|
||||
}
|
||||
|
||||
# Final Report
|
||||
Write-Step "Setup Summary"
|
||||
|
||||
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' })
|
||||
if ($script:Results.KiCADVersion) {
|
||||
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 " Node.js: $(if ($script:Results.NodeFound) { '[OK] Found' } else { '[ERROR] Not Found' })" -ForegroundColor $(if ($script:Results.NodeFound) { 'Green' } else { 'Red' })
|
||||
if ($script:Results.NodeVersion) {
|
||||
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 " 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' })
|
||||
|
||||
if ($script:Results.Errors.Count -gt 0) {
|
||||
Write-Host "`nErrors Encountered:" -ForegroundColor Red
|
||||
foreach ($error in $script:Results.Errors) {
|
||||
Write-Host " • $error" -ForegroundColor Red
|
||||
}
|
||||
}
|
||||
|
||||
# Check for log file
|
||||
$logPath = "$env:USERPROFILE\.kicad-mcp\logs\kicad_interface.log"
|
||||
if (Test-Path $logPath) {
|
||||
Write-Host "`nLog file location:" -ForegroundColor Cyan
|
||||
Write-Host " $logPath" -ForegroundColor Gray
|
||||
}
|
||||
|
||||
# Success criteria
|
||||
$isSuccess = $script:Results.KiCADFound -and
|
||||
$script:Results.PcbnewImport -and
|
||||
$script:Results.NodeFound -and
|
||||
$script:Results.ProjectBuilt
|
||||
|
||||
if ($isSuccess) {
|
||||
Write-Host "`n============================================================" -ForegroundColor Green
|
||||
Write-Host " [OK] Setup completed successfully!" -ForegroundColor Green
|
||||
Write-Host "" -ForegroundColor Green
|
||||
Write-Host " Next steps:" -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 " 3. Try: 'Create a new KiCAD project'" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "`n============================================================" -ForegroundColor Red
|
||||
Write-Host " [ERROR] Setup incomplete - issues detected" -ForegroundColor Red
|
||||
Write-Host "" -ForegroundColor Red
|
||||
Write-Host " Please resolve the errors above and run again" -ForegroundColor Red
|
||||
Write-Host "" -ForegroundColor Red
|
||||
Write-Host " For help:" -ForegroundColor Red
|
||||
Write-Host " https://github.com/mixelpixx/KiCAD-MCP-Server/issues" -ForegroundColor Red
|
||||
Write-Host "============================================================" -ForegroundColor Red
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Tests for KiCAD MCP Server"""
|
||||
"""Tests for KiCAD MCP Server"""
|
||||
|
||||
@@ -1,193 +1,193 @@
|
||||
"""
|
||||
Tests for platform_helper utility
|
||||
|
||||
These are unit tests that work on all platforms.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add parent directory to path to import utils
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||
|
||||
from utils.platform_helper import PlatformHelper, detect_platform
|
||||
|
||||
|
||||
class TestPlatformDetection:
|
||||
"""Test platform detection functions"""
|
||||
|
||||
def test_exactly_one_platform_detected(self):
|
||||
"""Ensure exactly one platform is detected"""
|
||||
platforms = [
|
||||
PlatformHelper.is_windows(),
|
||||
PlatformHelper.is_linux(),
|
||||
PlatformHelper.is_macos(),
|
||||
]
|
||||
assert sum(platforms) == 1, "Exactly one platform should be detected"
|
||||
|
||||
def test_platform_name_is_valid(self):
|
||||
"""Test platform name is human-readable"""
|
||||
name = PlatformHelper.get_platform_name()
|
||||
assert name in ["Windows", "Linux", "macOS"], f"Unknown platform: {name}"
|
||||
|
||||
def test_platform_name_matches_detection(self):
|
||||
"""Ensure platform name matches detection functions"""
|
||||
name = PlatformHelper.get_platform_name()
|
||||
if name == "Windows":
|
||||
assert PlatformHelper.is_windows()
|
||||
elif name == "Linux":
|
||||
assert PlatformHelper.is_linux()
|
||||
elif name == "macOS":
|
||||
assert PlatformHelper.is_macos()
|
||||
|
||||
|
||||
class TestPathGeneration:
|
||||
"""Test path generation functions"""
|
||||
|
||||
def test_config_dir_exists_after_ensure(self):
|
||||
"""Test that config directory is created"""
|
||||
PlatformHelper.ensure_directories()
|
||||
config_dir = PlatformHelper.get_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}"
|
||||
|
||||
def test_log_dir_exists_after_ensure(self):
|
||||
"""Test that log directory is created"""
|
||||
PlatformHelper.ensure_directories()
|
||||
log_dir = PlatformHelper.get_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}"
|
||||
|
||||
def test_cache_dir_exists_after_ensure(self):
|
||||
"""Test that cache directory is created"""
|
||||
PlatformHelper.ensure_directories()
|
||||
cache_dir = PlatformHelper.get_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}"
|
||||
|
||||
def test_config_dir_is_platform_appropriate(self):
|
||||
"""Test that config directory follows platform conventions"""
|
||||
config_dir = PlatformHelper.get_config_dir()
|
||||
|
||||
if PlatformHelper.is_linux():
|
||||
# Should be ~/.config/kicad-mcp or $XDG_CONFIG_HOME/kicad-mcp
|
||||
if "XDG_CONFIG_HOME" in os.environ:
|
||||
expected = Path(os.environ["XDG_CONFIG_HOME"]) / "kicad-mcp"
|
||||
else:
|
||||
expected = Path.home() / ".config" / "kicad-mcp"
|
||||
assert config_dir == expected
|
||||
|
||||
elif PlatformHelper.is_windows():
|
||||
# Should be %USERPROFILE%\.kicad-mcp
|
||||
expected = Path.home() / ".kicad-mcp"
|
||||
assert config_dir == expected
|
||||
|
||||
elif PlatformHelper.is_macos():
|
||||
# Should be ~/Library/Application Support/kicad-mcp
|
||||
expected = Path.home() / "Library" / "Application Support" / "kicad-mcp"
|
||||
assert config_dir == expected
|
||||
|
||||
def test_config_dir_ignores_relative_xdg_config_home(self, monkeypatch):
|
||||
"""Relative XDG_CONFIG_HOME should be ignored on Linux."""
|
||||
monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True))
|
||||
monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False))
|
||||
monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", "relative/path")
|
||||
|
||||
assert PlatformHelper.get_config_dir() == Path.home() / ".config" / "kicad-mcp"
|
||||
|
||||
def test_cache_dir_ignores_relative_xdg_cache_home(self, monkeypatch):
|
||||
"""Relative XDG_CACHE_HOME should be ignored on Linux."""
|
||||
monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True))
|
||||
monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False))
|
||||
monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False))
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", "relative/cache")
|
||||
|
||||
assert PlatformHelper.get_cache_dir() == Path.home() / ".cache" / "kicad-mcp"
|
||||
|
||||
def test_python_executable_is_valid(self):
|
||||
"""Test that Python executable path is valid"""
|
||||
exe = PlatformHelper.get_python_executable()
|
||||
assert exe.exists(), f"Python executable should exist: {exe}"
|
||||
assert str(exe) == sys.executable
|
||||
|
||||
def test_kicad_library_search_paths_returns_list(self):
|
||||
"""Test that library search paths returns a list"""
|
||||
paths = PlatformHelper.get_kicad_library_search_paths()
|
||||
assert isinstance(paths, list)
|
||||
assert len(paths) > 0
|
||||
# All paths should be strings (glob patterns)
|
||||
assert all(isinstance(p, str) for p in paths)
|
||||
|
||||
|
||||
class TestDetectPlatform:
|
||||
"""Test the detect_platform convenience function"""
|
||||
|
||||
def test_detect_platform_returns_dict(self):
|
||||
"""Test that detect_platform returns a dictionary"""
|
||||
info = detect_platform()
|
||||
assert isinstance(info, dict)
|
||||
|
||||
def test_detect_platform_has_required_keys(self):
|
||||
"""Test that detect_platform includes all required keys"""
|
||||
info = detect_platform()
|
||||
required_keys = [
|
||||
"system",
|
||||
"platform",
|
||||
"is_windows",
|
||||
"is_linux",
|
||||
"is_macos",
|
||||
"python_version",
|
||||
"python_executable",
|
||||
"config_dir",
|
||||
"log_dir",
|
||||
"cache_dir",
|
||||
"kicad_python_paths",
|
||||
]
|
||||
for key in required_keys:
|
||||
assert key in info, f"Missing key: {key}"
|
||||
|
||||
def test_detect_platform_python_version_format(self):
|
||||
"""Test that Python version is in correct format"""
|
||||
info = detect_platform()
|
||||
version = info["python_version"]
|
||||
# Should be like "3.12.3"
|
||||
parts = version.split(".")
|
||||
assert len(parts) == 3
|
||||
assert all(p.isdigit() for p in parts)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestKiCADPathDetection:
|
||||
"""Tests that require KiCAD to be installed"""
|
||||
|
||||
def test_kicad_python_paths_exist(self):
|
||||
"""Test that at least one KiCAD Python path exists (if KiCAD is installed)"""
|
||||
paths = PlatformHelper.get_kicad_python_paths()
|
||||
# This test only makes sense if KiCAD is installed
|
||||
# In CI, KiCAD should be installed
|
||||
if paths:
|
||||
assert all(p.exists() for p in paths), "All returned paths should exist"
|
||||
|
||||
def test_can_import_pcbnew_after_adding_paths(self):
|
||||
"""Test that pcbnew can be imported after adding KiCAD paths"""
|
||||
PlatformHelper.add_kicad_to_python_path()
|
||||
try:
|
||||
import pcbnew
|
||||
|
||||
# If we get here, pcbnew is available
|
||||
assert pcbnew is not None
|
||||
version = pcbnew.GetBuildVersion()
|
||||
assert version is not None
|
||||
print(f"Found KiCAD version: {version}")
|
||||
except ImportError:
|
||||
pytest.skip("KiCAD pcbnew module not available (KiCAD not installed)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests with pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
"""
|
||||
Tests for platform_helper utility
|
||||
|
||||
These are unit tests that work on all platforms.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
# Add parent directory to path to import utils
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||
|
||||
from utils.platform_helper import PlatformHelper, detect_platform
|
||||
|
||||
|
||||
class TestPlatformDetection:
|
||||
"""Test platform detection functions"""
|
||||
|
||||
def test_exactly_one_platform_detected(self):
|
||||
"""Ensure exactly one platform is detected"""
|
||||
platforms = [
|
||||
PlatformHelper.is_windows(),
|
||||
PlatformHelper.is_linux(),
|
||||
PlatformHelper.is_macos(),
|
||||
]
|
||||
assert sum(platforms) == 1, "Exactly one platform should be detected"
|
||||
|
||||
def test_platform_name_is_valid(self):
|
||||
"""Test platform name is human-readable"""
|
||||
name = PlatformHelper.get_platform_name()
|
||||
assert name in ["Windows", "Linux", "macOS"], f"Unknown platform: {name}"
|
||||
|
||||
def test_platform_name_matches_detection(self):
|
||||
"""Ensure platform name matches detection functions"""
|
||||
name = PlatformHelper.get_platform_name()
|
||||
if name == "Windows":
|
||||
assert PlatformHelper.is_windows()
|
||||
elif name == "Linux":
|
||||
assert PlatformHelper.is_linux()
|
||||
elif name == "macOS":
|
||||
assert PlatformHelper.is_macos()
|
||||
|
||||
|
||||
class TestPathGeneration:
|
||||
"""Test path generation functions"""
|
||||
|
||||
def test_config_dir_exists_after_ensure(self):
|
||||
"""Test that config directory is created"""
|
||||
PlatformHelper.ensure_directories()
|
||||
config_dir = PlatformHelper.get_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}"
|
||||
|
||||
def test_log_dir_exists_after_ensure(self):
|
||||
"""Test that log directory is created"""
|
||||
PlatformHelper.ensure_directories()
|
||||
log_dir = PlatformHelper.get_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}"
|
||||
|
||||
def test_cache_dir_exists_after_ensure(self):
|
||||
"""Test that cache directory is created"""
|
||||
PlatformHelper.ensure_directories()
|
||||
cache_dir = PlatformHelper.get_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}"
|
||||
|
||||
def test_config_dir_is_platform_appropriate(self):
|
||||
"""Test that config directory follows platform conventions"""
|
||||
config_dir = PlatformHelper.get_config_dir()
|
||||
|
||||
if PlatformHelper.is_linux():
|
||||
# Should be ~/.config/kicad-mcp or $XDG_CONFIG_HOME/kicad-mcp
|
||||
if "XDG_CONFIG_HOME" in os.environ:
|
||||
expected = Path(os.environ["XDG_CONFIG_HOME"]) / "kicad-mcp"
|
||||
else:
|
||||
expected = Path.home() / ".config" / "kicad-mcp"
|
||||
assert config_dir == expected
|
||||
|
||||
elif PlatformHelper.is_windows():
|
||||
# Should be %USERPROFILE%\.kicad-mcp
|
||||
expected = Path.home() / ".kicad-mcp"
|
||||
assert config_dir == expected
|
||||
|
||||
elif PlatformHelper.is_macos():
|
||||
# Should be ~/Library/Application Support/kicad-mcp
|
||||
expected = Path.home() / "Library" / "Application Support" / "kicad-mcp"
|
||||
assert config_dir == expected
|
||||
|
||||
def test_config_dir_ignores_relative_xdg_config_home(self, monkeypatch):
|
||||
"""Relative XDG_CONFIG_HOME should be ignored on Linux."""
|
||||
monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True))
|
||||
monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False))
|
||||
monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False))
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", "relative/path")
|
||||
|
||||
assert PlatformHelper.get_config_dir() == Path.home() / ".config" / "kicad-mcp"
|
||||
|
||||
def test_cache_dir_ignores_relative_xdg_cache_home(self, monkeypatch):
|
||||
"""Relative XDG_CACHE_HOME should be ignored on Linux."""
|
||||
monkeypatch.setattr(PlatformHelper, "is_linux", staticmethod(lambda: True))
|
||||
monkeypatch.setattr(PlatformHelper, "is_windows", staticmethod(lambda: False))
|
||||
monkeypatch.setattr(PlatformHelper, "is_macos", staticmethod(lambda: False))
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", "relative/cache")
|
||||
|
||||
assert PlatformHelper.get_cache_dir() == Path.home() / ".cache" / "kicad-mcp"
|
||||
|
||||
def test_python_executable_is_valid(self):
|
||||
"""Test that Python executable path is valid"""
|
||||
exe = PlatformHelper.get_python_executable()
|
||||
assert exe.exists(), f"Python executable should exist: {exe}"
|
||||
assert str(exe) == sys.executable
|
||||
|
||||
def test_kicad_library_search_paths_returns_list(self):
|
||||
"""Test that library search paths returns a list"""
|
||||
paths = PlatformHelper.get_kicad_library_search_paths()
|
||||
assert isinstance(paths, list)
|
||||
assert len(paths) > 0
|
||||
# All paths should be strings (glob patterns)
|
||||
assert all(isinstance(p, str) for p in paths)
|
||||
|
||||
|
||||
class TestDetectPlatform:
|
||||
"""Test the detect_platform convenience function"""
|
||||
|
||||
def test_detect_platform_returns_dict(self):
|
||||
"""Test that detect_platform returns a dictionary"""
|
||||
info = detect_platform()
|
||||
assert isinstance(info, dict)
|
||||
|
||||
def test_detect_platform_has_required_keys(self):
|
||||
"""Test that detect_platform includes all required keys"""
|
||||
info = detect_platform()
|
||||
required_keys = [
|
||||
"system",
|
||||
"platform",
|
||||
"is_windows",
|
||||
"is_linux",
|
||||
"is_macos",
|
||||
"python_version",
|
||||
"python_executable",
|
||||
"config_dir",
|
||||
"log_dir",
|
||||
"cache_dir",
|
||||
"kicad_python_paths",
|
||||
]
|
||||
for key in required_keys:
|
||||
assert key in info, f"Missing key: {key}"
|
||||
|
||||
def test_detect_platform_python_version_format(self):
|
||||
"""Test that Python version is in correct format"""
|
||||
info = detect_platform()
|
||||
version = info["python_version"]
|
||||
# Should be like "3.12.3"
|
||||
parts = version.split(".")
|
||||
assert len(parts) == 3
|
||||
assert all(p.isdigit() for p in parts)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestKiCADPathDetection:
|
||||
"""Tests that require KiCAD to be installed"""
|
||||
|
||||
def test_kicad_python_paths_exist(self):
|
||||
"""Test that at least one KiCAD Python path exists (if KiCAD is installed)"""
|
||||
paths = PlatformHelper.get_kicad_python_paths()
|
||||
# This test only makes sense if KiCAD is installed
|
||||
# In CI, KiCAD should be installed
|
||||
if paths:
|
||||
assert all(p.exists() for p in paths), "All returned paths should exist"
|
||||
|
||||
def test_can_import_pcbnew_after_adding_paths(self):
|
||||
"""Test that pcbnew can be imported after adding KiCAD paths"""
|
||||
PlatformHelper.add_kicad_to_python_path()
|
||||
try:
|
||||
import pcbnew
|
||||
|
||||
# If we get here, pcbnew is available
|
||||
assert pcbnew is not None
|
||||
version = pcbnew.GetBuildVersion()
|
||||
assert version is not None
|
||||
print(f"Found KiCAD version: {version}")
|
||||
except ImportError:
|
||||
pytest.skip("KiCAD pcbnew module not available (KiCAD not installed)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run tests with pytest
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
Reference in New Issue
Block a user