initial commit

This commit is contained in:
brentperteet
2026-02-22 08:16:48 -06:00
commit 3f0aff923d
15 changed files with 4364 additions and 0 deletions

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,3 @@
{
"parts_spreadsheet_path": "D:\\svn\\pcb\\PN\\parts_list_pcb.xlsx"
}

89
export_bom.py Normal file
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

278
sync_variant.py Normal file
View 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
View 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()">&times;</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()">&times;</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
View 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()">&times;</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()">&times;</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
View 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)")