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.
This commit is contained in:
Ryan Malloy 2026-03-04 16:55:09 -07:00
parent e0dbbb51e4
commit ce65035a17
22 changed files with 3441 additions and 1 deletions

View File

@ -92,6 +92,7 @@ addopts = [
"--tb=short", "--tb=short",
] ]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["."]
python_files = ["test_*.py"] python_files = ["test_*.py"]
python_functions = ["test_*"] python_functions = ["test_*"]
markers = [ markers = [

View File

@ -85,6 +85,8 @@ KICAD_EXTENSIONS = {
DATA_EXTENSIONS = [".csv", ".pos", ".net", ".zip", ".drl"] DATA_EXTENSIONS = [".csv", ".pos", ".net", ".zip", ".drl"]
INLINE_RESULT_THRESHOLD = 20
TIMEOUT_CONSTANTS = { TIMEOUT_CONSTANTS = {
"kicad_cli_version_check": 10.0, "kicad_cli_version_check": 10.0,
"kicad_cli_export": 30.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 = { DEFAULT_FOOTPRINTS = {
"R": [ "R": [
"Resistor_SMD:R_0805_2012Metric", "Resistor_SMD:R_0805_2012Metric",

View File

@ -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",
]

View File

@ -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",
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -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,
}

View File

@ -33,5 +33,13 @@ Steps: 1) Select components, 2) Create schematic, 3) Add connections,
def debug_schematic(schematic_path: str) -> str: def debug_schematic(schematic_path: str) -> str:
"""Help debug schematic connectivity issues.""" """Help debug schematic connectivity issues."""
return f"""Analyze the schematic at {schematic_path} for 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 Check for: unconnected pins, missing power connections, incorrect
component values, and ERC violations.""" component values, ERC violations, and net continuity issues."""

View File

@ -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)})

View File

@ -45,15 +45,21 @@ mcp = FastMCP("mckicad", lifespan=lifespan)
# Order doesn't matter — each module does @mcp.tool() at module level. # Order doesn't matter — each module does @mcp.tool() at module level.
from mckicad.prompts import templates # noqa: E402, F401 from mckicad.prompts import templates # noqa: E402, F401
from mckicad.resources import files, projects # 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 from mckicad.tools import ( # noqa: E402, F401
analysis, analysis,
batch,
bom, bom,
drc, drc,
export, export,
pcb, pcb,
power_symbols,
project, project,
routing, routing,
schematic, schematic,
schematic_analysis,
schematic_edit,
schematic_patterns,
) )

470
src/mckicad/tools/batch.py Normal file
View File

@ -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,
}

View File

@ -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,
}

View File

@ -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}

View File

@ -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}

View File

@ -3,6 +3,7 @@ File handling utilities for KiCad MCP Server.
""" """
import json import json
import logging
import os import os
from typing import Any 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 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]: def get_project_files(project_path: str) -> dict[str, str]:
"""Get all files related to a KiCad project. """Get all files related to a KiCad project.

0
tests/__init__.py Normal file
View File

View File

@ -1,9 +1,22 @@
"""Shared test fixtures for mckicad tests.""" """Shared test fixtures for mckicad tests."""
import json
import os
import tempfile import tempfile
import pytest 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 @pytest.fixture
def tmp_project_dir(tmp_path): def tmp_project_dir(tmp_path):
@ -40,6 +53,77 @@ def tmp_output_dir():
yield d 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) @pytest.fixture(autouse=True)
def _set_test_search_paths(tmp_project_dir, monkeypatch): def _set_test_search_paths(tmp_project_dir, monkeypatch):
"""Point KICAD_SEARCH_PATHS at the temp project directory for all tests.""" """Point KICAD_SEARCH_PATHS at the temp project directory for all tests."""

188
tests/test_batch.py Normal file
View File

@ -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

162
tests/test_geometry.py Normal file
View File

@ -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"

244
tests/test_patterns.py Normal file
View File

@ -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"

175
tests/test_power_symbols.py Normal file
View File

@ -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

View File

@ -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