Add library browser, BOM generation, and UI improvements

- Add parts library browser with ODBC database search
- Implement hierarchical BOM generation with PCB side detection
- Add STEP export and PCB rendering functionality
- Adjust page width to 80% up to 1536px max
- Add library-only mode for standalone library browsing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
brentperteet
2026-02-22 19:26:36 -06:00
parent ab8d5c0c14
commit 55235f222b
3 changed files with 1026 additions and 23 deletions

330
app.py
View File

@@ -13,6 +13,7 @@ from flask_socketio import SocketIO, emit
import time
from PyPDF2 import PdfMerger
from variant_manager import VariantManager
import pyodbc
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret!'
@@ -57,7 +58,10 @@ def index():
cmd_parts.append(arg)
invocation_cmd = ' '.join(cmd_parts)
return render_template('index.html', args=app_args, invocation_cmd=invocation_cmd)
# Check if called with no arguments (library-only mode)
library_only_mode = len(sys.argv) == 1 or not app_args
return render_template('index.html', args=app_args, invocation_cmd=invocation_cmd, library_only_mode=library_only_mode)
@socketio.on('connect')
def handle_connect():
@@ -265,6 +269,81 @@ def handle_generate_gerbers():
except Exception as e:
emit('gerber_error', {'error': str(e)})
@socketio.on('export_step')
def handle_export_step():
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('step_error', {'error': 'Missing kicad-cli or board-file arguments'})
return
# Create output filename
step_filename = f'{project_name}.step'
step_path = os.path.join(project_dir if project_dir else os.path.dirname(board_file), step_filename)
# Generate STEP file
emit('step_status', {'status': 'Exporting PCB to STEP format...'})
cmd = [kicad_cli, 'pcb', 'export', 'step', board_file, '-o', step_path]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
emit('step_error', {'error': f'STEP export failed: {result.stderr}'})
return
if not os.path.exists(step_path):
emit('step_error', {'error': 'STEP file was not created'})
return
emit('step_complete', {'path': step_path, 'filename': step_filename})
except Exception as e:
emit('step_error', {'error': str(e)})
@socketio.on('render_pcb')
def handle_render_pcb():
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('render_error', {'error': 'Missing kicad-cli or board-file arguments'})
return
# Create output filename
render_filename = f'{project_name}_iso_view.png'
render_path = os.path.join(project_dir if project_dir else os.path.dirname(board_file), render_filename)
# Render PCB with isometric view
emit('render_status', {'status': 'Rendering PCB image...'})
cmd = [
kicad_cli, 'pcb', 'render', board_file,
'--rotate', '25,0,45',
'-o', render_path
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
error_msg = f'PCB render failed:\nCommand: {" ".join(cmd)}\nStderr: {result.stderr}\nStdout: {result.stdout}'
emit('render_error', {'error': error_msg})
return
if not os.path.exists(render_path):
emit('render_error', {'error': 'Rendered image was not created'})
return
emit('render_complete', {'path': render_path, 'filename': render_filename})
except Exception as e:
emit('render_error', {'error': str(e)})
@socketio.on('sync_libraries')
def handle_sync_libraries():
try:
@@ -599,6 +678,12 @@ def handle_delete_variant(data):
@socketio.on('activate_variant')
def handle_activate_variant(data):
try:
import pygetwindow as gw
import pyautogui
import time
import win32gui
import win32con
manager = get_variant_manager()
if not manager:
emit('variant_error', {'error': 'No project loaded'})
@@ -608,30 +693,78 @@ def handle_activate_variant(data):
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()
# Step 1: Save and close schematic window
emit('variant_status', {'status': 'Looking for KiCad schematic window...'})
all_windows = gw.getAllTitles()
windows = gw.getWindowsWithTitle('Schematic Editor')
window_found = False
if not windows:
schematic_windows = [w for w in all_windows if 'kicad' in w.lower() and 'schematic' in w.lower()]
if schematic_windows:
windows = gw.getWindowsWithTitle(schematic_windows[0])
window_found = len(windows) > 0
else:
window_found = True
# If window is found, save and close it
if window_found:
window = windows[0]
emit('variant_status', {'status': f'Saving and closing: {window.title}'})
hwnd = window._hWnd
rect = win32gui.GetWindowRect(hwnd)
x, y, x2, y2 = rect
width = x2 - x
# Click to activate
click_x = x + width // 2
click_y = y + 10
pyautogui.click(click_x, click_y)
time.sleep(0.5)
# Save
pyautogui.hotkey('ctrl', 's')
time.sleep(1.0)
# Close
win32gui.PostMessage(hwnd, win32con.WM_CLOSE, 0, 0)
time.sleep(1.0)
# Try Alt+F4 if still open
if win32gui.IsWindow(hwnd):
try:
rect = win32gui.GetWindowRect(hwnd)
x, y, x2, y2 = rect
click_x = x + (x2 - x) // 2
click_y = y + 10
pyautogui.click(click_x, click_y)
time.sleep(0.3)
except:
pass
pyautogui.hotkey('alt', 'F4')
time.sleep(1.0)
# Step 2: Sync current variant from schematic
current_variant = manager.get_active_variant()
emit('variant_status', {'status': f'Syncing current variant "{current_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
# Step 3: Activate new variant and apply to schematic
emit('variant_status', {'status': f'Activating variant "{name}"...'})
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(
@@ -640,15 +773,39 @@ def handle_activate_variant(data):
text=True
)
if result.returncode == 0:
emit('variant_updated', {'message': f'Synced "{current_variant}", then activated and applied "{name}"'})
else:
if result.returncode != 0:
error_msg = result.stderr if result.stderr else result.stdout
emit('variant_error', {'error': f'Failed to apply variant: {error_msg}'})
return
else:
emit('variant_error', {'error': f'Variant "{name}" not found'})
return
# Step 4: Reopen schematic editor
emit('variant_status', {'status': 'Waiting 2 seconds before reopening...'})
time.sleep(2.0)
emit('variant_status', {'status': 'Reopening schematic editor...'})
kicad_bin_dir = r"C:\Program Files\KiCad\9.0\bin"
if not os.path.exists(kicad_bin_dir):
kicad_bin_dir = r"C:\Program Files\KiCad\8.0\bin"
eeschema_exe = os.path.join(kicad_bin_dir, "eeschema.exe")
if os.path.exists(eeschema_exe):
subprocess.Popen([eeschema_exe, schematic_file], shell=False)
time.sleep(2.0)
else:
emit('variant_status', {'status': f'Warning: eeschema.exe not found at {eeschema_exe}'})
emit('variant_updated', {'message': f'Activated variant "{name}" and reopened schematic'})
except ImportError as e:
emit('variant_error', {'error': f'Missing library: {str(e)}. Install: pip install pygetwindow pyautogui pywin32'})
except Exception as e:
emit('variant_error', {'error': str(e)})
import traceback
emit('variant_error', {'error': f'{str(e)}\n{traceback.format_exc()}'})
@socketio.on('get_variant_parts')
def handle_get_variant_parts(data):
@@ -860,6 +1017,149 @@ def handle_test_window_interaction():
import traceback
emit('window_test_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
# ---------------------------------------------------------------------------
# Library Management
# ---------------------------------------------------------------------------
def get_db_connection():
"""Get ODBC connection to the parts database"""
try:
# Use DSN connection as configured in KiCad database library
conn_str = "DSN=UM_KiCad_Parts;"
conn = pyodbc.connect(conn_str)
return conn
except Exception as e:
print(f"Database connection error: {e}")
return None
@socketio.on('search_parts')
def handle_search_parts(data):
try:
search_query = data.get('query', '').strip()
conn = get_db_connection()
if not conn:
emit('library_error', {'error': 'Could not connect to database'})
return
cursor = conn.cursor()
# Build search query - search across ipn, mpn, manufacturer, and description
# Column names in SQLite are lowercase
if search_query:
sql = """
SELECT ipn, mpn, manufacturer, description
FROM parts
WHERE ipn LIKE ?
OR mpn LIKE ?
OR manufacturer LIKE ?
OR description LIKE ?
ORDER BY ipn
LIMIT 100
"""
search_param = f'%{search_query}%'
cursor.execute(sql, (search_param, search_param, search_param, search_param))
else:
# No search query - return first 100 parts
sql = """
SELECT ipn, mpn, manufacturer, description
FROM parts
ORDER BY ipn
LIMIT 100
"""
cursor.execute(sql)
rows = cursor.fetchall()
parts = []
for row in rows:
parts.append({
'ipn': row.ipn if row.ipn else '',
'mpn': row.mpn if row.mpn else '',
'manufacturer': row.manufacturer if row.manufacturer else '',
'description': row.description if row.description else ''
})
cursor.close()
conn.close()
emit('library_search_results', {'parts': parts, 'count': len(parts)})
except Exception as e:
import traceback
emit('library_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
# ---------------------------------------------------------------------------
# BOM Generation
# ---------------------------------------------------------------------------
@socketio.on('generate_bom')
def handle_generate_bom():
try:
manager = get_variant_manager()
if not manager:
emit('bom_error', {'error': 'No project loaded'})
return
schematic_file = app_args.get('Schematic File', '')
board_file = app_args.get('Board File', '')
project_name = app_args.get('Project Name', 'project')
project_dir = app_args.get('Project Dir', '')
if not schematic_file:
emit('bom_error', {'error': 'No schematic file specified'})
return
# Get active variant and DNP parts
active_variant = manager.get_active_variant()
dnp_uuids = manager.get_dnp_parts(active_variant)
emit('bom_status', {'status': 'Generating BOMs...'})
# Call BOM generator script
bom_script_path = os.path.join(os.path.dirname(__file__), 'bom_generator.py')
# Build command with optional PCB file
cmd = [sys.executable, bom_script_path, schematic_file, project_name, active_variant, json.dumps(dnp_uuids)]
if board_file:
cmd.append(board_file)
result = subprocess.run(
cmd,
capture_output=True,
text=True
)
if result.returncode == 0:
# Create ZIP of all BOMs
output_dir = project_dir if project_dir else os.path.dirname(schematic_file)
base_name = f"{project_name}_{active_variant}"
zip_filename = f"{base_name}_BOMs.zip"
zip_path = os.path.join(output_dir, zip_filename)
with zipfile.ZipFile(zip_path, 'w') as zipf:
# Add all BOM files
bom_files = [
f"{base_name}_BOM.xlsx",
f"{base_name}_Not_Populated.csv",
f"{base_name}_BOM_Top.xlsx",
f"{base_name}_BOM_Bottom.xlsx"
]
for bom_file in bom_files:
full_path = os.path.join(output_dir, bom_file)
if os.path.exists(full_path):
zipf.write(full_path, bom_file)
emit('bom_complete', {'path': zip_path, 'filename': zip_filename})
else:
emit('bom_error', {'error': f'BOM generation failed: {result.stderr}'})
except Exception as e:
import traceback
emit('bom_error', {'error': f'{str(e)}\n\nTraceback:\n{traceback.format_exc()}'})
def shutdown_server():
print("Server stopped")
os._exit(0)