diff --git a/python/commands/wire_connectivity.py b/python/commands/wire_connectivity.py index d1728c9..9bbfbbb 100644 --- a/python/commands/wire_connectivity.py +++ b/python/commands/wire_connectivity.py @@ -273,3 +273,61 @@ def get_wire_connections( pins = _find_pins_on_net(net_points, schematic_path, schematic) return {"pins": pins, "wires": wires_out} + + +def get_pin_net(schematic: Any, schematic_path: str, x_mm: float, y_mm: float) -> Optional[Dict]: + """Return the net name and all connected pins for the wire network at (x_mm, y_mm). + + Returns dict with keys: + - "net": str or None (net label/power name, None if unnamed) + - "pins": list of {"component": str, "pin": str} + - "wires": list of {"start": {"x", "y"}, "end": {"x", "y"}} in mm + - "query_point": {"x": float, "y": float} + Or None if no wire endpoint found at the query point. + """ + all_wires = _parse_wires(schematic) + if not all_wires: + return {"net": None, "pins": [], "wires": [], "query_point": {"x": x_mm, "y": y_mm}} + + adjacency, iu_to_wires = _build_adjacency(all_wires) + point_to_label, label_to_points = _parse_virtual_connections(schematic, schematic_path) + + visited, net_points = _find_connected_wires( + x_mm, + y_mm, + all_wires, + iu_to_wires, + adjacency, + point_to_label=point_to_label, + label_to_points=label_to_points, + ) + if visited is None: + return None + + # Resolve net name: first label anchor that falls on this net's IU points + net: Optional[str] = None + for pt in net_points: + label = point_to_label.get(pt) + if label is not None: + net = label + break + + wires_out = [ + { + "start": { + "x": all_wires[i][0][0] / _IU_PER_MM, + "y": all_wires[i][0][1] / _IU_PER_MM, + }, + "end": { + "x": all_wires[i][-1][0] / _IU_PER_MM, + "y": all_wires[i][-1][1] / _IU_PER_MM, + }, + } + for i in visited + ] + + pins: List[Dict] = [] + if hasattr(schematic, "symbol"): + pins = _find_pins_on_net(net_points, schematic_path, schematic) + + return {"net": net, "pins": pins, "wires": wires_out, "query_point": {"x": x_mm, "y": y_mm}} diff --git a/python/kicad_interface.py b/python/kicad_interface.py index 5600828..a061670 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -382,6 +382,7 @@ class KiCADInterface: "get_schematic_pin_locations": self._handle_get_schematic_pin_locations, "get_net_connections": self._handle_get_net_connections, "get_wire_connections": self._handle_get_wire_connections, + "get_pin_net": self._handle_get_pin_net, "run_erc": self._handle_run_erc, "generate_netlist": self._handle_generate_netlist, "sync_schematic_to_board": self._handle_sync_schematic_to_board, @@ -2503,6 +2504,70 @@ class KiCADInterface: logger.error(traceback.format_exc()) return {"success": False, "message": str(e)} + def _handle_get_pin_net(self, params: Dict[str, Any]) -> Dict[str, Any]: + """Find net name and all connected pins for a reference+pin or coordinate query""" + logger.info("Getting pin net") + try: + from pathlib import Path + + from commands.pin_locator import PinLocator + from commands.wire_connectivity import get_pin_net + + schematic_path = params.get("schematicPath") + if not schematic_path: + return {"success": False, "message": "Missing required parameter: schematicPath"} + + reference = params.get("reference") + pin = params.get("pin") + x = params.get("x") + y = params.get("y") + + has_ref_pin = reference is not None and pin is not None + has_coords = x is not None and y is not None + + if not has_ref_pin and not has_coords: + return { + "success": False, + "message": "Must supply either {reference, pin} or {x, y}", + } + + if has_ref_pin: + location = PinLocator().get_pin_location(Path(schematic_path), reference, str(pin)) + if location is None: + return { + "success": False, + "message": f"Pin {pin} not found on {reference}", + } + x, y = location[0], location[1] + else: + try: + x, y = float(x), float(y) + except (TypeError, ValueError): + return {"success": False, "message": "Parameters x and y must be numeric"} + + schematic = SchematicManager.load_schematic(schematic_path) + if not schematic: + return {"success": False, "message": "Failed to load schematic"} + + if not hasattr(schematic, "wire"): + return {"success": False, "message": "Schematic has no wires"} + + result = get_pin_net(schematic, schematic_path, x, y) + if result is None: + return { + "success": False, + "message": f"No wire found at ({x},{y}) — pin may not be connected", + } + + return {"success": True, **result} + + except Exception as e: + logger.error(f"Error getting pin net: {str(e)}") + import traceback + + logger.error(traceback.format_exc()) + return {"success": False, "message": str(e)} + def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]: """Run Electrical Rules Check on a schematic via kicad-cli""" logger.info("Running ERC on schematic") diff --git a/python/schemas/tool_schemas.py b/python/schemas/tool_schemas.py index dad7d47..33eee7b 100644 --- a/python/schemas/tool_schemas.py +++ b/python/schemas/tool_schemas.py @@ -1523,6 +1523,44 @@ SCHEMATIC_TOOLS = [ "required": ["schematicPath", "x", "y"], }, }, + { + "name": "get_pin_net", + "title": "Get Pin Net", + "description": ( + "Returns the net name and all connected component pins for a query. " + "Accepts either a component reference + pin number (e.g. reference='U1', pin='3') " + "or a schematic coordinate (x, y in mm). " + "If a net label or power symbol is reachable from the query point, its name is " + "returned as 'net'; otherwise 'net' is null (unnamed net). " + "The 'pins' list contains every component pin on that same net." + ), + "inputSchema": { + "type": "object", + "properties": { + "schematicPath": { + "type": "string", + "description": "Path to the schematic file (.kicad_sch)", + }, + "reference": { + "type": "string", + "description": "Component reference (e.g. U1, R1). Pair with pin.", + }, + "pin": { + "type": "string", + "description": "Pin number or name (e.g. '3', 'SDA'). Pair with reference.", + }, + "x": { + "type": "number", + "description": "X coordinate of a wire endpoint in mm. Pair with y.", + }, + "y": { + "type": "number", + "description": "Y coordinate of a wire endpoint in mm. Pair with x.", + }, + }, + "required": ["schematicPath"], + }, + }, { "name": "get_schematic_pin_locations", "title": "Get Schematic Pin Locations", diff --git a/src/tools/schematic.ts b/src/tools/schematic.ts index d32e33f..1d2a53c 100644 --- a/src/tools/schematic.ts +++ b/src/tools/schematic.ts @@ -1424,4 +1424,61 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp }; }, ); + + server.tool( + "get_pin_net", + "Returns the net name and all connected pins for a component pin (reference + pin number) " + + "or a schematic coordinate (x, y in mm). Use this instead of list_schematic_nets + " + + "get_wire_connections when you want to answer 'what net is pin 3 of U1 on?'. " + + "Returns net=null for unnamed (unlabelled) nets.", + { + schematicPath: z.string().describe("Path to the schematic file"), + reference: z + .string() + .optional() + .describe("Component reference (e.g. U1, R1). Pair with pin."), + pin: z + .string() + .optional() + .describe("Pin number or name (e.g. '3', 'SDA'). Pair with reference."), + x: z.number().optional().describe("X coordinate of a wire endpoint in mm. Pair with y."), + y: z.number().optional().describe("Y coordinate of a wire endpoint in mm. Pair with x."), + }, + async (args: { + schematicPath: string; + reference?: string; + pin?: string; + x?: number; + y?: number; + }) => { + const result = await callKicadScript("get_pin_net", args); + if (result.success) { + const netLabel = result.net ?? "(unnamed)"; + const pinList = (result.pins ?? []) + .map((p: any) => ` - ${p.component}/${p.pin}`) + .join("\n"); + const qp = result.query_point; + return { + content: [ + { + type: "text", + text: + `Net: ${netLabel}\n` + + `Query point: (${qp?.x ?? args.x}, ${qp?.y ?? args.y})\n` + + `Connected pins:\n${pinList || " (none found)"}`, + }, + ], + }; + } else { + return { + content: [ + { + type: "text", + text: `Failed to get pin net: ${result.message || "Unknown error"}`, + }, + ], + }; + } + }, + ); } diff --git a/tests/test_get_pin_net.py b/tests/test_get_pin_net.py new file mode 100644 index 0000000..f9d43aa --- /dev/null +++ b/tests/test_get_pin_net.py @@ -0,0 +1,325 @@ +""" +Tests for the get_pin_net tool and its handler. + +Covers: + - Schema shape (TestGetPinNetSchema) + - Handler dispatch registration (TestGetPinNetHandlerDispatch) + - Parameter validation in the handler (TestGetPinNetHandlerParamValidation) + - Core logic: get_pin_net function (TestGetPinNetCoreLogic) + - Reference+pin resolution path (TestGetPinNetHandlerRefPinResolution) +""" + +import sys +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent / "python")) + +from commands.wire_connectivity import get_pin_net + +# --------------------------------------------------------------------------- +# Shared mock helpers (mirrors test_wire_connectivity.py) +# --------------------------------------------------------------------------- + + +def _make_point(x: float, y: float) -> MagicMock: + pt = MagicMock() + pt.value = [x, y] + return pt + + +def _make_wire(x1: float, y1: float, x2: float, y2: float) -> MagicMock: + wire = MagicMock() + wire.pts = MagicMock() + wire.pts.xy = [_make_point(x1, y1), _make_point(x2, y2)] + return wire + + +def _make_schematic(*wires: Any) -> MagicMock: + sch = MagicMock() + sch.wire = list(wires) + del sch.label + del sch.symbol + return sch + + +# --------------------------------------------------------------------------- +# TestGetPinNetSchema +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetPinNetSchema: + """Verify the get_pin_net tool schema is present and well-formed.""" + + def test_schema_registered(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + assert "get_pin_net" in TOOL_SCHEMAS + + def test_schema_required_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + required = TOOL_SCHEMAS["get_pin_net"]["inputSchema"]["required"] + assert required == ["schematicPath"] + + def test_schema_has_title_and_description(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + schema = TOOL_SCHEMAS["get_pin_net"] + assert schema.get("title") + assert schema.get("description") + + def test_schema_has_optional_fields(self) -> None: + from schemas.tool_schemas import TOOL_SCHEMAS + + props = TOOL_SCHEMAS["get_pin_net"]["inputSchema"]["properties"] + for field in ("reference", "pin", "x", "y"): + assert field in props, f"Expected '{field}' in schema properties" + + +# --------------------------------------------------------------------------- +# TestGetPinNetHandlerDispatch +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetPinNetHandlerDispatch: + """Verify the handler is wired into KiCadInterface.command_routes.""" + + def test_get_pin_net_in_routes(self) -> None: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + iface.board = None + iface.project_filename = None + iface.use_ipc = False + iface.ipc_backend = MagicMock() + iface.ipc_board_api = None + iface.footprint_library = MagicMock() + iface.project_commands = MagicMock() + iface.board_commands = MagicMock() + iface.component_commands = MagicMock() + iface.routing_commands = MagicMock() + KiCADInterface.__init__(iface) + + assert "get_pin_net" in iface.command_routes + assert callable(iface.command_routes["get_pin_net"]) + + +# --------------------------------------------------------------------------- +# TestGetPinNetHandlerParamValidation +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetPinNetHandlerParamValidation: + """Handler returns error responses for bad or missing parameters.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_pin_net + + def test_missing_schematic_path(self) -> None: + handler = self._make_handler() + result = handler({"x": 1.0, "y": 2.0}) + assert result["success"] is False + assert "schematicPath" in result["message"] or "Missing" in result["message"] + + def test_missing_both_modes(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch"}) + assert result["success"] is False + + def test_partial_ref_only(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "reference": "U1"}) + assert result["success"] is False + + def test_partial_pin_only(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "pin": "3"}) + assert result["success"] is False + + def test_partial_x_only(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": 1.0}) + assert result["success"] is False + + def test_partial_y_only(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "y": 1.0}) + assert result["success"] is False + + def test_non_numeric_coords(self) -> None: + handler = self._make_handler() + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "bad", "y": 2.0}) + assert result["success"] is False + assert "numeric" in result["message"].lower() or "x" in result["message"] + + +# --------------------------------------------------------------------------- +# TestGetPinNetCoreLogic +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetPinNetCoreLogic: + """Unit tests for the get_pin_net function.""" + + def test_no_wires_returns_empty_dict(self) -> None: + sch = MagicMock() + sch.wire = [] + del sch.label + del sch.symbol + result = get_pin_net(sch, "/tmp/test.kicad_sch", 1.0, 2.0) + assert result is not None + assert result["net"] is None + assert result["pins"] == [] + assert result["wires"] == [] + assert result["query_point"] == {"x": 1.0, "y": 2.0} + + def test_no_wire_at_point_returns_none(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + # Query a midpoint — not on a wire endpoint + result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.5, 0.0) + assert result is None + + def test_unnamed_net_no_labels(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result is not None + assert result["net"] is None + + def test_net_name_from_label(self) -> None: + """Wire with a net label at one endpoint should yield that label as net name.""" + wire = _make_wire(0.0, 0.0, 1.0, 0.0) + + label = MagicMock() + label.value = "SDA" + label.at = MagicMock() + label.at.value = [0.0, 0.0, 0] # label placed at wire start + + sch = MagicMock() + sch.wire = [wire] + sch.label = [label] + del sch.symbol + + result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result is not None + assert result["net"] == "SDA" + + def test_query_point_in_result(self) -> None: + sch = _make_schematic(_make_wire(5.0, 3.0, 6.0, 3.0)) + result = get_pin_net(sch, "/tmp/test.kicad_sch", 5.0, 3.0) + assert result is not None + assert result["query_point"] == {"x": 5.0, "y": 3.0} + + def test_result_has_pins_and_wires_keys(self) -> None: + sch = _make_schematic(_make_wire(0.0, 0.0, 1.0, 0.0)) + result = get_pin_net(sch, "/tmp/test.kicad_sch", 0.0, 0.0) + assert result is not None + assert "pins" in result + assert "wires" in result + + def test_wires_returned_in_mm(self) -> None: + sch = _make_schematic(_make_wire(2.0, 3.0, 4.0, 3.0)) + result = get_pin_net(sch, "/tmp/test.kicad_sch", 2.0, 3.0) + assert result is not None + assert len(result["wires"]) == 1 + w = result["wires"][0] + assert w["start"]["x"] == pytest.approx(2.0) + assert w["start"]["y"] == pytest.approx(3.0) + assert w["end"]["x"] == pytest.approx(4.0) + assert w["end"]["y"] == pytest.approx(3.0) + + +# --------------------------------------------------------------------------- +# TestGetPinNetHandlerRefPinResolution +# --------------------------------------------------------------------------- + + +@pytest.mark.unit +class TestGetPinNetHandlerRefPinResolution: + """Test the reference+pin → coordinate resolution path in the handler.""" + + def _make_handler(self) -> Any: + with patch("kicad_interface.USE_IPC_BACKEND", False): + from kicad_interface import KiCADInterface + + iface = KiCADInterface.__new__(KiCADInterface) + return iface._handle_get_pin_net + + def test_ref_pin_resolves_to_coordinates(self) -> None: + handler = self._make_handler() + mock_result = { + "net": "SDA", + "pins": [{"component": "U1", "pin": "3"}], + "wires": [], + "query_point": {"x": 10.5, "y": 15.2}, + } + with ( + patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=[10.5, 15.2], + ), + patch("kicad_interface.SchematicManager.load_schematic") as mock_load, + patch("commands.wire_connectivity.get_pin_net", return_value=mock_result) as mock_gpn, + ): + mock_sch = MagicMock() + mock_sch.wire = [_make_wire(10.5, 15.2, 11.5, 15.2)] + mock_load.return_value = mock_sch + + result = handler( + {"schematicPath": "/tmp/test.kicad_sch", "reference": "U1", "pin": "3"} + ) + + assert result["success"] is True + mock_gpn.assert_called_once() + call_args = mock_gpn.call_args + assert call_args[0][2] == pytest.approx(10.5) + assert call_args[0][3] == pytest.approx(15.2) + + def test_ref_pin_not_found(self) -> None: + handler = self._make_handler() + with patch( + "commands.pin_locator.PinLocator.get_pin_location", + return_value=None, + ): + result = handler( + {"schematicPath": "/tmp/test.kicad_sch", "reference": "U1", "pin": "99"} + ) + assert result["success"] is False + assert "U1" in result["message"] or "99" in result["message"] + + def test_coordinate_mode_passes_floats(self) -> None: + handler = self._make_handler() + mock_result = { + "net": None, + "pins": [], + "wires": [], + "query_point": {"x": 10.5, "y": 15.2}, + } + with ( + patch("kicad_interface.SchematicManager.load_schematic") as mock_load, + patch("commands.wire_connectivity.get_pin_net", return_value=mock_result) as mock_gpn, + ): + mock_sch = MagicMock() + mock_sch.wire = [_make_wire(10.5, 15.2, 11.5, 15.2)] + mock_load.return_value = mock_sch + + result = handler({"schematicPath": "/tmp/test.kicad_sch", "x": "10.5", "y": "15.2"}) + + assert result["success"] is True + call_args = mock_gpn.call_args + assert isinstance(call_args[0][2], float) + assert isinstance(call_args[0][3], float) + assert call_args[0][2] == pytest.approx(10.5) + assert call_args[0][3] == pytest.approx(15.2)