feat: datasheet tools + fix missing delete_schematic_component

New tools - datasheet:
- get_datasheet_url: construct LCSC datasheet PDF URL + product page URL
  without any API key (URL schema: https://www.lcsc.com/datasheet/<C#>.pdf)
- enrich_datasheets: scan .kicad_sch, write LCSC datasheet URL into every
  symbol that has an LCSC property but an empty Datasheet field; supports
  dry_run=true for preview; text-based implementation (no skip writes)
  Implementation: python/commands/datasheet_manager.py

New tool - schematic:
- delete_schematic_component: remove a placed symbol from a .kicad_sch file
  by reference designator (e.g. R1, U3)

Bug fix - delete_schematic_component (two separate root causes):
1. No MCP tool named delete_schematic_component was registered at all.
   Any delete-symbol request fell through to the PCB-only delete_component
   tool which searches pcbnew.BOARD and always returned 'Component not found'
   for schematic symbols.
2. component_schematic.py::remove_component() still used skip for writes.
   PR #40 rewrote DynamicSymbolLoader (add path) to avoid skip-induced
   schematic corruption, but the delete path was not touched by that PR.

Fix: _handle_delete_schematic_component in kicad_interface.py uses direct
text manipulation with parenthesis-depth tracking (same technique as PR #40),
bypassing component_schematic.py entirely. Error message explicitly guides
users: 'use delete_component for PCB footprints'.

Files changed:
- python/commands/datasheet_manager.py (new)
- src/tools/datasheet.ts (new)
- python/kicad_interface.py: 3 new handlers + dispatch entries
- src/tools/schematic.ts: delete_schematic_component tool
- src/server.ts: registerDatasheetTools import + call
- src/tools/index.ts: export registerDatasheetTools
- CHANGELOG.md: document all above
This commit is contained in:
Tom
2026-02-28 13:48:35 +01:00
parent 76503b144c
commit 1ba86f7769
7 changed files with 985 additions and 283 deletions

View File

@@ -0,0 +1,289 @@
"""
Datasheet Manager for KiCAD MCP Server
Enriches KiCAD schematic symbols with datasheet URLs derived from LCSC part numbers.
Uses direct text manipulation (like dynamic_symbol_loader.py) to avoid
skip-library-induced schematic corruption.
URL schema: https://www.lcsc.com/datasheet/{LCSC#}.pdf
No API key required.
"""
import re
import logging
from pathlib import Path
from typing import Dict, List, Optional
logger = logging.getLogger("kicad_interface")
LCSC_DATASHEET_URL = "https://www.lcsc.com/datasheet/{lcsc}.pdf"
LCSC_PRODUCT_URL = "https://www.lcsc.com/product-detail/{lcsc}.html"
# Values treated as "empty" datasheet
EMPTY_DATASHEET_VALUES = {"~", "", "~{DATASHEET}"}
class DatasheetManager:
"""
Enriches KiCAD schematics with LCSC datasheet URLs.
Reads .kicad_sch files, finds symbol instances that have an LCSC property
but an empty Datasheet property, and fills in the LCSC datasheet URL.
"""
@staticmethod
def _normalize_lcsc(lcsc: str) -> Optional[str]:
"""
Normalize LCSC number to standard format 'C123456'.
Accepts: 'C123456', '123456', 'c123456'
Returns: 'C123456' or None if invalid
"""
lcsc = lcsc.strip()
if not lcsc:
return None
# Remove leading C/c
without_prefix = lcsc.lstrip("Cc")
if without_prefix.isdigit():
return f"C{without_prefix}"
return None
@staticmethod
def _find_lib_symbols_range(lines: List[str]):
"""
Find the line range of the (lib_symbols ...) section.
Returns (start, end) line indices or (None, None) if not found.
These lines must be excluded from symbol-instance processing.
"""
lib_sym_start = None
lib_sym_end = None
depth = 0
for i, line in enumerate(lines):
if "(lib_symbols" in line and lib_sym_start is None:
lib_sym_start = i
depth = 0
for ch in line:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
elif lib_sym_start is not None and lib_sym_end is None:
for ch in line:
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
lib_sym_end = i
break
return lib_sym_start, lib_sym_end
@staticmethod
def _process_symbol_block(
lines: List[str], block_start: int, block_end: int
) -> Optional[Dict]:
"""
Extract LCSC and Datasheet info from a placed symbol block.
Returns dict with:
- lcsc: normalized LCSC number or None
- datasheet_line: line index of Datasheet property or None
- datasheet_value: current Datasheet value or None
"""
lcsc_value = None
datasheet_line_idx = None
datasheet_current = None
for k in range(block_start, block_end + 1):
line = lines[k]
lcsc_match = re.search(r'\(property\s+"LCSC"\s+"([^"]*)"', line)
if lcsc_match:
lcsc_value = lcsc_match.group(1)
ds_match = re.search(r'\(property\s+"Datasheet"\s+"([^"]*)"', line)
if ds_match:
datasheet_line_idx = k
datasheet_current = ds_match.group(1)
return {
"lcsc": lcsc_value,
"datasheet_line": datasheet_line_idx,
"datasheet_value": datasheet_current,
}
def enrich_schematic(
self, schematic_path: Path, dry_run: bool = False
) -> Dict:
"""
Scan a .kicad_sch file and fill in missing LCSC datasheet URLs.
For each placed symbol that has:
- (property "LCSC" "C123456") set
- (property "Datasheet" "~") or empty
Sets:
- (property "Datasheet" "https://www.lcsc.com/datasheet/C123456.pdf")
Args:
schematic_path: Path to .kicad_sch file
dry_run: If True, return what would be changed without writing
Returns:
{
"success": True,
"updated": <count>,
"already_set": <count>,
"no_lcsc": <count>,
"no_datasheet_field": <count>,
"details": [{"reference": "...", "lcsc": "...", "url": "..."}]
}
"""
schematic_path = Path(schematic_path)
if not schematic_path.exists():
return {
"success": False,
"message": f"Schematic not found: {schematic_path}",
}
with open(schematic_path, "r", encoding="utf-8") as f:
content = f.read()
lines = content.split("\n")
new_lines = list(lines)
lib_sym_start, lib_sym_end = self._find_lib_symbols_range(lines)
updated = 0
already_set = 0
no_lcsc = 0
no_datasheet_field = 0
details = []
i = 0
while i < len(new_lines):
line = new_lines[i]
# Skip lib_symbols section
if lib_sym_start is not None and lib_sym_end is not None:
if lib_sym_start <= i <= lib_sym_end:
i += 1
continue
# Detect placed symbol: (symbol (lib_id "...")
if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", line):
block_start = i
block_depth = 0
for ch in line:
if ch == "(":
block_depth += 1
elif ch == ")":
block_depth -= 1
j = i + 1
while j < len(new_lines) and block_depth > 0:
for ch in new_lines[j]:
if ch == "(":
block_depth += 1
elif ch == ")":
block_depth -= 1
if block_depth > 0:
j += 1
else:
break
block_end = j
info = self._process_symbol_block(new_lines, block_start, block_end)
raw_lcsc = info["lcsc"]
ds_line = info["datasheet_line"]
ds_value = info["datasheet_value"]
# Extract reference for reporting
ref_match = None
for k in range(block_start, block_end + 1):
m = re.search(r'\(property\s+"Reference"\s+"([^"]+)"', new_lines[k])
if m:
ref_match = m.group(1)
break
reference = ref_match or "?"
if not raw_lcsc:
no_lcsc += 1
elif ds_line is None:
no_datasheet_field += 1
logger.warning(
f"Symbol {reference} has LCSC={raw_lcsc} but no Datasheet property"
)
else:
lcsc_norm = self._normalize_lcsc(raw_lcsc)
if not lcsc_norm:
no_lcsc += 1
elif ds_value not in EMPTY_DATASHEET_VALUES:
already_set += 1
logger.debug(
f"Symbol {reference}: Datasheet already set to {ds_value!r}"
)
else:
url = LCSC_DATASHEET_URL.format(lcsc=lcsc_norm)
if not dry_run:
new_lines[ds_line] = re.sub(
r'(property\s+"Datasheet"\s+)"[^"]*"',
f'\\1"{url}"',
new_lines[ds_line],
)
updated += 1
details.append(
{
"reference": reference,
"lcsc": lcsc_norm,
"url": url,
"dry_run": dry_run,
}
)
logger.info(
f"{'[DRY RUN] ' if dry_run else ''}Set Datasheet for "
f"{reference} ({lcsc_norm}): {url}"
)
i = block_end + 1
continue
i += 1
if not dry_run and updated > 0:
with open(schematic_path, "w", encoding="utf-8") as f:
f.write("\n".join(new_lines))
logger.info(
f"Saved {schematic_path.name}: {updated} datasheet URLs written"
)
return {
"success": True,
"updated": updated,
"already_set": already_set,
"no_lcsc": no_lcsc,
"no_datasheet_field": no_datasheet_field,
"dry_run": dry_run,
"details": details,
"schematic": str(schematic_path),
}
def get_datasheet_url(self, lcsc: str) -> Optional[str]:
"""
Return the LCSC datasheet URL for a given LCSC number.
No network request pure URL construction.
"""
norm = self._normalize_lcsc(lcsc)
if norm:
return LCSC_DATASHEET_URL.format(lcsc=norm)
return None
def get_product_url(self, lcsc: str) -> Optional[str]:
"""Return the LCSC product page URL."""
norm = self._normalize_lcsc(lcsc)
if norm:
return LCSC_PRODUCT_URL.format(lcsc=norm)
return None

View File

@@ -223,6 +223,7 @@ try:
from commands.library_symbol import SymbolLibraryManager, SymbolLibraryCommands
from commands.jlcpcb import JLCPCBClient, test_jlcpcb_connection
from commands.jlcpcb_parts import JLCPCBPartsManager
from commands.datasheet_manager import DatasheetManager
logger.info("Successfully imported all command handlers")
except ImportError as e:
@@ -357,10 +358,14 @@ class KiCADInterface:
"get_jlcpcb_part": self._handle_get_jlcpcb_part,
"get_jlcpcb_database_stats": self._handle_get_jlcpcb_database_stats,
"suggest_jlcpcb_alternatives": self._handle_suggest_jlcpcb_alternatives,
# Datasheet commands
"enrich_datasheets": self._handle_enrich_datasheets,
"get_datasheet_url": self._handle_get_datasheet_url,
# Schematic commands
"create_schematic": self._handle_create_schematic,
"load_schematic": self._handle_load_schematic,
"add_schematic_component": self._handle_add_schematic_component,
"delete_schematic_component": self._handle_delete_schematic_component,
"add_schematic_wire": self._handle_add_schematic_wire,
"add_schematic_connection": self._handle_add_schematic_connection,
"add_schematic_net_label": self._handle_add_schematic_net_label,
@@ -606,6 +611,97 @@ class KiCADInterface:
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_delete_schematic_component(self, params):
"""Remove a placed symbol from a schematic using text-based manipulation (no skip writes)"""
logger.info("Deleting schematic component")
try:
from pathlib import Path
import re
schematic_path = params.get("schematicPath")
reference = params.get("reference")
if not schematic_path:
return {"success": False, "message": "schematicPath is required"}
if not reference:
return {"success": False, "message": "reference is required"}
sch_file = Path(schematic_path)
if not sch_file.exists():
return {"success": False, "message": f"Schematic not found: {schematic_path}"}
with open(sch_file, "r", encoding="utf-8") as f:
lines = f.read().split("\n")
# Find lib_symbols range to skip it
lib_sym_start, lib_sym_end = None, None
depth = 0
for i, line in enumerate(lines):
if "(lib_symbols" in line and lib_sym_start is None:
lib_sym_start = i
depth = sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")")
elif lib_sym_start is not None and lib_sym_end is None:
depth += sum(1 for c in line if c == "(") - sum(1 for c in line if c == ")")
if depth == 0:
lib_sym_end = i
break
# Find the placed symbol block matching the reference
block_start = None
block_end = None
i = 0
while i < len(lines):
# Skip lib_symbols
if lib_sym_start is not None and lib_sym_end is not None:
if lib_sym_start <= i <= lib_sym_end:
i += 1
continue
if re.match(r"\s*\(symbol\s+\(lib_id\s+\"", lines[i]):
b_start = i
b_depth = sum(1 for c in lines[i] if c == "(") - sum(1 for c in lines[i] if c == ")")
j = i + 1
while j < len(lines) and b_depth > 0:
b_depth += sum(1 for c in lines[j] if c == "(") - sum(1 for c in lines[j] if c == ")")
j += 1
b_end = j - 1
# Check if this block contains the target reference
block_text = "\n".join(lines[b_start:b_end + 1])
# Match: (property "Reference" "R1" ...
if re.search(r'\(property\s+"Reference"\s+"' + re.escape(reference) + r'"', block_text):
block_start = b_start
block_end = b_end
break
i = b_end + 1
continue
i += 1
if block_start is None:
return {
"success": False,
"message": f"Component '{reference}' not found in schematic (note: this tool removes schematic symbols, use delete_component for PCB footprints)",
}
# Remove the block (and any trailing blank line)
del lines[block_start:block_end + 1]
if block_start < len(lines) and lines[block_start].strip() == "":
del lines[block_start]
with open(sch_file, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
logger.info(f"Deleted schematic component {reference} from {sch_file.name}")
return {"success": True, "reference": reference, "schematic": str(sch_file)}
except Exception as e:
logger.error(f"Error deleting schematic component: {e}")
import traceback
logger.error(traceback.format_exc())
return {"success": False, "message": str(e)}
def _handle_add_schematic_wire(self, params):
"""Add a wire to a schematic using WireManager"""
logger.info("Adding wire to schematic")
@@ -1830,6 +1926,43 @@ class KiCADInterface:
"message": f"Failed to suggest alternatives: {str(e)}",
}
def _handle_enrich_datasheets(self, params):
"""Enrich schematic Datasheet fields from LCSC numbers"""
try:
from pathlib import Path
schematic_path = params.get("schematic_path")
if not schematic_path:
return {"success": False, "message": "Missing schematic_path parameter"}
dry_run = params.get("dry_run", False)
manager = DatasheetManager()
return manager.enrich_schematic(Path(schematic_path), dry_run=dry_run)
except Exception as e:
logger.error(f"Error enriching datasheets: {e}", exc_info=True)
return {"success": False, "message": f"Failed to enrich datasheets: {str(e)}"}
def _handle_get_datasheet_url(self, params):
"""Return LCSC datasheet and product URLs for a part number"""
try:
lcsc = params.get("lcsc", "")
if not lcsc:
return {"success": False, "message": "Missing lcsc parameter"}
manager = DatasheetManager()
datasheet_url = manager.get_datasheet_url(lcsc)
product_url = manager.get_product_url(lcsc)
if not datasheet_url:
return {"success": False, "message": f"Invalid LCSC number: {lcsc}"}
norm = manager._normalize_lcsc(lcsc)
return {
"success": True,
"lcsc": norm,
"datasheet_url": datasheet_url,
"product_url": product_url,
}
except Exception as e:
logger.error(f"Error getting datasheet URL: {e}", exc_info=True)
return {"success": False, "message": f"Failed to get datasheet URL: {str(e)}"}
def main():
"""Main entry point"""