diff --git a/python/commands/schematic_batch.py b/python/commands/schematic_batch.py new file mode 100644 index 0000000..3e45ab3 --- /dev/null +++ b/python/commands/schematic_batch.py @@ -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)} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 4bdec93..eb15e26 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -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, diff --git a/src/server.ts b/src/server.ts index 465ebe7..9b9f106 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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)); diff --git a/src/tools/registry.ts b/src/tools/registry.ts index c3aa76d..6f1f569 100644 --- a/src/tools/registry.ts +++ b/src/tools/registry.ts @@ -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", diff --git a/src/tools/schematic-batch.ts b/src/tools/schematic-batch.ts new file mode 100644 index 0000000..73e4c38 --- /dev/null +++ b/src/tools/schematic-batch.ts @@ -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") }] }; + }, + ); +} diff --git a/tests/test_schematic_batch.py b/tests/test_schematic_batch.py new file mode 100644 index 0000000..2ab926d --- /dev/null +++ b/tests/test_schematic_batch.py @@ -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"}}