872 lines
29 KiB
Python
872 lines
29 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Shared KiCad text parsing helpers for lightweight MCP servers.
|
|
|
|
These helpers intentionally work without pcbnew or KiCad system libraries.
|
|
They read `.kicad_sch` and `.kicad_pcb` files directly and only report facts
|
|
that can be derived from the available local files.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
|
import math
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
|
|
|
|
DEFAULT_KICAD_V10_IMAGE = "kill_life_cad-kicad-mcp:latest"
|
|
_CONTAINER_CACHE_STATE: Optional[Path] = None
|
|
_CONTAINER_CACHE_FAILED = False
|
|
_CONTAINER_FOOTPRINT_INDEX_CACHE: Optional[Tuple[str, int, Dict[str, Path]]] = None
|
|
_FOOTPRINT_NAME_CACHE: Dict[str, Tuple[str, ...]] = {}
|
|
INDEX_VERSION = 1
|
|
SYMBOL_INDEX_FILENAME = ".symbol_index.sqlite3"
|
|
FOOTPRINT_INDEX_FILENAME = ".footprint_index.json"
|
|
|
|
|
|
PROPERTY_PATTERN = re.compile(r'\(property\s+"([^"]+)"\s+"([^"]*)"')
|
|
LIB_ID_PATTERN = re.compile(r'\(lib_id\s+"([^"]+)"')
|
|
QUOTED_SYMBOL_PATTERN = re.compile(r'^\(symbol\s+"([^"]+)"')
|
|
LABEL_PATTERN = re.compile(r'\((?:global_label|label|hierarchical_label)\s+"([^"]+)"')
|
|
TITLE_PATTERN = re.compile(r'\(title\s+"([^"]*)"')
|
|
LAYER_PATTERN = re.compile(r'\(\s*\d+\s+"([^"]+)"\s+([^)]+)\)')
|
|
EDGE_LINE_PATTERN = re.compile(
|
|
r'\(gr_line\b.*?\(start\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
|
|
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
|
|
r'\(layer\s+"Edge\.Cuts"\)',
|
|
re.DOTALL,
|
|
)
|
|
EDGE_RECT_PATTERN = re.compile(
|
|
r'\(gr_rect\b.*?\(start\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
|
|
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
|
|
r'\(layer\s+"Edge\.Cuts"\)',
|
|
re.DOTALL,
|
|
)
|
|
EDGE_CIRCLE_PATTERN = re.compile(
|
|
r'\(gr_circle\b.*?\(center\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
|
|
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
|
|
r'\(layer\s+"Edge\.Cuts"\)',
|
|
re.DOTALL,
|
|
)
|
|
EDGE_ARC_PATTERN = re.compile(
|
|
r'\(gr_arc\b.*?\(start\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
|
|
r'\(mid\s+([-0-9.]+)\s+([-0-9.]+)\)\s*'
|
|
r'\(end\s+([-0-9.]+)\s+([-0-9.]+)\).*?'
|
|
r'\(layer\s+"Edge\.Cuts"\)',
|
|
re.DOTALL,
|
|
)
|
|
|
|
|
|
def read_text(path: Path) -> str:
|
|
return path.read_text(encoding="utf-8", errors="ignore")
|
|
|
|
|
|
def env_is_enabled(name: str, default: bool = True) -> bool:
|
|
value = os.getenv(name)
|
|
if value is None:
|
|
return default
|
|
return value.strip().lower() not in {"0", "false", "no", "off", ""}
|
|
|
|
|
|
def container_library_cache_root() -> Path:
|
|
override = os.getenv("KICAD_AUX_CONTAINER_CACHE_DIR")
|
|
if override:
|
|
return Path(override).expanduser()
|
|
return Path.home() / "Kill_LIFE" / ".cad-home" / "kicad-mcp" / "kicad-v10-libs"
|
|
|
|
|
|
def container_symbols_dir(cache_root: Optional[Path] = None) -> Path:
|
|
root = cache_root or container_library_cache_root()
|
|
return root / "symbols"
|
|
|
|
|
|
def container_footprints_dir(cache_root: Optional[Path] = None) -> Path:
|
|
root = cache_root or container_library_cache_root()
|
|
return root / "footprints"
|
|
|
|
|
|
def symbol_index_path(cache_root: Optional[Path] = None) -> Path:
|
|
root = cache_root or container_library_cache_root()
|
|
return root / SYMBOL_INDEX_FILENAME
|
|
|
|
|
|
def footprint_index_path(cache_root: Optional[Path] = None) -> Path:
|
|
root = cache_root or container_library_cache_root()
|
|
return root / FOOTPRINT_INDEX_FILENAME
|
|
|
|
|
|
def ensure_container_library_cache(refresh: bool = False) -> Optional[Path]:
|
|
global _CONTAINER_CACHE_STATE, _CONTAINER_CACHE_FAILED
|
|
|
|
if _CONTAINER_CACHE_STATE is not None and not refresh:
|
|
return _CONTAINER_CACHE_STATE
|
|
if _CONTAINER_CACHE_FAILED and not refresh:
|
|
return None
|
|
if not env_is_enabled("KICAD_AUX_USE_CONTAINER_LIBS", True):
|
|
_CONTAINER_CACHE_FAILED = True
|
|
return None
|
|
if shutil.which("docker") is None:
|
|
_CONTAINER_CACHE_FAILED = True
|
|
return None
|
|
|
|
image = os.getenv("KICAD_MCP_IMAGE", DEFAULT_KICAD_V10_IMAGE)
|
|
cache_root = container_library_cache_root()
|
|
symbols_dir = cache_root / "symbols"
|
|
footprints_dir = cache_root / "footprints"
|
|
|
|
if not refresh and symbols_dir.is_dir() and footprints_dir.is_dir():
|
|
_CONTAINER_CACHE_STATE = cache_root
|
|
return cache_root
|
|
|
|
if subprocess.run(
|
|
["docker", "image", "inspect", image],
|
|
check=False,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
).returncode != 0:
|
|
_CONTAINER_CACHE_FAILED = True
|
|
return None
|
|
|
|
container_id = ""
|
|
tmp_root = cache_root.with_name(f".{cache_root.name}.tmp")
|
|
try:
|
|
if tmp_root.exists():
|
|
shutil.rmtree(tmp_root)
|
|
tmp_root.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp_root.mkdir(parents=True, exist_ok=True)
|
|
|
|
container_id = subprocess.check_output(["docker", "create", image], text=True).strip()
|
|
subprocess.run(
|
|
["docker", "cp", f"{container_id}:/usr/share/kicad/symbols", str(tmp_root)],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
subprocess.run(
|
|
["docker", "cp", f"{container_id}:/usr/share/kicad/footprints", str(tmp_root)],
|
|
check=True,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
if cache_root.exists():
|
|
shutil.rmtree(cache_root)
|
|
tmp_root.rename(cache_root)
|
|
_CONTAINER_CACHE_STATE = cache_root
|
|
_CONTAINER_CACHE_FAILED = False
|
|
return cache_root
|
|
except (subprocess.CalledProcessError, OSError):
|
|
_CONTAINER_CACHE_FAILED = True
|
|
return None
|
|
finally:
|
|
if container_id:
|
|
subprocess.run(
|
|
["docker", "rm", "-f", container_id],
|
|
check=False,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
if tmp_root.exists():
|
|
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
|
|
|
|
def _safe_relpath(path: Path, root: Path) -> str:
|
|
try:
|
|
return str(path.resolve().relative_to(root.resolve()))
|
|
except ValueError:
|
|
return str(path)
|
|
|
|
|
|
def _footprint_index_metadata(cache_root: Path) -> Dict[str, object]:
|
|
footprints_dir = container_footprints_dir(cache_root)
|
|
dirs = sorted(footprints_dir.glob("*.pretty"))
|
|
return {
|
|
"version": INDEX_VERSION,
|
|
"footprints_dir": str(footprints_dir),
|
|
"footprints_dir_mtime_ns": footprints_dir.stat().st_mtime_ns if footprints_dir.exists() else None,
|
|
"library_count": len(dirs),
|
|
"library_names": [path.name for path in dirs],
|
|
}
|
|
|
|
|
|
def _metadata_matches(current: Dict[str, object], cached: Dict[str, object]) -> bool:
|
|
for key, value in current.items():
|
|
if cached.get(key) != value:
|
|
return False
|
|
return True
|
|
|
|
|
|
def load_container_symbol_index(
|
|
cache_root: Optional[Path] = None,
|
|
*,
|
|
refresh: bool = False,
|
|
) -> Tuple[List["KiCadComponent"], List[Path]]:
|
|
try:
|
|
from .kicad_symbol_sqlite import load_all_symbol_components
|
|
except ImportError:
|
|
from kicad_symbol_sqlite import load_all_symbol_components # type: ignore
|
|
|
|
return load_all_symbol_components(cache_root, refresh=refresh)
|
|
|
|
|
|
def load_container_footprint_index(
|
|
cache_root: Optional[Path] = None,
|
|
*,
|
|
refresh: bool = False,
|
|
) -> Dict[str, Path]:
|
|
global _CONTAINER_FOOTPRINT_INDEX_CACHE
|
|
|
|
resolved_root = cache_root or ensure_container_library_cache(refresh=refresh)
|
|
if resolved_root is None:
|
|
return {}
|
|
|
|
resolved_root = resolved_root.expanduser()
|
|
current_meta = _footprint_index_metadata(resolved_root)
|
|
cache_signature = int(current_meta.get("footprints_dir_mtime_ns") or 0)
|
|
cache_key = str(resolved_root)
|
|
|
|
if (
|
|
_CONTAINER_FOOTPRINT_INDEX_CACHE is not None
|
|
and _CONTAINER_FOOTPRINT_INDEX_CACHE[0] == cache_key
|
|
and _CONTAINER_FOOTPRINT_INDEX_CACHE[1] == cache_signature
|
|
and not refresh
|
|
):
|
|
return _CONTAINER_FOOTPRINT_INDEX_CACHE[2]
|
|
|
|
index_file = footprint_index_path(resolved_root)
|
|
if index_file.exists() and not refresh:
|
|
try:
|
|
payload = json.loads(index_file.read_text(encoding="utf-8"))
|
|
cached_meta = payload.get("meta", {})
|
|
if isinstance(cached_meta, dict) and _metadata_matches(current_meta, cached_meta):
|
|
libraries: Dict[str, Path] = {}
|
|
_FOOTPRINT_NAME_CACHE.clear()
|
|
for library_name, entry in payload.get("libraries", {}).items():
|
|
relative_path = entry.get("relative_path")
|
|
if not relative_path:
|
|
continue
|
|
full_path = resolved_root / relative_path
|
|
libraries[library_name] = full_path
|
|
_FOOTPRINT_NAME_CACHE[str(full_path)] = tuple(entry.get("footprints", []))
|
|
_CONTAINER_FOOTPRINT_INDEX_CACHE = (cache_key, cache_signature, libraries)
|
|
return libraries
|
|
except (OSError, json.JSONDecodeError, TypeError, ValueError):
|
|
pass
|
|
|
|
libraries: Dict[str, Path] = {}
|
|
serialized: Dict[str, Dict[str, object]] = {}
|
|
_FOOTPRINT_NAME_CACHE.clear()
|
|
footprints_dir = container_footprints_dir(resolved_root)
|
|
for library_path in sorted(footprints_dir.glob("*.pretty")):
|
|
if not library_path.is_dir():
|
|
continue
|
|
library_name = library_path.stem
|
|
if library_name in libraries:
|
|
continue
|
|
footprint_names = tuple(sorted(path.stem for path in library_path.glob("*.kicad_mod")))
|
|
libraries[library_name] = library_path
|
|
_FOOTPRINT_NAME_CACHE[str(library_path)] = footprint_names
|
|
serialized[library_name] = {
|
|
"relative_path": _safe_relpath(library_path, resolved_root),
|
|
"footprints": list(footprint_names),
|
|
}
|
|
|
|
payload = {"meta": current_meta, "libraries": serialized}
|
|
index_file.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8")
|
|
_CONTAINER_FOOTPRINT_INDEX_CACHE = (cache_key, cache_signature, libraries)
|
|
return libraries
|
|
|
|
|
|
def prewarm_container_indexes(refresh: bool = False) -> Dict[str, object]:
|
|
cache_root = ensure_container_library_cache(refresh=refresh)
|
|
if cache_root is None:
|
|
return {
|
|
"cache_root": None,
|
|
"symbol_libraries": 0,
|
|
"symbol_components": 0,
|
|
"footprint_libraries": 0,
|
|
}
|
|
|
|
try:
|
|
from .kicad_symbol_sqlite import build_symbol_sqlite_index
|
|
except ImportError:
|
|
from kicad_symbol_sqlite import build_symbol_sqlite_index # type: ignore
|
|
|
|
symbol_summary = build_symbol_sqlite_index(cache_root, refresh=refresh)
|
|
footprint_libraries = load_container_footprint_index(cache_root, refresh=refresh)
|
|
return {
|
|
"cache_root": str(cache_root),
|
|
"symbol_libraries": symbol_summary.get("symbol_libraries", 0),
|
|
"symbol_components": symbol_summary.get("symbol_components", 0),
|
|
"footprint_libraries": len(footprint_libraries),
|
|
}
|
|
|
|
|
|
def clear_container_indexes(cache_root: Optional[Path] = None) -> None:
|
|
global _CONTAINER_FOOTPRINT_INDEX_CACHE
|
|
|
|
resolved_root = cache_root or container_library_cache_root()
|
|
legacy_symbol_index = resolved_root / ".symbol_index.json"
|
|
for index_file in (symbol_index_path(resolved_root), footprint_index_path(resolved_root)):
|
|
try:
|
|
index_file.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
try:
|
|
legacy_symbol_index.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
_CONTAINER_FOOTPRINT_INDEX_CACHE = None
|
|
|
|
|
|
def extract_blocks(text: str, keyword: str) -> List[str]:
|
|
"""Return every S-expression block whose head is `keyword`."""
|
|
target = f"({keyword}"
|
|
blocks: List[str] = []
|
|
start = 0
|
|
|
|
while True:
|
|
idx = text.find(target, start)
|
|
if idx == -1:
|
|
return blocks
|
|
|
|
depth = 0
|
|
in_string = False
|
|
escaped = False
|
|
|
|
for pos in range(idx, len(text)):
|
|
char = text[pos]
|
|
if in_string:
|
|
if escaped:
|
|
escaped = False
|
|
elif char == "\\":
|
|
escaped = True
|
|
elif char == '"':
|
|
in_string = False
|
|
continue
|
|
|
|
if char == '"':
|
|
in_string = True
|
|
continue
|
|
if char == "(":
|
|
depth += 1
|
|
elif char == ")":
|
|
depth -= 1
|
|
if depth == 0:
|
|
blocks.append(text[idx : pos + 1])
|
|
start = pos + 1
|
|
break
|
|
else:
|
|
return blocks
|
|
|
|
|
|
def parse_properties(block: str) -> Dict[str, str]:
|
|
return {match.group(1): match.group(2) for match in PROPERTY_PATTERN.finditer(block)}
|
|
|
|
|
|
def parse_float(value: Optional[str]) -> Optional[float]:
|
|
if value in (None, ""):
|
|
return None
|
|
cleaned = str(value).strip().replace(",", ".")
|
|
match = re.search(r"-?\d+(?:\.\d+)?", cleaned)
|
|
if not match:
|
|
return None
|
|
try:
|
|
return float(match.group(0))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def parse_int(value: Optional[str]) -> Optional[int]:
|
|
if value in (None, ""):
|
|
return None
|
|
cleaned = str(value).strip().replace(",", "")
|
|
match = re.search(r"-?\d+", cleaned)
|
|
if not match:
|
|
return None
|
|
try:
|
|
return int(match.group(0))
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def derive_package(footprint: str) -> str:
|
|
if not footprint:
|
|
return ""
|
|
footprint_name = footprint.split(":", 1)[-1]
|
|
match = re.search(
|
|
r"(0201|0402|0603|0805|1206|1210|SOT-23(?:-[0-9]+)?|SOT-223|"
|
|
r"SOIC-[0-9]+|TSSOP-[0-9]+|QFN-[0-9]+|TQFP-[0-9]+|LQFP-[0-9]+|"
|
|
r"DIP-[0-9]+|PinHeader_[^:_]+|JST_[^:_]+)",
|
|
footprint_name,
|
|
re.IGNORECASE,
|
|
)
|
|
return match.group(1) if match else footprint_name
|
|
|
|
|
|
def infer_component_type(
|
|
reference: str = "",
|
|
lib_id: str = "",
|
|
value: str = "",
|
|
description: str = "",
|
|
category: str = "",
|
|
) -> str:
|
|
reference = (reference or "").upper()
|
|
lib_id_lower = (lib_id or "").lower()
|
|
haystack = " ".join([lib_id_lower, value.lower(), description.lower(), category.lower()])
|
|
|
|
if "regulator" in haystack or "ldo" in haystack:
|
|
return "regulator"
|
|
if "microcontroller" in haystack or "mcu" in haystack:
|
|
return "microcontroller"
|
|
if "opamp" in haystack or "amplifier" in haystack:
|
|
return "opamp"
|
|
if "connector" in haystack or lib_id_lower.startswith("connector"):
|
|
return "connector"
|
|
if "switch" in haystack:
|
|
return "switch"
|
|
if "mosfet" in haystack:
|
|
return "mosfet"
|
|
if "transistor" in haystack or lib_id_lower.startswith("device:q_"):
|
|
return "transistor"
|
|
if "crystal" in haystack or "oscillator" in haystack:
|
|
return "crystal"
|
|
if "inductor" in haystack or lib_id_lower.startswith("device:l"):
|
|
return "inductor"
|
|
if "capacitor" in haystack or lib_id_lower.startswith("device:c"):
|
|
return "capacitor"
|
|
if "resistor" in haystack or lib_id_lower.startswith("device:r"):
|
|
return "resistor"
|
|
if "led" in haystack:
|
|
return "led"
|
|
if "diode" in haystack or lib_id_lower.startswith("device:d"):
|
|
return "diode"
|
|
if "power" in haystack or lib_id_lower.startswith("power:"):
|
|
return "power"
|
|
|
|
if reference.startswith("R"):
|
|
return "resistor"
|
|
if reference.startswith("C"):
|
|
return "capacitor"
|
|
if reference.startswith("L"):
|
|
return "inductor"
|
|
if reference.startswith("D"):
|
|
return "diode"
|
|
if reference.startswith("Q"):
|
|
return "transistor"
|
|
if reference.startswith("U"):
|
|
return "ic"
|
|
if reference.startswith("J"):
|
|
return "connector"
|
|
if reference.startswith("Y"):
|
|
return "crystal"
|
|
if reference.startswith("SW"):
|
|
return "switch"
|
|
|
|
return "other"
|
|
|
|
|
|
def derive_manufacturer(lib_id: str, manufacturer: str) -> str:
|
|
if manufacturer:
|
|
return manufacturer
|
|
library_name = lib_id.split(":", 1)[0]
|
|
parts = [part for part in library_name.split("_") if part]
|
|
if len(parts) >= 2 and parts[0] in {"MCU", "CPU", "DSP", "FPGA", "CPLD"}:
|
|
aliases = {
|
|
"ST": "STMicroelectronics",
|
|
"NXP": "NXP",
|
|
"TI": "Texas Instruments",
|
|
}
|
|
return aliases.get(parts[1], parts[1])
|
|
return manufacturer
|
|
|
|
|
|
def derive_category(lib_id: str, category: str) -> str:
|
|
if category:
|
|
return category
|
|
return lib_id.split(":", 1)[0].replace("_", " ")
|
|
|
|
|
|
@dataclass
|
|
class KiCadComponent:
|
|
reference: str
|
|
value: str
|
|
footprint: str
|
|
datasheet: str
|
|
description: str
|
|
manufacturer: str
|
|
part_number: str
|
|
lcsc_id: str
|
|
lib_id: str
|
|
source_path: str
|
|
category: str
|
|
component_type: str
|
|
unit_price: Optional[float] = None
|
|
stock: Optional[int] = None
|
|
|
|
@property
|
|
def package(self) -> str:
|
|
return derive_package(self.footprint)
|
|
|
|
def to_dict(
|
|
self,
|
|
include_values: bool = True,
|
|
include_footprints: bool = True,
|
|
) -> Dict[str, object]:
|
|
result = asdict(self)
|
|
result["type"] = result.pop("component_type")
|
|
result["package"] = self.package if include_footprints else ""
|
|
result["value"] = self.value if include_values else None
|
|
result["footprint"] = self.footprint if include_footprints else None
|
|
result["price"] = self.unit_price
|
|
return result
|
|
|
|
|
|
def _build_component(
|
|
*,
|
|
reference: str,
|
|
value: str,
|
|
footprint: str,
|
|
datasheet: str,
|
|
description: str,
|
|
manufacturer: str,
|
|
part_number: str,
|
|
lcsc_id: str,
|
|
lib_id: str,
|
|
source_path: Path,
|
|
category: str,
|
|
price_raw: str,
|
|
stock_raw: str,
|
|
) -> KiCadComponent:
|
|
component_type = infer_component_type(
|
|
reference=reference,
|
|
lib_id=lib_id,
|
|
value=value,
|
|
description=description,
|
|
category=category,
|
|
)
|
|
return KiCadComponent(
|
|
reference=reference,
|
|
value=value,
|
|
footprint=footprint,
|
|
datasheet=datasheet,
|
|
description=description,
|
|
manufacturer=derive_manufacturer(lib_id, manufacturer),
|
|
part_number=part_number,
|
|
lcsc_id=lcsc_id,
|
|
lib_id=lib_id,
|
|
source_path=str(source_path),
|
|
category=derive_category(lib_id, category),
|
|
component_type=component_type,
|
|
unit_price=parse_float(price_raw),
|
|
stock=parse_int(stock_raw),
|
|
)
|
|
|
|
|
|
def parse_inline_library_symbols(path: Path) -> List[KiCadComponent]:
|
|
text = read_text(path)
|
|
components: List[KiCadComponent] = []
|
|
for block in extract_blocks(text, "symbol"):
|
|
match = QUOTED_SYMBOL_PATTERN.match(block)
|
|
if not match:
|
|
continue
|
|
symbol_name = match.group(1)
|
|
if re.search(r"_\d+_\d+$", symbol_name):
|
|
continue
|
|
if path.suffix == ".kicad_sym" and ":" not in symbol_name:
|
|
symbol_ref = f"{path.stem}:{symbol_name}"
|
|
else:
|
|
symbol_ref = symbol_name
|
|
properties = parse_properties(block)
|
|
components.append(
|
|
_build_component(
|
|
reference=properties.get("Reference", symbol_ref.split(":")[-1]),
|
|
value=properties.get("Value", symbol_ref.split(":")[-1]),
|
|
footprint=properties.get("Footprint", ""),
|
|
datasheet=properties.get("Datasheet", ""),
|
|
description=properties.get("Description", ""),
|
|
manufacturer=properties.get("Manufacturer", ""),
|
|
part_number=properties.get(
|
|
"Part",
|
|
properties.get("MPN", symbol_ref.split(":")[-1]),
|
|
),
|
|
lcsc_id=properties.get("LCSC", properties.get("LCSC Part", "")),
|
|
lib_id=symbol_ref,
|
|
source_path=path,
|
|
category=properties.get("Category", ""),
|
|
price_raw=properties.get("Price", ""),
|
|
stock_raw=properties.get("Stock", ""),
|
|
)
|
|
)
|
|
return components
|
|
|
|
|
|
def parse_schematic_components(
|
|
path: Path,
|
|
*,
|
|
include_templates: bool = False,
|
|
) -> List[KiCadComponent]:
|
|
text = read_text(path)
|
|
components: List[KiCadComponent] = []
|
|
for block in extract_blocks(text, "symbol"):
|
|
lib_id_match = LIB_ID_PATTERN.search(block)
|
|
if not lib_id_match:
|
|
continue
|
|
properties = parse_properties(block)
|
|
reference = properties.get("Reference", "")
|
|
if not reference:
|
|
continue
|
|
if not include_templates and reference.startswith("_TEMPLATE"):
|
|
continue
|
|
components.append(
|
|
_build_component(
|
|
reference=reference,
|
|
value=properties.get("Value", lib_id_match.group(1).split(":")[-1]),
|
|
footprint=properties.get("Footprint", ""),
|
|
datasheet=properties.get("Datasheet", ""),
|
|
description=properties.get("Description", ""),
|
|
manufacturer=properties.get("Manufacturer", ""),
|
|
part_number=properties.get(
|
|
"Part",
|
|
properties.get("MPN", lib_id_match.group(1).split(":")[-1]),
|
|
),
|
|
lcsc_id=properties.get("LCSC", properties.get("LCSC Part", "")),
|
|
lib_id=lib_id_match.group(1),
|
|
source_path=path,
|
|
category=properties.get("Category", ""),
|
|
price_raw=properties.get("Price", ""),
|
|
stock_raw=properties.get("Stock", ""),
|
|
)
|
|
)
|
|
return sorted(components, key=lambda item: item.reference)
|
|
|
|
|
|
def parse_schematic_metrics(path: Path) -> Dict[str, object]:
|
|
text = read_text(path)
|
|
labels = sorted(set(LABEL_PATTERN.findall(text)))
|
|
return {
|
|
"wire_count": text.count("(wire "),
|
|
"junction_count": text.count("(junction "),
|
|
"label_count": len(labels),
|
|
"labels": labels,
|
|
"sheet_count": text.count("(sheet "),
|
|
}
|
|
|
|
|
|
def _extract_layer_block(text: str) -> str:
|
|
blocks = extract_blocks(text, "layers")
|
|
return blocks[0] if blocks else ""
|
|
|
|
|
|
def _edge_cut_points(text: str) -> List[Tuple[float, float]]:
|
|
points: List[Tuple[float, float]] = []
|
|
|
|
for match in EDGE_LINE_PATTERN.finditer(text):
|
|
x1, y1, x2, y2 = map(float, match.groups())
|
|
points.extend([(x1, y1), (x2, y2)])
|
|
|
|
for match in EDGE_RECT_PATTERN.finditer(text):
|
|
x1, y1, x2, y2 = map(float, match.groups())
|
|
points.extend([(x1, y1), (x2, y2)])
|
|
|
|
for match in EDGE_CIRCLE_PATTERN.finditer(text):
|
|
cx, cy, ex, ey = map(float, match.groups())
|
|
radius = math.dist((cx, cy), (ex, ey))
|
|
points.extend(
|
|
[
|
|
(cx - radius, cy - radius),
|
|
(cx + radius, cy + radius),
|
|
]
|
|
)
|
|
|
|
for match in EDGE_ARC_PATTERN.finditer(text):
|
|
coords = list(map(float, match.groups()))
|
|
points.extend([(coords[0], coords[1]), (coords[2], coords[3]), (coords[4], coords[5])])
|
|
|
|
return points
|
|
|
|
|
|
def parse_pcb_summary(path: Path) -> Dict[str, object]:
|
|
text = read_text(path)
|
|
layer_block = _extract_layer_block(text)
|
|
copper_layers: List[str] = []
|
|
for name, layer_type in LAYER_PATTERN.findall(layer_block):
|
|
if name.endswith(".Cu") or "signal" in layer_type:
|
|
copper_layers.append(name)
|
|
|
|
points = _edge_cut_points(text)
|
|
width_mm = None
|
|
height_mm = None
|
|
if points:
|
|
xs = [point[0] for point in points]
|
|
ys = [point[1] for point in points]
|
|
width_mm = round(max(xs) - min(xs), 3)
|
|
height_mm = round(max(ys) - min(ys), 3)
|
|
|
|
title_match = TITLE_PATTERN.search(text)
|
|
return {
|
|
"title": title_match.group(1) if title_match else path.stem,
|
|
"copper_layers": copper_layers,
|
|
"footprint_count": len(re.findall(r"\(footprint\b", text)),
|
|
"segment_count": len(re.findall(r"\(segment\b", text)),
|
|
"via_count": len(re.findall(r"\(via\b", text)),
|
|
"zone_count": len(re.findall(r"\(zone\b", text)),
|
|
"net_count": len(set(re.findall(r'\(net\s+\d+\s+"([^"]*)"', text))),
|
|
"edge_cuts_count": text.count('Edge.Cuts'),
|
|
"width_mm": width_mm,
|
|
"height_mm": height_mm,
|
|
}
|
|
|
|
|
|
def discover_search_roots(base_dir: Path, env_var: str = "KICAD_AUX_MCP_ROOTS") -> List[Path]:
|
|
extra_roots: List[Path] = []
|
|
env_value = os.getenv(env_var, "")
|
|
if env_value:
|
|
extra_roots.extend(Path(entry).expanduser() for entry in env_value.split(os.pathsep) if entry)
|
|
|
|
sibling_templates = base_dir.parents[1] / "kicad_mcp_server" / "python" / "templates"
|
|
container_cache_root = ensure_container_library_cache(
|
|
refresh=env_is_enabled("KICAD_AUX_CONTAINER_REFRESH", False)
|
|
)
|
|
default_roots = [
|
|
Path.cwd(),
|
|
base_dir.parents[1],
|
|
sibling_templates,
|
|
Path.home() / "Documents" / "KiCad",
|
|
Path.home() / ".local" / "share" / "kicad",
|
|
Path.home() / "Kill_LIFE" / ".cad-home" / "kicad-mcp",
|
|
container_cache_root,
|
|
]
|
|
|
|
roots: List[Path] = []
|
|
seen = set()
|
|
for root in [*extra_roots, *default_roots]:
|
|
if root is None:
|
|
continue
|
|
root = root.expanduser()
|
|
key = str(root.resolve()) if root.exists() else str(root)
|
|
if key in seen or not root.exists():
|
|
continue
|
|
seen.add(key)
|
|
roots.append(root)
|
|
return roots
|
|
|
|
|
|
def discover_schematic_files(roots: Sequence[Path], limit: int = 64) -> List[Path]:
|
|
files: List[Path] = []
|
|
seen = set()
|
|
for root in roots:
|
|
for path in root.rglob("*.kicad_sch"):
|
|
key = str(path.resolve())
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
files.append(path)
|
|
if len(files) >= limit:
|
|
return files
|
|
return files
|
|
|
|
|
|
def discover_symbol_library_files(roots: Sequence[Path], limit: int = 512) -> List[Path]:
|
|
files: List[Path] = []
|
|
seen = set()
|
|
for root in roots:
|
|
for path in root.rglob("*.kicad_sym"):
|
|
key = str(path.resolve())
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
files.append(path)
|
|
if len(files) >= limit:
|
|
return files
|
|
return files
|
|
|
|
|
|
def discover_footprint_libraries(roots: Sequence[Path], limit: int = 256) -> Dict[str, Path]:
|
|
libraries: Dict[str, Path] = {}
|
|
container_root = ensure_container_library_cache(
|
|
refresh=env_is_enabled("KICAD_AUX_CONTAINER_REFRESH", False)
|
|
)
|
|
container_libraries_loaded = False
|
|
for root in roots:
|
|
if container_root is not None:
|
|
try:
|
|
root_resolved = root.resolve()
|
|
container_resolved = container_root.resolve()
|
|
if root_resolved == container_resolved or container_resolved in root_resolved.parents:
|
|
if not container_libraries_loaded:
|
|
for name, path in load_container_footprint_index(container_root).items():
|
|
libraries.setdefault(name, path)
|
|
if len(libraries) >= limit:
|
|
return libraries
|
|
container_libraries_loaded = True
|
|
continue
|
|
except FileNotFoundError:
|
|
pass
|
|
for path in root.rglob("*.pretty"):
|
|
if not path.is_dir():
|
|
continue
|
|
libraries.setdefault(path.stem, path)
|
|
if len(libraries) >= limit:
|
|
return libraries
|
|
return libraries
|
|
|
|
|
|
def footprint_exists(footprint_spec: str, libraries: Dict[str, Path]) -> bool:
|
|
if not footprint_spec:
|
|
return False
|
|
if ":" in footprint_spec:
|
|
library_name, footprint_name = footprint_spec.split(":", 1)
|
|
library_path = libraries.get(library_name)
|
|
if not library_path:
|
|
return False
|
|
cached_names = _FOOTPRINT_NAME_CACHE.get(str(library_path))
|
|
if cached_names:
|
|
return footprint_name in cached_names
|
|
return (library_path / f"{footprint_name}.kicad_mod").exists()
|
|
|
|
footprint_name = footprint_spec
|
|
for path in libraries.values():
|
|
cached_names = _FOOTPRINT_NAME_CACHE.get(str(path))
|
|
if cached_names:
|
|
if footprint_name in cached_names:
|
|
return True
|
|
continue
|
|
if (path / f"{footprint_name}.kicad_mod").exists():
|
|
return True
|
|
return False
|
|
|
|
|
|
def search_footprints(
|
|
libraries: Dict[str, Path],
|
|
patterns: Iterable[str],
|
|
limit: int = 5,
|
|
) -> List[str]:
|
|
results: List[str] = []
|
|
seen = set()
|
|
lowered_patterns = [pattern.lower() for pattern in patterns if pattern]
|
|
|
|
for library_name, library_path in libraries.items():
|
|
cached_names = _FOOTPRINT_NAME_CACHE.get(str(library_path))
|
|
if cached_names:
|
|
footprint_names = list(cached_names)
|
|
else:
|
|
footprint_names = [candidate.stem for candidate in library_path.glob("*.kicad_mod")]
|
|
for footprint_name in footprint_names:
|
|
search_text = f"{library_name}:{footprint_name}".lower()
|
|
if lowered_patterns and not any(pattern in search_text for pattern in lowered_patterns):
|
|
continue
|
|
full_name = f"{library_name}:{footprint_name}"
|
|
if full_name in seen:
|
|
continue
|
|
seen.add(full_name)
|
|
results.append(full_name)
|
|
if len(results) >= limit:
|
|
return results
|
|
return results
|