Files
KiCAD-MCP-Server/python/commands/symbol_creator.py
T

466 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)))',
' )',
' )',
]