Add comprehensive BOM management functionality with analysis, export, and viewing features

This commit is contained in:
Lama 2025-03-20 13:54:48 -04:00
parent c69bd66f71
commit 6953e5dc9a
5 changed files with 1384 additions and 0 deletions

218
docs/bom_guide.md Normal file
View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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
}