feat(schematic): batch component authoring + connection tools

Adds batch authoring tools that collapse the dozens of round-trips needed to
stand up a schematic into a handful: batch_add_components,
batch_edit_schematic_components, replace_schematic_component,
batch_add_no_connects, batch_connect, and batch_add_and_connect (place a set of
parts and wire them in one call). These reuse the existing single-item handlers
internally, so behavior matches exactly.

- python/commands/schematic_batch.py: SchematicBatchCommands(iface)
- python/commands/schematic_text_utils.py: shared .kicad_sch text helpers
- src/tools/schematic-batch.ts + registry 'schematic_batch' category
- python/kicad_interface.py: import + instantiate + dispatch routes
- tests/test_schematic_batch.py: 13 unit tests

Note: schematic_text_utils.py is also shipped by the field-placement and
hierarchy PRs; if those merge first, drop the duplicate copy on rebase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ravi
2026-06-02 19:43:10 -07:00
committed by mixelpixx
parent a0bf45488b
commit 4a251b7343
6 changed files with 1227 additions and 0 deletions

View File

@@ -0,0 +1,813 @@
"""
Batch schematic authoring commands.
Tools that place / edit / connect many things in one call to avoid per-item round-trips:
- batch_add_components: add many components at once
- batch_edit_schematic_components: edit many components (value/footprint/reference/...)
- replace_schematic_component: swap a symbol, preserving position and field values
- batch_add_no_connects: add no-connect (X) flags to many pins
- batch_connect: place net labels on many pins (with facing-label wiring)
- batch_add_and_connect: place components and wire their nets in one call
The command class is constructed with a reference to the KiCADInterface so it can reuse
existing single-item handlers (add/edit/get_schematic_component), the footprint library,
and the hierarchical-instance fixer when present.
"""
import logging
import re
from pathlib import Path
from typing import Any, Dict, List
from commands.dynamic_symbol_loader import DynamicSymbolLoader
from commands.pin_locator import PinLocator
from commands.schematic_text_utils import (
_extract_property_position,
_find_facing_label,
_find_placed_symbol_block,
_find_project_root,
_move_property_in_block,
)
from commands.wire_manager import WireManager
logger = logging.getLogger("kicad_interface")
_GRID = 1.27 # 50-mil KiCad schematic grid (mm)
def _snap(val: float) -> float:
"""Round a coordinate to the nearest 50-mil (1.27mm) schematic grid point."""
return round(round(val / _GRID) * _GRID, 4)
def _field_positions_for_pins(cx, cy, all_pins):
"""Compute (Reference, Value) field positions from pin axis. Returns list of (name,x,y,angle)."""
off = 2.54
if len(all_pins) == 2:
coords = list(all_pins.values())
p1x = float(coords[0][0]) if isinstance(coords[0], (list, tuple)) else float(coords[0]["x"])
p1y = float(coords[0][1]) if isinstance(coords[0], (list, tuple)) else float(coords[0]["y"])
p2x = float(coords[1][0]) if isinstance(coords[1], (list, tuple)) else float(coords[1]["x"])
p2y = float(coords[1][1]) if isinstance(coords[1], (list, tuple)) else float(coords[1]["y"])
if abs(p1y - p2y) > abs(p1x - p2x): # vertical pin axis -> labels left/right
return [("Reference", round(cx - off, 4), cy, 0), ("Value", round(cx + off, 4), cy, 0)]
return [("Reference", cx, round(cy - off, 4), 0), ("Value", cx, round(cy + off, 4), 0)]
if all_pins: # multi-pin -> above topmost / below bottommost
pin_ys = [
float(c[1]) if isinstance(c, (list, tuple)) else float(c["y"])
for c in all_pins.values()
]
return [
("Reference", cx, round(min(pin_ys) - off, 4), 0),
("Value", cx, round(max(pin_ys) + off, 4), 0),
]
return [("Reference", cx, round(cy - off, 4), 0), ("Value", cx, round(cy + off, 4), 0)]
def _bbox_from_pins(all_pins, cx, cy):
"""body_bbox from pin spread (±1.27mm), or center ±2.54mm fallback."""
try:
if all_pins:
xs = [c[0] if isinstance(c, (list, tuple)) else c["x"] for c in all_pins.values()]
ys = [c[1] if isinstance(c, (list, tuple)) else c["y"] for c in all_pins.values()]
return {
"x_min": min(xs) - _GRID,
"y_min": min(ys) - _GRID,
"x_max": max(xs) + _GRID,
"y_max": max(ys) + _GRID,
}
except Exception:
pass
return {"x_min": cx - 2.54, "y_min": cy - 2.54, "x_max": cx + 2.54, "y_max": cy + 2.54}
class SchematicBatchCommands:
"""Handlers for batch schematic authoring. Holds a back-reference to KiCADInterface."""
def __init__(self, iface):
self.iface = iface
def batch_add_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add multiple components to a schematic in a single call."""
logger.info("Batch adding components to schematic")
try:
schematic_path = params.get("schematicPath")
components = params.get("components", [])
origin_x = params.get("origin_x", 0) or 0
origin_y = params.get("origin_y", 0) or 0
auto_position_fields = params.get("auto_position_fields", True)
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
if not components:
return {
"success": False,
"message": "components list is required and must be non-empty",
}
schematic_file = Path(schematic_path)
project_path = schematic_file.parent
loader = DynamicSymbolLoader(project_path=project_path)
locator = PinLocator()
results: List[Dict[str, Any]] = []
errors: List[Dict[str, Any]] = []
for comp in components:
symbol = comp.get("symbol", "")
if ":" not in symbol:
errors.append(
{
"symbol": symbol,
"reference": comp.get("reference", "?"),
"error": "symbol must be 'Library:SymbolName'",
}
)
continue
library, sym_name = symbol.split(":", 1)
reference = comp.get("reference", "X?")
value = comp.get("value", sym_name)
footprint = comp.get("footprint", "")
pos = comp.get("position", {})
x = (pos.get("x", 0) if isinstance(pos, dict) else 0) + origin_x
y = (pos.get("y", 0) if isinstance(pos, dict) else 0) + origin_y
rotation = comp.get("rotation", 0)
include_pins = comp.get("includePins", False)
try:
loader.add_component(
schematic_file,
library,
sym_name,
reference=reference,
value=value,
footprint=footprint,
x=x,
y=y,
angle=rotation,
project_path=project_path,
)
entry: Dict[str, Any] = {
"reference": reference,
"symbol": symbol,
"snapped_position": {"x": _snap(x), "y": _snap(y)},
}
if footprint and self.iface.footprint_library.find_footprint(footprint) is None:
entry["footprint_warning"] = (
f"Footprint '{footprint}' was not found in any registered footprint library "
"(validation only — the string WAS written to the Footprint field). "
"Use search_footprints to find a valid footprint string."
)
locator._schematic_cache.pop(str(schematic_file), None)
all_pins = locator.get_all_symbol_pins(schematic_file, reference) or {}
block_text = None
raw_content = schematic_file.read_text(encoding="utf-8")
block_text, block_start, block_end = _find_placed_symbol_block(
raw_content, reference
)
if auto_position_fields and block_text:
cx, cy = _snap(x), _snap(y)
new_block = block_text
for prop, px, py, pa in _field_positions_for_pins(cx, cy, all_pins):
new_block, _ = _move_property_in_block(
new_block, prop, px, py, pa, True
)
if new_block != block_text:
raw_content = (
raw_content[:block_start] + new_block + raw_content[block_end + 1 :]
)
schematic_file.write_text(raw_content, encoding="utf-8")
block_text = new_block
if block_text:
ref_pos = _extract_property_position(block_text, "Reference")
val_pos = _extract_property_position(block_text, "Value")
if ref_pos:
entry["ref_position"] = ref_pos
if val_pos:
entry["value_position"] = val_pos
if include_pins:
pins_def = locator.get_symbol_pins(schematic_file, symbol) or {}
pins = {
pin_num: {
"x": coords[0],
"y": coords[1],
"name": pins_def.get(str(pin_num), {}).get("name", str(pin_num)),
}
for pin_num, coords in all_pins.items()
}
entry["pins"] = pins
if not pins:
entry["pins_error"] = (
f"Pin extraction returned no data for {reference} ({symbol}). "
"Use get_schematic_pin_locations as a follow-up if needed."
)
entry["body_bbox"] = _bbox_from_pins(all_pins, _snap(x), _snap(y))
results.append(entry)
except Exception as e:
logger.error(f"Error adding {reference} ({symbol}): {e}")
errors.append({"symbol": symbol, "reference": reference, "error": str(e)})
# If this schematic is a sub-sheet of another, fix hierarchical instance paths
# (best-effort; only when the interface provides the fixer).
hier = getattr(self.iface, "hierarchy_commands", None)
if hier is not None:
sch_name = schematic_file.name
for candidate in project_path.glob("*.kicad_sch"):
if candidate.resolve() == schematic_file.resolve():
continue
try:
candidate_content = candidate.read_text(encoding="utf-8")
if sch_name in candidate_content:
hier.fix_subsheet_instances(str(candidate), candidate_content)
except Exception:
pass
placement_bbox = None
bbs = [r["body_bbox"] for r in results if "body_bbox" in r]
if bbs:
placement_bbox = {
"x_min": min(b["x_min"] for b in bbs),
"y_min": min(b["y_min"] for b in bbs),
"x_max": max(b["x_max"] for b in bbs),
"y_max": max(b["y_max"] for b in bbs),
}
return {
"success": len(errors) == 0,
"added": results,
"errors": errors,
"added_count": len(results),
"error_count": len(errors),
"placement_bbox": placement_bbox,
}
except Exception as e:
logger.error(f"Error in batch_add_components: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def batch_edit_schematic_components(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Edit multiple components in one call via the single-component edit handler."""
logger.info("Batch editing schematic components")
schematic_path = params.get("schematicPath")
components = params.get("components")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
if not components or not isinstance(components, dict):
return {
"success": False,
"message": "components must be a dict {reference: {footprint?, value?, newReference?}}",
}
updated: Dict[str, Any] = {}
errors: List[Dict[str, Any]] = []
for reference, props in components.items():
sub_params = {"schematicPath": schematic_path, "reference": reference, **props}
result = self.iface._handle_edit_schematic_component(sub_params)
if result.get("success"):
updated[reference] = result.get("updated", {})
else:
errors.append(
{"reference": reference, "error": result.get("message", "Unknown error")}
)
return {
"success": len(errors) == 0,
"updated_count": len(updated),
"error_count": len(errors),
"updated": updated,
"errors": errors,
}
def replace_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Replace a placed symbol with a different symbol, preserving position and fields."""
logger.info("Replacing schematic component")
try:
schematic_path = params.get("schematicPath")
reference = params.get("reference")
new_symbol = params.get("newSymbol")
new_rotation = params.get("newRotation")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
if not reference:
return {"success": False, "message": "reference is required"}
if not new_symbol:
return {
"success": False,
"message": "newSymbol is required (e.g. 'Device:D_Zener')",
}
if ":" not in new_symbol:
return {"success": False, "message": "newSymbol must be in 'Library:Symbol' format"}
new_library, new_type = new_symbol.split(":", 1)
sch_file = Path(schematic_path)
if not sch_file.exists():
return {"success": False, "message": f"Schematic not found: {schematic_path}"}
comp_info = self.iface._handle_get_schematic_component(
{"schematicPath": schematic_path, "reference": reference}
)
if not comp_info.get("success"):
return {
"success": False,
"message": f"Cannot find component '{reference}': {comp_info.get('message', '')}",
}
old_pos = comp_info.get("position", {})
old_x = old_pos.get("x", 0)
old_y = old_pos.get("y", 0)
old_rotation = old_pos.get("angle", 0)
old_fields = comp_info.get("fields", {})
use_rotation = new_rotation if new_rotation is not None else old_rotation
content = sch_file.read_text(encoding="utf-8")
block_text, block_start, block_end = _find_placed_symbol_block(content, reference)
if block_text is None:
return {
"success": False,
"message": f"Component '{reference}' not found in schematic file",
}
trim_start = block_start
while trim_start > 0 and content[trim_start - 1] in (" ", "\t"):
trim_start -= 1
if trim_start > 0 and content[trim_start - 1] == "\n":
trim_start -= 1
content = content[:trim_start] + content[block_end + 1 :]
sch_file.write_text(content, encoding="utf-8")
derived_project_path = _find_project_root(sch_file.parent)
loader = DynamicSymbolLoader(project_path=derived_project_path)
def _field_value(name, default):
fd = old_fields.get(name)
return (
fd.get("value", default)
if isinstance(fd, dict)
else (fd if fd is not None else default)
)
old_value = _field_value("Value", new_type)
old_footprint = _field_value("Footprint", "")
loader.add_component(
sch_file,
new_library,
new_type,
reference=reference,
value=old_value,
footprint=old_footprint,
x=old_x,
y=old_y,
angle=use_rotation,
project_path=derived_project_path,
)
fields_to_restore = {}
for fname, fdata in old_fields.items():
if fname in ("Reference", "Value", "Footprint"):
continue
fields_to_restore[fname] = (
fdata.get("value", "") if isinstance(fdata, dict) else str(fdata)
)
if fields_to_restore:
content_after = sch_file.read_text(encoding="utf-8")
new_block, _, _ = _find_placed_symbol_block(content_after, reference)
if new_block:
for fname, fval in fields_to_restore.items():
prop_pat = re.compile(
r'(\(property\s+"' + re.escape(fname) + r'"\s+)"[^"]*"'
)
content_after = prop_pat.sub(
r'\1"' + fval.replace('"', '\\"') + '"', content_after, count=1
)
sch_file.write_text(content_after, encoding="utf-8")
pin_locator = PinLocator()
pins_raw = pin_locator.get_all_symbol_pins(sch_file, reference) or {}
pins_def = pin_locator.get_symbol_pins(sch_file, f"{new_library}:{new_type}") or {}
pins = {
pin_num: {
"x": coords[0],
"y": coords[1],
"name": pins_def.get(str(pin_num), {}).get("name", str(pin_num)),
}
for pin_num, coords in pins_raw.items()
}
return {
"success": True,
"reference": reference,
"newSymbol": new_symbol,
"position": {"x": old_x, "y": old_y, "rotation": use_rotation},
"pins": pins,
"message": f"Replaced {reference} with {new_symbol} at ({old_x}, {old_y})",
}
except Exception as e:
logger.error(f"Error replacing schematic component: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def batch_add_no_connects(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Add no-connect flags to multiple pins in a single call."""
logger.info("Batch adding no-connect flags")
try:
schematic_path = params.get("schematicPath")
pins = params.get("pins", [])
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
if not pins:
return {"success": False, "message": "pins list is required and must be non-empty"}
sch_path = Path(schematic_path)
locator = PinLocator()
placed: List[Dict[str, Any]] = []
failed: List[Dict[str, Any]] = []
for entry in pins:
comp_ref = entry.get("componentRef")
pin_name = entry.get("pinName")
if not comp_ref or pin_name is None:
failed.append(
{"entry": entry, "reason": "componentRef and pinName are required"}
)
continue
try:
pin_loc = locator.get_pin_location(sch_path, comp_ref, str(pin_name))
if not pin_loc:
all_pins = locator.get_all_symbol_pins(sch_path, comp_ref) or {}
if len(all_pins) == 1:
pin_loc = next(iter(all_pins.values()))
else:
avail = sorted(all_pins.keys()) if all_pins else []
failed.append(
{
"componentRef": comp_ref,
"pinName": str(pin_name),
"reason": f"Pin not found; available: {avail}",
}
)
continue
if WireManager.add_no_connect(sch_path, pin_loc):
placed.append(
{
"componentRef": comp_ref,
"pinName": str(pin_name),
"position": {"x": pin_loc[0], "y": pin_loc[1]},
}
)
else:
failed.append(
{
"componentRef": comp_ref,
"pinName": str(pin_name),
"reason": "add_no_connect returned False",
}
)
except Exception as pin_err:
failed.append(
{"componentRef": comp_ref, "pinName": str(pin_name), "reason": str(pin_err)}
)
return {
"success": len(failed) == 0,
"message": f"Placed {len(placed)} no-connect marker(s), {len(failed)} failed",
"placed": placed,
"failed": failed,
}
except Exception as e:
logger.error(f"Error in batch_add_no_connects: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def batch_connect(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Place net labels on multiple pins in a single call, with facing-label wiring."""
logger.info("Batch connect: placing net labels on multiple pins")
try:
schematic_path = params.get("schematicPath")
connections = params.get("connections")
replace = bool(params.get("replace", False))
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
if not connections or not isinstance(connections, dict):
return {
"success": False,
"message": "connections must be a dict {ref: {pin: netName}}",
}
locator = PinLocator()
sch_path = Path(schematic_path)
try:
from skip import Schematic
Schematic(str(sch_path))
except Exception as parse_err:
return {
"success": False,
"message": (
f"ERROR: Failed to load schematic at {schematic_path}: {parse_err}. "
"All pin operations aborted. Run run_erc to check the schematic."
),
}
placed: List[Dict[str, Any]] = []
failed: List[Dict[str, Any]] = []
placed_positions: Dict[tuple, str] = {}
for ref, pin_map in connections.items():
if not isinstance(pin_map, dict):
failed.append({"ref": ref, "reason": "pin_map must be a dict {pin: netName}"})
continue
for pin_id, net_name in pin_map.items():
try:
resolved_pin = str(pin_id)
position = locator.get_pin_location(sch_path, ref, resolved_pin)
if not position:
all_pins = locator.get_all_symbol_pins(sch_path, ref) or {}
if len(all_pins) == 1:
resolved_pin = next(iter(all_pins))
position = all_pins[resolved_pin]
else:
avail = sorted(all_pins.keys()) if all_pins else []
failed.append(
{
"ref": ref,
"pin": resolved_pin,
"reason": f"pin not found; available: {avail}",
}
)
continue
pos_key = (round(float(position[0]), 2), round(float(position[1]), 2))
if placed_positions.get(pos_key) == net_name:
placed.append(
{
"ref": ref,
"pin": str(pin_id),
"net": net_name,
"position": {"x": position[0], "y": position[1]},
"note": "deduped: label already placed at this coordinate for this net",
}
)
continue
raw_angle = locator.get_pin_angle(sch_path, ref, resolved_pin) or 0
cardinal = round(raw_angle / 90) * 90 % 360
orientation = {0: 180, 90: 270, 180: 0, 270: 90}.get(cardinal, 0)
existing_pos = _find_facing_label(sch_path, net_name, position, orientation)
if existing_pos:
if WireManager.add_wire(sch_path, list(position), existing_pos):
placed.append(
{
"ref": ref,
"pin": str(pin_id),
"net": net_name,
"position": {"x": position[0], "y": position[1]},
"note": f"wired to existing label at ({existing_pos[0]},{existing_pos[1]})",
}
)
placed_positions[pos_key] = net_name
else:
failed.append(
{
"ref": ref,
"pin": str(pin_id),
"net": net_name,
"reason": "wire-to-existing-label failed",
}
)
continue
if replace:
WireManager.delete_label(
sch_path, net_name, list(position), tolerance=0.5
)
self._delete_labels_at(sch_path, position)
if WireManager.add_label(
sch_path,
net_name,
position,
label_type="label",
orientation=orientation,
):
placed.append(
{
"ref": ref,
"pin": str(pin_id),
"net": net_name,
"position": {"x": position[0], "y": position[1]},
}
)
placed_positions[pos_key] = net_name
else:
failed.append(
{
"ref": ref,
"pin": str(pin_id),
"net": net_name,
"reason": "add_label failed",
}
)
except Exception as pin_err:
failed.append({"ref": ref, "pin": str(pin_id), "reason": str(pin_err)})
result: Dict[str, Any] = {
"success": len(failed) == 0,
"message": f"Placed {len(placed)} label(s), {len(failed)} failed",
"placed": placed,
"failed": failed,
}
warnings = self._pwr_flag_warnings(sch_path, locator, placed)
if warnings:
result["warnings"] = warnings
return result
except Exception as e:
logger.error(f"Error in batch_connect: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
@staticmethod
def _delete_labels_at(sch_path, position):
"""Drop any (label ...) within 0.5mm of *position* (used by batch_connect replace=True)."""
try:
import sexpdata
from sexpdata import Symbol
data = sexpdata.loads(sch_path.read_text(encoding="utf-8"))
changed = False
new_data = []
for item in data:
if isinstance(item, list) and len(item) > 0 and item[0] == Symbol("label"):
at = next(
(
p
for p in item[1:]
if isinstance(p, list) and len(p) >= 3 and p[0] == Symbol("at")
),
None,
)
if (
at
and abs(float(at[1]) - position[0]) < 0.5
and abs(float(at[2]) - position[1]) < 0.5
):
changed = True
continue
new_data.append(item)
if changed:
sch_path.write_text(sexpdata.dumps(new_data), encoding="utf-8")
except Exception as e:
logger.warning(f"replace cleanup failed: {e}")
@staticmethod
def _pwr_flag_warnings(sch_path, locator, placed):
"""Warn about power nets that have no PWR_FLAG attached."""
keywords = {
"VCC",
"VDD",
"VIN",
"VBUS",
"VBAT",
"GND",
"AGND",
"DGND",
"PGND",
"+5V",
"+3V3",
"+12V",
"+3.3V",
"+5.0V",
"PWR",
"POWER",
"AVCC",
}
try:
placed_nets = {p["net"] for p in placed}
power_nets = {n for n in placed_nets if any(kw in n.upper() for kw in keywords)}
if not power_nets:
return []
pwr_flag_nets: set = set()
try:
from skip import Schematic
sch_obj = Schematic(str(sch_path))
pwr_flag_refs = [
sym.property.Reference.value
for sym in getattr(sch_obj, "symbol", [])
if "PWR_FLAG" in (sym.lib_id.value if hasattr(sym, "lib_id") else "")
and hasattr(sym.property, "Reference")
]
for pref in pwr_flag_refs:
ppin = locator.get_pin_location(sch_path, pref, "1")
if not ppin:
all_p = locator.get_all_symbol_pins(sch_path, pref) or {}
ppin = next(iter(all_p.values())) if all_p else None
if ppin:
for pl in placed:
if (
abs(pl["position"]["x"] - ppin[0]) < 0.5
and abs(pl["position"]["y"] - ppin[1]) < 0.5
):
pwr_flag_nets.add(pl["net"])
except Exception:
pass
missing = sorted(power_nets - pwr_flag_nets)
if missing:
return [
f"Power nets without PWR_FLAG: {', '.join(missing)}. "
"Add a PWR_FLAG component to each to suppress ERC power-pin errors."
]
except Exception:
pass
return []
def batch_add_and_connect(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Place multiple components and connect their pins in a single call."""
logger.info("Batch add-and-connect: placing components and wiring nets")
try:
schematic_path = params.get("schematicPath")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
raw_components = params.get("components", [])
if not raw_components:
return {
"success": False,
"message": "components list is required and must be non-empty",
}
components_for_add = []
nets_per_ref: Dict[str, Any] = {}
for comp in raw_components:
components_for_add.append({k: v for k, v in comp.items() if k != "nets"})
if comp.get("nets"):
nets_per_ref[comp.get("reference", "")] = comp["nets"]
add_params = {k: v for k, v in params.items() if k != "components"}
add_params["components"] = components_for_add
add_result = self.batch_add_components(add_params)
connect_result: Dict[str, Any] = {
"placed": [],
"failed": [],
"message": "no nets specified",
}
if nets_per_ref:
connect_result = self.batch_connect(
{"schematicPath": schematic_path, "connections": nets_per_ref}
)
add_errors = add_result.get("errors", [])
conn_placed = connect_result.get("placed", [])
conn_failed = connect_result.get("failed", [])
return {
"success": add_result.get("added_count", 0) > 0,
"message": (
f"Placed {add_result.get('added_count', 0)} component(s)"
f"{' (' + str(len(add_errors)) + ' failed)' if add_errors else ''}, "
f"connected {len(conn_placed)} pin(s)"
f"{' (' + str(len(conn_failed)) + ' failed)' if conn_failed else ''}"
),
"added_count": add_result.get("added_count", 0),
"added": add_result.get("added", []),
"connected_count": len(conn_placed),
"connected": conn_placed,
"placement_bbox": add_result.get("placement_bbox"),
"errors": add_errors,
"failed_connections": conn_failed,
"warnings": connect_result.get("warnings", []),
}
except Exception as e:
logger.error(f"Error in batch_add_and_connect: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}

View File

@@ -327,6 +327,7 @@ try:
from commands.schematic import SchematicManager
from commands.schematic_hierarchy import SchematicHierarchyCommands
from commands.schematic_field_layout import SchematicFieldLayoutCommands
from commands.schematic_batch import SchematicBatchCommands
from commands.symbol_creator import SymbolCreator
from commands.symbol_pins import SymbolPinCommands
@@ -453,6 +454,9 @@ class KiCADInterface:
self.hierarchy_commands = SchematicHierarchyCommands(self)
# Schematic field placement / layout-check commands
self.field_layout_commands = SchematicFieldLayoutCommands()
# Batch schematic authoring commands (need an interface back-reference for the
# single-item add/edit/get handlers, footprint library, and sub-sheet fixer)
self.batch_commands = SchematicBatchCommands(self)
# Initialize JLCPCB API integration
self.jlcpcb_client = JLCPCBClient() # Official API (requires auth)
@@ -547,6 +551,13 @@ class KiCADInterface:
"set_schematic_property_position": self.field_layout_commands.set_schematic_property_position,
"batch_set_schematic_property_positions": self.field_layout_commands.batch_set_schematic_property_positions,
"autoplace_schematic_fields": self.field_layout_commands.autoplace_schematic_fields,
# Batch schematic authoring commands
"batch_add_components": self.batch_commands.batch_add_components,
"batch_edit_schematic_components": self.batch_commands.batch_edit_schematic_components,
"replace_schematic_component": self.batch_commands.replace_schematic_component,
"batch_add_no_connects": self.batch_commands.batch_add_no_connects,
"batch_connect": self.batch_commands.batch_connect,
"batch_add_and_connect": self.batch_commands.batch_add_and_connect,
# JLCPCB API commands (complete parts catalog via API)
"download_jlcpcb_database": self._handle_download_jlcpcb_database,
"search_jlcpcb_parts": self._handle_search_jlcpcb_parts,

View File

@@ -22,6 +22,7 @@ import { registerLibraryTools } from "./tools/library.js";
import { registerSymbolLibraryTools } from "./tools/library-symbol.js";
import { registerSchematicHierarchyTools } from "./tools/schematic-hierarchy.js";
import { registerSchematicLayoutTools } from "./tools/schematic-layout.js";
import { registerSchematicBatchTools } from "./tools/schematic-batch.js";
import { registerJLCPCBApiTools } from "./tools/jlcpcb-api.js";
import { registerDatasheetTools } from "./tools/datasheet.js";
import { registerFootprintTools } from "./tools/footprint.js";
@@ -300,6 +301,7 @@ export class KiCADMcpServer {
registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this));
registerSchematicHierarchyTools(this.server, this.callKicadScript.bind(this));
registerSchematicLayoutTools(this.server, this.callKicadScript.bind(this));
registerSchematicBatchTools(this.server, this.callKicadScript.bind(this));
registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this));
registerDatasheetTools(this.server, this.callKicadScript.bind(this));
registerFootprintTools(this.server, this.callKicadScript.bind(this));

View File

@@ -138,6 +138,19 @@ export const toolCategories: ToolCategory[] = [
"autoplace_schematic_fields",
],
},
{
name: "schematic_batch",
description:
"Batch schematic authoring: add/edit/replace components, batch no-connects, batch connect, add-and-connect",
tools: [
"batch_add_components",
"batch_edit_schematic_components",
"replace_schematic_component",
"batch_add_no_connects",
"batch_connect",
"batch_add_and_connect",
],
},
{
name: "routing",
description: "Advanced routing operations: vias, copper pours",

View File

@@ -0,0 +1,173 @@
/**
* Batch schematic authoring tools.
*
* Place / edit / connect many things in one call to avoid per-item round-trips.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
export function registerSchematicBatchTools(server: McpServer, callKicadScript: Function) {
// Add many components at once
server.tool(
"batch_add_components",
"Add multiple components to a schematic in one call (far fewer round-trips than add_schematic_component). Each component: {symbol:'Library:Name', reference, value?, footprint?, position:{x,y}, rotation?, includePins?}. Reference/Value fields are auto-positioned outside the body (disable with auto_position_fields=false). Returns per-component snapped position, field positions, body_bbox, and an overall placement_bbox.",
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
components: z
.array(
z.object({
symbol: z.string().describe("'Library:SymbolName' (e.g., Device:R)"),
reference: z.string().describe("Reference designator (e.g., R1)"),
value: z.string().optional(),
footprint: z.string().optional(),
position: z.object({ x: z.number(), y: z.number() }).optional(),
rotation: z.number().optional(),
includePins: z.boolean().optional(),
}),
)
.describe("Components to place"),
origin_x: z.number().optional().describe("X offset added to every component position (mm)"),
origin_y: z.number().optional().describe("Y offset added to every component position (mm)"),
auto_position_fields: z
.boolean()
.optional()
.default(true)
.describe("Auto-place Ref/Value fields outside the body (default true)"),
},
async (args: any) => {
const r = await callKicadScript("batch_add_components", args);
if (r.success === false && r.message)
return { content: [{ type: "text", text: `Failed: ${r.message}` }] };
const lines = [`Added ${r.added_count} component(s), ${r.error_count} error(s).`];
for (const e of r.errors || []) lines.push(`${e.reference} (${e.symbol}): ${e.error}`);
return { content: [{ type: "text", text: lines.join("\n") }] };
},
);
// Edit many components at once
server.tool(
"batch_edit_schematic_components",
"Edit multiple existing components in one call. 'components' is a map {reference: {value?, footprint?, newReference?, ...}}; each entry is applied via the single-component editor. Returns per-reference updated/errors.",
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
components: z
.record(z.string(), z.record(z.string(), z.any()))
.describe("Map of reference -> fields to change"),
},
async (args: any) => {
const r = await callKicadScript("batch_edit_schematic_components", args);
if (r.success === false && r.message)
return { content: [{ type: "text", text: `Failed: ${r.message}` }] };
const lines = [`Updated ${r.updated_count}, ${r.error_count} error(s).`];
for (const e of r.errors || []) lines.push(`${e.reference}: ${e.error}`);
return { content: [{ type: "text", text: lines.join("\n") }] };
},
);
// Swap a symbol, preserving position/fields
server.tool(
"replace_schematic_component",
"Replace a placed component's symbol with a different one (e.g. Device:R -> Device:R_Potentiometer), preserving its position, rotation, and field values (Value/Footprint/custom). Optionally override rotation with newRotation. Returns the new pin positions.",
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
reference: z.string().describe("Reference of the component to replace (e.g., D1)"),
newSymbol: z.string().describe("New symbol in 'Library:Symbol' form (e.g., Device:D_Zener)"),
newRotation: z
.number()
.optional()
.describe("Override rotation in degrees (default: keep existing)"),
},
async (args: any) => {
const r = await callKicadScript("replace_schematic_component", args);
return {
content: [
{ type: "text", text: r.success ? r.message : `Failed: ${r.message || "Unknown error"}` },
],
};
},
);
// No-connect flags on many pins
server.tool(
"batch_add_no_connects",
"Add no-connect (X) flags to multiple pins in one call, to mark intentionally unconnected pins and silence ERC. 'pins' is a list of {componentRef, pinName}.",
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
pins: z
.array(z.object({ componentRef: z.string(), pinName: z.string() }))
.describe("Pins to mark no-connect"),
},
async (args: any) => {
const r = await callKicadScript("batch_add_no_connects", args);
if (r.success === false && r.message)
return { content: [{ type: "text", text: `Failed: ${r.message}` }] };
const lines = [r.message];
for (const f of r.failed || []) lines.push(`${f.componentRef}/${f.pinName}: ${f.reason}`);
return { content: [{ type: "text", text: lines.join("\n") }] };
},
);
// Net labels on many pins
server.tool(
"batch_connect",
"Place net labels on multiple pins in one call to wire nets quickly. 'connections' is a map {reference: {pin: netName}} where pin is a number or name. If a facing label for the same net is nearby, a wire is drawn instead of a duplicate label. Set replace=true to clear any existing label at a pin first. Warns about power nets missing a PWR_FLAG.",
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
connections: z
.record(z.string(), z.record(z.string(), z.string()))
.describe("Map of reference -> {pin: netName}"),
replace: z
.boolean()
.optional()
.default(false)
.describe("Delete existing labels at each pin before placing (default false)"),
},
async (args: any) => {
const r = await callKicadScript("batch_connect", args);
if (r.success === false && r.message)
return { content: [{ type: "text", text: `Failed: ${r.message}` }] };
const lines = [r.message];
for (const f of r.failed || []) lines.push(`${f.ref}/${f.pin}: ${f.reason}`);
for (const w of r.warnings || []) lines.push(`${w}`);
return { content: [{ type: "text", text: lines.join("\n") }] };
},
);
// Place + wire in one call
server.tool(
"batch_add_and_connect",
"Place multiple components AND wire their nets in a single call — the fewest-round-trip way to build a subcircuit. Each component is like batch_add_components plus an optional 'nets' map {pin: netName}. Components are placed first, then nets are connected via batch_connect.",
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
components: z
.array(
z.object({
symbol: z.string(),
reference: z.string(),
value: z.string().optional(),
footprint: z.string().optional(),
position: z.object({ x: z.number(), y: z.number() }).optional(),
rotation: z.number().optional(),
nets: z
.record(z.string(), z.string())
.optional()
.describe("Map of pin -> netName to label after placement"),
}),
)
.describe("Components to place and connect"),
origin_x: z.number().optional(),
origin_y: z.number().optional(),
},
async (args: any) => {
const r = await callKicadScript("batch_add_and_connect", args);
if (r.success === false && r.message)
return { content: [{ type: "text", text: `Failed: ${r.message}` }] };
const lines = [r.message];
for (const e of r.errors || []) lines.push(` ✗ add ${e.reference}: ${e.error}`);
for (const f of r.failed_connections || [])
lines.push(` ✗ connect ${f.ref}/${f.pin}: ${f.reason}`);
for (const w of r.warnings || []) lines.push(`${w}`);
return { content: [{ type: "text", text: lines.join("\n") }] };
},
);
}

View File

@@ -0,0 +1,215 @@
"""
Unit tests for batch schematic authoring commands (commands/schematic_batch.py).
Pure helpers are tested directly; handlers are tested for parameter validation and
orchestration with the heavy dependencies (DynamicSymbolLoader / PinLocator /
WireManager / interface handlers) stubbed, so no real KiCad install is needed.
"""
import sys
import types
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
from commands import schematic_batch as sb # noqa: E402
from commands.schematic_batch import ( # noqa: E402
SchematicBatchCommands,
_bbox_from_pins,
_field_positions_for_pins,
_find_facing_label,
_find_project_root,
_snap,
)
class TestHelpers:
def test_snap(self):
# 1.27mm (50-mil) grid
assert _snap(1.27) == 1.27
assert _snap(2.54) == 2.54
assert _snap(0.6) == 0.0 # rounds down to 0
assert _snap(2.0) == 2.54 # nearest grid point
def test_find_project_root(self, tmp_path):
(tmp_path / "proj.kicad_pro").write_text("{}")
sub = tmp_path / "a" / "b"
sub.mkdir(parents=True)
assert _find_project_root(sub) == tmp_path.resolve()
def test_find_project_root_fallback(self, tmp_path):
sub = tmp_path / "x"
sub.mkdir()
assert _find_project_root(sub) == sub # no .kicad_pro anywhere -> start dir
def test_find_facing_label(self, tmp_path):
f = tmp_path / "s.kicad_sch"
# A label "VBUS" at (10,20) angle 0; a pin at (24,20) facing it needs orientation 180.
f.write_text('(kicad_sch (label "VBUS" (at 10 20 0)))')
assert _find_facing_label(f, "VBUS", [12, 20], orientation=180) == [10.0, 20.0]
# Wrong net -> None
assert _find_facing_label(f, "GND", [12, 20], orientation=180) is None
# Too far -> None
assert _find_facing_label(f, "VBUS", [100, 200], orientation=180) is None
def test_field_positions_two_pin_vertical(self):
# pins stacked vertically -> labels go left/right
pins = {"1": [0, 5], "2": [0, -5]}
fp = _field_positions_for_pins(0, 0, pins)
names = {n for n, *_ in fp}
assert names == {"Reference", "Value"}
ref = next(p for p in fp if p[0] == "Reference")
assert ref[1] != 0 # x offset (left/right), y unchanged
def test_field_positions_two_pin_horizontal(self):
pins = {"1": [-5, 0], "2": [5, 0]}
fp = _field_positions_for_pins(0, 0, pins)
ref = next(p for p in fp if p[0] == "Reference")
assert ref[1] == 0 and ref[2] != 0 # above/below
def test_bbox_from_pins(self):
bb = _bbox_from_pins({"1": [0, 0], "2": [10, 4]}, 5, 2)
assert bb["x_min"] == -1.27 and bb["x_max"] == 11.27
def test_bbox_from_pins_fallback(self):
bb = _bbox_from_pins({}, 5, 5)
assert bb == {"x_min": 2.46, "y_min": 2.46, "x_max": 7.54, "y_max": 7.54}
class TestParamValidation:
def setup_method(self):
self.c = SchematicBatchCommands(types.SimpleNamespace())
def test_validation(self):
assert self.c.batch_add_components({})["success"] is False
assert (
self.c.batch_add_components({"schematicPath": "/x"})["success"] is False
) # no components
assert self.c.batch_edit_schematic_components({"schematicPath": "/x"})["success"] is False
assert (
self.c.replace_schematic_component({"schematicPath": "/x", "reference": "R1"})[
"success"
]
is False
)
assert self.c.batch_add_no_connects({"schematicPath": "/x"})["success"] is False
assert self.c.batch_connect({"schematicPath": "/x"})["success"] is False
assert self.c.batch_add_and_connect({"schematicPath": "/x"})["success"] is False
class TestBatchEdit:
def test_aggregates_results(self):
calls = []
def fake_edit(params):
calls.append(params)
ref = params["reference"]
if ref == "BAD":
return {"success": False, "message": "nope"}
return {"success": True, "updated": {"value": params.get("value")}}
iface = types.SimpleNamespace(_handle_edit_schematic_component=fake_edit)
c = SchematicBatchCommands(iface)
r = c.batch_edit_schematic_components(
{
"schematicPath": "/x.kicad_sch",
"components": {"R1": {"value": "10k"}, "BAD": {"value": "x"}},
}
)
assert r["updated_count"] == 1
assert r["error_count"] == 1
assert r["success"] is False
# sub-handler received merged params
assert any(c_["reference"] == "R1" and c_["value"] == "10k" for c_ in calls)
class TestBatchAddNoConnects:
def test_happy_and_fallback(self, monkeypatch):
fake_loc = types.SimpleNamespace(
get_pin_location=lambda p, ref, pin: [10.0, 20.0] if pin == "1" else None,
get_all_symbol_pins=lambda p, ref: {"1": [10.0, 20.0]}, # single-pin fallback
)
added = []
fake_wm = types.SimpleNamespace(add_no_connect=lambda p, loc: added.append(loc) or True)
monkeypatch.setattr(sb, "PinLocator", lambda: fake_loc)
monkeypatch.setattr(sb, "WireManager", fake_wm)
c = SchematicBatchCommands(types.SimpleNamespace())
r = c.batch_add_no_connects(
{
"schematicPath": "/x.kicad_sch",
"pins": [
{"componentRef": "U1", "pinName": "1"},
{"componentRef": "TP1", "pinName": "9"},
],
}
)
assert r["success"] is True # both succeed (2nd via single-pin fallback)
assert len(r["placed"]) == 2
assert len(added) == 2
class TestBatchConnect:
def test_places_label(self, monkeypatch, tmp_path):
f = tmp_path / "x.kicad_sch"
f.write_text("(kicad_sch)")
fake_loc = types.SimpleNamespace(
get_pin_location=lambda p, ref, pin: [10.0, 20.0],
get_pin_angle=lambda p, ref, pin: 0,
get_all_symbol_pins=lambda p, ref: {"1": [10.0, 20.0]},
)
labels = []
fake_wm = types.SimpleNamespace(
add_label=lambda p, net, pos, label_type="label", orientation=0: labels.append(
(net, orientation)
)
or True,
add_wire=lambda *a: True,
delete_label=lambda *a, **k: True,
)
monkeypatch.setattr(sb, "PinLocator", lambda: fake_loc)
monkeypatch.setattr(sb, "WireManager", fake_wm)
monkeypatch.setattr(sb, "_find_facing_label", lambda *a, **k: None)
c = SchematicBatchCommands(types.SimpleNamespace())
r = c.batch_connect({"schematicPath": str(f), "connections": {"R1": {"1": "SDA"}}})
assert r["success"] is True
assert len(r["placed"]) == 1
assert labels == [("SDA", 180)] # pin angle 0 -> label orientation 180
class TestBatchAddAndConnect:
def test_splits_nets_and_orchestrates(self):
c = SchematicBatchCommands(types.SimpleNamespace())
add_args = {}
conn_args = {}
def fake_add(params):
add_args.update(params)
return {
"added_count": 1,
"added": [{"reference": "R1"}],
"errors": [],
"placement_bbox": None,
}
def fake_connect(params):
conn_args.update(params)
return {"placed": [{"ref": "R1", "pin": "1", "net": "VCC"}], "failed": []}
c.batch_add_components = fake_add
c.batch_connect = fake_connect
r = c.batch_add_and_connect(
{
"schematicPath": "/x.kicad_sch",
"components": [{"symbol": "Device:R", "reference": "R1", "nets": {"1": "VCC"}}],
}
)
assert r["success"] is True
assert r["added_count"] == 1
assert r["connected_count"] == 1
# 'nets' stripped from the components passed to batch_add_components
assert "nets" not in add_args["components"][0]
# nets routed to batch_connect keyed by reference
assert conn_args["connections"] == {"R1": {"1": "VCC"}}