feat: add get_pin_net tool for direct net/pin queries

Answers "what net is pin X of component Y on?" without requiring
callers to triangulate from list_schematic_nets or know a wire
coordinate first.

Accepts either {reference, pin} (resolved via PinLocator) or {x, y}
coordinate. Returns net label name (or null for unnamed nets), all
connected pins, wire segments, and the resolved query point.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eugene Mikhantyev
2026-04-12 15:19:14 +01:00
parent 9387683368
commit 5f3c20d308
5 changed files with 543 additions and 0 deletions

View File

@@ -273,3 +273,61 @@ def get_wire_connections(
pins = _find_pins_on_net(net_points, schematic_path, schematic) pins = _find_pins_on_net(net_points, schematic_path, schematic)
return {"pins": pins, "wires": wires_out} 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}}

View File

@@ -382,6 +382,7 @@ class KiCADInterface:
"get_schematic_pin_locations": self._handle_get_schematic_pin_locations, "get_schematic_pin_locations": self._handle_get_schematic_pin_locations,
"get_net_connections": self._handle_get_net_connections, "get_net_connections": self._handle_get_net_connections,
"get_wire_connections": self._handle_get_wire_connections, "get_wire_connections": self._handle_get_wire_connections,
"get_pin_net": self._handle_get_pin_net,
"run_erc": self._handle_run_erc, "run_erc": self._handle_run_erc,
"generate_netlist": self._handle_generate_netlist, "generate_netlist": self._handle_generate_netlist,
"sync_schematic_to_board": self._handle_sync_schematic_to_board, "sync_schematic_to_board": self._handle_sync_schematic_to_board,
@@ -2503,6 +2504,70 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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]: def _handle_run_erc(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Run Electrical Rules Check on a schematic via kicad-cli""" """Run Electrical Rules Check on a schematic via kicad-cli"""
logger.info("Running ERC on schematic") logger.info("Running ERC on schematic")

View File

@@ -1523,6 +1523,44 @@ SCHEMATIC_TOOLS = [
"required": ["schematicPath", "x", "y"], "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", "name": "get_schematic_pin_locations",
"title": "Get Schematic Pin Locations", "title": "Get Schematic Pin Locations",

View File

@@ -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"}`,
},
],
};
}
},
);
} }

325
tests/test_get_pin_net.py Normal file
View File

@@ -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)