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:
Thomas Ales
2026-07-28 11:21:29 -05:00
parent afe33249d1
commit 44febe34b8
8 changed files with 833 additions and 444 deletions
+87 -65
View File
@@ -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."""