diff --git a/docs/JLCPCB_USAGE_GUIDE.md b/docs/JLCPCB_USAGE_GUIDE.md new file mode 100644 index 0000000..1aaf4ac --- /dev/null +++ b/docs/JLCPCB_USAGE_GUIDE.md @@ -0,0 +1,472 @@ +# JLCPCB Integration Guide + +The KiCAD MCP Server provides **two complementary approaches** for working with JLCPCB parts: + +1. **Local Symbol Libraries** - Search JLCPCB libraries installed via KiCad PCM +2. **JLCPCB API Integration** - Access the complete 100k+ parts catalog with real-time pricing + +Both approaches can be used together to give you maximum flexibility. + +--- + +## Approach 1: Local Symbol Libraries (Recommended for Getting Started) + +### What It Does +- Searches symbol libraries you've installed via KiCad's Plugin and Content Manager (PCM) +- Works with community JLCPCB libraries like `JLCPCB-KiCad-Library` +- No API credentials needed +- Works offline +- Symbols already have LCSC IDs and footprints configured + +### Setup + +1. **Install JLCPCB Libraries via KiCad PCM:** + - Open KiCad → Tools → Plugin and Content Manager + - Search for "JLCPCB" or "JLC" + - Install libraries like: + - `JLCPCB-KiCad-Library` (community maintained) + - `EDA_MCP` (contains common JLCPCB parts) + - Any other JLCPCB-compatible libraries + +2. **Verify Installation:** + The libraries should appear in KiCad's symbol library table. + +### Usage Examples + +#### Search for Components +``` +search_symbols({ + query: "ESP32", + library: "JLCPCB" // Filter to JLCPCB libraries only +}) +``` + +Returns: +``` +Found 12 symbols matching "ESP32": + +PCM_JLCPCB-MCUs:ESP32-C3 | LCSC: C2934196 | ESP32-C3 RISC-V WiFi/BLE SoC +PCM_JLCPCB-MCUs:ESP32-S2 | LCSC: C701342 | ESP32-S2 WiFi SoC +... +``` + +#### Search by LCSC ID +``` +search_symbols({ + query: "C2934196" // Direct LCSC ID search +}) +``` + +#### Get Symbol Details +``` +get_symbol_info({ + symbol: "PCM_JLCPCB-MCUs:ESP32-C3" +}) +``` + +Returns: +``` +Symbol: PCM_JLCPCB-MCUs:ESP32-C3 +Description: ESP32-C3 RISC-V WiFi/BLE SoC +LCSC: C2934196 +Manufacturer: Espressif +MPN: ESP32-C3-WROOM-02 +Footprint: Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm +Class: Extended +``` + +### Advantages +- ✅ No API credentials required +- ✅ Works offline after library installation +- ✅ Symbols pre-configured with correct footprints +- ✅ Community-maintained and curated +- ✅ Instant availability + +### Limitations +- ❌ Only parts in installed libraries (typically 1k-10k parts) +- ❌ No real-time pricing or stock information +- ❌ Requires manual library updates via PCM + +--- + +## Approach 2: JLCPCB API Integration (For Complete Catalog Access) + +### What It Does +- Downloads the **complete JLCPCB parts catalog** (~100k+ parts) +- Provides **real-time pricing and stock information** +- Automatic **Basic vs Extended** library type identification (Basic = free assembly) +- Smart suggestions for cheaper/in-stock alternatives +- Package-to-footprint mapping for KiCad + +### Setup + +#### 1. Get JLCPCB API Credentials + +Visit [JLCPCB](https://jlcpcb.com/) and get your API credentials: +1. Log in to your JLCPCB account +2. Go to: **Account → API Management** +3. Click "Create API Key" +4. Save your `appKey` and `appSecret` + +#### 2. Configure Environment Variables + +Add to your shell profile (`~/.bashrc`, `~/.zshrc`, or `~/.profile`): + +```bash +export JLCPCB_API_KEY="your_app_key_here" +export JLCPCB_API_SECRET="your_app_secret_here" +``` + +Or create a `.env` file in the project root: + +``` +JLCPCB_API_KEY=your_app_key_here +JLCPCB_API_SECRET=your_app_secret_here +``` + +#### 3. Download the Parts Database + +**One-time setup** (takes 5-10 minutes): + +``` +download_jlcpcb_database({ force: false }) +``` + +This downloads ~100k parts from JLCPCB and creates a local SQLite database (`data/jlcpcb_parts.db`). + +**Output:** +``` +✓ Successfully downloaded JLCPCB parts database + +Total parts: 108,523 +Basic parts: 2,856 (free assembly) +Extended parts: 105,667 ($3 setup fee each) +Database size: 42.3 MB +Database path: /home/user/KiCAD-MCP-Server/data/jlcpcb_parts.db +``` + +### Usage Examples + +#### Search for Parts with Specifications + +``` +search_jlcpcb_parts({ + query: "10k resistor", + package: "0603", + library_type: "Basic" // Only free-assembly parts +}) +``` + +**Returns:** +``` +Found 15 JLCPCB parts: + +C25804: RC0603FR-0710KL - 10kΩ ±1% 0.1W [Basic] - $0.002/ea (15000 in stock) +C58972: 0603WAF1002T5E - 10kΩ ±1% 0.1W [Basic] - $0.001/ea (50000 in stock) +C25744: RC0603FR-0710KP - 10kΩ ±1% 0.1W [Basic] - $0.002/ea (12000 in stock) +... + +💡 Basic parts have free assembly. Extended parts charge $3 setup fee per unique part. +``` + +#### Get Part Details with Pricing + +``` +get_jlcpcb_part({ + lcsc_number: "C58972" +}) +``` + +**Returns:** +``` +LCSC: C58972 +MFR Part: 0603WAF1002T5E +Manufacturer: UNI-ROYAL +Category: Resistors / Chip Resistor - Surface Mount +Package: 0603 +Description: 10kΩ ±1% 0.1W Thick Film Resistors +Library Type: Basic (Free assembly!) +Stock: 50000 + +Price Breaks: + 1+: $0.0010/ea + 10+: $0.0009/ea + 100+: $0.0008/ea + 1000+: $0.0007/ea + +Suggested KiCAD Footprints: + - Resistor_SMD:R_0603_1608Metric + - Capacitor_SMD:C_0603_1608Metric + - LED_SMD:LED_0603_1608Metric +``` + +#### Find Cheaper Alternatives + +``` +suggest_jlcpcb_alternatives({ + lcsc_number: "C25804", + limit: 5 +}) +``` + +**Returns:** +``` +Alternative parts for C25804: + +1. C58972: 0603WAF1002T5E [Basic] - $0.001/ea (50% cheaper) + 10kΩ ±1% 0.1W Thick Film Resistors + Stock: 50000 + +2. C22790: 0603WAF1002T - [Basic] - $0.0011/ea (45% cheaper) + 10kΩ ±1% 0.1W Thick Film Resistors + Stock: 35000 +... +``` + +#### Search by Category and Package + +``` +search_jlcpcb_parts({ + category: "Microcontrollers", + package: "QFN-32", + manufacturer: "STM", + in_stock: true, + limit: 10 +}) +``` + +#### Get Database Statistics + +``` +get_jlcpcb_database_stats({}) +``` + +**Returns:** +``` +JLCPCB Database Statistics: + +Total parts: 108,523 +Basic parts: 2,856 (free assembly) +Extended parts: 105,667 ($3 setup fee each) +In stock: 95,432 +Database path: /home/user/KiCAD-MCP-Server/data/jlcpcb_parts.db +``` + +### Advantages +- ✅ Complete JLCPCB catalog (100k+ parts) +- ✅ Real-time pricing and stock data +- ✅ Automatic Basic/Extended identification +- ✅ Cost optimization suggestions +- ✅ Works offline after initial download +- ✅ Fast parametric search + +### Limitations +- ❌ Requires API credentials +- ❌ Initial download takes 5-10 minutes +- ❌ Database needs periodic updates for latest parts +- ❌ Footprint mapping may need manual verification + +--- + +## Best Practices: Using Both Approaches Together + +### Workflow 1: Design with Known Components + +**Use Local Libraries:** +``` +1. search_symbols({ query: "STM32F103", library: "JLCPCB" }) +2. Select component from installed library +3. Component already has correct symbol + footprint + LCSC ID +``` + +**Why:** Faster, symbols are pre-configured and tested. + +### Workflow 2: Find Optimal Part for Cost + +**Use JLCPCB API:** +``` +1. search_jlcpcb_parts({ + query: "10k resistor", + package: "0603", + library_type: "Basic" + }) +2. Select cheapest Basic part +3. Use suggested footprint from API +``` + +**Why:** Ensures lowest cost and maximum stock availability. + +### Workflow 3: Explore Unknown Parts + +**Start with API, verify with Libraries:** +``` +1. search_jlcpcb_parts({ query: "ESP32", limit: 20 }) +2. Find interesting part (e.g., C2934196) +3. search_symbols({ query: "C2934196" }) +4. If found in library → use library symbol +5. If not found → use API footprint suggestion +``` + +**Why:** Combines discovery power of API with quality of curated libraries. + +--- + +## Cost Optimization Tips + +### 1. Prefer Basic Parts + +``` +search_jlcpcb_parts({ + query: "resistor 10k", + library_type: "Basic" // Free assembly! +}) +``` + +**Why:** Basic parts have **$0 assembly fee**. Extended parts charge **$3 per unique part**. + +### 2. Use Alternatives Tool + +``` +suggest_jlcpcb_alternatives({ lcsc_number: "C12345" }) +``` + +**Why:** Find cheaper, more available, or Basic alternatives automatically. + +### 3. Check Stock Levels + +Always filter `in_stock: true` to avoid ordering parts that are out of stock: + +``` +search_jlcpcb_parts({ + query: "capacitor", + in_stock: true // Only show available parts +}) +``` + +### 4. Calculate BOM Cost + +For each part in your design: +1. Use `get_jlcpcb_part()` to get price breaks +2. Sum up total cost based on order quantity +3. Check library_type count (each unique Extended part = $3 fee) + +--- + +## Updating the Database + +The JLCPCB parts database should be updated periodically to get latest parts and pricing. + +### Manual Update + +``` +download_jlcpcb_database({ force: true }) +``` + +This re-downloads the entire catalog and replaces the existing database. + +### Automatic Updates (Future) + +Future versions will support incremental updates that only fetch new/changed parts. + +--- + +## Troubleshooting + +### "JLCPCB API credentials not configured" + +**Solution:** Set environment variables: +```bash +export JLCPCB_API_KEY="your_key" +export JLCPCB_API_SECRET="your_secret" +``` + +### "Database not found or empty" + +**Solution:** Run: +``` +download_jlcpcb_database({ force: false }) +``` + +### "No symbols found" (Local Libraries) + +**Solution:** +1. Install JLCPCB libraries via KiCad PCM +2. Verify library is enabled in KiCad symbol library table +3. Restart KiCad MCP server + +### "Authentication failed" + +**Solution:** +1. Verify your API credentials are correct +2. Check JLCPCB account has API access enabled +3. Try regenerating API key/secret in JLCPCB dashboard + +--- + +## API vs Libraries: Quick Reference + +| Feature | Local Libraries | JLCPCB API | +|---------|----------------|------------| +| **Parts Count** | 1k-10k (installed) | 100k+ (complete catalog) | +| **Setup** | Install via PCM | API credentials + download | +| **Offline Use** | ✅ Yes | ✅ Yes (after download) | +| **Pricing** | ❌ No | ✅ Real-time | +| **Stock Info** | ❌ No | ✅ Real-time | +| **Footprints** | ✅ Pre-configured | ⚠️ Auto-suggested | +| **Updates** | Manual via PCM | Re-download database | +| **Speed** | ⚡ Instant | ⚡ Fast (local DB) | +| **Cost Optimization** | ❌ Manual | ✅ Automatic | + +--- + +## Example Workflows + +### Complete Design Flow + +``` +# 1. Find main MCU from local library (curated) +search_symbols({ query: "ESP32", library: "JLCPCB" }) +→ Use: PCM_JLCPCB-MCUs:ESP32-C3 + +# 2. Find passives optimized for cost (API) +search_jlcpcb_parts({ + query: "capacitor 10uF", + package: "0805", + library_type: "Basic" +}) +→ Use: C15850 ($0.004, Basic, 80k stock) + +# 3. Verify connector in library +search_symbols({ query: "USB-C" }) +→ Use library symbol if available + +# 4. Export BOM with LCSC numbers +# All components now have LCSC IDs for JLCPCB assembly! +``` + +--- + +## Resources + +- [JLCPCB API Documentation](https://jlcpcb.com/help/article/JLCPCB-API) +- [JLCPCB Parts Library](https://jlcpcb.com/parts) +- [KiCad Plugin and Content Manager](https://www.kicad.org/help/pcm/) +- [JLCPCB-KiCad-Library (GitHub)](https://github.com/pejot/JLC2KiCad_lib) + +--- + +## Summary + +**Use Local Libraries when:** +- Starting a new design with common components +- You want pre-configured, tested symbols +- Working offline +- Components are in installed libraries + +**Use JLCPCB API when:** +- Optimizing cost (find cheapest Basic parts) +- Checking real-time stock availability +- Exploring parts outside installed libraries +- Need complete catalog access + +**Best approach:** Use both! Start with local libraries for known components, then use API for cost optimization and finding alternatives. diff --git a/python/commands/jlcpcb.py b/python/commands/jlcpcb.py new file mode 100644 index 0000000..ad5553e --- /dev/null +++ b/python/commands/jlcpcb.py @@ -0,0 +1,247 @@ +""" +JLCPCB API client for fetching parts data + +Handles authentication and downloading the JLCPCB parts library +for integration with KiCAD component selection. +""" + +import os +import logging +import requests +import time +from typing import Optional, Dict, List, Callable +from pathlib import Path + +logger = logging.getLogger('kicad_interface') + + +class JLCPCBClient: + """ + Client for JLCPCB API + + Handles authentication and fetching the complete parts library + from JLCPCB's external API. + """ + + BASE_URL = "https://jlcpcb.com/external" + + def __init__(self, api_key: Optional[str] = None, api_secret: Optional[str] = None): + """ + Initialize JLCPCB API client + + Args: + api_key: JLCPCB API key (or reads from JLCPCB_API_KEY env var) + api_secret: JLCPCB API secret (or reads from JLCPCB_API_SECRET env var) + """ + self.api_key = api_key or os.getenv('JLCPCB_API_KEY') + self.api_secret = api_secret or os.getenv('JLCPCB_API_SECRET') + self.token = None + self.token_expiry = 0 + + if not self.api_key or not self.api_secret: + logger.warning("JLCPCB API credentials not found. Set JLCPCB_API_KEY and JLCPCB_API_SECRET environment variables.") + + def authenticate(self) -> str: + """ + Get authentication token from JLCPCB API + + Returns: + Authentication token + + Raises: + Exception if authentication fails + """ + if not self.api_key or not self.api_secret: + raise Exception("JLCPCB API credentials not configured. Please set JLCPCB_API_KEY and JLCPCB_API_SECRET environment variables.") + + # Check if we have a valid token + if self.token and time.time() < self.token_expiry: + return self.token + + logger.info("Authenticating with JLCPCB API...") + + try: + response = requests.post( + f"{self.BASE_URL}/genToken", + json={ + "appKey": self.api_key, + "appSecret": self.api_secret + }, + timeout=30 + ) + + response.raise_for_status() + data = response.json() + + if data.get('code') != 200: + raise Exception(f"Authentication failed: {data.get('msg', 'Unknown error')}") + + self.token = data['data']['token'] + # Tokens typically expire after 2 hours, we'll refresh after 1.5 hours to be safe + self.token_expiry = time.time() + (90 * 60) + + logger.info("Successfully authenticated with JLCPCB API") + return self.token + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to authenticate with JLCPCB API: {e}") + raise Exception(f"JLCPCB API authentication failed: {e}") + + def fetch_parts_page(self, last_key: Optional[str] = None) -> Dict: + """ + Fetch one page of parts from JLCPCB API + + Args: + last_key: Pagination key from previous response (None for first page) + + Returns: + Response dict with parts data and pagination info + """ + token = self.authenticate() + + headers = { + "externalApiToken": token + } + + payload = {} + if last_key: + payload["lastKey"] = last_key + + try: + response = requests.post( + f"{self.BASE_URL}/component/getComponentInfos", + headers=headers, + json=payload, + timeout=60 + ) + + response.raise_for_status() + data = response.json() + + if data.get('code') != 200: + raise Exception(f"API request failed: {data.get('msg', 'Unknown error')}") + + return data['data'] + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to fetch parts page: {e}") + raise Exception(f"JLCPCB API request failed: {e}") + + def download_full_database( + self, + callback: Optional[Callable[[int, int, str], None]] = None + ) -> List[Dict]: + """ + Download entire parts library from JLCPCB + + Args: + callback: Optional progress callback function(current_page, total_parts, status_msg) + + Returns: + List of all parts + """ + all_parts = [] + last_key = None + page = 0 + + logger.info("Starting full JLCPCB parts database download...") + + while True: + page += 1 + + try: + data = self.fetch_parts_page(last_key) + + parts = data.get('componentInfos', []) + all_parts.extend(parts) + + last_key = data.get('lastKey') + + if callback: + callback(page, len(all_parts), f"Downloaded {len(all_parts)} parts...") + else: + logger.info(f"Page {page}: Downloaded {len(all_parts)} parts so far...") + + # Check if there are more pages + if not last_key or len(parts) == 0: + break + + # Rate limiting - be nice to the API + time.sleep(0.5) + + except Exception as e: + logger.error(f"Error downloading parts at page {page}: {e}") + if len(all_parts) > 0: + logger.warning(f"Partial download available: {len(all_parts)} parts") + return all_parts + else: + raise + + logger.info(f"Download complete: {len(all_parts)} parts retrieved") + return all_parts + + def get_part_by_lcsc(self, lcsc_number: str) -> Optional[Dict]: + """ + Get detailed information for a specific LCSC part number + + Note: This uses the same endpoint as fetching parts, as JLCPCB doesn't + have a dedicated single-part endpoint. In practice, you should use + the local database after initial download. + + Args: + lcsc_number: LCSC part number (e.g., "C25804") + + Returns: + Part info dict or None if not found + """ + # For now, this would require searching through pages + # In practice, you'd use the local database + logger.warning("get_part_by_lcsc should use local database, not API") + return None + + +def test_jlcpcb_connection(api_key: Optional[str] = None, api_secret: Optional[str] = None) -> bool: + """ + Test JLCPCB API connection + + Args: + api_key: Optional API key (uses env var if not provided) + api_secret: Optional API secret (uses env var if not provided) + + Returns: + True if connection successful, False otherwise + """ + try: + client = JLCPCBClient(api_key, api_secret) + token = client.authenticate() + logger.info("JLCPCB API connection test successful") + return True + except Exception as e: + logger.error(f"JLCPCB API connection test failed: {e}") + return False + + +if __name__ == '__main__': + # Test the JLCPCB client + logging.basicConfig(level=logging.INFO) + + print("Testing JLCPCB API connection...") + if test_jlcpcb_connection(): + print("✓ Connection successful!") + + client = JLCPCBClient() + print("\nFetching first page of parts...") + data = client.fetch_parts_page() + parts = data.get('componentInfos', []) + print(f"✓ Retrieved {len(parts)} parts in first page") + + if parts: + print(f"\nExample part:") + part = parts[0] + print(f" LCSC: {part.get('componentCode')}") + print(f" MFR Part: {part.get('componentModelEn')}") + print(f" Category: {part.get('firstSortName')} / {part.get('secondSortName')}") + print(f" Package: {part.get('componentSpecificationEn')}") + print(f" Stock: {part.get('stockCount')}") + else: + print("✗ Connection failed. Check your API credentials.") diff --git a/python/commands/jlcpcb_parts.py b/python/commands/jlcpcb_parts.py new file mode 100644 index 0000000..d90bbf1 --- /dev/null +++ b/python/commands/jlcpcb_parts.py @@ -0,0 +1,419 @@ +""" +JLCPCB Parts Database Manager + +Manages local SQLite database of JLCPCB parts for fast searching +and component selection. +""" + +import os +import sqlite3 +import json +import logging +from pathlib import Path +from typing import List, Dict, Optional +from datetime import datetime + +logger = logging.getLogger('kicad_interface') + + +class JLCPCBPartsManager: + """ + Manages local database of JLCPCB parts + + Provides fast parametric search, filtering, and package-to-footprint mapping. + """ + + def __init__(self, db_path: Optional[str] = None): + """ + Initialize parts database manager + + Args: + db_path: Path to SQLite database file (default: data/jlcpcb_parts.db) + """ + if db_path is None: + # Default to data directory in project root + project_root = Path(__file__).parent.parent.parent + data_dir = project_root / "data" + data_dir.mkdir(exist_ok=True) + db_path = str(data_dir / "jlcpcb_parts.db") + + self.db_path = db_path + self.conn = None + self._init_database() + + def _init_database(self): + """Initialize SQLite database with schema""" + self.conn = sqlite3.connect(self.db_path) + self.conn.row_factory = sqlite3.Row # Return rows as dicts + + cursor = self.conn.cursor() + + # Create components table + cursor.execute(''' + CREATE TABLE IF NOT EXISTS components ( + lcsc TEXT PRIMARY KEY, + category TEXT, + subcategory TEXT, + mfr_part TEXT, + package TEXT, + solder_joints INTEGER, + manufacturer TEXT, + library_type TEXT, + description TEXT, + datasheet TEXT, + stock INTEGER, + price_json TEXT, + last_updated INTEGER + ) + ''') + + # Create indexes for fast searching + cursor.execute('CREATE INDEX IF NOT EXISTS idx_category ON components(category, subcategory)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_package ON components(package)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_manufacturer ON components(manufacturer)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_library_type ON components(library_type)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_mfr_part ON components(mfr_part)') + + # Full-text search index for descriptions + cursor.execute(''' + CREATE VIRTUAL TABLE IF NOT EXISTS components_fts USING fts5( + lcsc, + description, + mfr_part, + manufacturer, + content=components + ) + ''') + + self.conn.commit() + logger.info(f"Initialized JLCPCB parts database at {self.db_path}") + + def import_parts(self, parts: List[Dict], progress_callback=None): + """ + Import parts into database from JLCPCB API response + + Args: + parts: List of part dicts from JLCPCB API + progress_callback: Optional callback(current, total, message) + """ + cursor = self.conn.cursor() + imported = 0 + skipped = 0 + + for i, part in enumerate(parts): + try: + # Extract price breaks + price_json = json.dumps(part.get('prices', [])) + + # Determine library type + library_type = self._determine_library_type(part) + + cursor.execute(''' + INSERT OR REPLACE INTO components ( + lcsc, category, subcategory, mfr_part, package, + solder_joints, manufacturer, library_type, description, + datasheet, stock, price_json, last_updated + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', ( + part.get('componentCode'), # lcsc + part.get('firstSortName'), # category + part.get('secondSortName'), # subcategory + part.get('componentModelEn'), # mfr_part + part.get('componentSpecificationEn'), # package + part.get('soldPoint'), # solder_joints + part.get('componentBrandEn'), # manufacturer + library_type, # library_type + part.get('describe'), # description + part.get('dataManualUrl'), # datasheet + part.get('stockCount', 0), # stock + price_json, # price_json + int(datetime.now().timestamp()) # last_updated + )) + + imported += 1 + + if progress_callback and (i + 1) % 1000 == 0: + progress_callback(i + 1, len(parts), f"Imported {imported} parts...") + + except Exception as e: + logger.error(f"Error importing part {part.get('componentCode')}: {e}") + skipped += 1 + + # Update FTS index + cursor.execute(''' + INSERT INTO components_fts(components_fts, rowid, lcsc, description, mfr_part, manufacturer) + SELECT 'rebuild', rowid, lcsc, description, mfr_part, manufacturer FROM components + ''') + + self.conn.commit() + logger.info(f"Import complete: {imported} parts imported, {skipped} skipped") + + def _determine_library_type(self, part: Dict) -> str: + """Determine if part is Basic, Extended, or Preferred""" + # JLCPCB API should provide this, but if not, we infer from assembly type + assembly_type = part.get('assemblyType', '') + + if 'Basic' in assembly_type or part.get('libraryType') == 'base': + return 'Basic' + elif 'Extended' in assembly_type: + return 'Extended' + elif 'Prefer' in assembly_type: + return 'Preferred' + else: + return 'Extended' # Default to Extended + + def search_parts( + self, + query: Optional[str] = None, + category: Optional[str] = None, + package: Optional[str] = None, + library_type: Optional[str] = None, + manufacturer: Optional[str] = None, + in_stock: bool = True, + limit: int = 20 + ) -> List[Dict]: + """ + Search for parts with filters + + Args: + query: Free-text search (searches description, mfr part, LCSC) + category: Filter by category name + package: Filter by package type + library_type: Filter by "Basic", "Extended", or "Preferred" + manufacturer: Filter by manufacturer name + in_stock: Only return parts with stock > 0 + limit: Maximum number of results + + Returns: + List of matching parts + """ + cursor = self.conn.cursor() + + # Build query + sql_parts = ["SELECT * FROM components WHERE 1=1"] + params = [] + + if query: + # Use FTS for text search + sql_parts.append(''' + AND lcsc IN ( + SELECT lcsc FROM components_fts + WHERE components_fts MATCH ? + ) + ''') + params.append(query) + + if category: + sql_parts.append("AND category LIKE ?") + params.append(f"%{category}%") + + if package: + sql_parts.append("AND package LIKE ?") + params.append(f"%{package}%") + + if library_type: + sql_parts.append("AND library_type = ?") + params.append(library_type) + + if manufacturer: + sql_parts.append("AND manufacturer LIKE ?") + params.append(f"%{manufacturer}%") + + if in_stock: + sql_parts.append("AND stock > 0") + + sql_parts.append("LIMIT ?") + params.append(limit) + + sql = " ".join(sql_parts) + + try: + cursor.execute(sql, params) + rows = cursor.fetchall() + return [dict(row) for row in rows] + except Exception as e: + logger.error(f"Search error: {e}") + return [] + + def get_part_info(self, lcsc_number: str) -> Optional[Dict]: + """ + Get detailed information for specific LCSC part + + Args: + lcsc_number: LCSC part number (e.g., "C25804") + + Returns: + Part info dict or None if not found + """ + cursor = self.conn.cursor() + cursor.execute("SELECT * FROM components WHERE lcsc = ?", (lcsc_number,)) + row = cursor.fetchone() + + if row: + part = dict(row) + # Parse price JSON + if part.get('price_json'): + try: + part['price_breaks'] = json.loads(part['price_json']) + except: + part['price_breaks'] = [] + return part + return None + + def get_database_stats(self) -> Dict: + """Get statistics about the database""" + cursor = self.conn.cursor() + + cursor.execute("SELECT COUNT(*) as total FROM components") + total = cursor.fetchone()['total'] + + cursor.execute("SELECT COUNT(*) as basic FROM components WHERE library_type = 'Basic'") + basic = cursor.fetchone()['basic'] + + cursor.execute("SELECT COUNT(*) as extended FROM components WHERE library_type = 'Extended'") + extended = cursor.fetchone()['extended'] + + cursor.execute("SELECT COUNT(*) as in_stock FROM components WHERE stock > 0") + in_stock = cursor.fetchone()['in_stock'] + + return { + 'total_parts': total, + 'basic_parts': basic, + 'extended_parts': extended, + 'in_stock': in_stock, + 'db_path': self.db_path + } + + def map_package_to_footprint(self, package: str) -> List[str]: + """ + Map JLCPCB package name to KiCAD footprint(s) + + Args: + package: JLCPCB package name (e.g., "0603", "SOT-23") + + Returns: + List of possible KiCAD footprint library refs + """ + # Load mapping from JSON file or use defaults + mappings = { + "0402": [ + "Resistor_SMD:R_0402_1005Metric", + "Capacitor_SMD:C_0402_1005Metric", + "LED_SMD:LED_0402_1005Metric" + ], + "0603": [ + "Resistor_SMD:R_0603_1608Metric", + "Capacitor_SMD:C_0603_1608Metric", + "LED_SMD:LED_0603_1608Metric" + ], + "0805": [ + "Resistor_SMD:R_0805_2012Metric", + "Capacitor_SMD:C_0805_2012Metric" + ], + "1206": [ + "Resistor_SMD:R_1206_3216Metric", + "Capacitor_SMD:C_1206_3216Metric" + ], + "SOT-23": [ + "Package_TO_SOT_SMD:SOT-23", + "Package_TO_SOT_SMD:SOT-23-3" + ], + "SOT-23-5": [ + "Package_TO_SOT_SMD:SOT-23-5" + ], + "SOT-23-6": [ + "Package_TO_SOT_SMD:SOT-23-6" + ], + "SOIC-8": [ + "Package_SO:SOIC-8_3.9x4.9mm_P1.27mm" + ], + "SOIC-16": [ + "Package_SO:SOIC-16_3.9x9.9mm_P1.27mm" + ], + "QFN-20": [ + "Package_DFN_QFN:QFN-20-1EP_4x4mm_P0.5mm_EP2.5x2.5mm" + ], + "QFN-32": [ + "Package_DFN_QFN:QFN-32-1EP_5x5mm_P0.5mm_EP3.45x3.45mm" + ] + } + + # Normalize package name + package_normalized = package.strip().upper() + + for key, footprints in mappings.items(): + if key.upper() in package_normalized: + return footprints + + return [] + + def suggest_alternatives(self, lcsc_number: str, limit: int = 5) -> List[Dict]: + """ + Find alternative parts similar to the given LCSC number + + Prioritizes: cheaper price, higher stock, Basic library type + + Args: + lcsc_number: Reference LCSC part number + limit: Maximum alternatives to return + + Returns: + List of alternative parts + """ + part = self.get_part_info(lcsc_number) + if not part: + return [] + + # Search for parts in same category with same package + alternatives = self.search_parts( + category=part['subcategory'], + package=part['package'], + in_stock=True, + limit=limit * 3 + ) + + # Filter out the original part + alternatives = [p for p in alternatives if p['lcsc'] != lcsc_number] + + # Sort by: Basic first, then by price, then by stock + def sort_key(p): + is_basic = 1 if p.get('library_type') == 'Basic' else 0 + try: + prices = json.loads(p.get('price_json', '[]')) + price = float(prices[0].get('price', 999)) if prices else 999 + except: + price = 999 + stock = p.get('stock', 0) + + return (-is_basic, price, -stock) + + alternatives.sort(key=sort_key) + + return alternatives[:limit] + + def close(self): + """Close database connection""" + if self.conn: + self.conn.close() + + +if __name__ == '__main__': + # Test the parts manager + logging.basicConfig(level=logging.INFO) + + manager = JLCPCBPartsManager() + + # Get stats + stats = manager.get_database_stats() + print(f"\nDatabase Statistics:") + print(f" Total parts: {stats['total_parts']}") + print(f" Basic parts: {stats['basic_parts']}") + print(f" Extended parts: {stats['extended_parts']}") + print(f" In stock: {stats['in_stock']}") + print(f" Database: {stats['db_path']}") + + if stats['total_parts'] > 0: + print("\nSearching for '10k resistor'...") + results = manager.search_parts(query="10k resistor", limit=5) + for part in results: + print(f" {part['lcsc']}: {part['mfr_part']} - {part['description']} ({part['library_type']})") diff --git a/python/kicad_interface.py b/python/kicad_interface.py index ddb5f42..6513835 100644 --- a/python/kicad_interface.py +++ b/python/kicad_interface.py @@ -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""" diff --git a/src/server.ts b/src/server.ts index a1e8445..5e8f9d9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,6 +20,7 @@ import { registerExportTools } from './tools/export.js'; import { registerSchematicTools } from './tools/schematic.js'; import { registerLibraryTools } from './tools/library.js'; import { registerSymbolLibraryTools } from './tools/library-symbol.js'; +import { registerJLCPCBApiTools } from './tools/jlcpcb-api.js'; import { registerUITools } from './tools/ui.js'; import { registerRouterTools } from './tools/router.js'; @@ -155,6 +156,7 @@ export class KiCADMcpServer { registerSchematicTools(this.server, this.callKicadScript.bind(this)); registerLibraryTools(this.server, this.callKicadScript.bind(this)); registerSymbolLibraryTools(this.server, this.callKicadScript.bind(this)); + registerJLCPCBApiTools(this.server, this.callKicadScript.bind(this)); registerUITools(this.server, this.callKicadScript.bind(this)); // Register all resources diff --git a/src/tools/jlcpcb-api.ts b/src/tools/jlcpcb-api.ts new file mode 100644 index 0000000..9640676 --- /dev/null +++ b/src/tools/jlcpcb-api.ts @@ -0,0 +1,245 @@ +/** + * JLCPCB API tools for KiCAD MCP server + * Provides access to JLCPCB's complete parts catalog via their API + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; + +export function registerJLCPCBApiTools(server: McpServer, callKicadScript: Function) { + // Download JLCPCB parts database + server.tool( + "download_jlcpcb_database", + `Download the complete JLCPCB parts catalog to local database. + +This is a one-time setup that downloads ~100k+ parts from JLCPCB API. +Requires JLCPCB_API_KEY and JLCPCB_API_SECRET environment variables. + +The download takes 5-10 minutes and creates a local SQLite database +for fast offline searching.`, + { + force: z.boolean().optional().default(false) + .describe("Force re-download even if database exists") + }, + async (args: { force?: boolean }) => { + const result = await callKicadScript("download_jlcpcb_database", args); + if (result.success) { + return { + content: [{ + type: "text", + text: `✓ Successfully downloaded JLCPCB parts database\n\n` + + `Total parts: ${result.total_parts}\n` + + `Basic parts: ${result.basic_parts}\n` + + `Extended parts: ${result.extended_parts}\n` + + `Database size: ${result.db_size_mb} MB\n` + + `Database path: ${result.db_path}` + }] + }; + } + return { + content: [{ + type: "text", + text: `✗ Failed to download JLCPCB database: ${result.message || 'Unknown error'}\n\n` + + `Make sure JLCPCB_API_KEY and JLCPCB_API_SECRET environment variables are set.` + }] + }; + } + ); + + // Search JLCPCB parts + server.tool( + "search_jlcpcb_parts", + `Search JLCPCB parts catalog by specifications. + +Searches the local JLCPCB database (must be downloaded first with download_jlcpcb_database). +Provides real pricing, stock info, and library type (Basic parts = free assembly). + +Use this to find components with exact specifications and cost optimization.`, + { + query: z.string().optional() + .describe("Free-text search (e.g., '10k resistor 0603', 'ESP32', 'STM32F103')"), + category: z.string().optional() + .describe("Filter by category (e.g., 'Resistors', 'Capacitors', 'Microcontrollers')"), + package: z.string().optional() + .describe("Filter by package type (e.g., '0603', 'SOT-23', 'QFN-32')"), + library_type: z.enum(["Basic", "Extended", "Preferred", "All"]).optional().default("All") + .describe("Filter by library type (Basic = free assembly at JLCPCB)"), + manufacturer: z.string().optional() + .describe("Filter by manufacturer name"), + in_stock: z.boolean().optional().default(true) + .describe("Only show parts with available stock"), + limit: z.number().optional().default(20) + .describe("Maximum number of results to return") + }, + async (args: any) => { + const result = await callKicadScript("search_jlcpcb_parts", args); + if (result.success && result.parts) { + if (result.parts.length === 0) { + return { + content: [{ + type: "text", + text: `No JLCPCB parts found matching your criteria.\n\n` + + `Try broadening your search or check if the database is populated.` + }] + }; + } + + const partsList = result.parts.map((p: any) => { + const priceInfo = p.price_breaks && p.price_breaks.length > 0 + ? ` - $${p.price_breaks[0].price}/ea` + : ''; + const stockInfo = p.stock > 0 ? ` (${p.stock} in stock)` : ' (out of stock)'; + return `${p.lcsc}: ${p.mfr_part} - ${p.description} [${p.library_type}]${priceInfo}${stockInfo}`; + }).join('\n'); + + return { + content: [{ + type: "text", + text: `Found ${result.count} JLCPCB parts:\n\n${partsList}\n\n` + + `💡 Basic parts have free assembly. Extended parts charge $3 setup fee per unique part.` + }] + }; + } + return { + content: [{ + type: "text", + text: `Failed to search JLCPCB parts: ${result.message || 'Unknown error'}\n\n` + + `Make sure you've downloaded the database first using download_jlcpcb_database.` + }] + }; + } + ); + + // Get JLCPCB part details + server.tool( + "get_jlcpcb_part", + "Get detailed information about a specific JLCPCB part by LCSC number", + { + lcsc_number: z.string() + .describe("LCSC part number (e.g., 'C25804', 'C2286')") + }, + async (args: { lcsc_number: string }) => { + const result = await callKicadScript("get_jlcpcb_part", args); + if (result.success && result.part) { + const p = result.part; + const priceTable = p.price_breaks && p.price_breaks.length > 0 + ? '\n\nPrice Breaks:\n' + p.price_breaks.map((pb: any) => + ` ${pb.qty}+: $${pb.price}/ea` + ).join('\n') + : ''; + + const footprints = result.footprints && result.footprints.length > 0 + ? '\n\nSuggested KiCAD Footprints:\n' + result.footprints.map((f: string) => + ` - ${f}` + ).join('\n') + : ''; + + return { + content: [{ + type: "text", + text: `LCSC: ${p.lcsc}\n` + + `MFR Part: ${p.mfr_part}\n` + + `Manufacturer: ${p.manufacturer}\n` + + `Category: ${p.category} / ${p.subcategory}\n` + + `Package: ${p.package}\n` + + `Description: ${p.description}\n` + + `Library Type: ${p.library_type} ${p.library_type === 'Basic' ? '(Free assembly!)' : ''}\n` + + `Stock: ${p.stock}\n` + + (p.datasheet ? `Datasheet: ${p.datasheet}\n` : '') + + priceTable + + footprints + }] + }; + } + return { + content: [{ + type: "text", + text: `Part not found: ${args.lcsc_number}\n\n` + + `Make sure you've downloaded the JLCPCB database first.` + }] + }; + } + ); + + // Get JLCPCB database statistics + server.tool( + "get_jlcpcb_database_stats", + "Get statistics about the local JLCPCB parts database", + {}, + async () => { + const result = await callKicadScript("get_jlcpcb_database_stats", {}); + if (result.success) { + const stats = result.stats; + return { + content: [{ + type: "text", + text: `JLCPCB Database Statistics:\n\n` + + `Total parts: ${stats.total_parts.toLocaleString()}\n` + + `Basic parts: ${stats.basic_parts.toLocaleString()} (free assembly)\n` + + `Extended parts: ${stats.extended_parts.toLocaleString()} ($3 setup fee each)\n` + + `In stock: ${stats.in_stock.toLocaleString()}\n` + + `Database path: ${stats.db_path}` + }] + }; + } + return { + content: [{ + type: "text", + text: `JLCPCB database not found or empty.\n\n` + + `Run download_jlcpcb_database first to populate the database.` + }] + }; + } + ); + + // Suggest alternative parts + server.tool( + "suggest_jlcpcb_alternatives", + `Suggest alternative JLCPCB parts for a given component. + +Finds similar parts that may be cheaper, have more stock, or are Basic library type. +Useful for cost optimization and finding alternatives when parts are out of stock.`, + { + lcsc_number: z.string() + .describe("Reference LCSC part number to find alternatives for"), + limit: z.number().optional().default(5) + .describe("Maximum number of alternatives to return") + }, + async (args: { lcsc_number: string; limit?: number }) => { + const result = await callKicadScript("suggest_jlcpcb_alternatives", args); + if (result.success && result.alternatives) { + if (result.alternatives.length === 0) { + return { + content: [{ + type: "text", + text: `No alternatives found for ${args.lcsc_number}` + }] + }; + } + + const altsList = result.alternatives.map((p: any, i: number) => { + const priceInfo = p.price_breaks && p.price_breaks.length > 0 + ? ` - $${p.price_breaks[0].price}/ea` + : ''; + const savings = result.reference_price && p.price_breaks && p.price_breaks.length > 0 + ? ` (${((1 - p.price_breaks[0].price / result.reference_price) * 100).toFixed(0)}% cheaper)` + : ''; + return `${i + 1}. ${p.lcsc}: ${p.mfr_part} [${p.library_type}]${priceInfo}${savings}\n ${p.description}\n Stock: ${p.stock}`; + }).join('\n\n'); + + return { + content: [{ + type: "text", + text: `Alternative parts for ${args.lcsc_number}:\n\n${altsList}` + }] + }; + } + return { + content: [{ + type: "text", + text: `Failed to find alternatives: ${result.message || 'Unknown error'}` + }] + }; + } + ); +}