b473aac6aa
Three things were making the panel feel slow, all of them waiting rather than working. The trailing quiet window is waited out once per query, so it set the pace of the whole sweep: at 50 ms that was 400 ms of a 590 ms poll spent listening to silence. A reply streams at the baud rate — ~1 ms between bytes, no measurable gap between its lines — and the deadline restarts on every line read, so 20 ms outlasts the gap it exists for twenty times over. A tail that still arrives late is caught by _discard_input(), which is what actually protects the next query. Replayed against the rig transcript, a sweep goes from 590 ms to 356 ms. The poll interval was 1 s on top of that, so a value could be 1.6 s stale. At 0.3 s the panel comes round about every 0.65 s. And a poll held the port for its whole sweep, so a button pressed during one waited for all eight queries. _work_pending() on the worker base lets a poll drop what is left as soon as the operator queues something: a click now waits ~135 ms for the register read in progress instead of the full sweep, and the rest is picked up next time round. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
131 lines
4.6 KiB
Python
131 lines
4.6 KiB
Python
"""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."""
|