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:
William Viana
2026-04-20 17:36:38 -07:00
parent 5b7434fdef
commit 4d7843c03a
8 changed files with 1471 additions and 64 deletions

View File

@@ -2,6 +2,63 @@
All notable changes to the KiCAD MCP Server project are documented here. All notable changes to the KiCAD MCP Server project are documented here.
## [Unreleased]
### New MCP Tools
- `set_schematic_component_property` — Add or update a single custom property
(BOM / sourcing field) on a placed schematic symbol. Convenience wrapper
around `edit_schematic_component` for the common case of attaching one MPN /
Manufacturer / DigiKey_PN / LCSC / JLCPCB_PN / Voltage / Tolerance /
Dielectric value at a time. Newly created properties default to hidden so
they do not clutter the schematic canvas.
- `remove_schematic_component_property` — Delete a custom property from a
placed schematic symbol. The four built-in fields (Reference, Value,
Footprint, Datasheet) are protected and cannot be removed; clear them by
setting their value to `""` via `edit_schematic_component` instead.
### Tool Enhancements
- `edit_schematic_component`: extended with two new optional parameters that
promote arbitrary custom properties to first-class citizens:
- **`properties`** — map of property name to either a string value or a full
spec object `{ value, x?, y?, angle?, hide?, fontSize? }`. Adds the
property if it does not yet exist on the symbol, otherwise updates the
existing value (and optionally its label position / visibility). Lets a
single tool call attach an entire BOM / sourcing payload to a component:
`properties: { MPN: "RC0603FR-0710KL", Manufacturer: "Yageo", Tolerance: "1%" }`.
- **`removeProperties`** — list of custom property names to delete in the
same call.
- String values written through any of the property paths are now properly
backslash-escaped so descriptions containing `"` or `\` no longer
corrupt the .kicad_sch file.
- `get_schematic_component`: clarified description — it already returns every
field on the symbol (built-in + custom). The tool description now spells
this out explicitly so agents know they can use it to inspect MPN,
Manufacturer, Distributor PN and other BOM fields without a separate call.
### New MCP Prompt
- `component_sourcing_properties` — Guides the LLM through attaching BOM and
sourcing metadata (MPN, Manufacturer, distributor part numbers, parametric
fields like Voltage / Tolerance / Dielectric) to schematic components. Lists
the conventional property names recognised by downstream BOM tooling and the
recommended call sequence (`list_schematic_components`
`get_schematic_component``set_schematic_component_property` /
`edit_schematic_component`).
### Tests
- `tests/test_schematic_component_properties.py`: 32 new tests covering custom
property add / update / remove (single + batched), full spec dicts, position
defaults, `(hide yes)` defaulting, protected built-in field rejection,
no-op removal, special-character escaping, UUID preservation, and the two
new convenience tools.
---
## [2.2.3] - 2026-03-11 ## [2.2.3] - 2026-03-11
### Merged: PR #57 (Kletternaut/demo/rpiCSI-videotest → main) ### Merged: PR #57 (Kletternaut/demo/rpiCSI-videotest → main)

View File

@@ -318,8 +318,10 @@ Complete schematic workflow with dynamic symbol loading (~10,000 symbols) and in
- `add_schematic_component` - Place symbols from any KiCad library - `add_schematic_component` - Place symbols from any KiCad library
- `delete_schematic_component` - Remove component - `delete_schematic_component` - Remove component
- `edit_schematic_component` - Edit properties and fields - `edit_schematic_component` - Edit footprint, value, reference, label positions, and **arbitrary custom properties** (MPN, Manufacturer, DigiKey_PN, LCSC, Voltage, Tolerance, Dielectric, …) in one batched call
- `get_schematic_component` - Get component info with field positions - `set_schematic_component_property` - Add or update a single custom property (BOM/sourcing field) on a component
- `remove_schematic_component_property` - Delete a single custom property from a component
- `get_schematic_component` - Inspect every field on a component (built-in + custom) including label positions
- `list_schematic_components` - List all components - `list_schematic_components` - List all components
- `move_schematic_component` - Reposition component - `move_schematic_component` - Reposition component
- `rotate_schematic_component` - Rotate component - `rotate_schematic_component` - Rotate component

View File

@@ -3,9 +3,9 @@
Added in: v2.1.0, expanded in v2.2.0-v2.2.3 Added in: v2.1.0, expanded in v2.2.0-v2.2.3
Contributors: @Mehanik (PRs #60, #66), @Kletternaut (PR #57) Contributors: @Mehanik (PRs #60, #66), @Kletternaut (PR #57)
This document provides a complete reference for the 27 schematic tools in the KiCAD MCP Server. These tools enable a complete schematic design workflow, from creating projects and adding components to wiring, validation, and synchronization with PCB boards. The dynamic symbol loading feature provides access to approximately 10,000 standard KiCad symbols. This document provides a complete reference for the 29 schematic tools in the KiCAD MCP Server. These tools enable a complete schematic design workflow, from creating projects and adding components to wiring, validation, BOM/sourcing metadata, and synchronization with PCB boards. The dynamic symbol loading feature provides access to approximately 10,000 standard KiCad symbols.
## Component Operations (8 tools) ## Component Operations (10 tools)
### add_schematic_component ### add_schematic_component
@@ -33,20 +33,100 @@ Remove a placed symbol from a KiCAD schematic (.kicad_sch). This removes the sym
### edit_schematic_component ### edit_schematic_component
Update properties of a placed symbol in a KiCAD schematic (.kicad_sch) in-place. Use this tool to assign or update a footprint, change the value, or rename the reference of an already-placed component. This is more efficient than delete + re-add because it preserves the component's position and UUID. Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_component. Update properties of a placed symbol in a KiCAD schematic (.kicad_sch) in-place.
| Parameter | Type | Required | Description | Use this tool to:
| -------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file | - assign or update the footprint, value, or reference designator,
| reference | string | Yes | Current reference designator of the component (e.g. R1, U3) | - reposition field labels (Reference / Value text),
| footprint | string | No | New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric) | - add, update, or remove **arbitrary custom properties** used by BOM and sourcing
| value | string | No | New value string (e.g. 10k, 100nF) | workflows (`MPN`, `Manufacturer`, `Manufacturer_PN`, `Distributor`, `DigiKey`,
| newReference | string | No | Rename the reference designator (e.g. R1 → R10) | `DigiKey_PN`, `Mouser_PN`, `LCSC`, `JLCPCB_PN`, `Voltage`, `Tolerance`, `Power`,
| fieldPositions | object | No | Reposition field labels: map of field name to {x, y, angle} (e.g. {"Reference": {"x": 12.5, "y": 17.0}}) | `Dielectric`, `Temperature_Coefficient`, …).
Custom properties are first-class — they survive ERC, are exported by
`export_bom`, and are picked up by the JLCPCB / Digi-Key sourcing tooling. Newly
created properties default to hidden so they do not clutter the schematic canvas.
Multiple updates can be batched in a single call: pass any combination of
`footprint`, `value`, `newReference`, `fieldPositions`, `properties`, and
`removeProperties` together. This is more efficient than delete + re-add because
it preserves the component's position and UUID. Operates on .kicad_sch files
only — to modify a PCB footprint use `edit_component` instead.
| Parameter | Type | Required | Description |
| ---------------- | -------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| reference | string | Yes | Current reference designator of the component (e.g. R1, U3) |
| footprint | string | No | New KiCAD footprint string (e.g. Resistor_SMD:R_0603_1608Metric) |
| value | string | No | New value string (e.g. 10k, 100nF) |
| newReference | string | No | Rename the reference designator (e.g. R1 → R10) |
| fieldPositions | object | No | Reposition field labels: map of field name to {x, y, angle} (e.g. {"Reference": {"x": 12.5, "y": 17.0}}) |
| properties | object | No | Add or update component properties. Map of property name to either a string value or `{value, x?, y?, angle?, hide?, fontSize?}`. Built-in fields (Reference/Value/Footprint/Datasheet) can also be set this way but the dedicated parameters above are clearer. |
| removeProperties | string[] | No | List of custom property names to delete. Built-in fields (Reference, Value, Footprint, Datasheet) cannot be removed (clear them by setting `value` to `""` instead). |
**Example — attach BOM/sourcing data to a 0603 resistor:**
```json
{
"schematicPath": "/path/to/board.kicad_sch",
"reference": "R7",
"value": "10k",
"footprint": "Resistor_SMD:R_0603_1608Metric",
"properties": {
"MPN": "RC0603FR-0710KL",
"Manufacturer": "Yageo",
"DigiKey_PN": "311-10.0KHRCT-ND",
"LCSC": "C25804",
"Tolerance": "1%",
"Power": "0.1W"
}
}
```
### set_schematic_component_property
Add or update a **single** custom property on a placed schematic symbol. Convenience
wrapper around `edit_schematic_component` for the common case of attaching one
BOM / sourcing field at a time. Creates the property if it does not yet exist.
Newly created properties default to hidden — set `hide: false` plus an explicit
`x`/`y` to display the value on the schematic canvas.
| Parameter | Type | Required | Description |
| ------------- | ------- | -------- | ---------------------------------------------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| reference | string | Yes | Reference designator of the component (e.g. R1, U3) |
| name | string | Yes | Property name (e.g. 'MPN', 'Manufacturer', 'DigiKey_PN', 'Voltage', 'Dielectric') |
| value | string | Yes | Property value to write (use empty string to clear) |
| x | number | No | Label X position in mm (default: component X) |
| y | number | No | Label Y position in mm (default: component Y) |
| angle | number | No | Label rotation in degrees (default: 0) |
| hide | boolean | No | Hide the property text on the schematic canvas. Defaults to true for newly created custom properties |
| fontSize | number | No | Font size in mm for the label (default: 1.27) |
### remove_schematic_component_property
Remove a single custom property from a placed schematic symbol. Built-in fields
(Reference, Value, Footprint, Datasheet) cannot be removed — KiCad requires them
on every symbol. To clear a built-in field, use `edit_schematic_component` and
set its value to an empty string.
| Parameter | Type | Required | Description |
| ------------- | ------ | -------- | ------------------------------------------------------------------------- |
| schematicPath | string | Yes | Path to the .kicad_sch file |
| reference | string | Yes | Reference designator of the component (e.g. R1, U3) |
| name | string | Yes | Custom property name to remove (e.g. 'MPN', 'Distributor_PN', 'OldField') |
### get_schematic_component ### get_schematic_component
Get full component info from a schematic: position, field values, and each field's label position (at x/y/angle). Use this to inspect or prepare repositioning of Reference/Value labels. Get full component info from a schematic: position, every field's value, and each
field's label position (at x/y/angle). Returns **all** properties — both built-in
fields (Reference, Value, Footprint, Datasheet) and any custom BOM/sourcing
properties present on the symbol (MPN, Manufacturer, DigiKey_PN, LCSC, Voltage,
Tolerance, Dielectric, etc.). Use this before `edit_schematic_component` /
`set_schematic_component_property` to inspect what is currently set, or to plan
a label repositioning.
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| ------------- | ------ | -------- | -------------------------------------------- | | ------------- | ------ | -------- | -------------------------------------------- |

View File

@@ -1,8 +1,8 @@
# KiCAD MCP Server - Complete Tool Inventory # KiCAD MCP Server - Complete Tool Inventory
**Version:** 2.2.3 **Version:** 2.2.3
**Total Tools:** 122 (18 direct + 65 routed + 4 router + 35 additional) **Total Tools:** 124 (18 direct + 65 routed + 4 router + 37 additional)
**Last Updated:** 2026-03-21 **Last Updated:** 2026-04-20
## How Tools Are Organized ## How Tools Are Organized
@@ -130,22 +130,24 @@ _Source: `src/tools/export.ts`_
--- ---
## Schematic (27 tools) ## Schematic (29 tools)
_Source: `src/tools/schematic.ts`_ _Source: `src/tools/schematic.ts`_
### Component Operations ### Component Operations
| Tool | Description | Access | | Tool | Description | Access |
| ---------------------------- | ------------------------------------------------------- | ------------------ | | ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------ |
| `add_schematic_component` | Add component to schematic (symbol from library) | Direct | | `add_schematic_component` | Add component to schematic (symbol from library) | Direct |
| `delete_schematic_component` | Remove component from schematic | Additional | | `delete_schematic_component` | Remove component from schematic | Additional |
| `edit_schematic_component` | Edit component properties (footprint, value, reference) | Additional | | `edit_schematic_component` | Edit footprint, value, reference, label positions, and **arbitrary custom properties** (MPN, Manufacturer, DigiKey, …) | Additional |
| `get_schematic_component` | Get component info with field positions | Additional | | `set_schematic_component_property` | Add or update a single custom property (BOM/sourcing field) on a component | Additional |
| `list_schematic_components` | List all components in schematic | Direct | | `remove_schematic_component_property` | Delete a single custom property from a component | Additional |
| `move_schematic_component` | Move component to new position | Routed (schematic) | | `get_schematic_component` | Get component info: built-in fields + all custom properties + label positions | Additional |
| `rotate_schematic_component` | Rotate component | Routed (schematic) | | `list_schematic_components` | List all components in schematic | Direct |
| `annotate_schematic` | Auto-annotate reference designators | Direct | | `move_schematic_component` | Move component to new position | Routed (schematic) |
| `rotate_schematic_component` | Rotate component | Routed (schematic) |
| `annotate_schematic` | Auto-annotate reference designators | Direct |
### Wiring and Connections ### Wiring and Connections

View File

@@ -13,7 +13,7 @@ import os
import sys import sys
import traceback import traceback
from pathlib import Path 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 from resources.resource_definitions import RESOURCE_DEFINITIONS, handle_resource_read
@@ -373,6 +373,8 @@ class KiCADInterface:
"add_schematic_component": self._handle_add_schematic_component, "add_schematic_component": self._handle_add_schematic_component,
"delete_schematic_component": self._handle_delete_schematic_component, "delete_schematic_component": self._handle_delete_schematic_component,
"edit_schematic_component": self._handle_edit_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, "get_schematic_component": self._handle_get_schematic_component,
"add_schematic_wire": self._handle_add_schematic_wire, "add_schematic_wire": self._handle_add_schematic_wire,
"add_schematic_net_label": self._handle_add_schematic_net_label, "add_schematic_net_label": self._handle_add_schematic_net_label,
@@ -856,9 +858,215 @@ class KiCADInterface:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return {"success": False, "message": str(e)} 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]: 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). """Update properties of a placed symbol in a schematic.
Uses text-based in-place editing preserves position, UUID and all other fields.
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") logger.info("Editing schematic component")
try: try:
@@ -870,9 +1078,12 @@ class KiCADInterface:
new_footprint = params.get("footprint") new_footprint = params.get("footprint")
new_value = params.get("value") new_value = params.get("value")
new_reference = params.get("newReference") new_reference = params.get("newReference")
field_positions = params.get( # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}}
"fieldPositions" field_positions = params.get("fieldPositions")
) # dict: {"Reference": {"x": 1, "y": 2, "angle": 0}} # 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: if not schematic_path:
return {"success": False, "message": "schematicPath is required"} return {"success": False, "message": "schematicPath is required"}
@@ -884,13 +1095,30 @@ class KiCADInterface:
new_value is not None, new_value is not None,
new_reference is not None, new_reference is not None,
field_positions is not None, field_positions is not None,
properties is not None,
remove_properties is not None,
] ]
): ):
return { return {
"success": False, "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) sch_file = Path(schematic_path)
if not sch_file.exists(): if not sch_file.exists():
return { return {
@@ -901,23 +1129,11 @@ class KiCADInterface:
with open(sch_file, "r", encoding="utf-8") as f: with open(sch_file, "r", encoding="utf-8") as f:
content = f.read() 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 # Skip lib_symbols section
lib_sym_pos = content.find("(lib_symbols") 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 # Find placed symbol blocks that match the reference
# Search for (symbol (lib_id "...") ... (property "Reference" "<ref>" ...) ...) # 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: if lib_sym_pos >= 0 and lib_sym_pos <= pos <= lib_sym_end:
search_start = lib_sym_end + 1 search_start = lib_sym_end + 1
continue continue
end = find_matching_paren(content, pos) end = self._find_matching_paren(content, pos)
if end < 0: if end < 0:
search_start = pos + 1 search_start = pos + 1
continue continue
@@ -954,20 +1170,36 @@ class KiCADInterface:
# Apply property replacements within the found block # Apply property replacements within the found block
block_text = content[block_start : block_end + 1] 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: if new_footprint is not None:
escaped_fp = self._escape_sexpr_string(str(new_footprint))
block_text = re.sub( block_text = re.sub(
r'(\(property\s+"Footprint"\s+)"[^"]*"', r'(\(property\s+"Footprint"\s+)"[^"]*"',
rf'\1"{new_footprint}"', rf'\1"{escaped_fp}"',
block_text, block_text,
) )
if new_value is not None: if new_value is not None:
escaped_v = self._escape_sexpr_string(str(new_value))
block_text = re.sub( 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: if new_reference is not None:
escaped_r = self._escape_sexpr_string(str(new_reference))
block_text = re.sub( block_text = re.sub(
r'(\(property\s+"Reference"\s+)"[^"]*"', r'(\(property\s+"Reference"\s+)"[^"]*"',
rf'\1"{new_reference}"', rf'\1"{escaped_r}"',
block_text, block_text,
) )
if field_positions is not None: if field_positions is not None:
@@ -983,12 +1215,50 @@ class KiCADInterface:
block_text, 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 :] content = content[:block_start] + block_text + content[block_end + 1 :]
with open(sch_file, "w", encoding="utf-8") as f: with open(sch_file, "w", encoding="utf-8") as f:
f.write(content) f.write(content)
changes = { changes: Dict[str, Any] = {
k: v k: v
for k, v in { for k, v in {
"footprint": new_footprint, "footprint": new_footprint,
@@ -999,6 +1269,13 @@ class KiCADInterface:
} }
if field_positions is not None: if field_positions is not None:
changes["fieldPositions"] = field_positions 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}") logger.info(f"Edited schematic component {reference}: {changes}")
return {"success": True, "reference": reference, "updated": changes} return {"success": True, "reference": reference, "updated": changes}
@@ -1009,6 +1286,52 @@ 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_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]: 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.""" """Return full component info: position and all field values with their (at x y angle) positions."""
logger.info("Getting schematic component info") logger.info("Getting schematic component info")

View File

@@ -195,6 +195,78 @@ Based on the available information, suggest likely causes of the issue and recom
}), }),
); );
// ------------------------------------------------------
// Component Sourcing / BOM Properties Prompt
// ------------------------------------------------------
server.prompt(
"component_sourcing_properties",
{
component_info: z
.string()
.describe(
"Description of the component(s) being sourced and which BOM fields need to be attached " +
"(MPN, distributor part numbers, manufacturer, etc.).",
),
},
() => ({
messages: [
{
role: "user",
content: {
type: "text",
text: `You are attaching sourcing and BOM metadata to schematic components. Here is the situation:
{{component_info}}
KiCad symbols carry arbitrary key/value properties on top of the four built-in fields
(Reference, Value, Footprint, Datasheet). These custom properties are written into
the .kicad_sch file, are exported by export_bom, and are picked up by JLCPCB / Digi-Key
sourcing tooling.
Conventional property names (use these so downstream BOM tools recognise them):
• MPN — Manufacturer Part Number (canonical)
• Manufacturer — Manufacturer name (e.g. "Yageo", "Murata")
• Manufacturer_PN — Alias some BOM templates expect; mirror MPN if unsure
• DigiKey, DigiKey_PN — Digi-Key catalogue number
• Mouser_PN — Mouser catalogue number
• LCSC, JLCPCB_PN — JLCPCB / LCSC part number (used by JLCPCB assembly)
• Distributor, Distributor_PN — Generic fallback fields
• Voltage — Working voltage rating (e.g. "50V")
• Tolerance — Tolerance (e.g. "1%", "±5%")
• Power — Power rating (e.g. "0.1W", "1/4W")
• Dielectric — Capacitor dielectric (e.g. "X7R", "C0G", "Y5V")
• Temperature_Coefficient — Resistor TC (e.g. "100ppm/°C")
• Description — Free-form human-readable description
Tools to use, in this order:
1. \`list_schematic_components\` — confirm which components need updating.
2. \`get_schematic_component\` — inspect what properties are already present
(returns ALL property fields, including custom ones).
3. \`set_schematic_component_property\` — attach or update one property at a time
when working on a single component.
4. \`edit_schematic_component\` with the \`properties\` parameter — batch-update
many properties on the same component in a single call:
properties: { MPN: "RC0603FR-0710KL", Manufacturer: "Yageo", Tolerance: "1%" }
5. \`remove_schematic_component_property\` — delete an obsolete custom field.
Hidden vs visible:
• Newly-created custom properties default to hidden — they appear in BOM exports
but do NOT clutter the schematic canvas. This is the normal convention for
sourcing metadata.
• If a value should be displayed (e.g. you want the MPN visible next to the
symbol), pass \`hide: false\` and a sensible \`x\`/\`y\` position.
Recommend the right set of properties for the components in the brief, generate
the actual tool calls (with concrete values), and explain any sourcing trade-offs
or substitutions you propose.`,
},
},
],
}),
);
// ------------------------------------------------------ // ------------------------------------------------------
// Component Value Calculation Prompt // Component Value Calculation Prompt
// ------------------------------------------------------ // ------------------------------------------------------

View File

@@ -139,16 +139,29 @@ To remove a footprint from a PCB, use delete_component instead.`,
}, },
); );
// Edit component properties in schematic (footprint, value, reference) // Edit component properties in schematic (footprint, value, reference, custom fields)
server.tool( server.tool(
"edit_schematic_component", "edit_schematic_component",
`Update properties of a placed symbol in a KiCAD schematic (.kicad_sch) in-place. `Update properties of a placed symbol in a KiCAD schematic (.kicad_sch) in-place.
Use this tool to assign or update a footprint, change the value, or rename the reference Use this tool to:
of an already-placed component. This is more efficient than delete + re-add because it • assign or update the footprint, value, or reference designator,
preserves the component's position and UUID. • reposition field labels (Reference / Value text),
• add, update, or remove ARBITRARY CUSTOM PROPERTIES used by BOM and sourcing
workflows: MPN, Manufacturer, Manufacturer_PN, Distributor, DigiKey, DigiKey_PN,
Mouser_PN, LCSC, JLCPCB_PN, Voltage, Tolerance, Power, Dielectric, etc.
Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_component.`, Custom properties are first-class — they survive ERC, are exported by export_bom,
and are picked up by the JLCPCB / Digi-Key BOM tooling. Newly-added properties
default to hidden so they do not clutter the schematic canvas.
Multiple updates can be batched in a single call: pass any combination of
\`footprint\`, \`value\`, \`newReference\`, \`fieldPositions\`, \`properties\`,
and \`removeProperties\` together.
This is more efficient than delete + re-add because it preserves the component's
position and UUID. Operates on .kicad_sch files only — to modify a PCB footprint
use edit_component instead.`,
{ {
schematicPath: z.string().describe("Path to the .kicad_sch file"), schematicPath: z.string().describe("Path to the .kicad_sch file"),
reference: z.string().describe("Current reference designator of the component (e.g. R1, U3)"), reference: z.string().describe("Current reference designator of the component (e.g. R1, U3)"),
@@ -173,6 +186,45 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
.describe( .describe(
'Reposition field labels: map of field name to {x, y, angle} (e.g. {"Reference": {"x": 12.5, "y": 17.0}})', 'Reposition field labels: map of field name to {x, y, angle} (e.g. {"Reference": {"x": 12.5, "y": 17.0}})',
), ),
properties: z
.record(
z.union([
z.string(),
z.object({
value: z.string().describe("Property value to write"),
x: z.number().optional().describe("Label X position in mm (default: component X)"),
y: z.number().optional().describe("Label Y position in mm (default: component Y)"),
angle: z.number().optional().describe("Label rotation in degrees (default: 0)"),
hide: z
.boolean()
.optional()
.describe(
"Whether to hide the property text on the schematic. Defaults to true for newly-created custom properties (BOM/sourcing data is normally hidden).",
),
fontSize: z
.number()
.optional()
.describe("Font size in mm for the label (default: 1.27)"),
}),
]),
)
.optional()
.describe(
"Add or update component properties. Map of property name to either a string value (sensible defaults) " +
"or a full spec object {value, x?, y?, angle?, hide?, fontSize?}. Use this to attach BOM and sourcing " +
"metadata such as MPN, Manufacturer, Distributor, DigiKey, LCSC, JLCPCB_PN, Voltage, Tolerance, " +
"Dielectric, Power, etc. Built-in fields (Reference, Value, Footprint, Datasheet) can also be set " +
"this way but the dedicated parameters above are clearer. Example: " +
'{"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo", "Tolerance": "1%"}',
),
removeProperties: z
.array(z.string())
.optional()
.describe(
"List of custom property names to delete from this component. The built-in fields " +
"Reference, Value, Footprint, and Datasheet cannot be removed (clear them by setting " +
'value to "" instead). Example: ["OldMPN", "Distributor_PN"]',
),
}, },
async (args: { async (args: {
schematicPath: string; schematicPath: string;
@@ -181,17 +233,41 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
value?: string; value?: string;
newReference?: string; newReference?: string;
fieldPositions?: Record<string, { x: number; y: number; angle?: number }>; fieldPositions?: Record<string, { x: number; y: number; angle?: number }>;
properties?: Record<
string,
| string
| {
value: string;
x?: number;
y?: number;
angle?: number;
hide?: boolean;
fontSize?: number;
}
>;
removeProperties?: string[];
}) => { }) => {
const result = await callKicadScript("edit_schematic_component", args); const result = await callKicadScript("edit_schematic_component", args);
if (result.success) { if (result.success) {
const changes = Object.entries(result.updated ?? {}) const updated = result.updated ?? {};
.map(([k, v]) => `${k}=${v}`) const summaryParts: string[] = [];
.join(", "); const simpleKeys = ["footprint", "value", "reference"] as const;
for (const k of simpleKeys) {
if (updated[k] !== undefined) summaryParts.push(`${k}=${updated[k]}`);
}
if (updated.fieldPositions)
summaryParts.push(`fieldPositions=${Object.keys(updated.fieldPositions).join(",")}`);
if (updated.propertiesAdded)
summaryParts.push(`added=${Object.keys(updated.propertiesAdded).join(",")}`);
if (updated.propertiesUpdated)
summaryParts.push(`updated=${Object.keys(updated.propertiesUpdated).join(",")}`);
if (updated.propertiesRemoved)
summaryParts.push(`removed=${updated.propertiesRemoved.join(",")}`);
return { return {
content: [ content: [
{ {
type: "text" as const, type: "text" as const,
text: `Successfully updated ${args.reference}: ${changes}`, text: `Successfully updated ${args.reference}: ${summaryParts.join("; ") || "(no-op)"}`,
}, },
], ],
}; };
@@ -207,10 +283,146 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
}, },
); );
// ------------------------------------------------------------------
// Single-property convenience tools (delegate to edit_schematic_component)
// ------------------------------------------------------------------
// Set a single custom property on a placed symbol
server.tool(
"set_schematic_component_property",
`Add or update a single custom property on a placed schematic symbol.
This is a focused convenience wrapper around edit_schematic_component for the very
common case of attaching one BOM / sourcing field at a time. The property is
created if it does not already exist on the component.
Typical custom properties:
• MPN, Manufacturer, Manufacturer_PN — manufacturer part number metadata
• DigiKey, DigiKey_PN, Mouser_PN, LCSC, JLCPCB_PN — distributor part numbers
• Voltage, Tolerance, Power, Dielectric, Temperature_Coefficient — passive parameters
• Description, Notes — free-form documentation
• Any custom field your BOM exporter expects.
These properties are written into the .kicad_sch file as standard KiCad property
records, are exported by export_bom, and are picked up by the JLCPCB and Digi-Key
sourcing tools. Newly-created properties default to hidden — set hide=false to
display the value on the schematic canvas.
For batch updates of multiple properties at once, use edit_schematic_component
with the \`properties\` parameter instead.`,
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
reference: z.string().describe("Reference designator of the component (e.g. R1, U3)"),
name: z
.string()
.describe(
"Property name (e.g. 'MPN', 'Manufacturer', 'DigiKey_PN', 'Voltage', 'Dielectric')",
),
value: z.string().describe("Property value to write (use empty string to clear)"),
x: z.number().optional().describe("Label X position in mm (default: component X)"),
y: z.number().optional().describe("Label Y position in mm (default: component Y)"),
angle: z.number().optional().describe("Label rotation in degrees (default: 0)"),
hide: z
.boolean()
.optional()
.describe(
"Hide the property text on the schematic canvas. Defaults to true for newly-created custom properties.",
),
fontSize: z.number().optional().describe("Font size in mm for the label (default: 1.27)"),
},
async (args: {
schematicPath: string;
reference: string;
name: string;
value: string;
x?: number;
y?: number;
angle?: number;
hide?: boolean;
fontSize?: number;
}) => {
const result = await callKicadScript("set_schematic_component_property", args);
if (result.success) {
const updated = result.updated ?? {};
const action = updated.propertiesAdded?.[args.name] !== undefined ? "added" : "updated";
return {
content: [
{
type: "text" as const,
text: `Successfully ${action} property ${args.name}="${args.value}" on ${args.reference}`,
},
],
};
}
return {
content: [
{
type: "text" as const,
text: `Failed to set property '${args.name}' on ${args.reference}: ${result.message || "Unknown error"}`,
},
],
};
},
);
// Remove a single custom property from a placed symbol
server.tool(
"remove_schematic_component_property",
`Remove a single custom property from a placed schematic symbol.
Built-in fields (Reference, Value, Footprint, Datasheet) cannot be removed —
KiCad requires them on every symbol. To clear a built-in field, use
edit_schematic_component and set its value to an empty string.`,
{
schematicPath: z.string().describe("Path to the .kicad_sch file"),
reference: z.string().describe("Reference designator of the component (e.g. R1, U3)"),
name: z
.string()
.describe("Custom property name to remove (e.g. 'MPN', 'Distributor_PN', 'OldField')"),
},
async (args: { schematicPath: string; reference: string; name: string }) => {
const result = await callKicadScript("remove_schematic_component_property", args);
if (result.success) {
const removed = result.updated?.propertiesRemoved ?? [];
if (removed.includes(args.name)) {
return {
content: [
{
type: "text" as const,
text: `Successfully removed property '${args.name}' from ${args.reference}`,
},
],
};
}
return {
content: [
{
type: "text" as const,
text: `Property '${args.name}' was not present on ${args.reference} (no change made)`,
},
],
};
}
return {
content: [
{
type: "text" as const,
text: `Failed to remove property '${args.name}' from ${args.reference}: ${result.message || "Unknown error"}`,
},
],
};
},
);
// Get component properties and field positions from schematic // Get component properties and field positions from schematic
server.tool( server.tool(
"get_schematic_component", "get_schematic_component",
"Get full component info from a schematic: position, field values, and each field's label position (at x/y/angle). Use this to inspect or prepare repositioning of Reference/Value labels.", "Get full component info from a schematic: position, every field's value, and each field's " +
"label position (at x/y/angle). Returns ALL properties — both built-in fields " +
"(Reference, Value, Footprint, Datasheet) and any custom BOM/sourcing properties present " +
"on the symbol (MPN, Manufacturer, DigiKey_PN, LCSC, Voltage, Tolerance, Dielectric, etc.). " +
"Use this before edit_schematic_component / set_schematic_component_property to inspect " +
"what is currently set, or to plan a label repositioning.",
{ {
schematicPath: z.string().describe("Path to the .kicad_sch file"), schematicPath: z.string().describe("Path to the .kicad_sch file"),
reference: z.string().describe("Component reference designator (e.g. R1, U1)"), reference: z.string().describe("Component reference designator (e.g. R1, U1)"),

View File

@@ -0,0 +1,659 @@
"""
Tests for custom property support on edit_schematic_component,
set_schematic_component_property, and remove_schematic_component_property.
Custom properties are arbitrary key/value fields attached to a placed schematic
symbol — used for BOM / sourcing metadata such as MPN, Manufacturer,
DigiKey_PN, LCSC, JLCPCB_PN, Voltage, Tolerance, Dielectric, etc.
"""
import re
import sys
from pathlib import Path
from typing import Any
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / "python"))
TEMPLATE_SCH = Path(__file__).parent.parent / "python" / "templates" / "empty.kicad_sch"
# Minimal placed-symbol block embedded into the test schematic
PLACED_RESISTOR_BLOCK = """\
(symbol (lib_id "Device:R") (at 50 50 0) (unit 1)
(in_bom yes) (on_board yes) (dnp no)
(uuid "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee")
(property "Reference" "R1" (at 51.27 47.46 0)
(effects (font (size 1.27 1.27)))
)
(property "Value" "10k" (at 51.27 52.54 0)
(effects (font (size 1.27 1.27)))
)
(property "Footprint" "Resistor_SMD:R_0603_1608Metric" (at 50 50 0)
(effects (font (size 1.27 1.27)) hide)
)
(property "Datasheet" "~" (at 50 50 0)
(effects (font (size 1.27 1.27)) hide)
)
)
"""
def _make_test_schematic(tmp_dir: Path, extra_block: str = "") -> Path:
"""Copy empty.kicad_sch into tmp_dir, optionally appending a placed symbol block."""
dest = tmp_dir / "test.kicad_sch"
src_content = TEMPLATE_SCH.read_text(encoding="utf-8")
if extra_block:
src_content = src_content.rstrip()
if src_content.endswith(")"):
src_content = src_content[:-1] + "\n" + extra_block + ")\n"
dest.write_text(src_content, encoding="utf-8")
return dest
# ---------------------------------------------------------------------------
# Pure unit tests — exercise the static helpers in isolation.
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestStaticHelpers:
"""Tests for _escape_sexpr_string and _find_matching_paren."""
def _iface(self) -> Any:
from kicad_interface import KiCADInterface
return KiCADInterface
def test_escape_handles_quotes(self) -> None:
cls = self._iface()
assert cls._escape_sexpr_string('a"b') == 'a\\"b'
def test_escape_handles_backslashes(self) -> None:
cls = self._iface()
assert cls._escape_sexpr_string("a\\b") == "a\\\\b"
def test_escape_handles_both(self) -> None:
cls = self._iface()
# Order matters: backslashes are doubled first, then quotes
assert cls._escape_sexpr_string('a"b\\c') == 'a\\"b\\\\c'
def test_escape_passes_normal_text(self) -> None:
cls = self._iface()
assert cls._escape_sexpr_string("RC0603FR-0710KL") == "RC0603FR-0710KL"
def test_find_matching_paren_simple(self) -> None:
cls = self._iface()
s = "(abc)"
assert cls._find_matching_paren(s, 0) == 4
def test_find_matching_paren_nested(self) -> None:
cls = self._iface()
s = "(a (b (c) d) e)"
assert cls._find_matching_paren(s, 0) == 14
assert cls._find_matching_paren(s, 3) == 11
assert cls._find_matching_paren(s, 6) == 8
def test_find_matching_paren_no_match(self) -> None:
cls = self._iface()
s = "(abc"
assert cls._find_matching_paren(s, 0) == -1
# ---------------------------------------------------------------------------
# Integration tests — full file I/O through the public command interface.
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestEditSchematicComponentProperties:
"""Tests for the new `properties` and `removeProperties` parameters."""
@pytest.fixture
def sch_with_r1(self, tmp_path: Any) -> Any:
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _iface(self) -> Any:
from kicad_interface import KiCADInterface
return KiCADInterface()
def test_add_single_custom_property_string(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {"MPN": "RC0603FR-0710KL"},
},
)
assert result["success"] is True
assert "MPN" in result["updated"]["propertiesAdded"]
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
assert "MPN" in get_result["fields"]
assert get_result["fields"]["MPN"]["value"] == "RC0603FR-0710KL"
def test_add_multiple_custom_properties(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {
"MPN": "RC0603FR-0710KL",
"Manufacturer": "Yageo",
"Tolerance": "1%",
"Power": "0.1W",
},
},
)
assert result["success"] is True
assert set(result["updated"]["propertiesAdded"].keys()) == {
"MPN",
"Manufacturer",
"Tolerance",
"Power",
}
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
for name, expected_value in [
("MPN", "RC0603FR-0710KL"),
("Manufacturer", "Yageo"),
("Tolerance", "1%"),
("Power", "0.1W"),
]:
assert name in get_result["fields"], f"Missing property {name}"
assert get_result["fields"][name]["value"] == expected_value
def test_update_existing_custom_property(self, sch_with_r1: Any) -> None:
iface = self._iface()
# First add
iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {"MPN": "OLD-PN"},
},
)
# Then update
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {"MPN": "RC0603FR-0710KL"},
},
)
assert result["success"] is True
assert "MPN" in result["updated"]["propertiesUpdated"]
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
assert get_result["fields"]["MPN"]["value"] == "RC0603FR-0710KL"
def test_add_property_with_full_spec_dict(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {
"MPN": {
"value": "RC0603FR-0710KL",
"x": 60.0,
"y": 60.0,
"angle": 90,
"hide": False,
}
},
},
)
assert result["success"] is True
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
mpn = get_result["fields"]["MPN"]
assert mpn["value"] == "RC0603FR-0710KL"
assert mpn["x"] == pytest.approx(60.0)
assert mpn["y"] == pytest.approx(60.0)
assert mpn["angle"] == pytest.approx(90.0)
# Verify the (hide no) flag actually made it into the file
content = sch_with_r1.read_text(encoding="utf-8")
m = re.search(
r'\(property\s+"MPN"\s+"[^"]*"\s+\(at[^)]+\)\s+\(effects.*?\(hide no\)',
content,
re.DOTALL,
)
assert m is not None, "Expected (hide no) on the MPN property"
def test_new_property_defaults_to_hidden(self, sch_with_r1: Any) -> None:
iface = self._iface()
iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {"DigiKey_PN": "311-10.0KHRCT-ND"},
},
)
content = sch_with_r1.read_text(encoding="utf-8")
# Match (hide yes) inside the DigiKey_PN property block
m = re.search(
r'\(property\s+"DigiKey_PN"\s+"[^"]*"\s+\(at[^)]+\)\s+\(effects.*?\(hide yes\)',
content,
re.DOTALL,
)
assert m is not None, "New custom properties should default to (hide yes)"
def test_property_added_at_component_origin_by_default(self, sch_with_r1: Any) -> None:
iface = self._iface()
iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {"MPN": "RC0603FR-0710KL"},
},
)
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
mpn = get_result["fields"]["MPN"]
# Default position should equal the parent symbol's (50, 50)
assert mpn["x"] == pytest.approx(50.0)
assert mpn["y"] == pytest.approx(50.0)
def test_remove_custom_property(self, sch_with_r1: Any) -> None:
iface = self._iface()
iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {"MPN": "RC0603FR-0710KL"},
},
)
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"removeProperties": ["MPN"],
},
)
assert result["success"] is True
assert "MPN" in result["updated"]["propertiesRemoved"]
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
assert "MPN" not in get_result["fields"]
def test_remove_protected_field_rejected(self, sch_with_r1: Any) -> None:
iface = self._iface()
for name in ("Reference", "Value", "Footprint", "Datasheet"):
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"removeProperties": [name],
},
)
assert result["success"] is False, f"Removal of {name} should be rejected"
assert name in result["message"]
def test_remove_non_existent_property_is_noop(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"removeProperties": ["DoesNotExist"],
},
)
assert result["success"] is True
# No-op: the field was not present, so it should not appear in propertiesRemoved
assert "propertiesRemoved" not in result["updated"]
def test_batch_update_adds_and_removes_atomically(self, sch_with_r1: Any) -> None:
iface = self._iface()
iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {"OldField": "drop_me"},
},
)
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {
"MPN": "RC0603FR-0710KL",
"Manufacturer": "Yageo",
},
"removeProperties": ["OldField"],
},
)
assert result["success"] is True
assert set(result["updated"]["propertiesAdded"].keys()) == {"MPN", "Manufacturer"}
assert "OldField" in result["updated"]["propertiesRemoved"]
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
assert "OldField" not in get_result["fields"]
assert "MPN" in get_result["fields"]
assert "Manufacturer" in get_result["fields"]
def test_property_with_special_chars_is_escaped(self, sch_with_r1: Any) -> None:
"""Values containing " and \\ must be backslash-escaped in the .kicad_sch file
so the resulting S-expression is still well-formed and can be re-opened by KiCad.
"""
iface = self._iface()
tricky = 'Has "quotes" and \\backslash'
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {"Description": tricky},
},
)
assert result["success"] is True
# Inspect the file directly: the on-disk form must contain the escaped
# representation, NOT the raw quotes (which would corrupt the S-expression).
content = sch_with_r1.read_text(encoding="utf-8")
assert (
r'(property "Description" "Has \"quotes\" and \\backslash"' in content
), f"Expected escaped property value in file. Got:\n{content[-1000:]}"
def test_existing_value_field_is_unchanged_when_adding_property(self, sch_with_r1: Any) -> None:
iface = self._iface()
iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {"MPN": "RC0603FR-0710KL"},
},
)
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
# Built-in fields must be untouched
assert get_result["fields"]["Value"]["value"] == "10k"
assert get_result["fields"]["Reference"]["value"] == "R1"
assert get_result["fields"]["Footprint"]["value"] == "Resistor_SMD:R_0603_1608Metric"
def test_uuid_preserved_after_property_changes(self, sch_with_r1: Any) -> None:
iface = self._iface()
before = sch_with_r1.read_text(encoding="utf-8")
iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": {"MPN": "RC0603FR-0710KL", "Manufacturer": "Yageo"},
"removeProperties": ["Datasheet"] if False else None,
},
)
after = sch_with_r1.read_text(encoding="utf-8")
assert "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" in after
# And the uuid must still be the only one (we did not duplicate the symbol)
assert before.count("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee") == after.count(
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
)
def test_unknown_reference_returns_failure(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R99",
"properties": {"MPN": "RC0603FR-0710KL"},
},
)
assert result["success"] is False
def test_invalid_properties_type_returns_failure(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"properties": ["not", "a", "dict"],
},
)
assert result["success"] is False
def test_invalid_remove_properties_type_returns_failure(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"edit_schematic_component",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"removeProperties": "MPN", # should be a list
},
)
assert result["success"] is False
# ---------------------------------------------------------------------------
# Tests for the dedicated set_/remove_ tools
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSetSchematicComponentProperty:
"""Tests for the convenience `set_schematic_component_property` tool."""
@pytest.fixture
def sch_with_r1(self, tmp_path: Any) -> Any:
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _iface(self) -> Any:
from kicad_interface import KiCADInterface
return KiCADInterface()
def test_set_creates_property(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"set_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"name": "MPN",
"value": "RC0603FR-0710KL",
},
)
assert result["success"] is True
assert result["updated"]["propertiesAdded"]["MPN"] == "RC0603FR-0710KL"
def test_set_updates_existing_property(self, sch_with_r1: Any) -> None:
iface = self._iface()
iface.handle_command(
"set_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"name": "MPN",
"value": "OLD",
},
)
result = iface.handle_command(
"set_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"name": "MPN",
"value": "NEW",
},
)
assert result["success"] is True
assert result["updated"]["propertiesUpdated"]["MPN"] == "NEW"
def test_set_with_visible_position(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"set_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"name": "MPN",
"value": "RC0603FR-0710KL",
"x": 70.0,
"y": 65.0,
"hide": False,
"fontSize": 0.85,
},
)
assert result["success"] is True
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
mpn = get_result["fields"]["MPN"]
assert mpn["x"] == pytest.approx(70.0)
assert mpn["y"] == pytest.approx(65.0)
def test_set_missing_name_fails(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"set_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"value": "RC0603FR-0710KL",
},
)
assert result["success"] is False
def test_set_missing_value_fails(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"set_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"name": "MPN",
},
)
assert result["success"] is False
def test_set_can_modify_built_in_value_field(self, sch_with_r1: Any) -> None:
"""Built-in fields can be re-targeted via set_..._property too."""
iface = self._iface()
result = iface.handle_command(
"set_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"name": "Value",
"value": "22k",
},
)
assert result["success"] is True
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
assert get_result["fields"]["Value"]["value"] == "22k"
@pytest.mark.integration
class TestRemoveSchematicComponentProperty:
"""Tests for the convenience `remove_schematic_component_property` tool."""
@pytest.fixture
def sch_with_r1(self, tmp_path: Any) -> Any:
return _make_test_schematic(tmp_path, PLACED_RESISTOR_BLOCK)
def _iface(self) -> Any:
from kicad_interface import KiCADInterface
return KiCADInterface()
def test_remove_existing_custom_property(self, sch_with_r1: Any) -> None:
iface = self._iface()
iface.handle_command(
"set_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"name": "MPN",
"value": "RC0603FR-0710KL",
},
)
result = iface.handle_command(
"remove_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"name": "MPN",
},
)
assert result["success"] is True
assert "MPN" in result["updated"]["propertiesRemoved"]
get_result = iface.handle_command(
"get_schematic_component",
{"schematicPath": str(sch_with_r1), "reference": "R1"},
)
assert "MPN" not in get_result["fields"]
def test_remove_built_in_field_rejected(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"remove_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"name": "Reference",
},
)
assert result["success"] is False
def test_remove_missing_property_succeeds_with_no_change(self, sch_with_r1: Any) -> None:
iface = self._iface()
result = iface.handle_command(
"remove_schematic_component_property",
{
"schematicPath": str(sch_with_r1),
"reference": "R1",
"name": "NeverExisted",
},
)
assert result["success"] is True
assert "propertiesRemoved" not in result["updated"]