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:
parent
e0dbbb51e4
commit
ce65035a17
@ -92,6 +92,7 @@ addopts = [
|
||||
"--tb=short",
|
||||
]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["."]
|
||||
python_files = ["test_*.py"]
|
||||
python_functions = ["test_*"]
|
||||
markers = [
|
||||
|
||||
@ -85,6 +85,8 @@ KICAD_EXTENSIONS = {
|
||||
|
||||
DATA_EXTENSIONS = [".csv", ".pos", ".net", ".zip", ".drl"]
|
||||
|
||||
INLINE_RESULT_THRESHOLD = 20
|
||||
|
||||
TIMEOUT_CONSTANTS = {
|
||||
"kicad_cli_version_check": 10.0,
|
||||
"kicad_cli_export": 30.0,
|
||||
@ -112,6 +114,26 @@ COMMON_LIBRARIES = {
|
||||
},
|
||||
}
|
||||
|
||||
POWER_SYMBOL_DEFAULTS = {
|
||||
"stub_length": 5.08,
|
||||
"grid_snap": 2.54,
|
||||
"fine_grid": 1.27,
|
||||
}
|
||||
|
||||
DECOUPLING_DEFAULTS = {
|
||||
"cols": 6,
|
||||
"h_spacing": 12.7,
|
||||
"v_spacing": 15.0,
|
||||
"offset_below_ic": 20.0,
|
||||
}
|
||||
|
||||
BATCH_LIMITS = {
|
||||
"max_components": 500,
|
||||
"max_wires": 1000,
|
||||
"max_labels": 500,
|
||||
"max_total_operations": 2000,
|
||||
}
|
||||
|
||||
DEFAULT_FOOTPRINTS = {
|
||||
"R": [
|
||||
"Resistor_SMD:R_0805_2012Metric",
|
||||
|
||||
42
src/mckicad/patterns/__init__.py
Normal file
42
src/mckicad/patterns/__init__.py
Normal 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",
|
||||
]
|
||||
226
src/mckicad/patterns/_geometry.py
Normal file
226
src/mckicad/patterns/_geometry.py
Normal 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",
|
||||
}
|
||||
170
src/mckicad/patterns/crystal_oscillator.py
Normal file
170
src/mckicad/patterns/crystal_oscillator.py
Normal 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,
|
||||
}
|
||||
137
src/mckicad/patterns/decoupling_bank.py
Normal file
137
src/mckicad/patterns/decoupling_bank.py
Normal 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,
|
||||
}
|
||||
103
src/mckicad/patterns/pull_resistor.py
Normal file
103
src/mckicad/patterns/pull_resistor.py
Normal 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,
|
||||
}
|
||||
@ -33,5 +33,13 @@ Steps: 1) Select components, 2) Create schematic, 3) Add connections,
|
||||
def debug_schematic(schematic_path: str) -> str:
|
||||
"""Help debug schematic connectivity issues."""
|
||||
return f"""Analyze the schematic at {schematic_path} for issues.
|
||||
|
||||
Recommended workflow:
|
||||
1. Run get_schematic_info to get statistics and validation summary
|
||||
2. Run run_schematic_erc to check for electrical rule violations
|
||||
3. Run analyze_connectivity to inspect the net graph for unconnected pins
|
||||
4. Use check_pin_connection on suspicious pins to trace connectivity
|
||||
5. Use get_schematic_hierarchy if this is a multi-sheet design
|
||||
|
||||
Check for: unconnected pins, missing power connections, incorrect
|
||||
component values, and ERC violations."""
|
||||
component values, ERC violations, and net continuity issues."""
|
||||
|
||||
149
src/mckicad/resources/schematic.py
Normal file
149
src/mckicad/resources/schematic.py
Normal 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)})
|
||||
@ -45,15 +45,21 @@ mcp = FastMCP("mckicad", lifespan=lifespan)
|
||||
# Order doesn't matter — each module does @mcp.tool() at module level.
|
||||
from mckicad.prompts import templates # noqa: E402, F401
|
||||
from mckicad.resources import files, projects # noqa: E402, F401
|
||||
from mckicad.resources import schematic as schematic_resources # noqa: E402, F401
|
||||
from mckicad.tools import ( # noqa: E402, F401
|
||||
analysis,
|
||||
batch,
|
||||
bom,
|
||||
drc,
|
||||
export,
|
||||
pcb,
|
||||
power_symbols,
|
||||
project,
|
||||
routing,
|
||||
schematic,
|
||||
schematic_analysis,
|
||||
schematic_edit,
|
||||
schematic_patterns,
|
||||
)
|
||||
|
||||
|
||||
|
||||
470
src/mckicad/tools/batch.py
Normal file
470
src/mckicad/tools/batch.py
Normal 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,
|
||||
}
|
||||
165
src/mckicad/tools/power_symbols.py
Normal file
165
src/mckicad/tools/power_symbols.py
Normal 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,
|
||||
}
|
||||
580
src/mckicad/tools/schematic_edit.py
Normal file
580
src/mckicad/tools/schematic_edit.py
Normal 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}
|
||||
296
src/mckicad/tools/schematic_patterns.py
Normal file
296
src/mckicad/tools/schematic_patterns.py
Normal 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}
|
||||
@ -3,6 +3,7 @@ File handling utilities for KiCad MCP Server.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
@ -10,6 +11,41 @@ from mckicad.config import DATA_EXTENSIONS, KICAD_EXTENSIONS
|
||||
|
||||
from .kicad_utils import get_project_name_from_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def write_detail_file(schematic_path: str | None, filename: str, data: Any) -> str:
|
||||
"""Write large result data to a .mckicad/ sidecar directory.
|
||||
|
||||
When ``schematic_path`` is provided, the sidecar directory is created
|
||||
next to the schematic file. When ``None``, falls back to CWD.
|
||||
|
||||
Args:
|
||||
schematic_path: Path to a .kicad_sch file (sidecar dir created next to it),
|
||||
or None to use the current working directory.
|
||||
filename: Name for the output file (e.g. "schematic_info.json").
|
||||
data: Data to serialize — dicts/lists become JSON, strings written directly.
|
||||
|
||||
Returns:
|
||||
Absolute path to the written file.
|
||||
"""
|
||||
parent_dir = os.path.dirname(os.path.abspath(schematic_path)) if schematic_path else os.getcwd()
|
||||
sidecar_dir = os.path.join(parent_dir, ".mckicad")
|
||||
os.makedirs(sidecar_dir, exist_ok=True)
|
||||
|
||||
out_path = os.path.join(sidecar_dir, filename)
|
||||
|
||||
if isinstance(data, (dict, list)):
|
||||
content = json.dumps(data, indent=2, default=str)
|
||||
else:
|
||||
content = str(data)
|
||||
|
||||
with open(out_path, "w") as f:
|
||||
f.write(content)
|
||||
|
||||
logger.info("Wrote detail file: %s (%d bytes)", out_path, len(content))
|
||||
return out_path
|
||||
|
||||
|
||||
def get_project_files(project_path: str) -> dict[str, str]:
|
||||
"""Get all files related to a KiCad project.
|
||||
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
@ -1,9 +1,22 @@
|
||||
"""Shared test fixtures for mckicad tests."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
# Detect whether kicad-sch-api is available for conditional tests
|
||||
_HAS_SCH_API = False
|
||||
try:
|
||||
from kicad_sch_api import create_schematic, load_schematic # noqa: F401
|
||||
|
||||
_HAS_SCH_API = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
requires_sch_api = pytest.mark.skipif(not _HAS_SCH_API, reason="kicad-sch-api not installed")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_project_dir(tmp_path):
|
||||
@ -40,6 +53,77 @@ def tmp_output_dir():
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def populated_schematic(tmp_output_dir):
|
||||
"""Create a schematic with components for testing edit/analysis tools.
|
||||
|
||||
Returns the path to the .kicad_sch file, or None if kicad-sch-api
|
||||
is not installed.
|
||||
"""
|
||||
if not _HAS_SCH_API:
|
||||
pytest.skip("kicad-sch-api not installed")
|
||||
|
||||
path = os.path.join(tmp_output_dir, "populated.kicad_sch")
|
||||
sch = create_schematic("test_populated")
|
||||
|
||||
# Add several components
|
||||
sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100))
|
||||
sch.components.add(lib_id="Device:R", reference="R2", value="4.7k", position=(200, 100))
|
||||
sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(100, 200))
|
||||
sch.components.add(lib_id="Device:LED", reference="D1", value="Red", position=(200, 200))
|
||||
|
||||
# Add a wire
|
||||
sch.add_wire(start=(100, 100), end=(200, 100))
|
||||
|
||||
sch.save(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def populated_schematic_with_ic(tmp_output_dir):
|
||||
"""Create a schematic with multi-pin components for power symbol and pattern tests.
|
||||
|
||||
Contains R1 (Device:R) and C1 (Device:C) at known positions. These
|
||||
2-pin passives have predictable pin layouts suitable for testing
|
||||
power symbol attachment and pattern placement.
|
||||
"""
|
||||
if not _HAS_SCH_API:
|
||||
pytest.skip("kicad-sch-api not installed")
|
||||
|
||||
path = os.path.join(tmp_output_dir, "ic_test.kicad_sch")
|
||||
sch = create_schematic("ic_test")
|
||||
|
||||
sch.components.add(lib_id="Device:R", reference="R1", value="10k", position=(100, 100))
|
||||
sch.components.add(lib_id="Device:C", reference="C1", value="100nF", position=(200, 100))
|
||||
|
||||
sch.save(path)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def batch_json_file(tmp_output_dir):
|
||||
"""Write a sample batch JSON file and return its path."""
|
||||
data = {
|
||||
"components": [
|
||||
{"lib_id": "Device:R", "reference": "R10", "value": "1k", "x": 100, "y": 100},
|
||||
{"lib_id": "Device:C", "reference": "C10", "value": "10nF", "x": 200, "y": 100},
|
||||
],
|
||||
"wires": [
|
||||
{"start_x": 100, "start_y": 100, "end_x": 200, "end_y": 100},
|
||||
],
|
||||
"labels": [
|
||||
{"text": "TEST_NET", "x": 150, "y": 80, "global": False},
|
||||
],
|
||||
"no_connects": [
|
||||
{"x": 300, "y": 300},
|
||||
],
|
||||
}
|
||||
path = os.path.join(tmp_output_dir, "test_batch.json")
|
||||
with open(path, "w") as f:
|
||||
json.dump(data, f)
|
||||
return path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _set_test_search_paths(tmp_project_dir, monkeypatch):
|
||||
"""Point KICAD_SEARCH_PATHS at the temp project directory for all tests."""
|
||||
|
||||
188
tests/test_batch.py
Normal file
188
tests/test_batch.py
Normal 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
162
tests/test_geometry.py
Normal 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
244
tests/test_patterns.py
Normal 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
175
tests/test_power_symbols.py
Normal 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
|
||||
176
tests/test_schematic_edit.py
Normal file
176
tests/test_schematic_edit.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user