Some checks failed
CI/CD Pipeline / TypeScript Build & Test (20.x, macos-latest) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (20.x, ubuntu-22.04) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (20.x, ubuntu-24.04) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (20.x, windows-latest) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (22.x, macos-latest) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (22.x, ubuntu-22.04) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (22.x, ubuntu-24.04) (push) Has been cancelled
CI/CD Pipeline / TypeScript Build & Test (22.x, windows-latest) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-22.04, 3.10) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-22.04, 3.11) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-22.04, 3.12) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-22.04, 3.9) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-24.04, 3.10) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-24.04, 3.11) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-24.04, 3.12) (push) Has been cancelled
CI/CD Pipeline / Python Tests (ubuntu-24.04, 3.9) (push) Has been cancelled
CI/CD Pipeline / Integration Tests (Linux + KiCAD) (10.0) (push) Has been cancelled
CI/CD Pipeline / Integration Tests (Linux + KiCAD) (8.0) (push) Has been cancelled
CI/CD Pipeline / Integration Tests (Linux + KiCAD) (9.0) (push) Has been cancelled
CI/CD Pipeline / Docker Build Test (push) Has been cancelled
CI/CD Pipeline / Code Quality (push) Has been cancelled
CI/CD Pipeline / Documentation Check (push) Has been cancelled
Fixes #245 — _find_kicad_footprint_dir() only listed the 9.0/8.0 Windows install paths, so a Windows host with only KiCad 10 installed got None and footprint auto-discovery found nothing. The same gap existed for symbols in library_symbol.py and dynamic_symbol_loader.py. - Add C:/Program Files/KiCad/10.0/share/kicad/{footprints,symbols} to the candidate lists (10.0 first, matching the existing newest-first order) - Honor KICAD10_FOOTPRINT_DIR and KICAD10_SYMBOL_DIR env overrides alongside the existing 9/8 ones - Regression tests in tests/test_kicad10_paths.py
749 lines
28 KiB
Python
749 lines
28 KiB
Python
"""
|
|
Library management for KiCAD symbols
|
|
|
|
Handles parsing sym-lib-table files, discovering symbols,
|
|
and providing search functionality for component selection.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import threading
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
|
|
logger = logging.getLogger("kicad_interface")
|
|
|
|
|
|
@dataclass
|
|
class SymbolInfo:
|
|
"""Information about a symbol in a library"""
|
|
|
|
name: str # Symbol name (without library prefix)
|
|
library: str # Library nickname
|
|
full_ref: str # "Library:SymbolName"
|
|
value: str = "" # Value property
|
|
description: str = "" # Description property
|
|
footprint: str = "" # Footprint reference if present
|
|
lcsc_id: str = "" # LCSC property if present
|
|
manufacturer: str = "" # Manufacturer property
|
|
mpn: str = "" # Part/MPN property
|
|
category: str = "" # Category property
|
|
datasheet: str = "" # Datasheet URL
|
|
stock: str = "" # Stock (from JLCPCB libs)
|
|
price: str = "" # Price (from JLCPCB libs)
|
|
lib_class: str = "" # Basic/Preferred/Extended
|
|
sim_pins: str = "" # Sim.Pins pin mapping (e.g. "1=in+ 2=in- 3=vcc 4=vee 5=out")
|
|
|
|
|
|
class SymbolLibraryManager:
|
|
"""
|
|
Manages KiCAD symbol libraries
|
|
|
|
Parses sym-lib-table files (both global and project-specific),
|
|
indexes available symbols, and provides search functionality.
|
|
"""
|
|
|
|
def __init__(self, project_path: Optional[Path] = None):
|
|
"""
|
|
Initialize symbol 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.symbol_cache: Dict[str, List[SymbolInfo]] = {} # library -> [SymbolInfo]
|
|
self._cache_lock = threading.Lock()
|
|
self._load_libraries()
|
|
self._warm_cache()
|
|
|
|
def _warm_cache(self) -> None:
|
|
"""Pre-parse all symbol libraries in the background so the first search
|
|
is fast.
|
|
|
|
Without warming, the first ``search_symbols`` call parses every
|
|
.kicad_sym file on demand, which can take tens of seconds across
|
|
200+ libraries. Warming pays that cost ahead of time.
|
|
|
|
Crucially this runs on a daemon thread so it does NOT block
|
|
``__init__``. The interface is constructed before the process emits
|
|
its READY handshake to the TypeScript host; warming synchronously here
|
|
delayed (and on large libraries effectively prevented) that handshake,
|
|
so the MCP server never finished starting. ``list_symbols`` is
|
|
cache-locked, so on-demand parses race-free with this background warm.
|
|
"""
|
|
|
|
def _warm() -> None:
|
|
for nickname in list(self.libraries.keys()):
|
|
try:
|
|
self.list_symbols(nickname)
|
|
except Exception:
|
|
logger.debug("Skipping unparseable library: %s", nickname)
|
|
logger.info("Symbol cache warm-up complete (%d libraries)", len(self.libraries))
|
|
|
|
threading.Thread(target=_warm, name="symbol-cache-warmup", daemon=True).start()
|
|
|
|
def _load_libraries(self) -> None:
|
|
"""Load libraries from sym-lib-table files"""
|
|
# Load global libraries
|
|
global_table = self._get_global_sym_lib_table()
|
|
if global_table and global_table.exists():
|
|
logger.info(f"Loading global sym-lib-table from: {global_table}")
|
|
self._parse_sym_lib_table(global_table)
|
|
else:
|
|
logger.warning(f"Global sym-lib-table not found at: {global_table}")
|
|
|
|
# Load project-specific libraries if project path provided
|
|
if self.project_path:
|
|
project_table = self.project_path / "sym-lib-table"
|
|
if project_table.exists():
|
|
logger.info(f"Loading project sym-lib-table from: {project_table}")
|
|
self._parse_sym_lib_table(project_table)
|
|
|
|
logger.info(f"Loaded {len(self.libraries)} symbol libraries")
|
|
|
|
def _get_global_sym_lib_table(self) -> Optional[Path]:
|
|
"""Get path to global sym-lib-table file"""
|
|
# Try different possible locations (same as fp-lib-table but for symbols)
|
|
kicad_config_paths = [
|
|
Path.home() / ".config" / "kicad" / "10.0" / "sym-lib-table",
|
|
Path.home() / ".config" / "kicad" / "9.0" / "sym-lib-table",
|
|
Path.home() / ".config" / "kicad" / "8.0" / "sym-lib-table",
|
|
Path.home() / ".config" / "kicad" / "sym-lib-table",
|
|
# Windows paths
|
|
Path.home() / "AppData" / "Roaming" / "kicad" / "10.0" / "sym-lib-table",
|
|
Path.home() / "AppData" / "Roaming" / "kicad" / "9.0" / "sym-lib-table",
|
|
Path.home() / "AppData" / "Roaming" / "kicad" / "8.0" / "sym-lib-table",
|
|
# macOS paths
|
|
Path.home() / "Library" / "Preferences" / "kicad" / "10.0" / "sym-lib-table",
|
|
Path.home() / "Library" / "Preferences" / "kicad" / "9.0" / "sym-lib-table",
|
|
Path.home() / "Library" / "Preferences" / "kicad" / "8.0" / "sym-lib-table",
|
|
]
|
|
|
|
for path in kicad_config_paths:
|
|
if path.exists():
|
|
return path
|
|
|
|
return None
|
|
|
|
def _parse_sym_lib_table(self, table_path: Path) -> None:
|
|
"""
|
|
Parse sym-lib-table file
|
|
|
|
Format is S-expression (Lisp-like):
|
|
(sym_lib_table
|
|
(lib (name "Library_Name")(type KiCad)(uri "${KICAD9_SYMBOL_DIR}/Library.kicad_sym")(options "")(descr "Description"))
|
|
)
|
|
"""
|
|
try:
|
|
with open(table_path, "r", encoding="utf-8") 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_sym_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.debug(f" Could not resolve URI for library {nickname}: {uri}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error parsing sym-lib-table at {table_path}: {e}")
|
|
|
|
def _resolve_uri(self, uri: str) -> Optional[str]:
|
|
"""
|
|
Resolve environment variables and paths in library URI
|
|
|
|
Handles:
|
|
- ${KICAD9_SYMBOL_DIR} -> /Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols
|
|
- ${KICAD9_3RD_PARTY} -> ~/Documents/KiCad/9.0/3rdparty
|
|
- ${KIPRJMOD} -> project directory
|
|
- Relative paths
|
|
- Absolute paths
|
|
"""
|
|
resolved = uri
|
|
|
|
# Common KiCAD environment variables
|
|
env_vars = {
|
|
"KICAD10_SYMBOL_DIR": self._find_kicad_symbol_dir(),
|
|
"KICAD9_SYMBOL_DIR": self._find_kicad_symbol_dir(),
|
|
"KICAD8_SYMBOL_DIR": self._find_kicad_symbol_dir(),
|
|
"KICAD_SYMBOL_DIR": self._find_kicad_symbol_dir(),
|
|
"KICAD10_3RD_PARTY": self._find_3rd_party_dir(),
|
|
"KICAD9_3RD_PARTY": self._find_3rd_party_dir(),
|
|
"KICAD8_3RD_PARTY": self._find_3rd_party_dir(),
|
|
"KICAD_3RD_PARTY": self._find_3rd_party_dir(),
|
|
"KISYSSYM": self._find_kicad_symbol_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_symbol_dir(self) -> Optional[str]:
|
|
"""Find KiCAD symbol directory"""
|
|
possible_paths = [
|
|
"/usr/share/kicad/symbols",
|
|
"/usr/local/share/kicad/symbols",
|
|
"C:/Program Files/KiCad/10.0/share/kicad/symbols",
|
|
"C:/Program Files/KiCad/9.0/share/kicad/symbols",
|
|
"C:/Program Files/KiCad/8.0/share/kicad/symbols",
|
|
"/Applications/KiCad/KiCad.app/Contents/SharedSupport/symbols",
|
|
]
|
|
|
|
# Check environment variable
|
|
if "KICAD10_SYMBOL_DIR" in os.environ:
|
|
possible_paths.insert(0, os.environ["KICAD10_SYMBOL_DIR"])
|
|
if "KICAD9_SYMBOL_DIR" in os.environ:
|
|
possible_paths.insert(0, os.environ["KICAD9_SYMBOL_DIR"])
|
|
if "KICAD8_SYMBOL_DIR" in os.environ:
|
|
possible_paths.insert(0, os.environ["KICAD8_SYMBOL_DIR"])
|
|
|
|
for path in possible_paths:
|
|
if os.path.isdir(path):
|
|
return path
|
|
|
|
return None
|
|
|
|
def _find_3rd_party_dir(self) -> Optional[str]:
|
|
"""Find KiCAD 3rd party library directory (PCM installed libs)"""
|
|
possible_paths = [
|
|
str(Path.home() / "Documents" / "KiCad" / "10.0" / "3rdparty"),
|
|
str(Path.home() / "Documents" / "KiCad" / "9.0" / "3rdparty"),
|
|
str(Path.home() / "Documents" / "KiCad" / "8.0" / "3rdparty"),
|
|
]
|
|
|
|
# Check environment variable
|
|
if "KICAD10_3RD_PARTY" in os.environ:
|
|
possible_paths.insert(0, os.environ["KICAD10_3RD_PARTY"])
|
|
if "KICAD9_3RD_PARTY" in os.environ:
|
|
possible_paths.insert(0, os.environ["KICAD9_3RD_PARTY"])
|
|
if "KICAD8_3RD_PARTY" in os.environ:
|
|
possible_paths.insert(0, os.environ["KICAD8_3RD_PARTY"])
|
|
if "KICAD_3RD_PARTY" in os.environ:
|
|
possible_paths.insert(0, os.environ["KICAD_3RD_PARTY"])
|
|
|
|
for path in possible_paths:
|
|
if os.path.isdir(path):
|
|
return path
|
|
|
|
return None
|
|
|
|
def _parse_kicad_sym_file(self, library_path: str, library_name: str) -> List[SymbolInfo]:
|
|
"""
|
|
Parse a .kicad_sym file to extract symbol metadata
|
|
|
|
Args:
|
|
library_path: Path to the .kicad_sym file
|
|
library_name: Nickname of the library
|
|
|
|
Returns:
|
|
List of SymbolInfo objects
|
|
"""
|
|
symbols = []
|
|
|
|
try:
|
|
with open(library_path, "r", encoding="utf-8") as f:
|
|
content = f.read()
|
|
|
|
# Find all top-level symbol definitions
|
|
# Pattern: (symbol "SYMBOL_NAME" ... ) at the top level
|
|
# We need to find symbols that are direct children of kicad_symbol_lib
|
|
# and not sub-symbols (which have names like "PARENT_0_1")
|
|
|
|
# Simple approach: find all (symbol "NAME" and filter out sub-symbols
|
|
symbol_pattern = r'\(symbol\s+"([^"]+)"'
|
|
|
|
for match in re.finditer(symbol_pattern, content):
|
|
symbol_name = match.group(1)
|
|
|
|
# Skip sub-symbols (they contain _0_, _1_, etc. suffixes)
|
|
if re.search(r"_\d+_\d+$", symbol_name):
|
|
continue
|
|
|
|
# Find the start position of this symbol
|
|
start_pos = match.start()
|
|
|
|
# Walk forward tracking parenthesis depth to find the true end of
|
|
# the block. Parens inside quoted strings (e.g. a Description like
|
|
# "MCU (LQFP-64)") must be ignored, otherwise the depth counter
|
|
# never rebalances and the walk runs to EOF for every symbol —
|
|
# turning a multi-MB library into an O(n^2) scan (minutes per file)
|
|
# and falsely flagging well-formed symbols as malformed.
|
|
depth = 0
|
|
i = start_pos
|
|
end_pos = start_pos
|
|
in_string = False
|
|
while i < len(content):
|
|
ch = content[i]
|
|
if in_string:
|
|
if ch == "\\":
|
|
i += 2 # skip escaped char
|
|
continue
|
|
if ch == '"':
|
|
in_string = False
|
|
elif ch == '"':
|
|
in_string = True
|
|
elif ch == "(":
|
|
depth += 1
|
|
elif ch == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
end_pos = i + 1
|
|
break
|
|
i += 1
|
|
|
|
if end_pos == start_pos:
|
|
logger.warning(
|
|
f"Malformed symbol block for '{symbol_name}' in {library_path}; skipping"
|
|
)
|
|
continue
|
|
|
|
symbol_block = content[start_pos:end_pos]
|
|
|
|
# Extract properties
|
|
properties = self._extract_properties(symbol_block)
|
|
|
|
symbol_info = SymbolInfo(
|
|
name=symbol_name,
|
|
library=library_name,
|
|
full_ref=f"{library_name}:{symbol_name}",
|
|
value=properties.get("Value", ""),
|
|
description=properties.get("Description", ""),
|
|
footprint=properties.get("Footprint", ""),
|
|
lcsc_id=properties.get("LCSC", ""),
|
|
manufacturer=properties.get("Manufacturer", ""),
|
|
mpn=properties.get("Part", properties.get("MPN", "")),
|
|
category=properties.get("Category", ""),
|
|
datasheet=properties.get("Datasheet", ""),
|
|
stock=properties.get("Stock", ""),
|
|
price=properties.get("Price", ""),
|
|
lib_class=properties.get("Class", ""),
|
|
sim_pins=properties.get("Sim.Pins", ""),
|
|
)
|
|
|
|
symbols.append(symbol_info)
|
|
|
|
logger.debug(f"Parsed {len(symbols)} symbols from {library_name}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error parsing symbol library {library_path}: {e}")
|
|
|
|
return symbols
|
|
|
|
def _extract_properties(self, symbol_block: str) -> Dict[str, str]:
|
|
"""Extract properties from a symbol block"""
|
|
properties = {}
|
|
|
|
# Pattern for properties: (property "KEY" "VALUE" ...)
|
|
prop_pattern = r'\(property\s+"([^"]+)"\s+"([^"]*)"'
|
|
|
|
for match in re.finditer(prop_pattern, symbol_block):
|
|
key = match.group(1)
|
|
value = match.group(2)
|
|
properties[key] = value
|
|
|
|
return properties
|
|
|
|
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_symbols(self, library_nickname: str) -> List[SymbolInfo]:
|
|
"""
|
|
List all symbols in a library
|
|
|
|
Args:
|
|
library_nickname: Library name (e.g., "Device")
|
|
|
|
Returns:
|
|
List of SymbolInfo objects
|
|
"""
|
|
# Check cache first
|
|
cached = self.symbol_cache.get(library_nickname)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
library_path = self.libraries.get(library_nickname)
|
|
if not library_path:
|
|
logger.warning(f"Library not found: {library_nickname}")
|
|
return []
|
|
|
|
# Parse the library file
|
|
symbols = self._parse_kicad_sym_file(library_path, library_nickname)
|
|
|
|
# Cache the results. Guarded so an on-demand parse and the background
|
|
# warm-up thread can't corrupt the dict mid-update.
|
|
with self._cache_lock:
|
|
self.symbol_cache[library_nickname] = symbols
|
|
|
|
return symbols
|
|
|
|
def search_symbols(
|
|
self, query: str, limit: int = 20, library_filter: Optional[str] = None
|
|
) -> List[SymbolInfo]:
|
|
"""
|
|
Search for symbols matching a query
|
|
|
|
Args:
|
|
query: Search query (matches name, LCSC ID, description, category, manufacturer)
|
|
limit: Maximum number of results to return
|
|
library_filter: Optional library name pattern to filter by
|
|
|
|
Returns:
|
|
List of SymbolInfo objects sorted by relevance
|
|
"""
|
|
results = []
|
|
query_lower = query.lower()
|
|
|
|
# Determine which libraries to search
|
|
libraries_to_search: list[str] = list(self.libraries.keys())
|
|
if library_filter:
|
|
filter_lower = library_filter.lower()
|
|
libraries_to_search = [
|
|
lib for lib in libraries_to_search if filter_lower in lib.lower()
|
|
]
|
|
|
|
for library_nickname in libraries_to_search:
|
|
symbols = self.list_symbols(library_nickname)
|
|
|
|
for symbol in symbols:
|
|
score = self._score_match(query_lower, symbol)
|
|
if score > 0:
|
|
results.append((score, symbol))
|
|
|
|
if len(results) >= limit * 3: # Get extra for sorting
|
|
break
|
|
|
|
if len(results) >= limit * 3:
|
|
break
|
|
|
|
# Sort by score (descending) and return top results
|
|
results.sort(key=lambda x: x[0], reverse=True)
|
|
return [symbol for _, symbol in results[:limit]]
|
|
|
|
def _score_match(self, query: str, symbol: SymbolInfo) -> int:
|
|
"""
|
|
Score how well a symbol matches a query
|
|
|
|
Returns:
|
|
Score (0 = no match, higher = better match)
|
|
"""
|
|
score = 0
|
|
|
|
# Exact LCSC ID match - highest priority
|
|
if symbol.lcsc_id and symbol.lcsc_id.lower() == query:
|
|
score += 1000
|
|
|
|
# Exact name match
|
|
if symbol.name.lower() == query:
|
|
score += 500
|
|
|
|
# Exact value match
|
|
if symbol.value.lower() == query:
|
|
score += 400
|
|
|
|
# Partial name match
|
|
if query in symbol.name.lower():
|
|
score += 100
|
|
|
|
# Partial value match
|
|
if query in symbol.value.lower():
|
|
score += 80
|
|
|
|
# Description match
|
|
if query in symbol.description.lower():
|
|
score += 50
|
|
|
|
# MPN match
|
|
if symbol.mpn and query in symbol.mpn.lower():
|
|
score += 70
|
|
|
|
# Manufacturer match
|
|
if symbol.manufacturer and query in symbol.manufacturer.lower():
|
|
score += 30
|
|
|
|
# Category match
|
|
if symbol.category and query in symbol.category.lower():
|
|
score += 20
|
|
|
|
return score
|
|
|
|
def get_symbol_info(self, library_nickname: str, symbol_name: str) -> Optional[SymbolInfo]:
|
|
"""
|
|
Get information about a specific symbol
|
|
|
|
Args:
|
|
library_nickname: Library name
|
|
symbol_name: Symbol name
|
|
|
|
Returns:
|
|
SymbolInfo or None if not found
|
|
"""
|
|
symbols = self.list_symbols(library_nickname)
|
|
|
|
for symbol in symbols:
|
|
if symbol.name == symbol_name:
|
|
return symbol
|
|
|
|
return None
|
|
|
|
def find_symbol(self, symbol_spec: str) -> Optional[SymbolInfo]:
|
|
"""
|
|
Find a symbol by specification
|
|
|
|
Supports multiple formats:
|
|
- "Library:Symbol" (e.g., "Device:R")
|
|
- "Symbol" (searches all libraries)
|
|
|
|
Args:
|
|
symbol_spec: Symbol specification
|
|
|
|
Returns:
|
|
SymbolInfo or None if not found
|
|
"""
|
|
if ":" in symbol_spec:
|
|
# Format: Library:Symbol
|
|
library_nickname, symbol_name = symbol_spec.split(":", 1)
|
|
return self.get_symbol_info(library_nickname, symbol_name)
|
|
else:
|
|
# Search all libraries
|
|
for library_nickname in self.libraries.keys():
|
|
result = self.get_symbol_info(library_nickname, symbol_spec)
|
|
if result:
|
|
return result
|
|
|
|
return None
|
|
|
|
|
|
class SymbolLibraryCommands:
|
|
"""Command handlers for symbol library operations"""
|
|
|
|
def __init__(self, library_manager: Optional[SymbolLibraryManager] = None):
|
|
"""Initialize with optional library manager"""
|
|
self.library_manager = library_manager or SymbolLibraryManager()
|
|
|
|
@staticmethod
|
|
def _derive_project_path(params: Dict) -> Optional[Path]:
|
|
"""Derive a project directory from caller-supplied params.
|
|
|
|
Accepts an explicit project directory or .kicad_pro file via projectPath,
|
|
or any related file path (schematicPath/boardPath) — in which case the
|
|
nearest ancestor containing sym-lib-table or a .kicad_pro is used.
|
|
"""
|
|
for key in ("projectPath", "project_path"):
|
|
value = params.get(key)
|
|
if value:
|
|
p = Path(value).expanduser()
|
|
if p.suffix == ".kicad_pro" or p.is_file():
|
|
p = p.parent
|
|
return p
|
|
|
|
for key in ("schematicPath", "boardPath"):
|
|
value = params.get(key)
|
|
if value:
|
|
start = Path(value).expanduser().parent
|
|
for ancestor in [start, *start.parents]:
|
|
if (ancestor / "sym-lib-table").exists() or list(ancestor.glob("*.kicad_pro")):
|
|
return ancestor
|
|
return start
|
|
|
|
return None
|
|
|
|
def use_project(self, project_path: Optional[Path]) -> None:
|
|
"""Switch the underlying manager to load project-scope libraries.
|
|
|
|
Callers (e.g. open_project / create_project) use this to make
|
|
`<project>/sym-lib-table` visible to subsequent search/list/info calls
|
|
without requiring every caller to pass projectPath.
|
|
"""
|
|
if project_path is None:
|
|
return
|
|
# Patch (SER2RJ45): keep cache when project_path matches AND libraries were
|
|
# actually loaded. Original early-return skipped rebuild even when the cache
|
|
# was empty (e.g. first call after create_project, before sym-lib-table existed).
|
|
if (
|
|
self.library_manager.project_path == project_path
|
|
and len(self.library_manager.libraries) > 0
|
|
):
|
|
return
|
|
logger.info(f"Rebuilding SymbolLibraryManager for project: {project_path}")
|
|
self.library_manager = SymbolLibraryManager(project_path=project_path)
|
|
|
|
def _ensure_manager_for(self, params: Dict) -> None:
|
|
"""Rebuild the library manager if the caller's project differs."""
|
|
self.use_project(self._derive_project_path(params))
|
|
|
|
def list_symbol_libraries(self, params: Dict) -> Dict:
|
|
"""List all available symbol libraries"""
|
|
try:
|
|
self._ensure_manager_for(params)
|
|
libraries = self.library_manager.list_libraries()
|
|
return {"success": True, "libraries": libraries, "count": len(libraries)}
|
|
except Exception as e:
|
|
logger.error(f"Error listing symbol libraries: {e}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to list symbol libraries",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def search_symbols(self, params: Dict) -> Dict:
|
|
"""Search for symbols by query"""
|
|
try:
|
|
query = params.get("query", "")
|
|
if not query:
|
|
return {"success": False, "message": "Missing query parameter"}
|
|
|
|
self._ensure_manager_for(params)
|
|
|
|
limit = params.get("limit", 20)
|
|
library_filter = params.get("library")
|
|
|
|
results = self.library_manager.search_symbols(query, limit, library_filter)
|
|
|
|
return {
|
|
"success": True,
|
|
"symbols": [asdict(s) for s in results],
|
|
"count": len(results),
|
|
"query": query,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error searching symbols: {e}")
|
|
return {"success": False, "message": "Failed to search symbols", "errorDetails": str(e)}
|
|
|
|
def list_library_symbols(self, params: Dict) -> Dict:
|
|
"""List all symbols in a specific library"""
|
|
try:
|
|
library = params.get("library")
|
|
if not library:
|
|
return {"success": False, "message": "Missing library parameter"}
|
|
|
|
self._ensure_manager_for(params)
|
|
|
|
# Check if library exists in sym-lib-table
|
|
if library not in self.library_manager.libraries:
|
|
available_libs = list(self.library_manager.libraries.keys())
|
|
return {
|
|
"success": False,
|
|
"message": f"Library '{library}' not found in sym-lib-table",
|
|
"errorDetails": f"Library '{library}' is not registered in your KiCad symbol library table. "
|
|
f"Found {len(available_libs)} libraries. "
|
|
f"Please add this library to your sym-lib-table file, or use one of the available libraries.",
|
|
"available_libraries_count": len(available_libs),
|
|
"suggestion": "Use 'list_symbol_libraries' to see all available libraries",
|
|
}
|
|
|
|
symbols = self.library_manager.list_symbols(library)
|
|
|
|
return {
|
|
"success": True,
|
|
"library": library,
|
|
"symbols": [asdict(s) for s in symbols],
|
|
"count": len(symbols),
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"Error listing library symbols: {e}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to list library symbols",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
def get_symbol_info(self, params: Dict) -> Dict:
|
|
"""Get information about a specific symbol"""
|
|
try:
|
|
symbol_spec = params.get("symbol")
|
|
if not symbol_spec:
|
|
return {"success": False, "message": "Missing symbol parameter"}
|
|
|
|
self._ensure_manager_for(params)
|
|
|
|
result = self.library_manager.find_symbol(symbol_spec)
|
|
|
|
if result:
|
|
return {"success": True, "symbol_info": asdict(result)}
|
|
else:
|
|
return {"success": False, "message": f"Symbol not found: {symbol_spec}"}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error getting symbol info: {e}")
|
|
return {
|
|
"success": False,
|
|
"message": "Failed to get symbol info",
|
|
"errorDetails": str(e),
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Test the symbol library manager
|
|
import json
|
|
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
manager = SymbolLibraryManager()
|
|
|
|
print(f"\nFound {len(manager.libraries)} symbol libraries:")
|
|
for name in list(manager.libraries.keys())[:10]:
|
|
print(f" - {name}")
|
|
if len(manager.libraries) > 10:
|
|
print(f" ... and {len(manager.libraries) - 10} more")
|
|
|
|
# Test search
|
|
if manager.libraries:
|
|
print("\n\nSearching for 'ESP32':")
|
|
results = manager.search_symbols("ESP32", limit=5)
|
|
for symbol in results:
|
|
print(f" - {symbol.full_ref}: {symbol.description or symbol.value}")
|
|
if symbol.lcsc_id:
|
|
print(f" LCSC: {symbol.lcsc_id}")
|