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:
330
app.py
330
app.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user