""" Project analysis and validation tools. Combines project validation, real-time board analysis, and component detail retrieval into a single module. Uses KiCad IPC when available for live data, falling back to file-based checks otherwise. """ import json import logging import os from typing import Any from mckicad.server import mcp from mckicad.utils.file_utils import get_project_files from mckicad.utils.ipc_client import check_kicad_availability, kicad_ipc_session logger = logging.getLogger(__name__) @mcp.tool() def validate_project(project_path: str) -> dict[str, Any]: """Validate a KiCad project's structure and essential files. Accepts either a path to a .kicad_pro file or a directory containing exactly one .kicad_pro file. Checks that the project JSON parses correctly, that schematic and PCB files exist, and -- when KiCad is running -- performs a live IPC check for component count, routing completion, and unrouted nets. Args: project_path: Path to a .kicad_pro file or a directory that contains one. Returns: Dictionary with validation result, list of issues found, files discovered, and optional real-time IPC analysis. """ # Resolve directory to .kicad_pro file if os.path.isdir(project_path): kicad_pro_files = [ f for f in os.listdir(project_path) if f.endswith(".kicad_pro") ] if not kicad_pro_files: return { "valid": False, "error": f"No .kicad_pro file found in directory: {project_path}", } if len(kicad_pro_files) > 1: return { "valid": False, "error": ( f"Multiple .kicad_pro files in directory: {project_path}. " "Specify the exact file." ), } project_path = os.path.join(project_path, kicad_pro_files[0]) if not os.path.exists(project_path): return {"valid": False, "error": f"Project file not found: {project_path}"} if not project_path.endswith(".kicad_pro"): return { "valid": False, "error": f"Expected .kicad_pro file, got: {project_path}", } issues: list[str] = [] # Discover associated files try: files = get_project_files(project_path) except Exception as e: return { "valid": False, "error": f"Error analysing project files: {e}", } if "pcb" not in files: issues.append("Missing PCB layout file (.kicad_pcb)") if "schematic" not in files: issues.append("Missing schematic file (.kicad_sch)") # Validate JSON integrity of the project file try: with open(project_path) as f: json.load(f) except json.JSONDecodeError as e: issues.append(f"Invalid project file JSON: {e}") except Exception as e: issues.append(f"Error reading project file: {e}") # Optional live analysis via KiCad IPC ipc_analysis: dict[str, Any] = {} ipc_status = check_kicad_availability() if ipc_status["available"] and "pcb" in files: try: with kicad_ipc_session(board_path=files["pcb"]) as client: board_stats = client.get_board_statistics() connectivity = client.check_connectivity() ipc_analysis = { "real_time_analysis": True, "board_statistics": board_stats, "connectivity_status": connectivity, "routing_completion": connectivity.get("routing_completion", 0), "component_count": board_stats.get("footprint_count", 0), "net_count": board_stats.get("net_count", 0), } if connectivity.get("unrouted_nets", 0) > 0: issues.append( f"{connectivity['unrouted_nets']} net(s) are not routed" ) if board_stats.get("footprint_count", 0) == 0: issues.append("No components found on PCB") except Exception as e: logger.debug(f"IPC analysis unavailable: {e}") ipc_analysis = { "real_time_analysis": False, "ipc_error": str(e), } else: ipc_analysis = { "real_time_analysis": False, "reason": ipc_status.get( "message", "KiCad IPC not available or PCB file missing" ), } return { "valid": len(issues) == 0, "path": project_path, "issues": issues if issues else None, "files_found": list(files.keys()), "ipc_analysis": ipc_analysis, "validation_mode": ( "enhanced_with_ipc" if ipc_analysis.get("real_time_analysis") else "file_based" ), } @mcp.tool() def analyze_board_real_time(project_path: str) -> dict[str, Any]: """Live board analysis via KiCad IPC. Connects to a running KiCad instance and pulls footprint, net, track, and connectivity data to build a comprehensive snapshot of the board state. Covers placement density, routing completion, design quality scoring, and manufacturability assessment. Requires KiCad to be running with the target project open. Args: project_path: Path to the KiCad project (.kicad_pro) or its parent directory. Returns: Dictionary with placement analysis, routing analysis, quality scores, and recommendations. """ try: files = get_project_files(project_path) if "pcb" not in files: return { "success": False, "error": "PCB file not found in project", } ipc_status = check_kicad_availability() if not ipc_status["available"]: return { "success": False, "error": f"KiCad IPC not available: {ipc_status['message']}", } board_path = files["pcb"] with kicad_ipc_session(board_path=board_path) as client: footprints = client.get_footprints() nets = client.get_nets() tracks = client.get_tracks() board_stats = client.get_board_statistics() connectivity = client.check_connectivity() # --- placement --- placement_analysis = { "total_components": len(footprints), "component_types": board_stats.get("component_types", {}), "placement_density": _placement_density(footprints), "component_distribution": _component_distribution(footprints), } # --- routing --- trace_items = [t for t in tracks if not hasattr(t, "drill")] via_items = [t for t in tracks if hasattr(t, "drill")] routing_analysis = { "total_nets": len(nets), "routed_nets": connectivity.get("routed_nets", 0), "unrouted_nets": connectivity.get("unrouted_nets", 0), "routing_completion": connectivity.get("routing_completion", 0), "track_count": len(trace_items), "via_count": len(via_items), "routing_efficiency": _routing_efficiency(tracks, nets), } # --- quality --- design_score = _design_score(placement_analysis, routing_analysis) critical_issues = _critical_issues(footprints, tracks, nets) optimization_opps = _optimization_opportunities( placement_analysis, routing_analysis ) mfg_score = _manufacturability_score(tracks, footprints) quality_analysis = { "design_score": design_score, "critical_issues": critical_issues, "optimization_opportunities": optimization_opps, "manufacturability_score": mfg_score, } recommendations = _board_recommendations( placement_analysis, routing_analysis, quality_analysis ) return { "success": True, "project_path": project_path, "board_path": board_path, "analysis_timestamp": os.path.getmtime(board_path), "placement_analysis": placement_analysis, "routing_analysis": routing_analysis, "quality_analysis": quality_analysis, "recommendations": recommendations, "board_statistics": board_stats, "analysis_mode": "real_time_ipc", } except Exception as e: logger.error(f"Error in real-time board analysis: {e}") return { "success": False, "error": str(e), "project_path": project_path, } @mcp.tool() def get_component_details( project_path: str, component_reference: str | None = None, ) -> dict[str, Any]: """Retrieve component details from a live KiCad board via IPC. When *component_reference* is given (e.g. ``"R1"``, ``"U3"``), returns position, rotation, layer, value, and footprint name for that single component. When omitted, returns the same information for every component on the board. Requires KiCad to be running with the target project open. Args: project_path: Path to the KiCad project (.kicad_pro) or its parent directory. component_reference: Reference designator of a specific component, or None to list all. Returns: Dictionary with component detail(s) or an error message. """ try: files = get_project_files(project_path) if "pcb" not in files: return { "success": False, "error": "PCB file not found in project", } ipc_status = check_kicad_availability() if not ipc_status["available"]: return { "success": False, "error": f"KiCad IPC not available: {ipc_status['message']}", } board_path = files["pcb"] with kicad_ipc_session(board_path=board_path) as client: if component_reference: fp = client.get_footprint_by_reference(component_reference) if not fp: return { "success": False, "error": f"Component '{component_reference}' not found", } return { "success": True, "project_path": project_path, "component_reference": component_reference, "component_details": _extract_component_details(fp), } # All components footprints = client.get_footprints() all_components = {} for fp in footprints: ref = getattr(fp, "reference", None) if ref: all_components[ref] = _extract_component_details(fp) return { "success": True, "project_path": project_path, "total_components": len(all_components), "components": all_components, } except Exception as e: logger.error(f"Error getting component details: {e}") return { "success": False, "error": str(e), "project_path": project_path, } # --------------------------------------------------------------------------- # Private helpers # --------------------------------------------------------------------------- def _extract_component_details(footprint: Any) -> dict[str, Any]: """Pull key attributes from a FootprintInstance into a plain dict.""" pos = getattr(footprint, "position", None) return { "reference": getattr(footprint, "reference", "Unknown"), "value": getattr(footprint, "value", "Unknown"), "position": { "x": getattr(pos, "x", 0) if pos else 0, "y": getattr(pos, "y", 0) if pos else 0, }, "rotation": getattr(footprint, "rotation", 0), "layer": getattr(footprint, "layer", "F.Cu"), "footprint_name": getattr(footprint, "footprint", "Unknown"), } def _placement_density(footprints: list) -> float: """Estimate placement density (simplified, 0.0 -- 1.0).""" if not footprints: return 0.0 return min(len(footprints) / 100.0, 1.0) def _component_distribution(footprints: list) -> dict[str, str]: """Simplified distribution characterisation.""" if not footprints: return {"distribution": "empty"} return { "distribution": "distributed", "clustering": "moderate", "edge_utilization": "good", } def _routing_efficiency(tracks: list, nets: list) -> float: """Track-to-net ratio as a percentage (0 -- 100).""" net_count = len(nets) if net_count == 0: return 0.0 return round(min(len(tracks) / (net_count * 2), 1.0) * 100, 1) def _design_score( placement: dict[str, Any], routing: dict[str, Any] ) -> int: """Composite design quality score (0 -- 100).""" base = 70 density_bonus = placement.get("placement_density", 0) * 15 completion_bonus = routing.get("routing_completion", 0) * 0.15 return min(int(base + density_bonus + completion_bonus), 100) def _critical_issues( footprints: list, tracks: list, nets: list ) -> list[str]: """Return a list of blocking design issues.""" issues: list[str] = [] if not footprints: issues.append("No components placed on board") if not tracks and nets: issues.append("No routing present despite having nets defined") return issues def _optimization_opportunities( placement: dict[str, Any], routing: dict[str, Any] ) -> list[str]: """Suggest areas where the design could be improved.""" opps: list[str] = [] if placement.get("placement_density", 0) < 0.3: opps.append("Board area could be reduced for better cost efficiency") if routing.get("routing_completion", 0) < 100: opps.append("Complete remaining routing for full functionality") return opps def _manufacturability_score(tracks: list, footprints: list) -> int: """Heuristic manufacturability score (0 -- 100).""" score = 85 if len(tracks) > 1000: score -= 10 if len(footprints) > 100: score -= 5 return max(score, 0) def _board_recommendations( placement: dict[str, Any], routing: dict[str, Any], quality: dict[str, Any], ) -> list[str]: """Compile a prioritised list of recommendations.""" recs: list[str] = [] if quality.get("design_score", 0) < 80: recs.append("Design score is below 80 -- consider optimisation") unrouted = routing.get("unrouted_nets", 0) if unrouted > 0: recs.append(f"Complete routing for {unrouted} unrouted net(s)") if placement.get("total_components", 0) > 0: recs.append("Review thermal management for power components") recs.append("Run DRC check to validate design rules") return recs