""" Symbol Creator for KiCAD MCP Server Creates and edits .kicad_sym symbol library files using raw S-Expression text generation. No sexpdata – pure f-string assembly to guarantee format correctness. KiCAD 9 .kicad_sym format: - Library file starts with (kicad_symbol_lib (version 20241209) ...) - Each symbol has a parent block with properties + two sub-symbols: SymbolName_0_1 → body graphics (rectangle, polyline, circle, arc) SymbolName_1_1 → pins - All coordinates in mm, 2.54mm grid typical for schematic symbols """ import os import re import logging from pathlib import Path from typing import Any, Dict, List, Optional logger = logging.getLogger("kicad_interface") KICAD9_SYMBOL_LIB_VERSION = "20241209" # Pin electrical types PIN_TYPES = { "input", "output", "bidirectional", "tri_state", "passive", "free", "unspecified", "power_in", "power_out", "open_collector", "open_emitter", "no_connect", } # Pin graphic shapes PIN_SHAPES = { "line", "inverted", "clock", "inverted_clock", "input_low", "clock_low", "output_low", "falling_edge_clock", "non_logic", } def _fmt(v: float) -> str: return f"{v:g}" def _esc(s: str) -> str: return s.replace('"', '\\"') class SymbolCreator: """Creates and edits KiCAD .kicad_sym symbol library files.""" # ------------------------------------------------------------------ # # create_symbol # # ------------------------------------------------------------------ # def create_symbol( self, library_path: str, name: str, reference_prefix: str = "U", description: str = "", keywords: str = "", datasheet: str = "~", footprint: str = "", in_bom: bool = True, on_board: bool = True, pins: Optional[List[Dict[str, Any]]] = None, rectangles: Optional[List[Dict[str, Any]]] = None, polylines: Optional[List[Dict[str, Any]]] = None, overwrite: bool = False, ) -> Dict[str, Any]: """ Add a new symbol to a .kicad_sym library (creates the file if missing). Parameters ---------- library_path : str Path to the .kicad_sym file (created if missing). name : str Symbol name, e.g. "TMC2209", "MyOpAmp". reference_prefix : str Schematic reference prefix, e.g. "U", "R", "J". description : str Human-readable description. keywords : str Space-separated keyword string for search. datasheet : str Datasheet URL or "~". footprint : str Default footprint, e.g. "Package_SO:SOIC-8". in_bom : bool Include in BOM (default True). on_board : bool Include in netlist for PCB (default True). pins : list of dicts Each pin dict: name (str) – pin name, e.g. "VCC", "GND", "~" for unnamed number (str) – pin number, e.g. "1", "A1" type (str) – electrical type: input|output|bidirectional| passive|power_in|power_out|tri_state| open_collector|open_emitter|free|unspecified at (dict) – {"x": float, "y": float, "angle": float} angle: 0=right, 90=up, 180=left, 270=down length (float) – pin length in mm (default 2.54) shape (str) – graphic shape: line|inverted|clock|... (default "line") rectangles : list of dicts or None Body rectangles: {"x1","y1","x2","y2", "width"(opt), "fill"(opt)} fill: "none"|"outline"|"background" (default "background") polylines : list of dicts or None {"points": [{"x":float,"y":float},...], "width"(opt), "fill"(opt)} overwrite : bool Replace existing symbol with same name (default False). Returns ------- dict with "success", "library_path", "symbol_name", "pin_count" """ lib_path = Path(library_path) if lib_path.suffix.lower() != ".kicad_sym": lib_path = lib_path.with_suffix(".kicad_sym") lib_path.parent.mkdir(parents=True, exist_ok=True) # Load or create library if lib_path.exists(): lib_content = lib_path.read_text(encoding="utf-8") else: lib_content = ( f'(kicad_symbol_lib\n' f' (version {KICAD9_SYMBOL_LIB_VERSION})\n' f' (generator "kicad-mcp")\n' f' (generator_version "9.0")\n' f')\n' ) # Check for duplicate if f'(symbol "{name}"' in lib_content: if not overwrite: return { "success": False, "error": f'Symbol "{name}" already exists in {lib_path}. Use overwrite=true.', "library_path": str(lib_path), } lib_content = self._remove_symbol(lib_content, name) pins = pins or [] rectangles = rectangles or [] polylines = polylines or [] symbol_block = self._build_symbol_block( name=name, reference_prefix=reference_prefix, description=description, keywords=keywords, datasheet=datasheet, footprint=footprint, in_bom=in_bom, on_board=on_board, pins=pins, rectangles=rectangles, polylines=polylines, ) # Insert before closing paren of library lib_content = lib_content.rstrip() if lib_content.endswith(")"): lib_content = lib_content[:-1].rstrip() + "\n" + symbol_block + "\n)\n" else: lib_content += "\n" + symbol_block + "\n)\n" lib_path.write_text(lib_content, encoding="utf-8") logger.info(f"Created symbol '{name}' in {lib_path} ({len(pins)} pins)") return { "success": True, "library_path": str(lib_path), "symbol_name": name, "pin_count": len(pins), } # ------------------------------------------------------------------ # # delete_symbol # # ------------------------------------------------------------------ # def delete_symbol(self, library_path: str, name: str) -> Dict[str, Any]: """Remove a symbol from a .kicad_sym library.""" lib_path = Path(library_path) if not lib_path.exists(): return {"success": False, "error": f"Library not found: {library_path}"} content = lib_path.read_text(encoding="utf-8") if f'(symbol "{name}"' not in content: return {"success": False, "error": f'Symbol "{name}" not found in {library_path}'} new_content = self._remove_symbol(content, name) lib_path.write_text(new_content, encoding="utf-8") return {"success": True, "library_path": str(lib_path), "deleted": name} # ------------------------------------------------------------------ # # list_symbols (in a single library file) # # ------------------------------------------------------------------ # def list_symbols(self, library_path: str) -> Dict[str, Any]: """List all symbols in a .kicad_sym file.""" lib_path = Path(library_path) if not lib_path.exists(): return {"success": False, "error": f"Library not found: {library_path}"} content = lib_path.read_text(encoding="utf-8") # Only top-level symbols (not sub-symbols like _0_1 or _1_1) names = re.findall(r'^\s*\(symbol "([^"_][^"]*)"', content, re.MULTILINE) # Filter out sub-symbols (contain _N_N suffix) symbols = [n for n in names if not re.search(r'_\d+_\d+$', n)] return { "success": True, "library_path": str(lib_path), "symbol_count": len(symbols), "symbols": symbols, } # ------------------------------------------------------------------ # # register_symbol_library # # ------------------------------------------------------------------ # def register_symbol_library( self, library_path: str, library_name: Optional[str] = None, description: str = "", scope: str = "project", project_path: Optional[str] = None, ) -> Dict[str, Any]: """ Register a .kicad_sym library in KiCAD's sym-lib-table. Parameters ---------- library_path : str – path to the .kicad_sym file library_name : str – nickname (default: file stem) scope : str – "project" or "global" project_path : str – .kicad_pro or directory (for scope=project) """ sym_path = Path(library_path) name = library_name or sym_path.stem uri = str(sym_path).replace("\\", "/") if scope == "project": if project_path: proj = Path(project_path) table_dir = proj if proj.is_dir() else proj.parent else: table_dir = sym_path.parent table_path = table_dir / "sym-lib-table" else: cfg_dirs = [ Path(os.environ.get("APPDATA", "")) / "kicad" / "9.0", Path.home() / ".config" / "kicad" / "9.0", ] table_path = None for d in cfg_dirs: candidate = d / "sym-lib-table" if candidate.exists(): table_path = candidate break if table_path is None: for d in cfg_dirs: try: d.mkdir(parents=True, exist_ok=True) table_path = d / "sym-lib-table" break except OSError: continue if table_path is None: return {"success": False, "error": "Could not find/create global sym-lib-table"} if table_path.exists(): content = table_path.read_text(encoding="utf-8") else: content = "(sym_lib_table\n (version 7)\n)\n" if f'(name "{name}")' in content or uri in content: return { "success": True, "already_registered": True, "table_path": str(table_path), "library_name": name, } new_entry = ( f' (lib (name "{name}")' f'(type "KiCad")' f'(uri "{uri}")' f'(options "")' f'(descr "{_esc(description)}"))' ) content = content.rstrip() if content.endswith(")"): content = content[:-1].rstrip() + "\n" + new_entry + "\n)\n" else: content += "\n" + new_entry + "\n)\n" table_path.write_text(content, encoding="utf-8") logger.info(f"Registered symbol library '{name}' in {table_path}") return { "success": True, "already_registered": False, "table_path": str(table_path), "library_name": name, "uri": uri, } # ------------------------------------------------------------------ # # Internal helpers # # ------------------------------------------------------------------ # def _build_symbol_block( self, name: str, reference_prefix: str, description: str, keywords: str, datasheet: str, footprint: str, in_bom: bool, on_board: bool, pins: List[Dict[str, Any]], rectangles: List[Dict[str, Any]], polylines: List[Dict[str, Any]], ) -> str: lines: List[str] = [] bom_str = "yes" if in_bom else "no" board_str = "yes" if on_board else "no" lines.append(f' (symbol "{name}"') lines.append(' (exclude_from_sim no)') lines.append(f' (in_bom {bom_str})') lines.append(f' (on_board {board_str})') # Properties lines.extend(_property_block("Reference", reference_prefix, 2.54, 0, visible=True)) lines.extend(_property_block("Value", name, 0, -2.54, visible=True)) lines.extend(_property_block("Footprint", footprint, 0, -5.08, visible=False)) lines.extend(_property_block("Datasheet", datasheet or "~", 0, -7.62, visible=False)) lines.extend(_property_block("Description", description, 0, -10.16, visible=False)) if keywords: lines.extend(_property_block("ki_keywords", keywords, 0, 0, visible=False)) # Sub-symbol _0_1: body graphics lines.append(f' (symbol "{name}_0_1"') for rect in rectangles: lines.extend(_rect_sym_lines(rect)) for pl in polylines: lines.extend(_polyline_lines(pl)) lines.append(' )') # Sub-symbol _1_1: pins lines.append(f' (symbol "{name}_1_1"') for pin in pins: lines.extend(_pin_lines(pin)) lines.append(' )') lines.append(' )') return "\n".join(lines) def _remove_symbol(self, content: str, name: str) -> str: """Remove a complete symbol block from library content.""" lines = content.split("\n") result = [] skip = False depth = 0 for line in lines: stripped = line.strip() if not skip: if re.match(rf'^\s*\(symbol "{re.escape(name)}"', line) and \ not re.search(r'_\d+_\d+"', line): skip = True depth = stripped.count("(") - stripped.count(")") continue result.append(line) else: depth += stripped.count("(") - stripped.count(")") if depth <= 0: skip = False return "\n".join(result) # ------------------------------------------------------------------ # # S-Expression helper functions # # ------------------------------------------------------------------ # def _property_block( key: str, value: str, x: float, y: float, visible: bool = True ) -> List[str]: hide = "" if visible else "\n (hide yes)" return [ f' (property "{_esc(key)}" "{_esc(value)}"', f' (at {_fmt(x)} {_fmt(y)} 0)', ' (effects', ' (font (size 1.27 1.27))', f' ){hide}', ' )', ] def _rect_sym_lines(rect: Dict[str, Any]) -> List[str]: x1 = _fmt(rect.get("x1", -2.54)) y1 = _fmt(rect.get("y1", -2.54)) x2 = _fmt(rect.get("x2", 2.54)) y2 = _fmt(rect.get("y2", 2.54)) w = _fmt(rect.get("width", 0.254)) fill = rect.get("fill", "background") return [ ' (rectangle', f' (start {x1} {y1})', f' (end {x2} {y2})', f' (stroke (width {w}) (type default))', f' (fill (type {fill}))', ' )', ] def _polyline_lines(pl: Dict[str, Any]) -> List[str]: pts = pl.get("points", []) w = _fmt(pl.get("width", 0.254)) fill = pl.get("fill", "none") lines = [ ' (polyline', ' (pts', ] for pt in pts: lines.append(f' (xy {_fmt(pt["x"])} {_fmt(pt["y"])})') lines += [ ' )', f' (stroke (width {w}) (type default))', f' (fill (type {fill}))', ' )', ] return lines def _pin_lines(pin: Dict[str, Any]) -> List[str]: ptype = pin.get("type", "passive").lower() shape = pin.get("shape", "line").lower() at = pin.get("at", {"x": 0, "y": 0, "angle": 0}) x = _fmt(at.get("x", 0)) y = _fmt(at.get("y", 0)) angle = _fmt(at.get("angle", 0)) length = _fmt(pin.get("length", 2.54)) pin_name = pin.get("name", "~") pin_number = str(pin.get("number", "1")) return [ f' (pin {ptype} {shape}', f' (at {x} {y} {angle})', f' (length {length})', f' (name "{_esc(pin_name)}"', ' (effects (font (size 1.27 1.27)))', ' )', f' (number "{_esc(pin_number)}"', ' (effects (font (size 1.27 1.27)))', ' )', ' )', ]