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:
@@ -0,0 +1,118 @@
|
||||
"""Shared Qt worker base for hardware that must be driven off the GUI thread.
|
||||
|
||||
Every device worker in this project was the same shape: a command queue, a
|
||||
`while running: get(timeout=…)` loop, an if/elif dispatch, and a standard
|
||||
connected/disconnected/failed signal trio. The timeout-poll versions woke
|
||||
10–20 times a second forever, even with nothing to do; this base blocks on
|
||||
the queue instead and wakes only when there is work.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import queue
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot
|
||||
|
||||
_STOP = object()
|
||||
|
||||
|
||||
class QueueWorker(QObject):
|
||||
"""Base for a device worker living on its own QThread.
|
||||
|
||||
Subclasses register handlers in ``self._handlers`` (command name →
|
||||
callable) and call ``self._enqueue(name, **kwargs)`` from the GUI thread.
|
||||
Override ``_on_stop`` to release hardware when the loop exits.
|
||||
"""
|
||||
|
||||
connected = pyqtSignal()
|
||||
disconnected = pyqtSignal()
|
||||
connection_failed = pyqtSignal(str)
|
||||
error_occurred = pyqtSignal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._cmd_q: queue.Queue = queue.Queue()
|
||||
self._handlers: dict[str, callable] = {}
|
||||
self._running = False
|
||||
self.is_connected = False
|
||||
|
||||
# ── Command submission (GUI thread) ───────────────────────────────────────
|
||||
|
||||
def _enqueue(self, cmd_type: str, **kwargs):
|
||||
self._cmd_q.put((cmd_type, kwargs))
|
||||
|
||||
def stop_worker(self):
|
||||
self._cmd_q.put(_STOP)
|
||||
|
||||
# ── Worker loop ───────────────────────────────────────────────────────────
|
||||
|
||||
@pyqtSlot()
|
||||
def run(self):
|
||||
self._running = True
|
||||
while self._running:
|
||||
item = self._cmd_q.get() # blocks — no idle wake-ups
|
||||
if item is _STOP:
|
||||
break
|
||||
cmd_type, kwargs = item
|
||||
handler = self._handlers.get(cmd_type)
|
||||
if handler is None:
|
||||
self.error_occurred.emit(f"Unknown command: {cmd_type}")
|
||||
continue
|
||||
try:
|
||||
handler(**kwargs)
|
||||
except Exception as exc:
|
||||
self.error_occurred.emit(str(exc))
|
||||
self._running = False
|
||||
self._on_stop()
|
||||
|
||||
def _on_stop(self):
|
||||
"""Release hardware when the loop exits. Override as needed."""
|
||||
|
||||
|
||||
class PollingQueueWorker(QueueWorker):
|
||||
"""QueueWorker that also polls the device on an interval.
|
||||
|
||||
The poll is self-rescheduling: the next one is queued only after the
|
||||
previous finishes, so a device slower than the interval can never
|
||||
accumulate a backlog of stale poll commands (which is exactly what the
|
||||
old free-running QTimer did to the Helios laser).
|
||||
"""
|
||||
|
||||
POLL_CMD = "_poll"
|
||||
|
||||
def __init__(self, poll_interval_s: float = 1.0):
|
||||
super().__init__()
|
||||
self._poll_interval_s = poll_interval_s
|
||||
self._polling = False
|
||||
self._handlers[self.POLL_CMD] = self._poll_and_reschedule
|
||||
|
||||
def start_polling(self):
|
||||
if not self._polling:
|
||||
self._polling = True
|
||||
self._enqueue(self.POLL_CMD)
|
||||
|
||||
def stop_polling(self):
|
||||
self._polling = False
|
||||
|
||||
def _poll_and_reschedule(self):
|
||||
if not self._polling or not self.is_connected:
|
||||
self._polling = False
|
||||
return
|
||||
try:
|
||||
self._poll_once()
|
||||
finally:
|
||||
if self._polling and self.is_connected:
|
||||
self._schedule_next_poll()
|
||||
|
||||
def _schedule_next_poll(self):
|
||||
# A timer thread rather than a sleep here, so the worker stays
|
||||
# responsive to commands during the interval.
|
||||
import threading
|
||||
t = threading.Timer(self._poll_interval_s,
|
||||
lambda: self._enqueue(self.POLL_CMD))
|
||||
t.daemon = True
|
||||
t.start()
|
||||
self._poll_timer = t
|
||||
|
||||
def _poll_once(self):
|
||||
"""Read device state and emit updates. Implemented by subclasses."""
|
||||
Reference in New Issue
Block a user