commit 3f0aff923d518e77b7dce5465b76f3bff013e9c0 Author: brentperteet Date: Sun Feb 22 08:16:48 2026 -0600 initial commit diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..913a2de --- /dev/null +++ b/.claude/settings.local.json @@ -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": [] + } +} \ No newline at end of file diff --git a/add_libraries.py b/add_libraries.py new file mode 100644 index 0000000..3d082bb --- /dev/null +++ b/add_libraries.py @@ -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()) \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..0530c4d --- /dev/null +++ b/app.py @@ -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/') +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) diff --git a/apply_variant.py b/apply_variant.py new file mode 100644 index 0000000..698dbf7 --- /dev/null +++ b/apply_variant.py @@ -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 [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) diff --git a/config.json b/config.json new file mode 100644 index 0000000..b646e01 --- /dev/null +++ b/config.json @@ -0,0 +1,3 @@ +{ + "parts_spreadsheet_path": "D:\\svn\\pcb\\PN\\parts_list_pcb.xlsx" +} \ No newline at end of file diff --git a/export_bom.py b/export_bom.py new file mode 100644 index 0000000..abdf6a2 --- /dev/null +++ b/export_bom.py @@ -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) diff --git a/extract_symbols.py b/extract_symbols.py new file mode 100644 index 0000000..bd19664 --- /dev/null +++ b/extract_symbols.py @@ -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) diff --git a/gen_passives_db.py b/gen_passives_db.py new file mode 100644 index 0000000..6a201ac --- /dev/null +++ b/gen_passives_db.py @@ -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 + +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 ", 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) diff --git a/init_user.py b/init_user.py new file mode 100644 index 0000000..0d7013f --- /dev/null +++ b/init_user.py @@ -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()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..034a6c5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +Flask==3.0.0 +flask-socketio==5.3.6 +python-socketio==5.11.0 +PyPDF2==3.0.1 +pandas +openpyxl diff --git a/static/logo_banner.png b/static/logo_banner.png new file mode 100644 index 0000000..c902f73 Binary files /dev/null and b/static/logo_banner.png differ diff --git a/sync_variant.py b/sync_variant.py new file mode 100644 index 0000000..4afe1bd --- /dev/null +++ b/sync_variant.py @@ -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 [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) diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..aeaf825 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,846 @@ + + + + + + UM KiCad Manager + + + + + Banner + +
Disconnected
+ +

KiCad Manager

+ +
+ + +
+ +
+ +
+

Invocation Command

+
+ + {{ invocation_cmd }} +
+
+ +
+ {% for key, value in args.items() %} +
+
{{ key }}:
+
{{ value }}
+
+ {% endfor %} +
+ +
+

Actions

+ + + + +
+
+
+
+
+ +
+

System Initialization

+ +
+
+ +
+

Settings

+
+ + + +
+
+
+ +
+ + +
+
+

Project: Loading...

+ + +
+
+ +
+

Variants

+
+
+
+ + + + + + + + + + diff --git a/templates/variants.html b/templates/variants.html new file mode 100644 index 0000000..4624d05 --- /dev/null +++ b/templates/variants.html @@ -0,0 +1,446 @@ + + + + + + Variant Manager - UM KiCad + + + + +
Disconnected
+ + ← Back to Main + +

Variant Manager

+ +
+

Project: Loading...

+ + +
+
+ +
+

Variants

+
+
+ + + + + + + + + + diff --git a/variant_manager.py b/variant_manager.py new file mode 100644 index 0000000..16f1241 --- /dev/null +++ b/variant_manager.py @@ -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 ") + 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)")