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:
813
python/commands/schematic_batch.py
Normal file
813
python/commands/schematic_batch.py
Normal 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)}
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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",
|
||||
|
||||
173
src/tools/schematic-batch.ts
Normal file
173
src/tools/schematic-batch.ts
Normal 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") }] };
|
||||
},
|
||||
);
|
||||
}
|
||||
215
tests/test_schematic_batch.py
Normal file
215
tests/test_schematic_batch.py
Normal 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"}}
|
||||
Reference in New Issue
Block a user