"""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-side helpers ─────────────────────────────────────────────────── def _work_pending(self) -> bool: """True if the operator is waiting on something. A poll is one queue item that can hold the port for hundreds of milliseconds; a button pressed during one should not have to wait for the whole sweep to finish. A poll that checks this between reads gives the port up and picks the rest up next time round. """ return not self._cmd_q.empty() # ── 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."""