|
2 | 2 | from tkinter import ttk |
3 | 3 | import re |
4 | 4 | import time |
| 5 | +import threading |
5 | 6 | from src import theme |
6 | 7 | from src.script import SCRIPT_COMMANDS |
7 | 8 | from src.comms import devices_lock |
@@ -221,6 +222,14 @@ def __init__(self, parent, script_editor_widget, device_manager, **kwargs): |
221 | 222 | self.after(1000, self._update_connection_status) |
222 | 223 | # Then start periodic refresh every 2 seconds |
223 | 224 | 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() |
224 | 233 |
|
225 | 234 | def _on_text_scroll(self, first, last): |
226 | 235 | """Handle scrollbar visibility based on content overflow.""" |
@@ -332,6 +341,74 @@ def _schedule_status_refresh(self): |
332 | 341 | # Schedule next refresh in 2 seconds |
333 | 342 | self.after(2000, self._schedule_status_refresh) |
334 | 343 |
|
| 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 | + |
335 | 412 | def _update_connection_status(self): |
336 | 413 | """Update only the connection status indicators without refreshing entire panel.""" |
337 | 414 | try: |
@@ -1061,43 +1138,23 @@ def on_text_right_click(self, event): |
1061 | 1138 | activebackground=theme.PRIMARY_ACCENT, |
1062 | 1139 | activeforeground=theme.FG_COLOR) |
1063 | 1140 |
|
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 |
1094 | 1145 |
|
1095 | 1146 | if ports: |
1096 | 1147 | for port, description in ports: |
1097 | 1148 | usb_submenu.add_command( |
1098 | 1149 | label=f"{port} - {description}", |
1099 | 1150 | command=lambda p=port, d=device_name: self.set_connection_usb(d, p) |
1100 | 1151 | ) |
| 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) |
1101 | 1158 |
|
1102 | 1159 | connection_submenu.add_cascade(label="USB Serial", menu=usb_submenu) |
1103 | 1160 |
|
|
0 commit comments