From 6953e5dc9a6aa58f46379b642fbc0f38dd1ea792 Mon Sep 17 00:00:00 2001 From: Lama Date: Thu, 20 Mar 2025 13:54:48 -0400 Subject: [PATCH] Add comprehensive BOM management functionality with analysis, export, and viewing features --- docs/bom_guide.md | 218 ++++++++ kicad_mcp/prompts/bom_prompts.py | 117 ++++ kicad_mcp/resources/bom_resources.py | 281 ++++++++++ kicad_mcp/server.py | 6 + kicad_mcp/tools/bom_tools.py | 762 +++++++++++++++++++++++++++ 5 files changed, 1384 insertions(+) create mode 100644 docs/bom_guide.md create mode 100644 kicad_mcp/prompts/bom_prompts.py create mode 100644 kicad_mcp/resources/bom_resources.py create mode 100644 kicad_mcp/tools/bom_tools.py diff --git a/docs/bom_guide.md b/docs/bom_guide.md new file mode 100644 index 0000000..af51baf --- /dev/null +++ b/docs/bom_guide.md @@ -0,0 +1,218 @@ +# KiCad BOM Management Guide + +This guide explains how to use the Bill of Materials (BOM) features in the KiCad MCP Server. + +## Overview + +The BOM functionality allows you to: + +1. Analyze component usage in your KiCad projects +2. Export BOMs from your schematics +3. Estimate project costs +4. View BOM data in various formats (Markdown, CSV, JSON) +5. Get assistance with component sourcing and optimization + +## Quick Reference + +| Task | Example Prompt | +|------|---------------| +| Analyze components | `Analyze the BOM for my KiCad project at /path/to/project.kicad_pro` | +| Export a BOM | `Export a BOM for my KiCad project at /path/to/project.kicad_pro` | +| View formatted report | `Show me the BOM report for /path/to/project.kicad_pro` | +| Get raw CSV data | `Show me the CSV BOM data for /path/to/project.kicad_pro` | +| Get JSON data | `Show me the JSON BOM data for /path/to/project.kicad_pro` | + +## Using BOM Features + +### Analyzing an Existing BOM + +To analyze a BOM that already exists in your project: + +1. In your MCP client, request analysis of your project's BOM: + +``` +Please analyze the BOM for my KiCad project at /Users/username/Documents/KiCad/my_project/my_project.kicad_pro +``` + +The `analyze_bom` tool will: +- Search for BOM files in your project directory +- Parse and analyze the component data +- Generate a comprehensive report with component counts, categories, and cost estimates (if available) +- Provide insights into your component usage + +### Exporting a New BOM + +If you don't have a BOM yet, you can export one directly: + +``` +Can you export a BOM for my KiCad project at /Users/username/Documents/KiCad/my_project/my_project.kicad_pro? +``` + +The `export_bom_csv` tool will: +- Find the schematic file in your project +- Use KiCad's command-line tools to generate a new BOM +- Save the BOM in your project directory +- Provide a path to the generated file + +### Viewing BOM Information + +There are several ways to view your BOM data: + +#### Formatted BOM Report + +For a well-formatted report with component analysis: + +``` +Show me the BOM report for /Users/username/Documents/KiCad/my_project/my_project.kicad_pro +``` + +This will load the `kicad://bom/project_path` resource, showing: +- Total component count +- Breakdown by component type +- Cost estimates (if available) +- A table of components +- Suggestions for optimization + +#### Raw BOM Data + +To get the raw BOM data in CSV format: + +``` +Can I see the raw CSV BOM data for /Users/username/Documents/KiCad/my_project/my_project.kicad_pro? +``` + +This will load the `kicad://bom/project_path/csv` resource, providing: +- The raw CSV data from your BOM file +- Ideal for importing into spreadsheets or other tools + +#### JSON Data + +For structured data access: + +``` +Show me the JSON BOM data for /Users/username/Documents/KiCad/my_project/my_project.kicad_pro +``` + +This will load the `kicad://bom/project_path/json` resource, providing: +- Structured JSON representation of all BOM data +- Component analysis in machine-readable format +- Useful for programmatic processing + +### Getting Help with BOM Tasks + +The KiCad MCP Server provides several prompt templates for BOM-related tasks: + +1. **Analyze Components** - Help with analyzing your component usage +2. **Cost Estimation** - Assistance with estimating project costs +3. **BOM Export Help** - Guidance on exporting BOMs from KiCad +4. **Component Sourcing** - Help with finding and sourcing components +5. **BOM Comparison** - Compare BOMs between different project versions + +To use these prompts, click on the prompt templates button in your MCP client, then select the desired BOM template. + +## Understanding BOM Results + +### Component Categories + +The BOM analysis provides a breakdown of component categories, such as: + +| Category | Description | Example Components | +|----------|-------------|-------------------| +| Resistors | Current-limiting or voltage-dividing components | R1, R2, R3 | +| Capacitors | Energy storage and filtering components | C1, C2, C3 | +| ICs | Integrated circuits | U1, U2, U3 | +| Connectors | Board-to-board or board-to-wire connectors | J1, J2, J3 | +| Transistors | Switching or amplifying components | Q1, Q2, Q3 | +| Diodes | Unidirectional current flow components | D1, D2, D3 | + +### Cost Information + +The BOM analysis will attempt to extract cost information if it's available in your BOM file. This includes: + +- Individual component costs +- Total project cost +- Breakdown of cost by component type + +To include cost information in your BOM, add a "Cost" or "Price" column to your KiCad component fields, or include this information when exporting your BOM. + +## Tips for Better BOM Management + +### Structure Your BOM Export + +When exporting a BOM from KiCad: + +1. Use descriptive component values +2. Add meaningful component descriptions +3. Group components logically +4. Include footprint information +5. Add supplier part numbers where possible +6. Include cost information for better estimates + +### Optimize Your Component Selection + +Based on BOM analysis, consider: + +- Standardizing component values (e.g., using the same resistor values across the design) +- Reducing the variety of footprints +- Selecting commonly available components +- Using components from the same supplier where possible +- Finding alternatives for expensive or hard-to-find components + +### Keep BOM Files Updated + +When making changes to your schematic: + +1. Re-export your BOM after significant changes +2. Compare with previous versions to identify changes +3. Verify that all components are still available +4. Update cost estimates regularly + +## Troubleshooting + +### BOM Analysis Fails + +If BOM analysis fails: + +1. Ensure your BOM file is in a supported format (CSV, XML, or JSON) +2. Check that the file is not corrupted or empty +3. Verify that the BOM file is in your project directory +4. Try exporting a new BOM from KiCad +5. Check for unusual characters or formatting in your BOM + +### BOM Export Fails + +If BOM export fails: + +1. Make sure KiCad is properly installed on your system +2. Verify that your schematic file exists and is valid +3. Check that you have write permissions in your project directory +4. Look for KiCad command-line tools in your KiCad installation +5. Try exporting manually from KiCad to see if there are specific errors + +## Advanced Usage + +### Custom BOM Analysis + +For deeper analysis of your BOM, you can ask specific questions about: + +1. Component distribution +2. Cost optimization +3. Footprint standardization +4. Supply chain considerations +5. Design for manufacturing improvements + +Simply ask with your specific query, referencing your BOM: + +``` +Looking at the BOM for my project at /path/to/project.kicad_pro, can you suggest ways to reduce the variety of resistor values while maintaining the same functionality? +``` + +### Comparing Multiple Projects + +To compare BOMs across different projects or revisions: + +``` +Can you compare the BOMs between my projects at /path/to/project_v1.kicad_pro and /path/to/project_v2.kicad_pro? +``` + +This allows you to see how component selection evolves across design iterations. diff --git a/kicad_mcp/prompts/bom_prompts.py b/kicad_mcp/prompts/bom_prompts.py new file mode 100644 index 0000000..18abf9b --- /dev/null +++ b/kicad_mcp/prompts/bom_prompts.py @@ -0,0 +1,117 @@ +""" +BOM-related prompt templates for KiCad. +""" +from mcp.server.fastmcp import FastMCP + + +def register_bom_prompts(mcp: FastMCP) -> None: + """Register BOM-related prompt templates with the MCP server. + + Args: + mcp: The FastMCP server instance + """ + + @mcp.prompt() + def analyze_components() -> str: + """Prompt for analyzing a KiCad project's components.""" + prompt = """ + I'd like to analyze the components used in my KiCad PCB design. Can you help me with: + + 1. Identifying all the components in my design + 2. Analyzing the distribution of component types + 3. Checking for any potential issues or opportunities for optimization + 4. Suggesting any alternatives for hard-to-find or expensive components + + My KiCad project is located at: + [Enter the full path to your .kicad_pro file here] + + Please use the BOM analysis tools to help me understand my component usage. + """ + + return prompt + + @mcp.prompt() + def cost_estimation() -> str: + """Prompt for estimating project costs based on BOM.""" + prompt = """ + I need to estimate the cost of my KiCad PCB project for: + + 1. A prototype run (1-5 boards) + 2. A small production run (10-100 boards) + 3. Larger scale production (500+ boards) + + My KiCad project is located at: + [Enter the full path to your .kicad_pro file here] + + Please analyze my BOM to help estimate component costs, and provide guidance on: + + - Which components contribute most to the overall cost + - Where I might find cost savings + - Potential volume discounts for larger runs + - Suggestions for alternative components that could reduce costs + - Estimated PCB fabrication costs based on board size and complexity + + If my BOM doesn't include cost data, please suggest how I might find pricing information for my components. + """ + + return prompt + + @mcp.prompt() + def bom_export_help() -> str: + """Prompt for assistance with exporting BOMs from KiCad.""" + prompt = """ + I need help exporting a Bill of Materials (BOM) from my KiCad project. I'm interested in: + + 1. Understanding the different BOM export options in KiCad + 2. Exporting a BOM with specific fields (reference, value, footprint, etc.) + 3. Generating a BOM in a format compatible with my preferred supplier + 4. Adding custom fields to my components that will appear in the BOM + + My KiCad project is located at: + [Enter the full path to your .kicad_pro file here] + + Please guide me through the process of creating a well-structured BOM for my project. + """ + + return prompt + + @mcp.prompt() + def component_sourcing() -> str: + """Prompt for help with component sourcing.""" + prompt = """ + I need help sourcing components for my KiCad PCB project. Specifically, I need assistance with: + + 1. Identifying reliable suppliers for my components + 2. Finding alternatives for any hard-to-find or obsolete parts + 3. Understanding lead times and availability constraints + 4. Balancing cost versus quality considerations + + My KiCad project is located at: + [Enter the full path to your .kicad_pro file here] + + Please analyze my BOM and provide guidance on sourcing these components efficiently. + """ + + return prompt + + @mcp.prompt() + def bom_comparison() -> str: + """Prompt for comparing BOMs between two design revisions.""" + prompt = """ + I have two versions of a KiCad project and I'd like to compare the changes between their Bills of Materials. I need to understand: + + 1. Which components were added or removed + 2. Which component values or footprints changed + 3. The impact of these changes on the overall design + 4. Any potential issues introduced by these changes + + My original KiCad project is located at: + [Enter the full path to your first .kicad_pro file here] + + My revised KiCad project is located at: + [Enter the full path to your second .kicad_pro file here] + + Please analyze the BOMs from both projects and help me understand the differences between them. + """ + + return prompt diff --git a/kicad_mcp/resources/bom_resources.py b/kicad_mcp/resources/bom_resources.py new file mode 100644 index 0000000..104913d --- /dev/null +++ b/kicad_mcp/resources/bom_resources.py @@ -0,0 +1,281 @@ +""" +Bill of Materials (BOM) resources for KiCad projects. +""" +import os +import csv +import json +import pandas as pd +from typing import Dict, List, Any, Optional +from mcp.server.fastmcp import FastMCP + +from kicad_mcp.utils.file_utils import get_project_files + +# Import the helper functions from bom_tools.py to avoid code duplication +from kicad_mcp.tools.bom_tools import parse_bom_file, analyze_bom_data + +def register_bom_resources(mcp: FastMCP) -> None: + """Register BOM-related resources with the MCP server. + + Args: + mcp: The FastMCP server instance + """ + + @mcp.resource("kicad://bom/{project_path}") + def get_bom_resource(project_path: str) -> str: + """Get a formatted BOM report for a KiCad project. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + + Returns: + Markdown-formatted BOM report + """ + print(f"Generating BOM report for project: {project_path}") + + if not os.path.exists(project_path): + return f"Project not found: {project_path}" + + # Get all project files + files = get_project_files(project_path) + + # Look for BOM files + bom_files = {} + for file_type, file_path in files.items(): + if "bom" in file_type.lower() or file_path.lower().endswith(".csv"): + bom_files[file_type] = file_path + print(f"Found potential BOM file: {file_path}") + + if not bom_files: + print("No BOM files found for project") + return f"# BOM Report\n\nNo BOM files found for project: {os.path.basename(project_path)}.\n\nExport a BOM from KiCad first, or use the `export_bom_csv` tool to generate one." + + # Format as Markdown report + project_name = os.path.basename(project_path)[:-10] # Remove .kicad_pro + + report = f"# Bill of Materials for {project_name}\n\n" + + # Process each BOM file + for file_type, file_path in bom_files.items(): + try: + # Parse and analyze the BOM + bom_data, format_info = parse_bom_file(file_path) + + if not bom_data: + report += f"## {file_type}\n\nFailed to parse BOM file: {os.path.basename(file_path)}\n\n" + continue + + analysis = analyze_bom_data(bom_data, format_info) + + # Add file section + report += f"## {file_type.capitalize()}\n\n" + report += f"**File**: {os.path.basename(file_path)}\n\n" + report += f"**Format**: {format_info.get('detected_format', 'Unknown')}\n\n" + + # Add summary + report += "### Summary\n\n" + report += f"- **Total Components**: {analysis.get('total_component_count', 0)}\n" + report += f"- **Unique Components**: {analysis.get('unique_component_count', 0)}\n" + + # Add cost if available + if analysis.get('has_cost_data', False) and 'total_cost' in analysis: + currency = analysis.get('currency', 'USD') + currency_symbols = {'USD': '$', 'EUR': '€', 'GBP': '£'} + symbol = currency_symbols.get(currency, '') + + report += f"- **Estimated Cost**: {symbol}{analysis['total_cost']} {currency}\n" + + report += "\n" + + # Add categories breakdown + if 'categories' in analysis and analysis['categories']: + report += "### Component Categories\n\n" + + for category, count in analysis['categories'].items(): + report += f"- **{category}**: {count}\n" + + report += "\n" + + # Add most common components if available + if 'most_common_values' in analysis and analysis['most_common_values']: + report += "### Most Common Components\n\n" + + for value, count in analysis['most_common_values'].items(): + report += f"- **{value}**: {count}\n" + + report += "\n" + + # Add component table (first 20 items) + if bom_data: + report += "### Component List\n\n" + + # Try to identify key columns + columns = [] + if format_info.get('header_fields'): + # Use a subset of columns for readability + preferred_cols = ['Reference', 'Value', 'Footprint', 'Quantity', 'Description'] + + # Find matching columns (case-insensitive) + header_lower = [h.lower() for h in format_info['header_fields']] + for col in preferred_cols: + col_lower = col.lower() + if col_lower in header_lower: + idx = header_lower.index(col_lower) + columns.append(format_info['header_fields'][idx]) + + # If we didn't find any preferred columns, use the first 4 + if not columns and len(format_info['header_fields']) > 0: + columns = format_info['header_fields'][:min(4, len(format_info['header_fields']))] + + # Generate the table header + if columns: + report += "| " + " | ".join(columns) + " |\n" + report += "| " + " | ".join(["---"] * len(columns)) + " |\n" + + # Add rows (limit to first 20 for readability) + for i, component in enumerate(bom_data[:20]): + row = [] + for col in columns: + value = component.get(col, "") + # Clean up cell content for Markdown table + value = str(value).replace("|", "\\|").replace("\n", " ") + row.append(value) + + report += "| " + " | ".join(row) + " |\n" + + # Add note if there are more components + if len(bom_data) > 20: + report += f"\n*...and {len(bom_data) - 20} more components*\n" + else: + report += "*Component table could not be generated - column headers not recognized*\n" + + report += "\n---\n\n" + + except Exception as e: + print(f"Error processing BOM file {file_path}: {str(e)}") + report += f"## {file_type}\n\nError processing BOM file: {str(e)}\n\n" + + # Add export instructions + report += "## How to Export a BOM\n\n" + report += "To generate a new BOM from your KiCad project:\n\n" + report += "1. Open your schematic in KiCad\n" + report += "2. Go to **Tools → Generate BOM**\n" + report += "3. Choose a BOM plugin and click **Generate**\n" + report += "4. Save the BOM file in your project directory\n\n" + report += "Alternatively, use the `export_bom_csv` tool in this MCP server to generate a BOM file.\n" + + return report + + @mcp.resource("kicad://bom/{project_path}/csv") + def get_bom_csv_resource(project_path: str) -> str: + """Get a CSV representation of the BOM for a KiCad project. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + + Returns: + CSV-formatted BOM data + """ + print(f"Generating CSV BOM for project: {project_path}") + + if not os.path.exists(project_path): + return f"Project not found: {project_path}" + + # Get all project files + files = get_project_files(project_path) + + # Look for BOM files + bom_files = {} + for file_type, file_path in files.items(): + if "bom" in file_type.lower() or file_path.lower().endswith(".csv"): + bom_files[file_type] = file_path + print(f"Found potential BOM file: {file_path}") + + if not bom_files: + print("No BOM files found for project") + return "No BOM files found for project. Export a BOM from KiCad first." + + # Use the first BOM file found + file_type = next(iter(bom_files)) + file_path = bom_files[file_type] + + try: + # If it's already a CSV, just return its contents + if file_path.lower().endswith('.csv'): + with open(file_path, 'r', encoding='utf-8-sig') as f: + return f.read() + + # Otherwise, try to parse and convert to CSV + bom_data, format_info = parse_bom_file(file_path) + + if not bom_data: + return f"Failed to parse BOM file: {file_path}" + + # Convert to DataFrame and then to CSV + df = pd.DataFrame(bom_data) + return df.to_csv(index=False) + + except Exception as e: + print(f"Error generating CSV from BOM file: {str(e)}") + return f"Error generating CSV from BOM file: {str(e)}" + + @mcp.resource("kicad://bom/{project_path}/json") + def get_bom_json_resource(project_path: str) -> str: + """Get a JSON representation of the BOM for a KiCad project. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + + Returns: + JSON-formatted BOM data + """ + print(f"Generating JSON BOM for project: {project_path}") + + if not os.path.exists(project_path): + return f"Project not found: {project_path}" + + # Get all project files + files = get_project_files(project_path) + + # Look for BOM files + bom_files = {} + for file_type, file_path in files.items(): + if "bom" in file_type.lower() or file_path.lower().endswith((".csv", ".json")): + bom_files[file_type] = file_path + print(f"Found potential BOM file: {file_path}") + + if not bom_files: + print("No BOM files found for project") + return json.dumps({"error": "No BOM files found for project"}, indent=2) + + try: + # Collect data from all BOM files + result = {"project": os.path.basename(project_path)[:-10], "bom_files": {}} + + for file_type, file_path in bom_files.items(): + # If it's already JSON, parse it directly + if file_path.lower().endswith('.json'): + with open(file_path, 'r') as f: + try: + result["bom_files"][file_type] = json.load(f) + continue + except: + # If JSON parsing fails, fall back to regular parsing + pass + + # Otherwise parse with our utility + bom_data, format_info = parse_bom_file(file_path) + + if bom_data: + analysis = analyze_bom_data(bom_data, format_info) + result["bom_files"][file_type] = { + "file": os.path.basename(file_path), + "format": format_info, + "analysis": analysis, + "components": bom_data + } + + return json.dumps(result, indent=2, default=str) + + except Exception as e: + print(f"Error generating JSON from BOM file: {str(e)}") + return json.dumps({"error": str(e)}, indent=2) diff --git a/kicad_mcp/server.py b/kicad_mcp/server.py index 1054537..6c7eadb 100644 --- a/kicad_mcp/server.py +++ b/kicad_mcp/server.py @@ -7,16 +7,19 @@ from mcp.server.fastmcp import FastMCP from kicad_mcp.resources.projects import register_project_resources from kicad_mcp.resources.files import register_file_resources from kicad_mcp.resources.drc_resources import register_drc_resources +from kicad_mcp.resources.bom_resources import register_bom_resources # Import tool handlers from kicad_mcp.tools.project_tools import register_project_tools from kicad_mcp.tools.analysis_tools import register_analysis_tools from kicad_mcp.tools.export_tools import register_export_tools from kicad_mcp.tools.drc_tools import register_drc_tools +from kicad_mcp.tools.bom_tools import register_bom_tools # Import prompt handlers from kicad_mcp.prompts.templates import register_prompts from kicad_mcp.prompts.drc_prompt import register_drc_prompts +from kicad_mcp.prompts.bom_prompts import register_bom_prompts # Import utils from kicad_mcp.utils.logger import Logger @@ -49,6 +52,7 @@ def create_server() -> FastMCP: register_project_resources(mcp) register_file_resources(mcp) register_drc_resources(mcp) + register_bom_resources(mcp) # Register tools logger.debug("Registering tools...") @@ -56,11 +60,13 @@ def create_server() -> FastMCP: register_analysis_tools(mcp) register_export_tools(mcp) register_drc_tools(mcp) + register_bom_tools(mcp) # Register prompts logger.debug("Registering prompts...") register_prompts(mcp) register_drc_prompts(mcp) + register_bom_prompts(mcp) logger.info("Server initialization complete") return mcp diff --git a/kicad_mcp/tools/bom_tools.py b/kicad_mcp/tools/bom_tools.py new file mode 100644 index 0000000..203b599 --- /dev/null +++ b/kicad_mcp/tools/bom_tools.py @@ -0,0 +1,762 @@ +""" +Bill of Materials (BOM) processing tools for KiCad projects. +""" +import os +import csv +import json +import pandas as pd +from typing import Dict, List, Any, Optional, Tuple +from mcp.server.fastmcp import FastMCP, Context, Image + +from kicad_mcp.utils.file_utils import get_project_files + +def register_bom_tools(mcp: FastMCP) -> None: + """Register BOM-related tools with the MCP server. + + Args: + mcp: The FastMCP server instance + """ + + @mcp.tool() + async def analyze_bom(project_path: str, ctx: Context) -> Dict[str, Any]: + """Analyze a KiCad project's Bill of Materials. + + This tool will look for BOM files related to a KiCad project and provide + analysis including component counts, categories, and cost estimates if available. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + ctx: MCP context for progress reporting + + Returns: + Dictionary with BOM analysis results + """ + logger.info(f"Analyzing BOM for project: {project_path}") + + if not os.path.exists(project_path): + logger.error(f"Project not found: {project_path}") + ctx.info(f"Project not found: {project_path}") + return {"success": False, "error": f"Project not found: {project_path}"} + + # Report progress + await ctx.report_progress(10, 100) + ctx.info(f"Looking for BOM files related to {os.path.basename(project_path)}") + + # Get all project files + files = get_project_files(project_path) + + # Look for BOM files + bom_files = {} + for file_type, file_path in files.items(): + if "bom" in file_type.lower() or file_path.lower().endswith(".csv"): + bom_files[file_type] = file_path + logger.info(f"Found potential BOM file: {file_path}") + + if not bom_files: + logger.warning("No BOM files found for project") + ctx.info("No BOM files found for project") + return { + "success": False, + "error": "No BOM files found. Export a BOM from KiCad first.", + "project_path": project_path + } + + await ctx.report_progress(30, 100) + + # Analyze each BOM file + results = { + "success": True, + "project_path": project_path, + "bom_files": {}, + "component_summary": {} + } + + total_unique_components = 0 + total_components = 0 + + for file_type, file_path in bom_files.items(): + try: + ctx.info(f"Analyzing {os.path.basename(file_path)}") + + # Parse the BOM file + bom_data, format_info = parse_bom_file(file_path) + + if not bom_data or len(bom_data) == 0: + logger.warning(f"Failed to parse BOM file: {file_path}") + continue + + # Analyze the BOM data + analysis = analyze_bom_data(bom_data, format_info) + + # Add to results + results["bom_files"][file_type] = { + "path": file_path, + "format": format_info, + "analysis": analysis + } + + # Update totals + total_unique_components += analysis["unique_component_count"] + total_components += analysis["total_component_count"] + + logger.info(f"Successfully analyzed BOM file: {file_path}") + + except Exception as e: + logger.error(f"Error analyzing BOM file {file_path}: {str(e)}", exc_info=True) + results["bom_files"][file_type] = { + "path": file_path, + "error": str(e) + } + + await ctx.report_progress(70, 100) + + # Generate overall component summary + if total_components > 0: + results["component_summary"] = { + "total_unique_components": total_unique_components, + "total_components": total_components + } + + # Calculate component categories across all BOMs + all_categories = {} + for file_type, file_info in results["bom_files"].items(): + if "analysis" in file_info and "categories" in file_info["analysis"]: + for category, count in file_info["analysis"]["categories"].items(): + if category not in all_categories: + all_categories[category] = 0 + all_categories[category] += count + + results["component_summary"]["categories"] = all_categories + + # Calculate total cost if available + total_cost = 0.0 + cost_available = False + for file_type, file_info in results["bom_files"].items(): + if "analysis" in file_info and "total_cost" in file_info["analysis"]: + if file_info["analysis"]["total_cost"] > 0: + total_cost += file_info["analysis"]["total_cost"] + cost_available = True + + if cost_available: + results["component_summary"]["total_cost"] = round(total_cost, 2) + currency = next(( + file_info["analysis"].get("currency", "USD") + for file_type, file_info in results["bom_files"].items() + if "analysis" in file_info and "currency" in file_info["analysis"] + ), "USD") + results["component_summary"]["currency"] = currency + + await ctx.report_progress(100, 100) + ctx.info(f"BOM analysis complete: found {total_components} components") + + return results + + @mcp.tool() + async def export_bom_csv(project_path: str, ctx: Context) -> Dict[str, Any]: + """Export a Bill of Materials for a KiCad project. + + This tool attempts to generate a CSV BOM file for a KiCad project. + It requires KiCad to be installed with the appropriate command-line tools. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + ctx: MCP context for progress reporting + + Returns: + Dictionary with export results + """ + logger.info(f"Exporting BOM for project: {project_path}") + + if not os.path.exists(project_path): + logger.error(f"Project not found: {project_path}") + ctx.info(f"Project not found: {project_path}") + return {"success": False, "error": f"Project not found: {project_path}"} + + # Get access to the app context + app_context = ctx.request_context.lifespan_context + kicad_modules_available = app_context.kicad_modules_available + + # Report progress + await ctx.report_progress(10, 100) + + # Get all project files + files = get_project_files(project_path) + + # We need the schematic file to generate a BOM + if "schematic" not in files: + logger.error("Schematic file not found in project") + ctx.info("Schematic file not found in project") + return {"success": False, "error": "Schematic file not found"} + + schematic_file = files["schematic"] + project_dir = os.path.dirname(project_path) + project_name = os.path.basename(project_path)[:-10] # Remove .kicad_pro extension + + await ctx.report_progress(20, 100) + ctx.info(f"Found schematic file: {os.path.basename(schematic_file)}") + + # Try to export BOM + # This will depend on KiCad's command-line tools or Python modules + export_result = {"success": False} + + if kicad_modules_available: + try: + # Try to use KiCad Python modules + ctx.info("Attempting to export BOM using KiCad Python modules...") + export_result = await export_bom_with_python(schematic_file, project_dir, project_name, ctx) + except Exception as e: + logger.error(f"Error exporting BOM with Python modules: {str(e)}", exc_info=True) + ctx.info(f"Error using Python modules: {str(e)}") + export_result = {"success": False, "error": str(e)} + + # If Python method failed, try command-line method + if not export_result.get("success", False): + try: + ctx.info("Attempting to export BOM using command-line tools...") + export_result = await export_bom_with_cli(schematic_file, project_dir, project_name, ctx) + except Exception as e: + logger.error(f"Error exporting BOM with CLI: {str(e)}", exc_info=True) + ctx.info(f"Error using command-line tools: {str(e)}") + export_result = {"success": False, "error": str(e)} + + await ctx.report_progress(100, 100) + + if export_result.get("success", False): + ctx.info(f"BOM exported successfully to {export_result.get('output_file', 'unknown location')}") + else: + ctx.info(f"Failed to export BOM: {export_result.get('error', 'Unknown error')}") + + return export_result + + +# Helper functions for BOM processing + +def parse_bom_file(file_path: str) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + """Parse a BOM file and detect its format. + + Args: + file_path: Path to the BOM file + + Returns: + Tuple containing: + - List of component dictionaries + - Dictionary with format information + """ + logger.info(f"Parsing BOM file: {file_path}") + + # Check file extension + _, ext = os.path.splitext(file_path) + ext = ext.lower() + + # Dictionary to store format detection info + format_info = { + "file_type": ext, + "detected_format": "unknown", + "header_fields": [] + } + + # Empty list to store component data + components = [] + + try: + if ext == '.csv': + # Try to parse as CSV + with open(file_path, 'r', encoding='utf-8-sig') as f: + # Read a few lines to analyze the format + sample = ''.join([f.readline() for _ in range(10)]) + f.seek(0) # Reset file pointer + + # Try to detect the delimiter + if ',' in sample: + delimiter = ',' + elif ';' in sample: + delimiter = ';' + elif '\t' in sample: + delimiter = '\t' + else: + delimiter = ',' # Default + + format_info["delimiter"] = delimiter + + # Read CSV + reader = csv.DictReader(f, delimiter=delimiter) + format_info["header_fields"] = reader.fieldnames if reader.fieldnames else [] + + # Detect BOM format based on header fields + header_str = ','.join(format_info["header_fields"]).lower() + + if 'reference' in header_str and 'value' in header_str: + format_info["detected_format"] = "kicad" + elif 'designator' in header_str: + format_info["detected_format"] = "altium" + elif 'part number' in header_str or 'manufacturer part' in header_str: + format_info["detected_format"] = "generic" + + # Read components + for row in reader: + components.append(dict(row)) + + elif ext == '.xml': + # Basic XML parsing + import xml.etree.ElementTree as ET + tree = ET.parse(file_path) + root = tree.getroot() + + format_info["detected_format"] = "xml" + + # Try to extract components based on common XML BOM formats + component_elements = root.findall('.//component') or root.findall('.//Component') + + if component_elements: + for elem in component_elements: + component = {} + for attr in elem.attrib: + component[attr] = elem.attrib[attr] + for child in elem: + component[child.tag] = child.text + components.append(component) + + elif ext == '.json': + # Parse JSON + with open(file_path, 'r') as f: + data = json.load(f) + + format_info["detected_format"] = "json" + + # Try to find components array in common JSON formats + if isinstance(data, list): + components = data + elif 'components' in data: + components = data['components'] + elif 'parts' in data: + components = data['parts'] + + else: + # Unknown format, try generic CSV parsing as fallback + try: + with open(file_path, 'r', encoding='utf-8-sig') as f: + reader = csv.DictReader(f) + format_info["header_fields"] = reader.fieldnames if reader.fieldnames else [] + format_info["detected_format"] = "unknown_csv" + + for row in reader: + components.append(dict(row)) + except: + logger.error(f"Failed to parse unknown file format: {file_path}") + return [], {"detected_format": "unsupported"} + + except Exception as e: + logger.error(f"Error parsing BOM file: {str(e)}", exc_info=True) + return [], {"error": str(e)} + + # Check if we actually got components + if not components: + logger.warning(f"No components found in BOM file: {file_path}") + else: + logger.info(f"Successfully parsed {len(components)} components from {file_path}") + + # Add a sample of the fields found + if components: + format_info["sample_fields"] = list(components[0].keys()) + + return components, format_info + + +def analyze_bom_data(components: List[Dict[str, Any]], format_info: Dict[str, Any]) -> Dict[str, Any]: + """Analyze component data from a BOM file. + + Args: + components: List of component dictionaries + format_info: Dictionary with format information + + Returns: + Dictionary with analysis results + """ + logger.info(f"Analyzing {len(components)} components") + + # Initialize results + results = { + "unique_component_count": 0, + "total_component_count": 0, + "categories": {}, + "has_cost_data": False + } + + if not components: + return results + + # Try to convert to pandas DataFrame for easier analysis + try: + df = pd.DataFrame(components) + + # Clean up column names + df.columns = [str(col).strip().lower() for col in df.columns] + + # Try to identify key columns based on format + ref_col = None + value_col = None + quantity_col = None + footprint_col = None + cost_col = None + category_col = None + + # Check for reference designator column + for possible_col in ['reference', 'designator', 'references', 'designators', 'refdes', 'ref']: + if possible_col in df.columns: + ref_col = possible_col + break + + # Check for value column + for possible_col in ['value', 'component', 'comp', 'part', 'component value', 'comp value']: + if possible_col in df.columns: + value_col = possible_col + break + + # Check for quantity column + for possible_col in ['quantity', 'qty', 'count', 'amount']: + if possible_col in df.columns: + quantity_col = possible_col + break + + # Check for footprint column + for possible_col in ['footprint', 'package', 'pattern', 'pcb footprint']: + if possible_col in df.columns: + footprint_col = possible_col + break + + # Check for cost column + for possible_col in ['cost', 'price', 'unit price', 'unit cost', 'cost each']: + if possible_col in df.columns: + cost_col = possible_col + break + + # Check for category column + for possible_col in ['category', 'type', 'group', 'component type', 'lib']: + if possible_col in df.columns: + category_col = possible_col + break + + # Count total components + if quantity_col: + # Try to convert quantity to numeric + df[quantity_col] = pd.to_numeric(df[quantity_col], errors='coerce').fillna(1) + results["total_component_count"] = int(df[quantity_col].sum()) + else: + # If no quantity column, assume each row is one component + results["total_component_count"] = len(df) + + # Count unique components + results["unique_component_count"] = len(df) + + # Calculate categories + if category_col: + # Use provided category column + categories = df[category_col].value_counts().to_dict() + results["categories"] = {str(k): int(v) for k, v in categories.items()} + elif footprint_col: + # Use footprint as category + categories = df[footprint_col].value_counts().to_dict() + results["categories"] = {str(k): int(v) for k, v in categories.items()} + elif ref_col: + # Try to extract categories from reference designators (R=resistor, C=capacitor, etc.) + def extract_prefix(ref): + if isinstance(ref, str): + import re + match = re.match(r'^([A-Za-z]+)', ref) + if match: + return match.group(1) + return "Other" + + if isinstance(df[ref_col].iloc[0], str) and ',' in df[ref_col].iloc[0]: + # Multiple references in one cell + all_refs = [] + for refs in df[ref_col]: + all_refs.extend([r.strip() for r in refs.split(',')]) + + categories = {} + for ref in all_refs: + prefix = extract_prefix(ref) + categories[prefix] = categories.get(prefix, 0) + 1 + + results["categories"] = categories + else: + # Single reference per row + categories = df[ref_col].apply(extract_prefix).value_counts().to_dict() + results["categories"] = {str(k): int(v) for k, v in categories.items()} + + # Map common reference prefixes to component types + category_mapping = { + 'R': 'Resistors', + 'C': 'Capacitors', + 'L': 'Inductors', + 'D': 'Diodes', + 'Q': 'Transistors', + 'U': 'ICs', + 'SW': 'Switches', + 'J': 'Connectors', + 'K': 'Relays', + 'Y': 'Crystals/Oscillators', + 'F': 'Fuses', + 'T': 'Transformers' + } + + mapped_categories = {} + for cat, count in results["categories"].items(): + if cat in category_mapping: + mapped_name = category_mapping[cat] + mapped_categories[mapped_name] = mapped_categories.get(mapped_name, 0) + count + else: + mapped_categories[cat] = count + + results["categories"] = mapped_categories + + # Calculate cost if available + if cost_col: + try: + # Try to extract numeric values from cost field + df[cost_col] = df[cost_col].astype(str).str.replace('$', '').str.replace(',', '') + df[cost_col] = pd.to_numeric(df[cost_col], errors='coerce') + + # Remove NaN values + df_with_cost = df.dropna(subset=[cost_col]) + + if not df_with_cost.empty: + results["has_cost_data"] = True + + if quantity_col: + total_cost = (df_with_cost[cost_col] * df_with_cost[quantity_col]).sum() + else: + total_cost = df_with_cost[cost_col].sum() + + results["total_cost"] = round(float(total_cost), 2) + + # Try to determine currency + # Check first row that has cost for currency symbols + for _, row in df.iterrows(): + cost_str = str(row.get(cost_col, '')) + if '$' in cost_str: + results["currency"] = "USD" + break + elif '€' in cost_str: + results["currency"] = "EUR" + break + elif '£' in cost_str: + results["currency"] = "GBP" + break + + if "currency" not in results: + results["currency"] = "USD" # Default + except: + logger.warning("Failed to parse cost data") + + # Add extra insights + if ref_col and value_col: + # Check for common components by value + value_counts = df[value_col].value_counts() + most_common = value_counts.head(5).to_dict() + results["most_common_values"] = {str(k): int(v) for k, v in most_common.items()} + + except Exception as e: + logger.error(f"Error analyzing BOM data: {str(e)}", exc_info=True) + # Fallback to basic analysis + results["unique_component_count"] = len(components) + results["total_component_count"] = len(components) + + return results + + +async def export_bom_with_python(schematic_file: str, output_dir: str, project_name: str, ctx: Context) -> Dict[str, Any]: + """Export a BOM using KiCad Python modules. + + Args: + schematic_file: Path to the schematic file + output_dir: Directory to save the BOM + project_name: Name of the project + ctx: MCP context for progress reporting + + Returns: + Dictionary with export results + """ + logger.info(f"Exporting BOM for schematic: {schematic_file}") + await ctx.report_progress(30, 100) + + try: + # Try to import KiCad Python modules + # This is a placeholder since exporting BOMs from schematic files + # is complex and KiCad's API for this is not well-documented + import pcbnew + + # For now, return a message indicating this method is not implemented yet + logger.warning("BOM export with Python modules not fully implemented") + ctx.info("BOM export with Python modules not fully implemented yet") + + return { + "success": False, + "error": "BOM export using Python modules is not fully implemented yet. Try using the command-line method.", + "schematic_file": schematic_file + } + + except ImportError: + logger.error("Failed to import KiCad Python modules") + return { + "success": False, + "error": "Failed to import KiCad Python modules", + "schematic_file": schematic_file + } + + +async def export_bom_with_cli(schematic_file: str, output_dir: str, project_name: str, ctx: Context) -> Dict[str, Any]: + """Export a BOM using KiCad command-line tools. + + Args: + schematic_file: Path to the schematic file + output_dir: Directory to save the BOM + project_name: Name of the project + ctx: MCP context for progress reporting + + Returns: + Dictionary with export results + """ + import subprocess + import platform + + system = platform.system() + logger.info(f"Exporting BOM using CLI tools on {system}") + await ctx.report_progress(40, 100) + + # Output file path + output_file = os.path.join(output_dir, f"{project_name}_bom.csv") + + # Define the command based on operating system + if system == "Darwin": # macOS + from kicad_mcp.config import KICAD_APP_PATH + + # Path to KiCad command-line tools on macOS + kicad_cli = os.path.join(KICAD_APP_PATH, "Contents/MacOS/kicad-cli") + + if not os.path.exists(kicad_cli): + return { + "success": False, + "error": f"KiCad CLI tool not found at {kicad_cli}", + "schematic_file": schematic_file + } + + # Command to generate BOM + cmd = [ + kicad_cli, + "sch", + "export", + "bom", + "--output", output_file, + schematic_file + ] + + elif system == "Windows": + from kicad_mcp.config import KICAD_APP_PATH + + # Path to KiCad command-line tools on Windows + kicad_cli = os.path.join(KICAD_APP_PATH, "bin", "kicad-cli.exe") + + if not os.path.exists(kicad_cli): + return { + "success": False, + "error": f"KiCad CLI tool not found at {kicad_cli}", + "schematic_file": schematic_file + } + + # Command to generate BOM + cmd = [ + kicad_cli, + "sch", + "export", + "bom", + "--output", output_file, + schematic_file + ] + + elif system == "Linux": + # Assume kicad-cli is in the PATH + kicad_cli = "kicad-cli" + + # Command to generate BOM + cmd = [ + kicad_cli, + "sch", + "export", + "bom", + "--output", output_file, + schematic_file + ] + + else: + return { + "success": False, + "error": f"Unsupported operating system: {system}", + "schematic_file": schematic_file + } + + try: + logger.info(f"Running command: {' '.join(cmd)}") + await ctx.report_progress(60, 100) + + # Run the command + process = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + + # Check if the command was successful + if process.returncode != 0: + logger.error(f"BOM export command failed with code {process.returncode}") + logger.error(f"Error output: {process.stderr}") + + return { + "success": False, + "error": f"BOM export command failed: {process.stderr}", + "schematic_file": schematic_file, + "command": ' '.join(cmd) + } + + # Check if the output file was created + if not os.path.exists(output_file): + return { + "success": False, + "error": "BOM file was not created", + "schematic_file": schematic_file, + "output_file": output_file + } + + await ctx.report_progress(80, 100) + + # Read the first few lines of the BOM to verify it's valid + with open(output_file, 'r') as f: + bom_content = f.read(1024) # Read first 1KB + + if len(bom_content.strip()) == 0: + return { + "success": False, + "error": "Generated BOM file is empty", + "schematic_file": schematic_file, + "output_file": output_file + } + + return { + "success": True, + "schematic_file": schematic_file, + "output_file": output_file, + "file_size": os.path.getsize(output_file), + "message": "BOM exported successfully" + } + + except subprocess.TimeoutExpired: + logger.error("BOM export command timed out after 30 seconds") + return { + "success": False, + "error": "BOM export command timed out after 30 seconds", + "schematic_file": schematic_file + } + + except Exception as e: + logger.error(f"Error exporting BOM: {str(e)}", exc_info=True) + return { + "success": False, + "error": f"Error exporting BOM: {str(e)}", + "schematic_file": schematic_file + }