feat: add set_footprint_type tool for placement attribute control
Adds a `set_footprint_type` MCP tool to set a placed footprint's placement type (through_hole / smd / unspecified) plus the optional exclude_from_pos_files / exclude_from_bom / not_in_schematic flags. These attributes gate inclusion in pick-and-place (.pos) exports and were previously unreachable: edit_component only handled reference/value/footprint and get_component_properties did not report the placement type at all. - python/commands/component.py: SWIG (pcbnew) handler via Set/GetAttributes + the dedicated exclusion setters; extends get_component_properties to report a human-readable `type` plus the three exclusion flags. - python/kicad_interface.py: IPC (kipy) handler setting attributes.mounting_style and the exclusion fields over the proto API with begin_commit/update_items/push_commit so changes are live in the KiCAD UI; registered in the command map, the IPC handler map, and the realtime/IPC whitelist; SWIG fallback on proto error. - src/tools/component.ts: tool registration with zod schema. - tests/test_set_footprint_type.py: 26 unit tests over both backends plus the extended get_component_properties response. - pyproject.toml: add `fitz` (PyMuPDF) to mypy ignore_missing_imports overrides; pre-existing gap that fails mypy on any edit to files importing it (board/view.py, kicad_interface.py). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ module = [
|
|||||||
"cairosvg",
|
"cairosvg",
|
||||||
"sexpdata",
|
"sexpdata",
|
||||||
"skip",
|
"skip",
|
||||||
|
"fitz",
|
||||||
"kipy",
|
"kipy",
|
||||||
"kipy.*",
|
"kipy.*",
|
||||||
"schematic",
|
"schematic",
|
||||||
|
|||||||
@@ -390,6 +390,104 @@ class ComponentCommands:
|
|||||||
logger.error(f"Error editing component: {str(e)}")
|
logger.error(f"Error editing component: {str(e)}")
|
||||||
return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)}
|
return {"success": False, "message": "Failed to edit component", "errorDetails": str(e)}
|
||||||
|
|
||||||
|
def set_footprint_type(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Set the placement type and exclusion flags of an existing PCB footprint.
|
||||||
|
|
||||||
|
The placement type controls whether a footprint appears in pick-and-place
|
||||||
|
(.pos) output files. KiCAD recognises three types:
|
||||||
|
- ``through_hole`` — PTH component (through-hole)
|
||||||
|
- ``smd`` — SMD component (surface-mount)
|
||||||
|
- ``unspecified`` — neither bit set (default for manually-placed items)
|
||||||
|
|
||||||
|
Optional exclusion flags (omit to leave unchanged):
|
||||||
|
- ``exclude_from_pos_files`` — suppress this ref from .pos exports
|
||||||
|
- ``exclude_from_bom`` — suppress this ref from BoM exports
|
||||||
|
- ``not_in_schematic`` — marks footprint as board-only (no schematic
|
||||||
|
symbol); KiCAD stores this as FP_BOARD_ONLY
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not self.board:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "No board is loaded",
|
||||||
|
"errorDetails": "Load or create a board first",
|
||||||
|
}
|
||||||
|
|
||||||
|
reference = params.get("reference")
|
||||||
|
fp_type = params.get("type")
|
||||||
|
|
||||||
|
if not reference:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Missing reference",
|
||||||
|
"errorDetails": "reference parameter is required",
|
||||||
|
}
|
||||||
|
if fp_type not in ("smd", "through_hole", "unspecified"):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Invalid type",
|
||||||
|
"errorDetails": "type must be one of: smd, through_hole, unspecified",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find the footprint
|
||||||
|
module = self.board.FindFootprintByReference(reference)
|
||||||
|
if not module:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Component not found",
|
||||||
|
"errorDetails": f"Could not find component: {reference}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Placement type (mutually exclusive bits) ---
|
||||||
|
# Read current attributes, clear both type bits, then set the new one.
|
||||||
|
attrs = module.GetAttributes()
|
||||||
|
attrs &= ~(pcbnew.FP_THROUGH_HOLE | pcbnew.FP_SMD)
|
||||||
|
if fp_type == "through_hole":
|
||||||
|
attrs |= pcbnew.FP_THROUGH_HOLE
|
||||||
|
elif fp_type == "smd":
|
||||||
|
attrs |= pcbnew.FP_SMD
|
||||||
|
# "unspecified" leaves both bits clear
|
||||||
|
module.SetAttributes(attrs)
|
||||||
|
|
||||||
|
# --- Optional exclusion flags ---
|
||||||
|
# Use the dedicated setters so we don't have to manage bit masks manually.
|
||||||
|
if "exclude_from_pos_files" in params:
|
||||||
|
module.SetExcludedFromPosFiles(bool(params["exclude_from_pos_files"]))
|
||||||
|
if "exclude_from_bom" in params:
|
||||||
|
module.SetExcludedFromBOM(bool(params["exclude_from_bom"]))
|
||||||
|
if "not_in_schematic" in params:
|
||||||
|
# FP_BOARD_ONLY is the underlying bit for the "not in schematic" flag.
|
||||||
|
module.SetBoardOnly(bool(params["not_in_schematic"]))
|
||||||
|
|
||||||
|
# Read back the final state so the caller can verify.
|
||||||
|
final_attrs = module.GetAttributes()
|
||||||
|
if final_attrs & pcbnew.FP_THROUGH_HOLE:
|
||||||
|
resolved_type = "through_hole"
|
||||||
|
elif final_attrs & pcbnew.FP_SMD:
|
||||||
|
resolved_type = "smd"
|
||||||
|
else:
|
||||||
|
resolved_type = "unspecified"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Updated footprint type for {reference}",
|
||||||
|
"component": {
|
||||||
|
"reference": reference,
|
||||||
|
"type": resolved_type,
|
||||||
|
"exclude_from_pos_files": module.IsExcludedFromPosFiles(),
|
||||||
|
"exclude_from_bom": module.IsExcludedFromBOM(),
|
||||||
|
"not_in_schematic": module.IsBoardOnly(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error setting footprint type: {str(e)}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Failed to set footprint type",
|
||||||
|
"errorDetails": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
def get_component_properties(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Get detailed properties of a component"""
|
"""Get detailed properties of a component"""
|
||||||
try:
|
try:
|
||||||
@@ -454,6 +552,15 @@ class ComponentCommands:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass # Courtyard may not exist or API may differ
|
pass # Courtyard may not exist or API may differ
|
||||||
|
|
||||||
|
# Resolve placement type as a human-readable string
|
||||||
|
raw_attrs = module.GetAttributes()
|
||||||
|
if raw_attrs & pcbnew.FP_THROUGH_HOLE:
|
||||||
|
placement_type = "through_hole"
|
||||||
|
elif raw_attrs & pcbnew.FP_SMD:
|
||||||
|
placement_type = "smd"
|
||||||
|
else:
|
||||||
|
placement_type = "unspecified"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"component": {
|
"component": {
|
||||||
@@ -464,9 +571,10 @@ class ComponentCommands:
|
|||||||
"rotation": module.GetOrientation().AsDegrees(),
|
"rotation": module.GetOrientation().AsDegrees(),
|
||||||
"layer": self.board.GetLayerName(module.GetLayer()),
|
"layer": self.board.GetLayerName(module.GetLayer()),
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"smd": module.GetAttributes() & pcbnew.FP_SMD,
|
"type": placement_type,
|
||||||
"through_hole": module.GetAttributes() & pcbnew.FP_THROUGH_HOLE,
|
"exclude_from_pos_files": module.IsExcludedFromPosFiles(),
|
||||||
"board_only": module.GetAttributes() & pcbnew.FP_BOARD_ONLY,
|
"exclude_from_bom": module.IsExcludedFromBOM(),
|
||||||
|
"not_in_schematic": module.IsBoardOnly(),
|
||||||
},
|
},
|
||||||
"boundingBox": bbox_data,
|
"boundingBox": bbox_data,
|
||||||
"courtyard": courtyard_data,
|
"courtyard": courtyard_data,
|
||||||
|
|||||||
@@ -325,9 +325,9 @@ try:
|
|||||||
from commands.project import ProjectCommands
|
from commands.project import ProjectCommands
|
||||||
from commands.routing import RoutingCommands
|
from commands.routing import RoutingCommands
|
||||||
from commands.schematic import SchematicManager
|
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.schematic_batch import SchematicBatchCommands
|
||||||
|
from commands.schematic_field_layout import SchematicFieldLayoutCommands
|
||||||
|
from commands.schematic_hierarchy import SchematicHierarchyCommands
|
||||||
from commands.symbol_creator import SymbolCreator
|
from commands.symbol_creator import SymbolCreator
|
||||||
from commands.symbol_pins import SymbolPinCommands
|
from commands.symbol_pins import SymbolPinCommands
|
||||||
|
|
||||||
@@ -504,6 +504,7 @@ class KiCADInterface:
|
|||||||
"align_components": self.component_commands.align_components,
|
"align_components": self.component_commands.align_components,
|
||||||
"check_courtyard_overlaps": self.component_commands.check_courtyard_overlaps,
|
"check_courtyard_overlaps": self.component_commands.check_courtyard_overlaps,
|
||||||
"duplicate_component": self.component_commands.duplicate_component,
|
"duplicate_component": self.component_commands.duplicate_component,
|
||||||
|
"set_footprint_type": self.component_commands.set_footprint_type,
|
||||||
# Routing commands
|
# Routing commands
|
||||||
"add_net": self.routing_commands.add_net,
|
"add_net": self.routing_commands.add_net,
|
||||||
"route_trace": self.routing_commands.route_trace,
|
"route_trace": self.routing_commands.route_trace,
|
||||||
@@ -678,6 +679,7 @@ class KiCADInterface:
|
|||||||
"delete_component": "_ipc_delete_component",
|
"delete_component": "_ipc_delete_component",
|
||||||
"get_component_list": "_ipc_get_component_list",
|
"get_component_list": "_ipc_get_component_list",
|
||||||
"get_component_properties": "_ipc_get_component_properties",
|
"get_component_properties": "_ipc_get_component_properties",
|
||||||
|
"set_footprint_type": "_ipc_set_footprint_type",
|
||||||
# Save command
|
# Save command
|
||||||
"save_project": "_ipc_save_project",
|
"save_project": "_ipc_save_project",
|
||||||
}
|
}
|
||||||
@@ -930,6 +932,7 @@ class KiCADInterface:
|
|||||||
"sync_schematic_to_board",
|
"sync_schematic_to_board",
|
||||||
"connect_passthrough",
|
"connect_passthrough",
|
||||||
"connect_to_net",
|
"connect_to_net",
|
||||||
|
"set_footprint_type",
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -6267,6 +6270,97 @@ print("ok")
|
|||||||
logger.error(f"IPC get_component_properties error: {e}")
|
logger.error(f"IPC get_component_properties error: {e}")
|
||||||
return {"success": False, "message": str(e)}
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
def _ipc_set_footprint_type(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""IPC handler for set_footprint_type.
|
||||||
|
|
||||||
|
Sets the placement type (through_hole / smd / unspecified) and optional
|
||||||
|
exclusion flags on a footprint via the kipy proto API, so the change is
|
||||||
|
visible in the KiCAD UI without a manual reload.
|
||||||
|
|
||||||
|
Falls back to the SWIG path if the IPC footprint lookup fails, because
|
||||||
|
kipy's Footprint wrapper does not always expose a ``not_in_schematic``
|
||||||
|
setter depending on the installed kipy version.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
reference = params.get("reference", params.get("componentId", ""))
|
||||||
|
fp_type = params.get("type")
|
||||||
|
|
||||||
|
if fp_type not in ("smd", "through_hole", "unspecified"):
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "Invalid type",
|
||||||
|
"errorDetails": "type must be one of: smd, through_hole, unspecified",
|
||||||
|
}
|
||||||
|
|
||||||
|
board = self.ipc_board_api._get_board()
|
||||||
|
footprints = board.get_footprints()
|
||||||
|
|
||||||
|
target_fp = None
|
||||||
|
for fp in footprints:
|
||||||
|
if fp.reference_field and fp.reference_field.text.value == reference:
|
||||||
|
target_fp = fp
|
||||||
|
break
|
||||||
|
|
||||||
|
if not target_fp:
|
||||||
|
return {"success": False, "message": f"Component {reference} not found"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
from kipy.proto.board.board_types_pb2 import FootprintMountingStyle
|
||||||
|
|
||||||
|
style_map = {
|
||||||
|
"through_hole": FootprintMountingStyle.FMS_THROUGH_HOLE,
|
||||||
|
"smd": FootprintMountingStyle.FMS_SMD,
|
||||||
|
"unspecified": FootprintMountingStyle.FMS_UNSPECIFIED,
|
||||||
|
}
|
||||||
|
target_fp.proto.attributes.mounting_style = style_map[fp_type]
|
||||||
|
|
||||||
|
if "exclude_from_pos_files" in params:
|
||||||
|
target_fp.proto.attributes.exclude_from_position_files = bool(
|
||||||
|
params["exclude_from_pos_files"]
|
||||||
|
)
|
||||||
|
if "exclude_from_bom" in params:
|
||||||
|
target_fp.proto.attributes.exclude_from_bill_of_materials = bool(
|
||||||
|
params["exclude_from_bom"]
|
||||||
|
)
|
||||||
|
if "not_in_schematic" in params:
|
||||||
|
target_fp.proto.attributes.not_in_schematic = bool(params["not_in_schematic"])
|
||||||
|
|
||||||
|
commit = board.begin_commit()
|
||||||
|
board.update_items([target_fp])
|
||||||
|
board.push_commit(commit, f"Set footprint type for {reference}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Updated footprint type for {reference} (visible in KiCAD UI)",
|
||||||
|
"component": {
|
||||||
|
"reference": reference,
|
||||||
|
"type": fp_type,
|
||||||
|
"exclude_from_pos_files": target_fp.proto.attributes.exclude_from_position_files, # noqa: E501
|
||||||
|
"exclude_from_bom": target_fp.proto.attributes.exclude_from_bill_of_materials,
|
||||||
|
"not_in_schematic": target_fp.proto.attributes.not_in_schematic,
|
||||||
|
},
|
||||||
|
"_backend": "ipc",
|
||||||
|
"_realtime": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as ipc_err:
|
||||||
|
# IPC proto manipulation failed; fall back to SWIG for the write.
|
||||||
|
logger.warning(
|
||||||
|
f"_ipc_set_footprint_type: IPC proto update failed ({ipc_err}), "
|
||||||
|
"falling back to SWIG"
|
||||||
|
)
|
||||||
|
if self.board:
|
||||||
|
result = self.component_commands.set_footprint_type(params)
|
||||||
|
if isinstance(result, dict):
|
||||||
|
result["_backend"] = "swig"
|
||||||
|
result["_realtime"] = False
|
||||||
|
return result
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"IPC set_footprint_type error: {e}")
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Legacy IPC command handlers (explicit ipc_* commands)
|
# Legacy IPC command handlers (explicit ipc_* commands)
|
||||||
|
|
||||||
|
|||||||
@@ -202,6 +202,59 @@ export function registerComponentTools(server: McpServer, callKicadScript: Comma
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ------------------------------------------------------
|
||||||
|
// Set Footprint Type Tool
|
||||||
|
// ------------------------------------------------------
|
||||||
|
server.tool(
|
||||||
|
"set_footprint_type",
|
||||||
|
"Set the placement type (through_hole / smd / unspecified) and optional exclusion flags on a placed PCB footprint. The placement type controls whether the footprint is included in pick-and-place (.pos) output files. Use exclude_from_pos_files to suppress a footprint from .pos exports without changing its type.",
|
||||||
|
{
|
||||||
|
reference: z.string().describe("Reference designator of the footprint (e.g. 'R1', 'U3')"),
|
||||||
|
type: z
|
||||||
|
.enum(["smd", "through_hole", "unspecified"])
|
||||||
|
.describe(
|
||||||
|
"Placement type: 'smd' for surface-mount, 'through_hole' for PTH components, 'unspecified' to clear both bits (e.g. for board-only or mechanically-placed items)",
|
||||||
|
),
|
||||||
|
exclude_from_pos_files: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"When true, suppress this footprint from pick-and-place (.pos) exports. Omit to leave the current setting unchanged.",
|
||||||
|
),
|
||||||
|
exclude_from_bom: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"When true, suppress this footprint from BoM exports. Omit to leave the current setting unchanged.",
|
||||||
|
),
|
||||||
|
not_in_schematic: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
"When true, marks the footprint as board-only (no corresponding schematic symbol). Omit to leave the current setting unchanged.",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
async ({ reference, type, exclude_from_pos_files, exclude_from_bom, not_in_schematic }) => {
|
||||||
|
logger.debug(`Setting footprint type for: ${reference} -> ${type}`);
|
||||||
|
const result = await callKicadScript("set_footprint_type", {
|
||||||
|
reference,
|
||||||
|
type,
|
||||||
|
exclude_from_pos_files,
|
||||||
|
exclude_from_bom,
|
||||||
|
not_in_schematic,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(result),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
// Find Component Tool
|
// Find Component Tool
|
||||||
// ------------------------------------------------------
|
// ------------------------------------------------------
|
||||||
|
|||||||
439
tests/test_set_footprint_type.py
Normal file
439
tests/test_set_footprint_type.py
Normal file
@@ -0,0 +1,439 @@
|
|||||||
|
"""Unit tests for the ``set_footprint_type`` command.
|
||||||
|
|
||||||
|
Tests exercise both the SWIG-path handler (``ComponentCommands.set_footprint_type``)
|
||||||
|
and the IPC-path handler (``KiCADInterface._ipc_set_footprint_type``).
|
||||||
|
|
||||||
|
conftest.py pre-installs a MagicMock for ``pcbnew`` in sys.modules; these tests
|
||||||
|
configure the relevant attribute constants on that mock so that component.py can
|
||||||
|
run without a real KiCAD install.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, call, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.unit
|
||||||
|
|
||||||
|
# The conftest.py-installed pcbnew MagicMock is already in sys.modules.
|
||||||
|
# We set the FP_* integer constants we need once at module level so they are
|
||||||
|
# stable for the life of the test session (conftest doesn't reset them).
|
||||||
|
import pcbnew as _pcbnew_stub # noqa: E402 — must come after sys.path insert
|
||||||
|
|
||||||
|
_pcbnew_stub.FP_THROUGH_HOLE = 1
|
||||||
|
_pcbnew_stub.FP_SMD = 2
|
||||||
|
_pcbnew_stub.FP_EXCLUDE_FROM_POS_FILES = 4
|
||||||
|
_pcbnew_stub.FP_EXCLUDE_FROM_BOM = 8
|
||||||
|
_pcbnew_stub.FP_BOARD_ONLY = 16
|
||||||
|
_pcbnew_stub.FP_DNP = 32
|
||||||
|
_pcbnew_stub.F_CrtYd = 0
|
||||||
|
_pcbnew_stub.B_CrtYd = 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_footprint_mock(attrs=0, excluded_pos=False, excluded_bom=False, board_only=False):
|
||||||
|
"""Return a MagicMock mimicking a pcbnew.FOOTPRINT for attribute editing."""
|
||||||
|
fp = MagicMock()
|
||||||
|
fp.GetAttributes.return_value = attrs
|
||||||
|
fp.IsExcludedFromPosFiles.return_value = excluded_pos
|
||||||
|
fp.IsExcludedFromBOM.return_value = excluded_bom
|
||||||
|
fp.IsBoardOnly.return_value = board_only
|
||||||
|
return fp
|
||||||
|
|
||||||
|
|
||||||
|
def _make_component_commands(fp_mock):
|
||||||
|
"""Return a ComponentCommands wired to a board holding *fp_mock*."""
|
||||||
|
from commands.component import ComponentCommands
|
||||||
|
|
||||||
|
board = MagicMock()
|
||||||
|
board.FindFootprintByReference.return_value = fp_mock
|
||||||
|
cmd = ComponentCommands.__new__(ComponentCommands)
|
||||||
|
cmd.board = board
|
||||||
|
cmd.library_manager = MagicMock()
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# SWIG path – ComponentCommands.set_footprint_type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetFootprintTypeSwig:
|
||||||
|
def test_set_through_hole_clears_smd_bit(self):
|
||||||
|
"""Setting through_hole must set FP_THROUGH_HOLE and clear FP_SMD."""
|
||||||
|
fp = _make_footprint_mock(attrs=_pcbnew_stub.FP_SMD)
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "R1", "type": "through_hole"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
fp.SetAttributes.assert_called_once()
|
||||||
|
written = fp.SetAttributes.call_args[0][0]
|
||||||
|
assert written & _pcbnew_stub.FP_THROUGH_HOLE
|
||||||
|
assert not (written & _pcbnew_stub.FP_SMD)
|
||||||
|
|
||||||
|
def test_set_smd_clears_through_hole_bit(self):
|
||||||
|
fp = _make_footprint_mock(attrs=_pcbnew_stub.FP_THROUGH_HOLE)
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "U1", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
written = fp.SetAttributes.call_args[0][0]
|
||||||
|
assert written & _pcbnew_stub.FP_SMD
|
||||||
|
assert not (written & _pcbnew_stub.FP_THROUGH_HOLE)
|
||||||
|
|
||||||
|
def test_set_unspecified_clears_both_bits(self):
|
||||||
|
fp = _make_footprint_mock(attrs=_pcbnew_stub.FP_THROUGH_HOLE | _pcbnew_stub.FP_SMD)
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "TP1", "type": "unspecified"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
written = fp.SetAttributes.call_args[0][0]
|
||||||
|
assert not (written & _pcbnew_stub.FP_SMD)
|
||||||
|
assert not (written & _pcbnew_stub.FP_THROUGH_HOLE)
|
||||||
|
|
||||||
|
def test_exclude_from_pos_files_set(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
cmd.set_footprint_type({"reference": "R2", "type": "smd", "exclude_from_pos_files": True})
|
||||||
|
|
||||||
|
fp.SetExcludedFromPosFiles.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_exclude_from_bom_set(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
cmd.set_footprint_type({"reference": "R3", "type": "smd", "exclude_from_bom": True})
|
||||||
|
|
||||||
|
fp.SetExcludedFromBOM.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_not_in_schematic_set(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
cmd.set_footprint_type(
|
||||||
|
{"reference": "MH1", "type": "unspecified", "not_in_schematic": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
fp.SetBoardOnly.assert_called_once_with(True)
|
||||||
|
|
||||||
|
def test_omitted_optional_flags_not_called(self):
|
||||||
|
"""When optional flags are omitted, their setters must NOT be called."""
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
cmd.set_footprint_type({"reference": "C1", "type": "smd"})
|
||||||
|
|
||||||
|
fp.SetExcludedFromPosFiles.assert_not_called()
|
||||||
|
fp.SetExcludedFromBOM.assert_not_called()
|
||||||
|
fp.SetBoardOnly.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_reference_returns_error(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "reference" in result["errorDetails"].lower()
|
||||||
|
|
||||||
|
def test_invalid_type_returns_error(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "R1", "type": "bad_type"})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_component_not_found_returns_error(self):
|
||||||
|
cmd = _make_component_commands(None)
|
||||||
|
cmd.board.FindFootprintByReference.return_value = None
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "ZZZ", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "not found" in result["message"].lower()
|
||||||
|
|
||||||
|
def test_no_board_returns_error(self):
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
cmd.board = None
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "R1", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "no board" in result["message"].lower()
|
||||||
|
|
||||||
|
def test_response_includes_readback_type(self):
|
||||||
|
"""Response component.type must reflect the resolved type after the write."""
|
||||||
|
# First call (before SetAttributes): original value; second call (readback): FP_SMD
|
||||||
|
fp = _make_footprint_mock(attrs=0)
|
||||||
|
fp.GetAttributes.side_effect = [0, _pcbnew_stub.FP_SMD]
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type({"reference": "R1", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["component"]["type"] == "smd"
|
||||||
|
|
||||||
|
def test_response_includes_exclusion_flags(self):
|
||||||
|
"""Response must include exclude_from_pos_files, exclude_from_bom, not_in_schematic."""
|
||||||
|
fp = _make_footprint_mock()
|
||||||
|
fp.GetAttributes.side_effect = [0, 0]
|
||||||
|
fp.IsExcludedFromPosFiles.return_value = True
|
||||||
|
fp.IsExcludedFromBOM.return_value = False
|
||||||
|
fp.IsBoardOnly.return_value = False
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
|
||||||
|
result = cmd.set_footprint_type(
|
||||||
|
{"reference": "R1", "type": "smd", "exclude_from_pos_files": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
comp = result["component"]
|
||||||
|
assert comp["exclude_from_pos_files"] is True
|
||||||
|
assert comp["exclude_from_bom"] is False
|
||||||
|
assert comp["not_in_schematic"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_component_properties – verify extended attributes in response
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetComponentPropertiesAttributes:
|
||||||
|
"""Verify that get_component_properties now returns human-readable type + flags."""
|
||||||
|
|
||||||
|
def _setup(self, attrs, excluded_pos=False, excluded_bom=False, board_only=False):
|
||||||
|
fp = _make_footprint_mock(
|
||||||
|
attrs=attrs,
|
||||||
|
excluded_pos=excluded_pos,
|
||||||
|
excluded_bom=excluded_bom,
|
||||||
|
board_only=board_only,
|
||||||
|
)
|
||||||
|
# get_component_properties needs GetPosition, GetBoundingBox etc.
|
||||||
|
fp.GetPosition.return_value = MagicMock(x=0, y=0)
|
||||||
|
fp.GetOrientation.return_value = MagicMock()
|
||||||
|
fp.GetOrientation.return_value.AsDegrees.return_value = 0.0
|
||||||
|
fp.GetCourtyard.return_value = MagicMock()
|
||||||
|
fp.GetCourtyard.return_value.OutlineCount.return_value = 0
|
||||||
|
fp.GetBoundingBox.return_value = MagicMock(
|
||||||
|
GetLeft=lambda: 0,
|
||||||
|
GetTop=lambda: 0,
|
||||||
|
GetRight=lambda: 0,
|
||||||
|
GetBottom=lambda: 0,
|
||||||
|
)
|
||||||
|
cmd = _make_component_commands(fp)
|
||||||
|
# board.GetLayerName must return a string
|
||||||
|
cmd.board.GetLayerName.return_value = "F.Cu"
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
def test_smd_type_string(self):
|
||||||
|
cmd = self._setup(_pcbnew_stub.FP_SMD)
|
||||||
|
result = cmd.get_component_properties({"reference": "R1"})
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["component"]["attributes"]["type"] == "smd"
|
||||||
|
|
||||||
|
def test_through_hole_type_string(self):
|
||||||
|
cmd = self._setup(_pcbnew_stub.FP_THROUGH_HOLE)
|
||||||
|
result = cmd.get_component_properties({"reference": "R1"})
|
||||||
|
assert result["component"]["attributes"]["type"] == "through_hole"
|
||||||
|
|
||||||
|
def test_unspecified_type_string(self):
|
||||||
|
cmd = self._setup(0)
|
||||||
|
result = cmd.get_component_properties({"reference": "R1"})
|
||||||
|
assert result["component"]["attributes"]["type"] == "unspecified"
|
||||||
|
|
||||||
|
def test_exclusion_flags_reported(self):
|
||||||
|
cmd = self._setup(
|
||||||
|
_pcbnew_stub.FP_SMD,
|
||||||
|
excluded_pos=True,
|
||||||
|
excluded_bom=True,
|
||||||
|
board_only=False,
|
||||||
|
)
|
||||||
|
result = cmd.get_component_properties({"reference": "R1"})
|
||||||
|
attrs = result["component"]["attributes"]
|
||||||
|
assert attrs["exclude_from_pos_files"] is True
|
||||||
|
assert attrs["exclude_from_bom"] is True
|
||||||
|
assert attrs["not_in_schematic"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# IPC path – KiCADInterface._ipc_set_footprint_type
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _make_ipc_iface():
|
||||||
|
"""Construct a minimal KiCADInterface with a mocked IPC board API."""
|
||||||
|
with patch("kicad_interface.USE_IPC_BACKEND", True):
|
||||||
|
from kicad_interface import KiCADInterface
|
||||||
|
|
||||||
|
iface = KiCADInterface.__new__(KiCADInterface)
|
||||||
|
iface.use_ipc = True
|
||||||
|
iface.board = None
|
||||||
|
iface.ipc_board_api = MagicMock()
|
||||||
|
iface.component_commands = MagicMock()
|
||||||
|
return iface
|
||||||
|
|
||||||
|
|
||||||
|
def _make_kipy_fp_mock(reference: str):
|
||||||
|
"""Return a MagicMock that looks enough like a kipy Footprint proto wrapper."""
|
||||||
|
fp = MagicMock()
|
||||||
|
fp.reference_field.text.value = reference
|
||||||
|
fp.proto.attributes.mounting_style = 0
|
||||||
|
fp.proto.attributes.exclude_from_position_files = False
|
||||||
|
fp.proto.attributes.exclude_from_bill_of_materials = False
|
||||||
|
fp.proto.attributes.not_in_schematic = False
|
||||||
|
return fp
|
||||||
|
|
||||||
|
|
||||||
|
def _fms_stub():
|
||||||
|
return types.SimpleNamespace(FMS_THROUGH_HOLE=1, FMS_SMD=2, FMS_UNSPECIFIED=3)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetFootprintTypeIpc:
|
||||||
|
def test_ipc_sets_mounting_style_smd(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("U1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "U1", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert target_fp.proto.attributes.mounting_style == fms.FMS_SMD
|
||||||
|
board_mock.update_items.assert_called_once_with([target_fp])
|
||||||
|
|
||||||
|
def test_ipc_sets_mounting_style_through_hole(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("J1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "J1", "type": "through_hole"})
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert target_fp.proto.attributes.mounting_style == fms.FMS_THROUGH_HOLE
|
||||||
|
|
||||||
|
def test_ipc_sets_exclude_from_pos_when_provided(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("R1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type(
|
||||||
|
{"reference": "R1", "type": "through_hole", "exclude_from_pos_files": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert target_fp.proto.attributes.exclude_from_position_files is True
|
||||||
|
|
||||||
|
def test_ipc_sets_exclude_from_bom(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("R1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type(
|
||||||
|
{"reference": "R1", "type": "smd", "exclude_from_bom": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert target_fp.proto.attributes.exclude_from_bill_of_materials is True
|
||||||
|
|
||||||
|
def test_ipc_not_in_schematic_written(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("MH1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type(
|
||||||
|
{"reference": "MH1", "type": "unspecified", "not_in_schematic": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
assert target_fp.proto.attributes.not_in_schematic is True
|
||||||
|
|
||||||
|
def test_ipc_component_not_found_returns_error(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = []
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "ZZZ", "type": "smd"})
|
||||||
|
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "not found" in result["message"].lower()
|
||||||
|
|
||||||
|
def test_ipc_invalid_type_returns_error(self):
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "R1", "type": "bad_type"})
|
||||||
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_ipc_fallback_to_swig_on_proto_error(self):
|
||||||
|
"""When kipy proto import fails, the handler must fall back to the SWIG path."""
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("R1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
iface.board = MagicMock() # SWIG board available
|
||||||
|
|
||||||
|
swig_result = {"success": True, "component": {"reference": "R1", "type": "smd"}}
|
||||||
|
iface.component_commands.set_footprint_type.return_value = swig_result
|
||||||
|
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": None}):
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "R1", "type": "smd"})
|
||||||
|
|
||||||
|
iface.component_commands.set_footprint_type.assert_called_once()
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result.get("_backend") == "swig"
|
||||||
|
|
||||||
|
def test_ipc_response_includes_backend_marker(self):
|
||||||
|
"""Successful IPC response must carry _backend: 'ipc'."""
|
||||||
|
iface = _make_ipc_iface()
|
||||||
|
target_fp = _make_kipy_fp_mock("C1")
|
||||||
|
board_mock = iface.ipc_board_api._get_board.return_value
|
||||||
|
board_mock.get_footprints.return_value = [target_fp]
|
||||||
|
|
||||||
|
fms = _fms_stub()
|
||||||
|
proto_mod = types.ModuleType("kipy.proto.board.board_types_pb2")
|
||||||
|
proto_mod.FootprintMountingStyle = fms
|
||||||
|
with patch.dict(sys.modules, {"kipy.proto.board.board_types_pb2": proto_mod}):
|
||||||
|
result = iface._ipc_set_footprint_type({"reference": "C1", "type": "smd"})
|
||||||
|
|
||||||
|
assert result.get("_backend") == "ipc"
|
||||||
|
assert result.get("_realtime") is True
|
||||||
Reference in New Issue
Block a user