initial commit
This commit is contained in:
14
.claude/settings.local.json
Normal file
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Read(//d/kicad/**)",
|
||||
"Read(//d/tx/25w-kicad/25w/**)",
|
||||
"Bash(for uuid in \"12ef4843-0c6b-44b3-b52b-21b354565dc0\" \"17a476c2-1017-41e7-9d81-f4153fe179f7\" \"25a5bbfc-04ad-4755-9a82-80d42d2cd8ce\")",
|
||||
"Bash(do echo \"=== UUID: $uuid ===\")",
|
||||
"Bash(grep -A 15 \"$uuid\" 25w.kicad_sch frequency.kicad_sch)",
|
||||
"Bash(done)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
335
add_libraries.py
Normal file
335
add_libraries.py
Normal file
@@ -0,0 +1,335 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Adds all *.kicad_sym libraries under UM_KICAD/lib/symbols
|
||||
to the KiCad v9 global sym-lib-table.
|
||||
|
||||
Also adds all *.pretty footprint libraries under UM_KICAD/lib/footprints
|
||||
to the KiCad v9 global fp-lib-table.
|
||||
|
||||
Safe behavior:
|
||||
- Does NOT remove existing entries
|
||||
- Skips libraries already present (by URI)
|
||||
- Creates sym-lib-table.bak and fp-lib-table.bak backups
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# KiCad v9 config location
|
||||
# ----------------------------
|
||||
def kicad9_config_dir() -> Path:
|
||||
home = Path.home()
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if not appdata:
|
||||
raise RuntimeError("APPDATA not set")
|
||||
return Path(appdata) / "kicad" / "9.0"
|
||||
|
||||
if sys.platform == "darwin":
|
||||
return home / "Library" / "Preferences" / "kicad" / "9.0"
|
||||
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
if xdg:
|
||||
return Path(xdg) / "kicad" / "9.0"
|
||||
|
||||
return home / ".config" / "kicad" / "9.0"
|
||||
|
||||
|
||||
def global_sym_lib_table_path() -> Path:
|
||||
return kicad9_config_dir() / "sym-lib-table"
|
||||
|
||||
|
||||
def global_fp_lib_table_path() -> Path:
|
||||
return kicad9_config_dir() / "fp-lib-table"
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Basic sym-lib-table parsing
|
||||
# ----------------------------
|
||||
LIB_BLOCK_RE = re.compile(r"\(lib\s+(?:.|\n)*?\)\s*\)", re.MULTILINE)
|
||||
NAME_RE = re.compile(r"\(name\s+([^)]+)\)")
|
||||
URI_RE = re.compile(r"\(uri\s+([^)]+)\)")
|
||||
|
||||
|
||||
def strip_atom(atom: str) -> str:
|
||||
atom = atom.strip()
|
||||
if atom.startswith('"') and atom.endswith('"'):
|
||||
return atom[1:-1]
|
||||
return atom
|
||||
|
||||
|
||||
def parse_existing_libs(text: str) -> Dict[str, str]:
|
||||
libs = {}
|
||||
for m in LIB_BLOCK_RE.finditer(text):
|
||||
block = m.group(0)
|
||||
name_m = NAME_RE.search(block)
|
||||
uri_m = URI_RE.search(block)
|
||||
if not name_m or not uri_m:
|
||||
continue
|
||||
name = strip_atom(name_m.group(1))
|
||||
uri = strip_atom(uri_m.group(1))
|
||||
libs[name] = uri
|
||||
return libs
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Helpers
|
||||
# ----------------------------
|
||||
def make_unique_name(name: str, used: set[str]) -> str:
|
||||
if name not in used:
|
||||
return name
|
||||
i = 2
|
||||
while f"{name}_{i}" in used:
|
||||
i += 1
|
||||
return f"{name}_{i}"
|
||||
|
||||
|
||||
def create_lib_block(name: str, uri: str, descr: str) -> str:
|
||||
return (
|
||||
"(lib\n"
|
||||
f" (name \"{name}\")\n"
|
||||
" (type \"KiCad\")\n"
|
||||
f" (uri \"{uri}\")\n"
|
||||
" (options \"\")\n"
|
||||
f" (descr \"{descr}\")\n"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
def create_fp_lib_block(name: str, uri: str, descr: str) -> str:
|
||||
return (
|
||||
"(lib\n"
|
||||
f" (name \"{name}\")\n"
|
||||
" (type \"KiCad\")\n"
|
||||
f" (uri \"{uri}\")\n"
|
||||
" (options \"\")\n"
|
||||
f" (descr \"{descr}\")\n"
|
||||
")"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Main logic
|
||||
# ----------------------------
|
||||
def sync_symbol_libraries(um_root: Path) -> int:
|
||||
"""Synchronize symbol libraries"""
|
||||
symbols_dir = um_root / "lib" / "symbols"
|
||||
|
||||
if not symbols_dir.exists():
|
||||
print(f"ERROR: {symbols_dir} does not exist")
|
||||
return 1
|
||||
|
||||
sym_table_path = global_sym_lib_table_path()
|
||||
sym_table_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing_text = ""
|
||||
if sym_table_path.exists():
|
||||
existing_text = sym_table_path.read_text(encoding="utf-8")
|
||||
|
||||
existing_libs = parse_existing_libs(existing_text)
|
||||
|
||||
# Backup
|
||||
if sym_table_path.exists():
|
||||
backup_path = sym_table_path.with_suffix(".bak")
|
||||
backup_path.write_text(existing_text, encoding="utf-8")
|
||||
print(f"Backup written: {backup_path}")
|
||||
|
||||
# Filter out all existing UM_KICAD entries
|
||||
existing_blocks = []
|
||||
removed = 0
|
||||
for m in LIB_BLOCK_RE.finditer(existing_text):
|
||||
block = m.group(0).strip()
|
||||
uri_m = URI_RE.search(block)
|
||||
if uri_m:
|
||||
uri = strip_atom(uri_m.group(1))
|
||||
if "${UM_KICAD}" in uri:
|
||||
removed += 1
|
||||
continue
|
||||
existing_blocks.append(block)
|
||||
|
||||
# Scan UM_KICAD/lib/symbols
|
||||
sym_files = sorted(symbols_dir.glob("*.kicad_sym"))
|
||||
|
||||
if not sym_files:
|
||||
print("No .kicad_sym files found.")
|
||||
# Still rebuild table without UM_KICAD entries
|
||||
output = ["(sym_lib_table"]
|
||||
for block in existing_blocks:
|
||||
output.append(" " + block.replace("\n", "\n "))
|
||||
output.append(")\n")
|
||||
sym_table_path.write_text("\n".join(output), encoding="utf-8")
|
||||
print(f"Removed {removed} UM_KICAD entries")
|
||||
return 0
|
||||
|
||||
# Build new UM_KICAD blocks
|
||||
new_blocks: List[str] = []
|
||||
used_names = set()
|
||||
|
||||
# Collect names from non-UM_KICAD entries to avoid conflicts
|
||||
for name, uri in existing_libs.items():
|
||||
if "${UM_KICAD}" not in uri:
|
||||
used_names.add(name)
|
||||
|
||||
for sym_file in sym_files:
|
||||
rel_path = sym_file.relative_to(um_root).as_posix()
|
||||
uri = "${UM_KICAD}/" + rel_path
|
||||
|
||||
base_name = sym_file.stem
|
||||
name = make_unique_name(base_name, used_names)
|
||||
|
||||
block = create_lib_block(
|
||||
name=name,
|
||||
uri=uri,
|
||||
descr=f"Auto-added from {rel_path}"
|
||||
)
|
||||
|
||||
new_blocks.append(block)
|
||||
used_names.add(name)
|
||||
|
||||
# Rebuild table
|
||||
all_blocks = existing_blocks + new_blocks
|
||||
|
||||
output = ["(sym_lib_table"]
|
||||
for block in all_blocks:
|
||||
output.append(" " + block.replace("\n", "\n "))
|
||||
output.append(")\n")
|
||||
|
||||
sym_table_path.write_text("\n".join(output), encoding="utf-8")
|
||||
|
||||
print(f"Updated {sym_table_path}")
|
||||
print(f"Found: {len(sym_files)} libraries")
|
||||
print(f"Removed: {removed} old UM_KICAD entries")
|
||||
print(f"Added: {len(new_blocks)} new UM_KICAD entries")
|
||||
return 0
|
||||
|
||||
|
||||
def sync_footprint_libraries(um_root: Path) -> int:
|
||||
"""Synchronize footprint libraries"""
|
||||
footprints_dir = um_root / "lib" / "footprints"
|
||||
|
||||
if not footprints_dir.exists():
|
||||
print(f"Warning: {footprints_dir} does not exist, skipping footprint sync")
|
||||
return 0
|
||||
|
||||
fp_table_path = global_fp_lib_table_path()
|
||||
fp_table_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
existing_text = ""
|
||||
if fp_table_path.exists():
|
||||
existing_text = fp_table_path.read_text(encoding="utf-8")
|
||||
|
||||
existing_libs = parse_existing_libs(existing_text)
|
||||
|
||||
# Backup
|
||||
if fp_table_path.exists():
|
||||
backup_path = fp_table_path.with_suffix(".bak")
|
||||
backup_path.write_text(existing_text, encoding="utf-8")
|
||||
print(f"Backup written: {backup_path}")
|
||||
|
||||
# Filter out all existing UM_KICAD entries
|
||||
existing_blocks = []
|
||||
removed = 0
|
||||
for m in LIB_BLOCK_RE.finditer(existing_text):
|
||||
block = m.group(0).strip()
|
||||
uri_m = URI_RE.search(block)
|
||||
if uri_m:
|
||||
uri = strip_atom(uri_m.group(1))
|
||||
if "${UM_KICAD}" in uri:
|
||||
removed += 1
|
||||
continue
|
||||
existing_blocks.append(block)
|
||||
|
||||
# Scan UM_KICAD/lib/footprints for *.pretty directories
|
||||
pretty_dirs = sorted([d for d in footprints_dir.iterdir() if d.is_dir() and d.name.endswith('.pretty')])
|
||||
|
||||
if not pretty_dirs:
|
||||
print("No .pretty footprint libraries found.")
|
||||
# Still rebuild table without UM_KICAD entries
|
||||
output = ["(fp_lib_table"]
|
||||
for block in existing_blocks:
|
||||
output.append(" " + block.replace("\n", "\n "))
|
||||
output.append(")\n")
|
||||
fp_table_path.write_text("\n".join(output), encoding="utf-8")
|
||||
print(f"Removed {removed} UM_KICAD footprint entries")
|
||||
return 0
|
||||
|
||||
# Build new UM_KICAD blocks
|
||||
new_blocks: List[str] = []
|
||||
used_names = set()
|
||||
|
||||
# Collect names from non-UM_KICAD entries to avoid conflicts
|
||||
for name, uri in existing_libs.items():
|
||||
if "${UM_KICAD}" not in uri:
|
||||
used_names.add(name)
|
||||
|
||||
for pretty_dir in pretty_dirs:
|
||||
rel_path = pretty_dir.relative_to(um_root).as_posix()
|
||||
uri = "${UM_KICAD}/" + rel_path
|
||||
|
||||
base_name = pretty_dir.stem # Removes .pretty extension
|
||||
name = make_unique_name(base_name, used_names)
|
||||
|
||||
block = create_fp_lib_block(
|
||||
name=name,
|
||||
uri=uri,
|
||||
descr=f"Auto-added from {rel_path}"
|
||||
)
|
||||
|
||||
new_blocks.append(block)
|
||||
used_names.add(name)
|
||||
|
||||
# Rebuild table
|
||||
all_blocks = existing_blocks + new_blocks
|
||||
|
||||
output = ["(fp_lib_table"]
|
||||
for block in all_blocks:
|
||||
output.append(" " + block.replace("\n", "\n "))
|
||||
output.append(")\n")
|
||||
|
||||
fp_table_path.write_text("\n".join(output), encoding="utf-8")
|
||||
|
||||
print(f"Updated {fp_table_path}")
|
||||
print(f"Found: {len(pretty_dirs)} footprint libraries")
|
||||
print(f"Removed: {removed} old UM_KICAD entries")
|
||||
print(f"Added: {len(new_blocks)} new UM_KICAD entries")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
um_root_env = os.environ.get("UM_KICAD")
|
||||
if not um_root_env:
|
||||
print("ERROR: UM_KICAD not set")
|
||||
return 1
|
||||
|
||||
um_root = Path(um_root_env).resolve()
|
||||
|
||||
print("=" * 60)
|
||||
print("Syncing Symbol Libraries...")
|
||||
print("=" * 60)
|
||||
result = sync_symbol_libraries(um_root)
|
||||
if result != 0:
|
||||
return result
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Syncing Footprint Libraries...")
|
||||
print("=" * 60)
|
||||
result = sync_footprint_libraries(um_root)
|
||||
if result != 0:
|
||||
return result
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Restart KiCad to refresh libraries.")
|
||||
print("=" * 60)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
763
app.py
Normal file
763
app.py
Normal file
@@ -0,0 +1,763 @@
|
||||
import sys
|
||||
import webbrowser
|
||||
import threading
|
||||
import subprocess
|
||||
import os
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import json
|
||||
from pathlib import Path
|
||||
from flask import Flask, render_template, request, send_file, jsonify
|
||||
from flask_socketio import SocketIO, emit
|
||||
import time
|
||||
from PyPDF2 import PdfMerger
|
||||
from variant_manager import VariantManager
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = 'secret!'
|
||||
socketio = SocketIO(app, cors_allowed_origins="*")
|
||||
|
||||
# Store arguments
|
||||
app_args = {}
|
||||
connected_clients = set()
|
||||
heartbeat_timeout = 5 # seconds
|
||||
|
||||
# Configuration
|
||||
config_file = 'config.json'
|
||||
app_config = {}
|
||||
|
||||
def load_config():
|
||||
"""Load configuration from file"""
|
||||
global app_config
|
||||
if os.path.exists(config_file):
|
||||
with open(config_file, 'r') as f:
|
||||
app_config = json.load(f)
|
||||
else:
|
||||
app_config = {
|
||||
'parts_spreadsheet_path': ''
|
||||
}
|
||||
return app_config
|
||||
|
||||
def save_config():
|
||||
"""Save configuration to file"""
|
||||
with open(config_file, 'w') as f:
|
||||
json.dump(app_config, f, indent=2)
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
# Reconstruct the command line that invoked this app
|
||||
cmd_parts = [sys.argv[0]]
|
||||
for i in range(1, len(sys.argv)):
|
||||
arg = sys.argv[i]
|
||||
# Quote arguments with spaces
|
||||
if ' ' in arg:
|
||||
cmd_parts.append(f'"{arg}"')
|
||||
else:
|
||||
cmd_parts.append(arg)
|
||||
invocation_cmd = ' '.join(cmd_parts)
|
||||
|
||||
return render_template('index.html', args=app_args, invocation_cmd=invocation_cmd)
|
||||
|
||||
@socketio.on('connect')
|
||||
def handle_connect():
|
||||
connected_clients.add(request.sid)
|
||||
print(f"Client connected: {request.sid}")
|
||||
|
||||
@socketio.on('disconnect')
|
||||
def handle_disconnect():
|
||||
connected_clients.discard(request.sid)
|
||||
print(f"Client disconnected: {request.sid}")
|
||||
|
||||
# Shutdown if no clients connected
|
||||
if not connected_clients:
|
||||
print("No clients connected. Shutting down...")
|
||||
threading.Timer(1.0, shutdown_server).start()
|
||||
|
||||
@socketio.on('heartbeat')
|
||||
def handle_heartbeat():
|
||||
emit('heartbeat_ack')
|
||||
|
||||
@socketio.on('generate_pdf')
|
||||
def handle_generate_pdf():
|
||||
try:
|
||||
kicad_cli = app_args.get('Kicad Cli', '')
|
||||
schematic_file = app_args.get('Schematic File', '')
|
||||
board_file = app_args.get('Board File', '')
|
||||
project_dir = app_args.get('Project Dir', '')
|
||||
project_name = app_args.get('Project Name', 'project')
|
||||
|
||||
if not kicad_cli:
|
||||
emit('pdf_error', {'error': 'Missing kicad-cli argument'})
|
||||
return
|
||||
|
||||
# Create temporary directory for PDFs
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
schematics_dir = os.path.join(temp_dir, 'schematics')
|
||||
board_dir = os.path.join(temp_dir, 'board')
|
||||
os.makedirs(schematics_dir, exist_ok=True)
|
||||
os.makedirs(board_dir, exist_ok=True)
|
||||
|
||||
# Generate schematic PDF
|
||||
if schematic_file:
|
||||
emit('pdf_status', {'status': 'Generating schematic PDF...'})
|
||||
sch_pdf_path = os.path.join(schematics_dir, f'{project_name}_schematic.pdf')
|
||||
cmd = [kicad_cli, 'sch', 'export', 'pdf', schematic_file, '-o', sch_pdf_path]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
shutil.rmtree(temp_dir)
|
||||
emit('pdf_error', {'error': f'Schematic PDF failed: {result.stderr}'})
|
||||
return
|
||||
|
||||
# Generate board layer PDFs - one per layer, then merge
|
||||
if board_file:
|
||||
emit('pdf_status', {'status': 'Generating board layer PDFs...'})
|
||||
|
||||
# All layers to export
|
||||
layers = [
|
||||
('F.Cu', 'Top_Copper'),
|
||||
('B.Cu', 'Bottom_Copper'),
|
||||
('F.Silkscreen', 'Top_Silkscreen'),
|
||||
('B.Silkscreen', 'Bottom_Silkscreen'),
|
||||
('F.Mask', 'Top_Soldermask'),
|
||||
('B.Mask', 'Bottom_Soldermask'),
|
||||
('F.Paste', 'Top_Paste'),
|
||||
('B.Paste', 'Bottom_Paste'),
|
||||
('Edge.Cuts', 'Board_Outline'),
|
||||
('F.Fab', 'Top_Fabrication'),
|
||||
('B.Fab', 'Bottom_Fabrication'),
|
||||
]
|
||||
|
||||
temp_pdf_dir = os.path.join(temp_dir, 'temp_pdfs')
|
||||
os.makedirs(temp_pdf_dir, exist_ok=True)
|
||||
pdf_files = []
|
||||
|
||||
for layer_name, file_suffix in layers:
|
||||
pdf_path = os.path.join(temp_pdf_dir, f'{file_suffix}.pdf')
|
||||
|
||||
# Include Edge.Cuts on every layer except the Edge.Cuts layer itself
|
||||
if layer_name == 'Edge.Cuts':
|
||||
layers_to_export = layer_name
|
||||
else:
|
||||
layers_to_export = f"{layer_name},Edge.Cuts"
|
||||
|
||||
cmd = [
|
||||
kicad_cli, 'pcb', 'export', 'pdf',
|
||||
board_file,
|
||||
'-l', layers_to_export,
|
||||
'--include-border-title',
|
||||
'-o', pdf_path
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
pdf_files.append(pdf_path)
|
||||
else:
|
||||
print(f"Warning: Failed to generate {layer_name}: {result.stderr}")
|
||||
|
||||
# Merge all PDFs into one
|
||||
if pdf_files:
|
||||
emit('pdf_status', {'status': 'Merging board layer PDFs...'})
|
||||
merged_pdf_path = os.path.join(board_dir, f'{project_name}.pdf')
|
||||
merger = PdfMerger()
|
||||
|
||||
for pdf in pdf_files:
|
||||
merger.append(pdf)
|
||||
|
||||
merger.write(merged_pdf_path)
|
||||
merger.close()
|
||||
|
||||
# Delete temp PDF directory
|
||||
shutil.rmtree(temp_pdf_dir)
|
||||
|
||||
# Create ZIP file
|
||||
emit('pdf_status', {'status': 'Creating ZIP archive...'})
|
||||
zip_filename = f'{project_name}_PDFs.zip'
|
||||
zip_path = os.path.join(project_dir if project_dir else temp_dir, zip_filename)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
for root, dirs, files in os.walk(temp_dir):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.relpath(file_path, temp_dir)
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
# Clean up temp directory
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
emit('pdf_complete', {'path': zip_path, 'filename': zip_filename})
|
||||
|
||||
except Exception as e:
|
||||
emit('pdf_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('generate_gerbers')
|
||||
def handle_generate_gerbers():
|
||||
try:
|
||||
kicad_cli = app_args.get('Kicad Cli', '')
|
||||
board_file = app_args.get('Board File', '')
|
||||
project_dir = app_args.get('Project Dir', '')
|
||||
project_name = app_args.get('Project Name', 'project')
|
||||
|
||||
if not kicad_cli or not board_file:
|
||||
emit('gerber_error', {'error': 'Missing kicad-cli or board-file arguments'})
|
||||
return
|
||||
|
||||
# Create temporary directory for gerbers
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
gerber_dir = os.path.join(temp_dir, 'gerbers')
|
||||
os.makedirs(gerber_dir, exist_ok=True)
|
||||
|
||||
# Generate gerbers
|
||||
emit('gerber_status', {'status': 'Generating gerber files...'})
|
||||
cmd = [kicad_cli, 'pcb', 'export', 'gerbers', board_file, '-o', gerber_dir]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
shutil.rmtree(temp_dir)
|
||||
emit('gerber_error', {'error': f'Gerber generation failed: {result.stderr}'})
|
||||
return
|
||||
|
||||
# Generate drill files
|
||||
emit('gerber_status', {'status': 'Generating drill files...'})
|
||||
cmd = [kicad_cli, 'pcb', 'export', 'drill', board_file, '-o', gerber_dir]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Warning: Drill file generation failed: {result.stderr}")
|
||||
|
||||
# Generate ODB++ files
|
||||
emit('gerber_status', {'status': 'Generating ODB++ files...'})
|
||||
odb_dir = os.path.join(temp_dir, 'odb')
|
||||
os.makedirs(odb_dir, exist_ok=True)
|
||||
odb_file = os.path.join(odb_dir, f'{project_name}.zip')
|
||||
cmd = [kicad_cli, 'pcb', 'export', 'odb', board_file, '-o', odb_file]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
|
||||
if result.returncode != 0:
|
||||
print(f"Warning: ODB++ generation failed: {result.stderr}")
|
||||
|
||||
# Create ZIP file
|
||||
emit('gerber_status', {'status': 'Creating ZIP archive...'})
|
||||
zip_filename = f'{project_name}_fab.zip'
|
||||
zip_path = os.path.join(project_dir if project_dir else temp_dir, zip_filename)
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
|
||||
# Add gerbers folder
|
||||
for root, dirs, files in os.walk(gerber_dir):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.join('gerbers', os.path.basename(file_path))
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
# Add odb folder
|
||||
for root, dirs, files in os.walk(odb_dir):
|
||||
for file in files:
|
||||
file_path = os.path.join(root, file)
|
||||
arcname = os.path.join('odb', os.path.relpath(file_path, odb_dir))
|
||||
zipf.write(file_path, arcname)
|
||||
|
||||
# Clean up temp directory
|
||||
shutil.rmtree(temp_dir)
|
||||
|
||||
emit('gerber_complete', {'path': zip_path, 'filename': zip_filename})
|
||||
|
||||
except Exception as e:
|
||||
emit('gerber_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('sync_libraries')
|
||||
def handle_sync_libraries():
|
||||
try:
|
||||
emit('sync_status', {'status': 'Starting library synchronization...'})
|
||||
|
||||
# Check if UM_KICAD is set
|
||||
um_kicad = os.environ.get('UM_KICAD')
|
||||
if not um_kicad:
|
||||
emit('sync_error', {'error': 'UM_KICAD environment variable is not set in the Flask app environment'})
|
||||
return
|
||||
|
||||
emit('sync_status', {'status': f'UM_KICAD is set to: {um_kicad}'})
|
||||
|
||||
# Run the add_libraries.py script
|
||||
script_path = os.path.join(os.path.dirname(__file__), 'add_libraries.py')
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
emit('sync_error', {'error': 'add_libraries.py script not found'})
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, script_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ.copy()
|
||||
)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
if result.returncode == 0:
|
||||
emit('sync_complete', {'output': output})
|
||||
else:
|
||||
emit('sync_error', {'error': f'Sync failed:\n{output}'})
|
||||
|
||||
except Exception as e:
|
||||
emit('sync_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('sync_database')
|
||||
def handle_sync_database():
|
||||
try:
|
||||
emit('db_sync_status', {'status': 'Starting database synchronization...'})
|
||||
|
||||
# Get the parts spreadsheet path from config
|
||||
parts_spreadsheet = app_config.get('parts_spreadsheet_path', '')
|
||||
if not parts_spreadsheet:
|
||||
emit('db_sync_error', {'error': 'Parts spreadsheet path not configured. Please set it in Settings.'})
|
||||
return
|
||||
|
||||
if not os.path.exists(parts_spreadsheet):
|
||||
emit('db_sync_error', {'error': f'Parts spreadsheet not found at: {parts_spreadsheet}'})
|
||||
return
|
||||
|
||||
emit('db_sync_status', {'status': f'Using parts spreadsheet: {parts_spreadsheet}'})
|
||||
|
||||
# Run the gen_resistors_db.py script
|
||||
script_path = os.path.join(os.path.dirname(__file__), 'gen_resistors_db.py')
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
emit('db_sync_error', {'error': 'gen_resistors_db.py script not found'})
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, script_path, parts_spreadsheet],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ.copy()
|
||||
)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
if result.returncode == 0:
|
||||
emit('db_sync_complete', {'output': output})
|
||||
else:
|
||||
emit('db_sync_error', {'error': f'Database sync failed:\n{output}'})
|
||||
|
||||
except Exception as e:
|
||||
emit('db_sync_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('init_user')
|
||||
def handle_init_user():
|
||||
try:
|
||||
emit('init_status', {'status': 'Starting user environment initialization...'})
|
||||
|
||||
# Check if UM_KICAD is set
|
||||
um_kicad = os.environ.get('UM_KICAD')
|
||||
if not um_kicad:
|
||||
emit('init_error', {'error': 'UM_KICAD environment variable is not set'})
|
||||
return
|
||||
|
||||
emit('init_status', {'status': f'UM_KICAD: {um_kicad}'})
|
||||
|
||||
# Run the init_user.py script
|
||||
script_path = os.path.join(os.path.dirname(__file__), 'init_user.py')
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
emit('init_error', {'error': 'init_user.py script not found'})
|
||||
return
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, script_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=os.environ.copy()
|
||||
)
|
||||
|
||||
output = result.stdout + result.stderr
|
||||
|
||||
if result.returncode == 0:
|
||||
emit('init_complete', {'output': output})
|
||||
else:
|
||||
emit('init_error', {'error': f'Initialization failed:\n{output}'})
|
||||
|
||||
except Exception as e:
|
||||
emit('init_error', {'error': str(e)})
|
||||
|
||||
@app.route('/download/<path:filename>')
|
||||
def download_file(filename):
|
||||
project_dir = app_args.get('Project Dir', '')
|
||||
file_path = os.path.join(project_dir, filename)
|
||||
if os.path.exists(file_path):
|
||||
return send_file(file_path, as_attachment=True)
|
||||
return "File not found", 404
|
||||
|
||||
@app.route('/config', methods=['GET', 'POST'])
|
||||
def config():
|
||||
if request.method == 'POST':
|
||||
data = request.get_json()
|
||||
app_config['parts_spreadsheet_path'] = data.get('parts_spreadsheet_path', '')
|
||||
save_config()
|
||||
return jsonify({'status': 'success', 'config': app_config})
|
||||
else:
|
||||
return jsonify(app_config)
|
||||
|
||||
@app.route('/variants')
|
||||
def variants_page():
|
||||
return render_template('variants.html')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Variant Management Socket Handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_variant_manager():
|
||||
"""Get VariantManager instance for current project"""
|
||||
schematic_file = app_args.get('Schematic File', '')
|
||||
if not schematic_file or not os.path.exists(schematic_file):
|
||||
return None
|
||||
return VariantManager(schematic_file)
|
||||
|
||||
def get_all_schematic_files(root_schematic):
|
||||
"""Get all schematic files in a hierarchical design"""
|
||||
from pathlib import Path
|
||||
|
||||
root_path = Path(root_schematic)
|
||||
if not root_path.exists():
|
||||
return [root_schematic]
|
||||
|
||||
schematic_files = [str(root_path)]
|
||||
schematic_dir = root_path.parent
|
||||
|
||||
try:
|
||||
with open(root_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
for line in content.split('\n'):
|
||||
if '(property "Sheetfile"' in line:
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 4:
|
||||
sheet_file = parts[3]
|
||||
sheet_path = schematic_dir / sheet_file
|
||||
if sheet_path.exists():
|
||||
sub_sheets = get_all_schematic_files(str(sheet_path))
|
||||
for sub in sub_sheets:
|
||||
if sub not in schematic_files:
|
||||
schematic_files.append(sub)
|
||||
except:
|
||||
pass
|
||||
|
||||
return schematic_files
|
||||
|
||||
|
||||
def get_all_parts_from_schematic():
|
||||
"""Get all component references, values, and UUIDs from all schematics (including hierarchical sheets)"""
|
||||
schematic_file = app_args.get('Schematic File', '')
|
||||
if not schematic_file or not os.path.exists(schematic_file):
|
||||
return []
|
||||
|
||||
# Get all schematic files
|
||||
all_schematics = get_all_schematic_files(schematic_file)
|
||||
all_parts = {} # uuid -> {reference, value}
|
||||
|
||||
for sch_file in all_schematics:
|
||||
try:
|
||||
with open(sch_file, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
lines = content.split('\n')
|
||||
in_symbol = False
|
||||
current_uuid = None
|
||||
current_ref = None
|
||||
current_value = None
|
||||
current_lib_id = None
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
|
||||
# Detect start of symbol
|
||||
if stripped.startswith('(symbol'):
|
||||
in_symbol = True
|
||||
current_uuid = None
|
||||
current_ref = None
|
||||
current_value = None
|
||||
current_lib_id = None
|
||||
|
||||
# Detect end of symbol
|
||||
elif in_symbol and stripped == ')':
|
||||
# Save the part if we have all the info, excluding power symbols
|
||||
is_power = current_lib_id and 'power:' in current_lib_id
|
||||
is_power = is_power or (current_ref and current_ref.startswith('#'))
|
||||
|
||||
if current_uuid and current_ref and not is_power and len(current_ref) > 1:
|
||||
all_parts[current_uuid] = {
|
||||
'reference': current_ref,
|
||||
'value': current_value or ''
|
||||
}
|
||||
in_symbol = False
|
||||
|
||||
# Extract lib_id to check for power symbols
|
||||
elif in_symbol and '(lib_id' in stripped:
|
||||
lib_parts = line.split('"')
|
||||
if len(lib_parts) >= 2:
|
||||
current_lib_id = lib_parts[1]
|
||||
|
||||
# Extract UUID
|
||||
elif in_symbol and '(uuid' in stripped:
|
||||
uuid_parts = line.split('"')
|
||||
if len(uuid_parts) >= 2:
|
||||
current_uuid = uuid_parts[1]
|
||||
|
||||
# Extract reference - format: (property "Reference" "U1" ...
|
||||
elif in_symbol and '(property "Reference"' in line:
|
||||
try:
|
||||
start = line.find('"Reference"') + len('"Reference"')
|
||||
remainder = line[start:]
|
||||
quote_start = remainder.find('"')
|
||||
if quote_start != -1:
|
||||
quote_end = remainder.find('"', quote_start + 1)
|
||||
if quote_end != -1:
|
||||
current_ref = remainder[quote_start + 1:quote_end]
|
||||
except:
|
||||
pass
|
||||
|
||||
# Extract value - format: (property "Value" "LM358" ...
|
||||
elif in_symbol and '(property "Value"' in line:
|
||||
try:
|
||||
start = line.find('"Value"') + len('"Value"')
|
||||
remainder = line[start:]
|
||||
quote_start = remainder.find('"')
|
||||
if quote_start != -1:
|
||||
quote_end = remainder.find('"', quote_start + 1)
|
||||
if quote_end != -1:
|
||||
current_value = remainder[quote_start + 1:quote_end]
|
||||
except:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading schematic {sch_file}: {e}")
|
||||
|
||||
return [{'uuid': uuid, 'reference': data['reference'], 'value': data['value']}
|
||||
for uuid, data in sorted(all_parts.items(), key=lambda x: x[1]['reference'])]
|
||||
|
||||
@socketio.on('get_variants')
|
||||
def handle_get_variants():
|
||||
try:
|
||||
manager = get_variant_manager()
|
||||
if not manager:
|
||||
emit('variant_error', {'error': 'No project loaded'})
|
||||
return
|
||||
|
||||
all_parts = get_all_parts_from_schematic()
|
||||
|
||||
emit('variants_data', {
|
||||
'project_name': manager.project_name,
|
||||
'variants': manager.get_variants(),
|
||||
'active_variant': manager.get_active_variant(),
|
||||
'all_parts': all_parts # Now includes uuid, reference, and value
|
||||
})
|
||||
except Exception as e:
|
||||
emit('variant_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('create_variant')
|
||||
def handle_create_variant(data):
|
||||
try:
|
||||
manager = get_variant_manager()
|
||||
if not manager:
|
||||
emit('variant_error', {'error': 'No project loaded'})
|
||||
return
|
||||
|
||||
name = data.get('name', '')
|
||||
description = data.get('description', '')
|
||||
based_on = data.get('based_on', None)
|
||||
|
||||
if not name:
|
||||
emit('variant_error', {'error': 'Variant name required'})
|
||||
return
|
||||
|
||||
success = manager.create_variant(name, description, based_on)
|
||||
if success:
|
||||
emit('variant_updated', {'message': f'Variant "{name}" created'})
|
||||
else:
|
||||
emit('variant_error', {'error': f'Variant "{name}" already exists'})
|
||||
except Exception as e:
|
||||
emit('variant_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('delete_variant')
|
||||
def handle_delete_variant(data):
|
||||
try:
|
||||
manager = get_variant_manager()
|
||||
if not manager:
|
||||
emit('variant_error', {'error': 'No project loaded'})
|
||||
return
|
||||
|
||||
name = data.get('name', '')
|
||||
success = manager.delete_variant(name)
|
||||
|
||||
if success:
|
||||
emit('variant_updated', {'message': f'Variant "{name}" deleted'})
|
||||
else:
|
||||
emit('variant_error', {'error': f'Cannot delete variant "{name}"'})
|
||||
except Exception as e:
|
||||
emit('variant_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('activate_variant')
|
||||
def handle_activate_variant(data):
|
||||
try:
|
||||
manager = get_variant_manager()
|
||||
if not manager:
|
||||
emit('variant_error', {'error': 'No project loaded'})
|
||||
return
|
||||
|
||||
name = data.get('name', '')
|
||||
schematic_file = app_args.get('Schematic File', '')
|
||||
kicad_cli = app_args.get('Kicad Cli', 'kicad-cli')
|
||||
|
||||
# First, sync the current variant from schematic to capture any manual changes
|
||||
current_variant = manager.get_active_variant()
|
||||
|
||||
print(f"Syncing current variant '{current_variant}' before switching...")
|
||||
# Import and call sync function directly instead of subprocess
|
||||
from sync_variant import sync_variant_from_schematic
|
||||
try:
|
||||
sync_success = sync_variant_from_schematic(schematic_file, current_variant)
|
||||
if sync_success:
|
||||
print(f"Successfully synced variant '{current_variant}'")
|
||||
# Reload the manager to get the updated data
|
||||
manager = get_variant_manager()
|
||||
else:
|
||||
print(f"Warning: Sync of variant '{current_variant}' failed")
|
||||
except Exception as e:
|
||||
print(f"Error during sync: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Now activate the new variant
|
||||
success = manager.set_active_variant(name)
|
||||
|
||||
if success:
|
||||
# Apply new variant to schematic
|
||||
apply_script_path = os.path.join(os.path.dirname(__file__), 'apply_variant.py')
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, apply_script_path, schematic_file, name, kicad_cli],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
emit('variant_updated', {'message': f'Synced "{current_variant}", then activated and applied "{name}"'})
|
||||
else:
|
||||
error_msg = result.stderr if result.stderr else result.stdout
|
||||
emit('variant_error', {'error': f'Failed to apply variant: {error_msg}'})
|
||||
else:
|
||||
emit('variant_error', {'error': f'Variant "{name}" not found'})
|
||||
except Exception as e:
|
||||
emit('variant_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('get_variant_parts')
|
||||
def handle_get_variant_parts(data):
|
||||
try:
|
||||
manager = get_variant_manager()
|
||||
if not manager:
|
||||
emit('variant_error', {'error': 'No project loaded'})
|
||||
return
|
||||
|
||||
variant_name = data.get('variant', '')
|
||||
all_parts = get_all_parts_from_schematic()
|
||||
dnp_uuids = manager.get_dnp_parts(variant_name)
|
||||
|
||||
parts_data = []
|
||||
for part in all_parts:
|
||||
parts_data.append({
|
||||
'uuid': part['uuid'],
|
||||
'reference': part['reference'],
|
||||
'value': part['value'],
|
||||
'is_dnp': part['uuid'] in dnp_uuids
|
||||
})
|
||||
|
||||
emit('variant_parts_data', {'parts': parts_data})
|
||||
except Exception as e:
|
||||
emit('variant_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('set_part_dnp')
|
||||
def handle_set_part_dnp(data):
|
||||
try:
|
||||
manager = get_variant_manager()
|
||||
if not manager:
|
||||
emit('variant_error', {'error': 'No project loaded'})
|
||||
return
|
||||
|
||||
variant = data.get('variant', '')
|
||||
uuid = data.get('uuid', '')
|
||||
is_dnp = data.get('is_dnp', False)
|
||||
|
||||
success = manager.set_part_dnp(variant, uuid, is_dnp)
|
||||
if success:
|
||||
# Re-send updated parts list
|
||||
handle_get_variant_parts({'variant': variant})
|
||||
else:
|
||||
emit('variant_error', {'error': 'Failed to update part'})
|
||||
except Exception as e:
|
||||
emit('variant_error', {'error': str(e)})
|
||||
|
||||
@socketio.on('sync_from_schematic')
|
||||
def handle_sync_from_schematic():
|
||||
try:
|
||||
manager = get_variant_manager()
|
||||
if not manager:
|
||||
emit('variant_error', {'error': 'No project loaded'})
|
||||
return
|
||||
|
||||
# Read DNP state from schematic and update active variant
|
||||
schematic_file = app_args.get('Schematic File', '')
|
||||
script_path = os.path.join(os.path.dirname(__file__), 'sync_variant.py')
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, script_path, schematic_file],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
emit('sync_complete', {'message': f'Synced from schematic:\n{result.stdout}'})
|
||||
else:
|
||||
emit('variant_error', {'error': f'Failed to sync: {result.stderr}'})
|
||||
except Exception as e:
|
||||
emit('variant_error', {'error': str(e)})
|
||||
|
||||
def shutdown_server():
|
||||
print("Server stopped")
|
||||
os._exit(0)
|
||||
|
||||
def parse_args(args):
|
||||
"""Parse command line arguments into a dictionary"""
|
||||
parsed = {'executable': args[0] if args else ''}
|
||||
|
||||
i = 1
|
||||
while i < len(args):
|
||||
if args[i].startswith('--'):
|
||||
key = args[i][2:].replace('-', ' ').title()
|
||||
if i + 1 < len(args) and not args[i + 1].startswith('--'):
|
||||
parsed[key] = args[i + 1]
|
||||
i += 2
|
||||
else:
|
||||
parsed[key] = 'true'
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return parsed
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Load configuration
|
||||
load_config()
|
||||
|
||||
# Parse arguments
|
||||
app_args = parse_args(sys.argv)
|
||||
|
||||
# Open browser after short delay
|
||||
def open_browser():
|
||||
time.sleep(1.5)
|
||||
webbrowser.open('http://127.0.0.1:5000')
|
||||
|
||||
threading.Thread(target=open_browser, daemon=True).start()
|
||||
|
||||
# Run the app
|
||||
print("Starting Flask app...")
|
||||
socketio.run(app, debug=False, host='127.0.0.1', port=5000)
|
||||
269
apply_variant.py
Normal file
269
apply_variant.py
Normal file
@@ -0,0 +1,269 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
apply_variant.py
|
||||
================
|
||||
Apply a variant to a KiCad schematic by setting DNP (Do Not Place) flags on components.
|
||||
|
||||
Uses KiCad CLI to modify the schematic.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from variant_manager import VariantManager
|
||||
|
||||
|
||||
def get_all_schematic_files(root_schematic: str) -> list:
|
||||
"""
|
||||
Get all schematic files in a hierarchical design.
|
||||
|
||||
Args:
|
||||
root_schematic: Path to root .kicad_sch file
|
||||
|
||||
Returns:
|
||||
List of all schematic file paths (including root)
|
||||
"""
|
||||
root_path = Path(root_schematic)
|
||||
if not root_path.exists():
|
||||
return [root_schematic]
|
||||
|
||||
schematic_files = [str(root_path)]
|
||||
schematic_dir = root_path.parent
|
||||
|
||||
# Read root schematic to find sheet files
|
||||
try:
|
||||
with open(root_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find all sheet file references
|
||||
for line in content.split('\n'):
|
||||
if '(property "Sheetfile"' in line:
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 4:
|
||||
sheet_file = parts[3]
|
||||
sheet_path = schematic_dir / sheet_file
|
||||
if sheet_path.exists():
|
||||
# Recursively get sheets from this sheet
|
||||
sub_sheets = get_all_schematic_files(str(sheet_path))
|
||||
for sub in sub_sheets:
|
||||
if sub not in schematic_files:
|
||||
schematic_files.append(sub)
|
||||
except Exception as e:
|
||||
print(f"Warning: Error reading sheet files: {e}")
|
||||
|
||||
return schematic_files
|
||||
|
||||
|
||||
def apply_variant_to_schematic(schematic_file: str, variant_name: str, kicad_cli: str = "kicad-cli") -> bool:
|
||||
"""
|
||||
Apply a variant to a schematic by setting DNP flags.
|
||||
Handles hierarchical schematics by processing all sheets.
|
||||
|
||||
Args:
|
||||
schematic_file: Path to root .kicad_sch file
|
||||
variant_name: Name of variant to apply
|
||||
kicad_cli: Path to kicad-cli executable
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
manager = VariantManager(schematic_file)
|
||||
|
||||
if variant_name not in manager.get_variants():
|
||||
print(f"Error: Variant '{variant_name}' not found")
|
||||
return False
|
||||
|
||||
dnp_parts = manager.get_dnp_parts(variant_name)
|
||||
|
||||
print(f"Applying variant '{variant_name}' to {Path(schematic_file).name}")
|
||||
print(f"DNP parts ({len(dnp_parts)}): {dnp_parts}")
|
||||
|
||||
# Get all schematic files (root + hierarchical sheets)
|
||||
all_schematics = get_all_schematic_files(schematic_file)
|
||||
print(f"Processing {len(all_schematics)} schematic file(s)")
|
||||
|
||||
overall_success = True
|
||||
|
||||
# Process each schematic file
|
||||
for idx, sch_file in enumerate(all_schematics):
|
||||
is_root = (idx == 0) # First file is the root schematic
|
||||
if not process_single_schematic(sch_file, dnp_parts, variant_name, is_root):
|
||||
overall_success = False
|
||||
|
||||
if overall_success:
|
||||
print(f"\nVariant '{variant_name}' applied successfully")
|
||||
print(f"Please reload the schematic in KiCad to see changes")
|
||||
|
||||
return overall_success
|
||||
|
||||
|
||||
def process_single_schematic(schematic_file: str, dnp_uuids: list, variant_name: str = None, is_root: bool = False) -> bool:
|
||||
"""
|
||||
Process a single schematic file to apply DNP flags.
|
||||
|
||||
Args:
|
||||
schematic_file: Path to .kicad_sch file
|
||||
dnp_uuids: List of UUIDs that should be DNP
|
||||
variant_name: Name of variant being applied (for title block)
|
||||
is_root: True if this is the root schematic (not a sub-sheet)
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
sch_path = Path(schematic_file)
|
||||
if not sch_path.exists():
|
||||
print(f"Error: Schematic file not found: {schematic_file}")
|
||||
return False
|
||||
|
||||
print(f"\n Processing: {sch_path.name}")
|
||||
|
||||
try:
|
||||
with open(sch_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse schematic and set DNP flags
|
||||
# KiCad 9 schematic format uses S-expressions
|
||||
# Component structure:
|
||||
# (symbol
|
||||
# (lib_id ...)
|
||||
# (at ...)
|
||||
# (uuid "...") <- UUID appears here
|
||||
# (dnp no) <- DNP flag appears here
|
||||
# (property "Reference" "U1" ...)
|
||||
# ...
|
||||
# )
|
||||
|
||||
lines = content.split('\n')
|
||||
modified = False
|
||||
|
||||
# Update title block comment if this is the root schematic
|
||||
if is_root and variant_name:
|
||||
in_title_block = False
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
|
||||
if stripped.startswith('(title_block'):
|
||||
in_title_block = True
|
||||
elif in_title_block and stripped == ')':
|
||||
in_title_block = False
|
||||
elif in_title_block and '(comment 1' in stripped:
|
||||
# Update comment 1 with variant name
|
||||
indent = line[:len(line) - len(line.lstrip())]
|
||||
new_line = indent + f'(comment 1 "{variant_name}")'
|
||||
if lines[i] != new_line:
|
||||
lines[i] = new_line
|
||||
modified = True
|
||||
print(f" Updated title block: Variant = {variant_name}")
|
||||
break
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
|
||||
# Look for DNP lines
|
||||
if '(dnp' in stripped and (stripped.startswith('(dnp') or '\t(dnp' in line or ' (dnp' in line):
|
||||
# Find the UUID for this symbol by looking forward (UUID comes after DNP)
|
||||
current_uuid = None
|
||||
current_ref = None
|
||||
is_power_symbol = False
|
||||
|
||||
# Look backward to check for power symbols
|
||||
for j in range(i - 1, max(0, i - 10), -1):
|
||||
if '(lib_id' in lines[j] and 'power:' in lines[j]:
|
||||
is_power_symbol = True
|
||||
break
|
||||
if lines[j].strip().startswith('(symbol'):
|
||||
break
|
||||
|
||||
# Skip power symbols
|
||||
if is_power_symbol:
|
||||
continue
|
||||
|
||||
# Look forward for UUID (it comes right after DNP in the structure)
|
||||
for j in range(i + 1, min(len(lines), i + 10)):
|
||||
if '(uuid' in lines[j]:
|
||||
# Extract UUID from line like: (uuid "681abb84-6eb2-4c95-9a2f-a9fc19a34beb")
|
||||
# Make sure it's at symbol level (minimal indentation)
|
||||
if '\t(uuid' in lines[j] or ' (uuid' in lines[j]:
|
||||
uuid_parts = lines[j].split('"')
|
||||
if len(uuid_parts) >= 2:
|
||||
current_uuid = uuid_parts[1]
|
||||
break
|
||||
# Stop if we hit properties or other structures
|
||||
if '(property "Reference"' in lines[j]:
|
||||
break
|
||||
|
||||
# Look forward for reference (for logging purposes)
|
||||
for j in range(i + 1, min(len(lines), i + 20)):
|
||||
if '(property "Reference"' in lines[j]:
|
||||
ref_parts = lines[j].split('"')
|
||||
if len(ref_parts) >= 4:
|
||||
current_ref = ref_parts[3]
|
||||
# Also skip if reference starts with #
|
||||
if current_ref.startswith('#'):
|
||||
is_power_symbol = True
|
||||
break
|
||||
if lines[j].strip().startswith('(symbol') or (lines[j].strip() == ')' and len(lines[j]) - len(lines[j].lstrip()) <= len(line) - len(line.lstrip())):
|
||||
break
|
||||
|
||||
if current_uuid and not is_power_symbol:
|
||||
# Get indentation
|
||||
indent = line[:len(line) - len(line.lstrip())]
|
||||
|
||||
# Check if this part should be DNP
|
||||
should_be_dnp = current_uuid in dnp_uuids
|
||||
|
||||
# Determine what the DNP line should say
|
||||
if should_be_dnp:
|
||||
target_dnp = '(dnp yes)'
|
||||
else:
|
||||
target_dnp = '(dnp no)'
|
||||
|
||||
# Update DNP flag if it's different from target
|
||||
if stripped != target_dnp.strip():
|
||||
lines[i] = indent + target_dnp
|
||||
modified = True
|
||||
if should_be_dnp:
|
||||
print(f" Set DNP: {current_ref if current_ref else current_uuid}")
|
||||
else:
|
||||
print(f" Cleared DNP: {current_ref if current_ref else current_uuid}")
|
||||
|
||||
if modified:
|
||||
# Backup original file
|
||||
backup_path = sch_path.with_suffix('.kicad_sch.bak')
|
||||
|
||||
# Remove old backup if it exists
|
||||
if backup_path.exists():
|
||||
backup_path.unlink()
|
||||
|
||||
# Create new backup
|
||||
import shutil
|
||||
shutil.copy2(sch_path, backup_path)
|
||||
print(f" Backup created: {backup_path.name}")
|
||||
|
||||
# Write modified schematic
|
||||
with open(sch_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(lines))
|
||||
|
||||
print(f" Updated successfully")
|
||||
else:
|
||||
print(f" No changes needed")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 3:
|
||||
print("Usage: python apply_variant.py <schematic.kicad_sch> <variant_name> [kicad-cli]")
|
||||
sys.exit(1)
|
||||
|
||||
schematic = sys.argv[1]
|
||||
variant = sys.argv[2]
|
||||
kicad_cli = sys.argv[3] if len(sys.argv) > 3 else "kicad-cli"
|
||||
|
||||
success = apply_variant_to_schematic(schematic, variant, kicad_cli)
|
||||
sys.exit(0 if success else 1)
|
||||
3
config.json
Normal file
3
config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"parts_spreadsheet_path": "D:\\svn\\pcb\\PN\\parts_list_pcb.xlsx"
|
||||
}
|
||||
89
export_bom.py
Normal file
89
export_bom.py
Normal file
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
BOM CSV Exporter
|
||||
================
|
||||
Reads extract_symbols.json and writes bom.csv with columns:
|
||||
Reference, MPN, MFG
|
||||
|
||||
Power symbols (#PWR, #FLG) are excluded.
|
||||
|
||||
Usage:
|
||||
python3 export_bom.py [project_dir]
|
||||
"""
|
||||
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main(project_dir: Path):
|
||||
json_path = project_dir / 'extract_symbols.json'
|
||||
if not json_path.exists():
|
||||
print(f"Error: {json_path} not found. Run extract_symbols.py first.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
data = json.loads(json_path.read_text(encoding='utf-8'))
|
||||
symbols = data['symbols']
|
||||
|
||||
# Filter out power/flag symbols, DNP parts, and parts excluded from BOM
|
||||
parts = [
|
||||
s for s in symbols
|
||||
if not (s.get('reference') or '').startswith(('#', '~'))
|
||||
and not (s.get('lib_id') or '').startswith('power:')
|
||||
and s.get('in_bom') is not False
|
||||
and not s.get('dnp')
|
||||
]
|
||||
|
||||
# Collapse multi-unit / duplicate records to one row per reference.
|
||||
# If multiple records exist for the same ref, pick the one with the
|
||||
# most complete MPN/MFG data (longest non-placeholder string).
|
||||
def data_score(s):
|
||||
props = s.get('properties', {})
|
||||
mpn = props.get('MPN', '')
|
||||
mfg = props.get('MFG') or props.get('MANUFACTURER', '')
|
||||
placeholder = mpn.strip().lower() in ('', 'x', 'tbd', 'n/a', 'na')
|
||||
return (0 if placeholder else len(mpn) + len(mfg))
|
||||
|
||||
from collections import defaultdict
|
||||
by_ref: dict[str, list] = defaultdict(list)
|
||||
for s in parts:
|
||||
by_ref[s.get('reference', '')].append(s)
|
||||
|
||||
best: list[dict] = []
|
||||
for ref, recs in by_ref.items():
|
||||
best.append(max(recs, key=data_score))
|
||||
|
||||
# Sort by reference
|
||||
def ref_sort_key(r):
|
||||
ref = r.get('reference') or ''
|
||||
letters = ''.join(c for c in ref if c.isalpha())
|
||||
digits = ''.join(c for c in ref if c.isdigit())
|
||||
return (letters, int(digits) if digits else 0)
|
||||
|
||||
best.sort(key=ref_sort_key)
|
||||
|
||||
out_path = project_dir / 'bom.csv'
|
||||
with out_path.open('w', newline='', encoding='utf-8') as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(['Reference', 'MPN', 'MFG'])
|
||||
for s in best:
|
||||
props = s.get('properties', {})
|
||||
writer.writerow([
|
||||
s.get('reference', ''),
|
||||
props.get('MPN', ''),
|
||||
props.get('MFG') or props.get('MANUFACTURER', ''),
|
||||
])
|
||||
|
||||
excluded_bom = sum(1 for s in symbols if s.get('in_bom') is False
|
||||
and not (s.get('reference') or '').startswith(('#', '~')))
|
||||
excluded_dnp = sum(1 for s in symbols if s.get('dnp')
|
||||
and not (s.get('reference') or '').startswith(('#', '~'))
|
||||
and s.get('in_bom') is not False)
|
||||
print(f"Excluded: {excluded_bom} 'exclude from BOM', {excluded_dnp} DNP")
|
||||
print(f"Wrote {len(best)} unique references to {out_path} (collapsed from {len(parts)} records)")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
project_dir = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path(__file__).parent.resolve()
|
||||
main(project_dir)
|
||||
517
extract_symbols.py
Normal file
517
extract_symbols.py
Normal file
@@ -0,0 +1,517 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
KiCad 9 Symbol Metadata Extractor
|
||||
==================================
|
||||
Walks every .kicad_sch file in the project directory and extracts
|
||||
metadata for every placed symbol (component instance), correctly
|
||||
expanding hierarchical sheet instances so that each unique reference
|
||||
in the final design becomes its own record.
|
||||
|
||||
KiCad stores multi-instance sheets by embedding an `(instances ...)`
|
||||
block in each symbol. That block contains one `(path ...)` entry per
|
||||
sheet instantiation, each with the authoritative reference for that
|
||||
copy. This script reads those paths so a sheet used N times produces
|
||||
N distinct records per symbol.
|
||||
|
||||
Output: extract_symbols.json (same directory as this script)
|
||||
|
||||
Usage:
|
||||
python3 extract_symbols.py [project_dir]
|
||||
|
||||
If project_dir is omitted, the directory containing this script is used.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# S-expression parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _tokenize(text: str) -> list:
|
||||
"""
|
||||
Convert raw KiCad S-expression text into a flat list of tokens.
|
||||
Token forms:
|
||||
('OPEN',) – opening paren
|
||||
('CLOSE',) – closing paren
|
||||
('ATOM', value) – unquoted word / number / bool
|
||||
('STR', value) – double-quoted string (escapes resolved)
|
||||
"""
|
||||
tokens = []
|
||||
i, n = 0, len(text)
|
||||
while i < n:
|
||||
c = text[i]
|
||||
if c in ' \t\r\n':
|
||||
i += 1
|
||||
elif c == '(':
|
||||
tokens.append(('OPEN',))
|
||||
i += 1
|
||||
elif c == ')':
|
||||
tokens.append(('CLOSE',))
|
||||
i += 1
|
||||
elif c == '"':
|
||||
j = i + 1
|
||||
buf = []
|
||||
while j < n:
|
||||
if text[j] == '\\' and j + 1 < n:
|
||||
buf.append(text[j + 1])
|
||||
j += 2
|
||||
elif text[j] == '"':
|
||||
j += 1
|
||||
break
|
||||
else:
|
||||
buf.append(text[j])
|
||||
j += 1
|
||||
tokens.append(('STR', ''.join(buf)))
|
||||
i = j
|
||||
else:
|
||||
j = i
|
||||
while j < n and text[j] not in ' \t\r\n()':
|
||||
j += 1
|
||||
tokens.append(('ATOM', text[i:j]))
|
||||
i = j
|
||||
return tokens
|
||||
|
||||
|
||||
def _parse(tokens: list, pos: int) -> tuple:
|
||||
"""
|
||||
Recursively parse one S-expression value starting at *pos*.
|
||||
Returns (parsed_value, next_pos).
|
||||
A list/node becomes a Python list; atoms and strings become strings.
|
||||
"""
|
||||
tok = tokens[pos]
|
||||
kind = tok[0]
|
||||
if kind == 'OPEN':
|
||||
pos += 1
|
||||
items = []
|
||||
while tokens[pos][0] != 'CLOSE':
|
||||
item, pos = _parse(tokens, pos)
|
||||
items.append(item)
|
||||
return items, pos + 1 # consume CLOSE
|
||||
elif kind in ('ATOM', 'STR'):
|
||||
return tok[1], pos + 1
|
||||
else:
|
||||
raise ValueError(f"Unexpected token at pos {pos}: {tok}")
|
||||
|
||||
|
||||
def parse_sexp(text: str):
|
||||
"""Parse a complete KiCad S-expression file. Returns the root list."""
|
||||
tokens = _tokenize(text)
|
||||
root, _ = _parse(tokens, 0)
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers to navigate parsed S-expressions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tag(node) -> str:
|
||||
if isinstance(node, list) and node and isinstance(node[0], str):
|
||||
return node[0]
|
||||
return ''
|
||||
|
||||
|
||||
def children(node: list) -> list:
|
||||
return node[1:] if isinstance(node, list) else []
|
||||
|
||||
|
||||
def first_child_with_tag(node: list, name: str):
|
||||
for child in children(node):
|
||||
if isinstance(child, list) and tag(child) == name:
|
||||
return child
|
||||
return None
|
||||
|
||||
|
||||
def all_children_with_tag(node: list, name: str) -> list:
|
||||
return [c for c in children(node) if isinstance(c, list) and tag(c) == name]
|
||||
|
||||
|
||||
def scalar(node, index: int = 1, default=None):
|
||||
if isinstance(node, list) and len(node) > index:
|
||||
return node[index]
|
||||
return default
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Instance path extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_instances(sym_node: list) -> list[dict]:
|
||||
"""
|
||||
Parse the (instances ...) block of a symbol and return one dict per
|
||||
hierarchical path. Each dict has:
|
||||
path – the full UUID path string
|
||||
reference – the reference designator for that instance
|
||||
unit – the unit number for that instance
|
||||
project – the project name
|
||||
|
||||
If there is no instances block (unusual), returns an empty list.
|
||||
"""
|
||||
instances_node = first_child_with_tag(sym_node, 'instances')
|
||||
if instances_node is None:
|
||||
return []
|
||||
|
||||
results = []
|
||||
for project_node in all_children_with_tag(instances_node, 'project'):
|
||||
project_name = scalar(project_node, 1, '')
|
||||
for path_node in all_children_with_tag(project_node, 'path'):
|
||||
path_str = scalar(path_node, 1, '')
|
||||
ref_node = first_child_with_tag(path_node, 'reference')
|
||||
unit_node = first_child_with_tag(path_node, 'unit')
|
||||
results.append({
|
||||
'path': path_str,
|
||||
'reference': scalar(ref_node, 1) if ref_node else None,
|
||||
'unit': scalar(unit_node, 1) if unit_node else None,
|
||||
'project': project_name,
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Symbol extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_symbol_records(sym_node: list, sheet_file: str) -> list[dict]:
|
||||
"""
|
||||
Extract metadata from a placed-symbol node and return one record per
|
||||
hierarchical instance (i.e. one record per path in the instances block).
|
||||
|
||||
For a sheet used only once, this produces a single record.
|
||||
For a sheet instantiated N times, this produces N records — each with
|
||||
its own unique reference designator from the instances block.
|
||||
"""
|
||||
# --- Shared fields (same for all instances of this symbol placement) ---
|
||||
shared = {
|
||||
'sheet_file': sheet_file,
|
||||
'lib_id': None,
|
||||
'at': None,
|
||||
'exclude_from_sim': None,
|
||||
'in_bom': None,
|
||||
'on_board': None,
|
||||
'dnp': None,
|
||||
'uuid': None,
|
||||
'properties': {},
|
||||
}
|
||||
|
||||
for child in children(sym_node):
|
||||
if not isinstance(child, list):
|
||||
continue
|
||||
t = tag(child)
|
||||
if t == 'lib_id':
|
||||
shared['lib_id'] = scalar(child, 1)
|
||||
elif t == 'at':
|
||||
shared['at'] = {
|
||||
'x': scalar(child, 1),
|
||||
'y': scalar(child, 2),
|
||||
'angle': scalar(child, 3, 0),
|
||||
}
|
||||
elif t == 'exclude_from_sim':
|
||||
shared['exclude_from_sim'] = scalar(child, 1) == 'yes'
|
||||
elif t == 'in_bom':
|
||||
shared['in_bom'] = scalar(child, 1) == 'yes'
|
||||
elif t == 'on_board':
|
||||
shared['on_board'] = scalar(child, 1) == 'yes'
|
||||
elif t == 'dnp':
|
||||
shared['dnp'] = scalar(child, 1) == 'yes'
|
||||
elif t == 'uuid':
|
||||
shared['uuid'] = scalar(child, 1)
|
||||
elif t == 'property':
|
||||
prop_name = scalar(child, 1)
|
||||
prop_val = scalar(child, 2)
|
||||
if prop_name is not None:
|
||||
shared['properties'][prop_name] = prop_val
|
||||
|
||||
# Promote standard properties for convenient access
|
||||
props = shared['properties']
|
||||
shared['value'] = props.get('Value')
|
||||
shared['footprint'] = props.get('Footprint')
|
||||
shared['datasheet'] = props.get('Datasheet')
|
||||
shared['description'] = props.get('Description')
|
||||
|
||||
# --- Per-instance fields (one record per path in instances block) ---
|
||||
instances = extract_instances(sym_node)
|
||||
|
||||
if not instances:
|
||||
# Fallback: no instances block — use top-level Reference property
|
||||
record = dict(shared)
|
||||
record['reference'] = props.get('Reference')
|
||||
record['instance_path'] = None
|
||||
record['instance_unit'] = shared.get('unit')
|
||||
record['instance_project']= None
|
||||
return [record]
|
||||
|
||||
records = []
|
||||
for inst in instances:
|
||||
record = dict(shared)
|
||||
record['properties'] = dict(shared['properties']) # copy so each is independent
|
||||
record['reference'] = inst['reference']
|
||||
record['instance_path'] = inst['path']
|
||||
record['instance_unit'] = inst['unit']
|
||||
record['instance_project'] = inst['project']
|
||||
records.append(record)
|
||||
|
||||
return records
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Hierarchy walker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def find_reachable_sheets(root_sch: Path) -> list[Path]:
|
||||
"""
|
||||
Walk the sheet hierarchy starting from *root_sch* and return an ordered
|
||||
list of every .kicad_sch file that is actually reachable (i.e. referenced
|
||||
directly or transitively as a sub-sheet). Handles repeated sub-sheet
|
||||
references (same file used N times) by visiting the file only once.
|
||||
"""
|
||||
reachable: list[Path] = []
|
||||
visited_names: set[str] = set()
|
||||
queue: list[Path] = [root_sch]
|
||||
|
||||
while queue:
|
||||
sch = queue.pop(0)
|
||||
if sch.name in visited_names:
|
||||
continue
|
||||
visited_names.add(sch.name)
|
||||
reachable.append(sch)
|
||||
|
||||
try:
|
||||
text = sch.read_text(encoding='utf-8')
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
root_node = parse_sexp(text)
|
||||
for child in children(root_node):
|
||||
if tag(child) != 'sheet':
|
||||
continue
|
||||
for prop in all_children_with_tag(child, 'property'):
|
||||
if scalar(prop, 1) == 'Sheetfile':
|
||||
child_filename = scalar(prop, 2)
|
||||
if child_filename:
|
||||
child_path = sch.parent / child_filename
|
||||
if child_path.exists() and child_path.name not in visited_names:
|
||||
queue.append(child_path)
|
||||
|
||||
return reachable
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-file parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def extract_from_schematic(sch_path: Path) -> list[dict]:
|
||||
"""
|
||||
Parse one .kicad_sch file and return a list of symbol records.
|
||||
lib_symbols definitions are skipped; only placed instances are returned.
|
||||
"""
|
||||
text = sch_path.read_text(encoding='utf-8')
|
||||
root = parse_sexp(text)
|
||||
|
||||
results = []
|
||||
for child in children(root):
|
||||
if not isinstance(child, list):
|
||||
continue
|
||||
t = tag(child)
|
||||
if t == 'lib_symbols':
|
||||
continue # skip library definitions
|
||||
if t == 'symbol' and first_child_with_tag(child, 'lib_id') is not None:
|
||||
records = extract_symbol_records(child, sch_path.name)
|
||||
results.extend(records)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_root_uuid(project_dir: Path) -> str | None:
|
||||
"""
|
||||
Find the UUID of the root schematic by reading the .kicad_pro file
|
||||
(which names the root sheet) or by scanning for the top-level sheet.
|
||||
Returns the UUID string, or None if it cannot be determined.
|
||||
"""
|
||||
# The .kicad_pro file tells us the root schematic filename
|
||||
pro_files = list(project_dir.glob('*.kicad_pro'))
|
||||
root_sch: Path | None = None
|
||||
|
||||
if pro_files:
|
||||
import json as _json
|
||||
try:
|
||||
pro = _json.loads(pro_files[0].read_text(encoding='utf-8'))
|
||||
root_name = pro.get('sheets', [{}])[0] if pro.get('sheets') else None
|
||||
# Fall back: just find a .kicad_sch with the same stem as the .pro
|
||||
root_sch = project_dir / (pro_files[0].stem + '.kicad_sch')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if root_sch is None or not root_sch.exists():
|
||||
# Guess: the .kicad_sch whose stem matches the .kicad_pro
|
||||
if pro_files:
|
||||
candidate = project_dir / (pro_files[0].stem + '.kicad_sch')
|
||||
if candidate.exists():
|
||||
root_sch = candidate
|
||||
|
||||
if root_sch is None or not root_sch.exists():
|
||||
return None
|
||||
|
||||
# Extract the first (uuid ...) at the root level of the file
|
||||
import re
|
||||
text = root_sch.read_text(encoding='utf-8')
|
||||
m = re.search(r'\(uuid\s+"([^"]+)"', text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def main(project_dir: Path):
|
||||
# Determine root schematic and walk the real hierarchy
|
||||
root_uuid = get_root_uuid(project_dir)
|
||||
|
||||
pro_files = list(project_dir.glob('*.kicad_pro'))
|
||||
root_sch = project_dir / (pro_files[0].stem + '.kicad_sch') if pro_files else None
|
||||
|
||||
if root_sch and root_sch.exists():
|
||||
sch_files = find_reachable_sheets(root_sch)
|
||||
print(f"Root sheet: {root_sch.name}")
|
||||
print(f"Found {len(sch_files)} reachable schematic file(s) in hierarchy:")
|
||||
else:
|
||||
# Fallback: glob everything
|
||||
sch_files = sorted(
|
||||
p for p in project_dir.rglob('*.kicad_sch')
|
||||
if not p.name.startswith('_autosave')
|
||||
and not p.suffix.endswith('.bak')
|
||||
)
|
||||
print(f"Warning: could not find root schematic; scanning all {len(sch_files)} files.\n")
|
||||
|
||||
if not sch_files:
|
||||
print(f"No .kicad_sch files found in {project_dir}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
for f in sch_files:
|
||||
print(f" {f.relative_to(project_dir)}")
|
||||
|
||||
all_records: list[dict] = []
|
||||
|
||||
for sch_path in sch_files:
|
||||
print(f"\nParsing {sch_path.name} ...", end=' ', flush=True)
|
||||
records = extract_from_schematic(sch_path)
|
||||
print(f"{len(records)} instance record(s)")
|
||||
all_records.extend(records)
|
||||
|
||||
# All records come from reachable sheets, so no orphan filtering needed.
|
||||
# Optionally still filter by root UUID to catch stale instance paths.
|
||||
if root_uuid:
|
||||
active_prefix = f'/{root_uuid}/'
|
||||
active = [r for r in all_records
|
||||
if (r.get('instance_path') or '').startswith(active_prefix)]
|
||||
stale = len(all_records) - len(active)
|
||||
print(f"\nTotal records : {len(all_records)}")
|
||||
if stale:
|
||||
print(f"Stale paths dropped: {stale}")
|
||||
else:
|
||||
active = all_records
|
||||
print(f"\nTotal records: {len(all_records)}")
|
||||
|
||||
# ---- Stage 1: dedup by (instance_path, uuid) ----
|
||||
# Collapses records that were seen from multiple sheet scans into one.
|
||||
seen: set = set()
|
||||
stage1: list[dict] = []
|
||||
for r in active:
|
||||
key = (r.get('instance_path'), r.get('uuid'))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
stage1.append(r)
|
||||
|
||||
# ---- Stage 2: dedup by uuid across different sheet files ----
|
||||
# If the SAME uuid appears in two *different* .kicad_sch files, that is a
|
||||
# UUID collision in the design (copy-paste without UUID regeneration).
|
||||
# The same uuid appearing in the same sheet file with different instance
|
||||
# paths is *correct* — it is how multi-instance sheets work, so those are
|
||||
# left alone.
|
||||
uuid_sheets: dict = {} # uuid -> set of sheet_files seen
|
||||
uuid_collisions: dict = {} # uuid -> list of colliding records
|
||||
unique: list[dict] = []
|
||||
for r in stage1:
|
||||
u = r.get('uuid')
|
||||
sf = r.get('sheet_file', '')
|
||||
sheets_so_far = uuid_sheets.setdefault(u, set())
|
||||
if not sheets_so_far or sf in sheets_so_far:
|
||||
# First time seeing this uuid, OR it's from the same sheet file
|
||||
# (legitimate multi-instance expansion) — keep it.
|
||||
sheets_so_far.add(sf)
|
||||
unique.append(r)
|
||||
else:
|
||||
# Same uuid, but from a DIFFERENT sheet file → UUID collision.
|
||||
uuid_collisions.setdefault(u, []).append(r)
|
||||
# Don't append to unique — drop the duplicate.
|
||||
|
||||
if uuid_collisions:
|
||||
print(f"\nNote: {len(uuid_collisions)} UUID collision(s) detected "
|
||||
f"(same symbol UUID in multiple sheet files — likely copy-paste artifacts).")
|
||||
print(" Only the first occurrence is kept in the output.")
|
||||
for u, recs in list(uuid_collisions.items())[:10]:
|
||||
refs = [r.get('reference') for r in recs]
|
||||
files = [r.get('sheet_file') for r in recs]
|
||||
print(f" uuid={u[:8]}... refs={refs} sheets={files}")
|
||||
|
||||
print(f"\nUnique instances after dedup: {len(unique)}")
|
||||
|
||||
# Separate power symbols from real parts
|
||||
real = [r for r in unique if not (r.get('lib_id') or '').startswith('power:')]
|
||||
power = [r for r in unique if (r.get('lib_id') or '').startswith('power:')]
|
||||
print(f" Non-power parts : {len(real)}")
|
||||
print(f" Power symbols : {len(power)}")
|
||||
|
||||
# Check for true reference duplicates (same ref, different uuid = multi-unit)
|
||||
from collections import defaultdict, Counter
|
||||
by_ref: dict[str, list] = defaultdict(list)
|
||||
for r in unique:
|
||||
by_ref[r.get('reference', '')].append(r)
|
||||
|
||||
multi_unit = {ref: recs for ref, recs in by_ref.items()
|
||||
if len(recs) > 1 and len({r['uuid'] for r in recs}) > 1}
|
||||
if multi_unit:
|
||||
refs = [r for r in multi_unit if not r.startswith('#')]
|
||||
if refs:
|
||||
print(f"\nMulti-unit components ({len(refs)} references, expected for split-unit symbols):")
|
||||
for ref in sorted(refs):
|
||||
units = [r['instance_unit'] for r in multi_unit[ref]]
|
||||
print(f" {ref}: units {units}")
|
||||
|
||||
output = {
|
||||
"project_dir": str(project_dir),
|
||||
"root_uuid": root_uuid,
|
||||
"schematic_files": [str(f.relative_to(project_dir)) for f in sch_files],
|
||||
"total_instances": len(unique),
|
||||
"non_power_count": len(real),
|
||||
"symbols": unique,
|
||||
}
|
||||
|
||||
out_path = project_dir / 'extract_symbols.json'
|
||||
out_path.write_text(json.dumps(output, indent=2, ensure_ascii=False), encoding='utf-8')
|
||||
print(f"\nOutput written to: {out_path}")
|
||||
|
||||
# Print a summary table
|
||||
print("\n--- Summary (non-power parts, sorted by reference) ---")
|
||||
for r in sorted(real, key=lambda x: x.get('reference') or ''):
|
||||
ref = r.get('reference', '')
|
||||
value = r.get('value', '')
|
||||
lib = r.get('lib_id', '')
|
||||
mpn = r['properties'].get('MPN', '')
|
||||
sheet = r.get('sheet_file', '')
|
||||
unit = r.get('instance_unit', '')
|
||||
print(f" {ref:<12} u{unit:<2} {value:<30} {lib:<40} MPN={mpn:<25} [{sheet}]")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) > 1:
|
||||
project_dir = Path(sys.argv[1]).resolve()
|
||||
else:
|
||||
project_dir = Path(__file__).parent.resolve()
|
||||
|
||||
if not project_dir.is_dir():
|
||||
print(f"Error: {project_dir} is not a directory", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
main(project_dir)
|
||||
288
gen_passives_db.py
Normal file
288
gen_passives_db.py
Normal file
@@ -0,0 +1,288 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
gen_resistors_db.py
|
||||
===================
|
||||
Reads the approved parts list spreadsheet and adds surface mount resistor
|
||||
and capacitor records to the KiCad SQLite database.
|
||||
|
||||
Processes all SMD resistors (0402, 0603, 0805, etc.) and capacitors from
|
||||
the spreadsheet.
|
||||
|
||||
Each part becomes a database record with:
|
||||
ipn ← GLE P/N (or generated ID if missing)
|
||||
description ← Description column
|
||||
value ← Value1 (e.g. "10k", "4.7k", "100")
|
||||
footprint ← Standard KiCad footprint based on size (e.g., "Resistor_SMD:R_0402_1005Metric")
|
||||
fp_display ← Footprint column from spreadsheet (for display purposes)
|
||||
symbol ← "UM_template:R" for resistors, "UM_template:C" for capacitors
|
||||
mpn ← Mfg.1 P/N
|
||||
manufacturer ← Mfg.1
|
||||
datasheet ← (empty for now)
|
||||
class ← Class column
|
||||
|
||||
Where multiple approved vendors share the same value+tolerance+footprint,
|
||||
only the first row is used (duplicates are reported and skipped).
|
||||
|
||||
Usage:
|
||||
python3 gen_resistors_db.py <parts_list.xlsx>
|
||||
|
||||
The script reads the database path from ../database/parts.sqlite relative
|
||||
to this script.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import sqlite3
|
||||
import pandas as pd
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_footprint(description: str, part_type: str) -> str:
|
||||
"""
|
||||
Extract footprint size from description and return standard KiCad footprint.
|
||||
|
||||
Args:
|
||||
description: Part description containing size (e.g., "0402", "0603")
|
||||
part_type: "resistor" or "capacitor"
|
||||
|
||||
Returns:
|
||||
Standard KiCad footprint string
|
||||
"""
|
||||
# Footprint size mapping to KiCad standard footprints
|
||||
resistor_footprints = {
|
||||
'0201': 'Resistor_SMD:R_0201_0603Metric',
|
||||
'0402': 'Resistor_SMD:R_0402_1005Metric',
|
||||
'0603': 'Resistor_SMD:R_0603_1608Metric',
|
||||
'0805': 'Resistor_SMD:R_0805_2012Metric',
|
||||
'1206': 'Resistor_SMD:R_1206_3216Metric',
|
||||
'1210': 'Resistor_SMD:R_1210_3225Metric',
|
||||
'2010': 'Resistor_SMD:R_2010_5025Metric',
|
||||
'2512': 'Resistor_SMD:R_2512_6332Metric',
|
||||
}
|
||||
|
||||
capacitor_footprints = {
|
||||
'0201': 'Capacitor_SMD:C_0201_0603Metric',
|
||||
'0402': 'Capacitor_SMD:C_0402_1005Metric',
|
||||
'0603': 'Capacitor_SMD:C_0603_1608Metric',
|
||||
'0805': 'Capacitor_SMD:C_0805_2012Metric',
|
||||
'1206': 'Capacitor_SMD:C_1206_3216Metric',
|
||||
'1210': 'Capacitor_SMD:C_1210_3225Metric',
|
||||
'2010': 'Capacitor_SMD:C_2010_5025Metric',
|
||||
'2512': 'Capacitor_SMD:C_2512_6332Metric',
|
||||
}
|
||||
|
||||
# Extract size from description
|
||||
size_match = re.search(r'\b(0201|0402|0603|0805|1206|1210|2010|2512)\b', description)
|
||||
if not size_match:
|
||||
return ""
|
||||
|
||||
size = size_match.group(1)
|
||||
|
||||
if part_type == "resistor":
|
||||
return resistor_footprints.get(size, "")
|
||||
elif part_type == "capacitor":
|
||||
return capacitor_footprints.get(size, "")
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def process_parts(parts_df: pd.DataFrame, part_type: str, symbol: str,
|
||||
cursor, existing_ipns: set) -> tuple[int, int, list]:
|
||||
"""
|
||||
Process a dataframe of parts (resistors or capacitors) and insert/update in database.
|
||||
|
||||
Args:
|
||||
parts_df: DataFrame containing the parts to process
|
||||
part_type: "resistor" or "capacitor" (for reporting)
|
||||
symbol: KiCad symbol reference (e.g., "UM_template:R")
|
||||
cursor: Database cursor
|
||||
existing_ipns: Set of existing IPNs in database
|
||||
|
||||
Returns:
|
||||
Tuple of (added_count, updated_count, skipped_list)
|
||||
"""
|
||||
added = 0
|
||||
updated = 0
|
||||
skipped = []
|
||||
seen_parts: dict[str, str] = {} # value+tol+footprint → GLE P/N of first occurrence
|
||||
|
||||
for _, row in parts_df.iterrows():
|
||||
gle_pn = str(row['GLE P/N']).strip()
|
||||
value = str(row['Value1']).strip()
|
||||
description = str(row['Description']).strip()
|
||||
mfg = str(row['Mfg.1']).strip()
|
||||
mpn = str(row['Mfg.1 P/N']).strip()
|
||||
part_class = str(row.get('Class', '')).strip()
|
||||
fp_display = str(row.get('Footprint', '')).strip() # From spreadsheet for display
|
||||
|
||||
if not gle_pn:
|
||||
skipped.append((value, '(no GLE P/N)'))
|
||||
continue
|
||||
|
||||
# Get standard KiCad footprint based on size in description
|
||||
footprint = get_footprint(description, part_type)
|
||||
if not footprint:
|
||||
skipped.append((value, f'could not determine footprint size from: {description}'))
|
||||
continue
|
||||
|
||||
# Create unique key from value+tolerance+footprint to detect duplicates
|
||||
# Extract tolerance from description
|
||||
tol_match = re.search(r'(\d+(?:\.\d+)?%)', description)
|
||||
tolerance = tol_match.group(1) if tol_match else 'X'
|
||||
part_key = f"{value}_{tolerance}_{footprint}"
|
||||
|
||||
# Skip duplicate value+tolerance+footprint combinations (alternate approved vendors)
|
||||
if part_key in seen_parts:
|
||||
skipped.append((value, f'dup value/tol/fp, first: {seen_parts[part_key]}, this: {gle_pn}'))
|
||||
continue
|
||||
seen_parts[part_key] = gle_pn
|
||||
|
||||
# Prepare database record
|
||||
ipn = gle_pn
|
||||
datasheet = "" # Could be populated from spreadsheet if available
|
||||
|
||||
# Insert or update record
|
||||
if ipn in existing_ipns:
|
||||
cursor.execute("""
|
||||
UPDATE parts
|
||||
SET description = ?, value = ?, footprint = ?, symbol = ?,
|
||||
mpn = ?, manufacturer = ?, datasheet = ?, class = ?, fp_display = ?
|
||||
WHERE ipn = ?
|
||||
""", (description, value, footprint, symbol, mpn, mfg, datasheet, part_class, fp_display, ipn))
|
||||
updated += 1
|
||||
else:
|
||||
cursor.execute("""
|
||||
INSERT INTO parts (ipn, description, value, footprint, symbol, mpn, manufacturer, datasheet, class, fp_display)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (ipn, description, value, footprint, symbol, mpn, mfg, datasheet, part_class, fp_display))
|
||||
added += 1
|
||||
existing_ipns.add(ipn)
|
||||
|
||||
return added, updated, skipped
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(xlsx_path: Path, db_path: Path):
|
||||
# ---- Load spreadsheet ----
|
||||
df = pd.read_excel(xlsx_path, sheet_name='PCB', dtype=str)
|
||||
df = df.fillna('')
|
||||
|
||||
# Filter to SMD resistors and capacitors
|
||||
# Match common SMD footprints: 0402, 0603, 0805, 1206, etc.
|
||||
smd_pattern = r'0(201|402|603|805)|1206|1210|2010|2512'
|
||||
|
||||
resistor_mask = (
|
||||
df['Footprint'].str.contains(smd_pattern, na=False, regex=True) &
|
||||
df['Description'].str.contains('[Rr]es', na=False, regex=True)
|
||||
)
|
||||
resistors = df[resistor_mask].copy()
|
||||
|
||||
capacitor_mask = (
|
||||
df['Footprint'].str.contains(smd_pattern, na=False, regex=True) &
|
||||
df['Description'].str.contains('[Cc]ap', na=False, regex=True)
|
||||
)
|
||||
capacitors = df[capacitor_mask].copy()
|
||||
|
||||
print(f"Found {len(resistors)} SMD resistors in parts list")
|
||||
print(f"Found {len(capacitors)} SMD capacitors in parts list")
|
||||
|
||||
# ---- Connect to database ----
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get existing IPNs to check for duplicates
|
||||
cursor.execute("SELECT ipn FROM parts")
|
||||
existing_ipns = set(row[0] for row in cursor.fetchall())
|
||||
|
||||
# ---- Process resistors ----
|
||||
print("\nProcessing resistors...")
|
||||
r_added, r_updated, r_skipped = process_parts(
|
||||
resistors, "resistor", "UM_template:R", cursor, existing_ipns
|
||||
)
|
||||
|
||||
# ---- Process capacitors ----
|
||||
print("Processing capacitors...")
|
||||
c_added, c_updated, c_skipped = process_parts(
|
||||
capacitors, "capacitor", "UM_template:C", cursor, existing_ipns
|
||||
)
|
||||
|
||||
# Commit changes
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Report results
|
||||
print(f"\n{'='*60}")
|
||||
print("Database updated:")
|
||||
print(f"{'='*60}")
|
||||
print(f"\nResistors:")
|
||||
print(f" Added: {r_added}")
|
||||
print(f" Updated: {r_updated}")
|
||||
print(f" Skipped: {len(r_skipped)} (duplicates or missing data)")
|
||||
|
||||
print(f"\nCapacitors:")
|
||||
print(f" Added: {c_added}")
|
||||
print(f" Updated: {c_updated}")
|
||||
print(f" Skipped: {len(c_skipped)} (duplicates or missing data)")
|
||||
|
||||
print(f"\nTotals:")
|
||||
print(f" Added: {r_added + c_added}")
|
||||
print(f" Updated: {r_updated + c_updated}")
|
||||
print(f" Skipped: {len(r_skipped) + len(c_skipped)}")
|
||||
|
||||
# Show some skipped items if any
|
||||
all_skipped = r_skipped + c_skipped
|
||||
if all_skipped:
|
||||
print(f"\n Sample skipped items:")
|
||||
for val, reason in all_skipped[:10]: # Show first 10
|
||||
print(f" {val}: {reason}")
|
||||
if len(all_skipped) > 10:
|
||||
print(f" ... and {len(all_skipped) - 10} more")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Get paths
|
||||
script_dir = Path(__file__).parent
|
||||
|
||||
# Database path
|
||||
db_path = script_dir.parent / 'database' / 'parts.sqlite'
|
||||
if not db_path.exists():
|
||||
print(f"Error: database not found at {db_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Spreadsheet path - try command line arg, then config file
|
||||
if len(sys.argv) >= 2:
|
||||
xlsx_path = Path(sys.argv[1])
|
||||
else:
|
||||
# Try to read from config.json
|
||||
import json
|
||||
config_file = script_dir / 'config.json'
|
||||
if config_file.exists():
|
||||
with open(config_file, 'r') as f:
|
||||
config = json.load(f)
|
||||
xlsx_str = config.get('parts_spreadsheet_path', '')
|
||||
if xlsx_str:
|
||||
xlsx_path = Path(xlsx_str)
|
||||
else:
|
||||
print("Error: no parts_spreadsheet_path in config.json", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
print("Error: no spreadsheet path provided and config.json not found", file=sys.stderr)
|
||||
print("Usage: python3 gen_resistors_db.py <parts_list.xlsx>", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if not xlsx_path.exists():
|
||||
print(f"Error: spreadsheet not found at {xlsx_path}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Reading parts from: {xlsx_path}")
|
||||
print(f"Database: {db_path}")
|
||||
print()
|
||||
|
||||
main(xlsx_path, db_path)
|
||||
299
init_user.py
Normal file
299
init_user.py
Normal file
@@ -0,0 +1,299 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
init_user.py
|
||||
============
|
||||
Initialize a new user's KiCad environment with UM customizations.
|
||||
|
||||
This script:
|
||||
1. Creates ODBC System DSN for SQLite database (Windows only)
|
||||
2. Copies theme files from UM_KICAD/lib/themes to the user's KiCad config directory
|
||||
3. Updates KiCad preferences to use the UM theme
|
||||
4. Sets other UM-specific preferences
|
||||
|
||||
Usage:
|
||||
python3 init_user.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# KiCad v9 config location
|
||||
# ---------------------------------------------------------------------------
|
||||
def kicad9_config_dir() -> Path:
|
||||
home = Path.home()
|
||||
|
||||
if sys.platform.startswith("win"):
|
||||
appdata = os.environ.get("APPDATA")
|
||||
if not appdata:
|
||||
raise RuntimeError("APPDATA not set")
|
||||
return Path(appdata) / "kicad" / "9.0"
|
||||
|
||||
if sys.platform == "darwin":
|
||||
return home / "Library" / "Preferences" / "kicad" / "9.0"
|
||||
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
if xdg:
|
||||
return Path(xdg) / "kicad" / "9.0"
|
||||
|
||||
return home / ".config" / "kicad" / "9.0"
|
||||
|
||||
|
||||
def get_themes_dir() -> Path:
|
||||
"""Get the user's KiCad themes directory"""
|
||||
return kicad9_config_dir() / "colors"
|
||||
|
||||
|
||||
def get_kicad_common_json() -> Path:
|
||||
"""Get the path to kicad_common.json (main preferences file)"""
|
||||
return kicad9_config_dir() / "kicad_common.json"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ODBC DSN Creation (Windows only)
|
||||
# ---------------------------------------------------------------------------
|
||||
def create_odbc_dsn(um_root: Path) -> bool:
|
||||
"""
|
||||
Create a System DSN for SQLite ODBC connection on Windows.
|
||||
|
||||
Args:
|
||||
um_root: Path to UM_KICAD root directory
|
||||
|
||||
Returns:
|
||||
True if DSN was created or already exists, False on error
|
||||
"""
|
||||
if not sys.platform.startswith("win"):
|
||||
print(" ODBC DSN creation is only supported on Windows")
|
||||
return True # Not an error, just not applicable
|
||||
|
||||
try:
|
||||
import winreg
|
||||
except ImportError:
|
||||
print(" Warning: winreg module not available, skipping ODBC DSN creation")
|
||||
return False
|
||||
|
||||
# Database path
|
||||
db_path = um_root / "database" / "parts.sqlite"
|
||||
if not db_path.exists():
|
||||
print(f" Warning: Database not found at {db_path}")
|
||||
return False
|
||||
|
||||
dsn_name = "UM_2KiCad_Parts2"
|
||||
driver_name = "SQLite3 ODBC Driver"
|
||||
|
||||
try:
|
||||
# Check if DSN already exists
|
||||
try:
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\ODBC\ODBC.INI\UM_KiCad_Parts",
|
||||
0,
|
||||
winreg.KEY_READ
|
||||
)
|
||||
winreg.CloseKey(key)
|
||||
print(f" ODBC DSN '{dsn_name}' already exists")
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
pass # DSN doesn't exist, we'll create it
|
||||
|
||||
# Create the DSN
|
||||
# First, add to ODBC Data Sources list
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
r"SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources",
|
||||
0,
|
||||
winreg.KEY_WRITE
|
||||
)
|
||||
winreg.SetValueEx(key, dsn_name, 0, winreg.REG_SZ, driver_name)
|
||||
winreg.CloseKey(key)
|
||||
|
||||
# Create the DSN configuration key
|
||||
key = winreg.CreateKey(
|
||||
winreg.HKEY_LOCAL_MACHINE,
|
||||
rf"SOFTWARE\ODBC\ODBC.INI\{dsn_name}"
|
||||
)
|
||||
|
||||
# Set DSN configuration values
|
||||
winreg.SetValueEx(key, "Driver", 0, winreg.REG_SZ, driver_name)
|
||||
winreg.SetValueEx(key, "Database", 0, winreg.REG_SZ, str(db_path))
|
||||
winreg.SetValueEx(key, "Description", 0, winreg.REG_SZ, "UM KiCad Parts Database")
|
||||
|
||||
winreg.CloseKey(key)
|
||||
|
||||
print(f" Created ODBC System DSN '{dsn_name}'")
|
||||
print(f" Database: {db_path}")
|
||||
return True
|
||||
|
||||
except PermissionError:
|
||||
print(" ERROR: Administrator privileges required to create System DSN")
|
||||
print(" Please run this script as Administrator, or create the DSN manually:")
|
||||
print(f" DSN Name: {dsn_name}")
|
||||
print(f" Driver: {driver_name}")
|
||||
print(f" Database: {db_path}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f" Warning: Failed to create ODBC DSN: {e}")
|
||||
print(f" You may need to create it manually:")
|
||||
print(f" DSN Name: {dsn_name}")
|
||||
print(f" Driver: {driver_name}")
|
||||
print(f" Database: {db_path}")
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Theme installation
|
||||
# ---------------------------------------------------------------------------
|
||||
def install_themes(um_root: Path) -> list[str]:
|
||||
"""
|
||||
Copy all theme files from UM_KICAD/lib/themes to user's KiCad config.
|
||||
|
||||
Returns:
|
||||
List of installed theme names
|
||||
"""
|
||||
source_themes_dir = um_root / "lib" / "themes"
|
||||
if not source_themes_dir.exists():
|
||||
print(f"Warning: Themes directory not found at {source_themes_dir}")
|
||||
return []
|
||||
|
||||
dest_themes_dir = get_themes_dir()
|
||||
dest_themes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
installed = []
|
||||
theme_files = list(source_themes_dir.glob("*.json"))
|
||||
|
||||
if not theme_files:
|
||||
print("Warning: No theme files found")
|
||||
return []
|
||||
|
||||
for theme_file in theme_files:
|
||||
dest_file = dest_themes_dir / theme_file.name
|
||||
shutil.copy2(theme_file, dest_file)
|
||||
theme_name = theme_file.stem
|
||||
installed.append(theme_name)
|
||||
print(f" Installed theme: {theme_name}")
|
||||
|
||||
return installed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Preferences configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
def set_theme_preference(theme_name: str):
|
||||
"""
|
||||
Update kicad_common.json and user.json to use the specified theme.
|
||||
|
||||
Args:
|
||||
theme_name: Name of the theme (without .json extension)
|
||||
"""
|
||||
# 1. Copy the theme to user.json (this is the active theme file)
|
||||
themes_dir = get_themes_dir()
|
||||
source_theme = themes_dir / f"{theme_name}.json"
|
||||
user_theme = themes_dir / "user.json"
|
||||
|
||||
if source_theme.exists():
|
||||
shutil.copy2(source_theme, user_theme)
|
||||
print(f" Activated theme '{theme_name}' by copying to user.json")
|
||||
else:
|
||||
print(f" Warning: Theme file not found: {source_theme}")
|
||||
|
||||
# 2. Update kicad_common.json to reference the theme
|
||||
config_file = get_kicad_common_json()
|
||||
|
||||
# Load existing config or create new one
|
||||
if config_file.exists():
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
print(f" Loaded existing preferences from {config_file}")
|
||||
else:
|
||||
config = {}
|
||||
print(f" Creating new preferences file at {config_file}")
|
||||
|
||||
# Ensure the appearance section exists
|
||||
if "appearance" not in config:
|
||||
config["appearance"] = {}
|
||||
|
||||
# Set the theme for all applications
|
||||
config["appearance"]["color_theme"] = theme_name
|
||||
|
||||
# Also set it for specific editors
|
||||
for editor in ["pcb_editor", "schematic_editor", "gerbview", "3d_viewer"]:
|
||||
if editor not in config:
|
||||
config[editor] = {}
|
||||
if "appearance" not in config[editor]:
|
||||
config[editor]["appearance"] = {}
|
||||
config[editor]["appearance"]["color_theme"] = theme_name
|
||||
|
||||
# Backup existing config
|
||||
if config_file.exists():
|
||||
backup_file = config_file.with_suffix('.json.bak')
|
||||
shutil.copy2(config_file, backup_file)
|
||||
print(f" Backed up existing config to {backup_file.name}")
|
||||
|
||||
# Write updated config
|
||||
config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(config, f, indent=2)
|
||||
|
||||
print(f" Set theme to '{theme_name}' in KiCad preferences")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
def main() -> int:
|
||||
um_root_env = os.environ.get("UM_KICAD")
|
||||
if not um_root_env:
|
||||
print("ERROR: UM_KICAD environment variable not set")
|
||||
print("Please set UM_KICAD to the root of your UM KiCad repository")
|
||||
return 1
|
||||
|
||||
um_root = Path(um_root_env).resolve()
|
||||
if not um_root.exists():
|
||||
print(f"ERROR: UM_KICAD path does not exist: {um_root}")
|
||||
return 1
|
||||
|
||||
print("=" * 60)
|
||||
print("UM KiCad User Initialization")
|
||||
print("=" * 60)
|
||||
print(f"\nUM_KICAD: {um_root}")
|
||||
print(f"KiCad config: {kicad9_config_dir()}")
|
||||
print()
|
||||
|
||||
# Create ODBC DSN (Windows only)
|
||||
if sys.platform.startswith("win"):
|
||||
print("Creating ODBC System DSN...")
|
||||
create_odbc_dsn(um_root)
|
||||
print()
|
||||
|
||||
# Install themes
|
||||
print("Installing themes...")
|
||||
installed_themes = install_themes(um_root)
|
||||
|
||||
if not installed_themes:
|
||||
print("\nNo themes were installed")
|
||||
return 1
|
||||
|
||||
print(f"\nInstalled {len(installed_themes)} theme(s)")
|
||||
|
||||
# Set the first theme as default (typically "UM")
|
||||
default_theme = installed_themes[0]
|
||||
print(f"\nSetting default theme...")
|
||||
set_theme_preference(default_theme)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("Initialization complete!")
|
||||
print("=" * 60)
|
||||
print("\nNext steps:")
|
||||
print("1. Restart KiCad to see the new theme")
|
||||
print("2. You can change themes in Preferences > Colors")
|
||||
print(f"3. Available themes: {', '.join(installed_themes)}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
6
requirements.txt
Normal file
6
requirements.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Flask==3.0.0
|
||||
flask-socketio==5.3.6
|
||||
python-socketio==5.11.0
|
||||
PyPDF2==3.0.1
|
||||
pandas
|
||||
openpyxl
|
||||
BIN
static/logo_banner.png
Normal file
BIN
static/logo_banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
278
sync_variant.py
Normal file
278
sync_variant.py
Normal file
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
sync_variant.py
|
||||
===============
|
||||
Sync variant from KiCad schematic - read DNP flags and update variant data.
|
||||
|
||||
This script reads the current state of DNP flags from the schematic and updates
|
||||
the active variant to match.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from variant_manager import VariantManager
|
||||
|
||||
|
||||
def get_all_schematic_files(root_schematic: str) -> list:
|
||||
"""
|
||||
Get all schematic files in a hierarchical design.
|
||||
|
||||
Args:
|
||||
root_schematic: Path to root .kicad_sch file
|
||||
|
||||
Returns:
|
||||
List of all schematic file paths (including root)
|
||||
"""
|
||||
root_path = Path(root_schematic)
|
||||
if not root_path.exists():
|
||||
return [root_schematic]
|
||||
|
||||
schematic_files = [str(root_path)]
|
||||
schematic_dir = root_path.parent
|
||||
|
||||
# Read root schematic to find sheet files
|
||||
try:
|
||||
with open(root_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Find all sheet file references
|
||||
for line in content.split('\n'):
|
||||
if '(property "Sheetfile"' in line:
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 4:
|
||||
sheet_file = parts[3]
|
||||
sheet_path = schematic_dir / sheet_file
|
||||
if sheet_path.exists():
|
||||
# Recursively get sheets from this sheet
|
||||
sub_sheets = get_all_schematic_files(str(sheet_path))
|
||||
for sub in sub_sheets:
|
||||
if sub not in schematic_files:
|
||||
schematic_files.append(sub)
|
||||
except Exception as e:
|
||||
print(f"Warning: Error reading sheet files: {e}")
|
||||
|
||||
return schematic_files
|
||||
|
||||
|
||||
def sync_variant_from_schematic(schematic_file: str, target_variant: str = None) -> bool:
|
||||
"""
|
||||
Sync variant from schematic DNP flags and title block.
|
||||
|
||||
Args:
|
||||
schematic_file: Path to .kicad_sch file
|
||||
target_variant: Specific variant to sync to (optional). If not provided, uses title block or active variant.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
manager = VariantManager(schematic_file)
|
||||
|
||||
# If target_variant specified, use that
|
||||
if target_variant:
|
||||
if target_variant not in manager.get_variants():
|
||||
print(f"Error: Variant '{target_variant}' not found")
|
||||
return False
|
||||
active_variant = target_variant
|
||||
print(f"Syncing to specified variant: {active_variant}")
|
||||
else:
|
||||
# Read variant name from title block in root schematic
|
||||
variant_from_title = None
|
||||
sch_path = Path(schematic_file)
|
||||
if sch_path.exists():
|
||||
try:
|
||||
with open(sch_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
lines = content.split('\n')
|
||||
in_title_block = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('(title_block'):
|
||||
in_title_block = True
|
||||
elif in_title_block and stripped == ')':
|
||||
break
|
||||
elif in_title_block and '(comment 1' in stripped:
|
||||
# Extract variant name from comment 1
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 2:
|
||||
variant_from_title = parts[1]
|
||||
print(f"Found variant in title block: {variant_from_title}")
|
||||
break
|
||||
except:
|
||||
pass
|
||||
|
||||
# Use variant from title block if found, otherwise use active variant
|
||||
if variant_from_title and variant_from_title in manager.get_variants():
|
||||
active_variant = variant_from_title
|
||||
manager.set_active_variant(variant_from_title)
|
||||
print(f"Set active variant to: {active_variant}")
|
||||
else:
|
||||
active_variant = manager.get_active_variant()
|
||||
print(f"Using active variant: {active_variant}")
|
||||
|
||||
# Get all schematic files (root + hierarchical sheets)
|
||||
all_schematics = get_all_schematic_files(schematic_file)
|
||||
print(f"Processing {len(all_schematics)} schematic file(s)")
|
||||
|
||||
all_dnp_uuids = []
|
||||
all_uuids = []
|
||||
|
||||
# Process each schematic file
|
||||
for sch_file in all_schematics:
|
||||
sch_path = Path(sch_file)
|
||||
if not sch_path.exists():
|
||||
print(f"Warning: Schematic file not found: {sch_file}")
|
||||
continue
|
||||
|
||||
print(f"\n Processing: {sch_path.name}")
|
||||
|
||||
try:
|
||||
with open(sch_path, 'r', encoding='utf-8') as f:
|
||||
content = f.read()
|
||||
|
||||
# Parse schematic to find DNP components
|
||||
lines = content.split('\n')
|
||||
in_symbol = False
|
||||
current_uuid = None
|
||||
current_ref = None
|
||||
current_lib_id = None
|
||||
has_dnp = False
|
||||
|
||||
# Track line depth to know when we're at symbol level
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
|
||||
# Detect start of symbol
|
||||
if stripped.startswith('(symbol'):
|
||||
in_symbol = True
|
||||
current_uuid = None
|
||||
current_ref = None
|
||||
current_lib_id = None
|
||||
has_dnp = False
|
||||
symbol_uuid_found = False # Track if we found the main symbol UUID
|
||||
|
||||
# Detect end of symbol
|
||||
elif in_symbol and stripped == ')':
|
||||
# Check if this symbol block is closing (simple heuristic)
|
||||
# Skip power symbols
|
||||
is_power = current_lib_id and 'power:' in current_lib_id
|
||||
is_power = is_power or (current_ref and current_ref.startswith('#'))
|
||||
|
||||
if current_uuid and has_dnp and not is_power:
|
||||
if current_uuid not in all_dnp_uuids:
|
||||
all_dnp_uuids.append(current_uuid)
|
||||
print(f" Found DNP: {current_ref if current_ref else current_uuid}")
|
||||
in_symbol = False
|
||||
|
||||
# Extract lib_id to check for power symbols
|
||||
elif in_symbol and '(lib_id' in stripped:
|
||||
lib_parts = line.split('"')
|
||||
if len(lib_parts) >= 2:
|
||||
current_lib_id = lib_parts[1]
|
||||
|
||||
# Check for DNP flag - can be (dnp), (dnp yes), or (dnp no)
|
||||
# Do this before UUID extraction so we know if we need the UUID
|
||||
elif in_symbol and '(dnp' in stripped and not has_dnp:
|
||||
# Only set has_dnp if it's (dnp) or (dnp yes), not (dnp no)
|
||||
if '(dnp yes)' in stripped or (stripped == '(dnp)'):
|
||||
has_dnp = True
|
||||
# Now look forward for the UUID (it comes right after DNP)
|
||||
for j in range(i + 1, min(len(lines), i + 5)):
|
||||
if '(uuid' in lines[j]:
|
||||
# Check it's at symbol level
|
||||
if '\t(uuid' in lines[j] or ' (uuid' in lines[j]:
|
||||
uuid_parts = lines[j].split('"')
|
||||
if len(uuid_parts) >= 2:
|
||||
current_uuid = uuid_parts[1]
|
||||
symbol_uuid_found = True
|
||||
break
|
||||
|
||||
# Extract reference designator (for logging)
|
||||
elif in_symbol and '(property "Reference"' in line and not current_ref:
|
||||
# Extract reference from line like: (property "Reference" "R1"
|
||||
parts = line.split('"')
|
||||
if len(parts) >= 4:
|
||||
current_ref = parts[3]
|
||||
|
||||
# Get all component UUIDs (excluding power symbols)
|
||||
# Use same approach - look for UUID after DNP line
|
||||
in_symbol = False
|
||||
current_uuid = None
|
||||
current_lib_id = None
|
||||
current_ref = None
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith('(symbol'):
|
||||
in_symbol = True
|
||||
current_uuid = None
|
||||
current_lib_id = None
|
||||
current_ref = None
|
||||
elif in_symbol and stripped == ')':
|
||||
# Skip power symbols
|
||||
is_power = current_lib_id and 'power:' in current_lib_id
|
||||
is_power = is_power or (current_ref and current_ref.startswith('#'))
|
||||
|
||||
if current_uuid and not is_power and current_uuid not in all_uuids:
|
||||
all_uuids.append(current_uuid)
|
||||
in_symbol = False
|
||||
elif in_symbol and '(lib_id' in stripped:
|
||||
lib_parts = line.split('"')
|
||||
if len(lib_parts) >= 2:
|
||||
current_lib_id = lib_parts[1]
|
||||
elif in_symbol and '(property "Reference"' in stripped and not current_ref:
|
||||
ref_parts = line.split('"')
|
||||
if len(ref_parts) >= 4:
|
||||
current_ref = ref_parts[3]
|
||||
elif in_symbol and '(dnp' in stripped and not current_uuid:
|
||||
# Found DNP line - look forward for UUID
|
||||
for j in range(i + 1, min(len(lines), i + 5)):
|
||||
if '(uuid' in lines[j]:
|
||||
if '\t(uuid' in lines[j] or ' (uuid' in lines[j]:
|
||||
uuid_parts = lines[j].split('"')
|
||||
if len(uuid_parts) >= 2:
|
||||
current_uuid = uuid_parts[1]
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
print(f" Error processing {sch_path.name}: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# Update variant with DNP list
|
||||
print(f"\nUpdating variant '{active_variant}'...")
|
||||
print(f" Found {len(all_uuids)} total UUIDs")
|
||||
print(f" Found {len(all_dnp_uuids)} DNP UUIDs")
|
||||
|
||||
# Build the new DNP list directly instead of calling set_part_dnp multiple times
|
||||
# This avoids multiple file saves
|
||||
if active_variant in manager.variants["variants"]:
|
||||
# Set the DNP list directly
|
||||
manager.variants["variants"][active_variant]["dnp_parts"] = sorted(all_dnp_uuids)
|
||||
|
||||
# Save once at the end
|
||||
manager._save_variants()
|
||||
|
||||
print(f" Updated DNP list with {len(all_dnp_uuids)} parts")
|
||||
for uuid in all_dnp_uuids:
|
||||
print(f" DNP UUID: {uuid}")
|
||||
else:
|
||||
print(f" Error: Variant '{active_variant}' not found in variants")
|
||||
return False
|
||||
|
||||
print(f"\nVariant '{active_variant}' updated:")
|
||||
print(f" Total components: {len(all_uuids)}")
|
||||
print(f" DNP components: {len(all_dnp_uuids)}")
|
||||
print(f" Fitted components: {len(all_uuids) - len(all_dnp_uuids)}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python sync_variant.py <schematic.kicad_sch> [variant_name]")
|
||||
sys.exit(1)
|
||||
|
||||
schematic = sys.argv[1]
|
||||
variant = sys.argv[2] if len(sys.argv) > 2 else None
|
||||
success = sync_variant_from_schematic(schematic, variant)
|
||||
sys.exit(0 if success else 1)
|
||||
846
templates/index.html
Normal file
846
templates/index.html
Normal file
@@ -0,0 +1,846 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>UM KiCad Manager</title>
|
||||
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
border-bottom: 3px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.arg-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-top: 20px;
|
||||
}
|
||||
.arg-item {
|
||||
display: flex;
|
||||
margin: 15px 0;
|
||||
padding: 10px;
|
||||
border-left: 4px solid #007bff;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.arg-label {
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
min-width: 150px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
.arg-value {
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
}
|
||||
.status {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status.connected {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
.status.disconnected {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.actions {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-top: 20px;
|
||||
}
|
||||
.btn {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.btn:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.btn:disabled {
|
||||
background-color: #6c757d;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.message {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
}
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.message.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
.command-container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-top: 20px;
|
||||
}
|
||||
.command-box {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
padding: 15px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
overflow-x: auto;
|
||||
position: relative;
|
||||
}
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 5px 10px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
.copy-btn:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
border-bottom: 2px solid #007bff;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
background-color: #e9ecef;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-right: 5px;
|
||||
border-radius: 5px 5px 0 0;
|
||||
}
|
||||
.tab:hover {
|
||||
background-color: #dee2e6;
|
||||
}
|
||||
.tab.active {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
.variant-card {
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.variant-card.active-variant {
|
||||
border-color: #007bff;
|
||||
background-color: #e7f3ff;
|
||||
}
|
||||
.variant-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.variant-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
.variant-description {
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.variant-stats {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
.modal-content {
|
||||
background-color: #fefefe;
|
||||
margin: 5% auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
border-radius: 8px;
|
||||
width: 50%;
|
||||
max-width: 500px;
|
||||
}
|
||||
.close {
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover {
|
||||
color: black;
|
||||
}
|
||||
.form-group {
|
||||
margin: 15px 0;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.parts-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
}
|
||||
.part-item {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.part-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.part-item.dnp {
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="{{ url_for('static', filename='logo_banner.png') }}" alt="Banner" style="width: 100%; max-width: 800px; display: block; margin: 0 auto 20px auto;">
|
||||
|
||||
<div id="status" class="status disconnected">Disconnected</div>
|
||||
|
||||
<h1>KiCad Manager</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('main')">Main</button>
|
||||
<button class="tab" onclick="switchTab('variants')">Variant Manager</button>
|
||||
</div>
|
||||
|
||||
<div id="mainTab" class="tab-content active">
|
||||
|
||||
<div class="command-container">
|
||||
<h2>Invocation Command</h2>
|
||||
<div class="command-box">
|
||||
<button class="copy-btn" onclick="copyCommand()">Copy</button>
|
||||
<code id="invocationCmd">{{ invocation_cmd }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="arg-container">
|
||||
{% for key, value in args.items() %}
|
||||
<div class="arg-item">
|
||||
<div class="arg-label">{{ key }}:</div>
|
||||
<div class="arg-value">{{ value }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<h2>Actions</h2>
|
||||
<button id="generatePdfBtn" class="btn">Generate All PDFs (Schematic + Board Layers)</button>
|
||||
<button id="generateGerbersBtn" class="btn" style="margin-left: 10px;">Generate Gerbers & Drill Files</button>
|
||||
<button id="syncLibrariesBtn" class="btn" style="margin-left: 10px;">Sync Symbol Libraries</button>
|
||||
<button id="syncDbBtn" class="btn" style="margin-left: 10px;">Sync Parts to Database (R & C)</button>
|
||||
<div id="message" class="message"></div>
|
||||
<div id="gerberMessage" class="message"></div>
|
||||
<div id="syncMessage" class="message"></div>
|
||||
<div id="dbSyncMessage" class="message"></div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<h2>System Initialization</h2>
|
||||
<button id="initUserBtn" class="btn">Initialize User Environment</button>
|
||||
<div id="initMessage" class="message"></div>
|
||||
</div>
|
||||
|
||||
<div class="arg-container">
|
||||
<h2>Settings</h2>
|
||||
<div style="margin: 15px 0;">
|
||||
<label for="partsSpreadsheet" style="display: block; font-weight: bold; color: #007bff; margin-bottom: 5px;">
|
||||
Master Parts Spreadsheet (XLSX):
|
||||
</label>
|
||||
<input type="text" id="partsSpreadsheet" style="width: 100%; padding: 8px; border: 1px solid #dee2e6; border-radius: 5px; font-family: 'Courier New', monospace;">
|
||||
<button id="saveConfigBtn" class="btn" style="margin-top: 10px;">Save Settings</button>
|
||||
<div id="configMessage" class="message"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- End mainTab -->
|
||||
|
||||
<!-- Variant Manager Tab -->
|
||||
<div id="variantsTab" class="tab-content">
|
||||
<div class="container">
|
||||
<h2>Project: <span id="projectName">Loading...</span></h2>
|
||||
<button id="createVariantBtn" class="btn">+ Create New Variant</button>
|
||||
<button id="syncFromSchematicBtn" class="btn secondary">Sync from Schematic</button>
|
||||
<div id="variantMessage" class="message"></div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h2>Variants</h2>
|
||||
<div id="variantsList"></div>
|
||||
</div>
|
||||
</div><!-- End variantsTab -->
|
||||
|
||||
<!-- Create Variant Modal -->
|
||||
<div id="createModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeCreateModal()">×</span>
|
||||
<h2>Create New Variant</h2>
|
||||
<div class="form-group">
|
||||
<label for="variantName">Variant Name:</label>
|
||||
<input type="text" id="variantName" placeholder="e.g., low-cost, premium">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="variantDescription">Description:</label>
|
||||
<textarea id="variantDescription" rows="3" placeholder="Description of this variant"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="basedOn">Based On:</label>
|
||||
<select id="basedOn">
|
||||
<option value="">Empty (no parts DNP)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" onclick="createVariant()">Create</button>
|
||||
<button class="btn secondary" onclick="closeCreateModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Parts Modal -->
|
||||
<div id="editPartsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeEditPartsModal()">×</span>
|
||||
<h2>Edit Parts: <span id="editVariantName"></span></h2>
|
||||
<p>Toggle parts between Fitted and DNP (Do Not Place)</p>
|
||||
<div class="parts-list" id="partsList"></div>
|
||||
<button class="btn" onclick="closeEditPartsModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const socket = io();
|
||||
const statusEl = document.getElementById('status');
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
statusEl.textContent = 'Connected';
|
||||
statusEl.className = 'status connected';
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
statusEl.textContent = 'Disconnected';
|
||||
statusEl.className = 'status disconnected';
|
||||
});
|
||||
|
||||
// Send heartbeat every 2 seconds
|
||||
setInterval(() => {
|
||||
if (socket.connected) {
|
||||
socket.emit('heartbeat');
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
socket.on('heartbeat_ack', () => {
|
||||
console.log('Heartbeat acknowledged');
|
||||
});
|
||||
|
||||
// Detect when page is about to close
|
||||
window.addEventListener('beforeunload', () => {
|
||||
socket.disconnect();
|
||||
});
|
||||
|
||||
// PDF Generation
|
||||
const generatePdfBtn = document.getElementById('generatePdfBtn');
|
||||
const messageEl = document.getElementById('message');
|
||||
|
||||
function showMessage(text, type) {
|
||||
messageEl.textContent = text;
|
||||
messageEl.className = 'message ' + type;
|
||||
messageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
generatePdfBtn.addEventListener('click', () => {
|
||||
generatePdfBtn.disabled = true;
|
||||
showMessage('Requesting PDF generation...', 'info');
|
||||
socket.emit('generate_pdf');
|
||||
});
|
||||
|
||||
socket.on('pdf_status', (data) => {
|
||||
showMessage(data.status, 'info');
|
||||
});
|
||||
|
||||
socket.on('pdf_complete', (data) => {
|
||||
showMessage('PDFs generated successfully! Downloading ZIP archive...', 'success');
|
||||
generatePdfBtn.disabled = false;
|
||||
|
||||
// Trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = '/download/' + data.filename;
|
||||
link.download = data.filename;
|
||||
link.click();
|
||||
});
|
||||
|
||||
socket.on('pdf_error', (data) => {
|
||||
showMessage('Error: ' + data.error, 'error');
|
||||
generatePdfBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Gerber Generation
|
||||
const generateGerbersBtn = document.getElementById('generateGerbersBtn');
|
||||
const gerberMessageEl = document.getElementById('gerberMessage');
|
||||
|
||||
function showGerberMessage(text, type) {
|
||||
gerberMessageEl.textContent = text;
|
||||
gerberMessageEl.className = 'message ' + type;
|
||||
gerberMessageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
generateGerbersBtn.addEventListener('click', () => {
|
||||
generateGerbersBtn.disabled = true;
|
||||
showGerberMessage('Requesting gerber generation...', 'info');
|
||||
socket.emit('generate_gerbers');
|
||||
});
|
||||
|
||||
socket.on('gerber_status', (data) => {
|
||||
showGerberMessage(data.status, 'info');
|
||||
});
|
||||
|
||||
socket.on('gerber_complete', (data) => {
|
||||
showGerberMessage('Gerbers generated successfully! Downloading ZIP archive...', 'success');
|
||||
generateGerbersBtn.disabled = false;
|
||||
|
||||
// Trigger download
|
||||
const link = document.createElement('a');
|
||||
link.href = '/download/' + data.filename;
|
||||
link.download = data.filename;
|
||||
link.click();
|
||||
});
|
||||
|
||||
socket.on('gerber_error', (data) => {
|
||||
showGerberMessage('Error: ' + data.error, 'error');
|
||||
generateGerbersBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Library Synchronization
|
||||
const syncLibrariesBtn = document.getElementById('syncLibrariesBtn');
|
||||
const syncMessageEl = document.getElementById('syncMessage');
|
||||
|
||||
function showSyncMessage(text, type) {
|
||||
syncMessageEl.textContent = text;
|
||||
syncMessageEl.className = 'message ' + type;
|
||||
syncMessageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
syncLibrariesBtn.addEventListener('click', () => {
|
||||
syncLibrariesBtn.disabled = true;
|
||||
showSyncMessage('Starting library synchronization...', 'info');
|
||||
socket.emit('sync_libraries');
|
||||
});
|
||||
|
||||
socket.on('sync_status', (data) => {
|
||||
showSyncMessage(data.status, 'info');
|
||||
});
|
||||
|
||||
socket.on('sync_complete', (data) => {
|
||||
const output = data.output.replace(/\n/g, '<br>');
|
||||
syncMessageEl.innerHTML = '<strong>Sync Complete!</strong><br><pre style="margin-top: 10px; background: #f8f9fa; padding: 10px; border-radius: 5px; overflow-x: auto;">' + output + '</pre>';
|
||||
syncMessageEl.className = 'message success';
|
||||
syncMessageEl.style.display = 'block';
|
||||
syncLibrariesBtn.disabled = false;
|
||||
});
|
||||
|
||||
socket.on('sync_error', (data) => {
|
||||
const error = data.error.replace(/\n/g, '<br>');
|
||||
syncMessageEl.innerHTML = '<strong>Error:</strong><br><pre style="margin-top: 10px; background: #f8d7da; padding: 10px; border-radius: 5px; overflow-x: auto;">' + error + '</pre>';
|
||||
syncMessageEl.className = 'message error';
|
||||
syncMessageEl.style.display = 'block';
|
||||
syncLibrariesBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Database Synchronization
|
||||
const syncDbBtn = document.getElementById('syncDbBtn');
|
||||
const dbSyncMessageEl = document.getElementById('dbSyncMessage');
|
||||
|
||||
function showDbSyncMessage(text, type) {
|
||||
dbSyncMessageEl.textContent = text;
|
||||
dbSyncMessageEl.className = 'message ' + type;
|
||||
dbSyncMessageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
syncDbBtn.addEventListener('click', () => {
|
||||
syncDbBtn.disabled = true;
|
||||
showDbSyncMessage('Starting database synchronization...', 'info');
|
||||
socket.emit('sync_database');
|
||||
});
|
||||
|
||||
socket.on('db_sync_status', (data) => {
|
||||
showDbSyncMessage(data.status, 'info');
|
||||
});
|
||||
|
||||
socket.on('db_sync_complete', (data) => {
|
||||
const output = data.output.replace(/\n/g, '<br>');
|
||||
dbSyncMessageEl.innerHTML = '<strong>Database Sync Complete!</strong><br><pre style="margin-top: 10px; background: #f8f9fa; padding: 10px; border-radius: 5px; overflow-x: auto;">' + output + '</pre>';
|
||||
dbSyncMessageEl.className = 'message success';
|
||||
dbSyncMessageEl.style.display = 'block';
|
||||
syncDbBtn.disabled = false;
|
||||
});
|
||||
|
||||
socket.on('db_sync_error', (data) => {
|
||||
const error = data.error.replace(/\n/g, '<br>');
|
||||
dbSyncMessageEl.innerHTML = '<strong>Error:</strong><br><pre style="margin-top: 10px; background: #f8d7da; padding: 10px; border-radius: 5px; overflow-x: auto;">' + error + '</pre>';
|
||||
dbSyncMessageEl.className = 'message error';
|
||||
dbSyncMessageEl.style.display = 'block';
|
||||
syncDbBtn.disabled = false;
|
||||
});
|
||||
|
||||
// User Initialization
|
||||
const initUserBtn = document.getElementById('initUserBtn');
|
||||
const initMessageEl = document.getElementById('initMessage');
|
||||
|
||||
function showInitMessage(text, type) {
|
||||
initMessageEl.textContent = text;
|
||||
initMessageEl.className = 'message ' + type;
|
||||
initMessageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
initUserBtn.addEventListener('click', () => {
|
||||
initUserBtn.disabled = true;
|
||||
showInitMessage('Initializing user environment...', 'info');
|
||||
socket.emit('init_user');
|
||||
});
|
||||
|
||||
socket.on('init_status', (data) => {
|
||||
showInitMessage(data.status, 'info');
|
||||
});
|
||||
|
||||
socket.on('init_complete', (data) => {
|
||||
const output = data.output.replace(/\n/g, '<br>');
|
||||
initMessageEl.innerHTML = '<strong>Initialization Complete!</strong><br><pre style="margin-top: 10px; background: #f8f9fa; padding: 10px; border-radius: 5px; overflow-x: auto;">' + output + '</pre>';
|
||||
initMessageEl.className = 'message success';
|
||||
initMessageEl.style.display = 'block';
|
||||
initUserBtn.disabled = false;
|
||||
});
|
||||
|
||||
socket.on('init_error', (data) => {
|
||||
const error = data.error.replace(/\n/g, '<br>');
|
||||
initMessageEl.innerHTML = '<strong>Error:</strong><br><pre style="margin-top: 10px; background: #f8d7da; padding: 10px; border-radius: 5px; overflow-x: auto;">' + error + '</pre>';
|
||||
initMessageEl.className = 'message error';
|
||||
initMessageEl.style.display = 'block';
|
||||
initUserBtn.disabled = false;
|
||||
});
|
||||
|
||||
// Copy command to clipboard
|
||||
function copyCommand() {
|
||||
const cmdText = document.getElementById('invocationCmd').textContent;
|
||||
navigator.clipboard.writeText(cmdText).then(() => {
|
||||
const btn = event.target;
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = 'Copied!';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Configuration Management
|
||||
const partsSpreadsheetInput = document.getElementById('partsSpreadsheet');
|
||||
const saveConfigBtn = document.getElementById('saveConfigBtn');
|
||||
const configMessageEl = document.getElementById('configMessage');
|
||||
|
||||
function showConfigMessage(text, type) {
|
||||
configMessageEl.textContent = text;
|
||||
configMessageEl.className = 'message ' + type;
|
||||
configMessageEl.style.display = 'block';
|
||||
}
|
||||
|
||||
// Load configuration on page load
|
||||
fetch('/config')
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
partsSpreadsheetInput.value = config.parts_spreadsheet_path || '';
|
||||
});
|
||||
|
||||
// Save configuration
|
||||
saveConfigBtn.addEventListener('click', () => {
|
||||
const config = {
|
||||
parts_spreadsheet_path: partsSpreadsheetInput.value
|
||||
};
|
||||
|
||||
fetch('/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
showConfigMessage('Settings saved successfully!', 'success');
|
||||
setTimeout(() => {
|
||||
configMessageEl.style.display = 'none';
|
||||
}, 3000);
|
||||
})
|
||||
.catch(error => {
|
||||
showConfigMessage('Error saving settings: ' + error, 'error');
|
||||
});
|
||||
});
|
||||
|
||||
// ========================================
|
||||
// Tab Switching
|
||||
// ========================================
|
||||
function switchTab(tabName) {
|
||||
// Hide all tabs
|
||||
document.querySelectorAll('.tab-content').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
// Show selected tab
|
||||
if (tabName === 'main') {
|
||||
document.getElementById('mainTab').classList.add('active');
|
||||
document.querySelectorAll('.tab')[0].classList.add('active');
|
||||
} else if (tabName === 'variants') {
|
||||
document.getElementById('variantsTab').classList.add('active');
|
||||
document.querySelectorAll('.tab')[1].classList.add('active');
|
||||
loadVariants();
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// Variant Manager
|
||||
// ========================================
|
||||
let currentVariant = '';
|
||||
let allParts = [];
|
||||
|
||||
function showVariantMessage(text, type) {
|
||||
const messageEl = document.getElementById('variantMessage');
|
||||
messageEl.textContent = text;
|
||||
messageEl.className = 'message ' + type;
|
||||
messageEl.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
messageEl.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function loadVariants() {
|
||||
socket.emit('get_variants');
|
||||
}
|
||||
|
||||
socket.on('variants_data', (data) => {
|
||||
document.getElementById('projectName').textContent = data.project_name;
|
||||
currentVariant = data.active_variant;
|
||||
allParts = data.all_parts || [];
|
||||
renderVariants(data.variants, data.active_variant);
|
||||
updateBasedOnSelect(data.variants);
|
||||
});
|
||||
|
||||
function renderVariants(variants, activeVariant) {
|
||||
const list = document.getElementById('variantsList');
|
||||
list.innerHTML = '';
|
||||
|
||||
for (const [name, variant] of Object.entries(variants)) {
|
||||
const isActive = name === activeVariant;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'variant-card' + (isActive ? ' active-variant' : '');
|
||||
|
||||
const dnpCount = variant.dnp_parts.length;
|
||||
const fittedCount = allParts.length - dnpCount;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="variant-header">
|
||||
<div class="variant-name">${variant.name}${isActive ? ' (Active)' : ''}</div>
|
||||
<div>
|
||||
${!isActive ? `<button class="btn success" onclick="activateVariant('${name}')">Activate</button>` : ''}
|
||||
<button class="btn" onclick="editParts('${name}')">Edit Parts</button>
|
||||
${name !== 'default' ? `<button class="btn danger" onclick="deleteVariant('${name}')">Delete</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="variant-description">${variant.description || 'No description'}</div>
|
||||
<div class="variant-stats">
|
||||
Fitted: ${fittedCount} | DNP: ${dnpCount} | Total: ${allParts.length}
|
||||
</div>
|
||||
`;
|
||||
|
||||
list.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function updateBasedOnSelect(variants) {
|
||||
const select = document.getElementById('basedOn');
|
||||
select.innerHTML = '<option value="">Empty (no parts DNP)</option>';
|
||||
for (const name of Object.keys(variants)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('createVariantBtn').addEventListener('click', () => {
|
||||
document.getElementById('createModal').style.display = 'block';
|
||||
});
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('createModal').style.display = 'none';
|
||||
document.getElementById('variantName').value = '';
|
||||
document.getElementById('variantDescription').value = '';
|
||||
document.getElementById('basedOn').value = '';
|
||||
}
|
||||
|
||||
function createVariant() {
|
||||
const name = document.getElementById('variantName').value.trim();
|
||||
const description = document.getElementById('variantDescription').value.trim();
|
||||
const basedOn = document.getElementById('basedOn').value;
|
||||
|
||||
if (!name) {
|
||||
alert('Please enter a variant name');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('create_variant', { name, description, based_on: basedOn });
|
||||
closeCreateModal();
|
||||
}
|
||||
|
||||
function deleteVariant(name) {
|
||||
if (confirm(`Are you sure you want to delete variant "${name}"?`)) {
|
||||
socket.emit('delete_variant', { name });
|
||||
}
|
||||
}
|
||||
|
||||
function activateVariant(name) {
|
||||
if (confirm(`Activate variant "${name}"? This will apply DNP settings to the schematic.`)) {
|
||||
socket.emit('activate_variant', { name });
|
||||
}
|
||||
}
|
||||
|
||||
function editParts(variantName) {
|
||||
currentVariant = variantName;
|
||||
document.getElementById('editVariantName').textContent = variantName;
|
||||
socket.emit('get_variant_parts', { variant: variantName });
|
||||
}
|
||||
|
||||
function closeEditPartsModal() {
|
||||
document.getElementById('editPartsModal').style.display = 'none';
|
||||
}
|
||||
|
||||
socket.on('variant_parts_data', (data) => {
|
||||
const list = document.getElementById('partsList');
|
||||
list.innerHTML = '';
|
||||
|
||||
for (const part of data.parts) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'part-item' + (part.is_dnp ? ' dnp' : '');
|
||||
item.innerHTML = `
|
||||
<div>
|
||||
<strong>${part.reference}</strong> - ${part.value || 'N/A'}
|
||||
${part.is_dnp ? '<span style="color: #856404; margin-left: 10px;">[DNP]</span>' : ''}
|
||||
</div>
|
||||
<button class="btn ${part.is_dnp ? 'success' : 'secondary'}"
|
||||
onclick="togglePartDNP('${part.uuid}', ${!part.is_dnp})">
|
||||
${part.is_dnp ? 'Fit' : 'DNP'}
|
||||
</button>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
document.getElementById('editPartsModal').style.display = 'block';
|
||||
});
|
||||
|
||||
function togglePartDNP(uuid, isDNP) {
|
||||
socket.emit('set_part_dnp', {
|
||||
variant: currentVariant,
|
||||
uuid: uuid,
|
||||
is_dnp: isDNP
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('variant_updated', (data) => {
|
||||
showVariantMessage(data.message, 'success');
|
||||
loadVariants();
|
||||
});
|
||||
|
||||
socket.on('variant_error', (data) => {
|
||||
showVariantMessage(data.error, 'error');
|
||||
});
|
||||
|
||||
document.getElementById('syncFromSchematicBtn').addEventListener('click', () => {
|
||||
if (confirm('Sync variant from current schematic state? This will update DNP settings based on the schematic.')) {
|
||||
socket.emit('sync_from_schematic');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('sync_complete', (data) => {
|
||||
showVariantMessage(data.message, 'success');
|
||||
loadVariants();
|
||||
});
|
||||
|
||||
// Close modals when clicking outside
|
||||
window.onclick = function(event) {
|
||||
if (event.target.className === 'modal') {
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
446
templates/variants.html
Normal file
446
templates/variants.html
Normal file
@@ -0,0 +1,446 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Variant Manager - UM KiCad</title>
|
||||
<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
h1 {
|
||||
color: #333;
|
||||
border-bottom: 3px solid #007bff;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.status {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.status.connected {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
.status.disconnected {
|
||||
background-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-top: 20px;
|
||||
}
|
||||
.btn {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
margin: 5px;
|
||||
}
|
||||
.btn:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
.btn.secondary {
|
||||
background-color: #6c757d;
|
||||
}
|
||||
.btn.secondary:hover {
|
||||
background-color: #545b62;
|
||||
}
|
||||
.btn.danger {
|
||||
background-color: #dc3545;
|
||||
}
|
||||
.btn.danger:hover {
|
||||
background-color: #c82333;
|
||||
}
|
||||
.btn.success {
|
||||
background-color: #28a745;
|
||||
}
|
||||
.btn.success:hover {
|
||||
background-color: #218838;
|
||||
}
|
||||
.variant-card {
|
||||
border: 2px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.variant-card.active {
|
||||
border-color: #007bff;
|
||||
background-color: #e7f3ff;
|
||||
}
|
||||
.variant-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.variant-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
.variant-description {
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.variant-stats {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
.modal-content {
|
||||
background-color: #fefefe;
|
||||
margin: 10% auto;
|
||||
padding: 20px;
|
||||
border: 1px solid #888;
|
||||
border-radius: 8px;
|
||||
width: 50%;
|
||||
max-width: 500px;
|
||||
}
|
||||
.close {
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
.close:hover {
|
||||
color: black;
|
||||
}
|
||||
.form-group {
|
||||
margin: 15px 0;
|
||||
}
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.form-group input,
|
||||
.form-group textarea,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.parts-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
}
|
||||
.part-item {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.part-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.part-item.dnp {
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
.nav-link {
|
||||
display: inline-block;
|
||||
margin: 10px 10px 10px 0;
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.nav-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.message {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
display: none;
|
||||
}
|
||||
.message.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.message.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.message.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="status" class="status disconnected">Disconnected</div>
|
||||
|
||||
<a href="/" class="nav-link">← Back to Main</a>
|
||||
|
||||
<h1>Variant Manager</h1>
|
||||
|
||||
<div class="container">
|
||||
<h2>Project: <span id="projectName">Loading...</span></h2>
|
||||
<button id="createVariantBtn" class="btn">+ Create New Variant</button>
|
||||
<button id="syncFromSchematicBtn" class="btn secondary">Sync from Schematic</button>
|
||||
<div id="message" class="message"></div>
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<h2>Variants</h2>
|
||||
<div id="variantsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Create Variant Modal -->
|
||||
<div id="createModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeCreateModal()">×</span>
|
||||
<h2>Create New Variant</h2>
|
||||
<div class="form-group">
|
||||
<label for="variantName">Variant Name:</label>
|
||||
<input type="text" id="variantName" placeholder="e.g., low-cost, premium">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="variantDescription">Description:</label>
|
||||
<textarea id="variantDescription" rows="3" placeholder="Description of this variant"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="basedOn">Based On:</label>
|
||||
<select id="basedOn">
|
||||
<option value="">Empty (no parts DNP)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn" onclick="createVariant()">Create</button>
|
||||
<button class="btn secondary" onclick="closeCreateModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Parts Modal -->
|
||||
<div id="editPartsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<span class="close" onclick="closeEditPartsModal()">×</span>
|
||||
<h2>Edit Parts: <span id="editVariantName"></span></h2>
|
||||
<p>Toggle parts between Fitted and DNP (Do Not Place)</p>
|
||||
<div class="parts-list" id="partsList"></div>
|
||||
<button class="btn" onclick="closeEditPartsModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const socket = io();
|
||||
const statusEl = document.getElementById('status');
|
||||
let currentVariant = '';
|
||||
let allParts = [];
|
||||
|
||||
socket.on('connect', () => {
|
||||
statusEl.textContent = 'Connected';
|
||||
statusEl.className = 'status connected';
|
||||
loadVariants();
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
statusEl.textContent = 'Disconnected';
|
||||
statusEl.className = 'status disconnected';
|
||||
});
|
||||
|
||||
function showMessage(text, type) {
|
||||
const messageEl = document.getElementById('message');
|
||||
messageEl.textContent = text;
|
||||
messageEl.className = 'message ' + type;
|
||||
messageEl.style.display = 'block';
|
||||
setTimeout(() => {
|
||||
messageEl.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function loadVariants() {
|
||||
socket.emit('get_variants');
|
||||
}
|
||||
|
||||
socket.on('variants_data', (data) => {
|
||||
document.getElementById('projectName').textContent = data.project_name;
|
||||
currentVariant = data.active_variant;
|
||||
allParts = data.all_parts || [];
|
||||
renderVariants(data.variants, data.active_variant);
|
||||
updateBasedOnSelect(data.variants);
|
||||
});
|
||||
|
||||
function renderVariants(variants, activeVariant) {
|
||||
const list = document.getElementById('variantsList');
|
||||
list.innerHTML = '';
|
||||
|
||||
for (const [name, variant] of Object.entries(variants)) {
|
||||
const isActive = name === activeVariant;
|
||||
const card = document.createElement('div');
|
||||
card.className = 'variant-card' + (isActive ? ' active' : '');
|
||||
|
||||
const dnpCount = variant.dnp_parts.length;
|
||||
const fittedCount = allParts.length - dnpCount;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="variant-header">
|
||||
<div class="variant-name">${variant.name}${isActive ? ' (Active)' : ''}</div>
|
||||
<div>
|
||||
${!isActive ? `<button class="btn success" onclick="activateVariant('${name}')">Activate</button>` : ''}
|
||||
<button class="btn" onclick="editParts('${name}')">Edit Parts</button>
|
||||
${name !== 'default' ? `<button class="btn danger" onclick="deleteVariant('${name}')">Delete</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="variant-description">${variant.description || 'No description'}</div>
|
||||
<div class="variant-stats">
|
||||
Fitted: ${fittedCount} | DNP: ${dnpCount} | Total: ${allParts.length}
|
||||
</div>
|
||||
`;
|
||||
|
||||
list.appendChild(card);
|
||||
}
|
||||
}
|
||||
|
||||
function updateBasedOnSelect(variants) {
|
||||
const select = document.getElementById('basedOn');
|
||||
select.innerHTML = '<option value="">Empty (no parts DNP)</option>';
|
||||
for (const name of Object.keys(variants)) {
|
||||
const option = document.createElement('option');
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
select.appendChild(option);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('createVariantBtn').addEventListener('click', () => {
|
||||
document.getElementById('createModal').style.display = 'block';
|
||||
});
|
||||
|
||||
function closeCreateModal() {
|
||||
document.getElementById('createModal').style.display = 'none';
|
||||
document.getElementById('variantName').value = '';
|
||||
document.getElementById('variantDescription').value = '';
|
||||
document.getElementById('basedOn').value = '';
|
||||
}
|
||||
|
||||
function createVariant() {
|
||||
const name = document.getElementById('variantName').value.trim();
|
||||
const description = document.getElementById('variantDescription').value.trim();
|
||||
const basedOn = document.getElementById('basedOn').value;
|
||||
|
||||
if (!name) {
|
||||
alert('Please enter a variant name');
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('create_variant', { name, description, based_on: basedOn });
|
||||
closeCreateModal();
|
||||
}
|
||||
|
||||
function deleteVariant(name) {
|
||||
if (confirm(`Are you sure you want to delete variant "${name}"?`)) {
|
||||
socket.emit('delete_variant', { name });
|
||||
}
|
||||
}
|
||||
|
||||
function activateVariant(name) {
|
||||
if (confirm(`Activate variant "${name}"? This will apply DNP settings to the schematic.`)) {
|
||||
socket.emit('activate_variant', { name });
|
||||
}
|
||||
}
|
||||
|
||||
function editParts(variantName) {
|
||||
currentVariant = variantName;
|
||||
document.getElementById('editVariantName').textContent = variantName;
|
||||
socket.emit('get_variant_parts', { variant: variantName });
|
||||
}
|
||||
|
||||
function closeEditPartsModal() {
|
||||
document.getElementById('editPartsModal').style.display = 'none';
|
||||
}
|
||||
|
||||
socket.on('variant_parts_data', (data) => {
|
||||
const list = document.getElementById('partsList');
|
||||
list.innerHTML = '';
|
||||
|
||||
for (const part of data.parts) {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'part-item' + (part.is_dnp ? ' dnp' : '');
|
||||
item.innerHTML = `
|
||||
<div>
|
||||
<strong>${part.reference}</strong> - ${part.value || 'N/A'}
|
||||
${part.is_dnp ? '<span style="color: #856404; margin-left: 10px;">[DNP]</span>' : ''}
|
||||
</div>
|
||||
<button class="btn ${part.is_dnp ? 'success' : 'secondary'}"
|
||||
onclick="togglePartDNP('${part.reference}', ${!part.is_dnp})">
|
||||
${part.is_dnp ? 'Fit' : 'DNP'}
|
||||
</button>
|
||||
`;
|
||||
list.appendChild(item);
|
||||
}
|
||||
|
||||
document.getElementById('editPartsModal').style.display = 'block';
|
||||
});
|
||||
|
||||
function togglePartDNP(reference, isDNP) {
|
||||
socket.emit('set_part_dnp', {
|
||||
variant: currentVariant,
|
||||
reference: reference,
|
||||
is_dnp: isDNP
|
||||
});
|
||||
}
|
||||
|
||||
socket.on('variant_updated', (data) => {
|
||||
showMessage(data.message, 'success');
|
||||
loadVariants();
|
||||
});
|
||||
|
||||
socket.on('variant_error', (data) => {
|
||||
showMessage(data.error, 'error');
|
||||
});
|
||||
|
||||
document.getElementById('syncFromSchematicBtn').addEventListener('click', () => {
|
||||
if (confirm('Sync variant from current schematic state? This will update DNP settings based on the schematic.')) {
|
||||
socket.emit('sync_from_schematic');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('sync_complete', (data) => {
|
||||
showMessage(data.message, 'success');
|
||||
loadVariants();
|
||||
});
|
||||
|
||||
// Close modals when clicking outside
|
||||
window.onclick = function(event) {
|
||||
if (event.target.className === 'modal') {
|
||||
event.target.style.display = 'none';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
211
variant_manager.py
Normal file
211
variant_manager.py
Normal file
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
variant_manager.py
|
||||
==================
|
||||
Manage KiCad design variants - track which parts are fitted/unfitted in different variants.
|
||||
|
||||
Variants are stored in a JSON file alongside the project.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set
|
||||
|
||||
|
||||
class VariantManager:
|
||||
"""Manages design variants for a KiCad project"""
|
||||
|
||||
def __init__(self, project_path: str):
|
||||
"""
|
||||
Initialize variant manager for a project.
|
||||
|
||||
Args:
|
||||
project_path: Path to the .kicad_pro or .kicad_sch file
|
||||
"""
|
||||
self.project_path = Path(project_path)
|
||||
self.project_dir = self.project_path.parent
|
||||
self.project_name = self.project_path.stem
|
||||
|
||||
# Variants file stored alongside project
|
||||
self.variants_file = self.project_dir / f"{self.project_name}.variants.json"
|
||||
self.variants = self._load_variants()
|
||||
|
||||
def _load_variants(self) -> Dict:
|
||||
"""Load variants from JSON file"""
|
||||
if self.variants_file.exists():
|
||||
with open(self.variants_file, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
else:
|
||||
# Default structure
|
||||
return {
|
||||
"meta": {
|
||||
"version": 2, # Version 2 uses UUIDs instead of references
|
||||
"active_variant": "default"
|
||||
},
|
||||
"variants": {
|
||||
"default": {
|
||||
"name": "default",
|
||||
"description": "Default variant - all parts fitted",
|
||||
"dnp_parts": [] # List of UUIDs that are DNP (Do Not Place)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def _save_variants(self):
|
||||
"""Save variants to JSON file"""
|
||||
with open(self.variants_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.variants, f, indent=2)
|
||||
|
||||
def get_variants(self) -> Dict:
|
||||
"""Get all variants"""
|
||||
return self.variants["variants"]
|
||||
|
||||
def get_active_variant(self) -> str:
|
||||
"""Get the name of the active variant"""
|
||||
return self.variants["meta"]["active_variant"]
|
||||
|
||||
def create_variant(self, name: str, description: str = "", based_on: str = None) -> bool:
|
||||
"""
|
||||
Create a new variant.
|
||||
|
||||
Args:
|
||||
name: Variant name
|
||||
description: Variant description
|
||||
based_on: Name of variant to copy from (None = start empty)
|
||||
|
||||
Returns:
|
||||
True if created, False if already exists
|
||||
"""
|
||||
if name in self.variants["variants"]:
|
||||
return False
|
||||
|
||||
if based_on and based_on in self.variants["variants"]:
|
||||
# Copy DNP list from base variant
|
||||
dnp_parts = self.variants["variants"][based_on]["dnp_parts"].copy()
|
||||
else:
|
||||
dnp_parts = []
|
||||
|
||||
self.variants["variants"][name] = {
|
||||
"name": name,
|
||||
"description": description,
|
||||
"dnp_parts": dnp_parts
|
||||
}
|
||||
|
||||
self._save_variants()
|
||||
return True
|
||||
|
||||
def delete_variant(self, name: str) -> bool:
|
||||
"""
|
||||
Delete a variant.
|
||||
|
||||
Args:
|
||||
name: Variant name
|
||||
|
||||
Returns:
|
||||
True if deleted, False if doesn't exist or is active
|
||||
"""
|
||||
if name not in self.variants["variants"]:
|
||||
return False
|
||||
|
||||
if name == self.variants["meta"]["active_variant"]:
|
||||
return False # Can't delete active variant
|
||||
|
||||
if name == "default":
|
||||
return False # Can't delete default variant
|
||||
|
||||
del self.variants["variants"][name]
|
||||
self._save_variants()
|
||||
return True
|
||||
|
||||
def set_active_variant(self, name: str) -> bool:
|
||||
"""
|
||||
Set the active variant.
|
||||
|
||||
Args:
|
||||
name: Variant name
|
||||
|
||||
Returns:
|
||||
True if set, False if variant doesn't exist
|
||||
"""
|
||||
if name not in self.variants["variants"]:
|
||||
return False
|
||||
|
||||
self.variants["meta"]["active_variant"] = name
|
||||
self._save_variants()
|
||||
return True
|
||||
|
||||
def set_part_dnp(self, variant_name: str, uuid: str, is_dnp: bool) -> bool:
|
||||
"""
|
||||
Set whether a part is DNP (Do Not Place) in a variant.
|
||||
|
||||
Args:
|
||||
variant_name: Variant name
|
||||
uuid: Component UUID (e.g., "681abb84-6eb2-4c95-9a2f-a9fc19a34beb")
|
||||
is_dnp: True to mark as DNP, False to mark as fitted
|
||||
|
||||
Returns:
|
||||
True if successful, False if variant doesn't exist
|
||||
"""
|
||||
if variant_name not in self.variants["variants"]:
|
||||
return False
|
||||
|
||||
dnp_list = self.variants["variants"][variant_name]["dnp_parts"]
|
||||
|
||||
if is_dnp:
|
||||
if uuid not in dnp_list:
|
||||
dnp_list.append(uuid)
|
||||
dnp_list.sort() # Keep sorted
|
||||
else:
|
||||
if uuid in dnp_list:
|
||||
dnp_list.remove(uuid)
|
||||
|
||||
self._save_variants()
|
||||
return True
|
||||
|
||||
def get_dnp_parts(self, variant_name: str) -> List[str]:
|
||||
"""
|
||||
Get list of DNP parts for a variant.
|
||||
|
||||
Args:
|
||||
variant_name: Variant name
|
||||
|
||||
Returns:
|
||||
List of UUIDs, or empty list if variant doesn't exist
|
||||
"""
|
||||
if variant_name not in self.variants["variants"]:
|
||||
return []
|
||||
|
||||
return self.variants["variants"][variant_name]["dnp_parts"].copy()
|
||||
|
||||
def is_part_dnp(self, variant_name: str, uuid: str) -> bool:
|
||||
"""
|
||||
Check if a part is DNP in a variant.
|
||||
|
||||
Args:
|
||||
variant_name: Variant name
|
||||
uuid: Component UUID
|
||||
|
||||
Returns:
|
||||
True if DNP, False if fitted or variant doesn't exist
|
||||
"""
|
||||
if variant_name not in self.variants["variants"]:
|
||||
return False
|
||||
|
||||
return uuid in self.variants["variants"][variant_name]["dnp_parts"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python variant_manager.py <project.kicad_pro>")
|
||||
sys.exit(1)
|
||||
|
||||
manager = VariantManager(sys.argv[1])
|
||||
|
||||
# Print current variants
|
||||
print(f"Project: {manager.project_name}")
|
||||
print(f"Active variant: {manager.get_active_variant()}")
|
||||
print("\nVariants:")
|
||||
for name, variant in manager.get_variants().items():
|
||||
dnp_count = len(variant["dnp_parts"])
|
||||
print(f" {name}: {variant['description']} ({dnp_count} DNP parts)")
|
||||
Reference in New Issue
Block a user