diff --git a/gui/qt_workers.py b/gui/qt_workers.py index f7719ca..3f6100c 100644 --- a/gui/qt_workers.py +++ b/gui/qt_workers.py @@ -44,6 +44,18 @@ class QueueWorker(QObject): 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() diff --git a/hardware/helios_laser.py b/hardware/helios_laser.py index f992f3f..7a80473 100755 --- a/hardware/helios_laser.py +++ b/hardware/helios_laser.py @@ -107,7 +107,15 @@ class HeliosLaser: # as a "Bit 15..0" string, and the reads around it timing out on a # leading blank line. So: match a reply to the command that asked for # it, and read off the rest of it before the next command goes out. - TRAILING_QUIET_S = 0.05 # the line counts as idle after this long + # How long the line has to stay silent before a reply counts as over. + # It is waited out once per query, so it sets the pace of the whole + # status poll: at 50 ms that was 400 ms of a 590 ms poll spent listening + # to nothing. A reply streams at the baud rate — ~1 ms between bytes, + # no measurable gap between its lines — and the deadline restarts on + # every line, so 20 ms is twenty times the gap it has to outlast. A + # tail that still arrives late is caught by _discard_input() rather + # than by waiting longer here. + TRAILING_QUIET_S = 0.02 MAX_REPLY_LINES = 8 def _discard_input(self): @@ -269,7 +277,7 @@ class HeliosLaser: # it worked, and the panel's next status poll reads back the old value — # which looks exactly like the GUI refusing the operator's number. SET_RETRIES = 3 - SET_SETTLE_S = 0.05 # let the controller store it before reading + SET_SETTLE_S = 0.02 # let the controller store it before reading def _write_verified(self, mnemonic: str, value: int) -> bool: """Write `value` to `mnemonic`, and confirm the controller took it. diff --git a/sc3_aui_app.py b/sc3_aui_app.py index f7ea655..ced8060 100755 --- a/sc3_aui_app.py +++ b/sc3_aui_app.py @@ -343,7 +343,9 @@ class HeliosWorker(PollingQueueWorker): qswitch_temp_updated = pyqtSignal(float) status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None) - POLL_INTERVAL_S = 1.0 + # A full sweep is eight queries, ~360 ms of port time. The interval is + # the gap between sweeps, so the panel refreshes about every 0.65 s. + POLL_INTERVAL_S = 0.3 def __init__(self): super().__init__(poll_interval_s=self.POLL_INTERVAL_S) @@ -416,17 +418,26 @@ class HeliosWorker(PollingQueueWorker): def _poll_once(self): """Read the full status set. Individual reads are allowed to fail - (a timed-out register shouldn't suppress the rest of the panel).""" + (a timed-out register shouldn't suppress the rest of the panel). + + Abandoned as soon as the operator queues something: the rest of the + sweep is worth less than a button that responds now, and the next + poll will pick it up. + """ if not self._laser or not self._laser.is_connected: return try: self.status_registers_updated.emit(*self._laser.get_status_registers()) except Exception: pass + if self._work_pending(): + return try: self.enabled_updated.emit(self._laser.is_laser_enabled()) except Exception: pass + if self._work_pending(): + return # A failed LDS read must not leave the last good value on screen # looking live: that is indistinguishable from a setpoint that # refuses to move, which is the failure this panel exists to show. @@ -444,6 +455,8 @@ class HeliosWorker(PollingQueueWorker): (self._laser.get_power_stage_temp_c, self.pstage_temp_updated), (self._laser.get_qswitch_temp_c, self.qswitch_temp_updated), ): + if self._work_pending(): + return try: value = getter() if value is not None: diff --git a/tests/test_qt_workers.py b/tests/test_qt_workers.py index cec6c17..5254bd3 100644 --- a/tests/test_qt_workers.py +++ b/tests/test_qt_workers.py @@ -137,6 +137,55 @@ def test_polling_never_overlaps_or_backs_up(qapp): assert queued <= 1, f"{queued} stale polls queued up" +class _YieldingPoller(PollingQueueWorker): + """A poll made of several reads that gives up as soon as work arrives.""" + + def __init__(self): + super().__init__(poll_interval_s=0.02) + self.reads = 0 + self.handled = [] + self.is_connected = True + self._handlers["click"] = self._click + + def _click(self, value): + self.handled.append(value) + + def _poll_once(self): + for _ in range(6): + if self._work_pending(): + return + time.sleep(0.02) + self.reads += 1 + + +def test_a_queued_command_interrupts_a_poll(qapp): + """A button pressed mid-poll should not wait out the whole sweep. + + The Helios sweep is eight serial queries; before this, a command queued + behind one waited for every last read to finish. + """ + w = _YieldingPoller() + t = threading.Thread(target=w.run, daemon=True) + t.start() + w.start_polling() + time.sleep(0.03) # a poll is now in progress + + pressed = time.monotonic() + w._enqueue("click", value="set current") + deadline = pressed + 2.0 + while not w.handled and time.monotonic() < deadline: + time.sleep(0.002) + waited = time.monotonic() - pressed + + w.stop_polling() + w.stop_worker() + t.join(timeout=5) + + assert w.handled == ["set current"] + # A full sweep is 6 x 20 ms; the command must not have waited for it. + assert waited < 0.08, f"command waited {waited * 1000:.0f} ms for the poll" + + def test_stop_polling_halts_the_cycle(qapp): w = _Poller() t = threading.Thread(target=w.run, daemon=True)