diff --git a/python/commands/symbol_pins.py b/python/commands/symbol_pins.py new file mode 100644 index 0000000..475c1f7 --- /dev/null +++ b/python/commands/symbol_pins.py @@ -0,0 +1,199 @@ +""" +Symbol pin discovery commands. + +Read-only tools for inspecting a symbol's pins straight from the KiCad symbol +libraries, without needing a schematic loaded. Useful as a pre-flight step before +placing components and wiring nets (e.g. discover pin numbers/names before +connect_to_net). Complements ``get_schematic_pin_locations`` (which reports pin +coordinates only *after* a symbol has been placed on a schematic). + +Tools: + - list_symbol_pins: pins for one symbol, read straight from the library file + - batch_list_symbol_pins: pins for many symbols in one call (+ body bounding box) +""" + +import logging +import re +from pathlib import Path +from typing import Any, Dict, List, Optional + +from commands.dynamic_symbol_loader import DynamicSymbolLoader + +logger = logging.getLogger("kicad_interface") + +# Standard symmetric 2-pin passives that qualify for compact output in +# batch_list_symbol_pins (their pin detail is rarely needed when placing). +COMPACT_SYMBOLS = { + "Device:R", + "Device:R_Small", + "Device:R_US", + "Device:C", + "Device:C_Small", + "Device:C_Polarized", + "Device:C_Polarized_Small", + "Device:L", + "Device:L_Small", + "Device:LED", + "Device:D", + "Device:D_Zener", + "Device:D_Schottky", + "Device:Ferrite_Bead", +} + +# Pin-envelope padding (mm) used to derive a symbol body bounding box (50 mil). +_BODY_PAD_MM = 1.27 + +# Matches a pin S-expression inside a symbol definition, capturing +# type, x, y, angle, name and number. +_PIN_RE = re.compile( + r"\(pin\s+(\S+)\s+\S+\s+\(at\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\)" + r'.*?\(name\s+"([^"]*)".*?\(number\s+"([^"]*)"', + re.DOTALL, +) + + +def _parse_symbol_pins( + loader: DynamicSymbolLoader, library_name: str, symbol_name: str +) -> List[Dict[str, Any]]: + """Return pin data for a symbol read directly from its library file (no schematic needed). + + Each entry: {"number", "name", "type", "x", "y", "angle"} where x/y/angle are in + symbol-local coordinates (Y increases upward, per the KiCad library convention). + + Raises ValueError (carrying .suggestions) if the symbol cannot be found — this mirrors + DynamicSymbolLoader.extract_symbol_from_library's behaviour for close-match hints. + """ + block = loader.extract_symbol_from_library(library_name, symbol_name) + if not block: + err = ValueError(f"Symbol '{library_name}:{symbol_name}' not found") + err.suggestions = [] # type: ignore[attr-defined] + raise err + pins: List[Dict[str, Any]] = [] + for m in _PIN_RE.finditer(block): + pins.append( + { + "number": m.group(6), + "name": m.group(5), + "type": m.group(1), + "x": float(m.group(2)), + "y": float(m.group(3)), + "angle": float(m.group(4)), + } + ) + return sorted(pins, key=lambda p: (len(p["number"]), p["number"])) + + +def _body_bbox(pins: List[Dict[str, Any]]) -> Optional[Dict[str, float]]: + """Bounding box of the pin envelope expanded by _BODY_PAD_MM on each side.""" + coords = [(p["x"], p["y"]) for p in pins if "x" in p] + if not coords: + return None + xs = [c[0] for c in coords] + ys = [c[1] for c in coords] + return { + "x_min": round(min(xs) - _BODY_PAD_MM, 4), + "y_min": round(min(ys) - _BODY_PAD_MM, 4), + "x_max": round(max(xs) + _BODY_PAD_MM, 4), + "y_max": round(max(ys) + _BODY_PAD_MM, 4), + "width": round(max(xs) - min(xs) + 2 * _BODY_PAD_MM, 4), + "height": round(max(ys) - min(ys) + 2 * _BODY_PAD_MM, 4), + } + + +class SymbolPinCommands: + """Handlers for symbol pin discovery tools. Stateless; each call builds its own loader.""" + + def list_symbol_pins(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List pin numbers, names and types for one symbol, read from its library.""" + logger.info("Listing symbol pins from library") + try: + symbol_spec = params.get("symbol", "") + schematic_path = params.get("schematicPath") + + if not symbol_spec or ":" not in symbol_spec: + return {"success": False, "message": "symbol must be 'Library:SymbolName'"} + + library_name, symbol_name = symbol_spec.split(":", 1) + project_path = Path(schematic_path).parent if schematic_path else None + loader = DynamicSymbolLoader(project_path=project_path) + + try: + pins = _parse_symbol_pins(loader, library_name, symbol_name) + except ValueError as e: + return { + "success": False, + "message": str(e), + "suggestions": getattr(e, "suggestions", []), + } + + return { + "success": True, + "symbol": symbol_spec, + "pin_count": len(pins), + "pins": pins, + } + + except Exception as e: + logger.error(f"Error listing symbol pins: {e}") + return {"success": False, "message": str(e)} + + def batch_list_symbol_pins(self, params: Dict[str, Any]) -> Dict[str, Any]: + """List pins (+ body bounding box) for multiple symbols in a single call.""" + logger.info("Batch listing symbol pins") + try: + symbols = params.get("symbols", []) + schematic_path = params.get("schematicPath") + compact = bool(params.get("compact", False)) + + if not symbols: + return {"success": False, "message": "symbols list is required"} + + project_path = Path(schematic_path).parent if schematic_path else None + loader = DynamicSymbolLoader(project_path=project_path) + results: Dict[str, Any] = {} + errors: Dict[str, Any] = {} + + for symbol_spec in symbols: + if ":" not in symbol_spec: + errors[symbol_spec] = "symbol must be 'Library:SymbolName'" + continue + library_name, symbol_name = symbol_spec.split(":", 1) + try: + pins = _parse_symbol_pins(loader, library_name, symbol_name) + except ValueError as e: + errors[symbol_spec] = { + "message": str(e), + "suggestions": getattr(e, "suggestions", []), + } + continue + + body_bbox = _body_bbox(pins) + is_symmetric_2pin = len(pins) == 2 and ( + symbol_spec in COMPACT_SYMBOLS + or all(p.get("type", "") == "passive" for p in pins) + ) + if compact and is_symmetric_2pin: + results[symbol_spec] = { + "pin_count": len(pins), + "body_bbox": body_bbox, + "is_symmetric": True, + "compact": True, + "note": "Pin detail omitted (compact mode, symmetric 2-pin passive). " + "Set compact=false to see individual pin coords.", + } + else: + results[symbol_spec] = { + "pins": pins, + "pin_count": len(pins), + "body_bbox": body_bbox, + } + + return { + "success": len(errors) == 0, + "symbols": results, + "errors": errors if errors else None, + } + + except Exception as e: + logger.error(f"Error in batch_list_symbol_pins: {e}") + return {"success": False, "message": str(e)} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 4b9e95a..c0e7848 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -326,6 +326,7 @@ try: from commands.routing import RoutingCommands from commands.schematic import SchematicManager from commands.symbol_creator import SymbolCreator + from commands.symbol_pins import SymbolPinCommands logger.info("Successfully imported all command handlers") except ImportError as e: @@ -444,6 +445,9 @@ class KiCADInterface: # Initialize symbol library manager (for searching local KiCad symbol libraries) self.symbol_library_commands = SymbolLibraryCommands() + # Symbol pin discovery commands (read-only pin lookup from symbol libraries) + self.symbol_pin_commands = SymbolPinCommands() + # Initialize JLCPCB API integration self.jlcpcb_client = JLCPCBClient() # Official API (requires auth) from commands.jlcsearch import JLCSearchClient @@ -527,6 +531,9 @@ class KiCADInterface: "search_symbols": self.symbol_library_commands.search_symbols, "list_library_symbols": self.symbol_library_commands.list_library_symbols, "get_symbol_info": self.symbol_library_commands.get_symbol_info, + # Symbol pin discovery commands (read pins straight from symbol libraries) + "list_symbol_pins": self.symbol_pin_commands.list_symbol_pins, + "batch_list_symbol_pins": self.symbol_pin_commands.batch_list_symbol_pins, # JLCPCB API commands (complete parts catalog via API) "download_jlcpcb_database": self._handle_download_jlcpcb_database, "search_jlcpcb_parts": self._handle_search_jlcpcb_parts, diff --git a/src/tools/library-symbol.ts b/src/tools/library-symbol.ts index 3ed6fed..227e323 100644 --- a/src/tools/library-symbol.ts +++ b/src/tools/library-symbol.ts @@ -207,4 +207,108 @@ Returns symbol references that can be used directly in schematics.`, }; }, ); + + // List pins for a symbol from the library (no schematic needed) + server.tool( + "list_symbol_pins", + "Return pin names, numbers, and types for a symbol directly from the library — no schematic required. Use this before add_schematic_component to discover pins for connect_to_net calls. Each pin has 'number' (e.g. '1', 'A5') and 'name' (e.g. 'FB', 'GND') — connect_to_net accepts either. Pass schematicPath to resolve project-local symbols. Returns close-match suggestions if the symbol name is slightly wrong.", + { + symbol: z + .string() + .describe("Symbol in 'Library:SymbolName' format (e.g., Device:R, Connector:Conn_01x04)"), + schematicPath: z + .string() + .optional() + .describe("Path to .kicad_sch — enables project-local sym-lib-table lookup"), + }, + async (args: { symbol: string; schematicPath?: string }) => { + const result = await callKicadScript("list_symbol_pins", args); + if (result.success) { + if (result.pins.length === 0) { + return { + content: [{ type: "text", text: `Symbol ${result.symbol} has no pins.` }], + }; + } + const lines = result.pins.map( + (p: any) => ` Pin ${p.number} (${p.name}) — type: ${p.type}`, + ); + return { + content: [ + { + type: "text", + text: `${result.symbol} — ${result.pin_count} pin(s):\n${lines.join("\n")}`, + }, + ], + }; + } + const hint = result.suggestions?.length + ? `\nDid you mean: ${result.suggestions.join(", ")}?` + : ""; + return { + content: [ + { + type: "text", + text: `Failed to list pins: ${result.message || "Unknown error"}${hint}`, + }, + ], + }; + }, + ); + + // List pins for multiple symbols in one call + server.tool( + "batch_list_symbol_pins", + "Return pin names, numbers, types, and symbol-local coordinates for multiple symbols in a single call. Use instead of calling list_symbol_pins repeatedly when placing a subcircuit — saves 5–10 round-trips. Each result includes pins (with x/y/angle in symbol-local coords, Y-up per KiCAD lib convention) and body_bbox (bounding box of pin envelope ±1.27mm, symbol-local coords). IMPORTANT: coordinates are symbol-local (Y-up, pre-rotation); after placement use get_schematic_pin_locations for post-rotation schematic coordinates. Set compact=true for simple 2-pin passives (Device:R/C/L) to get just pin_count, body_bbox, and is_symmetric.", + { + symbols: z + .array(z.string()) + .describe( + "Array of symbols in 'Library:SymbolName' format (e.g., ['Device:R', 'Device:C'])", + ), + schematicPath: z + .string() + .optional() + .describe("Path to .kicad_sch — enables project-local sym-lib-table lookup"), + compact: z + .boolean() + .optional() + .describe("If true, omit per-pin detail for standard 2-pin symmetric passives."), + }, + async (args: { symbols: string[]; schematicPath?: string; compact?: boolean }) => { + const result = await callKicadScript("batch_list_symbol_pins", args); + if (result.success !== false || (result.symbols && Object.keys(result.symbols).length > 0)) { + const lines: string[] = []; + for (const [sym, data] of Object.entries(result.symbols || {})) { + const d = data as any; + const bb = d.body_bbox; + const bboxStr = bb ? ` | body ${bb.width.toFixed(2)}×${bb.height.toFixed(2)}mm` : ""; + if (d.is_symmetric && d.compact) { + lines.push(`${sym} — ${d.pin_count} pin(s), symmetric${bboxStr}`); + } else { + const pinLines = (d.pins || []).map((p: any) => { + const coords = p.x !== undefined ? ` at (${p.x},${p.y}) angle=${p.angle}` : ""; + return ` Pin ${p.number} (${p.name}) — type: ${p.type}${coords}`; + }); + lines.push(`${sym} — ${d.pin_count} pin(s)${bboxStr}:`); + lines.push(...pinLines); + } + } + if (result.errors && Object.keys(result.errors).length > 0) { + lines.push("\nErrors:"); + for (const [sym, err] of Object.entries(result.errors as Record)) { + const hint = err.suggestions?.length + ? ` (did you mean: ${err.suggestions.join(", ")}?)` + : ""; + lines.push(` ${sym}: ${err.message || err}${hint}`); + } + } + return { content: [{ type: "text", text: lines.join("\n") }] }; + } + return { + content: [ + { type: "text", text: `Failed to list pins: ${result.message || "Unknown error"}` }, + ], + }; + }, + ); } diff --git a/src/tools/registry.ts b/src/tools/registry.ts index 445f395..bd64921 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -118,6 +118,11 @@ export const toolCategories: ToolCategory[] = [ description: "Footprint library access: search, browse, get footprint information", tools: ["list_libraries", "search_footprints", "list_library_footprints", "get_footprint_info"], }, + { + name: "symbol_pins", + description: "Read a symbol's pins straight from the library (no schematic needed)", + tools: ["list_symbol_pins", "batch_list_symbol_pins"], + }, { name: "routing", description: "Advanced routing operations: vias, copper pours", diff --git a/tests/test_symbol_pins.py b/tests/test_symbol_pins.py new file mode 100644 index 0000000..ec3f883 --- /dev/null +++ b/tests/test_symbol_pins.py @@ -0,0 +1,137 @@ +""" +Unit tests for the symbol/pin discovery commands (commands/symbol_pins.py). + +These avoid any dependency on a system-wide KiCad library install by feeding the +parser a hand-written symbol block and by stubbing DynamicSymbolLoader, so they run +identically in CI and on a developer machine. +""" + +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +from commands import symbol_pins # noqa: E402 +from commands.symbol_pins import SymbolPinCommands, _body_bbox, _parse_symbol_pins # noqa: E402 + +# A minimal symbol definition block with two pins, in KiCad lib S-expression form. +_R_BLOCK = """ +(symbol "R" + (symbol "R_1_1" + (pin passive line (at 0 3.81 270) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "1" (effects (font (size 1.27 1.27))))) + (pin passive line (at 0 -3.81 90) (length 1.27) + (name "~" (effects (font (size 1.27 1.27)))) + (number "2" (effects (font (size 1.27 1.27))))) + ) +) +""" + +# Three-pin block with named pins to verify name/type/ordering. +_U_BLOCK = """ +(symbol "LDO" + (symbol "LDO_1_1" + (pin power_in line (at -5.08 0 0) (name "VIN" (effects (font (size 1 1)))) (number "1")) + (pin power_out line (at 5.08 0 180) (name "VOUT" (effects (font (size 1 1)))) (number "3")) + (pin power_in line (at 0 -5.08 90) (name "GND" (effects (font (size 1 1)))) (number "2")) + ) +) +""" + + +def _fake_loader(block): + loader = MagicMock() + loader.extract_symbol_from_library.return_value = block + return loader + + +class TestParseSymbolPins: + def test_basic_two_pin(self): + pins = _parse_symbol_pins(_fake_loader(_R_BLOCK), "Device", "R") + assert [p["number"] for p in pins] == ["1", "2"] + assert pins[0] == { + "number": "1", + "name": "~", + "type": "passive", + "x": 0.0, + "y": 3.81, + "angle": 270.0, + } + + def test_named_pins_and_sort(self): + pins = _parse_symbol_pins(_fake_loader(_U_BLOCK), "Reg", "LDO") + # Sorted by (len(number), number): "1","2","3" + assert [p["number"] for p in pins] == ["1", "2", "3"] + by_num = {p["number"]: p for p in pins} + assert by_num["1"]["name"] == "VIN" and by_num["1"]["type"] == "power_in" + assert by_num["3"]["name"] == "VOUT" and by_num["3"]["type"] == "power_out" + + def test_not_found_raises(self): + with pytest.raises(ValueError): + _parse_symbol_pins(_fake_loader(None), "Device", "Nope") + + +class TestBodyBbox: + def test_bbox_math(self): + pins = [{"x": 0.0, "y": 3.81}, {"x": 0.0, "y": -3.81}] + bb = _body_bbox(pins) + assert bb["width"] == pytest.approx(2.54) + assert bb["height"] == pytest.approx(10.16) + assert bb["y_max"] == pytest.approx(5.08) + + def test_bbox_empty(self): + assert _body_bbox([]) is None + + +class TestListSymbolPins: + def test_bad_spec(self): + c = SymbolPinCommands() + r = c.list_symbol_pins({"symbol": "NoColon"}) + assert r["success"] is False + + def test_happy_path(self, monkeypatch): + monkeypatch.setattr(symbol_pins, "DynamicSymbolLoader", lambda **kw: _fake_loader(_R_BLOCK)) + c = SymbolPinCommands() + r = c.list_symbol_pins({"symbol": "Device:R"}) + assert r["success"] is True + assert r["pin_count"] == 2 + assert r["symbol"] == "Device:R" + + def test_not_found_returns_suggestions(self, monkeypatch): + monkeypatch.setattr(symbol_pins, "DynamicSymbolLoader", lambda **kw: _fake_loader(None)) + c = SymbolPinCommands() + r = c.list_symbol_pins({"symbol": "Device:Nope"}) + assert r["success"] is False + assert "suggestions" in r + + +class TestBatchListSymbolPins: + def test_requires_symbols(self): + c = SymbolPinCommands() + assert c.batch_list_symbol_pins({"symbols": []})["success"] is False + + def test_compact_symmetric_passive(self, monkeypatch): + monkeypatch.setattr(symbol_pins, "DynamicSymbolLoader", lambda **kw: _fake_loader(_R_BLOCK)) + c = SymbolPinCommands() + r = c.batch_list_symbol_pins({"symbols": ["Device:R"], "compact": True}) + assert r["success"] is True + entry = r["symbols"]["Device:R"] + assert entry["compact"] is True and entry["is_symmetric"] is True + assert "pins" not in entry # detail omitted in compact mode + assert entry["body_bbox"]["width"] == pytest.approx(2.54) + + def test_full_detail_when_not_compact(self, monkeypatch): + monkeypatch.setattr(symbol_pins, "DynamicSymbolLoader", lambda **kw: _fake_loader(_R_BLOCK)) + c = SymbolPinCommands() + r = c.batch_list_symbol_pins({"symbols": ["Device:R"], "compact": False}) + assert r["symbols"]["Device:R"]["pins"] + + def test_bad_spec_collected_as_error(self): + c = SymbolPinCommands() + r = c.batch_list_symbol_pins({"symbols": ["NoColon"]}) + assert r["success"] is False + assert "NoColon" in r["errors"]