From ccd817531a67c4acb26424f2b1700be81e8940c6 Mon Sep 17 00:00:00 2001 From: Eugene Mikhantyev Date: Sun, 15 Mar 2026 15:37:52 +0000 Subject: [PATCH] feat: add get_schematic_component tool and fieldPositions to edit_schematic_component - New `get_schematic_component` MCP tool returns component position and all field values with their label (at x/y/angle) positions - Extends `edit_schematic_component` with optional `fieldPositions` dict so callers can reposition Reference/Value/etc. labels in one call - Adds 18 tests (6 unit, 12 integration) covering parsing, round-trips, and edge cases Co-Authored-By: Claude Sonnet 4.6 --- python/kicad_interface.py | 117 ++++++- .../tests/test_schematic_component_fields.py | 302 ++++++++++++++++++ src/tools/schematic.ts | 40 +++ 3 files changed, 458 insertions(+), 1 deletion(-) create mode 100644 python/tests/test_schematic_component_fields.py diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 40ca581..7d1f02a 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -372,6 +372,7 @@ class KiCADInterface: "add_schematic_component": self._handle_add_schematic_component, "delete_schematic_component": self._handle_delete_schematic_component, "edit_schematic_component": self._handle_edit_schematic_component, + "get_schematic_component": self._handle_get_schematic_component, "add_schematic_wire": self._handle_add_schematic_wire, "add_schematic_connection": self._handle_add_schematic_connection, "add_schematic_net_label": self._handle_add_schematic_net_label, @@ -832,6 +833,7 @@ class KiCADInterface: new_footprint = params.get("footprint") new_value = params.get("value") new_reference = params.get("newReference") + field_positions = params.get("fieldPositions") # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}} if not schematic_path: return {"success": False, "message": "schematicPath is required"} @@ -842,11 +844,12 @@ class KiCADInterface: new_footprint is not None, new_value is not None, new_reference is not None, + field_positions is not None, ] ): return { "success": False, - "message": "At least one of footprint, value, or newReference must be provided", + "message": "At least one of footprint, value, newReference, or fieldPositions must be provided", } sch_file = Path(schematic_path) @@ -930,6 +933,16 @@ class KiCADInterface: rf'\1"{new_reference}"', block_text, ) + if field_positions is not None: + for field_name, pos in field_positions.items(): + x = pos.get("x", 0) + y = pos.get("y", 0) + angle = pos.get("angle", 0) + block_text = re.sub( + r'(\(property\s+"' + re.escape(field_name) + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + rf'\1(at {x} {y} {angle})', + block_text, + ) content = content[:block_start] + block_text + content[block_end + 1 :] @@ -945,6 +958,8 @@ class KiCADInterface: }.items() if v is not None } + if field_positions is not None: + changes["fieldPositions"] = field_positions logger.info(f"Edited schematic component {reference}: {changes}") return {"success": True, "reference": reference, "updated": changes} @@ -955,6 +970,106 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_get_schematic_component(self, params): + """Return full component info: position and all field values with their (at x y angle) positions.""" + logger.info("Getting schematic component info") + try: + from pathlib import Path + import re + + schematic_path = params.get("schematicPath") + reference = params.get("reference") + + if not schematic_path: + return {"success": False, "message": "schematicPath is required"} + if not reference: + return {"success": False, "message": "reference is required"} + + sch_file = Path(schematic_path) + if not sch_file.exists(): + return {"success": False, "message": f"Schematic not found: {schematic_path}"} + + with open(sch_file, "r", encoding="utf-8") as f: + content = f.read() + + def find_matching_paren(s, start): + depth = 0 + i = start + while i < len(s): + if s[i] == "(": + depth += 1 + elif s[i] == ")": + depth -= 1 + if depth == 0: + return i + i += 1 + return -1 + + # Skip lib_symbols section + lib_sym_pos = content.find("(lib_symbols") + lib_sym_end = find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1 + + # Find the placed symbol block for this reference + block_start = block_end = None + search_start = 0 + pattern = re.compile(r'\(symbol\s+\(lib_id\s+"') + while True: + m = pattern.search(content, search_start) + if not m: + break + pos = m.start() + if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end: + search_start = lib_sym_end + 1 + continue + end = find_matching_paren(content, pos) + if end < 0: + search_start = pos + 1 + continue + block_text = content[pos: end + 1] + if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text): + block_start, block_end = pos, end + break + search_start = end + 1 + + if block_start is None: + return {"success": False, "message": f"Component '{reference}' not found in schematic"} + + block_text = content[block_start: block_end + 1] + + # Extract component position: first (at x y angle) in the symbol header line + comp_at = re.search(r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', block_text) + if comp_at: + comp_pos = {"x": float(comp_at.group(1)), "y": float(comp_at.group(2)), "angle": float(comp_at.group(3))} + else: + comp_pos = None + + # Extract all properties with their at positions + prop_pattern = re.compile( + r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' + ) + fields = {} + for m in prop_pattern.finditer(block_text): + name, value, x, y, angle = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5) + fields[name] = { + "value": value, + "x": float(x), + "y": float(y), + "angle": float(angle), + } + + return { + "success": True, + "reference": reference, + "position": comp_pos, + "fields": fields, + } + + except Exception as e: + logger.error(f"Error getting schematic component: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_add_schematic_wire(self, params): """Add a wire to a schematic using WireManager""" logger.info("Adding wire to schematic") diff --git a/python/tests/test_schematic_component_fields.py b/python/tests/test_schematic_component_fields.py new file mode 100644 index 0000000..0a3da02 --- /dev/null +++ b/python/tests/test_schematic_component_fields.py @@ -0,0 +1,302 @@ +""" +Tests for get_schematic_component and edit_schematic_component fieldPositions support. +""" +import re +import sys +import shutil +import tempfile +from pathlib import Path + +import pytest + +# Ensure python/ directory is on path so kicad_interface can be imported +sys.path.insert(0, str(Path(__file__).parent.parent)) +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "python")) + + +# --------------------------------------------------------------------------- +# Helpers shared across tests +# --------------------------------------------------------------------------- + +TEMPLATE_SCH = Path(__file__).parent.parent / "templates" / "empty.kicad_sch" + +# Minimal placed-symbol block we can embed into a schematic for testing +PLACED_RESISTOR_BLOCK = '''\ + (symbol (lib_id "Device:R") (at 50 50 0) (unit 1) + (in_bom yes) (on_board yes) (dnp no) + (uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") + (property "Reference" "R1" (at 51.27 47.46 0) + (effects (font (size 1.27 1.27))) + ) + (property "Value" "10k" (at 51.27 52.54 0) + (effects (font (size 1.27 1.27))) + ) + (property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + (property "Datasheet" "~" (at 50 50 0) + (effects (font (size 1.27 1.27)) hide) + ) + ) +''' + + +def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path: + """Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block.""" + dest = tmp_dir / "test.kicad_sch" + src_content = TEMPLATE_SCH.read_text(encoding="utf-8") + # Insert placed symbol block before the closing paren of the top-level form + if extra_block: + src_content = src_content.rstrip() + if src_content.endswith(")"): + src_content = src_content[:-1] + "\n" + extra_block + ")\n" + dest.write_text(src_content, encoding="utf-8") + return dest + + +# --------------------------------------------------------------------------- +# Unit tests – regex / parsing logic only (no file I/O, no KiCAD imports) +# --------------------------------------------------------------------------- + +@pytest.mark.unit +class TestGetSchematicComponentParsing: + """Unit tests for the regex logic used by _handle_get_schematic_component.""" + + def _parse_fields(self, block_text: str) -> dict: + """Mirrors the regex used in _handle_get_schematic_component.""" + prop_pattern = re.compile( + r'\(property\s+"([^"]*)"\s+"([^"]*)"\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)' + ) + fields = {} + for m in prop_pattern.finditer(block_text): + name, value, x, y, angle = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5) + fields[name] = {"value": value, "x": float(x), "y": float(y), "angle": float(angle)} + return fields + + def _parse_comp_pos(self, block_text: str): + """Mirrors the regex used to extract symbol position.""" + m = re.search( + r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)\s*\)', + block_text, + ) + if m: + return {"x": float(m.group(1)), "y": float(m.group(2)), "angle": float(m.group(3))} + return None + + def test_parses_reference_field(self): + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert "Reference" in fields + assert fields["Reference"]["value"] == "R1" + assert fields["Reference"]["x"] == pytest.approx(51.27) + assert fields["Reference"]["y"] == pytest.approx(47.46) + assert fields["Reference"]["angle"] == pytest.approx(0.0) + + def test_parses_value_field(self): + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert "Value" in fields + assert fields["Value"]["value"] == "10k" + assert fields["Value"]["x"] == pytest.approx(51.27) + assert fields["Value"]["y"] == pytest.approx(52.54) + + def test_parses_all_four_standard_fields(self): + fields = self._parse_fields(PLACED_RESISTOR_BLOCK) + assert set(fields.keys()) >= {"Reference", "Value", "Footprint", "Datasheet"} + + def test_parses_component_position(self): + pos = self._parse_comp_pos(PLACED_RESISTOR_BLOCK) + assert pos is not None + assert pos["x"] == pytest.approx(50.0) + assert pos["y"] == pytest.approx(50.0) + assert pos["angle"] == pytest.approx(0.0) + + def test_field_position_regex_replaces_correctly(self): + """Mirrors the regex used in _handle_edit_schematic_component for fieldPositions.""" + field_name = "Reference" + new_x, new_y, new_angle = 99.0, 88.0, 0 + block = PLACED_RESISTOR_BLOCK + block = re.sub( + r'(\(property\s+"' + re.escape(field_name) + r'"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + rf'\1(at {new_x} {new_y} {new_angle})', + block, + ) + fields = self._parse_fields(block) + assert fields["Reference"]["x"] == pytest.approx(99.0) + assert fields["Reference"]["y"] == pytest.approx(88.0) + # Value should be unchanged + assert fields["Value"]["x"] == pytest.approx(51.27) + + def test_field_position_regex_preserves_value(self): + """Replacing position must not change the field value string.""" + block = PLACED_RESISTOR_BLOCK + block = re.sub( + r'(\(property\s+"Value"\s+"[^"]*"\s+)\(at\s+[\d\.\-]+\s+[\d\.\-]+\s+[\d\.\-]+\s*\)', + r'\1(at 0.0 0.0 0)', + block, + ) + fields = self._parse_fields(block) + assert fields["Value"]["value"] == "10k" + + +# --------------------------------------------------------------------------- +# Integration tests – real file I/O using the empty.kicad_sch template +# --------------------------------------------------------------------------- + +@pytest.mark.integration +class TestGetSchematicComponentIntegration: + """Integration tests: write a real .kicad_sch and call the handler.""" + + @pytest.fixture + def sch_with_r1(self, tmp_path): + return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) + + def _get_interface(self): + """Lazily import KiCADInterface to avoid pcbnew import at collection time.""" + from kicad_interface import KiCADInterface + return KiCADInterface() + + def test_get_returns_success(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert result["success"] is True + + def test_get_returns_correct_reference(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert result["reference"] == "R1" + + def test_get_returns_component_position(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert result["position"] is not None + assert result["position"]["x"] == pytest.approx(50.0) + assert result["position"]["y"] == pytest.approx(50.0) + + def test_get_returns_reference_field_position(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + ref_field = result["fields"]["Reference"] + assert ref_field["value"] == "R1" + assert ref_field["x"] == pytest.approx(51.27) + assert ref_field["y"] == pytest.approx(47.46) + + def test_get_returns_value_field(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + val_field = result["fields"]["Value"] + assert val_field["value"] == "10k" + assert val_field["x"] == pytest.approx(51.27) + assert val_field["y"] == pytest.approx(52.54) + + def test_get_unknown_reference_returns_failure(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R99", + }) + assert result["success"] is False + assert "R99" in result["message"] + + def test_get_missing_path_returns_failure(self): + iface = self._get_interface() + result = iface.handle_command("get_schematic_component", { + "reference": "R1", + }) + assert result["success"] is False + + +@pytest.mark.integration +class TestEditSchematicComponentFieldPositions: + """Integration tests for the new fieldPositions parameter.""" + + @pytest.fixture + def sch_with_r1(self, tmp_path): + return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK) + + def _get_interface(self): + from kicad_interface import KiCADInterface + return KiCADInterface() + + def test_reposition_reference_label(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("edit_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0, "angle": 0}}, + }) + assert result["success"] is True + + # Verify the position was actually written + get_result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert get_result["fields"]["Reference"]["x"] == pytest.approx(99.0) + assert get_result["fields"]["Reference"]["y"] == pytest.approx(88.0) + + def test_reposition_does_not_change_value(self, sch_with_r1): + iface = self._get_interface() + iface.handle_command("edit_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Reference": {"x": 99.0, "y": 88.0}}, + }) + get_result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + # Value field position must be unchanged + assert get_result["fields"]["Value"]["x"] == pytest.approx(51.27) + assert get_result["fields"]["Value"]["y"] == pytest.approx(52.54) + + def test_reposition_multiple_fields(self, sch_with_r1): + iface = self._get_interface() + result = iface.handle_command("edit_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": { + "Reference": {"x": 10.0, "y": 20.0, "angle": 0}, + "Value": {"x": 10.0, "y": 30.0, "angle": 0}, + }, + }) + assert result["success"] is True + + get_result = iface.handle_command("get_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert get_result["fields"]["Reference"]["x"] == pytest.approx(10.0) + assert get_result["fields"]["Value"]["y"] == pytest.approx(30.0) + + def test_fieldpositions_alone_is_valid(self, sch_with_r1): + """fieldPositions without value/footprint/newReference should succeed.""" + iface = self._get_interface() + result = iface.handle_command("edit_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + "fieldPositions": {"Value": {"x": 55.0, "y": 60.0}}, + }) + assert result["success"] is True + + def test_no_params_still_fails(self, sch_with_r1): + """Providing no update params should return an error.""" + iface = self._get_interface() + result = iface.handle_command("edit_schematic_component", { + "schematicPath": str(sch_with_r1), + "reference": "R1", + }) + assert result["success"] is False diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index 65ebc44..ebe5782 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -160,6 +160,11 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp footprint: z.string().optional().describe("New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric)"), value: z.string().optional().describe("New value string (e.g. 10k, 100nF)"), newReference: z.string().optional().describe("Rename the reference designator (e.g. R1 → R10)"), + fieldPositions: z.record(z.object({ + x: z.number(), + y: z.number(), + angle: z.number().optional().default(0), + })).optional().describe("Reposition field labels: map of field name to {x, y, angle} (e.g. {\"Reference\": {\"x\": 12.5, \"y\": 17.0}})"), }, async (args: { schematicPath: string; @@ -167,6 +172,7 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp footprint?: string; value?: string; newReference?: string; + fieldPositions?: Record; }) => { const result = await callKicadScript("edit_schematic_component", args); if (result.success) { @@ -193,6 +199,40 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }, ); + // Get component properties and field positions from schematic + server.tool( + "get_schematic_component", + "Get full component info from a schematic: position, field values, and each field's label position (at x/y/angle). Use this to inspect or prepare repositioning of Reference/Value labels.", + { + schematicPath: z.string().describe("Path to the .kicad_sch file"), + reference: z.string().describe("Component reference designator (e.g. R1, U1)"), + }, + async (args: { schematicPath: string; reference: string }) => { + const result = await callKicadScript("get_schematic_component", args); + if (result.success) { + const pos = result.position + ? `(${result.position.x}, ${result.position.y}, angle=${result.position.angle}°)` + : "unknown"; + const fieldLines = Object.entries(result.fields ?? {}).map( + ([name, f]: [string, any]) => + ` ${name}: "${f.value}" @ (${f.x}, ${f.y}, angle=${f.angle}°)` + ); + return { + content: [{ + type: "text", + text: `Component ${result.reference} at ${pos}\nFields:\n${fieldLines.join("\n")}`, + }], + }; + } + return { + content: [{ + type: "text", + text: `Failed to get component: ${result.message || "Unknown error"}`, + }], + }; + }, + ); + // Connect components with wire server.tool( "add_wire",