""" Library management for KiCAD footprints Handles parsing fp-lib-table files, discovering footprints, and providing search functionality for component placement. """ import glob import logging import os import re from pathlib import Path from typing import Dict, List, Optional, Tuple 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) -> None: """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" / "10.0" / "fp-lib-table", Path.home() / ".config" / "kicad" / "9.0" / "fp-lib-table", Path.home() / ".config" / "kicad" / "8.0" / "fp-lib-table", Path.home() / ".config" / "kicad" / "fp-lib-table", # Windows paths Path.home() / "AppData" / "Roaming" / "kicad" / "10.0" / "fp-lib-table", Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "fp-lib-table", Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "fp-lib-table", # macOS paths Path.home() / "Library" / "Preferences" / "kicad" / "10.0" / "fp-lib-table", Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "fp-lib-table", Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "fp-lib-table", ] for path in kicad_config_paths: if path.exists(): return path return None def _parse_fp_lib_table(self, table_path: Path) -> None: """ 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. Name, type, and URI # may be quoted or bare; quoted URIs can contain spaces. lib_pattern = ( r"\(lib\s+" r'\(name\s+(?:"([^"]+)"|([^"\)\s]+))\)\s*' r'\(type\s+(?:"([^"]+)"|([^"\)\s]+))\)\s*' r'\(uri\s+(?:"([^"]+)"|([^"\)\s]+))' ) for match in re.finditer(lib_pattern, content, re.IGNORECASE): nickname = match.group(1) or match.group(2) lib_type = match.group(3) or match.group(4) uri = match.group(5) or match.group(6) if lib_type.lower() == "table": table_uri = uri if os.path.isabs(table_uri) and os.path.isfile(table_uri): logger.info(f" Following Table reference: {nickname} -> {table_uri}") self._parse_fp_lib_table(Path(table_uri)) else: logger.warning(f" Could not resolve Table URI: {table_uri}") continue # Resolve environment variables in URI resolved_uri = self._resolve_uri(uri) 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 = { "KICAD10_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), "KICAD9_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), "KICAD8_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), "KICAD_FOOTPRINT_DIR": self._find_kicad_footprint_dir(), "KISYSMOD": self._find_kicad_footprint_dir(), "KICAD10_3RD_PARTY": self._find_kicad_3rdparty_dir(), "KICAD9_3RD_PARTY": self._find_kicad_3rdparty_dir(), "KICAD8_3RD_PARTY": self._find_kicad_3rdparty_dir(), "KICAD_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 for var in ("KICAD10_3RD_PARTY", "KICAD9_3RD_PARTY", "KICAD8_3RD_PARTY", "KICAD_3RD_PARTY"): if var in os.environ: path = os.environ[var] 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") or params.get("library_name") 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_name") 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 # Minimal info — always returned even if the parser fails info: Dict = { "library": library_nickname, "name": footprint_name, "full_name": f"{library_nickname}:{footprint_name}", "library_path": library_path, } # Attempt to enrich with parsed .kicad_mod data try: from pathlib import Path as _Path from parsers.kicad_mod_parser import parse_kicad_mod mod_file = str(_Path(library_path) / f"{footprint_name}.kicad_mod") parsed = parse_kicad_mod(mod_file) if parsed: # Merge parser output into info; keep our resolved library context info.update(parsed) info["name"] = footprint_name # entry name wins over in-file name info["library"] = library_nickname info["full_name"] = f"{library_nickname}:{footprint_name}" info["library_path"] = library_path else: logger.warning( f"get_footprint_info: parser returned nothing for {mod_file}, using minimal info" ) except Exception as parse_err: logger.warning( f"get_footprint_info: parser error ({parse_err}), using minimal info" ) return {"success": True, "info": info} except Exception as e: logger.error(f"Error getting footprint info: {e}") return { "success": False, "message": "Failed to get footprint info", "errorDetails": str(e), }