Skip to content

Commit 78434cc

Browse files
committed
v1.21.0: USB port scanning improvements and report fixes
1 parent e0d0f65 commit 78434cc

File tree

12 files changed

+271
-59
lines changed

12 files changed

+271
-59
lines changed

.cursor/rules/app.mdc

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
description: BR Equipment Control App development rules
3+
globs: ["**/*.py"]
4+
alwaysApply: true
5+
---
6+
7+
# App Development Rules
8+
9+
## Logging and Debug Output
10+
11+
- **NEVER use `print()` for user-facing debug output** - print() goes to stdout which is not visible in the app
12+
- **ALWAYS use `log_to_terminal()` for debug messages** - this displays in the app's built-in terminal
13+
- Import with: `from src.logging.terminal import log_to_terminal`
14+
- Usage: `log_to_terminal("message", shared_gui_refs)` or `log_to_terminal("message", self.gui_refs)`
15+
- For modules without access to `shared_gui_refs`, return error details to the caller for logging
16+
17+
## Error Handling
18+
19+
- Always surface meaningful error messages to the user via `log_to_terminal()`
20+
- Include relevant context (file paths, actual error messages) in error output
21+
- Don't silently swallow exceptions - log them to the app terminal

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.
44

55
## [Unreleased]
66

7+
## [1.21.0] - 2025-01-24
8+
9+
### Added
10+
- **Jinja2 Dependency**: Added explicit `jinja2>=3.0.0` requirement for HTML report template rendering
11+
12+
### Changed
13+
- **USB Port Scanning**: Background scanner now pre-fetches serial ports every 3 seconds, eliminating "Scanning ports..." delays in connection menus
14+
- **USB Connection Menu**: Added "Refresh Ports" option and shows "No ports detected" when empty
15+
- **GUI Queue Polling**: Reduced from 100ms to 50ms for faster response to device messages
16+
17+
### Fixed
18+
- **Report Filename Sanitization**: Serial numbers containing invalid characters (like "N/A") no longer create subdirectories - invalid filename characters are now replaced with dashes
19+
- **Report Error Messages**: Now shows actual Python import errors when report modules fail to load, instead of generic "not found" messages
20+
- **CSV File Sync on Windows**: Data logger now calls `fsync` before closing files to ensure Windows flushes data to disk before report generation
21+
- **GUI Thread Safety**: Script completion callbacks now properly schedule Tkinter updates on the main thread, preventing potential race conditions
22+
- **Report Debug Logging**: Enhanced logging for troubleshooting report generation (CSV paths, file locks, handler calls)
23+
724
## [1.20.0] - 2025-11-28
825

926
### Added

_version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "1.20.0"
1+
__version__ = "1.21.0"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"folders": [
3+
{
4+
"path": "."
5+
},
6+
{
7+
"path": "../pressboi"
8+
},
9+
{
10+
"path": "../gantry"
11+
}
12+
],
13+
"settings": {}
14+
}

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# Runtime dependencies
22
pyserial>=3.5 # For USB serial communication
33
psutil>=5.9.0 # For system stats (uptime, etc.)
4+
jinja2>=3.0.0 # For report generation (HTML templates)
45

56
# Note: Barcode/QR scanner support uses keyboard input emulation
67
# Most USB/Bluetooth scanners work as HID devices, no extra dependencies needed

src/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1342,7 +1342,9 @@ def process_gui_queue(self):
13421342
except Empty:
13431343
pass # Queue is empty, do nothing
13441344
finally:
1345-
self.root.after(100, self.process_gui_queue)
1345+
# Poll every 50ms for responsive DONE message handling
1346+
# (reduced from 100ms to minimize delay between device response and UI update)
1347+
self.root.after(50, self.process_gui_queue)
13461348

13471349
def monitor_panel_state(self):
13481350
"""

src/device/manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,8 @@ def get_report_handler(self, device_name, report_name):
212212
report_name: Name of the report (without device prefix)
213213
214214
Returns:
215-
Callable handler function or None if not found
215+
tuple: (handler, error_message) - handler is the callable function or None,
216+
error_message explains why handler is None (or None if successful)
216217
"""
217218
return self.registry.get_report_handler(device_name, report_name)
218219

src/device/panel.py

Lines changed: 87 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from tkinter import ttk
33
import re
44
import time
5+
import threading
56
from src import theme
67
from src.script import SCRIPT_COMMANDS
78
from src.comms import devices_lock
@@ -221,6 +222,14 @@ def __init__(self, parent, script_editor_widget, device_manager, **kwargs):
221222
self.after(1000, self._update_connection_status)
222223
# Then start periodic refresh every 2 seconds
223224
self.after(2000, self._schedule_status_refresh)
225+
226+
# Port scanning state - pre-fetch ports in background to avoid "Scanning ports..." issue
227+
self._cached_serial_ports = []
228+
self._port_scan_lock = threading.Lock()
229+
self._port_scan_timer_id = None
230+
231+
# Start background port scanner
232+
self._start_port_scanner()
224233

225234
def _on_text_scroll(self, first, last):
226235
"""Handle scrollbar visibility based on content overflow."""
@@ -332,6 +341,74 @@ def _schedule_status_refresh(self):
332341
# Schedule next refresh in 2 seconds
333342
self.after(2000, self._schedule_status_refresh)
334343

344+
def _start_port_scanner(self):
345+
"""Start background timer to keep serial port cache fresh."""
346+
def scan_ports():
347+
try:
348+
from src.comms import serial
349+
ports = serial.list_serial_ports()
350+
with self._port_scan_lock:
351+
self._cached_serial_ports = ports
352+
except Exception as e:
353+
print(f"[PORT_SCAN] Error scanning ports: {e}")
354+
finally:
355+
# Schedule next scan in 3 seconds (using main thread)
356+
try:
357+
if self.winfo_exists():
358+
self._port_scan_timer_id = self.after(3000, self._schedule_port_scan)
359+
except Exception:
360+
pass # Widget might be destroyed
361+
362+
# Initial scan in background thread
363+
threading.Thread(target=scan_ports, daemon=True).start()
364+
365+
def _schedule_port_scan(self):
366+
"""Schedule the next port scan in a background thread."""
367+
def scan_ports():
368+
try:
369+
from src.comms import serial
370+
ports = serial.list_serial_ports()
371+
with self._port_scan_lock:
372+
self._cached_serial_ports = ports
373+
except Exception as e:
374+
print(f"[PORT_SCAN] Error scanning ports: {e}")
375+
finally:
376+
# Schedule next scan in 3 seconds (using main thread)
377+
try:
378+
if self.winfo_exists():
379+
self._port_scan_timer_id = self.after(3000, self._schedule_port_scan)
380+
except Exception:
381+
pass # Widget might be destroyed
382+
383+
threading.Thread(target=scan_ports, daemon=True).start()
384+
385+
def _refresh_ports(self):
386+
"""Manually refresh port list (called from context menu)."""
387+
def do_refresh():
388+
try:
389+
from src.comms import serial
390+
ports = serial.list_serial_ports()
391+
with self._port_scan_lock:
392+
self._cached_serial_ports = ports
393+
# Log refresh
394+
print(f"[PORT_SCAN] Manual refresh: {len(ports)} ports found")
395+
except Exception as e:
396+
print(f"[PORT_SCAN] Error during manual refresh: {e}")
397+
398+
threading.Thread(target=do_refresh, daemon=True).start()
399+
400+
def destroy(self):
401+
"""Clean up resources when the widget is destroyed."""
402+
# Cancel the background port scanner timer
403+
if hasattr(self, '_port_scan_timer_id') and self._port_scan_timer_id is not None:
404+
try:
405+
self.after_cancel(self._port_scan_timer_id)
406+
except Exception:
407+
pass # Timer might already be cancelled
408+
409+
# Call parent destroy
410+
super().destroy()
411+
335412
def _update_connection_status(self):
336413
"""Update only the connection status indicators without refreshing entire panel."""
337414
try:
@@ -1061,43 +1138,23 @@ def on_text_right_click(self, event):
10611138
activebackground=theme.PRIMARY_ACCENT,
10621139
activeforeground=theme.FG_COLOR)
10631140

1064-
# List available serial ports (use cached list to avoid blocking)
1065-
from src.comms import serial
1066-
# Use cached port list if available (prevents freeze when USB devices plugged/unplugged)
1067-
if hasattr(self, '_cached_serial_ports') and self._cached_serial_ports is not None:
1068-
ports = self._cached_serial_ports
1069-
else:
1070-
# First time or cache expired - show "Scanning..." and fetch in background
1071-
usb_submenu.add_command(label="Scanning ports...", state='disabled')
1072-
ports = []
1073-
1074-
# Fetch ports in background thread to avoid blocking UI
1075-
def fetch_ports_async():
1076-
try:
1077-
import threading
1078-
def get_ports():
1079-
ports_list = serial.list_serial_ports()
1080-
# Cache for 5 seconds
1081-
self._cached_serial_ports = ports_list
1082-
self._cached_ports_time = time.time()
1083-
# Refresh cache every 5 seconds
1084-
def clear_cache():
1085-
if hasattr(self, '_cached_ports_time'):
1086-
if time.time() - self._cached_ports_time > 5:
1087-
self._cached_serial_ports = None
1088-
self.after(5000, clear_cache)
1089-
thread = threading.Thread(target=get_ports, daemon=True)
1090-
thread.start()
1091-
except Exception as e:
1092-
print(f"[ERROR] Failed to fetch serial ports: {e}")
1093-
fetch_ports_async()
1141+
# Use pre-cached port list (background scanner keeps it fresh)
1142+
# This avoids the "Scanning ports..." race condition
1143+
with self._port_scan_lock:
1144+
ports = list(self._cached_serial_ports) # Copy to avoid race conditions
10941145

10951146
if ports:
10961147
for port, description in ports:
10971148
usb_submenu.add_command(
10981149
label=f"{port} - {description}",
10991150
command=lambda p=port, d=device_name: self.set_connection_usb(d, p)
11001151
)
1152+
else:
1153+
usb_submenu.add_command(label="No ports detected", state='disabled')
1154+
1155+
# Add separator and refresh option
1156+
usb_submenu.add_separator()
1157+
usb_submenu.add_command(label="Refresh Ports", command=self._refresh_ports)
11011158

11021159
connection_submenu.add_cascade(label="USB Serial", menu=usb_submenu)
11031160

src/device/registry.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,26 @@ def _load_module_from_path(self, device_name, module_name, device_path):
6767
def _load_package_from_path(self, device_name, package_name, package_path):
6868
"""
6969
Load a Python package (folder with __init__.py) from a specific path.
70-
Returns the package module if successful, None otherwise.
70+
71+
Returns:
72+
tuple: (module, error_message) - module is None if loading failed,
73+
error_message is None if loading succeeded.
7174
"""
7275
init_file = os.path.join(package_path, '__init__.py')
7376
if not os.path.exists(init_file):
74-
return None
77+
return None, f"Package '{package_name}' not found (missing __init__.py)"
7578

7679
try:
7780
# Create a unique module name to avoid conflicts
7881
full_module_name = f"devices.{device_name}.{package_name}"
7982

83+
# Clear any cached versions of this module and its submodules
84+
# This ensures we always load fresh from disk
85+
modules_to_remove = [key for key in sys.modules.keys()
86+
if key == full_module_name or key.startswith(f"{full_module_name}.")]
87+
for mod_name in modules_to_remove:
88+
del sys.modules[mod_name]
89+
8090
# Load the package from __init__.py
8191
spec = importlib.util.spec_from_file_location(
8292
full_module_name,
@@ -87,13 +97,15 @@ def _load_package_from_path(self, device_name, package_name, package_path):
8797
module = importlib.util.module_from_spec(spec)
8898
sys.modules[full_module_name] = module
8999
spec.loader.exec_module(module)
90-
return module
100+
return module, None
91101
except Exception as e:
92-
self.log(f"Error loading package {package_name} for {device_name}: {e}")
102+
error_msg = str(e)
103+
self.log(f"Error loading package {package_name} for {device_name}: {error_msg}")
93104
import traceback
94105
traceback.print_exc()
106+
return None, error_msg
95107

96-
return None
108+
return None, f"Failed to create module spec for '{package_name}'"
97109

98110
def discover_devices(self):
99111
"""
@@ -209,9 +221,10 @@ def _load_device_from_path(self, device_name, definition_path):
209221

210222
# Load reports module if reports/ folder exists (as a package with __init__.py)
211223
reports_module = None
224+
reports_load_error = None
212225
reports_folder = os.path.join(definition_path, 'reports')
213226
if os.path.isdir(reports_folder):
214-
reports_module = self._load_package_from_path(device_name, 'reports', reports_folder)
227+
reports_module, reports_load_error = self._load_package_from_path(device_name, 'reports', reports_folder)
215228

216229
# Load warnings from JSON (always from definition folder)
217230
warnings_data = {}
@@ -242,6 +255,9 @@ def _load_device_from_path(self, device_name, definition_path):
242255
'parser': parser_module,
243256
'reports': reports_module,
244257
},
258+
'module_errors': {
259+
'reports': reports_load_error,
260+
},
245261
}
246262

247263
return True
@@ -463,21 +479,26 @@ def get_report_handler(self, device_name, report_name):
463479
report_name: Name of the report (without device prefix)
464480
465481
Returns:
466-
Callable handler function or None if not found
482+
tuple: (handler, error_message) - handler is the callable function or None,
483+
error_message explains why handler is None (or None if successful)
467484
"""
468485
device = self.devices.get(device_name)
469486
if not device:
470-
return None
487+
return None, f"Device '{device_name}' not found"
471488

472489
reports_module = device.get('modules', {}).get('reports')
473490
if not reports_module:
474-
return None
491+
# Check if there was a load error
492+
load_error = device.get('module_errors', {}).get('reports')
493+
if load_error:
494+
return None, f"Reports module failed to load: {load_error}"
495+
return None, f"Reports module not available for device '{device_name}'"
475496

476497
# Look for the handler function in the reports module
477498
# The handler name is typically the report_name itself (e.g., generate_press_report)
478499
handler = getattr(reports_module, report_name, None)
479500
if callable(handler):
480-
return handler
501+
return handler, None
481502

482-
return None
503+
return None, f"Report handler '{report_name}' not found in reports module"
483504

src/logging/data.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
import csv
99
import os
10+
import re
1011
import sys
1112
import threading
1213
import datetime
@@ -69,6 +70,9 @@ def _get_unique_filename(self, base_filename: str) -> str:
6970
# Apply job, op, and serial number formatting (handles <job>/<op>/<serial> placeholders)
7071
base_filename = format_filename_with_serial(base_filename, serial, job, op)
7172

73+
# Clean up multiple consecutive spaces (from empty template tags) and trim
74+
base_filename = re.sub(r'\s+', ' ', base_filename).strip()
75+
7276
if not base_filename.endswith('.csv'):
7377
base_filename += '.csv'
7478

@@ -261,6 +265,7 @@ def timer_callback():
261265
freq_info = f" at {frequency} Hz" if frequency else " (synced with telemetry)"
262266
log_msg = f"Started logging {var_count} variable(s){freq_info} to {os.path.basename(filepath)}"
263267
log_to_terminal(log_msg, self.shared_gui_refs)
268+
log_to_terminal(f"[DATA LOGGER] Full path: {filepath}", self.shared_gui_refs)
264269

265270
print(f"[TRACE] start_logging complete, returning success")
266271
return (True, log_msg, filepath)
@@ -329,8 +334,13 @@ def _close_log_file(self, filepath: str):
329334
"""
330335
log_info = self.active_logs.get(filepath)
331336
if log_info:
332-
# Close file handle if open
337+
# Close file handle if open - flush and sync to ensure Windows writes to disk
333338
if log_info['file_handle']:
339+
try:
340+
log_info['file_handle'].flush()
341+
os.fsync(log_info['file_handle'].fileno())
342+
except Exception:
343+
pass # Ignore errors during sync (file might already be closed)
334344
log_info['file_handle'].close()
335345

336346
# Cancel timer if present

0 commit comments

Comments
 (0)