Migrate from FastMCP 2.14.5 to 3.1.0 with complete architectural overhaul. Adopt src-layout packaging, lazy config functions to eliminate .env race condition, and decorator-based tool registration. Consolidate 14 tool modules into 8 focused modules (33 tools total). Add 9 new schematic tools via kicad-sch-api for creating and manipulating .kicad_sch files. Drop pandas dependency (BOM uses stdlib csv). Remove ~17k lines of stubs, over-engineering, and dead code. All checks pass: ruff clean, mypy 0 errors, 17/17 tests green.
446 lines
15 KiB
Python
446 lines
15 KiB
Python
"""
|
|
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
|