From ce65035a178156b29a7611be59bdaefc6782bc3d Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Wed, 4 Mar 2026 16:55:09 -0700 Subject: [PATCH] Add batch operations, power symbols, pattern templates, and schematic editing New modules: - patterns/ library: decoupling bank, pull resistor, crystal oscillator placement with power symbol attachment and grid math helpers - tools/batch.py: atomic file-based batch operations with dry_run - tools/power_symbols.py: add_power_symbol with auto #PWR refs - tools/schematic_patterns.py: MCP wrappers for pattern library - tools/schematic_edit.py: modify/remove components, title blocks, annotations - resources/schematic.py: schematic data resources 43 new tests (99 total), lint clean. --- pyproject.toml | 1 + src/mckicad/config.py | 22 + src/mckicad/patterns/__init__.py | 42 ++ src/mckicad/patterns/_geometry.py | 226 ++++++++ src/mckicad/patterns/crystal_oscillator.py | 170 ++++++ src/mckicad/patterns/decoupling_bank.py | 137 +++++ src/mckicad/patterns/pull_resistor.py | 103 ++++ src/mckicad/prompts/templates.py | 10 +- src/mckicad/resources/schematic.py | 149 ++++++ src/mckicad/server.py | 6 + src/mckicad/tools/batch.py | 470 +++++++++++++++++ src/mckicad/tools/power_symbols.py | 165 ++++++ src/mckicad/tools/schematic_edit.py | 580 +++++++++++++++++++++ src/mckicad/tools/schematic_patterns.py | 296 +++++++++++ src/mckicad/utils/file_utils.py | 36 ++ tests/__init__.py | 0 tests/conftest.py | 84 +++ tests/test_batch.py | 188 +++++++ tests/test_geometry.py | 162 ++++++ tests/test_patterns.py | 244 +++++++++ tests/test_power_symbols.py | 175 +++++++ tests/test_schematic_edit.py | 176 +++++++ 22 files changed, 3441 insertions(+), 1 deletion(-) create mode 100644 src/mckicad/patterns/__init__.py create mode 100644 src/mckicad/patterns/_geometry.py create mode 100644 src/mckicad/patterns/crystal_oscillator.py create mode 100644 src/mckicad/patterns/decoupling_bank.py create mode 100644 src/mckicad/patterns/pull_resistor.py create mode 100644 src/mckicad/resources/schematic.py create mode 100644 src/mckicad/tools/batch.py create mode 100644 src/mckicad/tools/power_symbols.py create mode 100644 src/mckicad/tools/schematic_edit.py create mode 100644 src/mckicad/tools/schematic_patterns.py create mode 100644 tests/__init__.py create mode 100644 tests/test_batch.py create mode 100644 tests/test_geometry.py create mode 100644 tests/test_patterns.py create mode 100644 tests/test_power_symbols.py create mode 100644 tests/test_schematic_edit.py diff --git a/pyproject.toml b/pyproject.toml index f83a1ba..042c2f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,6 +92,7 @@ addopts = [ "--tb=short", ] testpaths = ["tests"] +pythonpath = ["."] python_files = ["test_*.py"] python_functions = ["test_*"] markers = [ diff --git a/src/mckicad/config.py b/src/mckicad/config.py index e2188b0..e4ba141 100644 --- a/src/mckicad/config.py +++ b/src/mckicad/config.py @@ -85,6 +85,8 @@ KICAD_EXTENSIONS = { DATA_EXTENSIONS = [".csv", ".pos", ".net", ".zip", ".drl"] +INLINE_RESULT_THRESHOLD = 20 + TIMEOUT_CONSTANTS = { "kicad_cli_version_check": 10.0, "kicad_cli_export": 30.0, @@ -112,6 +114,26 @@ COMMON_LIBRARIES = { }, } +POWER_SYMBOL_DEFAULTS = { + "stub_length": 5.08, + "grid_snap": 2.54, + "fine_grid": 1.27, +} + +DECOUPLING_DEFAULTS = { + "cols": 6, + "h_spacing": 12.7, + "v_spacing": 15.0, + "offset_below_ic": 20.0, +} + +BATCH_LIMITS = { + "max_components": 500, + "max_wires": 1000, + "max_labels": 500, + "max_total_operations": 2000, +} + DEFAULT_FOOTPRINTS = { "R": [ "Resistor_SMD:R_0805_2012Metric", diff --git a/src/mckicad/patterns/__init__.py b/src/mckicad/patterns/__init__.py new file mode 100644 index 0000000..7983b23 --- /dev/null +++ b/src/mckicad/patterns/__init__.py @@ -0,0 +1,42 @@ +""" +Importable pattern library for common KiCad schematic subcircuits. + +These functions operate directly on kicad-sch-api SchematicDocument objects +and do NOT call .save() — the caller manages the load/save lifecycle. +This makes them composable: use them from MCP tools (single save after all +patterns applied) or from standalone Python scripts. + +Public API: + add_power_symbol_to_pin — attach a power symbol to a component pin + place_decoupling_bank — grid of decoupling caps with power/GND symbols + place_pull_resistor — pull-up or pull-down resistor on a signal pin + place_crystal_with_caps — crystal oscillator with load capacitors + +Geometry helpers: + snap_to_grid — snap coordinates to KiCad grid + grid_positions — generate grid layout coordinates + is_ground_net — heuristic ground net name detection + resolve_power_lib_id — map net name to power library symbol +""" + +from mckicad.patterns._geometry import ( + add_power_symbol_to_pin, + grid_positions, + is_ground_net, + resolve_power_lib_id, + snap_to_grid, +) +from mckicad.patterns.crystal_oscillator import place_crystal_with_caps +from mckicad.patterns.decoupling_bank import place_decoupling_bank +from mckicad.patterns.pull_resistor import place_pull_resistor + +__all__ = [ + "add_power_symbol_to_pin", + "grid_positions", + "is_ground_net", + "place_crystal_with_caps", + "place_decoupling_bank", + "place_pull_resistor", + "resolve_power_lib_id", + "snap_to_grid", +] diff --git a/src/mckicad/patterns/_geometry.py b/src/mckicad/patterns/_geometry.py new file mode 100644 index 0000000..9f68759 --- /dev/null +++ b/src/mckicad/patterns/_geometry.py @@ -0,0 +1,226 @@ +""" +Shared geometry helpers for KiCad schematic pattern placement. + +These utilities handle grid snapping, layout generation, power symbol +detection, and the core power-symbol-to-pin attachment logic shared by +all pattern modules and the add_power_symbol MCP tool. + +All functions that accept a ``sch`` parameter expect a kicad-sch-api +SchematicDocument. None of them call ``sch.save()`` — the caller is +responsible for the load/save lifecycle. +""" + +import logging +import re +from typing import Any + +from mckicad.config import COMMON_LIBRARIES, POWER_SYMBOL_DEFAULTS + +logger = logging.getLogger(__name__) + +# Pre-compiled patterns for ground net detection +_GROUND_PATTERNS = re.compile( + r"^(GND[A-Z0-9_]*|VSS[A-Z0-9_]*|AGND|DGND|PGND|SGND|GND)$", + re.IGNORECASE, +) + + +def snap_to_grid(value: float, grid: float = 2.54) -> float: + """Snap a coordinate value to the nearest KiCad grid point. + + KiCad uses a 2.54mm (100mil) standard grid with a 1.27mm (50mil) fine + grid. Snapping prevents off-grid placement that causes DRC warnings + and connection issues. + + Args: + value: Coordinate value in mm. + grid: Grid spacing in mm. Defaults to 2.54 (standard grid). + + Returns: + The nearest grid-aligned value. + """ + return round(value / grid) * grid + + +def grid_positions( + origin_x: float, + origin_y: float, + count: int, + cols: int = 6, + h_spacing: float = 12.7, + v_spacing: float = 15.0, +) -> list[tuple[float, float]]: + """Generate a grid of positions for placing multiple components. + + Fills left-to-right, top-to-bottom. All coordinates are snapped to + the standard 2.54mm grid. + + Args: + origin_x: Top-left X coordinate of the grid. + origin_y: Top-left Y coordinate of the grid. + count: Number of positions to generate. + cols: Maximum columns before wrapping to the next row. + h_spacing: Horizontal spacing between columns in mm. + v_spacing: Vertical spacing between rows in mm. + + Returns: + List of (x, y) tuples, one per requested position. + """ + positions: list[tuple[float, float]] = [] + for i in range(count): + col = i % cols + row = i // cols + x = snap_to_grid(origin_x + col * h_spacing) + y = snap_to_grid(origin_y + row * v_spacing) + positions.append((x, y)) + return positions + + +def is_ground_net(net: str) -> bool: + """Heuristic check whether a net name represents a ground rail. + + Matches common ground naming conventions used in KiCad schematics: + GND, GNDA, GNDD, PGND, VSS, VSSA, GND_*, etc. + + Args: + net: Net name string. + + Returns: + True if the net name matches a ground pattern. + """ + return bool(_GROUND_PATTERNS.match(net.strip())) + + +def resolve_power_lib_id(net: str) -> str: + """Map a net name to a KiCad power library symbol ID. + + Uses the COMMON_LIBRARIES power section first, then falls back to + ``power:{net}`` which works for most standard symbols in KiCad's + built-in power library (+5V, +3V3, VCC, GND, etc.). + + Args: + net: Net name (e.g. "GND", "+3V3", "VCC"). + + Returns: + Library ID string like ``power:GND`` or ``power:+3V3``. + """ + # Check the known power symbols table + power_libs = COMMON_LIBRARIES.get("power", {}) + key = net.lower().strip() + if key in power_libs: + entry = power_libs[key] + return f"{entry['library']}:{entry['symbol']}" + + # Fall back to power:{net} — KiCad's power library uses the net name + # as the symbol name for most standard rails + return f"power:{net}" + + +def _next_pwr_reference(sch: Any) -> str: + """Find the next available ``#PWR0N`` reference in a schematic. + + Scans existing components for ``#PWR`` references and returns the + next sequential number. + """ + max_num = 0 + for comp in sch.components: + ref = getattr(comp, "reference", "") + if ref.startswith("#PWR"): + suffix = ref[4:] + try: + num = int(suffix) + max_num = max(max_num, num) + except ValueError: + pass + return f"#PWR{max_num + 1:02d}" + + +def add_power_symbol_to_pin( + sch: Any, + pin_position: tuple[float, float], + net: str, + lib_id: str | None = None, + stub_length: float | None = None, +) -> dict[str, Any]: + """Attach a power symbol to a pin position with a wire stub. + + Core logic shared by the ``add_power_symbol`` MCP tool and all pattern + modules. Places the symbol above (supply) or below (ground) the pin + with a connecting wire stub. + + Does NOT call ``sch.save()`` — the caller manages persistence. + + Args: + sch: A kicad-sch-api SchematicDocument instance. + pin_position: (x, y) coordinate of the target pin. + net: Power net name (e.g. "GND", "+3V3", "VCC"). + lib_id: Override the auto-detected library symbol ID. + stub_length: Wire stub length in mm. Defaults to config value. + + Returns: + Dictionary describing what was placed: reference, lib_id, + symbol_position, wire_id, etc. + """ + if stub_length is None: + stub_length = POWER_SYMBOL_DEFAULTS["stub_length"] + + if lib_id is None: + lib_id = resolve_power_lib_id(net) + + pin_x, pin_y = pin_position + ground = is_ground_net(net) + + # Ground symbols go below the pin; supply symbols go above. + # In KiCad's coordinate system, Y increases downward. + symbol_y = snap_to_grid(pin_y + stub_length) if ground else snap_to_grid(pin_y - stub_length) + + symbol_x = snap_to_grid(pin_x) + symbol_y = snap_to_grid(symbol_y) + + # Auto-assign a #PWR reference + reference = _next_pwr_reference(sch) + + # Place the power symbol using add_with_pin_at so pin 1 lands + # at the symbol position (power symbols connect at their origin) + try: + sch.components.add_with_pin_at( + lib_id=lib_id, + pin_number="1", + pin_position=(symbol_x, symbol_y), + reference=reference, + value=net, + ) + except (TypeError, AttributeError): + # Fall back to positional add if add_with_pin_at unavailable + sch.components.add( + lib_id=lib_id, + reference=reference, + value=net, + position=(symbol_x, symbol_y), + ) + + # Draw wire stub from pin to power symbol + wire_id = sch.add_wire( + start=(pin_x, pin_y), + end=(symbol_x, symbol_y), + ) + + logger.info( + "Placed %s (%s) at (%.2f, %.2f) with stub from (%.2f, %.2f)", + reference, + lib_id, + symbol_x, + symbol_y, + pin_x, + pin_y, + ) + + return { + "reference": reference, + "lib_id": lib_id, + "net": net, + "symbol_position": {"x": symbol_x, "y": symbol_y}, + "pin_position": {"x": pin_x, "y": pin_y}, + "wire_id": wire_id, + "direction": "down" if ground else "up", + } diff --git a/src/mckicad/patterns/crystal_oscillator.py b/src/mckicad/patterns/crystal_oscillator.py new file mode 100644 index 0000000..6d993e9 --- /dev/null +++ b/src/mckicad/patterns/crystal_oscillator.py @@ -0,0 +1,170 @@ +""" +Crystal oscillator with load capacitors pattern. + +Places a crystal at a specified position with load capacitors on each +side, ground symbols on the caps, and optional wires to IC pins. +""" + +import logging +from typing import Any + +from mckicad.patterns._geometry import add_power_symbol_to_pin, snap_to_grid + +logger = logging.getLogger(__name__) + + +def place_crystal_with_caps( + sch: Any, + xtal_value: str, + cap_value: str, + x: float, + y: float, + ic_ref: str | None = None, + ic_xin_pin: str | None = None, + ic_xout_pin: str | None = None, + ground_net: str = "GND", + xtal_lib_id: str = "Device:Crystal", + cap_lib_id: str = "Device:C", +) -> dict[str, Any]: + """Place a crystal oscillator with load capacitors. + + Creates the classic crystal + 2x load cap circuit: + - Crystal at center + - Load cap on each side (XIN and XOUT) + - GND on each cap's second pin + - Optional wires to IC oscillator pins + + Does NOT call ``sch.save()`` — caller manages persistence. + + Args: + sch: A kicad-sch-api SchematicDocument. + xtal_value: Crystal frequency string (e.g. "16MHz", "32.768kHz"). + cap_value: Load capacitor value (e.g. "22pF", "15pF"). + x: Center X position for the crystal. + y: Center Y position for the crystal. + ic_ref: Optional IC reference for wiring (e.g. "U1"). + ic_xin_pin: XIN pin number on the IC. + ic_xout_pin: XOUT pin number on the IC. + ground_net: Ground rail name. + xtal_lib_id: Crystal symbol library ID. + cap_lib_id: Capacitor symbol library ID. + + Returns: + Dictionary with crystal and cap references, wire IDs, and + power symbol details. + """ + cap_offset = 10.16 # Horizontal offset for load caps from crystal + + # Auto-generate references + max_y_num = 0 + max_c_num = 0 + for comp in sch.components: + ref = getattr(comp, "reference", "") + if ref.startswith("Y") and ref[1:].isdigit(): + max_y_num = max(max_y_num, int(ref[1:])) + elif ref.startswith("C") and ref[1:].isdigit(): + max_c_num = max(max_c_num, int(ref[1:])) + + xtal_ref = f"Y{max_y_num + 1}" + cap_xin_ref = f"C{max_c_num + 1}" + cap_xout_ref = f"C{max_c_num + 2}" + + # Place crystal at center + xtal_x = snap_to_grid(x) + xtal_y = snap_to_grid(y) + sch.components.add( + lib_id=xtal_lib_id, + reference=xtal_ref, + value=xtal_value, + position=(xtal_x, xtal_y), + ) + + # Place load caps on each side + cap_xin_x = snap_to_grid(x - cap_offset) + cap_xout_x = snap_to_grid(x + cap_offset) + cap_y = snap_to_grid(y + 7.62) # Below the crystal + + sch.components.add( + lib_id=cap_lib_id, + reference=cap_xin_ref, + value=cap_value, + position=(cap_xin_x, cap_y), + ) + sch.components.add( + lib_id=cap_lib_id, + reference=cap_xout_ref, + value=cap_value, + position=(cap_xout_x, cap_y), + ) + + # GND on each cap's pin 2 + gnd_symbols: list[dict[str, Any]] = [] + for cap_ref in (cap_xin_ref, cap_xout_ref): + pin2_pos = sch.get_component_pin_position(cap_ref, "2") + if pin2_pos: + gnd_result = add_power_symbol_to_pin( + sch=sch, + pin_position=(pin2_pos.x, pin2_pos.y), + net=ground_net, + ) + gnd_symbols.append(gnd_result) + + # Wire crystal pins to cap pins + internal_wires: list[str] = [] + + # Crystal pin 1 -> XIN cap pin 1 + xtal_pin1 = sch.get_component_pin_position(xtal_ref, "1") + cap_xin_pin1 = sch.get_component_pin_position(cap_xin_ref, "1") + if xtal_pin1 and cap_xin_pin1: + wid = sch.add_wire( + start=(xtal_pin1.x, xtal_pin1.y), + end=(cap_xin_pin1.x, cap_xin_pin1.y), + ) + internal_wires.append(str(wid)) + + # Crystal pin 2 -> XOUT cap pin 1 + xtal_pin2 = sch.get_component_pin_position(xtal_ref, "2") + cap_xout_pin1 = sch.get_component_pin_position(cap_xout_ref, "1") + if xtal_pin2 and cap_xout_pin1: + wid = sch.add_wire( + start=(xtal_pin2.x, xtal_pin2.y), + end=(cap_xout_pin1.x, cap_xout_pin1.y), + ) + internal_wires.append(str(wid)) + + # Optional wires to IC pins + ic_wires: list[str] = [] + if ic_ref and ic_xin_pin: + ic_xin_pos = sch.get_component_pin_position(ic_ref, ic_xin_pin) + if ic_xin_pos and xtal_pin1: + wid = sch.add_wire( + start=(xtal_pin1.x, xtal_pin1.y), + end=(ic_xin_pos.x, ic_xin_pos.y), + ) + ic_wires.append(str(wid)) + + if ic_ref and ic_xout_pin: + ic_xout_pos = sch.get_component_pin_position(ic_ref, ic_xout_pin) + if ic_xout_pos and xtal_pin2: + wid = sch.add_wire( + start=(xtal_pin2.x, xtal_pin2.y), + end=(ic_xout_pos.x, ic_xout_pos.y), + ) + ic_wires.append(str(wid)) + + logger.info( + "Placed crystal %s (%s) with caps %s, %s (%s) at (%.1f, %.1f)", + xtal_ref, xtal_value, cap_xin_ref, cap_xout_ref, cap_value, x, y, + ) + + return { + "crystal_ref": xtal_ref, + "xtal_value": xtal_value, + "cap_xin_ref": cap_xin_ref, + "cap_xout_ref": cap_xout_ref, + "cap_value": cap_value, + "gnd_symbols": gnd_symbols, + "internal_wires": internal_wires, + "ic_wires": ic_wires, + "ground_net": ground_net, + } diff --git a/src/mckicad/patterns/decoupling_bank.py b/src/mckicad/patterns/decoupling_bank.py new file mode 100644 index 0000000..fd02009 --- /dev/null +++ b/src/mckicad/patterns/decoupling_bank.py @@ -0,0 +1,137 @@ +""" +Decoupling capacitor bank pattern. + +Places a grid of decoupling capacitors with power and ground symbols +attached. Common pattern for IC power supply filtering in PCB designs. +""" + +import logging +from typing import Any + +from mckicad.config import DECOUPLING_DEFAULTS +from mckicad.patterns._geometry import ( + add_power_symbol_to_pin, + grid_positions, +) + +logger = logging.getLogger(__name__) + + +def place_decoupling_bank( + sch: Any, + caps: list[dict[str, str]], + power_net: str, + x: float, + y: float, + cols: int | None = None, + h_spacing: float | None = None, + v_spacing: float | None = None, + ground_net: str = "GND", + cap_lib_id: str = "Device:C", +) -> dict[str, Any]: + """Place a grid of decoupling capacitors with power and ground symbols. + + Each capacitor gets a power symbol on pin 1 and a ground symbol on + pin 2. Caps are arranged in a grid layout (left-to-right, + top-to-bottom). + + Does NOT call ``sch.save()`` — caller manages persistence. + + Args: + sch: A kicad-sch-api SchematicDocument. + caps: List of cap specifications. Each dict must have at least + ``value`` (e.g. "100nF"). Optional ``reference`` to force a + specific ref, otherwise auto-assigned as C1, C2, etc. + power_net: Supply rail name (e.g. "+3V3", "VCC", "+5V"). + x: Top-left X of the placement grid. + y: Top-left Y of the placement grid. + cols: Max columns before wrapping. Defaults to config value. + h_spacing: Horizontal spacing in mm. Defaults to config value. + v_spacing: Vertical spacing in mm. Defaults to config value. + ground_net: Ground rail name. Defaults to "GND". + cap_lib_id: Library ID for capacitor symbol. Defaults to "Device:C". + + Returns: + Dictionary with placed references, power symbol details, and grid bounds. + """ + if cols is None: + cols = int(DECOUPLING_DEFAULTS["cols"]) + if h_spacing is None: + h_spacing = float(DECOUPLING_DEFAULTS["h_spacing"]) + if v_spacing is None: + v_spacing = float(DECOUPLING_DEFAULTS["v_spacing"]) + + positions = grid_positions(x, y, len(caps), cols=cols, h_spacing=h_spacing, v_spacing=v_spacing) + + # Find the highest existing C reference to avoid collisions + max_c = 0 + for comp in sch.components: + comp_ref = getattr(comp, "reference", "") + if comp_ref.startswith("C") and comp_ref[1:].isdigit(): + max_c = max(max_c, int(comp_ref[1:])) + + placed_refs: list[str] = [] + power_symbols: list[dict[str, Any]] = [] + + for i, (cap_spec, pos) in enumerate(zip(caps, positions)): + cap_x, cap_y = pos + ref = cap_spec.get("reference", f"C{max_c + i + 1}") + value = cap_spec.get("value", "100nF") + + # Place the capacitor + sch.components.add( + lib_id=cap_lib_id, + reference=ref, + value=value, + position=(cap_x, cap_y), + ) + placed_refs.append(ref) + + # Get pin positions from the placed component + pin1_pos = sch.get_component_pin_position(ref, "1") + pin2_pos = sch.get_component_pin_position(ref, "2") + + # Power symbol on pin 1 (supply) + if pin1_pos: + pwr_result = add_power_symbol_to_pin( + sch=sch, + pin_position=(pin1_pos.x, pin1_pos.y), + net=power_net, + ) + power_symbols.append(pwr_result) + + # Ground symbol on pin 2 + if pin2_pos: + gnd_result = add_power_symbol_to_pin( + sch=sch, + pin_position=(pin2_pos.x, pin2_pos.y), + net=ground_net, + ) + power_symbols.append(gnd_result) + + # Calculate grid bounds + if positions: + all_x = [p[0] for p in positions] + all_y = [p[1] for p in positions] + bounds = { + "min_x": min(all_x), + "min_y": min(all_y), + "max_x": max(all_x), + "max_y": max(all_y), + } + else: + bounds = {"min_x": x, "min_y": y, "max_x": x, "max_y": y} + + logger.info( + "Placed decoupling bank: %d caps, %s/%s, grid at (%.1f, %.1f)", + len(placed_refs), power_net, ground_net, x, y, + ) + + return { + "placed_refs": placed_refs, + "cap_count": len(placed_refs), + "power_symbols": power_symbols, + "power_net": power_net, + "ground_net": ground_net, + "grid_bounds": bounds, + } diff --git a/src/mckicad/patterns/pull_resistor.py b/src/mckicad/patterns/pull_resistor.py new file mode 100644 index 0000000..73a31d4 --- /dev/null +++ b/src/mckicad/patterns/pull_resistor.py @@ -0,0 +1,103 @@ +""" +Pull-up / pull-down resistor pattern. + +Places a resistor connected between a signal pin and a power rail, +with the power symbol automatically attached. +""" + +import logging +from typing import Any + +from mckicad.patterns._geometry import add_power_symbol_to_pin, snap_to_grid + +logger = logging.getLogger(__name__) + + +def place_pull_resistor( + sch: Any, + signal_ref: str, + signal_pin: str, + rail_net: str, + value: str = "10k", + offset_x: float = 5.08, + resistor_lib_id: str = "Device:R", +) -> dict[str, Any]: + """Place a pull-up or pull-down resistor on a signal pin. + + Connects one end of the resistor to the signal pin via a wire, and + attaches a power symbol to the other end. Direction (pull-up vs + pull-down) is inferred from the rail net name. + + Does NOT call ``sch.save()`` — caller manages persistence. + + Args: + sch: A kicad-sch-api SchematicDocument. + signal_ref: Reference designator of the signal component (e.g. "U1"). + signal_pin: Pin number on the signal component (e.g. "3"). + rail_net: Power rail name (e.g. "+3V3" for pull-up, "GND" for pull-down). + value: Resistor value string. Defaults to "10k". + offset_x: Horizontal offset from signal pin for resistor placement. + resistor_lib_id: Library ID for resistor symbol. + + Returns: + Dictionary with resistor reference, wire ID, and power symbol details. + """ + # Find the signal pin position + pin_pos = sch.get_component_pin_position(signal_ref, signal_pin) + if pin_pos is None: + raise ValueError( + f"Pin {signal_pin} on component {signal_ref} not found. " + f"Use get_component_pins to list available pins." + ) + + # Place resistor offset from the signal pin + res_x = snap_to_grid(pin_pos.x + offset_x) + res_y = snap_to_grid(pin_pos.y) + + # Auto-generate a reference by finding next available R number + max_r = 0 + for comp in sch.components: + ref = getattr(comp, "reference", "") + if ref.startswith("R") and ref[1:].isdigit(): + max_r = max(max_r, int(ref[1:])) + res_ref = f"R{max_r + 1}" + + sch.components.add( + lib_id=resistor_lib_id, + reference=res_ref, + value=value, + position=(res_x, res_y), + ) + + # Wire the signal pin to resistor pin 1 + res_pin1_pos = sch.get_component_pin_position(res_ref, "1") + wire_id = None + if res_pin1_pos: + wire_id = sch.add_wire( + start=(pin_pos.x, pin_pos.y), + end=(res_pin1_pos.x, res_pin1_pos.y), + ) + + # Power symbol on resistor pin 2 + res_pin2_pos = sch.get_component_pin_position(res_ref, "2") + power_result = None + if res_pin2_pos: + power_result = add_power_symbol_to_pin( + sch=sch, + pin_position=(res_pin2_pos.x, res_pin2_pos.y), + net=rail_net, + ) + + logger.info( + "Placed pull resistor %s (%s) from %s.%s to %s", + res_ref, value, signal_ref, signal_pin, rail_net, + ) + + return { + "resistor_ref": res_ref, + "value": value, + "signal": {"reference": signal_ref, "pin": signal_pin}, + "rail_net": rail_net, + "wire_id": str(wire_id) if wire_id else None, + "power_symbol": power_result, + } diff --git a/src/mckicad/prompts/templates.py b/src/mckicad/prompts/templates.py index 10207ce..ee5c33d 100644 --- a/src/mckicad/prompts/templates.py +++ b/src/mckicad/prompts/templates.py @@ -33,5 +33,13 @@ Steps: 1) Select components, 2) Create schematic, 3) Add connections, def debug_schematic(schematic_path: str) -> str: """Help debug schematic connectivity issues.""" return f"""Analyze the schematic at {schematic_path} for issues. + +Recommended workflow: +1. Run get_schematic_info to get statistics and validation summary +2. Run run_schematic_erc to check for electrical rule violations +3. Run analyze_connectivity to inspect the net graph for unconnected pins +4. Use check_pin_connection on suspicious pins to trace connectivity +5. Use get_schematic_hierarchy if this is a multi-sheet design + Check for: unconnected pins, missing power connections, incorrect -component values, and ERC violations.""" +component values, ERC violations, and net continuity issues.""" diff --git a/src/mckicad/resources/schematic.py b/src/mckicad/resources/schematic.py new file mode 100644 index 0000000..ac614fe --- /dev/null +++ b/src/mckicad/resources/schematic.py @@ -0,0 +1,149 @@ +""" +MCP resources for browsable schematic data. + +Provides read-only access to component lists, net lists, and sheet +hierarchy for KiCad schematics via MCP resource URIs. +""" + +import json +import logging +import os +from typing import Any + +from mckicad.server import mcp + +logger = logging.getLogger(__name__) + +_HAS_SCH_API = False + +try: + from kicad_sch_api import load_schematic as _ksa_load + + _HAS_SCH_API = True +except ImportError: + pass + + +def _expand(path: str) -> str: + return os.path.abspath(os.path.expanduser(path)) + + +@mcp.resource("kicad://schematic/{path}/components") +def schematic_components_resource(path: str) -> str: + """Browsable component list for a KiCad schematic. + + Returns a JSON array of all components with reference, lib_id, value, + and position. + """ + if not _HAS_SCH_API: + return json.dumps({"error": "kicad-sch-api not installed"}) + + expanded = _expand(path) + if not os.path.isfile(expanded): + return json.dumps({"error": f"File not found: {expanded}"}) + + try: + sch = _ksa_load(expanded) + components: list[dict[str, Any]] = [] + for comp in sch.components: + entry: dict[str, Any] = { + "reference": getattr(comp, "reference", None), + "lib_id": getattr(comp, "lib_id", None), + "value": getattr(comp, "value", None), + } + pos = getattr(comp, "position", None) + if pos is not None and isinstance(pos, (list, tuple)) and len(pos) >= 2: + entry["position"] = {"x": pos[0], "y": pos[1]} + components.append(entry) + return json.dumps(components, indent=2) + except Exception as e: + logger.error("Failed to load schematic components for resource: %s", e) + return json.dumps({"error": str(e)}) + + +@mcp.resource("kicad://schematic/{path}/nets") +def schematic_nets_resource(path: str) -> str: + """Browsable net list for a KiCad schematic. + + Returns a JSON object mapping net names to lists of connected pins. + """ + if not _HAS_SCH_API: + return json.dumps({"error": "kicad-sch-api not installed"}) + + expanded = _expand(path) + if not os.path.isfile(expanded): + return json.dumps({"error": f"File not found: {expanded}"}) + + try: + sch = _ksa_load(expanded) + nets_attr = getattr(sch, "nets", None) + + if nets_attr is None: + return json.dumps({"error": "Schematic has no nets attribute"}) + + net_data: dict[str, list[dict[str, str]]] = {} + if isinstance(nets_attr, dict): + for net_name, pins in nets_attr.items(): + pin_list = [] + for p in (pins if isinstance(pins, (list, tuple)) else []): + if isinstance(p, dict): + pin_list.append(p) + else: + pin_list.append({ + "reference": str(getattr(p, "reference", "")), + "pin": str(getattr(p, "pin", getattr(p, "number", ""))), + }) + net_data[str(net_name)] = pin_list + + return json.dumps(net_data, indent=2) + except Exception as e: + logger.error("Failed to load schematic nets for resource: %s", e) + return json.dumps({"error": str(e)}) + + +@mcp.resource("kicad://schematic/{path}/hierarchy") +def schematic_hierarchy_resource(path: str) -> str: + """Browsable sheet hierarchy for a KiCad schematic. + + Returns the hierarchical sheet tree with filenames and per-sheet + component counts. Useful for understanding multi-sheet designs. + """ + if not _HAS_SCH_API: + return json.dumps({"error": "kicad-sch-api not installed"}) + + expanded = _expand(path) + if not os.path.isfile(expanded): + return json.dumps({"error": f"File not found: {expanded}"}) + + try: + sch = _ksa_load(expanded) + + def _build(sheet_sch: Any, name: str, filename: str) -> dict[str, Any]: + info: dict[str, Any] = { + "name": name, + "filename": filename, + "component_count": len(list(sheet_sch.components)) if hasattr(sheet_sch, "components") else 0, + } + children: list[dict[str, Any]] = [] + if hasattr(sheet_sch, "sheets"): + for child in sheet_sch.sheets: + child_name = getattr(child, "name", "unknown") + child_file = getattr(child, "filename", "unknown") + child_path = os.path.join(os.path.dirname(expanded), child_file) + if os.path.isfile(child_path): + try: + child_sch = _ksa_load(child_path) + children.append(_build(child_sch, child_name, child_file)) + except Exception: + children.append({"name": child_name, "filename": child_file, "error": "load failed"}) + else: + children.append({"name": child_name, "filename": child_file, "note": "file not found"}) + if children: + info["sheets"] = children + return info + + hierarchy = _build(sch, "root", os.path.basename(expanded)) + return json.dumps(hierarchy, indent=2) + except Exception as e: + logger.error("Failed to load schematic hierarchy for resource: %s", e) + return json.dumps({"error": str(e)}) diff --git a/src/mckicad/server.py b/src/mckicad/server.py index 2d73ca1..0f0d53f 100644 --- a/src/mckicad/server.py +++ b/src/mckicad/server.py @@ -45,15 +45,21 @@ mcp = FastMCP("mckicad", lifespan=lifespan) # Order doesn't matter — each module does @mcp.tool() at module level. from mckicad.prompts import templates # noqa: E402, F401 from mckicad.resources import files, projects # noqa: E402, F401 +from mckicad.resources import schematic as schematic_resources # noqa: E402, F401 from mckicad.tools import ( # noqa: E402, F401 analysis, + batch, bom, drc, export, pcb, + power_symbols, project, routing, schematic, + schematic_analysis, + schematic_edit, + schematic_patterns, ) diff --git a/src/mckicad/tools/batch.py b/src/mckicad/tools/batch.py new file mode 100644 index 0000000..8cd8729 --- /dev/null +++ b/src/mckicad/tools/batch.py @@ -0,0 +1,470 @@ +""" +Batch operations tool for the mckicad MCP server. + +Applies multiple schematic modifications (components, power symbols, wires, +labels, no-connects) atomically from a JSON file. All operations share a +single load-save cycle for performance and consistency. + +Batch JSON files live in the ``.mckicad/`` sidecar directory by default. +The schema supports five operation types processed in dependency order: +components -> power_symbols -> wires -> labels -> no_connects. +""" + +import json +import logging +import os +from typing import Any + +from mckicad.config import BATCH_LIMITS +from mckicad.server import mcp + +logger = logging.getLogger(__name__) + +_HAS_SCH_API = False + +try: + from kicad_sch_api import load_schematic as _ksa_load + + _HAS_SCH_API = True +except ImportError: + logger.warning( + "kicad-sch-api not installed — batch tools will return helpful errors. " + "Install with: uv add kicad-sch-api" + ) + + +def _require_sch_api() -> dict[str, Any] | None: + if not _HAS_SCH_API: + return { + "success": False, + "error": "kicad-sch-api is not installed. Install it with: uv add kicad-sch-api", + "engine": "none", + } + return None + + +def _get_schematic_engine() -> str: + return "kicad-sch-api" if _HAS_SCH_API else "none" + + +def _validate_schematic_path(path: str, must_exist: bool = True) -> dict[str, Any] | None: + if not path: + return {"success": False, "error": "Schematic path must be a non-empty string"} + expanded = os.path.expanduser(path) + if not expanded.endswith(".kicad_sch"): + return {"success": False, "error": f"Path must end with .kicad_sch, got: {path}"} + if must_exist and not os.path.isfile(expanded): + return {"success": False, "error": f"Schematic file not found: {expanded}"} + return None + + +def _expand(path: str) -> str: + return os.path.abspath(os.path.expanduser(path)) + + +def _resolve_batch_file(batch_file: str, schematic_path: str) -> str: + """Resolve batch file path, checking .mckicad/ directory if relative.""" + expanded = os.path.expanduser(batch_file) + if os.path.isabs(expanded): + return expanded + + # Try relative to .mckicad/ sidecar directory + sch_dir = os.path.dirname(os.path.abspath(schematic_path)) + mckicad_path = os.path.join(sch_dir, ".mckicad", expanded) + if os.path.isfile(mckicad_path): + return mckicad_path + + # Try relative to schematic directory + sch_relative = os.path.join(sch_dir, expanded) + if os.path.isfile(sch_relative): + return sch_relative + + return mckicad_path # Return .mckicad path for error reporting + + +def _validate_batch_data(data: dict[str, Any], sch: Any) -> list[str]: + """Validate batch JSON data before applying. Returns list of error strings.""" + errors: list[str] = [] + + # Check for unknown keys + valid_keys = {"components", "power_symbols", "wires", "labels", "no_connects"} + unknown = set(data.keys()) - valid_keys + if unknown: + errors.append(f"Unknown batch keys: {', '.join(sorted(unknown))}") + + components = data.get("components", []) + power_symbols = data.get("power_symbols", []) + wires = data.get("wires", []) + labels = data.get("labels", []) + no_connects = data.get("no_connects", []) + + # Check limits + if len(components) > BATCH_LIMITS["max_components"]: + errors.append( + f"Too many components: {len(components)} " + f"(max {BATCH_LIMITS['max_components']})" + ) + if len(wires) > BATCH_LIMITS["max_wires"]: + errors.append( + f"Too many wires: {len(wires)} (max {BATCH_LIMITS['max_wires']})" + ) + if len(labels) > BATCH_LIMITS["max_labels"]: + errors.append( + f"Too many labels: {len(labels)} (max {BATCH_LIMITS['max_labels']})" + ) + + total = len(components) + len(power_symbols) + len(wires) + len(labels) + len(no_connects) + if total > BATCH_LIMITS["max_total_operations"]: + errors.append( + f"Too many total operations: {total} " + f"(max {BATCH_LIMITS['max_total_operations']})" + ) + + if total == 0: + errors.append("Batch file contains no operations") + + # Validate component entries + # Track refs declared in this batch for wire pin-reference validation + batch_refs: set[str] = set() + existing_refs: set[str] = set() + for comp in sch.components: + ref = getattr(comp, "reference", None) + if ref: + existing_refs.add(ref) + + for i, comp in enumerate(components): + if not isinstance(comp, dict): + errors.append(f"components[{i}]: must be a dict") + continue + if "lib_id" not in comp: + errors.append(f"components[{i}]: missing required field 'lib_id'") + if "x" not in comp or "y" not in comp: + errors.append(f"components[{i}]: missing required fields 'x' and 'y'") + ref = comp.get("reference") + if ref: + batch_refs.add(ref) + + # Validate power symbol entries + for i, ps in enumerate(power_symbols): + if not isinstance(ps, dict): + errors.append(f"power_symbols[{i}]: must be a dict") + continue + if "net" not in ps: + errors.append(f"power_symbols[{i}]: missing required field 'net'") + if "pin_ref" not in ps: + errors.append(f"power_symbols[{i}]: missing required field 'pin_ref'") + if "pin_number" not in ps: + errors.append(f"power_symbols[{i}]: missing required field 'pin_number'") + + pin_ref = ps.get("pin_ref", "") + if pin_ref and pin_ref not in existing_refs and pin_ref not in batch_refs: + errors.append( + f"power_symbols[{i}]: pin_ref '{pin_ref}' not found in schematic " + f"or batch components" + ) + + # Validate wire entries + for i, wire in enumerate(wires): + if not isinstance(wire, dict): + errors.append(f"wires[{i}]: must be a dict") + continue + + has_coords = all(k in wire for k in ("start_x", "start_y", "end_x", "end_y")) + has_refs = all(k in wire for k in ("from_ref", "from_pin", "to_ref", "to_pin")) + + if not has_coords and not has_refs: + errors.append( + f"wires[{i}]: must have either coordinate fields " + f"(start_x/start_y/end_x/end_y) or pin-reference fields " + f"(from_ref/from_pin/to_ref/to_pin)" + ) + + if has_refs: + for ref_key in ("from_ref", "to_ref"): + ref = wire.get(ref_key, "") + if ref and ref not in existing_refs and ref not in batch_refs: + errors.append( + f"wires[{i}]: {ref_key} '{ref}' not found in schematic " + f"or batch components" + ) + + # Validate label entries + for i, label in enumerate(labels): + if not isinstance(label, dict): + errors.append(f"labels[{i}]: must be a dict") + continue + if "text" not in label: + errors.append(f"labels[{i}]: missing required field 'text'") + if "x" not in label or "y" not in label: + errors.append(f"labels[{i}]: missing required fields 'x' and 'y'") + + # Validate no-connect entries + for i, nc in enumerate(no_connects): + if not isinstance(nc, dict): + errors.append(f"no_connects[{i}]: must be a dict") + continue + if "x" not in nc or "y" not in nc: + errors.append(f"no_connects[{i}]: missing required fields 'x' and 'y'") + + return errors + + +def _apply_batch_operations(sch: Any, data: dict[str, Any]) -> dict[str, Any]: + """Apply all batch operations to a loaded schematic. Returns summary.""" + from mckicad.patterns._geometry import add_power_symbol_to_pin + + placed_components: list[str] = [] + placed_power: list[dict[str, Any]] = [] + placed_wires: list[str] = [] + placed_labels: list[str] = [] + placed_no_connects = 0 + + # 1. Components + for comp in data.get("components", []): + ref = comp.get("reference") + sch.components.add( + lib_id=comp["lib_id"], + reference=ref, + value=comp.get("value", ""), + position=(comp["x"], comp["y"]), + ) + # Apply optional rotation + if "rotation" in comp and ref: + placed = sch.components.get(ref) + if placed: + placed.rotate(comp["rotation"]) + placed_components.append(ref or comp["lib_id"]) + + # 2. Power symbols + for ps in data.get("power_symbols", []): + pin_pos = sch.get_component_pin_position(ps["pin_ref"], ps["pin_number"]) + if pin_pos is None: + logger.warning( + "Skipping power symbol: pin %s.%s not found", + ps["pin_ref"], + ps["pin_number"], + ) + continue + result = add_power_symbol_to_pin( + sch=sch, + pin_position=(pin_pos.x, pin_pos.y), + net=ps["net"], + lib_id=ps.get("lib_id"), + stub_length=ps.get("stub_length"), + ) + placed_power.append(result) + + # 3. Wires + for wire in data.get("wires", []): + if "from_ref" in wire: + wire_id = sch.add_wire_between_pins( + component1_ref=wire["from_ref"], + pin1_number=wire["from_pin"], + component2_ref=wire["to_ref"], + pin2_number=wire["to_pin"], + ) + else: + wire_id = sch.add_wire( + start=(wire["start_x"], wire["start_y"]), + end=(wire["end_x"], wire["end_y"]), + ) + placed_wires.append(str(wire_id)) + + # 4. Labels + for label in data.get("labels", []): + is_global = label.get("global", False) + if is_global: + label_id = sch.add_global_label( + text=label["text"], + position=(label["x"], label["y"]), + ) + else: + label_id = sch.add_label( + text=label["text"], + position=(label["x"], label["y"]), + ) + placed_labels.append(str(label_id)) + + # 5. No-connects + for nc in data.get("no_connects", []): + sch.no_connects.add(position=(nc["x"], nc["y"])) + placed_no_connects += 1 + + return { + "components_placed": len(placed_components), + "component_refs": placed_components, + "power_symbols_placed": len(placed_power), + "power_details": placed_power, + "wires_placed": len(placed_wires), + "wire_ids": placed_wires, + "labels_placed": len(placed_labels), + "label_ids": placed_labels, + "no_connects_placed": placed_no_connects, + "total_operations": ( + len(placed_components) + + len(placed_power) + + len(placed_wires) + + len(placed_labels) + + placed_no_connects + ), + } + + +@mcp.tool() +def apply_batch( + schematic_path: str, + batch_file: str, + dry_run: bool = False, +) -> dict[str, Any]: + """Apply a batch of schematic modifications from a JSON file. + + Reads a JSON file containing components, power symbols, wires, labels, + and no-connect flags, then applies them all in a single atomic operation. + All changes share one load-save cycle for performance and consistency. + + Operations are processed in dependency order: components first (so + pin-reference wires can find them), then power symbols, wires, labels, + and finally no-connects. + + Use ``dry_run=True`` to validate the batch file without modifying the + schematic -- returns validation results and a preview of what would be + applied. + + Batch files can be placed in the ``.mckicad/`` directory next to the + schematic for clean organization. + + **Batch JSON schema:** + + .. code-block:: json + + { + "components": [ + {"lib_id": "Device:C", "reference": "C1", "value": "100nF", + "x": 100, "y": 200, "rotation": 0} + ], + "power_symbols": [ + {"net": "GND", "pin_ref": "C1", "pin_number": "2"} + ], + "wires": [ + {"start_x": 100, "start_y": 200, "end_x": 200, "end_y": 200}, + {"from_ref": "R1", "from_pin": "1", "to_ref": "R2", "to_pin": "2"} + ], + "labels": [ + {"text": "SPI_CLK", "x": 150, "y": 100, "global": false} + ], + "no_connects": [ + {"x": 300, "y": 300} + ] + } + + Args: + schematic_path: Path to an existing .kicad_sch file. + batch_file: Path to the batch JSON file. Relative paths are resolved + against the ``.mckicad/`` directory first, then the schematic's + directory. + dry_run: When True, validates the batch without applying changes. + Returns validation results and operation preview. + + Returns: + Dictionary with ``success``, counts of each operation type applied, + and wire/label IDs for the created elements. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + # Resolve and load batch file + resolved_path = _resolve_batch_file(batch_file, schematic_path) + if not os.path.isfile(resolved_path): + return { + "success": False, + "error": f"Batch file not found: {resolved_path}", + "searched_paths": [ + resolved_path, + os.path.join(os.path.dirname(schematic_path), batch_file), + ], + } + + try: + with open(resolved_path) as f: + data = json.load(f) + except json.JSONDecodeError as e: + return { + "success": False, + "error": f"Invalid JSON in batch file: {e}", + "batch_file": resolved_path, + } + + if not isinstance(data, dict): + return { + "success": False, + "error": "Batch file must contain a JSON object at the top level", + "batch_file": resolved_path, + } + + try: + sch = _ksa_load(schematic_path) + + # Validation pass + validation_errors = _validate_batch_data(data, sch) + if validation_errors: + return { + "success": False, + "error": "Batch validation failed", + "validation_errors": validation_errors, + "batch_file": resolved_path, + "schematic_path": schematic_path, + } + + # Preview for dry run + if dry_run: + return { + "success": True, + "dry_run": True, + "preview": { + "components": len(data.get("components", [])), + "power_symbols": len(data.get("power_symbols", [])), + "wires": len(data.get("wires", [])), + "labels": len(data.get("labels", [])), + "no_connects": len(data.get("no_connects", [])), + }, + "validation": "passed", + "batch_file": resolved_path, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + + # Apply all operations + summary = _apply_batch_operations(sch, data) + + # Single save + sch.save(schematic_path) + + logger.info( + "Batch applied: %d operations from %s to %s", + summary["total_operations"], + resolved_path, + schematic_path, + ) + + return { + "success": True, + **summary, + "batch_file": resolved_path, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + + except Exception as e: + logger.error("Batch apply failed for %s: %s", schematic_path, e) + return { + "success": False, + "error": str(e), + "batch_file": resolved_path, + "schematic_path": schematic_path, + } diff --git a/src/mckicad/tools/power_symbols.py b/src/mckicad/tools/power_symbols.py new file mode 100644 index 0000000..aca03bb --- /dev/null +++ b/src/mckicad/tools/power_symbols.py @@ -0,0 +1,165 @@ +""" +Power symbol placement tool for the mckicad MCP server. + +Attaches power rail symbols (GND, VCC, +3V3, etc.) to component pins +with automatic reference numbering, direction detection, and wire stubs. +Uses the shared geometry helpers from the patterns library. +""" + +import logging +import os +from typing import Any + +from mckicad.server import mcp + +logger = logging.getLogger(__name__) + +_HAS_SCH_API = False + +try: + from kicad_sch_api import load_schematic as _ksa_load + + _HAS_SCH_API = True +except ImportError: + logger.warning( + "kicad-sch-api not installed — power symbol tools will return helpful errors. " + "Install with: uv add kicad-sch-api" + ) + + +def _require_sch_api() -> dict[str, Any] | None: + if not _HAS_SCH_API: + return { + "success": False, + "error": "kicad-sch-api is not installed. Install it with: uv add kicad-sch-api", + "engine": "none", + } + return None + + +def _get_schematic_engine() -> str: + return "kicad-sch-api" if _HAS_SCH_API else "none" + + +def _validate_schematic_path(path: str, must_exist: bool = True) -> dict[str, Any] | None: + if not path: + return {"success": False, "error": "Schematic path must be a non-empty string"} + expanded = os.path.expanduser(path) + if not expanded.endswith(".kicad_sch"): + return {"success": False, "error": f"Path must end with .kicad_sch, got: {path}"} + if must_exist and not os.path.isfile(expanded): + return {"success": False, "error": f"Schematic file not found: {expanded}"} + return None + + +def _expand(path: str) -> str: + return os.path.abspath(os.path.expanduser(path)) + + +@mcp.tool() +def add_power_symbol( + schematic_path: str, + net: str, + pin_ref: str, + pin_number: str, + lib_id: str | None = None, + stub_length: float = 5.08, +) -> dict[str, Any]: + """Attach a power symbol (GND, VCC, +3V3, etc.) to a component pin. + + Automatically determines the correct power library symbol from the net + name, assigns a ``#PWR`` reference, places the symbol above (supply) + or below (ground) the pin, and draws a connecting wire stub. + + This is the recommended way to connect power rails to component pins -- + it handles direction, grid alignment, and reference numbering for you. + + For bulk power connections, see ``apply_batch`` (power_symbols section) + or the pattern tools (``place_decoupling_bank_pattern``, etc.). + + Args: + schematic_path: Path to an existing .kicad_sch file. + net: Power net name, e.g. ``GND``, ``+3V3``, ``VCC``, ``+5V``. + The corresponding ``power:`` library symbol is auto-detected. + pin_ref: Reference designator of the target component (e.g. ``C1``, ``U3``). + pin_number: Pin number on the target component (e.g. ``1``, ``2``). + lib_id: Override the auto-detected power symbol library ID. + Only needed for non-standard symbols not in KiCad's power library. + stub_length: Wire stub length in mm between pin and symbol. + Defaults to 5.08 (2 grid units). + + Returns: + Dictionary with ``success``, placed ``reference``, ``lib_id``, + symbol position, wire ID, and direction. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + if not net: + return {"success": False, "error": "net must be a non-empty string"} + if not pin_ref: + return {"success": False, "error": "pin_ref must be a non-empty string"} + if not pin_number: + return {"success": False, "error": "pin_number must be a non-empty string"} + + try: + from mckicad.patterns._geometry import add_power_symbol_to_pin + + sch = _ksa_load(schematic_path) + + # Look up the target pin position + pin_pos = sch.get_component_pin_position(pin_ref, pin_number) + if pin_pos is None: + return { + "success": False, + "error": ( + f"Could not find pin {pin_number} on component {pin_ref}. " + f"Use get_component_pins to list available pins." + ), + "schematic_path": schematic_path, + } + + result = add_power_symbol_to_pin( + sch=sch, + pin_position=(pin_pos.x, pin_pos.y), + net=net, + lib_id=lib_id, + stub_length=stub_length, + ) + + sch.save(schematic_path) + + logger.info( + "Added %s power symbol %s to %s pin %s in %s", + net, + result["reference"], + pin_ref, + pin_number, + schematic_path, + ) + + return { + "success": True, + **result, + "target_component": pin_ref, + "target_pin": pin_number, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + + except Exception as e: + logger.error( + "Failed to add power symbol %s to %s.%s in %s: %s", + net, pin_ref, pin_number, schematic_path, e, + ) + return { + "success": False, + "error": str(e), + "schematic_path": schematic_path, + } diff --git a/src/mckicad/tools/schematic_edit.py b/src/mckicad/tools/schematic_edit.py new file mode 100644 index 0000000..e51374d --- /dev/null +++ b/src/mckicad/tools/schematic_edit.py @@ -0,0 +1,580 @@ +""" +Schematic editing tools for modifying existing elements in KiCad schematics. + +Complements the creation-oriented tools in schematic.py with modification, +removal, and annotation capabilities. Uses the same kicad-sch-api engine +and follows the same swap-point pattern for future kipy IPC support. +""" + +import logging +import os +from typing import Any + +from mckicad.server import mcp + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Engine abstraction — mirrors schematic.py setup +# --------------------------------------------------------------------------- + +_HAS_SCH_API = False + +try: + from kicad_sch_api import load_schematic as _ksa_load + + _HAS_SCH_API = True +except ImportError: + logger.warning( + "kicad-sch-api not installed — schematic edit tools will return helpful errors. " + "Install with: uv add kicad-sch-api" + ) + + +def _get_schematic_engine() -> str: + """Return the name of the active schematic manipulation engine.""" + if _HAS_SCH_API: + return "kicad-sch-api" + return "none" + + +def _require_sch_api() -> dict[str, Any] | None: + """Return an error dict if kicad-sch-api is unavailable, else None.""" + if not _HAS_SCH_API: + return { + "success": False, + "error": "kicad-sch-api is not installed. Install it with: uv add kicad-sch-api", + "engine": "none", + } + return None + + +def _validate_schematic_path(path: str, must_exist: bool = True) -> dict[str, Any] | None: + """Validate a schematic file path. Returns an error dict on failure, else None.""" + if not path: + return {"success": False, "error": "Schematic path must be a non-empty string"} + + expanded = os.path.expanduser(path) + + if not expanded.endswith(".kicad_sch"): + return { + "success": False, + "error": f"Path must end with .kicad_sch, got: {path}", + } + + if must_exist and not os.path.isfile(expanded): + return { + "success": False, + "error": f"Schematic file not found: {expanded}", + } + + return None + + +def _expand(path: str) -> str: + """Expand ~ and return an absolute path.""" + return os.path.abspath(os.path.expanduser(path)) + + +# --------------------------------------------------------------------------- +# Tools +# --------------------------------------------------------------------------- + + +@mcp.tool() +def modify_component( + schematic_path: str, + reference: str, + x: float | None = None, + y: float | None = None, + rotation: float | None = None, + value: str | None = None, + footprint: str | None = None, + properties: dict[str, str] | None = None, + in_bom: bool | None = None, + on_board: bool | None = None, +) -> dict[str, Any]: + """Edit an existing component in a KiCad schematic. + + Supports moving, rotating, changing value/footprint, updating arbitrary + properties, and toggling BOM/board inclusion -- all in a single call. + Only the parameters you provide will be changed; everything else is left + untouched. + + Args: + schematic_path: Path to an existing .kicad_sch file. + reference: Reference designator of the component to modify (e.g. ``R1``, ``U3``). + x: New X position. Must be provided together with ``y`` to move. + y: New Y position. Must be provided together with ``x`` to move. + rotation: New rotation angle in degrees (e.g. 0, 90, 180, 270). + value: New value string (e.g. ``4.7k``, ``100nF``). + footprint: New footprint identifier (e.g. ``Resistor_SMD:R_0603_1608Metric``). + properties: Dictionary of property names to values to set or update. + in_bom: Set to True/False to include or exclude the component from BOM output. + on_board: Set to True/False to include or exclude the component on the PCB. + + Returns: + Dictionary with ``success``, ``reference``, ``modified`` list, and ``engine``. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + if not reference: + return {"success": False, "error": "reference must be a non-empty string"} + + # Validate that move coordinates come in pairs + if (x is None) != (y is None): + return { + "success": False, + "error": "Both x and y must be provided together to move a component", + } + + try: + sch = _ksa_load(schematic_path) + comp = sch.components.get(reference) + if comp is None: + return { + "success": False, + "error": f"Component '{reference}' not found in schematic", + "schematic_path": schematic_path, + } + modified: list[str] = [] + + if x is not None and y is not None: + comp.move(x, y) + modified.append(f"position=({x}, {y})") + + if rotation is not None: + comp.rotate(rotation) + modified.append(f"rotation={rotation}") + + if value is not None: + comp.value = value + modified.append(f"value={value}") + + if footprint is not None: + comp.footprint = footprint + modified.append(f"footprint={footprint}") + + if properties: + for key, val in properties.items(): + comp.set_property(key, val) + modified.append(f"property[{key}]={val}") + + if in_bom is not None: + comp.in_bom = in_bom + modified.append(f"in_bom={in_bom}") + + if on_board is not None: + comp.on_board = on_board + modified.append(f"on_board={on_board}") + + if not modified: + return { + "success": True, + "reference": reference, + "modified": [], + "note": "No changes requested -- component left unchanged", + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + + sch.save(schematic_path) + + logger.info("Modified %s in %s: %s", reference, schematic_path, ", ".join(modified)) + return { + "success": True, + "reference": reference, + "modified": modified, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + except Exception as e: + logger.error("Failed to modify component %s in %s: %s", reference, schematic_path, e) + return { + "success": False, + "error": str(e), + "reference": reference, + "schematic_path": schematic_path, + } + + +@mcp.tool() +def remove_component(schematic_path: str, reference: str) -> dict[str, Any]: + """Remove a component from a KiCad schematic by its reference designator. + + The component and all its associated properties are deleted from the + schematic file. Wires connected to the component's pins are *not* + automatically removed -- use ``remove_wire`` to clean those up if needed. + + Args: + schematic_path: Path to an existing .kicad_sch file. + reference: Reference designator of the component to remove (e.g. ``R1``, ``U3``). + + Returns: + Dictionary with ``success``, removed ``reference``, and ``engine``. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + if not reference: + return {"success": False, "error": "reference must be a non-empty string"} + + try: + sch = _ksa_load(schematic_path) + removed = sch.components.remove(reference) + if removed is False: + return { + "success": False, + "error": f"Component '{reference}' not found in schematic", + "reference": reference, + "schematic_path": schematic_path, + } + sch.save(schematic_path) + + logger.info("Removed component %s from %s", reference, schematic_path) + return { + "success": True, + "reference": reference, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + except Exception as e: + logger.error("Failed to remove component %s from %s: %s", reference, schematic_path, e) + return { + "success": False, + "error": str(e), + "reference": reference, + "schematic_path": schematic_path, + } + + +@mcp.tool() +def remove_wire(schematic_path: str, wire_id: str) -> dict[str, Any]: + """Remove a wire segment from a KiCad schematic by its UUID. + + Wire IDs are returned by ``add_wire`` and ``connect_pins``, or can be + found via ``get_schematic_info``. Removing a wire breaks the electrical + connection it represents. + + Args: + schematic_path: Path to an existing .kicad_sch file. + wire_id: UUID of the wire segment to remove. + + Returns: + Dictionary with ``success``, removed ``wire_id``, and ``engine``. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + if not wire_id: + return {"success": False, "error": "wire_id must be a non-empty string"} + + try: + sch = _ksa_load(schematic_path) + sch.remove_wire(wire_id) + sch.save(schematic_path) + + logger.info("Removed wire %s from %s", wire_id, schematic_path) + return { + "success": True, + "wire_id": wire_id, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + except Exception as e: + logger.error("Failed to remove wire %s from %s: %s", wire_id, schematic_path, e) + return { + "success": False, + "error": str(e), + "wire_id": wire_id, + "schematic_path": schematic_path, + } + + +@mcp.tool() +def remove_label(schematic_path: str, label_id: str) -> dict[str, Any]: + """Remove a net label (local or global) from a KiCad schematic by its UUID. + + Label IDs are returned by ``add_label`` or can be found via + ``get_schematic_info``. Removing a label may disconnect nets that + relied on it for naming or inter-sheet connectivity. + + Args: + schematic_path: Path to an existing .kicad_sch file. + label_id: UUID of the label to remove. + + Returns: + Dictionary with ``success``, removed ``label_id``, and ``engine``. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + if not label_id: + return {"success": False, "error": "label_id must be a non-empty string"} + + try: + sch = _ksa_load(schematic_path) + sch.remove_label(label_id) + sch.save(schematic_path) + + logger.info("Removed label %s from %s", label_id, schematic_path) + return { + "success": True, + "label_id": label_id, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + except Exception as e: + logger.error("Failed to remove label %s from %s: %s", label_id, schematic_path, e) + return { + "success": False, + "error": str(e), + "label_id": label_id, + "schematic_path": schematic_path, + } + + +@mcp.tool() +def set_title_block( + schematic_path: str, + title: str | None = None, + author: str | None = None, + date: str | None = None, + revision: str | None = None, + company: str | None = None, +) -> dict[str, Any]: + """Set or update the title block fields on a KiCad schematic. + + The title block appears in the lower-right corner of printed schematics + and is embedded in exported PDFs. Only the fields you provide will be + changed; omitted fields keep their current values. + + Args: + schematic_path: Path to an existing .kicad_sch file. + title: Schematic title (e.g. ``Motor Driver Rev B``). + author: Designer or team name. + date: Design date string (e.g. ``2026-03-03``). + revision: Revision identifier (e.g. ``1.2``, ``C``). + company: Company or organisation name. + + Returns: + Dictionary with ``success``, ``fields_set`` list, and ``engine``. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + # Build kwargs for kicad-sch-api's set_title_block(). + # API signature: set_title_block(title, date, rev, company, comments) + # There is no 'author' param — KiCad convention puts author in Comment1. + kwargs: dict[str, Any] = {} + fields_set: list[str] = [] + if title is not None: + kwargs["title"] = title + fields_set.append("title") + if date is not None: + kwargs["date"] = date + fields_set.append("date") + if revision is not None: + kwargs["rev"] = revision + fields_set.append("revision") + if company is not None: + kwargs["company"] = company + fields_set.append("company") + if author is not None: + kwargs["comments"] = {1: author} + fields_set.append("author") + + if not fields_set: + return { + "success": True, + "fields_set": [], + "note": "No fields provided -- title block left unchanged", + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + + try: + sch = _ksa_load(schematic_path) + sch.set_title_block(**kwargs) + sch.save(schematic_path) + + logger.info("Set title block fields %s in %s", fields_set, schematic_path) + return { + "success": True, + "fields_set": fields_set, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + except Exception as e: + logger.error("Failed to set title block in %s: %s", schematic_path, e) + return {"success": False, "error": str(e), "schematic_path": schematic_path} + + +@mcp.tool() +def add_text_annotation( + schematic_path: str, + text: str, + x: float, + y: float, +) -> dict[str, Any]: + """Add a free-text annotation to a KiCad schematic. + + Text annotations are non-electrical notes placed directly on the + schematic sheet -- useful for design notes, revision comments, or + pin-function callouts that don't affect the netlist. + + Args: + schematic_path: Path to an existing .kicad_sch file. + text: The annotation text to display. + x: Horizontal position in schematic units. + y: Vertical position in schematic units. + + Returns: + Dictionary with ``success``, ``text_id``, ``position``, and ``engine``. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + if not text: + return {"success": False, "error": "text must be a non-empty string"} + + try: + sch = _ksa_load(schematic_path) + text_id = sch.add_text(text=text, position=(x, y)) + sch.save(schematic_path) + + logger.info("Added text annotation at (%.1f, %.1f) in %s", x, y, schematic_path) + return { + "success": True, + "text_id": text_id, + "text": text, + "position": {"x": x, "y": y}, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + except Exception as e: + logger.error("Failed to add text annotation in %s: %s", schematic_path, e) + return {"success": False, "error": str(e), "schematic_path": schematic_path} + + +@mcp.tool() +def add_no_connect( + schematic_path: str, + x: float, + y: float, +) -> dict[str, Any]: + """Place a no-connect flag (X marker) on a schematic pin. + + No-connect flags tell the ERC (electrical rules checker) that an + unconnected pin is intentional, suppressing false-positive warnings. + Place one on every unused pin to keep ERC results clean. + + Args: + schematic_path: Path to an existing .kicad_sch file. + x: X position of the pin to mark as no-connect. + y: Y position of the pin to mark as no-connect. + + Returns: + Dictionary with ``success``, ``position``, and ``engine``. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + try: + sch = _ksa_load(schematic_path) + sch.no_connects.add(position=(x, y)) + sch.save(schematic_path) + + logger.info("Added no-connect at (%.1f, %.1f) in %s", x, y, schematic_path) + return { + "success": True, + "position": {"x": x, "y": y}, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + except Exception as e: + logger.error("Failed to add no-connect in %s: %s", schematic_path, e) + return {"success": False, "error": str(e), "schematic_path": schematic_path} + + +@mcp.tool() +def backup_schematic(schematic_path: str) -> dict[str, Any]: + """Create a timestamped backup of a KiCad schematic file. + + Writes a copy of the schematic to a backup file alongside the original, + named with a timestamp so that multiple backups can coexist. Call this + before making destructive edits (bulk component removal, major rewiring) + to ensure you can recover the previous state. + + Args: + schematic_path: Path to the .kicad_sch file to back up. + + Returns: + Dictionary with ``success``, ``backup_path``, and ``engine``. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + try: + sch = _ksa_load(schematic_path) + backup_path = sch.backup() + + logger.info("Backed up %s to %s", schematic_path, backup_path) + return { + "success": True, + "original_path": schematic_path, + "backup_path": str(backup_path), + "engine": _get_schematic_engine(), + } + except Exception as e: + logger.error("Failed to back up %s: %s", schematic_path, e) + return {"success": False, "error": str(e), "schematic_path": schematic_path} diff --git a/src/mckicad/tools/schematic_patterns.py b/src/mckicad/tools/schematic_patterns.py new file mode 100644 index 0000000..6173724 --- /dev/null +++ b/src/mckicad/tools/schematic_patterns.py @@ -0,0 +1,296 @@ +""" +MCP tool wrappers for the mckicad pattern library. + +Each tool handles the load/save lifecycle and converts flat MCP parameters +(strings, floats) into the richer types the pattern library expects. +The patterns themselves live in ``mckicad.patterns`` and are importable +for use in standalone scripts without MCP. +""" + +import logging +import os +from typing import Any + +from mckicad.server import mcp + +logger = logging.getLogger(__name__) + +_HAS_SCH_API = False + +try: + from kicad_sch_api import load_schematic as _ksa_load + + _HAS_SCH_API = True +except ImportError: + logger.warning( + "kicad-sch-api not installed — pattern tools will return helpful errors. " + "Install with: uv add kicad-sch-api" + ) + + +def _require_sch_api() -> dict[str, Any] | None: + if not _HAS_SCH_API: + return { + "success": False, + "error": "kicad-sch-api is not installed. Install it with: uv add kicad-sch-api", + "engine": "none", + } + return None + + +def _get_schematic_engine() -> str: + return "kicad-sch-api" if _HAS_SCH_API else "none" + + +def _validate_schematic_path(path: str, must_exist: bool = True) -> dict[str, Any] | None: + if not path: + return {"success": False, "error": "Schematic path must be a non-empty string"} + expanded = os.path.expanduser(path) + if not expanded.endswith(".kicad_sch"): + return {"success": False, "error": f"Path must end with .kicad_sch, got: {path}"} + if must_exist and not os.path.isfile(expanded): + return {"success": False, "error": f"Schematic file not found: {expanded}"} + return None + + +def _expand(path: str) -> str: + return os.path.abspath(os.path.expanduser(path)) + + +@mcp.tool() +def place_decoupling_bank_pattern( + schematic_path: str, + cap_values: str, + power_net: str, + x: float, + y: float, + ground_net: str = "GND", + cols: int = 6, + cap_lib_id: str = "Device:C", +) -> dict[str, Any]: + """Place a grid of decoupling capacitors with power and ground symbols. + + Creates the standard IC power decoupling pattern: a row/grid of + capacitors, each with a power rail symbol on pin 1 and a ground + symbol on pin 2. + + Typical usage for an ESP32: ``cap_values="100nF,100nF,100nF,10uF"`` + with ``power_net="+3V3"`` places four decoupling caps with +3V3 and + GND symbols attached. + + Args: + schematic_path: Path to an existing .kicad_sch file. + cap_values: Comma-separated capacitor values (e.g. ``100nF,100nF,10uF``). + Each value creates one capacitor in the grid. + power_net: Supply rail name (e.g. ``+3V3``, ``VCC``, ``+5V``). + x: Top-left X position of the capacitor grid. + y: Top-left Y position of the capacitor grid. + ground_net: Ground rail name. Defaults to ``GND``. + cols: Maximum columns before wrapping to next row. + cap_lib_id: KiCad library ID for capacitor symbol. + + Returns: + Dictionary with placed component references, power symbol details, + and grid bounds. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + # Parse comma-separated values into cap specs + values = [v.strip() for v in cap_values.split(",") if v.strip()] + if not values: + return {"success": False, "error": "cap_values must contain at least one value"} + + caps = [{"value": v} for v in values] + + try: + from mckicad.patterns.decoupling_bank import place_decoupling_bank + + sch = _ksa_load(schematic_path) + result = place_decoupling_bank( + sch=sch, + caps=caps, + power_net=power_net, + x=x, + y=y, + cols=cols, + ground_net=ground_net, + cap_lib_id=cap_lib_id, + ) + sch.save(schematic_path) + + logger.info( + "Placed decoupling bank: %d caps at (%.1f, %.1f) in %s", + len(values), x, y, schematic_path, + ) + + return { + "success": True, + **result, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + + except Exception as e: + logger.error("Failed to place decoupling bank in %s: %s", schematic_path, e) + return {"success": False, "error": str(e), "schematic_path": schematic_path} + + +@mcp.tool() +def place_pull_resistor_pattern( + schematic_path: str, + signal_ref: str, + signal_pin: str, + rail_net: str, + value: str = "10k", + offset_x: float = 5.08, + resistor_lib_id: str = "Device:R", +) -> dict[str, Any]: + """Place a pull-up or pull-down resistor on a signal pin. + + Connects a resistor between a component's signal pin and a power rail. + Pull-up vs pull-down is inferred from the rail name: use ``+3V3`` or + ``VCC`` for pull-up, ``GND`` for pull-down. + + The resistor is placed offset from the signal pin with a wire connecting + them, and a power symbol on the rail side. + + Args: + schematic_path: Path to an existing .kicad_sch file. + signal_ref: Reference of the signal component (e.g. ``U1``). + signal_pin: Pin number on the signal component (e.g. ``3``). + rail_net: Power rail name (e.g. ``+3V3``, ``VCC``, ``GND``). + value: Resistor value. Defaults to ``10k``. + offset_x: Horizontal offset from pin for resistor placement in mm. + resistor_lib_id: KiCad library ID for resistor symbol. + + Returns: + Dictionary with resistor reference, wire ID, and power symbol details. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + try: + from mckicad.patterns.pull_resistor import place_pull_resistor + + sch = _ksa_load(schematic_path) + result = place_pull_resistor( + sch=sch, + signal_ref=signal_ref, + signal_pin=signal_pin, + rail_net=rail_net, + value=value, + offset_x=offset_x, + resistor_lib_id=resistor_lib_id, + ) + sch.save(schematic_path) + + logger.info( + "Placed pull resistor from %s.%s to %s in %s", + signal_ref, signal_pin, rail_net, schematic_path, + ) + + return { + "success": True, + **result, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + + except Exception as e: + logger.error( + "Failed to place pull resistor for %s.%s in %s: %s", + signal_ref, signal_pin, schematic_path, e, + ) + return {"success": False, "error": str(e), "schematic_path": schematic_path} + + +@mcp.tool() +def place_crystal_pattern( + schematic_path: str, + xtal_value: str, + cap_value: str, + x: float, + y: float, + ic_ref: str | None = None, + ic_xin_pin: str | None = None, + ic_xout_pin: str | None = None, + ground_net: str = "GND", +) -> dict[str, Any]: + """Place a crystal oscillator with load capacitors. + + Creates the classic crystal circuit: crystal at center, a load + capacitor on each side, and GND symbols on each cap. Optionally + wires the crystal to IC oscillator pins. + + Common values: 16MHz crystal with 22pF load caps, or 32.768kHz + crystal with 6.8pF caps. + + Args: + schematic_path: Path to an existing .kicad_sch file. + xtal_value: Crystal frequency (e.g. ``16MHz``, ``32.768kHz``). + cap_value: Load capacitor value (e.g. ``22pF``, ``15pF``). + x: Center X position for the crystal. + y: Center Y position for the crystal. + ic_ref: Optional IC reference for direct wiring (e.g. ``U1``). + ic_xin_pin: XIN pin number on the IC. + ic_xout_pin: XOUT pin number on the IC. + ground_net: Ground rail name. Defaults to ``GND``. + + Returns: + Dictionary with crystal and capacitor references, wire IDs, + and ground symbol details. + """ + err = _require_sch_api() + if err: + return err + + schematic_path = _expand(schematic_path) + verr = _validate_schematic_path(schematic_path) + if verr: + return verr + + try: + from mckicad.patterns.crystal_oscillator import place_crystal_with_caps + + sch = _ksa_load(schematic_path) + result = place_crystal_with_caps( + sch=sch, + xtal_value=xtal_value, + cap_value=cap_value, + x=x, + y=y, + ic_ref=ic_ref, + ic_xin_pin=ic_xin_pin, + ic_xout_pin=ic_xout_pin, + ground_net=ground_net, + ) + sch.save(schematic_path) + + logger.info( + "Placed crystal pattern: %s with %s caps at (%.1f, %.1f) in %s", + xtal_value, cap_value, x, y, schematic_path, + ) + + return { + "success": True, + **result, + "schematic_path": schematic_path, + "engine": _get_schematic_engine(), + } + + except Exception as e: + logger.error("Failed to place crystal pattern in %s: %s", schematic_path, e) + return {"success": False, "error": str(e), "schematic_path": schematic_path} diff --git a/src/mckicad/utils/file_utils.py b/src/mckicad/utils/file_utils.py index 739b26d..8b403ec 100644 --- a/src/mckicad/utils/file_utils.py +++ b/src/mckicad/utils/file_utils.py @@ -3,6 +3,7 @@ File handling utilities for KiCad MCP Server. """ import json +import logging import os from typing import Any @@ -10,6 +11,41 @@ from mckicad.config import DATA_EXTENSIONS, KICAD_EXTENSIONS from .kicad_utils import get_project_name_from_path +logger = logging.getLogger(__name__) + + +def write_detail_file(schematic_path: str | None, filename: str, data: Any) -> str: + """Write large result data to a .mckicad/ sidecar directory. + + When ``schematic_path`` is provided, the sidecar directory is created + next to the schematic file. When ``None``, falls back to CWD. + + Args: + schematic_path: Path to a .kicad_sch file (sidecar dir created next to it), + or None to use the current working directory. + filename: Name for the output file (e.g. "schematic_info.json"). + data: Data to serialize — dicts/lists become JSON, strings written directly. + + Returns: + Absolute path to the written file. + """ + parent_dir = os.path.dirname(os.path.abspath(schematic_path)) if schematic_path else os.getcwd() + sidecar_dir = os.path.join(parent_dir, ".mckicad") + os.makedirs(sidecar_dir, exist_ok=True) + + out_path = os.path.join(sidecar_dir, filename) + + if isinstance(data, (dict, list)): + content = json.dumps(data, indent=2, default=str) + else: + content = str(data) + + with open(out_path, "w") as f: + f.write(content) + + logger.info("Wrote detail file: %s (%d bytes)", out_path, len(content)) + return out_path + def get_project_files(project_path: str) -> dict[str, str]: """Get all files related to a KiCad project. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py index 861611a..8b4f014 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,22 @@ """Shared test fixtures for mckicad tests.""" +import json +import os import tempfile import pytest +# Detect whether kicad-sch-api is available for conditional tests +_HAS_SCH_API = False +try: + from kicad_sch_api import create_schematic, load_schematic # noqa: F401 + + _HAS_SCH_API = True +except ImportError: + pass + +requires_sch_api = pytest.mark.skipif(not _HAS_SCH_API, reason="kicad-sch-api not installed") + @pytest.fixture def tmp_project_dir(tmp_path): @@ -40,6 +53,77 @@ def tmp_output_dir(): yield d +@pytest.fixture +def populated_schematic(tmp_output_dir): + """Create a schematic with components for testing edit/analysis tools. + + Returns the path to the .kicad_sch file, or None if kicad-sch-api + is not installed. + """ + if not _HAS_SCH_API: + pytest.skip("kicad-sch-api not installed") + + path = os.path.join(tmp_output_dir, "populated.kicad_sch") + sch = create_schematic("test_populated") + + # Add several components + sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100)) + sch.components.add(lib_id="Device:R", reference="R2", value="4.7k", position=(200, 100)) + sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(100, 200)) + sch.components.add(lib_id="Device:LED", reference="D1", value="Red", position=(200, 200)) + + # Add a wire + sch.add_wire(start=(100, 100), end=(200, 100)) + + sch.save(path) + return path + + +@pytest.fixture +def populated_schematic_with_ic(tmp_output_dir): + """Create a schematic with multi-pin components for power symbol and pattern tests. + + Contains R1 (Device:R) and C1 (Device:C) at known positions. These + 2-pin passives have predictable pin layouts suitable for testing + power symbol attachment and pattern placement. + """ + if not _HAS_SCH_API: + pytest.skip("kicad-sch-api not installed") + + path = os.path.join(tmp_output_dir, "ic_test.kicad_sch") + sch = create_schematic("ic_test") + + sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100)) + sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(200, 100)) + + sch.save(path) + return path + + +@pytest.fixture +def batch_json_file(tmp_output_dir): + """Write a sample batch JSON file and return its path.""" + data = { + "components": [ + {"lib_id": "Device:R", "reference": "R10", "value": "1k", "x": 100, "y": 100}, + {"lib_id": "Device:C", "reference": "C10", "value": "10nF", "x": 200, "y": 100}, + ], + "wires": [ + {"start_x": 100, "start_y": 100, "end_x": 200, "end_y": 100}, + ], + "labels": [ + {"text": "TEST_NET", "x": 150, "y": 80, "global": False}, + ], + "no_connects": [ + {"x": 300, "y": 300}, + ], + } + path = os.path.join(tmp_output_dir, "test_batch.json") + with open(path, "w") as f: + json.dump(data, f) + return path + + @pytest.fixture(autouse=True) def _set_test_search_paths(tmp_project_dir, monkeypatch): """Point KICAD_SEARCH_PATHS at the temp project directory for all tests.""" diff --git a/tests/test_batch.py b/tests/test_batch.py new file mode 100644 index 0000000..4810b5f --- /dev/null +++ b/tests/test_batch.py @@ -0,0 +1,188 @@ +"""Tests for the batch operations tool.""" + +import json +import os + +from tests.conftest import requires_sch_api + + +class TestBatchValidation: + """Tests for batch JSON validation (no kicad-sch-api needed for some).""" + + def test_bad_json_file(self, tmp_output_dir): + from mckicad.tools.batch import apply_batch + + # Create a schematic file (minimal) + sch_path = os.path.join(tmp_output_dir, "test.kicad_sch") + with open(sch_path, "w") as f: + f.write("(kicad_sch (version 20230121))") + + # Create a bad JSON file + bad_json = os.path.join(tmp_output_dir, "bad.json") + with open(bad_json, "w") as f: + f.write("{invalid json}") + + result = apply_batch( + schematic_path=sch_path, + batch_file=bad_json, + ) + + assert result["success"] is False + assert "json" in result["error"].lower() or "JSON" in result["error"] + + def test_nonexistent_batch_file(self, tmp_output_dir): + from mckicad.tools.batch import apply_batch + + sch_path = os.path.join(tmp_output_dir, "test.kicad_sch") + with open(sch_path, "w") as f: + f.write("(kicad_sch (version 20230121))") + + result = apply_batch( + schematic_path=sch_path, + batch_file="/nonexistent/batch.json", + ) + + assert result["success"] is False + assert "not found" in result["error"].lower() + + def test_bad_schematic_path(self, batch_json_file): + from mckicad.tools.batch import apply_batch + + result = apply_batch( + schematic_path="/nonexistent/path.kicad_sch", + batch_file=batch_json_file, + ) + + assert result["success"] is False + + +@requires_sch_api +class TestBatchDryRun: + """Tests for batch dry_run mode.""" + + def test_dry_run_returns_preview(self, populated_schematic_with_ic, batch_json_file): + from mckicad.tools.batch import apply_batch + + result = apply_batch( + schematic_path=populated_schematic_with_ic, + batch_file=batch_json_file, + dry_run=True, + ) + + assert result["success"] is True + assert result["dry_run"] is True + assert "preview" in result + assert result["preview"]["components"] == 2 + assert result["preview"]["wires"] == 1 + assert result["preview"]["labels"] == 1 + assert result["preview"]["no_connects"] == 1 + assert result["validation"] == "passed" + + def test_dry_run_catches_missing_fields(self, populated_schematic_with_ic, tmp_output_dir): + from mckicad.tools.batch import apply_batch + + bad_data = { + "components": [{"lib_id": "Device:R"}], # missing x, y + } + batch_path = os.path.join(tmp_output_dir, "bad_batch.json") + with open(batch_path, "w") as f: + json.dump(bad_data, f) + + result = apply_batch( + schematic_path=populated_schematic_with_ic, + batch_file=batch_path, + dry_run=True, + ) + + assert result["success"] is False + assert "validation_errors" in result + + def test_empty_batch_rejected(self, populated_schematic_with_ic, tmp_output_dir): + from mckicad.tools.batch import apply_batch + + batch_path = os.path.join(tmp_output_dir, "empty_batch.json") + with open(batch_path, "w") as f: + json.dump({}, f) + + result = apply_batch( + schematic_path=populated_schematic_with_ic, + batch_file=batch_path, + ) + + assert result["success"] is False + + +@requires_sch_api +class TestBatchApply: + """Integration tests for applying batch operations.""" + + def test_apply_components_and_wires(self, populated_schematic_with_ic, batch_json_file): + from kicad_sch_api import load_schematic + + from mckicad.tools.batch import apply_batch + + result = apply_batch( + schematic_path=populated_schematic_with_ic, + batch_file=batch_json_file, + ) + + assert result["success"] is True + assert result["components_placed"] == 2 + assert result["wires_placed"] == 1 + assert result["labels_placed"] == 1 + assert result["no_connects_placed"] == 1 + + # Verify components exist in saved schematic + sch = load_schematic(populated_schematic_with_ic) + r10 = sch.components.get("R10") + assert r10 is not None + assert r10.value == "1k" + + def test_apply_with_power_symbols(self, populated_schematic_with_ic, tmp_output_dir): + from mckicad.tools.batch import apply_batch + + data = { + "components": [ + {"lib_id": "Device:C", "reference": "C20", "value": "100nF", "x": 300, "y": 100}, + ], + "power_symbols": [ + {"net": "GND", "pin_ref": "C20", "pin_number": "2"}, + ], + } + batch_path = os.path.join(tmp_output_dir, "pwr_batch.json") + with open(batch_path, "w") as f: + json.dump(data, f) + + result = apply_batch( + schematic_path=populated_schematic_with_ic, + batch_file=batch_path, + ) + + assert result["success"] is True + assert result["components_placed"] == 1 + assert result["power_symbols_placed"] == 1 + + def test_mckicad_sidecar_lookup(self, populated_schematic_with_ic, tmp_output_dir): + """Test that relative paths resolve to .mckicad/ directory.""" + from mckicad.tools.batch import apply_batch + + # Create .mckicad/ sidecar next to schematic + sch_dir = os.path.dirname(populated_schematic_with_ic) + mckicad_dir = os.path.join(sch_dir, ".mckicad") + os.makedirs(mckicad_dir, exist_ok=True) + + data = { + "labels": [{"text": "SIDECAR_TEST", "x": 100, "y": 100}], + } + sidecar_path = os.path.join(mckicad_dir, "sidecar_batch.json") + with open(sidecar_path, "w") as f: + json.dump(data, f) + + # Use relative path — should find it in .mckicad/ + result = apply_batch( + schematic_path=populated_schematic_with_ic, + batch_file="sidecar_batch.json", + ) + + assert result["success"] is True + assert result["labels_placed"] == 1 diff --git a/tests/test_geometry.py b/tests/test_geometry.py new file mode 100644 index 0000000..ab42ab8 --- /dev/null +++ b/tests/test_geometry.py @@ -0,0 +1,162 @@ +"""Unit tests for the geometry helpers in mckicad.patterns._geometry. + +These tests do NOT require kicad-sch-api — they test pure math and +string matching functions. +""" + +import pytest + + +class TestSnapToGrid: + """Tests for snap_to_grid().""" + + def test_exact_grid_point(self): + from mckicad.patterns._geometry import snap_to_grid + + assert snap_to_grid(2.54) == 2.54 + assert snap_to_grid(5.08) == 5.08 + assert snap_to_grid(0.0) == 0.0 + + def test_snap_up(self): + from mckicad.patterns._geometry import snap_to_grid + + # 2.0 is closer to 2.54 than 0.0 with grid=2.54 + assert snap_to_grid(2.0) == 2.54 + + def test_snap_down(self): + from mckicad.patterns._geometry import snap_to_grid + + # 1.0 is closer to 0.0 than 2.54 + assert snap_to_grid(1.0) == 0.0 + + def test_negative_values(self): + from mckicad.patterns._geometry import snap_to_grid + + assert snap_to_grid(-2.54) == -2.54 + assert snap_to_grid(-1.0) == 0.0 + + def test_custom_grid(self): + from mckicad.patterns._geometry import snap_to_grid + + # Fine grid (1.27mm) + assert snap_to_grid(1.0, grid=1.27) == 1.27 + assert snap_to_grid(0.5, grid=1.27) == 0.0 + + def test_large_values(self): + from mckicad.patterns._geometry import snap_to_grid + + result = snap_to_grid(100.0) + assert result % 2.54 == pytest.approx(0.0, abs=0.001) + + +class TestGridPositions: + """Tests for grid_positions().""" + + def test_single_position(self): + from mckicad.patterns._geometry import grid_positions + + positions = grid_positions(100, 100, 1) + assert len(positions) == 1 + # snap_to_grid(100) = round(100/2.54)*2.54 = 39*2.54 = 99.06 + assert positions[0] == (pytest.approx(99.06, abs=0.01), pytest.approx(99.06, abs=0.01)) + + def test_row_layout(self): + from mckicad.patterns._geometry import grid_positions + + positions = grid_positions(0, 0, 3, cols=6, h_spacing=10.0) + assert len(positions) == 3 + # All should be on same row (same y) + ys = [p[1] for p in positions] + assert all(y == ys[0] for y in ys) + + def test_wraps_to_next_row(self): + from mckicad.patterns._geometry import grid_positions + + positions = grid_positions(0, 0, 4, cols=2, h_spacing=10.0, v_spacing=15.0) + assert len(positions) == 4 + # First two on row 0, next two on row 1 + assert positions[0][1] == positions[1][1] # same row + assert positions[2][1] == positions[3][1] # same row + assert positions[2][1] != positions[0][1] # different rows + + def test_zero_count(self): + from mckicad.patterns._geometry import grid_positions + + positions = grid_positions(100, 100, 0) + assert positions == [] + + def test_all_positions_grid_aligned(self): + from mckicad.patterns._geometry import grid_positions, snap_to_grid + + positions = grid_positions(100, 100, 12, cols=4) + for x, y in positions: + # Verify each coordinate is unchanged by snap_to_grid + # (i.e., already on the grid) + assert snap_to_grid(x) == pytest.approx(x, abs=0.001) + assert snap_to_grid(y) == pytest.approx(y, abs=0.001) + + +class TestIsGroundNet: + """Tests for is_ground_net().""" + + def test_standard_grounds(self): + from mckicad.patterns._geometry import is_ground_net + + assert is_ground_net("GND") is True + assert is_ground_net("GNDA") is True + assert is_ground_net("GNDD") is True + assert is_ground_net("VSS") is True + assert is_ground_net("VSSA") is True + + def test_extended_grounds(self): + from mckicad.patterns._geometry import is_ground_net + + assert is_ground_net("AGND") is True + assert is_ground_net("DGND") is True + assert is_ground_net("PGND") is True + assert is_ground_net("SGND") is True + + def test_case_insensitive(self): + from mckicad.patterns._geometry import is_ground_net + + assert is_ground_net("gnd") is True + assert is_ground_net("Gnd") is True + assert is_ground_net("vss") is True + + def test_not_ground(self): + from mckicad.patterns._geometry import is_ground_net + + assert is_ground_net("VCC") is False + assert is_ground_net("+3V3") is False + assert is_ground_net("+5V") is False + assert is_ground_net("SPI_CLK") is False + assert is_ground_net("") is False + + def test_whitespace_stripped(self): + from mckicad.patterns._geometry import is_ground_net + + assert is_ground_net(" GND ") is True + + +class TestResolvePowerLibId: + """Tests for resolve_power_lib_id().""" + + def test_known_symbols(self): + from mckicad.patterns._geometry import resolve_power_lib_id + + assert resolve_power_lib_id("GND") == "power:GND" + assert resolve_power_lib_id("VCC") == "power:VCC" + assert resolve_power_lib_id("+5V") == "power:+5V" + assert resolve_power_lib_id("+3V3") == "power:+3V3" + + def test_case_insensitive_lookup(self): + from mckicad.patterns._geometry import resolve_power_lib_id + + assert resolve_power_lib_id("gnd") == "power:GND" + assert resolve_power_lib_id("vcc") == "power:VCC" + + def test_unknown_falls_back_to_power_prefix(self): + from mckicad.patterns._geometry import resolve_power_lib_id + + assert resolve_power_lib_id("+1V8") == "power:+1V8" + assert resolve_power_lib_id("VDDIO") == "power:VDDIO" diff --git a/tests/test_patterns.py b/tests/test_patterns.py new file mode 100644 index 0000000..51c7f80 --- /dev/null +++ b/tests/test_patterns.py @@ -0,0 +1,244 @@ +"""Tests for the pattern library and MCP wrapper tools.""" + +import os + +import pytest + +from tests.conftest import requires_sch_api + + +@requires_sch_api +class TestDecouplingBankPattern: + """Tests for the decoupling bank pattern library function.""" + + def test_place_single_cap(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns.decoupling_bank import place_decoupling_bank + + path = os.path.join(tmp_output_dir, "decoup_test.kicad_sch") + sch = create_schematic("decoup_test") + + result = place_decoupling_bank( + sch=sch, + caps=[{"value": "100nF"}], + power_net="+3V3", + x=100, + y=100, + ) + + assert result["cap_count"] == 1 + assert result["placed_refs"] == ["C1"] + assert result["power_net"] == "+3V3" + assert result["ground_net"] == "GND" + # Should have 2 power symbols (one VCC, one GND per cap) + assert len(result["power_symbols"]) == 2 + + sch.save(path) + + def test_place_multiple_caps_grid(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns.decoupling_bank import place_decoupling_bank + + sch = create_schematic("decoup_multi") + + result = place_decoupling_bank( + sch=sch, + caps=[{"value": "100nF"}, {"value": "100nF"}, {"value": "10uF"}], + power_net="+3V3", + x=100, + y=100, + cols=3, + ) + + assert result["cap_count"] == 3 + assert len(result["placed_refs"]) == 3 + # 2 power symbols per cap = 6 total + assert len(result["power_symbols"]) == 6 + + def test_custom_references(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns.decoupling_bank import place_decoupling_bank + + sch = create_schematic("decoup_refs") + + result = place_decoupling_bank( + sch=sch, + caps=[ + {"value": "100nF", "reference": "C101"}, + {"value": "10uF", "reference": "C102"}, + ], + power_net="VCC", + x=100, + y=100, + ) + + assert result["placed_refs"] == ["C101", "C102"] + + +@requires_sch_api +class TestPullResistorPattern: + """Tests for the pull resistor pattern library function.""" + + def test_place_pull_up(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns.pull_resistor import place_pull_resistor + + sch = create_schematic("pull_test") + sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100)) + + result = place_pull_resistor( + sch=sch, + signal_ref="R1", + signal_pin="1", + rail_net="+3V3", + value="10k", + ) + + assert result["resistor_ref"].startswith("R") + assert result["value"] == "10k" + assert result["rail_net"] == "+3V3" + assert result["wire_id"] is not None + assert result["power_symbol"] is not None + assert result["power_symbol"]["direction"] == "up" + + def test_place_pull_down(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns.pull_resistor import place_pull_resistor + + sch = create_schematic("pulldown_test") + sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100)) + + result = place_pull_resistor( + sch=sch, + signal_ref="R1", + signal_pin="1", + rail_net="GND", + ) + + assert result["power_symbol"]["direction"] == "down" + + def test_invalid_pin_raises(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns.pull_resistor import place_pull_resistor + + sch = create_schematic("pull_bad") + sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100)) + + with pytest.raises(ValueError, match="not found"): + place_pull_resistor( + sch=sch, + signal_ref="R1", + signal_pin="99", # nonexistent pin + rail_net="+3V3", + ) + + +@requires_sch_api +class TestCrystalPattern: + """Tests for the crystal oscillator pattern library function.""" + + def test_place_crystal_standalone(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns.crystal_oscillator import place_crystal_with_caps + + sch = create_schematic("xtal_test") + + result = place_crystal_with_caps( + sch=sch, + xtal_value="16MHz", + cap_value="22pF", + x=200, + y=200, + ) + + assert result["crystal_ref"] == "Y1" + assert result["cap_xin_ref"] == "C1" + assert result["cap_xout_ref"] == "C2" + assert result["xtal_value"] == "16MHz" + assert result["cap_value"] == "22pF" + assert len(result["gnd_symbols"]) == 2 + assert len(result["internal_wires"]) >= 0 # may vary with pin geometry + assert result["ic_wires"] == [] + + path = os.path.join(tmp_output_dir, "xtal_test.kicad_sch") + sch.save(path) + + +@requires_sch_api +class TestDecouplingBankMCPTool: + """Tests for the place_decoupling_bank_pattern MCP tool wrapper.""" + + def test_comma_separated_values(self, populated_schematic_with_ic): + from mckicad.tools.schematic_patterns import place_decoupling_bank_pattern + + result = place_decoupling_bank_pattern( + schematic_path=populated_schematic_with_ic, + cap_values="100nF,100nF,10uF", + power_net="+3V3", + x=300, + y=300, + ) + + assert result["success"] is True + assert result["cap_count"] == 3 + assert result["engine"] == "kicad-sch-api" + + def test_empty_values_rejected(self, populated_schematic_with_ic): + from mckicad.tools.schematic_patterns import place_decoupling_bank_pattern + + result = place_decoupling_bank_pattern( + schematic_path=populated_schematic_with_ic, + cap_values="", + power_net="+3V3", + x=100, + y=100, + ) + + assert result["success"] is False + + +@requires_sch_api +class TestPullResistorMCPTool: + """Tests for the place_pull_resistor_pattern MCP tool wrapper.""" + + def test_pull_up_via_tool(self, populated_schematic_with_ic): + from mckicad.tools.schematic_patterns import place_pull_resistor_pattern + + result = place_pull_resistor_pattern( + schematic_path=populated_schematic_with_ic, + signal_ref="R1", + signal_pin="1", + rail_net="+3V3", + value="4.7k", + ) + + assert result["success"] is True + assert result["value"] == "4.7k" + assert result["rail_net"] == "+3V3" + + +@requires_sch_api +class TestCrystalMCPTool: + """Tests for the place_crystal_pattern MCP tool wrapper.""" + + def test_crystal_via_tool(self, populated_schematic_with_ic): + from mckicad.tools.schematic_patterns import place_crystal_pattern + + result = place_crystal_pattern( + schematic_path=populated_schematic_with_ic, + xtal_value="16MHz", + cap_value="22pF", + x=400, + y=400, + ) + + assert result["success"] is True + assert result["crystal_ref"] == "Y1" + assert result["cap_value"] == "22pF" diff --git a/tests/test_power_symbols.py b/tests/test_power_symbols.py new file mode 100644 index 0000000..bd8cc24 --- /dev/null +++ b/tests/test_power_symbols.py @@ -0,0 +1,175 @@ +"""Tests for the power symbol tool and geometry helper.""" + +import os + +from tests.conftest import requires_sch_api + + +@requires_sch_api +class TestAddPowerSymbolToPin: + """Tests for the _geometry.add_power_symbol_to_pin() helper.""" + + def test_ground_symbol_placed_below_pin(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns._geometry import add_power_symbol_to_pin + + path = os.path.join(tmp_output_dir, "pwr_test.kicad_sch") + sch = create_schematic("pwr_test") + sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100)) + + pin_pos = sch.get_component_pin_position("R1", "2") + assert pin_pos is not None + + result = add_power_symbol_to_pin( + sch=sch, + pin_position=(pin_pos.x, pin_pos.y), + net="GND", + ) + + assert result["net"] == "GND" + assert result["direction"] == "down" + assert result["reference"].startswith("#PWR") + assert result["lib_id"] == "power:GND" + assert result["wire_id"] is not None + + # Ground symbol should be below the pin (higher Y in KiCad coords) + assert result["symbol_position"]["y"] > pin_pos.y + + sch.save(path) + + def test_supply_symbol_placed_above_pin(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns._geometry import add_power_symbol_to_pin + + path = os.path.join(tmp_output_dir, "pwr_test2.kicad_sch") + sch = create_schematic("pwr_test2") + sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100)) + + pin_pos = sch.get_component_pin_position("R1", "1") + assert pin_pos is not None + + result = add_power_symbol_to_pin( + sch=sch, + pin_position=(pin_pos.x, pin_pos.y), + net="+3V3", + ) + + assert result["net"] == "+3V3" + assert result["direction"] == "up" + assert result["lib_id"] == "power:+3V3" + + # Supply symbol should be above the pin (lower Y in KiCad coords) + assert result["symbol_position"]["y"] < pin_pos.y + + sch.save(path) + + def test_custom_lib_id_override(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns._geometry import add_power_symbol_to_pin + + sch = create_schematic("pwr_test3") + sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(100, 100)) + + pin_pos = sch.get_component_pin_position("C1", "1") + result = add_power_symbol_to_pin( + sch=sch, + pin_position=(pin_pos.x, pin_pos.y), + net="VCC", + lib_id="power:VCC", + ) + + assert result["lib_id"] == "power:VCC" + + def test_sequential_pwr_references(self, tmp_output_dir): + from kicad_sch_api import create_schematic + + from mckicad.patterns._geometry import add_power_symbol_to_pin + + sch = create_schematic("pwr_seq") + sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100)) + + pin1 = sch.get_component_pin_position("R1", "1") + pin2 = sch.get_component_pin_position("R1", "2") + + r1 = add_power_symbol_to_pin(sch, (pin1.x, pin1.y), "+3V3") + r2 = add_power_symbol_to_pin(sch, (pin2.x, pin2.y), "GND") + + # References should be sequential + assert r1["reference"] != r2["reference"] + assert r1["reference"].startswith("#PWR") + assert r2["reference"].startswith("#PWR") + + +@requires_sch_api +class TestAddPowerSymbolTool: + """Integration tests for the add_power_symbol MCP tool.""" + + def test_add_gnd_to_resistor_pin(self, populated_schematic_with_ic): + from mckicad.tools.power_symbols import add_power_symbol + + result = add_power_symbol( + schematic_path=populated_schematic_with_ic, + net="GND", + pin_ref="R1", + pin_number="2", + ) + + assert result["success"] is True + assert result["net"] == "GND" + assert result["target_component"] == "R1" + assert result["target_pin"] == "2" + assert result["reference"].startswith("#PWR") + assert result["engine"] == "kicad-sch-api" + + def test_add_vcc_to_cap_pin(self, populated_schematic_with_ic): + from mckicad.tools.power_symbols import add_power_symbol + + result = add_power_symbol( + schematic_path=populated_schematic_with_ic, + net="VCC", + pin_ref="C1", + pin_number="1", + ) + + assert result["success"] is True + assert result["net"] == "VCC" + + def test_invalid_pin_ref(self, populated_schematic_with_ic): + from mckicad.tools.power_symbols import add_power_symbol + + result = add_power_symbol( + schematic_path=populated_schematic_with_ic, + net="GND", + pin_ref="NONEXISTENT", + pin_number="1", + ) + + assert result["success"] is False + assert "not found" in result["error"].lower() or "NONEXISTENT" in result["error"] + + def test_empty_net_rejected(self, populated_schematic_with_ic): + from mckicad.tools.power_symbols import add_power_symbol + + result = add_power_symbol( + schematic_path=populated_schematic_with_ic, + net="", + pin_ref="R1", + pin_number="1", + ) + + assert result["success"] is False + + def test_bad_schematic_path(self): + from mckicad.tools.power_symbols import add_power_symbol + + result = add_power_symbol( + schematic_path="/nonexistent/path.kicad_sch", + net="GND", + pin_ref="R1", + pin_number="1", + ) + + assert result["success"] is False diff --git a/tests/test_schematic_edit.py b/tests/test_schematic_edit.py new file mode 100644 index 0000000..2dad2a3 --- /dev/null +++ b/tests/test_schematic_edit.py @@ -0,0 +1,176 @@ +"""Tests for schematic editing tools.""" + +import os + +import pytest + +from tests.conftest import requires_sch_api + + +@requires_sch_api +@pytest.mark.unit +class TestModifyComponent: + """Tests for the modify_component tool.""" + + def test_modify_value(self, populated_schematic): + from mckicad.tools.schematic_edit import modify_component + + result = modify_component( + schematic_path=populated_schematic, + reference="R1", + value="22k", + ) + assert result["success"] is True + assert any("value" in m for m in result["modified"]) + + def test_modify_nonexistent_component(self, populated_schematic): + from mckicad.tools.schematic_edit import modify_component + + result = modify_component( + schematic_path=populated_schematic, + reference="Z99", + value="1k", + ) + assert result["success"] is False + assert "error" in result + + def test_modify_no_changes(self, populated_schematic): + from mckicad.tools.schematic_edit import modify_component + + result = modify_component( + schematic_path=populated_schematic, + reference="R1", + ) + assert result["success"] is True + assert result.get("modified") == [] + + +@requires_sch_api +@pytest.mark.unit +class TestRemoveComponent: + """Tests for the remove_component tool.""" + + def test_remove_existing(self, populated_schematic): + from mckicad.tools.schematic_edit import remove_component + + result = remove_component( + schematic_path=populated_schematic, + reference="R2", + ) + assert result["success"] is True + assert result["reference"] == "R2" + + def test_remove_nonexistent(self, populated_schematic): + from mckicad.tools.schematic_edit import remove_component + + result = remove_component( + schematic_path=populated_schematic, + reference="Z99", + ) + assert result["success"] is False + + +@requires_sch_api +@pytest.mark.unit +class TestBackupSchematic: + """Tests for the backup_schematic tool.""" + + def test_backup_creates_file(self, populated_schematic): + from mckicad.tools.schematic_edit import backup_schematic + + result = backup_schematic(schematic_path=populated_schematic) + assert result["success"] is True + assert "backup_path" in result + assert os.path.isfile(result["backup_path"]) + + +@pytest.mark.unit +class TestValidation: + """Tests for input validation in edit tools.""" + + def test_invalid_path_extension(self): + from mckicad.tools.schematic_edit import modify_component + + result = modify_component( + schematic_path="/tmp/test.txt", + reference="R1", + value="1k", + ) + assert result["success"] is False + assert ".kicad_sch" in result["error"] + + def test_empty_path(self): + from mckicad.tools.schematic_edit import remove_component + + result = remove_component(schematic_path="", reference="R1") + assert result["success"] is False + + def test_nonexistent_file(self): + from mckicad.tools.schematic_edit import set_title_block + + result = set_title_block( + schematic_path="/tmp/nonexistent.kicad_sch", + title="Test", + ) + assert result["success"] is False + + +@requires_sch_api +@pytest.mark.unit +class TestSetTitleBlock: + """Tests for the set_title_block tool.""" + + def test_set_fields(self, populated_schematic): + from mckicad.tools.schematic_edit import set_title_block + + result = set_title_block( + schematic_path=populated_schematic, + title="Test Circuit", + revision="1.0", + ) + assert result["success"] is True + assert "title" in result.get("fields_set", []) + + def test_set_author(self, populated_schematic): + from mckicad.tools.schematic_edit import set_title_block + + result = set_title_block( + schematic_path=populated_schematic, + author="Test Author", + ) + assert result["success"] is True + assert "author" in result.get("fields_set", []) + + def test_no_fields_is_noop(self, populated_schematic): + from mckicad.tools.schematic_edit import set_title_block + + result = set_title_block(schematic_path=populated_schematic) + assert result["success"] is True + assert result.get("fields_set") == [] + + +@requires_sch_api +@pytest.mark.unit +class TestAnnotationTools: + """Tests for add_text_annotation and add_no_connect.""" + + def test_add_text(self, populated_schematic): + from mckicad.tools.schematic_edit import add_text_annotation + + result = add_text_annotation( + schematic_path=populated_schematic, + text="Test note", + x=50, + y=50, + ) + assert result["success"] is True + + def test_add_no_connect(self, populated_schematic): + from mckicad.tools.schematic_edit import add_no_connect + + result = add_no_connect( + schematic_path=populated_schematic, + x=300, + y=300, + ) + assert result["success"] is True