feat: support arbitrary custom properties on schematic components
Promotes BOM / sourcing fields (MPN, Manufacturer, DigiKey_PN, LCSC,
JLCPCB_PN, Voltage, Tolerance, Dielectric, ...) to first-class citizens
on placed schematic symbols.
New MCP tools:
- set_schematic_component_property: add or update one custom property
on a component (convenience wrapper around edit_schematic_component).
- remove_schematic_component_property: delete one custom property.
The four built-in fields (Reference, Value, Footprint, Datasheet) are
protected and rejected.
edit_schematic_component enhancements:
- New `properties` parameter: map of property name to either a string
value or a full spec object { value, x?, y?, angle?, hide?, fontSize? }.
Adds the property when missing, otherwise updates the existing field
(and optionally its label position / visibility). Lets a single tool
call attach an entire BOM payload to a component.
- New `removeProperties` parameter: list of custom property names to
delete in the same call.
- Property values are now backslash-escaped so descriptions containing
a double-quote or a backslash no longer corrupt the .kicad_sch file.
- New properties default to (hide yes) so they appear in BOM exports
without cluttering the schematic canvas.
get_schematic_component description clarified to highlight that it
already returns every field on the symbol, including custom ones.
New MCP prompt component_sourcing_properties guides agents through the
conventional property names recognised by downstream BOM tooling and
the recommended call sequence.
Implementation (python/kicad_interface.py):
- _PROTECTED_PROPERTY_FIELDS frozenset
- _escape_sexpr_string / _find_matching_paren static helpers
- _set_property_in_block / _set_hide_on_property /
_remove_property_from_block surgical text-level edits that preserve
formatting and the property's UUID
- _handle_edit_schematic_component rewritten to orchestrate
add/update/remove and return a per-property summary
- New handlers _handle_set_schematic_component_property and
_handle_remove_schematic_component_property registered in the
command dispatch table
Tests (tests/test_schematic_component_properties.py):
32 tests covering escape helper, paren matcher, add/update/remove
(single + batched), full spec dicts, default position, default
(hide yes), special-character escaping, UUID preservation, protected
built-in field rejection, no-op removal, both new convenience tools,
and input validation. All 590 tests in the project still pass.
Docs: README, SCHEMATIC_TOOLS_REFERENCE, TOOL_INVENTORY, CHANGELOG.
This commit is contained in:
@@ -13,7 +13,7 @@ import os
|
||||
import sys
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
|
||||
|
||||
@@ -373,6 +373,8 @@ 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,
|
||||
"set_schematic_component_property": self._handle_set_schematic_component_property,
|
||||
"remove_schematic_component_property": self._handle_remove_schematic_component_property,
|
||||
"get_schematic_component": self._handle_get_schematic_component,
|
||||
"add_schematic_wire": self._handle_add_schematic_wire,
|
||||
"add_schematic_net_label": self._handle_add_schematic_net_label,
|
||||
@@ -856,9 +858,215 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
# Built-in property names that have dedicated parameters and cannot be removed
|
||||
# via the generic removeProperties path. They are also written by KiCad on every
|
||||
# save, so deleting them produces an invalid schematic.
|
||||
_PROTECTED_PROPERTY_FIELDS = frozenset({"Reference", "Value", "Footprint", "Datasheet"})
|
||||
|
||||
@staticmethod
|
||||
def _escape_sexpr_string(value: str) -> str:
|
||||
"""Escape a string for safe insertion into an S-expression double-quoted token."""
|
||||
return value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
|
||||
@staticmethod
|
||||
def _find_matching_paren(s: str, start: int) -> int:
|
||||
"""Return the index of the closing paren matching the opening paren at `start`.
|
||||
|
||||
Returns -1 if no match is found. Does not understand string literals — that's
|
||||
fine for KiCAD .kicad_sch files because property values cannot contain a
|
||||
bare `(` or `)` character (they would be backslash-escaped).
|
||||
"""
|
||||
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
|
||||
|
||||
def _set_property_in_block(
|
||||
self,
|
||||
block: str,
|
||||
name: str,
|
||||
spec: Dict[str, Any],
|
||||
default_position: Tuple[float, float],
|
||||
) -> Tuple[str, str]:
|
||||
"""Add or update a property within a placed-symbol block.
|
||||
|
||||
Args:
|
||||
block: The full text of the (symbol ...) block.
|
||||
name: Property name (e.g. "MPN", "Manufacturer").
|
||||
spec: Dict that may contain keys: value, x, y, angle, hide, fontSize.
|
||||
default_position: (x, y) of the parent symbol — used as the default
|
||||
location for newly-created properties so the field is anchored
|
||||
near the component, not at (0, 0).
|
||||
|
||||
Returns:
|
||||
Tuple of (new_block_text, action_taken) where action is "added" or "updated".
|
||||
"""
|
||||
import re
|
||||
|
||||
new_value = spec.get("value")
|
||||
new_x = spec.get("x")
|
||||
new_y = spec.get("y")
|
||||
new_angle = spec.get("angle")
|
||||
new_hide = spec.get("hide")
|
||||
font_size = spec.get("fontSize", 1.27)
|
||||
|
||||
existing_match = re.search(
|
||||
r'\(property\s+"' + re.escape(name) + r'"\s+"',
|
||||
block,
|
||||
)
|
||||
|
||||
if existing_match:
|
||||
# Property exists — patch value / position / hide in place
|
||||
if new_value is not None:
|
||||
escaped = self._escape_sexpr_string(str(new_value))
|
||||
block = re.sub(
|
||||
r'(\(property\s+"' + re.escape(name) + r'"\s+)"[^"]*"',
|
||||
rf'\1"{escaped}"',
|
||||
block,
|
||||
count=1,
|
||||
)
|
||||
|
||||
if new_x is not None or new_y is not None or new_angle is not None:
|
||||
pos_match = re.search(
|
||||
r'(\(property\s+"'
|
||||
+ re.escape(name)
|
||||
+ r'"\s+"[^"]*"\s+\(at\s+)([\d\.\-]+)\s+([\d\.\-]+)\s+([\d\.\-]+)(\s*\))',
|
||||
block,
|
||||
)
|
||||
if pos_match:
|
||||
cx = new_x if new_x is not None else float(pos_match.group(2))
|
||||
cy = new_y if new_y is not None else float(pos_match.group(3))
|
||||
ca = new_angle if new_angle is not None else float(pos_match.group(4))
|
||||
block = (
|
||||
block[: pos_match.start()]
|
||||
+ pos_match.group(1)
|
||||
+ f"{cx} {cy} {ca}"
|
||||
+ pos_match.group(5)
|
||||
+ block[pos_match.end() :]
|
||||
)
|
||||
|
||||
if new_hide is not None:
|
||||
block = self._set_hide_on_property(block, name, bool(new_hide))
|
||||
|
||||
return block, "updated"
|
||||
|
||||
# Property does not exist — append a new one after the last existing property
|
||||
if new_value is None:
|
||||
# Adding a brand-new property requires at least a value
|
||||
raise ValueError(
|
||||
f"Property '{name}' does not exist on this component yet — supply a value to create it"
|
||||
)
|
||||
|
||||
cx = new_x if new_x is not None else default_position[0]
|
||||
cy = new_y if new_y is not None else default_position[1]
|
||||
ca = new_angle if new_angle is not None else 0
|
||||
# New properties default to hidden (BOM/sourcing data normally has no
|
||||
# visible footprint on the schematic canvas).
|
||||
hide_str = "(hide yes)" if (new_hide is None or new_hide) else "(hide no)"
|
||||
escaped = self._escape_sexpr_string(str(new_value))
|
||||
escaped_name = self._escape_sexpr_string(str(name))
|
||||
|
||||
new_prop = (
|
||||
f' (property "{escaped_name}" "{escaped}" (at {cx} {cy} {ca})\n'
|
||||
f" (effects (font (size {font_size} {font_size})) {hide_str})\n"
|
||||
f" )"
|
||||
)
|
||||
|
||||
# Find the last existing property block and insert immediately after it.
|
||||
last_prop_end = -1
|
||||
for m in re.finditer(r'\(property\s+"', block):
|
||||
end = self._find_matching_paren(block, m.start())
|
||||
if end > last_prop_end:
|
||||
last_prop_end = end
|
||||
|
||||
if last_prop_end < 0:
|
||||
# No properties at all — insert just before the closing paren of the symbol
|
||||
block_close = block.rfind(")")
|
||||
if block_close < 0:
|
||||
raise ValueError("Malformed symbol block: no closing paren")
|
||||
block = block[:block_close] + "\n" + new_prop + "\n " + block[block_close:]
|
||||
else:
|
||||
block = block[: last_prop_end + 1] + "\n" + new_prop + block[last_prop_end + 1 :]
|
||||
|
||||
return block, "added"
|
||||
|
||||
def _set_hide_on_property(self, block: str, name: str, hide: bool) -> str:
|
||||
"""Set the (hide yes|no) flag on a named property's effects clause.
|
||||
|
||||
Handles three pre-existing forms:
|
||||
(effects (font (size 1.27 1.27))) — no hide flag
|
||||
(effects (font (size 1.27 1.27)) hide) — legacy bare token
|
||||
(effects (font (size 1.27 1.27)) (hide yes|no)) — KiCad 9 form
|
||||
"""
|
||||
import re
|
||||
|
||||
prop_match = re.search(
|
||||
r'\(property\s+"' + re.escape(name) + r'"',
|
||||
block,
|
||||
)
|
||||
if not prop_match:
|
||||
return block
|
||||
prop_start = prop_match.start()
|
||||
prop_end = self._find_matching_paren(block, prop_start)
|
||||
if prop_end < 0:
|
||||
return block
|
||||
|
||||
# Locate the (effects ...) clause inside the property
|
||||
prop_segment = block[prop_start : prop_end + 1]
|
||||
eff_match = re.search(r"\(effects\b", prop_segment)
|
||||
if not eff_match:
|
||||
return block
|
||||
eff_start = prop_start + eff_match.start()
|
||||
eff_end = self._find_matching_paren(block, eff_start)
|
||||
if eff_end < 0:
|
||||
return block
|
||||
|
||||
eff_inner = block[eff_start + 1 : eff_end] # 'effects (font ...) ...'
|
||||
eff_inner = re.sub(r"\s*\(hide\s+(yes|no)\)", "", eff_inner)
|
||||
eff_inner = re.sub(r"\s+hide\b(?!\s+(yes|no))", "", eff_inner)
|
||||
eff_inner = eff_inner.rstrip() + f' (hide {"yes" if hide else "no"})'
|
||||
|
||||
new_effects = "(" + eff_inner + ")"
|
||||
return block[:eff_start] + new_effects + block[eff_end + 1 :]
|
||||
|
||||
def _remove_property_from_block(self, block: str, name: str) -> Tuple[str, bool]:
|
||||
"""Remove a property from the symbol block. Returns (new_block, removed_bool)."""
|
||||
import re
|
||||
|
||||
m = re.search(r'\(property\s+"' + re.escape(name) + r'"\s+"', block)
|
||||
if not m:
|
||||
return block, False
|
||||
start = m.start()
|
||||
end = self._find_matching_paren(block, start)
|
||||
if end < 0:
|
||||
return block, False
|
||||
|
||||
# Trim surrounding whitespace (leading newline + indent) so the resulting
|
||||
# file does not develop blank lines after every removal.
|
||||
trim_start = start
|
||||
while trim_start > 0 and block[trim_start - 1] in (" ", "\t"):
|
||||
trim_start -= 1
|
||||
if trim_start > 0 and block[trim_start - 1] == "\n":
|
||||
trim_start -= 1
|
||||
return block[:trim_start] + block[end + 1 :], True
|
||||
|
||||
def _handle_edit_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Update properties of a placed symbol in a schematic (footprint, value, reference).
|
||||
Uses text-based in-place editing – preserves position, UUID and all other fields.
|
||||
"""Update properties of a placed symbol in a schematic.
|
||||
|
||||
Supports updating the standard fields (footprint / value / reference rename),
|
||||
repositioning field labels, and managing **arbitrary custom properties**
|
||||
(MPN, Manufacturer, Distributor part numbers, Voltage, Dielectric, Tolerance,
|
||||
LCSC, etc.) used by BOM/CPL exporters and JLCPCB / Digi-Key sourcing.
|
||||
|
||||
Uses text-based in-place editing — preserves position, UUID, and all
|
||||
unrelated fields.
|
||||
"""
|
||||
logger.info("Editing schematic component")
|
||||
try:
|
||||
@@ -870,9 +1078,12 @@ 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}}
|
||||
# dict: {"Reference": {"x": 1, "y": 2, "angle": 0}}
|
||||
field_positions = params.get("fieldPositions")
|
||||
# dict: {"MPN": "RC0603FR-0710KL"} OR {"MPN": {"value": "...", "hide": true}}
|
||||
properties = params.get("properties")
|
||||
# list[str]: ["OldField"] — protected built-ins are rejected
|
||||
remove_properties = params.get("removeProperties")
|
||||
|
||||
if not schematic_path:
|
||||
return {"success": False, "message": "schematicPath is required"}
|
||||
@@ -884,13 +1095,30 @@ class KiCADInterface:
|
||||
new_value is not None,
|
||||
new_reference is not None,
|
||||
field_positions is not None,
|
||||
properties is not None,
|
||||
remove_properties is not None,
|
||||
]
|
||||
):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "At least one of footprint, value, newReference, or fieldPositions must be provided",
|
||||
"message": (
|
||||
"At least one of footprint, value, newReference, fieldPositions, "
|
||||
"properties, or removeProperties must be provided"
|
||||
),
|
||||
}
|
||||
|
||||
# Reject removal attempts targeting protected built-in fields up-front
|
||||
if remove_properties:
|
||||
blocked = [n for n in remove_properties if n in self._PROTECTED_PROPERTY_FIELDS]
|
||||
if blocked:
|
||||
return {
|
||||
"success": False,
|
||||
"message": (
|
||||
f"Cannot remove built-in field(s) {blocked}: use the dedicated "
|
||||
"value/footprint/newReference parameters or set the value to ''"
|
||||
),
|
||||
}
|
||||
|
||||
sch_file = Path(schematic_path)
|
||||
if not sch_file.exists():
|
||||
return {
|
||||
@@ -901,23 +1129,11 @@ class KiCADInterface:
|
||||
with open(sch_file, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
def find_matching_paren(s: str, start: int) -> int:
|
||||
"""Find the position of the closing paren matching the opening paren at 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
|
||||
lib_sym_end = (
|
||||
self._find_matching_paren(content, lib_sym_pos) if lib_sym_pos >= 0 else -1
|
||||
)
|
||||
|
||||
# Find placed symbol blocks that match the reference
|
||||
# Search for (symbol (lib_id "...") ... (property "Reference" "<ref>" ...) ...)
|
||||
@@ -933,7 +1149,7 @@ class KiCADInterface:
|
||||
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)
|
||||
end = self._find_matching_paren(content, pos)
|
||||
if end < 0:
|
||||
search_start = pos + 1
|
||||
continue
|
||||
@@ -954,20 +1170,36 @@ class KiCADInterface:
|
||||
|
||||
# Apply property replacements within the found block
|
||||
block_text = content[block_start : block_end + 1]
|
||||
|
||||
# Determine the parent symbol position so that newly-added properties
|
||||
# default to a sensible location (anchored near the component).
|
||||
comp_at = re.search(
|
||||
r'\(symbol\s+\(lib_id\s+"[^"]*"\s*\)\s+\(at\s+([\d\.\-]+)\s+([\d\.\-]+)',
|
||||
block_text,
|
||||
)
|
||||
comp_origin: Tuple[float, float] = (
|
||||
(float(comp_at.group(1)), float(comp_at.group(2))) if comp_at else (0.0, 0.0)
|
||||
)
|
||||
|
||||
if new_footprint is not None:
|
||||
escaped_fp = self._escape_sexpr_string(str(new_footprint))
|
||||
block_text = re.sub(
|
||||
r'(\(property\s+"Footprint"\s+)"[^"]*"',
|
||||
rf'\1"{new_footprint}"',
|
||||
rf'\1"{escaped_fp}"',
|
||||
block_text,
|
||||
)
|
||||
if new_value is not None:
|
||||
escaped_v = self._escape_sexpr_string(str(new_value))
|
||||
block_text = re.sub(
|
||||
r'(\(property\s+"Value"\s+)"[^"]*"', rf'\1"{new_value}"', block_text
|
||||
r'(\(property\s+"Value"\s+)"[^"]*"',
|
||||
rf'\1"{escaped_v}"',
|
||||
block_text,
|
||||
)
|
||||
if new_reference is not None:
|
||||
escaped_r = self._escape_sexpr_string(str(new_reference))
|
||||
block_text = re.sub(
|
||||
r'(\(property\s+"Reference"\s+)"[^"]*"',
|
||||
rf'\1"{new_reference}"',
|
||||
rf'\1"{escaped_r}"',
|
||||
block_text,
|
||||
)
|
||||
if field_positions is not None:
|
||||
@@ -983,12 +1215,50 @@ class KiCADInterface:
|
||||
block_text,
|
||||
)
|
||||
|
||||
properties_added: Dict[str, Any] = {}
|
||||
properties_updated: Dict[str, Any] = {}
|
||||
if properties:
|
||||
if not isinstance(properties, dict):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "properties must be a dict mapping property name -> value or spec",
|
||||
}
|
||||
for name, spec in properties.items():
|
||||
if not isinstance(name, str) or not name:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Invalid property name: {name!r}",
|
||||
}
|
||||
# Normalise scalar values to a spec dict with just {"value": ...}
|
||||
if not isinstance(spec, dict):
|
||||
spec = {"value": spec}
|
||||
try:
|
||||
block_text, action = self._set_property_in_block(
|
||||
block_text, name, spec, comp_origin
|
||||
)
|
||||
except ValueError as ve:
|
||||
return {"success": False, "message": str(ve)}
|
||||
target = properties_added if action == "added" else properties_updated
|
||||
target[name] = spec.get("value")
|
||||
|
||||
properties_removed: list = []
|
||||
if remove_properties:
|
||||
if not isinstance(remove_properties, list):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "removeProperties must be a list of property names",
|
||||
}
|
||||
for name in remove_properties:
|
||||
block_text, removed = self._remove_property_from_block(block_text, name)
|
||||
if removed:
|
||||
properties_removed.append(name)
|
||||
|
||||
content = content[:block_start] + block_text + content[block_end + 1 :]
|
||||
|
||||
with open(sch_file, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
changes = {
|
||||
changes: Dict[str, Any] = {
|
||||
k: v
|
||||
for k, v in {
|
||||
"footprint": new_footprint,
|
||||
@@ -999,6 +1269,13 @@ class KiCADInterface:
|
||||
}
|
||||
if field_positions is not None:
|
||||
changes["fieldPositions"] = field_positions
|
||||
if properties_added:
|
||||
changes["propertiesAdded"] = properties_added
|
||||
if properties_updated:
|
||||
changes["propertiesUpdated"] = properties_updated
|
||||
if properties_removed:
|
||||
changes["propertiesRemoved"] = properties_removed
|
||||
|
||||
logger.info(f"Edited schematic component {reference}: {changes}")
|
||||
return {"success": True, "reference": reference, "updated": changes}
|
||||
|
||||
@@ -1009,6 +1286,52 @@ class KiCADInterface:
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
def _handle_set_schematic_component_property(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Add or update a single property on a placed schematic symbol.
|
||||
|
||||
Convenience wrapper around `edit_schematic_component` for the very common
|
||||
case of setting one BOM/sourcing field at a time. The property is created
|
||||
if it does not already exist, otherwise its value (and optionally its
|
||||
position / visibility) is updated in place.
|
||||
"""
|
||||
logger.info("Setting schematic component property")
|
||||
name = params.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
return {"success": False, "message": "name is required"}
|
||||
if "value" not in params:
|
||||
return {"success": False, "message": "value is required"}
|
||||
|
||||
spec: Dict[str, Any] = {"value": params["value"]}
|
||||
for key in ("x", "y", "angle", "hide", "fontSize"):
|
||||
if params.get(key) is not None:
|
||||
spec[key] = params[key]
|
||||
|
||||
return self._handle_edit_schematic_component(
|
||||
{
|
||||
"schematicPath": params.get("schematicPath"),
|
||||
"reference": params.get("reference"),
|
||||
"properties": {name: spec},
|
||||
}
|
||||
)
|
||||
|
||||
def _handle_remove_schematic_component_property(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Remove a single custom property from a placed schematic symbol.
|
||||
|
||||
Built-in fields (Reference, Value, Footprint, Datasheet) cannot be
|
||||
removed — use `edit_schematic_component` to clear them instead.
|
||||
"""
|
||||
logger.info("Removing schematic component property")
|
||||
name = params.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
return {"success": False, "message": "name is required"}
|
||||
return self._handle_edit_schematic_component(
|
||||
{
|
||||
"schematicPath": params.get("schematicPath"),
|
||||
"reference": params.get("reference"),
|
||||
"removeProperties": [name],
|
||||
}
|
||||
)
|
||||
|
||||
def _handle_get_schematic_component(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return full component info: position and all field values with their (at x y angle) positions."""
|
||||
logger.info("Getting schematic component info")
|
||||
|
||||
Reference in New Issue
Block a user