Loading files
This 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),
}
8c8d6fdc8This file is pinned to the selected release. Download the original to open it in your design tool.