feat: Week 1 complete - Linux support + IPC API prep

🎉 Major v2.0 rebuild kickoff - Week 1 accomplished!

## Highlights

### Cross-Platform Support 🌍
-  Linux primary platform (Ubuntu/Debian tested)
-  Windows fully supported
-  macOS experimental support
-  Platform-agnostic path handling (XDG spec)
-  Auto-detection of KiCAD installation

### Infrastructure 🏗️
-  GitHub Actions CI/CD pipeline
-  Pytest framework with 20+ tests
-  Pre-commit hooks (Black, MyPy, ESLint)
-  Automated Linux installation script
-  Enhanced npm scripts

### IPC API Migration Prep 🚀
-  Comprehensive migration plan (30 pages)
-  Backend abstraction layer (800+ lines)
-  Factory pattern with auto-detection
-  SWIG backward compatibility wrapper
-  IPC backend skeleton ready

### Documentation 📚
-  Updated README (Linux installation)
-  CONTRIBUTING.md guide
-  Linux compatibility audit
-  IPC API migration plan
-  Session summaries
-  Platform-specific config templates

## Files Changed

- 27 files created
- ~3,000 lines of code/docs
- 8 comprehensive documentation pages
- 20+ unit tests
- 5 abstraction layer modules

## Next Steps

- Week 2: IPC API migration (project.py → component.py → routing.py)
- Migrate from deprecated SWIG to official IPC API
- JLCPCB/Digikey integration prep

🤖 Generated with Claude Code
https://claude.com/claude-code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
KiCAD MCP Bot
2025-10-25 20:48:00 -04:00
commit e4c7119c51
81 changed files with 16003 additions and 0 deletions

View File

@@ -0,0 +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.factory import create_backend
from kicad_api.base import KiCADBackend
__all__ = ['create_backend', 'KiCADBackend']
__version__ = '2.0.0-alpha.1'

204
python/kicad_api/base.py Normal file
View File

@@ -0,0 +1,204 @@
"""
Abstract base class for KiCAD API backends
Defines the interface that all KiCAD backends must implement.
"""
from abc import ABC, abstractmethod
from pathlib import Path
from typing import Optional, Dict, Any, List
import logging
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, float]:
"""
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"
) -> 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
# Add more abstract methods for routing, DRC, export, etc.
# These will be filled in during migration
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

198
python/kicad_api/factory.py Normal file
View File

@@ -0,0 +1,198 @@
"""
Backend factory for creating appropriate KiCAD API backend
Auto-detects available backends and provides fallback mechanism.
"""
import os
import logging
from typing import Optional
from pathlib import Path
from kicad_api.base import KiCADBackend, APINotAvailableError
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
try:
import kicad
results['ipc'] = {
'available': True,
'version': getattr(kicad, '__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}")

View File

@@ -0,0 +1,195 @@
"""
IPC API Backend (KiCAD 9.0+)
Uses the official kicad-python library for inter-process communication
with a running KiCAD instance.
Note: Requires KiCAD to be running with IPC server enabled:
Preferences > Plugins > Enable IPC API Server
"""
import logging
from pathlib import Path
from typing import Optional, Dict, Any, List
from kicad_api.base import (
KiCADBackend,
BoardAPI,
ConnectionError,
APINotAvailableError
)
logger = logging.getLogger(__name__)
class IPCBackend(KiCADBackend):
"""
KiCAD IPC API backend
Communicates with KiCAD via Protocol Buffers over UNIX sockets.
Requires KiCAD 9.0+ to be running with IPC enabled.
"""
def __init__(self):
self.kicad = None
self._connected = False
def connect(self) -> bool:
"""
Connect to running KiCAD instance via IPC
Returns:
True if connection successful
Raises:
ConnectionError: If connection fails
"""
try:
# Import here to allow module to load even without kicad-python
from kicad import KiCad
logger.info("Connecting to KiCAD via IPC...")
self.kicad = KiCad()
# Verify connection with version check
version = self.get_version()
logger.info(f"✓ Connected to KiCAD {version} via IPC")
self._connected = True
return True
except ImportError as e:
logger.error("kicad-python library not found")
raise APINotAvailableError(
"IPC backend requires kicad-python. "
"Install with: pip install kicad-python"
) from e
except Exception as e:
logger.error(f"Failed to connect via IPC: {e}")
logger.info(
"Ensure KiCAD is running with IPC enabled: "
"Preferences > Plugins > Enable IPC API Server"
)
raise ConnectionError(f"IPC connection failed: {e}") from e
def disconnect(self) -> None:
"""Disconnect from KiCAD"""
if self.kicad:
# kicad-python handles cleanup automatically
self.kicad = None
self._connected = False
logger.info("Disconnected from KiCAD IPC")
def is_connected(self) -> bool:
"""Check if connected"""
return self._connected and self.kicad is not None
def get_version(self) -> str:
"""Get KiCAD version"""
if not self.kicad:
raise ConnectionError("Not connected to KiCAD")
try:
# Use kicad-python's version checking
version_info = self.kicad.check_version()
return str(version_info)
except Exception as e:
logger.warning(f"Could not get version: {e}")
return "unknown"
# Project Operations
def create_project(self, path: Path, name: str) -> Dict[str, Any]:
"""
Create a new KiCAD project
TODO: Implement with IPC API
"""
if not self.is_connected():
raise ConnectionError("Not connected to KiCAD")
logger.warning("create_project not yet implemented for IPC backend")
raise NotImplementedError(
"Project creation via IPC API is not yet implemented. "
"This will be added in Week 2-3 migration."
)
def open_project(self, path: Path) -> Dict[str, Any]:
"""Open existing project"""
if not self.is_connected():
raise ConnectionError("Not connected to KiCAD")
logger.warning("open_project not yet implemented for IPC backend")
raise NotImplementedError("Coming in Week 2-3 migration")
def save_project(self, path: Optional[Path] = None) -> Dict[str, Any]:
"""Save current project"""
if not self.is_connected():
raise ConnectionError("Not connected to KiCAD")
logger.warning("save_project not yet implemented for IPC backend")
raise NotImplementedError("Coming in Week 2-3 migration")
def close_project(self) -> None:
"""Close current project"""
if not self.is_connected():
raise ConnectionError("Not connected to KiCAD")
logger.warning("close_project not yet implemented for IPC backend")
raise NotImplementedError("Coming in Week 2-3 migration")
# Board Operations
def get_board(self) -> BoardAPI:
"""Get board API"""
if not self.is_connected():
raise ConnectionError("Not connected to KiCAD")
return IPCBoardAPI(self.kicad)
class IPCBoardAPI(BoardAPI):
"""Board API implementation for IPC backend"""
def __init__(self, kicad_instance):
self.kicad = kicad_instance
self._board = None
def _get_board(self):
"""Lazy-load board instance"""
if self._board is None:
self._board = self.kicad.get_board()
return self._board
def set_size(self, width: float, height: float, unit: str = "mm") -> bool:
"""Set board size"""
logger.warning("set_size not yet implemented for IPC backend")
raise NotImplementedError("Coming in Week 2-3 migration")
def get_size(self) -> Dict[str, float]:
"""Get board size"""
logger.warning("get_size not yet implemented for IPC backend")
raise NotImplementedError("Coming in Week 2-3 migration")
def add_layer(self, layer_name: str, layer_type: str) -> bool:
"""Add layer"""
logger.warning("add_layer not yet implemented for IPC backend")
raise NotImplementedError("Coming in Week 2-3 migration")
def list_components(self) -> List[Dict[str, Any]]:
"""List components"""
logger.warning("list_components not yet implemented for IPC backend")
raise NotImplementedError("Coming in Week 2-3 migration")
def place_component(
self,
reference: str,
footprint: str,
x: float,
y: float,
rotation: float = 0,
layer: str = "F.Cu"
) -> bool:
"""Place component"""
logger.warning("place_component not yet implemented for IPC backend")
raise NotImplementedError("Coming in Week 2-3 migration")
# Note: Full implementation will be completed during Week 2-3 migration
# This is a skeleton to establish the pattern

View File

@@ -0,0 +1,214 @@
"""
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 Optional, Dict, Any, List
from kicad_api.base import (
KiCADBackend,
BoardAPI,
ConnectionError,
APINotAvailableError
)
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):
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(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:
path_str = str(path) if path else None
result = ProjectCommands.save_project(path_str)
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):
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.set_board_size(width, height, unit)
return result.get("success", False)
except Exception as e:
logger.error(f"Failed to set board size: {e}")
return False
def get_size(self) -> Dict[str, float]:
"""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.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"
) -> bool:
"""Place component using existing implementation"""
from commands.component import ComponentCommands
try:
result = ComponentCommands.place_component(
component_id=footprint,
position={"x": x, "y": y, "unit": "mm"},
reference=reference,
rotation=rotation,
layer=layer
)
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.