Merge pull request #123 from vdawger/feat/schematic-component-custom-properties
feat: support arbitrary custom properties on schematic components
This commit is contained in:
57
CHANGELOG.md
57
CHANGELOG.md
@@ -2,6 +2,63 @@
|
||||
|
||||
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
|
||||
|
||||
### Merged: PR #57 (Kletternaut/demo/rpiCSI-videotest → main)
|
||||
|
||||
@@ -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
|
||||
- `delete_schematic_component` - Remove component
|
||||
- `edit_schematic_component` - Edit properties and fields
|
||||
- `get_schematic_component` - Get component info with field positions
|
||||
- `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
|
||||
- `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
|
||||
- `move_schematic_component` - Reposition component
|
||||
- `rotate_schematic_component` - Rotate component
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
Added in: v2.1.0, expanded in v2.2.0-v2.2.3
|
||||
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
|
||||
|
||||
@@ -33,20 +33,100 @@ Remove a placed symbol from a KiCAD schematic (.kicad_sch). This removes the sym
|
||||
|
||||
### 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 |
|
||||
| -------------- | ------ | -------- | -------------------------------------------------------------------------------------------------------- |
|
||||
| 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}}) |
|
||||
Use this tool to:
|
||||
|
||||
- assign or update the footprint, value, or reference designator,
|
||||
- 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`, `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 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 |
|
||||
| ------------- | ------ | -------- | -------------------------------------------- |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# KiCAD MCP Server - Complete Tool Inventory
|
||||
|
||||
**Version:** 2.2.3
|
||||
**Total Tools:** 122 (18 direct + 65 routed + 4 router + 35 additional)
|
||||
**Last Updated:** 2026-03-21
|
||||
**Total Tools:** 124 (18 direct + 65 routed + 4 router + 37 additional)
|
||||
**Last Updated:** 2026-04-20
|
||||
|
||||
## How Tools Are Organized
|
||||
|
||||
@@ -130,22 +130,24 @@ _Source: `src/tools/export.ts`_
|
||||
|
||||
---
|
||||
|
||||
## Schematic (27 tools)
|
||||
## Schematic (29 tools)
|
||||
|
||||
_Source: `src/tools/schematic.ts`_
|
||||
|
||||
### Component Operations
|
||||
|
||||
| Tool | Description | Access |
|
||||
| ---------------------------- | ------------------------------------------------------- | ------------------ |
|
||||
| `add_schematic_component` | Add component to schematic (symbol from library) | Direct |
|
||||
| `delete_schematic_component` | Remove component from schematic | Additional |
|
||||
| `edit_schematic_component` | Edit component properties (footprint, value, reference) | Additional |
|
||||
| `get_schematic_component` | Get component info with field positions | Additional |
|
||||
| `list_schematic_components` | List all components in schematic | 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 |
|
||||
| Tool | Description | Access |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------ |
|
||||
| `add_schematic_component` | Add component to schematic (symbol from library) | Direct |
|
||||
| `delete_schematic_component` | Remove component from schematic | Additional |
|
||||
| `edit_schematic_component` | Edit footprint, value, reference, label positions, and **arbitrary custom properties** (MPN, Manufacturer, DigiKey, …) | Additional |
|
||||
| `set_schematic_component_property` | Add or update a single custom property (BOM/sourcing field) on a component | Additional |
|
||||
| `remove_schematic_component_property` | Delete a single custom property from a component | Additional |
|
||||
| `get_schematic_component` | Get component info: built-in fields + all custom properties + label positions | Additional |
|
||||
| `list_schematic_components` | List all components in schematic | 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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
// ------------------------------------------------------
|
||||
|
||||
@@ -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(
|
||||
"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.
|
||||
Use this tool to:
|
||||
• assign or update the footprint, value, or reference designator,
|
||||
• 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"),
|
||||
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(
|
||||
'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: {
|
||||
schematicPath: string;
|
||||
@@ -181,17 +233,41 @@ Note: operates on .kicad_sch files only. To modify a PCB footprint use edit_comp
|
||||
value?: string;
|
||||
newReference?: string;
|
||||
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);
|
||||
if (result.success) {
|
||||
const changes = Object.entries(result.updated ?? {})
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(", ");
|
||||
const updated = result.updated ?? {};
|
||||
const summaryParts: string[] = [];
|
||||
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 {
|
||||
content: [
|
||||
{
|
||||
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
|
||||
server.tool(
|
||||
"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"),
|
||||
reference: z.string().describe("Component reference designator (e.g. R1, U1)"),
|
||||
|
||||
659
tests/test_schematic_component_properties.py
Normal file
659
tests/test_schematic_component_properties.py
Normal 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"]
|
||||
Reference in New Issue
Block a user