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