feat: Add complete JLCPCB API integration with dual-mode support

Implements full JLCPCB parts catalog integration alongside existing local library search,
giving users two complementary approaches for component selection:

1. Local Symbol Libraries (PR #25)
   - Search JLCPCB libraries installed via KiCad PCM
   - Pre-configured symbols with footprints
   - Works offline, no API needed

2. JLCPCB API Integration (NEW)
   - Complete 100k+ parts catalog access
   - Real-time pricing and stock information
   - Basic/Extended library type identification
   - Cost optimization and alternative suggestions
   - Package-to-footprint mapping

New Features:
- download_jlcpcb_database: Download complete parts catalog to local SQLite DB
- search_jlcpcb_parts: Parametric search with pricing, stock, library type filters
- get_jlcpcb_part: Detailed part info with price breaks and footprint suggestions
- get_jlcpcb_database_stats: Database statistics and status
- suggest_jlcpcb_alternatives: Find cheaper/available alternatives

Implementation:
- Python API client (commands/jlcpcb.py) - JLCPCB API authentication and data fetching
- Parts database manager (commands/jlcpcb_parts.py) - SQLite storage and search
- TypeScript MCP tools (tools/jlcpcb-api.ts) - User-facing tool definitions
- Comprehensive documentation (docs/JLCPCB_USAGE_GUIDE.md)

Database Features:
- ~100k parts with descriptions, pricing, stock levels
- Full-text search on descriptions and part numbers
- Parametric filtering (category, package, manufacturer, library type)
- Package-to-footprint mapping for KiCad
- Intelligent alternative suggestions

Setup Requirements:
- JLCPCB_API_KEY and JLCPCB_API_SECRET environment variables
- One-time database download (~5-10 minutes, 42MB)
- requests library (already in requirements.txt)

Benefits:
- Cost optimization (identify Basic parts = free assembly)
- Real-time stock checking
- Complete catalog access
- Works offline after initial download
- Complements local library search

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
KiCAD MCP Bot
2025-12-31 11:04:08 -05:00
parent 0227dd48d2
commit 4c6514eb6b
6 changed files with 1590 additions and 0 deletions

View File

@@ -213,6 +213,8 @@ try:
from commands.library_schematic import LibraryManager as SchematicLibraryManager
from commands.library import LibraryManager as FootprintLibraryManager, LibraryCommands
from commands.library_symbol import SymbolLibraryManager, SymbolLibraryCommands
from commands.jlcpcb import JLCPCBClient, test_jlcpcb_connection
from commands.jlcpcb_parts import JLCPCBPartsManager
logger.info("Successfully imported all command handlers")
except ImportError as e:
logger.error(f"Failed to import command handlers: {e}")
@@ -262,6 +264,10 @@ class KiCADInterface:
# Initialize symbol library manager (for searching local KiCad symbol libraries)
self.symbol_library_commands = SymbolLibraryCommands()
# Initialize JLCPCB API integration
self.jlcpcb_client = JLCPCBClient()
self.jlcpcb_parts = JLCPCBPartsManager()
# Schematic-related classes don't need board reference
# as they operate directly on schematic files
@@ -333,6 +339,13 @@ class KiCADInterface:
"list_library_symbols": self.symbol_library_commands.list_library_symbols,
"get_symbol_info": self.symbol_library_commands.get_symbol_info,
# JLCPCB API commands (complete parts catalog via API)
"download_jlcpcb_database": self._handle_download_jlcpcb_database,
"search_jlcpcb_parts": self._handle_search_jlcpcb_parts,
"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,
# Schematic commands
"create_schematic": self._handle_create_schematic,
"load_schematic": self._handle_load_schematic,
@@ -1476,6 +1489,198 @@ class KiCADInterface:
logger.error(f"Error saving board via IPC: {e}")
return {"success": False, "message": str(e)}
# JLCPCB API handlers
def _handle_download_jlcpcb_database(self, params):
"""Download JLCPCB parts database from API"""
try:
force = params.get('force', False)
# Check if database exists
import os
stats = self.jlcpcb_parts.get_database_stats()
if stats['total_parts'] > 0 and not force:
return {
"success": False,
"message": "Database already exists. Use force=true to re-download.",
"stats": stats
}
logger.info("Downloading JLCPCB parts database...")
# Download parts from API
parts = self.jlcpcb_client.download_full_database(
callback=lambda page, total, msg: logger.info(f"Page {page}: {msg}")
)
# Import into database
logger.info(f"Importing {len(parts)} parts into database...")
self.jlcpcb_parts.import_parts(
parts,
progress_callback=lambda curr, total, msg: logger.info(msg)
)
# Get final stats
stats = self.jlcpcb_parts.get_database_stats()
# Calculate database size
db_size_mb = os.path.getsize(self.jlcpcb_parts.db_path) / (1024 * 1024)
return {
"success": True,
"total_parts": stats['total_parts'],
"basic_parts": stats['basic_parts'],
"extended_parts": stats['extended_parts'],
"db_size_mb": round(db_size_mb, 2),
"db_path": stats['db_path']
}
except Exception as e:
logger.error(f"Error downloading JLCPCB database: {e}", exc_info=True)
return {
"success": False,
"message": f"Failed to download database: {str(e)}"
}
def _handle_search_jlcpcb_parts(self, params):
"""Search JLCPCB parts database"""
try:
query = params.get('query')
category = params.get('category')
package = params.get('package')
library_type = params.get('library_type', 'All')
manufacturer = params.get('manufacturer')
in_stock = params.get('in_stock', True)
limit = params.get('limit', 20)
# Adjust library_type filter
if library_type == 'All':
library_type = None
parts = self.jlcpcb_parts.search_parts(
query=query,
category=category,
package=package,
library_type=library_type,
manufacturer=manufacturer,
in_stock=in_stock,
limit=limit
)
# Add price breaks and footprints to each part
for part in parts:
if part.get('price_json'):
try:
part['price_breaks'] = json.loads(part['price_json'])
except:
part['price_breaks'] = []
return {
"success": True,
"parts": parts,
"count": len(parts)
}
except Exception as e:
logger.error(f"Error searching JLCPCB parts: {e}", exc_info=True)
return {
"success": False,
"message": f"Search failed: {str(e)}"
}
def _handle_get_jlcpcb_part(self, params):
"""Get detailed information for a specific JLCPCB part"""
try:
lcsc_number = params.get('lcsc_number')
if not lcsc_number:
return {
"success": False,
"message": "Missing lcsc_number parameter"
}
part = self.jlcpcb_parts.get_part_info(lcsc_number)
if not part:
return {
"success": False,
"message": f"Part not found: {lcsc_number}"
}
# Get suggested KiCAD footprints
footprints = self.jlcpcb_parts.map_package_to_footprint(part.get('package', ''))
return {
"success": True,
"part": part,
"footprints": footprints
}
except Exception as e:
logger.error(f"Error getting JLCPCB part: {e}", exc_info=True)
return {
"success": False,
"message": f"Failed to get part info: {str(e)}"
}
def _handle_get_jlcpcb_database_stats(self, params):
"""Get statistics about JLCPCB database"""
try:
stats = self.jlcpcb_parts.get_database_stats()
return {
"success": True,
"stats": stats
}
except Exception as e:
logger.error(f"Error getting database stats: {e}", exc_info=True)
return {
"success": False,
"message": f"Failed to get stats: {str(e)}"
}
def _handle_suggest_jlcpcb_alternatives(self, params):
"""Suggest alternative JLCPCB parts"""
try:
lcsc_number = params.get('lcsc_number')
limit = params.get('limit', 5)
if not lcsc_number:
return {
"success": False,
"message": "Missing lcsc_number parameter"
}
# Get original part for price comparison
original_part = self.jlcpcb_parts.get_part_info(lcsc_number)
reference_price = None
if original_part and original_part.get('price_breaks'):
try:
reference_price = float(original_part['price_breaks'][0].get('price', 0))
except:
pass
alternatives = self.jlcpcb_parts.suggest_alternatives(lcsc_number, limit)
# Add price breaks to alternatives
for part in alternatives:
if part.get('price_json'):
try:
part['price_breaks'] = json.loads(part['price_json'])
except:
part['price_breaks'] = []
return {
"success": True,
"alternatives": alternatives,
"reference_price": reference_price
}
except Exception as e:
logger.error(f"Error suggesting alternatives: {e}", exc_info=True)
return {
"success": False,
"message": f"Failed to suggest alternatives: {str(e)}"
}
def main():
"""Main entry point"""