131 lines
4.6 KiB
Python
Executable File
131 lines
4.6 KiB
Python
Executable File
"""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."""
|