kicad-mcp/kicad_mcp/resources/bom_resources.py
Ryan Malloy 995dfd57c1 Add comprehensive advanced KiCad features and fix MCP compatibility issues
- Implement 3D model analysis and mechanical constraints checking
- Add advanced DRC rule customization for HDI, RF, and automotive applications
- Create symbol library management with analysis and validation tools
- Implement PCB layer stack-up analysis with impedance calculations
- Fix Context parameter validation errors causing client failures
- Add enhanced tool annotations with examples for better LLM compatibility
- Include comprehensive test coverage improvements (22.21% coverage)
- Add CLAUDE.md documentation for development guidance

New Advanced Tools:
• 3D model analysis: analyze_3d_models, check_mechanical_constraints
• Advanced DRC: create_drc_rule_set, analyze_pcb_drc_violations
• Symbol management: analyze_symbol_library, validate_symbol_library
• Layer analysis: analyze_pcb_stackup, calculate_trace_impedance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-11 15:57:46 -06:00

292 lines
12 KiB
Python

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