Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
670d2a9c4This file is pinned to the selected release. Download the original to open it in your design tool.
"""
Project-related command implementations for KiCAD interface
"""
import logging
import os
import shutil
from pathlib import Path
from typing import Any, Dict, Optional
import pcbnew # type: ignore
from utils.kicad_project import write_kicad_pro
from utils.project_settings_guard import (
preserve_project_settings,
restore_project_file_if_changed,
snapshot_project_file,
)
logger = logging.getLogger("kicad_interface")
def normalize_fs_path(path: Optional[str]) -> Optional[str]:
"""Canonicalize a filesystem path for same-file comparison.
A plain string compare of two absolute paths is not a same-file test. On
Windows ``C:\\Project\\Board.kicad_pcb`` and ``c:\\project\\board.kicad_pcb``
name one file but compare unequal, which made Save As wrongly treat the
board's own file as a distinct, already-existing destination and refuse the
save. ``normcase`` folds case and separators; ``realpath`` resolves symlinks
and ``..`` segments. Use this only for comparisons — never as the path
written to, so symlinks stay intact.
"""
if not path:
return None
candidate = os.path.abspath(os.path.expanduser(str(path)))
try:
candidate = os.path.realpath(candidate)
except OSError: # pragma: no cover - realpath is best effort
pass
return os.path.normcase(candidate)
class ProjectCommands:
"""Handles project-related KiCAD operations"""
def __init__(self, board: Optional[pcbnew.BOARD] = None):
"""Initialize with optional board instance"""
self.board = board
def create_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new KiCAD project"""
try:
# Accept both 'name' (from MCP tool) and 'projectName' (legacy)
project_name = params.get("name") or params.get("projectName", "New_Project")
path = params.get("path", os.getcwd())
template = params.get("template")
# Generate the full project path
project_path = os.path.join(path, project_name)
if not project_path.endswith(".kicad_pro"):
project_path += ".kicad_pro"
# Create project directory if it doesn't exist
os.makedirs(os.path.dirname(project_path), exist_ok=True)
# Create a new board
board = pcbnew.BOARD()
# Set project properties
board.GetTitleBlock().SetTitle(project_name)
# Set current date with proper parameter
from datetime import datetime
current_date = datetime.now().strftime("%Y-%m-%d")
board.GetTitleBlock().SetDate(current_date)
# If template is specified, try to load it
if template:
template_path = os.path.expanduser(template)
if os.path.exists(template_path):
template_board = pcbnew.LoadBoard(template_path)
# Copy settings from template
board.SetDesignSettings(template_board.GetDesignSettings())
board.SetLayerStack(template_board.GetLayerStack())
# Save the board
board_path = project_path.replace(".kicad_pro", ".kicad_pcb")
board.SetFileName(board_path)
pcbnew.SaveBoard(board_path, board)
# Create schematic from a blank KiCad 10 template (empty lib_symbols,
# no placed symbols). The old template_with_symbols_expanded.kicad_sch
# pre-seeded _TEMPLATE_* symbols that leaked into every new project;
# the live add tool synthesizes its own lib_symbols via the dynamic
# loader, so a blank start is correct (issue #221, also closes #243).
schematic_path = project_path.replace(".kicad_pro", ".kicad_sch")
template_sch_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"templates",
"blank.kicad_sch",
)
if os.path.exists(template_sch_path):
# Copy template schematic
shutil.copy(template_sch_path, schematic_path)
# Regenerate UUID to ensure uniqueness for each created project
import re
import uuid as uuid_module
with open(schematic_path, "r", encoding="utf-8") as f:
content = f.read()
schematic_root_uuid = str(uuid_module.uuid4())
content = re.sub(
r"\(uuid [0-9a-fA-F-]+\)",
f"(uuid {schematic_root_uuid})",
content,
count=1, # Only replace first (schematic) UUID
)
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
f.write(content)
logger.info(f"Created schematic from template: {schematic_path}")
else:
# Fallback: create minimal schematic
logger.warning(
f"Template not found at {template_sch_path}, creating minimal schematic"
)
import uuid as uuid_module
schematic_root_uuid = str(uuid_module.uuid4())
with open(schematic_path, "w", encoding="utf-8", newline="\n") as f:
# KiCad 10 schematic header (matches what eeschema writes for a
# new file). The older 20250114 token is the KiCad 9 format and
# is stale under KiCad 10 (issue #221).
f.write(
'(kicad_sch (version 20260101) (generator "eeschema")'
' (generator_version "10.0")\n\n'
)
f.write(f" (uuid {schematic_root_uuid})\n\n")
f.write(' (paper "A4")\n\n')
f.write(" (lib_symbols\n )\n\n")
f.write(' (sheet_instances\n (path "/" (page "1"))\n )\n')
f.write(")\n")
# Write a conformant KiCad 10 .kicad_pro (issue #220). The old
# hand-rolled stub carried only board.filename plus a sheets entry
# with the literal id "root"; KiCad regenerated defaults on open and
# discarded any intended configuration. write_kicad_pro emits the
# full structure KiCad 10 itself writes for a new project, with the
# sheets entry pointing at the real schematic root-sheet UUID.
write_kicad_pro(project_path, sheet_uuid=schematic_root_uuid)
self.board = board
# Normalize returned paths to a single separator (forward slashes).
# os.path.join mixes separators on Windows when the caller passes a
# path with forward slashes (issue #224): the joined filename used a
# backslash while the rest used forward slashes. The on-disk writes
# above keep the OS-native paths; only the reported paths are
# normalized so callers get consistent, predictable values.
return {
"success": True,
"message": f"Created project: {project_name}",
"project": {
"name": project_name,
"path": Path(project_path).as_posix(),
"boardPath": Path(board_path).as_posix(),
"schematicPath": Path(schematic_path).as_posix(),
},
}
except Exception as e:
logger.error(f"Error creating project: {str(e)}")
return {
"success": False,
"message": "Failed to create project",
"errorDetails": str(e),
}
def open_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Open an existing KiCAD project"""
try:
filename = params.get("filename")
if not filename:
return {
"success": False,
"message": "No filename provided",
"errorDetails": "The filename parameter is required",
}
# Expand user path and make absolute
filename = os.path.abspath(os.path.expanduser(filename))
# If it's a project file, get the board file
if filename.endswith(".kicad_pro"):
board_path = filename.replace(".kicad_pro", ".kicad_pcb")
else:
board_path = filename
# Load the board. Opening is a read operation: some KiCad builds
# rewrite the sibling .kicad_pro during/after LoadBoard from a
# stale in-memory project model, dropping hand-edited
# net_settings — snapshot and restore it verbatim if it changed.
pro_snapshot = snapshot_project_file(board_path)
board = pcbnew.LoadBoard(board_path)
restore_project_file_if_changed(board_path, pro_snapshot)
self.board = board
return {
"success": True,
"message": f"Opened project: {os.path.basename(board_path)}",
"project": {
"name": os.path.splitext(os.path.basename(board_path))[0],
"path": filename,
"boardPath": board_path,
},
}
except Exception as e:
logger.error(f"Error opening project: {str(e)}")
return {
"success": False,
"message": "Failed to open project",
"errorDetails": str(e),
}
def save_project(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Save the current KiCAD project"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
filename = params.get("filename") or params.get("path")
current_filename = self.board.GetFileName()
save_filename = current_filename
is_save_as = False
if filename:
# Save to new location
filename = os.path.abspath(os.path.expanduser(filename))
# Compare canonicalized paths: a case- or separator-different
# spelling of the loaded board file is the same file and must be
# an ordinary save, not an "already exists" Save As rejection.
is_save_as = normalize_fs_path(filename) != normalize_fs_path(current_filename)
if is_save_as and os.path.exists(filename) and not params.get("overwrite", False):
return {
"success": False,
"message": f"Destination already exists: {filename}",
"errorDetails": "Pass overwrite=true to replace it",
"boardPath": filename,
}
# A different spelling of the loaded file writes the board's own
# path, so the on-disk identity never drifts on a plain save.
save_filename = filename if is_save_as else (current_filename or filename)
# Save first, then switch the in-memory identity. A failed Save As
# must not leave the BOARD claiming to own a destination that was
# never written successfully. Guarded: SaveBoard serializes project
# settings from a possibly stale model — preserve .kicad_pro
# net_settings around the write.
with preserve_project_settings(save_filename):
saved = pcbnew.SaveBoard(save_filename, self.board)
if saved is False:
return {
"success": False,
"message": f"Failed to save project to: {save_filename}",
"errorDetails": "pcbnew.SaveBoard returned false",
}
if is_save_as:
self.board.SetFileName(filename)
return {
"success": True,
"message": f"Saved project to: {self.board.GetFileName()}",
"project": {
"name": os.path.splitext(os.path.basename(self.board.GetFileName()))[0],
"path": self.board.GetFileName(),
},
}
except Exception as e:
logger.error(f"Error saving project: {str(e)}")
return {
"success": False,
"message": "Failed to save project",
"errorDetails": str(e),
}
def get_project_info(self, params: Dict[str, Any]) -> Dict[str, Any]:
"""Get information about the current project"""
try:
if not self.board:
return {
"success": False,
"message": "No board is loaded",
"errorDetails": "Load or create a board first",
}
title_block = self.board.GetTitleBlock()
filename = self.board.GetFileName()
return {
"success": True,
"project": {
"name": os.path.splitext(os.path.basename(filename))[0],
"path": filename,
"title": title_block.GetTitle(),
"date": title_block.GetDate(),
"revision": title_block.GetRevision(),
"company": title_block.GetCompany(),
"comment1": title_block.GetComment(0),
"comment2": title_block.GetComment(1),
"comment3": title_block.GetComment(2),
"comment4": title_block.GetComment(3),
},
}
except Exception as e:
logger.error(f"Error getting project info: {str(e)}")
return {
"success": False,
"message": "Failed to get project information",
"errorDetails": str(e),
}