diff --git a/README.md b/README.md index 11a7bde..ad805d1 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ The KiCad MCP Server provides several key features: - **Project Management**: List, examine, and open KiCad projects - **PCB Design Analysis**: Get insights about your PCB designs and schematics +- **Netlist Extraction**: Extract and analyze component connections from schematics - **BOM Management**: Analyze and export Bills of Materials - **Design Rule Checking**: Run DRC checks and track your progress over time - **PCB Visualization**: Generate visual representations of your PCB layouts @@ -155,6 +156,7 @@ Detailed documentation for each feature is available in the `docs/` directory: - [Project Management](docs/project_guide.md) - [PCB Design Analysis](docs/analysis_guide.md) +- [Netlist Extraction](docs/netlist_guide.md) - [Bill of Materials (BOM)](docs/bom_guide.md) - [Design Rule Checking (DRC)](docs/drc_guide.md) - [PCB Visualization](docs/thumbnail_guide.md) diff --git a/docs/netlist_guide.md b/docs/netlist_guide.md new file mode 100644 index 0000000..6835590 --- /dev/null +++ b/docs/netlist_guide.md @@ -0,0 +1,183 @@ +# KiCad Netlist Extraction Guide + +This guide explains how to use the schematic netlist extraction features in the KiCad MCP Server. + +## Overview + +The netlist extraction functionality allows you to: + +1. Extract comprehensive netlist information from KiCad schematics +2. Analyze component connections and relationships +3. Identify power and signal nets +4. Find specific component connections +5. Visualize the connectivity of your design + +## Quick Reference + +| Task | Example Prompt | +|------|---------------| +| Extract netlist | `Extract the netlist from my schematic at /path/to/project.kicad_sch` | +| Analyze project netlist | `Analyze the netlist in my KiCad project at /path/to/project.kicad_pro` | +| Check component connections | `Show me the connections for R5 in my schematic at /path/to/project.kicad_sch` | +| View formatted netlist | `Show me the netlist report for /path/to/project.kicad_sch` | + +## Using Netlist Features + +### Extracting a Netlist + +To extract a netlist from a schematic: + +``` +Extract the netlist from my schematic at /path/to/project.kicad_sch +``` + +This will: +- Parse the schematic file +- Extract all components and their properties +- Identify connections between components +- Analyze power and signal nets +- Return comprehensive netlist information + +### Project-Based Netlist Extraction + +To extract a netlist from a KiCad project: + +``` +Extract the netlist for my KiCad project at /path/to/project.kicad_pro +``` + +This will find the schematic associated with your project and extract its netlist. + +### Analyzing Component Connections + +To find all connections for a specific component: + +``` +Show me the connections for U1 in my schematic at /path/to/project.kicad_sch +``` + +This will provide: +- Detailed component information +- All pins and their connections +- Components connected to each pin +- Net names for each connection + +### Viewing Netlist Reports + +For a formatted netlist report: + +``` +Show me the netlist report for /path/to/project.kicad_sch +``` + +This will load the `kicad://netlist/project_path` resource, showing: +- Component summary +- Net summary +- Connection details +- Power nets +- Potential issues + +## Understanding Netlist Data + +### Components + +Components in a netlist include: + +| Field | Description | Example | +|-------|-------------|---------| +| Reference | Component reference designator | R1, C2, U3 | +| Type (lib_id) | Component type from library | Device:R, Device:C | +| Value | Component value | 10k, 100n, ATmega328P | +| Footprint | PCB footprint | Resistor_SMD:R_0805 | +| Pins | List of pin numbers and names | 1 (VCC), 2 (GND) | + +### Nets + +Nets in a netlist include: + +| Field | Description | Example | +|-------|-------------|---------| +| Name | Net name | VCC, GND, NET1 | +| Pins | List of connected pins | R1.1, C1.1, U1.5 | +| Type | Power or signal | Power, Signal | + +## Advanced Usage + +### Integration with BOM Analysis + +You can combine netlist extraction with BOM analysis: + +``` +Compare the netlist and BOM for my project at /path/to/project.kicad_pro +``` + +This helps identify: +- Components in the schematic but missing from the BOM +- Components in the BOM but missing from the schematic +- Value or footprint inconsistencies + +### Design Validation + +Use netlist extraction for design validation: + +``` +Check for floating inputs in my schematic at /path/to/project.kicad_sch +``` + +``` +Verify power connections for all ICs in my project at /path/to/project.kicad_pro +``` + +### Power Analysis + +Analyze your design's power distribution: + +``` +Show me all power nets in my schematic at /path/to/project.kicad_sch +``` + +``` +List all components connected to the VCC net in my project at /path/to/project.kicad_pro +``` + +## Tips for Better Netlist Analysis + +### Schematic Organization + +For more meaningful netlist analysis: + +1. **Use descriptive net names** instead of auto-generated ones +2. **Add power flags** to explicitly mark power inputs +3. **Organize hierarchical sheets** by function +4. **Use global labels** consistently for important signals +5. **Add metadata as properties** to components for better analysis + +### Working with Complex Designs + +For large schematics: + +1. Focus on **specific sections** using hierarchical labels +2. Analyze **one component type at a time** +3. Examine **critical nets** individually +4. Use **reference designators systematically** (e.g., U1-U10 for microcontrollers) + +## Troubleshooting + +### Netlist Extraction Fails + +If netlist extraction fails: + +1. **Check file paths**: Ensure the schematic file exists and has the correct extension +2. **Verify file format**: Make sure the schematic is a valid KiCad 6+ .kicad_sch file +3. **Check file permissions**: Ensure you have read access to the file +4. **Look for syntax errors**: Recent edits might have corrupted the schematic file +5. **Try a simpler schematic**: Start with a small test case to verify functionality + +### Missing Connections + +If connections are missing from the netlist: + +1. **Check for disconnected wires**: Wires that appear connected in KiCad might not actually be connected +2. **Verify junction points**: Make sure junction dots are present where needed +3. **Check hierarchical connections**: Ensure labels match across hierarchical sheets +4. **Verify net labels**: Net labels must be correctly placed to establish connections diff --git a/kicad_mcp/resources/netlist_resources.py b/kicad_mcp/resources/netlist_resources.py new file mode 100644 index 0000000..a6ee457 --- /dev/null +++ b/kicad_mcp/resources/netlist_resources.py @@ -0,0 +1,253 @@ +""" +Netlist resources for KiCad schematics. +""" +import os +from mcp.server.fastmcp import FastMCP + +from kicad_mcp.utils.file_utils import get_project_files +from kicad_mcp.utils.netlist_parser import extract_netlist, analyze_netlist + + +def register_netlist_resources(mcp: FastMCP) -> None: + """Register netlist-related resources with the MCP server. + + Args: + mcp: The FastMCP server instance + """ + + @mcp.resource("kicad://netlist/{schematic_path}") + def get_netlist_resource(schematic_path: str) -> str: + """Get a formatted netlist report for a KiCad schematic. + + Args: + schematic_path: Path to the KiCad schematic file (.kicad_sch) + + Returns: + Markdown-formatted netlist report + """ + print(f"Generating netlist report for schematic: {schematic_path}") + + if not os.path.exists(schematic_path): + return f"Schematic file not found: {schematic_path}" + + try: + # Extract netlist information + netlist_data = extract_netlist(schematic_path) + + if "error" in netlist_data: + return f"# Netlist Extraction Error\n\nError: {netlist_data['error']}" + + # Analyze the netlist + analysis_results = analyze_netlist(netlist_data) + + # Format as Markdown report + schematic_name = os.path.basename(schematic_path) + + report = f"# Netlist Analysis for {schematic_name}\n\n" + + # Overview section + report += "## Overview\n\n" + report += f"- **Components**: {netlist_data['component_count']}\n" + report += f"- **Nets**: {netlist_data['net_count']}\n" + + if "total_pin_connections" in analysis_results: + report += f"- **Pin Connections**: {analysis_results['total_pin_connections']}\n" + + report += "\n" + + # Component Types section + if "component_types" in analysis_results and analysis_results["component_types"]: + report += "## Component Types\n\n" + + for comp_type, count in analysis_results["component_types"].items(): + report += f"- **{comp_type}**: {count}\n" + + report += "\n" + + # Power Nets section + if "power_nets" in analysis_results and analysis_results["power_nets"]: + report += "## Power Nets\n\n" + + for net_name in analysis_results["power_nets"]: + report += f"- **{net_name}**\n" + + report += "\n" + + # Components section + components = netlist_data.get("components", {}) + if components: + report += "## Component List\n\n" + report += "| Reference | Type | Value | Footprint |\n" + report += "|-----------|------|-------|----------|\n" + + # Sort components by reference + for ref in sorted(components.keys()): + component = components[ref] + lib_id = component.get('lib_id', 'Unknown') + value = component.get('value', '') + footprint = component.get('footprint', '') + + report += f"| {ref} | {lib_id} | {value} | {footprint} |\n" + + report += "\n" + + # Nets section (limit to showing first 20 for readability) + nets = netlist_data.get("nets", {}) + if nets: + report += "## Net List\n\n" + + # Filter to show only the first 20 nets + net_items = list(nets.items())[:20] + + for net_name, pins in net_items: + report += f"### Net: {net_name}\n\n" + + if pins: + report += "**Connected Pins:**\n\n" + for pin in pins: + component = pin.get('component', 'Unknown') + pin_num = pin.get('pin', 'Unknown') + report += f"- {component}.{pin_num}\n" + else: + report += "*No connections found*\n" + + report += "\n" + + if len(nets) > 20: + report += f"*...and {len(nets) - 20} more nets*\n\n" + + return report + + except Exception as e: + return f"# Netlist Extraction Error\n\nError: {str(e)}" + + @mcp.resource("kicad://project_netlist/{project_path}") + def get_project_netlist_resource(project_path: str) -> str: + """Get a formatted netlist report for a KiCad project. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + + Returns: + Markdown-formatted netlist report + """ + print(f"Generating netlist report for project: {project_path}") + + if not os.path.exists(project_path): + return f"Project not found: {project_path}" + + # Get the schematic file + try: + files = get_project_files(project_path) + + if "schematic" not in files: + return "Schematic file not found in project" + + schematic_path = files["schematic"] + print(f"Found schematic file: {schematic_path}") + + # Get the netlist resource for this schematic + return get_netlist_resource(schematic_path) + + except Exception as e: + return f"# Netlist Extraction Error\n\nError: {str(e)}" + + @mcp.resource("kicad://component/{schematic_path}/{component_ref}") + def get_component_resource(schematic_path: str, component_ref: str) -> str: + """Get detailed information about a specific component and its connections. + + Args: + schematic_path: Path to the KiCad schematic file (.kicad_sch) + component_ref: Component reference designator (e.g., R1) + + Returns: + Markdown-formatted component report + """ + print(f"Generating component report for {component_ref} in schematic: {schematic_path}") + + if not os.path.exists(schematic_path): + return f"Schematic file not found: {schematic_path}" + + try: + # Extract netlist information + netlist_data = extract_netlist(schematic_path) + + if "error" in netlist_data: + return f"# Component Analysis Error\n\nError: {netlist_data['error']}" + + # Check if the component exists + components = netlist_data.get("components", {}) + if component_ref not in components: + return f"# Component Not Found\n\nComponent {component_ref} was not found in the schematic.\n\n**Available Components**:\n\n" + "\n".join([f"- {ref}" for ref in sorted(components.keys())]) + + component_info = components[component_ref] + + # Format as Markdown report + report = f"# Component Analysis: {component_ref}\n\n" + + # Component Details section + report += "## Component Details\n\n" + report += f"- **Reference**: {component_ref}\n" + + if "lib_id" in component_info: + report += f"- **Type**: {component_info['lib_id']}\n" + + if "value" in component_info: + report += f"- **Value**: {component_info['value']}\n" + + if "footprint" in component_info: + report += f"- **Footprint**: {component_info['footprint']}\n" + + # Add other properties + if "properties" in component_info: + for prop_name, prop_value in component_info["properties"].items(): + report += f"- **{prop_name}**: {prop_value}\n" + + report += "\n" + + # Pins section + if "pins" in component_info: + report += "## Pins\n\n" + + for pin in component_info["pins"]: + report += f"- **Pin {pin['num']}**: {pin['name']}\n" + + report += "\n" + + # Connections section + report += "## Connections\n\n" + + nets = netlist_data.get("nets", {}) + connected_nets = [] + + for net_name, pins in nets.items(): + # Check if any pin belongs to our component + for pin in pins: + if pin.get('component') == component_ref: + connected_nets.append({ + "net_name": net_name, + "pin": pin.get('pin', 'Unknown'), + "connections": [p for p in pins if p.get('component') != component_ref] + }) + + if connected_nets: + for net in connected_nets: + report += f"### Pin {net['pin']} - Net: {net['net_name']}\n\n" + + if net["connections"]: + report += "**Connected To:**\n\n" + for conn in net["connections"]: + comp = conn.get('component', 'Unknown') + pin = conn.get('pin', 'Unknown') + report += f"- {comp}.{pin}\n" + else: + report += "*No connections*\n" + + report += "\n" + else: + report += "*No connections found for this component*\n\n" + + return report + + except Exception as e: + return f"# Component Analysis Error\n\nError: {str(e)}" diff --git a/kicad_mcp/server.py b/kicad_mcp/server.py index 6c7eadb..a750ab3 100644 --- a/kicad_mcp/server.py +++ b/kicad_mcp/server.py @@ -8,6 +8,7 @@ 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 +from kicad_mcp.resources.netlist_resources import register_netlist_resources # Import tool handlers from kicad_mcp.tools.project_tools import register_project_tools @@ -15,6 +16,7 @@ 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 +from kicad_mcp.tools.netlist_tools import register_netlist_tools # Import prompt handlers from kicad_mcp.prompts.templates import register_prompts @@ -53,6 +55,7 @@ def create_server() -> FastMCP: register_file_resources(mcp) register_drc_resources(mcp) register_bom_resources(mcp) + register_netlist_resources(mcp) # Register tools logger.debug("Registering tools...") @@ -61,6 +64,7 @@ def create_server() -> FastMCP: register_export_tools(mcp) register_drc_tools(mcp) register_bom_tools(mcp) + register_netlist_tools(mcp) # Register prompts logger.debug("Registering prompts...") diff --git a/kicad_mcp/tools/netlist_tools.py b/kicad_mcp/tools/netlist_tools.py new file mode 100644 index 0000000..1e2c8d0 --- /dev/null +++ b/kicad_mcp/tools/netlist_tools.py @@ -0,0 +1,371 @@ +""" +Netlist extraction and analysis tools for KiCad schematics. +""" +import os +from typing import Dict, List, Any, Optional +from mcp.server.fastmcp import FastMCP, Context + +from kicad_mcp.utils.file_utils import get_project_files +from kicad_mcp.utils.logger import Logger +from kicad_mcp.utils.netlist_parser import extract_netlist, analyze_netlist + +# Create logger for this module +logger = Logger() + +def register_netlist_tools(mcp: FastMCP) -> None: + """Register netlist-related tools with the MCP server. + + Args: + mcp: The FastMCP server instance + """ + + @mcp.tool() + async def extract_schematic_netlist(schematic_path: str, ctx: Context) -> Dict[str, Any]: + """Extract netlist information from a KiCad schematic. + + This tool parses a KiCad schematic file and extracts comprehensive + netlist information including components, connections, and labels. + + Args: + schematic_path: Path to the KiCad schematic file (.kicad_sch) + ctx: MCP context for progress reporting + + Returns: + Dictionary with netlist information + """ + logger.info(f"Extracting netlist from schematic: {schematic_path}") + + if not os.path.exists(schematic_path): + logger.error(f"Schematic file not found: {schematic_path}") + ctx.info(f"Schematic file not found: {schematic_path}") + return {"success": False, "error": f"Schematic file not found: {schematic_path}"} + + # Report progress + await ctx.report_progress(10, 100) + ctx.info(f"Loading schematic file: {os.path.basename(schematic_path)}") + + # Extract netlist information + try: + await ctx.report_progress(20, 100) + ctx.info("Parsing schematic structure...") + + netlist_data = extract_netlist(schematic_path) + + if "error" in netlist_data: + logger.error(f"Error extracting netlist: {netlist_data['error']}") + ctx.info(f"Error extracting netlist: {netlist_data['error']}") + return {"success": False, "error": netlist_data['error']} + + await ctx.report_progress(50, 100) + + # Check if the component exists + components = netlist_data.get("components", {}) + if component_ref not in components: + logger.error(f"Component {component_ref} not found in schematic") + ctx.info(f"Component {component_ref} not found in schematic") + return { + "success": False, + "error": f"Component {component_ref} not found in schematic", + "available_components": list(components.keys()) + } + + component_info = components[component_ref] + ctx.info(f"Found component: {component_ref} ({component_info.get('lib_id', 'unknown type')})") + + # Find all nets connected to this component + await ctx.report_progress(70, 100) + ctx.info("Analyzing component connections...") + + # Build connection information + connections = [] + connected_components = set() + + nets = netlist_data.get("nets", {}) + for net_name, pins in nets.items(): + # Check if any pin belongs to our component + component_pins = [pin for pin in pins if pin.get('component') == component_ref] + + if component_pins: + # This net connects to our component + net_info = { + "net_name": net_name, + "pins": component_pins, + "connected_to": [] + } + + # Find other components connected to this net + for pin in pins: + other_component = pin.get('component') + if other_component and other_component != component_ref: + connected_components.add(other_component) + net_info["connected_to"].append({ + "component": other_component, + "pin": pin.get('pin', 'unknown') + }) + + connections.append(net_info) + + await ctx.report_progress(90, 100) + + # Build result + result = { + "success": True, + "component_ref": component_ref, + "component_info": component_info, + "connections": connections, + "connected_component_count": len(connected_components), + "connected_components": list(connected_components) + } + + # Complete progress + await ctx.report_progress(100, 100) + ctx.info(f"Found {len(connections)} connections to component {component_ref}") + + return resultf"Error extracting netlist: {netlist_data['error']}") + ctx.info(f"Error extracting netlist: {netlist_data['error']}") + return {"success": False, "error": netlist_data['error']} + + await ctx.report_progress(60, 100) + ctx.info(f"Extracted {netlist_data['component_count']} components and {netlist_data['net_count']} nets") + + # Analyze the netlist + await ctx.report_progress(70, 100) + ctx.info("Analyzing netlist data...") + + analysis_results = analyze_netlist(netlist_data) + + await ctx.report_progress(90, 100) + + # Build result + result = { + "success": True, + "schematic_path": schematic_path, + "component_count": netlist_data["component_count"], + "net_count": netlist_data["net_count"], + "components": netlist_data["components"], + "nets": netlist_data["nets"], + "analysis": analysis_results + } + + # Complete progress + await ctx.report_progress(100, 100) + ctx.info("Netlist extraction complete") + + return result + + except Exception as e: + logger.error(f"Error extracting netlist: {str(e)}") + ctx.info(f"Error extracting netlist: {str(e)}") + return {"success": False, "error": str(e)} + + @mcp.tool() + async def extract_project_netlist(project_path: str, ctx: Context) -> Dict[str, Any]: + """Extract netlist from a KiCad project's schematic. + + This tool finds the schematic associated with a KiCad project + and extracts its netlist information. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + ctx: MCP context for progress reporting + + Returns: + Dictionary with netlist information + """ + logger.info(f"Extracting netlist 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) + + # Get the schematic file + try: + files = get_project_files(project_path) + + 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 in project"} + + schematic_path = files["schematic"] + logger.info(f"Found schematic file: {schematic_path}") + ctx.info(f"Found schematic file: {os.path.basename(schematic_path)}") + + # Extract netlist + await ctx.report_progress(20, 100) + + # Call the schematic netlist extraction + result = await extract_schematic_netlist(schematic_path, ctx) + + # Add project path to result + if "success" in result and result["success"]: + result["project_path"] = project_path + + return result + + except Exception as e: + logger.error(f"Error extracting project netlist: {str(e)}") + ctx.info(f"Error extracting project netlist: {str(e)}") + return {"success": False, "error": str(e)} + + @mcp.tool() + async def analyze_schematic_connections(schematic_path: str, ctx: Context) -> Dict[str, Any]: + """Analyze connections in a KiCad schematic. + + This tool provides detailed analysis of component connections, + including power nets, signal paths, and potential issues. + + Args: + schematic_path: Path to the KiCad schematic file (.kicad_sch) + ctx: MCP context for progress reporting + + Returns: + Dictionary with connection analysis + """ + logger.info(f"Analyzing connections in schematic: {schematic_path}") + + if not os.path.exists(schematic_path): + logger.error(f"Schematic file not found: {schematic_path}") + ctx.info(f"Schematic file not found: {schematic_path}") + return {"success": False, "error": f"Schematic file not found: {schematic_path}"} + + # Report progress + await ctx.report_progress(10, 100) + ctx.info(f"Extracting netlist from: {os.path.basename(schematic_path)}") + + # Extract netlist information + try: + netlist_data = extract_netlist(schematic_path) + + if "error" in netlist_data: + logger.error(f"Error extracting netlist: {netlist_data['error']}") + ctx.info(f"Error extracting netlist: {netlist_data['error']}") + return {"success": False, "error": netlist_data['error']} + + await ctx.report_progress(40, 100) + + # Advanced connection analysis + ctx.info("Performing connection analysis...") + + analysis = { + "component_count": netlist_data["component_count"], + "net_count": netlist_data["net_count"], + "component_types": {}, + "power_nets": [], + "signal_nets": [], + "potential_issues": [] + } + + # Analyze component types + components = netlist_data.get("components", {}) + for ref, component in components.items(): + # Extract component type from reference (e.g., R1 -> R) + import re + comp_type_match = re.match(r'^([A-Za-z_]+)', ref) + if comp_type_match: + comp_type = comp_type_match.group(1) + if comp_type not in analysis["component_types"]: + analysis["component_types"][comp_type] = 0 + analysis["component_types"][comp_type] += 1 + + await ctx.report_progress(60, 100) + + # Identify power nets + nets = netlist_data.get("nets", {}) + for net_name, pins in nets.items(): + if any(net_name.startswith(prefix) for prefix in ["VCC", "VDD", "GND", "+5V", "+3V3", "+12V"]): + analysis["power_nets"].append({ + "name": net_name, + "pin_count": len(pins) + }) + else: + analysis["signal_nets"].append({ + "name": net_name, + "pin_count": len(pins) + }) + + await ctx.report_progress(80, 100) + + # Check for potential issues + # 1. Nets with only one connection (floating) + for net_name, pins in nets.items(): + if len(pins) <= 1 and not any(net_name.startswith(prefix) for prefix in ["VCC", "VDD", "GND", "+5V", "+3V3", "+12V"]): + analysis["potential_issues"].append({ + "type": "floating_net", + "net": net_name, + "description": f"Net '{net_name}' appears to be floating (only has {len(pins)} connection)" + }) + + # 2. Power pins without connections + # This would require more detailed parsing of the schematic + + await ctx.report_progress(90, 100) + + # Build result + result = { + "success": True, + "schematic_path": schematic_path, + "analysis": analysis + } + + # Complete progress + await ctx.report_progress(100, 100) + ctx.info("Connection analysis complete") + + return result + + except Exception as e: + logger.error(f"Error analyzing connections: {str(e)}") + ctx.info(f"Error analyzing connections: {str(e)}") + return {"success": False, "error": str(e)} + + @mcp.tool() + async def find_component_connections(project_path: str, component_ref: str, ctx: Context) -> Dict[str, Any]: + """Find all connections for a specific component in a KiCad project. + + This tool extracts information about how a specific component + is connected to other components in the schematic. + + Args: + project_path: Path to the KiCad project file (.kicad_pro) + component_ref: Component reference (e.g., "R1", "U3") + ctx: MCP context for progress reporting + + Returns: + Dictionary with component connection information + """ + logger.info(f"Finding connections for component {component_ref} in 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) + + # Get the schematic file + try: + files = get_project_files(project_path) + + 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 in project"} + + schematic_path = files["schematic"] + logger.info(f"Found schematic file: {schematic_path}") + ctx.info(f"Found schematic file: {os.path.basename(schematic_path)}") + + # Extract netlist + await ctx.report_progress(30, 100) + ctx.info(f"Extracting netlist to find connections for {component_ref}...") + + netlist_data = extract_netlist(schematic_path) + + if "error" in netlist_data: + logger.error( diff --git a/kicad_mcp/utils/netlist_parser.py b/kicad_mcp/utils/netlist_parser.py new file mode 100644 index 0000000..615ac12 --- /dev/null +++ b/kicad_mcp/utils/netlist_parser.py @@ -0,0 +1,453 @@ +""" +KiCad schematic netlist extraction utilities. +""" +import os +import re +from typing import Dict, List, Set, Tuple, Any, Optional +from collections import defaultdict + +from kicad_mcp.utils.logger import Logger + +# Create logger for this module +logger = Logger() + +class SchematicParser: + """Parser for KiCad schematic files to extract netlist information.""" + + def __init__(self, schematic_path: str): + """Initialize the schematic parser. + + Args: + schematic_path: Path to the KiCad schematic file (.kicad_sch) + """ + self.schematic_path = schematic_path + self.content = "" + self.components = [] + self.labels = [] + self.wires = [] + self.junctions = [] + self.no_connects = [] + self.power_symbols = [] + self.hierarchical_labels = [] + self.global_labels = [] + + # Netlist information + self.nets = defaultdict(list) # Net name -> connected pins + self.component_pins = {} # (component_ref, pin_num) -> net_name + + # Component information + self.component_info = {} # component_ref -> component details + + # Load the file + self._load_schematic() + + def _load_schematic(self) -> None: + """Load the schematic file content.""" + if not os.path.exists(self.schematic_path): + logger.error(f"Schematic file not found: {self.schematic_path}") + raise FileNotFoundError(f"Schematic file not found: {self.schematic_path}") + + try: + with open(self.schematic_path, 'r') as f: + self.content = f.read() + logger.info(f"Successfully loaded schematic: {self.schematic_path}") + except Exception as e: + logger.error(f"Error reading schematic file: {str(e)}") + raise + + def parse(self) -> Dict[str, Any]: + """Parse the schematic to extract netlist information. + + Returns: + Dictionary with parsed netlist information + """ + logger.info("Starting schematic parsing") + + # Extract symbols (components) + self._extract_components() + + # Extract wires + self._extract_wires() + + # Extract junctions + self._extract_junctions() + + # Extract labels + self._extract_labels() + + # Extract power symbols + self._extract_power_symbols() + + # Extract no-connects + self._extract_no_connects() + + # Build netlist + self._build_netlist() + + # Create result + result = { + "components": self.component_info, + "nets": dict(self.nets), + "labels": self.labels, + "wires": self.wires, + "junctions": self.junctions, + "power_symbols": self.power_symbols, + "component_count": len(self.component_info), + "net_count": len(self.nets) + } + + logger.info(f"Schematic parsing complete: found {len(self.component_info)} components and {len(self.nets)} nets") + return result + + def _extract_s_expressions(self, pattern: str) -> List[str]: + """Extract all matching S-expressions from the schematic content. + + Args: + pattern: Regex pattern to match the start of S-expressions + + Returns: + List of matching S-expressions + """ + matches = [] + positions = [] + + # Find all starting positions of matches + for match in re.finditer(pattern, self.content): + positions.append(match.start()) + + # Extract full S-expressions for each match + for pos in positions: + # Start from the matching position + current_pos = pos + depth = 0 + s_exp = "" + + # Extract the full S-expression by tracking parentheses + while current_pos < len(self.content): + char = self.content[current_pos] + s_exp += char + + if char == '(': + depth += 1 + elif char == ')': + depth -= 1 + if depth == 0: + # Found the end of the S-expression + break + + current_pos += 1 + + matches.append(s_exp) + + return matches + + def _extract_components(self) -> None: + """Extract component information from schematic.""" + logger.info("Extracting components") + + # Extract all symbol expressions (components) + symbols = self._extract_s_expressions(r'\(symbol\s+') + + for symbol in symbols: + component = self._parse_component(symbol) + if component: + self.components.append(component) + + # Add to component info dictionary + ref = component.get('reference', 'Unknown') + self.component_info[ref] = component + + logger.info(f"Extracted {len(self.components)} components") + + def _parse_component(self, symbol_expr: str) -> Dict[str, Any]: + """Parse a component from a symbol S-expression. + + Args: + symbol_expr: Symbol S-expression + + Returns: + Component information dictionary + """ + component = {} + + # Extract library component ID + lib_id_match = re.search(r'\(lib_id\s+"([^"]+)"\)', symbol_expr) + if lib_id_match: + component['lib_id'] = lib_id_match.group(1) + + # Extract reference (e.g., R1, C2) + property_matches = re.finditer(r'\(property\s+"([^"]+)"\s+"([^"]+)"', symbol_expr) + for match in property_matches: + prop_name = match.group(1) + prop_value = match.group(2) + + if prop_name == "Reference": + component['reference'] = prop_value + elif prop_name == "Value": + component['value'] = prop_value + elif prop_name == "Footprint": + component['footprint'] = prop_value + else: + # Store other properties + if 'properties' not in component: + component['properties'] = {} + component['properties'][prop_name] = prop_value + + # Extract position + pos_match = re.search(r'\(at\s+([\d\.-]+)\s+([\d\.-]+)(\s+[\d\.-]+)?\)', symbol_expr) + if pos_match: + component['position'] = { + 'x': float(pos_match.group(1)), + 'y': float(pos_match.group(2)), + 'angle': float(pos_match.group(3).strip() if pos_match.group(3) else 0) + } + + # Extract pins + pins = [] + pin_matches = re.finditer(r'\(pin\s+\(num\s+"([^"]+)"\)\s+\(name\s+"([^"]+)"\)', symbol_expr) + for match in pin_matches: + pin_num = match.group(1) + pin_name = match.group(2) + pins.append({ + 'num': pin_num, + 'name': pin_name + }) + + if pins: + component['pins'] = pins + + return component + + def _extract_wires(self) -> None: + """Extract wire information from schematic.""" + logger.info("Extracting wires") + + # Extract all wire expressions + wires = self._extract_s_expressions(r'\(wire\s+') + + for wire in wires: + # Extract the wire coordinates + pts_match = re.search(r'\(pts\s+\(xy\s+([\d\.-]+)\s+([\d\.-]+)\)\s+\(xy\s+([\d\.-]+)\s+([\d\.-]+)\)\)', wire) + if pts_match: + self.wires.append({ + 'start': { + 'x': float(pts_match.group(1)), + 'y': float(pts_match.group(2)) + }, + 'end': { + 'x': float(pts_match.group(3)), + 'y': float(pts_match.group(4)) + } + }) + + logger.info(f"Extracted {len(self.wires)} wires") + + def _extract_junctions(self) -> None: + """Extract junction information from schematic.""" + logger.info("Extracting junctions") + + # Extract all junction expressions + junctions = self._extract_s_expressions(r'\(junction\s+') + + for junction in junctions: + # Extract the junction coordinates + xy_match = re.search(r'\(junction\s+\(xy\s+([\d\.-]+)\s+([\d\.-]+)\)\)', junction) + if xy_match: + self.junctions.append({ + 'x': float(xy_match.group(1)), + 'y': float(xy_match.group(2)) + }) + + logger.info(f"Extracted {len(self.junctions)} junctions") + + def _extract_labels(self) -> None: + """Extract label information from schematic.""" + logger.info("Extracting labels") + + # Extract local labels + local_labels = self._extract_s_expressions(r'\(label\s+') + + for label in local_labels: + # Extract label text and position + label_match = re.search(r'\(label\s+"([^"]+)"\s+\(at\s+([\d\.-]+)\s+([\d\.-]+)(\s+[\d\.-]+)?\)', label) + if label_match: + self.labels.append({ + 'type': 'local', + 'text': label_match.group(1), + 'position': { + 'x': float(label_match.group(2)), + 'y': float(label_match.group(3)), + 'angle': float(label_match.group(4).strip() if label_match.group(4) else 0) + } + }) + + # Extract global labels + global_labels = self._extract_s_expressions(r'\(global_label\s+') + + for label in global_labels: + # Extract global label text and position + label_match = re.search(r'\(global_label\s+"([^"]+)"\s+\(shape\s+([^\s\)]+)\)\s+\(at\s+([\d\.-]+)\s+([\d\.-]+)(\s+[\d\.-]+)?\)', label) + if label_match: + self.global_labels.append({ + 'type': 'global', + 'text': label_match.group(1), + 'shape': label_match.group(2), + 'position': { + 'x': float(label_match.group(3)), + 'y': float(label_match.group(4)), + 'angle': float(label_match.group(5).strip() if label_match.group(5) else 0) + } + }) + + # Extract hierarchical labels + hierarchical_labels = self._extract_s_expressions(r'\(hierarchical_label\s+') + + for label in hierarchical_labels: + # Extract hierarchical label text and position + label_match = re.search(r'\(hierarchical_label\s+"([^"]+)"\s+\(shape\s+([^\s\)]+)\)\s+\(at\s+([\d\.-]+)\s+([\d\.-]+)(\s+[\d\.-]+)?\)', label) + if label_match: + self.hierarchical_labels.append({ + 'type': 'hierarchical', + 'text': label_match.group(1), + 'shape': label_match.group(2), + 'position': { + 'x': float(label_match.group(3)), + 'y': float(label_match.group(4)), + 'angle': float(label_match.group(5).strip() if label_match.group(5) else 0) + } + }) + + logger.info(f"Extracted {len(self.labels)} local labels, {len(self.global_labels)} global labels, and {len(self.hierarchical_labels)} hierarchical labels") + + def _extract_power_symbols(self) -> None: + """Extract power symbol information from schematic.""" + logger.info("Extracting power symbols") + + # Extract all power symbol expressions + power_symbols = self._extract_s_expressions(r'\(symbol\s+\(lib_id\s+"power:') + + for symbol in power_symbols: + # Extract power symbol type and position + type_match = re.search(r'\(lib_id\s+"power:([^"]+)"\)', symbol) + pos_match = re.search(r'\(at\s+([\d\.-]+)\s+([\d\.-]+)(\s+[\d\.-]+)?\)', symbol) + + if type_match and pos_match: + self.power_symbols.append({ + 'type': type_match.group(1), + 'position': { + 'x': float(pos_match.group(1)), + 'y': float(pos_match.group(2)), + 'angle': float(pos_match.group(3).strip() if pos_match.group(3) else 0) + } + }) + + logger.info(f"Extracted {len(self.power_symbols)} power symbols") + + def _extract_no_connects(self) -> None: + """Extract no-connect information from schematic.""" + logger.info("Extracting no-connects") + + # Extract all no-connect expressions + no_connects = self._extract_s_expressions(r'\(no_connect\s+') + + for no_connect in no_connects: + # Extract the no-connect coordinates + xy_match = re.search(r'\(no_connect\s+\(at\s+([\d\.-]+)\s+([\d\.-]+)\)', no_connect) + if xy_match: + self.no_connects.append({ + 'x': float(xy_match.group(1)), + 'y': float(xy_match.group(2)) + }) + + logger.info(f"Extracted {len(self.no_connects)} no-connects") + + def _build_netlist(self) -> None: + """Build the netlist from extracted components and connections.""" + logger.info("Building netlist from schematic data") + + # TODO: Implement netlist building algorithm + # This is a complex task that involves: + # 1. Tracking connections between components via wires + # 2. Handling labels (local, global, hierarchical) + # 3. Processing power symbols + # 4. Resolving junctions + + # For now, we'll implement a basic version that creates a list of nets + # based on component references and pin numbers + + # Process global labels as nets + for label in self.global_labels: + net_name = label['text'] + self.nets[net_name] = [] # Initialize empty list for this net + + # Process power symbols as nets + for power in self.power_symbols: + net_name = power['type'] + if net_name not in self.nets: + self.nets[net_name] = [] + + # In a full implementation, we would now trace connections between + # components, but that requires a more complex algorithm to follow wires + # and detect connected pins + + # For demonstration, we'll add a placeholder note + logger.info("Note: Full netlist building requires complex connectivity tracing") + logger.info(f"Found {len(self.nets)} potential nets from labels and power symbols") + + +def extract_netlist(schematic_path: str) -> Dict[str, Any]: + """Extract netlist information from a KiCad schematic file. + + Args: + schematic_path: Path to the KiCad schematic file (.kicad_sch) + + Returns: + Dictionary with netlist information + """ + try: + parser = SchematicParser(schematic_path) + return parser.parse() + except Exception as e: + logger.error(f"Error extracting netlist: {str(e)}") + return { + "error": str(e), + "components": {}, + "nets": {}, + "component_count": 0, + "net_count": 0 + } + + +def analyze_netlist(netlist_data: Dict[str, Any]) -> Dict[str, Any]: + """Analyze netlist data to provide insights. + + Args: + netlist_data: Dictionary with netlist information + + Returns: + Dictionary with analysis results + """ + results = { + "component_count": netlist_data.get("component_count", 0), + "net_count": netlist_data.get("net_count", 0), + "component_types": defaultdict(int), + "power_nets": [] + } + + # Analyze component types + for ref, component in netlist_data.get("components", {}).items(): + # Extract component type from reference (e.g., R1 -> R) + comp_type = re.match(r'^([A-Za-z_]+)', ref) + if comp_type: + results["component_types"][comp_type.group(1)] += 1 + + # Identify power nets + for net_name in netlist_data.get("nets", {}): + if any(net_name.startswith(prefix) for prefix in ["VCC", "VDD", "GND", "+5V", "+3V3", "+12V"]): + results["power_nets"].append(net_name) + + # Count pin connections + total_pins = sum(len(pins) for pins in netlist_data.get("nets", {}).values()) + results["total_pin_connections"] = total_pins + + return results diff --git a/requirements.txt b/requirements.txt index f0a753c..455d29a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ mcp[cli] httpx pytest +pandas