New MCP tools added (TypeScript layer): - Routing: delete_trace, query_traces, get_nets_list, modify_trace, create_netclass, route_differential_pair, refill_zones (with SWIG warning) - Component: get_component_pads, get_component_list, get_pad_position, place_component_array, align_components, duplicate_component Bug fixes: - routing.py: Fix SwigPyObject UUID comparison (str() -> m_Uuid.AsString()) - routing.py: Fix SWIG iterator invalidation after board.Remove() by converting board.Tracks() to list() before iteration - routing.py: Add board.SetModified() and clear Python refs after Remove() to prevent dangling SWIG pointers crashing subsequent calls - routing.py: Wrap each track access in try/except in query_traces() to gracefully skip invalid track objects after bulk delete - routing.py: Add missing return statement (mypy fix) - library.py: Fix search_footprints param mapping search_term->pattern - library.py: Fix fp.name -> fp.full_name field access - library.py: Accept both 'pattern' and 'search_term' parameter names - library.py: Add library filter support in search_footprints - library.py: Fix loop variable shadowing Path object (mypy fix) - design_rules.py: Add type annotation for violation_counts (mypy fix) Contribution guidelines followed (CONTRIBUTING.md): - black python/ applied (3 touched files pass without changes) - mypy python/ passes with 0 errors on all changed files - npx prettier --write applied to changed TypeScript files - npm run build passes (tsc --noEmit: 0 errors) - Commit messages follow feat:/fix: convention Note on diff size: The large insertion/deletion count in the changed files is due to black and prettier reformatting previously unformatted code (missing trailing commas, quote style, line length). The actual logic changes are limited to the 6 files listed above. Note: refill_zones has known SWIG segfault risk (see KNOWN_ISSUES.md). Prefer IPC backend (KiCAD open) or zone fill via KiCAD UI.
532 lines
19 KiB
Python
532 lines
19 KiB
Python
"""
|
|
Library management for KiCAD footprints
|
|
|
|
Handles parsing fp-lib-table files, discovering footprints,
|
|
and providing search functionality for component placement.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional, Tuple
|
|
import glob
|
|
|
|
logger = logging.getLogger("kicad_interface")
|
|
|
|
|
|
class LibraryManager:
|
|
"""
|
|
Manages KiCAD footprint libraries
|
|
|
|
Parses fp-lib-table files (both global and project-specific),
|
|
indexes available footprints, and provides search functionality.
|
|
"""
|
|
|
|
def __init__(self, project_path: Optional[Path] = None):
|
|
"""
|
|
Initialize library manager
|
|
|
|
Args:
|
|
project_path: Optional path to project directory for project-specific libraries
|
|
"""
|
|
self.project_path = project_path
|
|
self.libraries: Dict[str, str] = {} # nickname -> path mapping
|
|
self.footprint_cache: Dict[str, List[str]] = {} # library -> [footprint names]
|
|
self._load_libraries()
|
|
|
|
def _load_libraries(self):
|
|
"""Load libraries from fp-lib-table files"""
|
|
# Load global libraries
|
|
global_table = self._get_global_fp_lib_table()
|
|
if global_table and global_table.exists():
|
|
logger.info(f"Loading global fp-lib-table from: {global_table}")
|
|
self._parse_fp_lib_table(global_table)
|
|
else:
|
|
logger.warning(f"Global fp-lib-table not found at: {global_table}")
|
|
|
|
# Load project-specific libraries if project path provided
|
|
if self.project_path:
|
|
project_table = self.project_path / "fp-lib-table"
|
|
if project_table.exists():
|
|
logger.info(f"Loading project fp-lib-table from: {project_table}")
|
|
self._parse_fp_lib_table(project_table)
|
|
|
|
logger.info(f"Loaded {len(self.libraries)} footprint libraries")
|
|
|
|
def _get_global_fp_lib_table(self) -> Optional[Path]:
|
|
"""Get path to global fp-lib-table file"""
|
|
# Try different possible locations
|
|
kicad_config_paths = [
|
|
Path.home() / ".config" / "kicad" / "9.0" / "fp-lib-table",
|
|
Path.home() / ".config" / "kicad" / "8.0" / "fp-lib-table",
|
|
Path.home() / ".config" / "kicad" / "fp-lib-table",
|
|
# Windows paths
|
|
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "fp-lib-table",
|
|
Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "fp-lib-table",
|
|
# macOS paths
|
|
Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "fp-lib-table",
|
|
Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "fp-lib-table",
|
|
]
|
|
|
|
for path in kicad_config_paths:
|
|
if path.exists():
|
|
return path
|
|
|
|
return None
|
|
|
|
def _parse_fp_lib_table(self, table_path: Path):
|
|
"""
|
|
Parse fp-lib-table file
|
|
|
|
Format is S-expression (Lisp-like):
|
|
(fp_lib_table
|
|
(lib (name "Library_Name")(type KiCad)(uri "${KICAD9_FOOTPRINT_DIR}/Library.pretty")(options "")(descr "Description"))
|
|
)
|
|
"""
|
|
try:
|
|
with open(table_path, "r") as f:
|
|
content = f.read()
|
|
|
|
# Simple regex-based parser for lib entries
|
|
# Pattern: (lib (name "NAME")(type TYPE)(uri "URI")...)
|
|
lib_pattern = r'\(lib\s+\(name\s+"?([^")\s]+)"?\)\s*\(type\s+[^)]+\)\s*\(uri\s+"?([^")\s]+)"?'
|
|
|
|
for match in re.finditer(lib_pattern, content, re.IGNORECASE):
|
|
nickname = match.group(1)
|
|
uri = match.group(2)
|
|
|
|
# Resolve environment variables in URI
|
|
resolved_uri = self._resolve_uri(uri)
|
|
|
|
if resolved_uri:
|
|
self.libraries[nickname] = resolved_uri
|
|
logger.debug(f" Found library: {nickname} -> {resolved_uri}")
|
|
else:
|
|
logger.warning(
|
|
f" Could not resolve URI for library {nickname}: {uri}"
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error parsing fp-lib-table at {table_path}: {e}")
|
|
|
|
def _resolve_uri(self, uri: str) -> Optional[str]:
|
|
"""
|
|
Resolve environment variables and paths in library URI
|
|
|
|
Handles:
|
|
- ${KICAD9_FOOTPRINT_DIR} -> /usr/share/kicad/footprints
|
|
- ${KICAD8_FOOTPRINT_DIR} -> /usr/share/kicad/footprints
|
|
- ${KIPRJMOD} -> project directory
|
|
- Relative paths
|
|
- Absolute paths
|
|
"""
|
|
# Replace environment variables
|
|
resolved = uri
|
|
|
|
# Common KiCAD environment variables
|
|
env_vars = {
|
|
"KICAD9_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
|
|
"KICAD8_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
|
|
"KICAD_FOOTPRINT_DIR": self._find_kicad_footprint_dir(),
|
|
"KISYSMOD": self._find_kicad_footprint_dir(),
|
|
"KICAD9_3RD_PARTY": self._find_kicad_3rdparty_dir(),
|
|
"KICAD8_3RD_PARTY": self._find_kicad_3rdparty_dir(),
|
|
}
|
|
|
|
# Project directory
|
|
if self.project_path:
|
|
env_vars["KIPRJMOD"] = str(self.project_path)
|
|
|
|
# Replace environment variables
|
|
for var, value in env_vars.items():
|
|
if value:
|
|
resolved = resolved.replace(f"${{{var}}}", value)
|
|
resolved = resolved.replace(f"${var}", value)
|
|
|
|
# Expand ~ to home directory
|
|
resolved = os.path.expanduser(resolved)
|
|
|
|
# Convert to absolute path
|
|
path = Path(resolved)
|
|
|
|
# Check if path exists
|
|
if path.exists():
|
|
return str(path)
|
|
else:
|
|
logger.debug(f" Path does not exist: {path}")
|
|
return None
|
|
|
|
def _find_kicad_footprint_dir(self) -> Optional[str]:
|
|
"""Find KiCAD footprint directory"""
|
|
# Try common locations
|
|
possible_paths = [
|
|
"/usr/share/kicad/footprints",
|
|
"/usr/local/share/kicad/footprints",
|
|
"C:/Program Files/KiCad/9.0/share/kicad/footprints",
|
|
"C:/Program Files/KiCad/8.0/share/kicad/footprints",
|
|
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/footprints",
|
|
]
|
|
|
|
# Also check environment variable
|
|
if "KICAD9_FOOTPRINT_DIR" in os.environ:
|
|
possible_paths.insert(0, os.environ["KICAD9_FOOTPRINT_DIR"])
|
|
if "KICAD8_FOOTPRINT_DIR" in os.environ:
|
|
possible_paths.insert(0, os.environ["KICAD8_FOOTPRINT_DIR"])
|
|
|
|
for path in possible_paths:
|
|
if os.path.isdir(path):
|
|
return path
|
|
|
|
return None
|
|
|
|
def _find_kicad_3rdparty_dir(self) -> Optional[str]:
|
|
"""
|
|
Find KiCAD 3rd party libraries directory.
|
|
|
|
Resolution order:
|
|
1. Shell environment variable KICAD9_3RD_PARTY
|
|
2. User settings in kicad_common.json
|
|
3. Platform-specific defaults based on detected KiCad version
|
|
"""
|
|
import json
|
|
|
|
# 1. Check shell environment variable first
|
|
if "KICAD9_3RD_PARTY" in os.environ:
|
|
path = os.environ["KICAD9_3RD_PARTY"]
|
|
if os.path.isdir(path):
|
|
return path
|
|
|
|
# 2. Check kicad_common.json for user-defined variables
|
|
kicad_common_paths = [
|
|
Path.home()
|
|
/ "Library"
|
|
/ "Preferences"
|
|
/ "kicad"
|
|
/ "9.0"
|
|
/ "kicad_common.json", # macOS
|
|
Path.home() / ".config" / "kicad" / "9.0" / "kicad_common.json", # Linux
|
|
Path.home()
|
|
/ "AppData"
|
|
/ "Roaming"
|
|
/ "kicad"
|
|
/ "9.0"
|
|
/ "kicad_common.json", # Windows
|
|
]
|
|
|
|
for config_path in kicad_common_paths:
|
|
if config_path.exists():
|
|
try:
|
|
with open(config_path, "r") as f:
|
|
config = json.load(f)
|
|
env_vars = config.get("environment", {}).get("vars", {})
|
|
if env_vars and "KICAD9_3RD_PARTY" in env_vars:
|
|
path = env_vars["KICAD9_3RD_PARTY"]
|
|
if os.path.isdir(path):
|
|
return path
|
|
except (json.JSONDecodeError, KeyError, TypeError):
|
|
pass
|
|
|
|
# Derive version from config path location
|
|
version = config_path.parent.name # e.g., "9.0"
|
|
break
|
|
else:
|
|
version = "9.0" # Default
|
|
|
|
# 3. Use platform-specific defaults
|
|
possible_paths = [
|
|
# macOS - Documents/KiCad/{version}/3rdparty
|
|
Path.home() / "Documents" / "KiCad" / version / "3rdparty",
|
|
# Linux - ~/.local/share/kicad/{version}/3rdparty
|
|
Path.home() / ".local" / "share" / "kicad" / version / "3rdparty",
|
|
# Windows - Documents/KiCad/{version}/3rdparty
|
|
Path.home() / "Documents" / "KiCad" / version / "3rdparty",
|
|
]
|
|
|
|
for candidate in possible_paths:
|
|
if candidate.exists():
|
|
logger.info(f"Found KiCad 3rd party directory: {candidate}")
|
|
return str(candidate)
|
|
|
|
logger.warning("Could not find KiCad 3rd party directory")
|
|
return None
|
|
|
|
def list_libraries(self) -> List[str]:
|
|
"""Get list of available library nicknames"""
|
|
return list(self.libraries.keys())
|
|
|
|
def get_library_path(self, nickname: str) -> Optional[str]:
|
|
"""Get filesystem path for a library nickname"""
|
|
return self.libraries.get(nickname)
|
|
|
|
def list_footprints(self, library_nickname: str) -> List[str]:
|
|
"""
|
|
List all footprints in a library
|
|
|
|
Args:
|
|
library_nickname: Library name (e.g., "Resistor_SMD")
|
|
|
|
Returns:
|
|
List of footprint names (without .kicad_mod extension)
|
|
"""
|
|
# Check cache first
|
|
if library_nickname in self.footprint_cache:
|
|
return self.footprint_cache[library_nickname]
|
|
|
|
library_path = self.libraries.get(library_nickname)
|
|
if not library_path:
|
|
logger.warning(f"Library not found: {library_nickname}")
|
|
return []
|
|
|
|
try:
|
|
footprints = []
|
|
lib_dir = Path(library_path)
|
|
|
|
# List all .kicad_mod files
|
|
for fp_file in lib_dir.glob("*.kicad_mod"):
|
|
# Remove .kicad_mod extension
|
|
footprint_name = fp_file.stem
|
|
footprints.append(footprint_name)
|
|
|
|
# Cache the results
|
|
self.footprint_cache[library_nickname] = footprints
|
|
logger.debug(f"Found {len(footprints)} footprints in {library_nickname}")
|
|
|
|
return footprints
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error listing footprints in {library_nickname}: {e}")
|
|
return []
|
|
|
|
def find_footprint(self, footprint_spec: str) -> Optional[Tuple[str, str]]:
|
|
"""
|
|
Find a footprint by specification
|
|
|
|
Supports multiple formats:
|
|
- "Library:Footprint" (e.g., "Resistor_SMD:R_0603_1608Metric")
|
|
- "Footprint" (searches all libraries)
|
|
|
|
Args:
|
|
footprint_spec: Footprint specification
|
|
|
|
Returns:
|
|
Tuple of (library_path, footprint_name) or None if not found
|
|
"""
|
|
# Parse specification
|
|
if ":" in footprint_spec:
|
|
# Format: Library:Footprint
|
|
library_nickname, footprint_name = footprint_spec.split(":", 1)
|
|
library_path = self.libraries.get(library_nickname)
|
|
|
|
if not library_path:
|
|
logger.warning(f"Library not found: {library_nickname}")
|
|
return None
|
|
|
|
# Check if footprint exists
|
|
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
|
|
if fp_file.exists():
|
|
return (library_path, footprint_name)
|
|
else:
|
|
logger.warning(f"Footprint not found: {footprint_spec}")
|
|
return None
|
|
else:
|
|
# Format: Footprint (search all libraries)
|
|
footprint_name = footprint_spec
|
|
|
|
# Search in all libraries
|
|
for library_nickname, library_path in self.libraries.items():
|
|
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
|
|
if fp_file.exists():
|
|
logger.info(
|
|
f"Found footprint {footprint_name} in library {library_nickname}"
|
|
)
|
|
return (library_path, footprint_name)
|
|
|
|
logger.warning(f"Footprint not found in any library: {footprint_name}")
|
|
return None
|
|
|
|
def search_footprints(self, pattern: str, limit: int = 20) -> List[Dict[str, str]]:
|
|
"""
|
|
Search for footprints matching a pattern
|
|
|
|
Args:
|
|
pattern: Search pattern (supports wildcards *, case-insensitive)
|
|
limit: Maximum number of results to return
|
|
|
|
Returns:
|
|
List of dicts with 'library', 'footprint', and 'full_name' keys
|
|
"""
|
|
results = []
|
|
pattern_lower = pattern.lower()
|
|
|
|
# Convert wildcards to regex
|
|
regex_pattern = pattern_lower.replace("*", ".*")
|
|
regex = re.compile(regex_pattern)
|
|
|
|
for library_nickname in self.libraries.keys():
|
|
footprints = self.list_footprints(library_nickname)
|
|
|
|
for footprint in footprints:
|
|
if regex.search(footprint.lower()):
|
|
results.append(
|
|
{
|
|
"library": library_nickname,
|
|
"footprint": footprint,
|
|
"full_name": f"{library_nickname}:{footprint}",
|
|
}
|
|
)
|
|
|
|
if len(results) >= limit:
|
|
return results
|
|
|
|
return results
|
|
|
|
def get_footprint_info(
|
|
self, library_nickname: str, footprint_name: str
|
|
) -> Optional[Dict[str, str]]:
|
|
"""
|
|
Get information about a specific footprint
|
|
|
|
Args:
|
|
library_nickname: Library name
|
|
footprint_name: Footprint name
|
|
|
|
Returns:
|
|
Dict with footprint information or None if not found
|
|
"""
|
|
library_path = self.libraries.get(library_nickname)
|
|
if not library_path:
|
|
return None
|
|
|
|
fp_file = Path(library_path) / f"{footprint_name}.kicad_mod"
|
|
if not fp_file.exists():
|
|
return None
|
|
|
|
return {
|
|
"library": library_nickname,
|
|
"footprint": footprint_name,
|
|
"full_name": f"{library_nickname}:{footprint_name}",
|
|
"path": str(fp_file),
|
|
"library_path": library_path,
|
|
}
|
|
|
|
|
|
class LibraryCommands:
|
|
"""Command handlers for library operations"""
|
|
|
|
def __init__(self, library_manager: Optional[LibraryManager] = None):
|
|
"""Initialize with optional library manager"""
|
|
self.library_manager = library_manager or LibraryManager()
|
|
|
|
def list_libraries(self, params: Dict) -> Dict:
|
|
"""List all available footprint libraries"""
|
|
try:
|
|
libraries = self.library_manager.list_libraries()
|
|
return {"success": True, "libraries": libraries, "count": len(libraries)}
|
|
except Exception as e:
|
|
logger.error(f"Error listing libraries: {e}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to list libraries",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def search_footprints(self, params: Dict) -> Dict:
|
|
"""Search for footprints by pattern"""
|
|
try:
|
|
# Support both 'pattern' and 'search_term' parameter names
|
|
pattern = params.get("pattern") or params.get("search_term", "*")
|
|
limit = params.get("limit", 20)
|
|
library_filter = params.get("library")
|
|
|
|
results = self.library_manager.search_footprints(
|
|
pattern, limit * 10 if library_filter else limit
|
|
)
|
|
|
|
# Filter by library if specified
|
|
if library_filter:
|
|
results = [
|
|
r
|
|
for r in results
|
|
if r.get("library", "").lower() == library_filter.lower()
|
|
]
|
|
results = results[:limit]
|
|
|
|
return {
|
|
"success": True,
|
|
"footprints": results,
|
|
"count": len(results),
|
|
"pattern": pattern,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error searching footprints: {e}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to search footprints",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def list_library_footprints(self, params: Dict) -> Dict:
|
|
"""List all footprints in a specific library"""
|
|
try:
|
|
library = params.get("library")
|
|
if not library:
|
|
return {"success": False, "message": "Missing library parameter"}
|
|
|
|
footprints = self.library_manager.list_footprints(library)
|
|
|
|
return {
|
|
"success": True,
|
|
"library": library,
|
|
"footprints": footprints,
|
|
"count": len(footprints),
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error listing library footprints: {e}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to list library footprints",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def get_footprint_info(self, params: Dict) -> Dict:
|
|
"""Get information about a specific footprint"""
|
|
try:
|
|
footprint_spec = params.get("footprint")
|
|
if not footprint_spec:
|
|
return {"success": False, "message": "Missing footprint parameter"}
|
|
|
|
# Try to find the footprint
|
|
result = self.library_manager.find_footprint(footprint_spec)
|
|
|
|
if result:
|
|
library_path, footprint_name = result
|
|
# Extract library nickname from path
|
|
library_nickname = None
|
|
for nick, path in self.library_manager.libraries.items():
|
|
if path == library_path:
|
|
library_nickname = nick
|
|
break
|
|
|
|
info = {
|
|
"library": library_nickname,
|
|
"footprint": footprint_name,
|
|
"full_name": f"{library_nickname}:{footprint_name}",
|
|
"library_path": library_path,
|
|
}
|
|
|
|
return {"success": True, "footprint_info": info}
|
|
else:
|
|
return {
|
|
"success": False,
|
|
"message": f"Footprint not found: {footprint_spec}",
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting footprint info: {e}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to get footprint info",
|
|
"errorDetails": str(e),
|
|
}
|