Phase 5: shared worker base, self-rescheduling polls, driver robustness
gui/qt_workers.py — one QueueWorker base replaces the per-device command queue + dispatch + signal boilerplate. The loop blocks on the queue instead of waking 10-20x/second forever (test_idle_worker_does_not_spin asserts an idle worker burns ~no CPU). PollingQueueWorker adds self-rescheduling polling: the next poll is queued only after the previous finishes, so a device slower than the interval can't accumulate a backlog (test_polling_never_overlaps_or_backs_up). Helios responsiveness — the concrete bug that motivated the above: a free running 1 s QTimer queued a status poll that took ~2 s, so the queue grew for as long as the panel stayed connected. - helios_laser._query reads until the CR terminator instead of sleeping a fixed 0.05 + 0.2 s per query - one _query_int() helper replaces five copies of parse-with-logging - polling is now driven by the worker; HeliosWindow's QTimer is gone - dropped __del__, which disabled the laser and wrote to the serial port from the garbage collector at an unpredictable time helios_test_app.py — the worker was moveToThread'd but every call site invoked its methods directly, so all serial I/O (including the sleeps) ran on the GUI thread; Query All froze the UI for ~2 s. Calls now go through a queued signal to a pyqtSlot. Also: connect/disconnect cycles leaked a QThread + worker + 9 connections each time; 16 copies of the not-connected guard collapse to _require_connection(); the Query Power button called a method that has never existed (AttributeError popup) and is now disabled and documented in KNOWN_ISSUES. DCBiasImageWidget preallocates its image and uses set_data/set_clim, so the live preview stops rebuilding the array and the whole artist tree per row (O(rows^2) over a scan). bbd20x: connect() now raises when no bays respond instead of reporting success on the wrong port; disconnect() joins with a timeout so a wedged reader can't hang shutdown; one _channel_for() helper replaces four copy-pasted axis mappings; hardcoded travel limits become TRAVEL_MM; the joke error strings are gone. gui/widgets.py adds the shared ConnectionBar / PortSelector / bounded LogConsole / StatusGrid for the test benches to adopt. 65 tests passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+87
-65
@@ -12,7 +12,7 @@ from PyQt6.QtWidgets import (
|
||||
QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QSpinBox,
|
||||
QMessageBox, QTabWidget, QTextEdit
|
||||
)
|
||||
from PyQt6.QtCore import QThread, pyqtSignal, QObject
|
||||
from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot, QObject
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from hardware.helios_laser import HeliosLaser, PulseMode
|
||||
@@ -140,6 +140,20 @@ class LaserWorker(QObject):
|
||||
super().__init__()
|
||||
self.laser = laser
|
||||
|
||||
@pyqtSlot(str, object)
|
||||
def invoke(self, method_name: str, args: tuple):
|
||||
"""Run one of this worker's methods on the worker thread.
|
||||
|
||||
Reached through a queued signal connection, so the serial I/O (and
|
||||
its blocking reads) stays off the GUI thread. Calling the methods
|
||||
directly, as this app used to, executes them in the caller's thread
|
||||
and freezes the UI for the duration.
|
||||
"""
|
||||
try:
|
||||
getattr(self, method_name)(*args)
|
||||
except Exception as exc:
|
||||
self.operation_complete.emit(False, str(exc))
|
||||
|
||||
def set_frequency(self, freq: int):
|
||||
try:
|
||||
success = self.laser.set_frequency_hz(freq)
|
||||
@@ -197,15 +211,13 @@ class LaserWorker(QObject):
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def query_power(self):
|
||||
try:
|
||||
power = self.laser.get_power_mw()
|
||||
if power is not None:
|
||||
self.power_updated.emit(power)
|
||||
self.operation_complete.emit(True, f"Power: {power:.2f} mW")
|
||||
else:
|
||||
self.operation_complete.emit(False, "Failed to query power")
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
# HeliosLaser has no power query: the driver README advertises
|
||||
# get_power_mw(), but no such method exists and the protocol
|
||||
# mnemonic for an output-power read is not documented anywhere in
|
||||
# this repo. This used to raise AttributeError into a popup.
|
||||
# See KNOWN_ISSUES.md — needs the command from the Helios manual.
|
||||
self.operation_complete.emit(
|
||||
False, "Power query is not implemented (no known protocol command)")
|
||||
|
||||
def query_enabled(self):
|
||||
try:
|
||||
@@ -289,6 +301,9 @@ class LaserWorker(QObject):
|
||||
class HeliosTestApp(QMainWindow):
|
||||
"""Main application window for Helios laser testing."""
|
||||
|
||||
# Dispatches a worker method name + args across the thread boundary.
|
||||
worker_call = pyqtSignal(str, object)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.laser = HeliosLaser()
|
||||
@@ -586,6 +601,10 @@ class HeliosTestApp(QMainWindow):
|
||||
power_layout = QHBoxLayout()
|
||||
|
||||
btn_get_power = QPushButton("Query Power")
|
||||
btn_get_power.setEnabled(False)
|
||||
btn_get_power.setToolTip(
|
||||
"Not implemented: no documented Helios protocol command for "
|
||||
"output power (see KNOWN_ISSUES.md).")
|
||||
btn_get_power.clicked.connect(self.on_query_power)
|
||||
power_layout.addWidget(btn_get_power)
|
||||
|
||||
@@ -697,6 +716,9 @@ class HeliosTestApp(QMainWindow):
|
||||
self.worker = LaserWorker(self.laser)
|
||||
self.worker_thread = QThread()
|
||||
self.worker.moveToThread(self.worker_thread)
|
||||
# Queued (cross-thread) connection: the worker's methods run on
|
||||
# the worker thread, not on whichever thread emits.
|
||||
self.worker_call.connect(self.worker.invoke)
|
||||
self.worker.operation_complete.connect(self.on_operation_complete)
|
||||
self.worker.frequency_updated.connect(self.on_frequency_updated)
|
||||
self.worker.current_updated.connect(self.on_current_updated)
|
||||
@@ -713,11 +735,27 @@ class HeliosTestApp(QMainWindow):
|
||||
else:
|
||||
QMessageBox.critical(self, "Connection Error", f"Failed to connect to {port}")
|
||||
|
||||
def _require_connection(self) -> bool:
|
||||
"""Warn and return False when no laser is connected."""
|
||||
if self.laser.is_connected and self.worker is not None:
|
||||
return True
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return False
|
||||
|
||||
def _call_worker(self, method_name: str, *args):
|
||||
"""Run a worker method on the worker thread via a queued signal."""
|
||||
if self.worker is not None:
|
||||
self.worker_call.emit(method_name, args)
|
||||
|
||||
def disconnect_laser(self):
|
||||
"""Disconnect from the laser."""
|
||||
if self.worker_thread:
|
||||
self.worker_thread.quit()
|
||||
self.worker_thread.wait()
|
||||
self.worker_thread.wait(2000)
|
||||
# Drop both references: a reconnect used to leak the previous
|
||||
# QThread, worker, and all nine signal connections.
|
||||
self.worker = None
|
||||
self.worker_thread = None
|
||||
|
||||
self.laser.disconnect()
|
||||
self.lbl_status.setText("Status: Disconnected")
|
||||
@@ -729,137 +767,121 @@ class HeliosTestApp(QMainWindow):
|
||||
|
||||
def on_set_frequency(self):
|
||||
"""Set the laser frequency."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
freq = self.spin_frequency.value()
|
||||
self.worker.set_frequency(freq)
|
||||
self._call_worker("set_frequency", freq)
|
||||
|
||||
def on_query_frequency(self):
|
||||
"""Query the laser frequency."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.query_frequency()
|
||||
self._call_worker("query_frequency")
|
||||
|
||||
def on_set_current(self):
|
||||
"""Set the laser current."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
current = self.spin_current.value()
|
||||
self.worker.set_current(current)
|
||||
self._call_worker("set_current", current)
|
||||
|
||||
def on_query_current(self):
|
||||
"""Query the laser current."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.query_current()
|
||||
self._call_worker("query_current")
|
||||
|
||||
def on_set_mode(self):
|
||||
"""Set the laser pulse mode."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
mode = self.combo_mode.currentData()
|
||||
self.worker.set_pulse_mode(mode)
|
||||
self._call_worker("set_pulse_mode", mode)
|
||||
|
||||
def on_enable_laser(self):
|
||||
"""Enable the laser."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.set_laser_enable(True)
|
||||
self._call_worker("set_laser_enable", True)
|
||||
|
||||
def on_disable_laser(self):
|
||||
"""Disable the laser."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.set_laser_enable(False)
|
||||
self._call_worker("set_laser_enable", False)
|
||||
|
||||
def on_query_enabled(self):
|
||||
"""Query if laser is enabled."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.query_enabled()
|
||||
self._call_worker("query_enabled")
|
||||
|
||||
def on_query_power(self):
|
||||
"""Query the laser output power."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.query_power()
|
||||
self._call_worker("query_power")
|
||||
|
||||
def on_query_all(self):
|
||||
"""Query all laser parameters."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.query_serials()
|
||||
self.worker.query_frequency()
|
||||
self.worker.query_current()
|
||||
self.worker.query_power()
|
||||
self.worker.query_enabled()
|
||||
self.worker.query_status_registers()
|
||||
self.worker.query_remote_enable()
|
||||
self._call_worker("query_serials")
|
||||
self._call_worker("query_frequency")
|
||||
self._call_worker("query_current")
|
||||
self._call_worker("query_power")
|
||||
self._call_worker("query_enabled")
|
||||
self._call_worker("query_status_registers")
|
||||
self._call_worker("query_remote_enable")
|
||||
|
||||
def on_query_status(self):
|
||||
"""Query LER/LCE/CCE status registers."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
self.worker.query_status_registers()
|
||||
self._call_worker("query_status_registers")
|
||||
|
||||
def on_reset_faults(self):
|
||||
"""Send the fault reset sequence."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
self.worker.do_reset_faults()
|
||||
self._call_worker("do_reset_faults")
|
||||
|
||||
def on_query_remote_enable(self):
|
||||
"""Query the remote enable (LRE) state."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
self.worker.query_remote_enable()
|
||||
self._call_worker("query_remote_enable")
|
||||
|
||||
def on_set_remote_enable(self, enable: bool):
|
||||
"""Set the remote enable (LRE) state."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
self.worker.set_remote_enable(enable)
|
||||
self._call_worker("set_remote_enable", enable)
|
||||
|
||||
def on_ler_reset(self):
|
||||
"""Send LER 0 only."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
self.worker.do_ler_reset()
|
||||
self._call_worker("do_ler_reset")
|
||||
|
||||
def on_send_raw(self):
|
||||
"""Send the raw command from the terminal input."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
cmd = self.le_raw_cmd.text().strip()
|
||||
if not cmd:
|
||||
return
|
||||
self.worker.send_raw(cmd)
|
||||
self._call_worker("send_raw", cmd)
|
||||
|
||||
def on_raw_response(self, cmd: str, response: str):
|
||||
"""Display raw TX/RX pair in the terminal log."""
|
||||
|
||||
Reference in New Issue
Block a user