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:
@@ -17,6 +17,20 @@ If they appear, the setters need a stop-live/apply/restart sequence
|
||||
(re-create the helper around the two call sites in
|
||||
[uc480_camera.py](hardware/uc480_camera.py)).
|
||||
|
||||
## Helios: no output-power query
|
||||
|
||||
`docs/hardware/HELIOS_DRIVER_README.md` documents `driver.get_power_mw()`,
|
||||
but `HeliosLaser` has no such method and no output-power mnemonic appears
|
||||
anywhere in this repo's protocol notes. `helios_test_app.py` called it
|
||||
anyway and raised `AttributeError` into a popup; the button is now disabled
|
||||
and the handler reports the gap instead.
|
||||
|
||||
**Bench check:** find the power-read command in the Helios manual (the
|
||||
other reads are three-letter mnemonics like `LDO`, `LDS`, `LTA`). If one
|
||||
exists, add `get_power_mw()` to `hardware/helios_laser.py` using
|
||||
`_query_int`, then re-enable the button. If it doesn't, delete the Power
|
||||
Monitoring group from the test app and fix the README.
|
||||
|
||||
## Genesis laser: forked protocol implementations disagree
|
||||
|
||||
`hardware/genesis_core.py` and the reference implementation
|
||||
|
||||
@@ -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."""
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
"""Widgets shared by the main app and the per-device test benches.
|
||||
|
||||
Each of these was hand-rolled several times across the apps, with slightly
|
||||
different behaviour every time (only one log console bounded its buffer,
|
||||
only one port picker sorted by device type).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt6.QtCore import Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QFont
|
||||
from PyQt6.QtWidgets import (
|
||||
QComboBox, QGroupBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton,
|
||||
QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from hardware.serial_util import scored_ports
|
||||
|
||||
|
||||
def set_toggle(btn, checked: bool, text: str, enabled: bool = True):
|
||||
"""Update a checkable button without re-triggering its toggled signal."""
|
||||
btn.blockSignals(True)
|
||||
btn.setChecked(checked)
|
||||
btn.setText(text)
|
||||
btn.setEnabled(enabled)
|
||||
btn.blockSignals(False)
|
||||
|
||||
|
||||
class PortSelector(QWidget):
|
||||
"""Serial port combo + refresh button, likeliest device first."""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
row = QHBoxLayout(self)
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
self.combo = QComboBox()
|
||||
self.combo.setMinimumWidth(220)
|
||||
refresh = QPushButton("⟳")
|
||||
refresh.setFixedWidth(30)
|
||||
refresh.setToolTip("Rescan serial ports")
|
||||
refresh.clicked.connect(self.refresh)
|
||||
row.addWidget(self.combo, stretch=1)
|
||||
row.addWidget(refresh)
|
||||
self.refresh()
|
||||
|
||||
def refresh(self):
|
||||
"""Repopulate the list, preserving the current selection if present."""
|
||||
current = self.current_port()
|
||||
self.combo.clear()
|
||||
for device, label in scored_ports():
|
||||
self.combo.addItem(label, device)
|
||||
if self.combo.count() == 0:
|
||||
self.combo.addItem("(no serial ports found)", None)
|
||||
elif current:
|
||||
idx = self.combo.findData(current)
|
||||
if idx >= 0:
|
||||
self.combo.setCurrentIndex(idx)
|
||||
|
||||
def current_port(self) -> str | None:
|
||||
return self.combo.currentData()
|
||||
|
||||
def set_port(self, device: str):
|
||||
idx = self.combo.findData(device)
|
||||
if idx >= 0:
|
||||
self.combo.setCurrentIndex(idx)
|
||||
|
||||
|
||||
class ConnectionBar(QGroupBox):
|
||||
"""Port picker + connect toggle + status label.
|
||||
|
||||
Emits connect_requested(port) / disconnect_requested(); the owner drives
|
||||
the state back through on_connected/on_disconnected/on_failed so the
|
||||
button can never disagree with the hardware.
|
||||
"""
|
||||
|
||||
connect_requested = pyqtSignal(str)
|
||||
disconnect_requested = pyqtSignal()
|
||||
|
||||
def __init__(self, title: str = "Connection", parent=None):
|
||||
super().__init__(title, parent)
|
||||
layout = QVBoxLayout(self)
|
||||
self.port_selector = PortSelector()
|
||||
layout.addWidget(self.port_selector)
|
||||
|
||||
row = QHBoxLayout()
|
||||
self.toggle = QPushButton("Connect")
|
||||
self.toggle.setCheckable(True)
|
||||
self.toggle.toggled.connect(self._on_toggled)
|
||||
self.status = QLabel("Disconnected")
|
||||
self.status.setStyleSheet("font-weight: bold;")
|
||||
row.addWidget(self.toggle)
|
||||
row.addWidget(self.status, stretch=1)
|
||||
layout.addLayout(row)
|
||||
|
||||
def _on_toggled(self, checked: bool):
|
||||
if checked:
|
||||
port = self.port_selector.current_port()
|
||||
if not port:
|
||||
set_toggle(self.toggle, False, "Connect")
|
||||
self.status.setText("No port selected")
|
||||
return
|
||||
self.toggle.setText("Connecting…")
|
||||
self.toggle.setEnabled(False)
|
||||
self.connect_requested.emit(port)
|
||||
else:
|
||||
self.disconnect_requested.emit()
|
||||
|
||||
def on_connected(self, detail: str = "Connected"):
|
||||
set_toggle(self.toggle, True, "Disconnect")
|
||||
self.status.setText(detail)
|
||||
self.status.setStyleSheet("font-weight: bold; color: green;")
|
||||
|
||||
def on_disconnected(self, detail: str = "Disconnected"):
|
||||
set_toggle(self.toggle, False, "Connect")
|
||||
self.status.setText(detail)
|
||||
self.status.setStyleSheet("font-weight: bold;")
|
||||
|
||||
def on_failed(self, message: str):
|
||||
set_toggle(self.toggle, False, "Connect")
|
||||
self.status.setText(f"Failed: {message}")
|
||||
self.status.setStyleSheet("font-weight: bold; color: red;")
|
||||
|
||||
|
||||
class LogConsole(QWidget):
|
||||
"""Bounded, monospace, auto-scrolling log view with a Clear button.
|
||||
|
||||
The block cap is the point: unbounded QTextEdit logs grew for the whole
|
||||
session in every app that hand-rolled one.
|
||||
"""
|
||||
|
||||
KINDS = {"tx": "→", "rx": "←", "info": "●", "err": "!"}
|
||||
|
||||
def __init__(self, max_blocks: int = 2000, parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.view = QPlainTextEdit()
|
||||
self.view.setReadOnly(True)
|
||||
self.view.setMaximumBlockCount(max_blocks)
|
||||
font = QFont("Menlo")
|
||||
font.setStyleHint(QFont.StyleHint.Monospace)
|
||||
font.setPointSize(11)
|
||||
self.view.setFont(font)
|
||||
layout.addWidget(self.view)
|
||||
|
||||
row = QHBoxLayout()
|
||||
row.addStretch(1)
|
||||
clear = QPushButton("Clear")
|
||||
clear.clicked.connect(self.view.clear)
|
||||
row.addWidget(clear)
|
||||
layout.addLayout(row)
|
||||
|
||||
def log(self, message: str, kind: str = "info"):
|
||||
self.view.appendPlainText(f"{self.KINDS.get(kind, '●')} {message}")
|
||||
self.view.verticalScrollBar().setValue(
|
||||
self.view.verticalScrollBar().maximum())
|
||||
|
||||
|
||||
class StatusGrid(QWidget):
|
||||
"""Label/value rows with consistent ok/warn/error colouring."""
|
||||
|
||||
_COLORS = {"ok": "green", "warn": "#b8860b", "error": "red", "": ""}
|
||||
|
||||
def __init__(self, fields: list[str], parent=None):
|
||||
super().__init__(parent)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self._values: dict[str, QLabel] = {}
|
||||
for name in fields:
|
||||
row = QHBoxLayout()
|
||||
label = QLabel(f"{name}:")
|
||||
value = QLabel("—")
|
||||
value.setAlignment(Qt.AlignmentFlag.AlignRight
|
||||
| Qt.AlignmentFlag.AlignVCenter)
|
||||
row.addWidget(label)
|
||||
row.addWidget(value, stretch=1)
|
||||
layout.addLayout(row)
|
||||
self._values[name] = value
|
||||
|
||||
def set(self, name: str, text: str, state: str = ""):
|
||||
label = self._values.get(name)
|
||||
if label is None:
|
||||
return
|
||||
label.setText(text)
|
||||
color = self._COLORS.get(state, "")
|
||||
label.setStyleSheet(f"color: {color};" if color else "")
|
||||
+57
-125
@@ -115,37 +115,36 @@ class HeliosLaser:
|
||||
return False
|
||||
|
||||
def _query(self, command: str) -> Optional[str]:
|
||||
"""
|
||||
Send a query and read response.
|
||||
"""Send a query and return the value from its response.
|
||||
|
||||
Args:
|
||||
command: ASCII query command (without CR or ?)
|
||||
|
||||
Returns:
|
||||
Response value string or None if error
|
||||
Reads until the CR terminator rather than sleeping a fixed interval:
|
||||
the device usually answers in a few ms, so the old unconditional
|
||||
0.05 + 0.2 s cost ~250 ms per query and made an 8-query status poll
|
||||
take ~2 s — longer than the 1 s interval that scheduled it.
|
||||
"""
|
||||
try:
|
||||
# Clear any pending data in the buffer
|
||||
# Clear any stale bytes so a previous timed-out reply can't be
|
||||
# mistaken for this command's response.
|
||||
self.serial.reset_input_buffer()
|
||||
time.sleep(0.05)
|
||||
|
||||
if not self._send_command(command):
|
||||
return None
|
||||
|
||||
time.sleep(0.2) # Give device time to respond
|
||||
|
||||
response = self.serial.read_until(b'\r').decode('ascii', errors='replace').strip()
|
||||
if not response:
|
||||
logger.warning(f"Query '{command}' timed out after {self.timeout}s")
|
||||
return None
|
||||
logger.debug(f"Query '{command}' response: {response}")
|
||||
|
||||
# Helios format: "COMMAND = VALUE UNIT"
|
||||
# Extract just the value part
|
||||
# Helios format: "COMMAND = VALUE UNIT" — take just the value
|
||||
if '=' in response:
|
||||
parts = response.split('=')
|
||||
if len(parts) >= 2:
|
||||
value_part = parts[1].strip()
|
||||
# Remove unit suffix if present (e.g., "ns", "mA", "mW")
|
||||
value = value_part.split()[0]
|
||||
return value
|
||||
# Strip the unit suffix if present (e.g. "ns", "mA", "mW")
|
||||
fields = value_part.split()
|
||||
if fields:
|
||||
return fields[0]
|
||||
|
||||
return response
|
||||
|
||||
@@ -153,6 +152,17 @@ class HeliosLaser:
|
||||
logger.error(f"Failed to read response for '{command}': {e}")
|
||||
return None
|
||||
|
||||
def _query_int(self, command: str) -> Optional[int]:
|
||||
"""Query a value that should parse as an int; None if absent/unparseable."""
|
||||
raw = self._query(command)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
logger.error(f"Query '{command}' returned non-integer {raw!r}")
|
||||
return None
|
||||
|
||||
def set_frequency_hz(self, frequency: int) -> bool:
|
||||
"""
|
||||
Set laser pulse frequency in Hz.
|
||||
@@ -228,60 +238,24 @@ class HeliosLaser:
|
||||
return success
|
||||
|
||||
def is_laser_enabled(self) -> bool:
|
||||
"""
|
||||
Check if laser is currently enabled.
|
||||
|
||||
Returns:
|
||||
True if laser is enabled
|
||||
"""
|
||||
response = self._query("LDO")
|
||||
if response:
|
||||
try:
|
||||
return int(response) == 1
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for LDO: {response}")
|
||||
return False
|
||||
"""True if laser emission is currently enabled."""
|
||||
return self._query_int("LDO") == 1
|
||||
|
||||
def get_frequency_hz(self) -> Optional[int]:
|
||||
"""
|
||||
Get current laser frequency in Hz.
|
||||
|
||||
Returns:
|
||||
Frequency in Hz or None if error
|
||||
"""
|
||||
response = self._query("LDF")
|
||||
if response:
|
||||
try:
|
||||
period_ns = int(response)
|
||||
return int(1e9 / period_ns)
|
||||
except (ValueError, ZeroDivisionError):
|
||||
logger.error(f"Invalid response for LDF: {response}")
|
||||
return None
|
||||
"""Current laser frequency in Hz, or None on error."""
|
||||
period_ns = self._query_int("LDF")
|
||||
if not period_ns:
|
||||
return None
|
||||
return int(1e9 / period_ns)
|
||||
|
||||
def get_current_ma(self) -> Optional[int]:
|
||||
"""
|
||||
Get current pump diode current in mA.
|
||||
|
||||
Returns:
|
||||
Current in mA or None if error
|
||||
"""
|
||||
response = self._query("LDS")
|
||||
if response:
|
||||
try:
|
||||
return int(response)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for LDS: {response}")
|
||||
return None
|
||||
"""Current pump diode current in mA, or None on error."""
|
||||
return self._query_int("LDS")
|
||||
|
||||
def _query_millicelsius(self, command: str) -> Optional[float]:
|
||||
"""Query a temperature register (returns milli-°C) and convert to °C."""
|
||||
response = self._query(command)
|
||||
if response:
|
||||
try:
|
||||
return int(response) / 1000.0
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for {command}: {response}")
|
||||
return None
|
||||
"""Query a temperature register (milli-°C) and convert to °C."""
|
||||
value = self._query_int(command)
|
||||
return None if value is None else value / 1000.0
|
||||
|
||||
def get_diode_temp_c(self) -> Optional[float]:
|
||||
"""Diode temperature in °C (LTA, 5000–50000 milli-°C)."""
|
||||
@@ -296,46 +270,22 @@ class HeliosLaser:
|
||||
return self._query_millicelsius("EOA")
|
||||
|
||||
def get_controller_serial(self) -> Optional[str]:
|
||||
"""
|
||||
Get controller serial number.
|
||||
|
||||
Returns:
|
||||
Serial number string or None if error
|
||||
"""
|
||||
"""Controller serial number, or None on error."""
|
||||
return self._query("CSR")
|
||||
|
||||
def get_head_serial(self) -> Optional[str]:
|
||||
"""
|
||||
Get laser head serial number.
|
||||
|
||||
Returns:
|
||||
Serial number string or None if error
|
||||
"""
|
||||
"""Laser head serial number, or None on error."""
|
||||
return self._query("HSR")
|
||||
|
||||
def get_status_registers(self) -> tuple:
|
||||
"""Query the LER, LCE and CCE status registers.
|
||||
|
||||
Each is a bitmask (sum of flags); non-zero means active faults,
|
||||
cleared with reset_faults(). Returns (ler, lce, cce), any of which
|
||||
is None if that register could not be read.
|
||||
"""
|
||||
Query LER, LCE, and CCE status registers.
|
||||
|
||||
Each register is a bitmask (sum of flags). Non-zero values indicate
|
||||
active faults. Reset with reset_faults().
|
||||
|
||||
Returns:
|
||||
Tuple of (ler, lce, cce) as ints, or None for each on error.
|
||||
"""
|
||||
def _read_reg(cmd):
|
||||
resp = self._query(cmd)
|
||||
if resp is not None:
|
||||
try:
|
||||
return int(resp)
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for {cmd}: {resp}")
|
||||
return None
|
||||
|
||||
ler = _read_reg("LER")
|
||||
lce = _read_reg("LCE")
|
||||
cce = _read_reg("CCE")
|
||||
return (ler, lce, cce)
|
||||
return (self._query_int("LER"), self._query_int("LCE"),
|
||||
self._query_int("CCE"))
|
||||
|
||||
def reset_faults(self) -> bool:
|
||||
"""
|
||||
@@ -357,38 +307,19 @@ class HeliosLaser:
|
||||
return ok
|
||||
|
||||
def get_remote_enable(self) -> Optional[bool]:
|
||||
"""
|
||||
Query the remote enable state (LRE - activates utility connector pin 8).
|
||||
|
||||
Returns:
|
||||
True if remote enable is active, False if not, None on error
|
||||
"""
|
||||
response = self._query("LRE")
|
||||
if response is not None:
|
||||
try:
|
||||
return int(response) == 1
|
||||
except ValueError:
|
||||
logger.error(f"Invalid response for LRE: {response}")
|
||||
return None
|
||||
"""Remote enable state (LRE — utility connector pin 8); None on error."""
|
||||
value = self._query_int("LRE")
|
||||
return None if value is None else value == 1
|
||||
|
||||
def send_raw_command(self, command: str) -> Optional[str]:
|
||||
"""
|
||||
Send a raw command string and return the raw response.
|
||||
|
||||
Useful for diagnostics. Sends *command* + CR, waits briefly,
|
||||
then reads whatever the device returns (up to the first CR or timeout).
|
||||
|
||||
Returns:
|
||||
Raw response string (decoded, stripped) or None on error.
|
||||
"""
|
||||
"""Send a raw command and return the unparsed response (diagnostics)."""
|
||||
if not self.is_connected or not self.serial:
|
||||
logger.error("Not connected to laser")
|
||||
return None
|
||||
try:
|
||||
self.serial.reset_input_buffer()
|
||||
time.sleep(0.05)
|
||||
self.serial.write((command + '\r').encode('ascii'))
|
||||
time.sleep(0.3)
|
||||
if not self._send_command(command):
|
||||
return None
|
||||
raw = self.serial.read_until(b'\r')
|
||||
if not raw:
|
||||
raw = self.serial.read(self.serial.in_waiting)
|
||||
@@ -410,6 +341,7 @@ class HeliosLaser:
|
||||
command = f"LRE {1 if enable else 0}"
|
||||
return self._send_command(command)
|
||||
|
||||
def __del__(self):
|
||||
"""Destructor - ensure cleanup"""
|
||||
self.disconnect()
|
||||
# No __del__: it used to call disconnect(), which disables the laser and
|
||||
# writes to the serial port from the garbage collector at an
|
||||
# unpredictable time (including interpreter shutdown, when the port may
|
||||
# already be torn down). Callers close the driver explicitly.
|
||||
|
||||
+48
-40
@@ -12,12 +12,18 @@ from .apt_messages import APTProtocol
|
||||
from .serial_comms import SerialSnooper
|
||||
|
||||
|
||||
# APT bay addresses for the two stage axes
|
||||
AXIS_X_ADDR = 0x21
|
||||
AXIS_Y_ADDR = 0x22
|
||||
|
||||
|
||||
class ThorlabsServoDriver():
|
||||
# These are specific to the MLS203-1
|
||||
# change for a different application
|
||||
counts_per_mm = 20000
|
||||
accel_scaling = 13.744
|
||||
velocity_scaling = 134217.73
|
||||
TRAVEL_MM = (110.0, 75.0) # usable travel per channel (X, Y)
|
||||
|
||||
def __init__(self):
|
||||
self.am_connected = False
|
||||
@@ -83,10 +89,27 @@ class ThorlabsServoDriver():
|
||||
pass
|
||||
|
||||
if not self.bays_present:
|
||||
print(" [WARN] No bays detected!")
|
||||
# Fail loudly: reporting success with no bays made connecting to
|
||||
# the wrong port look like it worked, and every later command
|
||||
# then silently went nowhere.
|
||||
self.disconnect()
|
||||
raise RuntimeError(
|
||||
f"No BBD202 bays responded on {port}. Check the port, the "
|
||||
f"controller power, and that no other process holds the device."
|
||||
)
|
||||
|
||||
self.am_connected = True
|
||||
|
||||
@staticmethod
|
||||
def _channel_for(axis):
|
||||
"""Map an APT axis address to this driver's 0-based channel index."""
|
||||
if axis == AXIS_X_ADDR:
|
||||
return 0
|
||||
if axis == AXIS_Y_ADDR:
|
||||
return 1
|
||||
raise ValueError(f"Unknown axis address 0x{axis:02x} "
|
||||
f"(expected 0x{AXIS_X_ADDR:02x} or 0x{AXIS_Y_ADDR:02x})")
|
||||
|
||||
# ── Worker threads ───────────────────────────────────────────
|
||||
|
||||
def _tx_worker(self):
|
||||
@@ -223,15 +246,18 @@ class ThorlabsServoDriver():
|
||||
APTProtocol.build_message(0x0002, destination=addr,
|
||||
source=0x01))
|
||||
time.sleep(0.2) # let TX worker flush them out
|
||||
# Stop all worker loops, then wait for threads to exit
|
||||
# Stop all worker loops, then wait for threads to exit. Joins
|
||||
# are bounded: disconnect() runs from closeEvent, and a wedged
|
||||
# reader must not hang application shutdown.
|
||||
self.am_listening = False
|
||||
self.serial_snoop.stop()
|
||||
self._rx_thread.join()
|
||||
self._tx_thread.join()
|
||||
self._poll_thread.join()
|
||||
self.serial_snoop.join()
|
||||
for thread in (self._rx_thread, self._tx_thread, self._poll_thread,
|
||||
self.serial_snoop):
|
||||
if thread is not None:
|
||||
thread.join(timeout=2.0)
|
||||
# Close port only after all threads are done
|
||||
self.serial_snoop.close()
|
||||
self.am_connected = False
|
||||
|
||||
# ── State update handlers ────────────────────────────────────
|
||||
|
||||
@@ -305,13 +331,8 @@ class ThorlabsServoDriver():
|
||||
toggle_enabled_state(axis) - enables the axis if disabled. disables
|
||||
if enabled. not much more to it.
|
||||
'''
|
||||
if axis == 0x21:
|
||||
ch = 0
|
||||
elif axis == 0x22:
|
||||
ch = 1
|
||||
else:
|
||||
raise ValueError("I don't know that axis!")
|
||||
# get the old state and flip it like a sample
|
||||
ch = self._channel_for(axis)
|
||||
# Read the cached state and invert it
|
||||
new_state = not self.am_enabled[ch]
|
||||
self.send_message(0x0210, chan_ident=1,
|
||||
enable_state=0x01 if new_state else 0x02,
|
||||
@@ -323,12 +344,7 @@ class ThorlabsServoDriver():
|
||||
power up. Default timeout is 60s, but 20-30s is fine as well if
|
||||
you're in that much of a hurry.
|
||||
'''
|
||||
if axis == 0x21:
|
||||
ch = 0
|
||||
elif axis == 0x22:
|
||||
ch = 1
|
||||
else:
|
||||
raise ValueError("I don't know that axis!")
|
||||
ch = self._channel_for(axis)
|
||||
|
||||
self.send_and_wait(0x0443, timeout=timeout, retries=0, chan_ident=1,
|
||||
destination=axis, source=0x01)
|
||||
@@ -340,11 +356,11 @@ class ThorlabsServoDriver():
|
||||
moves the specified axis a specified distance in mm.
|
||||
Timeout defaults to ten seconds.
|
||||
'''
|
||||
# sanity check
|
||||
if axis == 0x21 and abs(distance_in_mm) > 110.0:
|
||||
raise ValueError("You can't move farther than the stage is long.")
|
||||
elif axis == 0x22 and abs(distance_in_mm) > 75.0:
|
||||
raise ValueError("You can't move farther than the stage is wide.")
|
||||
travel = self.TRAVEL_MM[self._channel_for(axis)]
|
||||
if abs(distance_in_mm) > travel:
|
||||
raise ValueError(
|
||||
f"Relative move of {distance_in_mm:.3f} mm exceeds the "
|
||||
f"{travel:g} mm travel of this axis.")
|
||||
|
||||
_distance_in_encoder = int(round(distance_in_mm * self.counts_per_mm))
|
||||
self.send_and_wait(0x0448, timeout=timeout, chan_ident=1,
|
||||
@@ -358,10 +374,12 @@ class ThorlabsServoDriver():
|
||||
moves the specified axis to an absolute position in mm.
|
||||
Timeout defaults to ten seconds.
|
||||
'''
|
||||
if axis == 0x21 and (position_in_mm < 0.0 or position_in_mm > 110.0):
|
||||
raise ValueError("Position out of range for X axis (0-110 mm).")
|
||||
elif axis == 0x22 and (position_in_mm < 0.0 or position_in_mm > 75.0):
|
||||
raise ValueError("Position out of range for Y axis (0-75 mm).")
|
||||
ch = self._channel_for(axis)
|
||||
travel = self.TRAVEL_MM[ch]
|
||||
if not 0.0 <= position_in_mm <= travel:
|
||||
raise ValueError(
|
||||
f"Position {position_in_mm:.3f} mm is out of range for the "
|
||||
f"{'XY'[ch]} axis (0-{travel:g} mm).")
|
||||
|
||||
_position_in_encoder = int(round(position_in_mm * self.counts_per_mm))
|
||||
self.send_and_wait(0x0453, timeout=timeout, chan_ident=1,
|
||||
@@ -377,12 +395,7 @@ class ThorlabsServoDriver():
|
||||
for the specified axis. Returns a dict with keys:
|
||||
min_velocity (mm/s), acceleration (mm/s2), max_velocity (mm/s)
|
||||
'''
|
||||
if axis == 0x21:
|
||||
ch = 0
|
||||
elif axis == 0x22:
|
||||
ch = 1
|
||||
else:
|
||||
raise ValueError("I don't know that axis!")
|
||||
ch = self._channel_for(axis)
|
||||
|
||||
result = self.send_and_wait(0x0414, timeout=timeout, chan_ident=1,
|
||||
zero_this=0x00, destination=axis,
|
||||
@@ -405,12 +418,7 @@ class ThorlabsServoDriver():
|
||||
Values are in mm/s and mm/s2 respectively. Any parameter
|
||||
left as None keeps its current value.
|
||||
'''
|
||||
if axis == 0x21:
|
||||
ch = 0
|
||||
elif axis == 0x22:
|
||||
ch = 1
|
||||
else:
|
||||
raise ValueError("I don't know that axis!")
|
||||
ch = self._channel_for(axis)
|
||||
|
||||
# Only query current params if we need to fill in a missing value
|
||||
if max_velocity is None or acceleration is None:
|
||||
|
||||
+87
-65
@@ -12,7 +12,7 @@ from PyQt6.QtWidgets import (
|
||||
QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QSpinBox,
|
||||
QMessageBox, QTabWidget, QTextEdit
|
||||
)
|
||||
from PyQt6.QtCore import QThread, pyqtSignal, QObject
|
||||
from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot, QObject
|
||||
from PyQt6.QtGui import QFont
|
||||
|
||||
from hardware.helios_laser import HeliosLaser, PulseMode
|
||||
@@ -140,6 +140,20 @@ class LaserWorker(QObject):
|
||||
super().__init__()
|
||||
self.laser = laser
|
||||
|
||||
@pyqtSlot(str, object)
|
||||
def invoke(self, method_name: str, args: tuple):
|
||||
"""Run one of this worker's methods on the worker thread.
|
||||
|
||||
Reached through a queued signal connection, so the serial I/O (and
|
||||
its blocking reads) stays off the GUI thread. Calling the methods
|
||||
directly, as this app used to, executes them in the caller's thread
|
||||
and freezes the UI for the duration.
|
||||
"""
|
||||
try:
|
||||
getattr(self, method_name)(*args)
|
||||
except Exception as exc:
|
||||
self.operation_complete.emit(False, str(exc))
|
||||
|
||||
def set_frequency(self, freq: int):
|
||||
try:
|
||||
success = self.laser.set_frequency_hz(freq)
|
||||
@@ -197,15 +211,13 @@ class LaserWorker(QObject):
|
||||
self.operation_complete.emit(False, str(e))
|
||||
|
||||
def query_power(self):
|
||||
try:
|
||||
power = self.laser.get_power_mw()
|
||||
if power is not None:
|
||||
self.power_updated.emit(power)
|
||||
self.operation_complete.emit(True, f"Power: {power:.2f} mW")
|
||||
else:
|
||||
self.operation_complete.emit(False, "Failed to query power")
|
||||
except Exception as e:
|
||||
self.operation_complete.emit(False, str(e))
|
||||
# HeliosLaser has no power query: the driver README advertises
|
||||
# get_power_mw(), but no such method exists and the protocol
|
||||
# mnemonic for an output-power read is not documented anywhere in
|
||||
# this repo. This used to raise AttributeError into a popup.
|
||||
# See KNOWN_ISSUES.md — needs the command from the Helios manual.
|
||||
self.operation_complete.emit(
|
||||
False, "Power query is not implemented (no known protocol command)")
|
||||
|
||||
def query_enabled(self):
|
||||
try:
|
||||
@@ -289,6 +301,9 @@ class LaserWorker(QObject):
|
||||
class HeliosTestApp(QMainWindow):
|
||||
"""Main application window for Helios laser testing."""
|
||||
|
||||
# Dispatches a worker method name + args across the thread boundary.
|
||||
worker_call = pyqtSignal(str, object)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.laser = HeliosLaser()
|
||||
@@ -586,6 +601,10 @@ class HeliosTestApp(QMainWindow):
|
||||
power_layout = QHBoxLayout()
|
||||
|
||||
btn_get_power = QPushButton("Query Power")
|
||||
btn_get_power.setEnabled(False)
|
||||
btn_get_power.setToolTip(
|
||||
"Not implemented: no documented Helios protocol command for "
|
||||
"output power (see KNOWN_ISSUES.md).")
|
||||
btn_get_power.clicked.connect(self.on_query_power)
|
||||
power_layout.addWidget(btn_get_power)
|
||||
|
||||
@@ -697,6 +716,9 @@ class HeliosTestApp(QMainWindow):
|
||||
self.worker = LaserWorker(self.laser)
|
||||
self.worker_thread = QThread()
|
||||
self.worker.moveToThread(self.worker_thread)
|
||||
# Queued (cross-thread) connection: the worker's methods run on
|
||||
# the worker thread, not on whichever thread emits.
|
||||
self.worker_call.connect(self.worker.invoke)
|
||||
self.worker.operation_complete.connect(self.on_operation_complete)
|
||||
self.worker.frequency_updated.connect(self.on_frequency_updated)
|
||||
self.worker.current_updated.connect(self.on_current_updated)
|
||||
@@ -713,11 +735,27 @@ class HeliosTestApp(QMainWindow):
|
||||
else:
|
||||
QMessageBox.critical(self, "Connection Error", f"Failed to connect to {port}")
|
||||
|
||||
def _require_connection(self) -> bool:
|
||||
"""Warn and return False when no laser is connected."""
|
||||
if self.laser.is_connected and self.worker is not None:
|
||||
return True
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
return False
|
||||
|
||||
def _call_worker(self, method_name: str, *args):
|
||||
"""Run a worker method on the worker thread via a queued signal."""
|
||||
if self.worker is not None:
|
||||
self.worker_call.emit(method_name, args)
|
||||
|
||||
def disconnect_laser(self):
|
||||
"""Disconnect from the laser."""
|
||||
if self.worker_thread:
|
||||
self.worker_thread.quit()
|
||||
self.worker_thread.wait()
|
||||
self.worker_thread.wait(2000)
|
||||
# Drop both references: a reconnect used to leak the previous
|
||||
# QThread, worker, and all nine signal connections.
|
||||
self.worker = None
|
||||
self.worker_thread = None
|
||||
|
||||
self.laser.disconnect()
|
||||
self.lbl_status.setText("Status: Disconnected")
|
||||
@@ -729,137 +767,121 @@ class HeliosTestApp(QMainWindow):
|
||||
|
||||
def on_set_frequency(self):
|
||||
"""Set the laser frequency."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
freq = self.spin_frequency.value()
|
||||
self.worker.set_frequency(freq)
|
||||
self._call_worker("set_frequency", freq)
|
||||
|
||||
def on_query_frequency(self):
|
||||
"""Query the laser frequency."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.query_frequency()
|
||||
self._call_worker("query_frequency")
|
||||
|
||||
def on_set_current(self):
|
||||
"""Set the laser current."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
current = self.spin_current.value()
|
||||
self.worker.set_current(current)
|
||||
self._call_worker("set_current", current)
|
||||
|
||||
def on_query_current(self):
|
||||
"""Query the laser current."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.query_current()
|
||||
self._call_worker("query_current")
|
||||
|
||||
def on_set_mode(self):
|
||||
"""Set the laser pulse mode."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
mode = self.combo_mode.currentData()
|
||||
self.worker.set_pulse_mode(mode)
|
||||
self._call_worker("set_pulse_mode", mode)
|
||||
|
||||
def on_enable_laser(self):
|
||||
"""Enable the laser."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.set_laser_enable(True)
|
||||
self._call_worker("set_laser_enable", True)
|
||||
|
||||
def on_disable_laser(self):
|
||||
"""Disable the laser."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.set_laser_enable(False)
|
||||
self._call_worker("set_laser_enable", False)
|
||||
|
||||
def on_query_enabled(self):
|
||||
"""Query if laser is enabled."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.query_enabled()
|
||||
self._call_worker("query_enabled")
|
||||
|
||||
def on_query_power(self):
|
||||
"""Query the laser output power."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.query_power()
|
||||
self._call_worker("query_power")
|
||||
|
||||
def on_query_all(self):
|
||||
"""Query all laser parameters."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
|
||||
self.worker.query_serials()
|
||||
self.worker.query_frequency()
|
||||
self.worker.query_current()
|
||||
self.worker.query_power()
|
||||
self.worker.query_enabled()
|
||||
self.worker.query_status_registers()
|
||||
self.worker.query_remote_enable()
|
||||
self._call_worker("query_serials")
|
||||
self._call_worker("query_frequency")
|
||||
self._call_worker("query_current")
|
||||
self._call_worker("query_power")
|
||||
self._call_worker("query_enabled")
|
||||
self._call_worker("query_status_registers")
|
||||
self._call_worker("query_remote_enable")
|
||||
|
||||
def on_query_status(self):
|
||||
"""Query LER/LCE/CCE status registers."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
self.worker.query_status_registers()
|
||||
self._call_worker("query_status_registers")
|
||||
|
||||
def on_reset_faults(self):
|
||||
"""Send the fault reset sequence."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
self.worker.do_reset_faults()
|
||||
self._call_worker("do_reset_faults")
|
||||
|
||||
def on_query_remote_enable(self):
|
||||
"""Query the remote enable (LRE) state."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
self.worker.query_remote_enable()
|
||||
self._call_worker("query_remote_enable")
|
||||
|
||||
def on_set_remote_enable(self, enable: bool):
|
||||
"""Set the remote enable (LRE) state."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
self.worker.set_remote_enable(enable)
|
||||
self._call_worker("set_remote_enable", enable)
|
||||
|
||||
def on_ler_reset(self):
|
||||
"""Send LER 0 only."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
self.worker.do_ler_reset()
|
||||
self._call_worker("do_ler_reset")
|
||||
|
||||
def on_send_raw(self):
|
||||
"""Send the raw command from the terminal input."""
|
||||
if not self.laser.is_connected:
|
||||
QMessageBox.warning(self, "Error", "Not connected to laser")
|
||||
if not self._require_connection():
|
||||
return
|
||||
cmd = self.le_raw_cmd.text().strip()
|
||||
if not cmd:
|
||||
return
|
||||
self.worker.send_raw(cmd)
|
||||
self._call_worker("send_raw", cmd)
|
||||
|
||||
def on_raw_response(self, cmd: str, response: str):
|
||||
"""Display raw TX/RX pair in the terminal log."""
|
||||
|
||||
+150
-214
@@ -5,7 +5,6 @@ Loads sc3-aui-main.ui, sc3-aui-camera.ui, sc3-aui-scanprogress.ui via uic
|
||||
and wires up T3R, BBD202, oscilloscope, and camera hardware workers.
|
||||
"""
|
||||
|
||||
import queue
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
@@ -14,7 +13,7 @@ from threading import Thread
|
||||
|
||||
import numpy as np
|
||||
from PyQt6 import uic
|
||||
from PyQt6.QtCore import QObject, QThread, QTimer, Qt, pyqtSignal, pyqtSlot
|
||||
from PyQt6.QtCore import QObject, QThread, QTimer, Qt, pyqtSignal
|
||||
from PyQt6.QtGui import QImage, QPixmap
|
||||
from PyQt6.QtWidgets import (
|
||||
QApplication, QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
|
||||
@@ -38,6 +37,7 @@ from core.scan_resume import is_compatible, plan_resume
|
||||
from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
|
||||
from core.sras_format import SCAN_CHANNELS, SrasFile, plan_from_header
|
||||
from gui.qt_t3r import QtT3RAdapter
|
||||
from gui.qt_workers import PollingQueueWorker, QueueWorker
|
||||
from gui.scan_bridge import QtScanController
|
||||
from hardware.helios_laser import HeliosLaser
|
||||
from hardware.pybbd202 import AXIS_X, AXIS_Y, ThorlabsServoDriver
|
||||
@@ -53,7 +53,13 @@ BBD_DEFAULT_JOG_MM = 0.5 # default jog step for BBD202
|
||||
|
||||
|
||||
class DCBiasImageWidget(FigureCanvas):
|
||||
"""Live 2D DC-bias image: rows × frames, matching sras_viewer's compute_dc_image view."""
|
||||
"""Live 2D DC-bias image: rows × frames, matching the viewer's DC image.
|
||||
|
||||
The image buffer is allocated once per scan and updated in place. The
|
||||
previous version rebuilt the whole array and the entire matplotlib
|
||||
artist tree on every row, so the live preview got steadily slower as a
|
||||
scan progressed (O(rows²) work for one scan).
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None):
|
||||
fig = Figure(tight_layout=True)
|
||||
@@ -65,36 +71,59 @@ class DCBiasImageWidget(FigureCanvas):
|
||||
QSizePolicy.Policy.Expanding,
|
||||
)
|
||||
self.ax = fig.add_subplot(111)
|
||||
self._row_data: list[np.ndarray] = []
|
||||
self._img: np.ndarray | None = None
|
||||
self._im = None
|
||||
self.ax.set_title("DC Bias Preview (ADC counts)")
|
||||
self.ax.set_xlabel("Frame")
|
||||
self.ax.set_ylabel("Row")
|
||||
self.ax.text(0.5, 0.5, "Waiting for data…",
|
||||
transform=self.ax.transAxes, ha="center", va="center", color="gray")
|
||||
self.draw()
|
||||
|
||||
def add_row(self, row_index: int, values):
|
||||
"""Store per-frame DC mean values for row_index (1-based) and refresh the image."""
|
||||
idx = row_index - 1
|
||||
while len(self._row_data) <= idx:
|
||||
self._row_data.append(np.empty(0, dtype=np.float32))
|
||||
self._row_data[idx] = np.asarray(values, dtype=np.float32)
|
||||
|
||||
# Build rectangular image — pad shorter rows with NaN so they render distinctly
|
||||
max_len = max(len(r) for r in self._row_data)
|
||||
img = np.full((len(self._row_data), max_len), np.nan, dtype=np.float32)
|
||||
for i, row in enumerate(self._row_data):
|
||||
img[i, :len(row)] = row
|
||||
self._placeholder = None
|
||||
self._reset_axes()
|
||||
|
||||
def _reset_axes(self):
|
||||
self.ax.clear()
|
||||
self.ax.set_title("DC Bias Preview (ADC counts)")
|
||||
self.ax.set_xlabel("Frame")
|
||||
self.ax.set_ylabel("Row")
|
||||
self.ax.imshow(
|
||||
img, aspect="auto", origin="upper",
|
||||
cmap="viridis", interpolation="nearest",
|
||||
)
|
||||
self._placeholder = self.ax.text(
|
||||
0.5, 0.5, "Waiting for data…", transform=self.ax.transAxes,
|
||||
ha="center", va="center", color="gray")
|
||||
self._im = None
|
||||
self.draw_idle()
|
||||
|
||||
def begin_scan(self, n_rows: int, n_frames: int):
|
||||
"""Preallocate the image for a scan of this shape."""
|
||||
self._img = np.full((max(1, n_rows), max(1, n_frames)), np.nan,
|
||||
dtype=np.float32)
|
||||
self._reset_axes()
|
||||
|
||||
def add_row(self, row_index: int, values):
|
||||
"""Store per-frame DC means for row_index (1-based) and refresh."""
|
||||
row = np.asarray(values, dtype=np.float32)
|
||||
idx = row_index - 1
|
||||
|
||||
# Grow only if the scan turned out taller/wider than announced.
|
||||
if self._img is None:
|
||||
self.begin_scan(idx + 1, len(row))
|
||||
if idx >= self._img.shape[0] or len(row) > self._img.shape[1]:
|
||||
grown = np.full((max(idx + 1, self._img.shape[0]),
|
||||
max(len(row), self._img.shape[1])),
|
||||
np.nan, dtype=np.float32)
|
||||
grown[:self._img.shape[0], :self._img.shape[1]] = self._img
|
||||
self._img = grown
|
||||
self._im = None # extent changed; rebuild the artist once
|
||||
|
||||
self._img[idx, :len(row)] = row
|
||||
|
||||
if self._im is None:
|
||||
if self._placeholder is not None:
|
||||
self._placeholder.remove()
|
||||
self._placeholder = None
|
||||
self._im = self.ax.imshow(
|
||||
self._img, aspect="auto", origin="upper",
|
||||
cmap="viridis", interpolation="nearest",
|
||||
)
|
||||
else:
|
||||
self._im.set_data(self._img)
|
||||
finite = self._img[np.isfinite(self._img)]
|
||||
if finite.size:
|
||||
self._im.set_clim(float(finite.min()), float(finite.max()))
|
||||
self.draw_idle()
|
||||
|
||||
|
||||
@@ -103,61 +132,35 @@ BBD_JOG_ACCEL_MM_S2 = 50.0
|
||||
|
||||
# ── BBD202 worker ─────────────────────────────────────────────────────────────
|
||||
|
||||
class BBD202Worker(QObject):
|
||||
class BBD202Worker(PollingQueueWorker):
|
||||
"""Manages ThorlabsServoDriver (MLS203-1) in a worker thread."""
|
||||
connected = pyqtSignal()
|
||||
disconnected = pyqtSignal()
|
||||
connection_failed = pyqtSignal(str)
|
||||
position_updated = pyqtSignal(float, float) # x_mm, y_mm
|
||||
homed_status = pyqtSignal(bool, bool) # x_homed, y_homed
|
||||
error_occurred = pyqtSignal(str)
|
||||
homed_status = pyqtSignal(bool, bool) # x_homed, y_homed
|
||||
|
||||
POLL_INTERVAL_S = 0.2
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
|
||||
self.controller: ThorlabsServoDriver | None = None
|
||||
self._cmd_q: queue.Queue = queue.Queue()
|
||||
self._running = False
|
||||
self.is_connected = False
|
||||
self.scanning_active = False # pause polling during scan
|
||||
self.scanning_active = False # pause polling during a scan
|
||||
self._jog_step = BBD_DEFAULT_JOG_MM
|
||||
self._last_x: float | None = None
|
||||
self._last_y: float | None = None
|
||||
self._last_xh: bool | None = None
|
||||
self._last_yh: bool | None = None
|
||||
self._last_poll_t = 0.0
|
||||
self._last_pos: tuple[float, float] | None = None
|
||||
self._last_homed: tuple[bool, bool] | None = None
|
||||
self._handlers.update({
|
||||
"connect": self._do_connect,
|
||||
"disconnect": self._do_disconnect,
|
||||
"jog": self._do_jog,
|
||||
"home_all": self._do_home_all,
|
||||
"enable_all": self._do_enable_all,
|
||||
})
|
||||
|
||||
@pyqtSlot()
|
||||
def run(self):
|
||||
self._running = True
|
||||
while self._running:
|
||||
try:
|
||||
cmd = self._cmd_q.get(timeout=0.05)
|
||||
self._dispatch(cmd)
|
||||
except queue.Empty:
|
||||
pass
|
||||
if self.is_connected and self.controller and not self.scanning_active:
|
||||
self._poll()
|
||||
def _on_stop(self):
|
||||
if self.controller:
|
||||
try:
|
||||
self.controller.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _dispatch(self, cmd: dict):
|
||||
t = cmd["type"]
|
||||
if t == "connect":
|
||||
self._do_connect(cmd["port"])
|
||||
elif t == "disconnect":
|
||||
self._do_disconnect()
|
||||
elif t == "jog":
|
||||
self._do_jog(cmd["axis"], cmd["direction"])
|
||||
elif t == "home_all":
|
||||
self._do_home_all()
|
||||
elif t == "enable_all":
|
||||
self._do_enable_all()
|
||||
elif t == "stop":
|
||||
self._running = False
|
||||
|
||||
def _do_connect(self, port: str):
|
||||
try:
|
||||
self.controller = ThorlabsServoDriver()
|
||||
@@ -173,10 +176,12 @@ class BBD202Worker(QObject):
|
||||
acceleration=BBD_JOG_ACCEL_MM_S2)
|
||||
self.is_connected = True
|
||||
self.connected.emit()
|
||||
self.start_polling()
|
||||
except Exception as e:
|
||||
self.connection_failed.emit(str(e))
|
||||
|
||||
def _do_disconnect(self):
|
||||
self.stop_polling()
|
||||
if self.controller:
|
||||
try:
|
||||
self.controller.disconnect()
|
||||
@@ -194,8 +199,6 @@ class BBD202Worker(QObject):
|
||||
self.controller.move_axis_relative(dest, self._jog_step * direction, timeout=2.0)
|
||||
except TimeoutError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(str(e))
|
||||
|
||||
def _do_home_all(self):
|
||||
if not self.controller:
|
||||
@@ -214,79 +217,54 @@ class BBD202Worker(QObject):
|
||||
self.controller.toggle_enabled_state(AXIS_X)
|
||||
self.controller.toggle_enabled_state(AXIS_Y)
|
||||
|
||||
def _poll(self):
|
||||
now = time.time()
|
||||
if now - self._last_poll_t < 0.2:
|
||||
def _poll_once(self):
|
||||
"""Emit position/homed updates, but only when they actually change."""
|
||||
if self.scanning_active or not self.controller:
|
||||
return
|
||||
self._last_poll_t = now
|
||||
try:
|
||||
x = self.controller.positions[0]
|
||||
y = self.controller.positions[1]
|
||||
if x != self._last_x or y != self._last_y:
|
||||
self._last_x = x
|
||||
self._last_y = y
|
||||
self.position_updated.emit(x, y)
|
||||
xh = self.controller.am_homed[0]
|
||||
yh = self.controller.am_homed[1]
|
||||
if xh != self._last_xh or yh != self._last_yh:
|
||||
self._last_xh = xh
|
||||
self._last_yh = yh
|
||||
self.homed_status.emit(xh, yh)
|
||||
pos = (self.controller.positions[0], self.controller.positions[1])
|
||||
if pos != self._last_pos:
|
||||
self._last_pos = pos
|
||||
self.position_updated.emit(*pos)
|
||||
homed = (self.controller.am_homed[0], self.controller.am_homed[1])
|
||||
if homed != self._last_homed:
|
||||
self._last_homed = homed
|
||||
self.homed_status.emit(*homed)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# ── Queue API ─────────────────────────────────────────────────────────────
|
||||
|
||||
def queue_connect(self, port: str):
|
||||
self._cmd_q.put({"type": "connect", "port": port})
|
||||
self._enqueue("connect", port=port)
|
||||
|
||||
def queue_disconnect(self):
|
||||
self._cmd_q.put({"type": "disconnect"})
|
||||
self._enqueue("disconnect")
|
||||
|
||||
def queue_jog(self, axis: str, direction: int):
|
||||
self._cmd_q.put({"type": "jog", "axis": axis, "direction": direction})
|
||||
self._enqueue("jog", axis=axis, direction=direction)
|
||||
|
||||
def queue_home_all(self):
|
||||
self._cmd_q.put({"type": "home_all"})
|
||||
self._enqueue("home_all")
|
||||
|
||||
def queue_enable_all(self):
|
||||
self._cmd_q.put({"type": "enable_all"})
|
||||
|
||||
def stop_worker(self):
|
||||
self._cmd_q.put({"type": "stop"})
|
||||
self._enqueue("enable_all")
|
||||
|
||||
|
||||
# ── Oscilloscope worker ───────────────────────────────────────────────────────
|
||||
|
||||
class OscopeWorker(QObject):
|
||||
class OscopeWorker(QueueWorker):
|
||||
"""Manages TektronixOscilloscopeBase in a worker thread."""
|
||||
connected = pyqtSignal()
|
||||
disconnected = pyqtSignal()
|
||||
connection_failed = pyqtSignal(str)
|
||||
error_occurred = pyqtSignal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.scope: TektronixOscilloscopeBase | None = None
|
||||
self._cmd_q: queue.Queue = queue.Queue()
|
||||
self._running = False
|
||||
self.is_connected = False
|
||||
self._handlers.update({
|
||||
"connect": self._do_connect,
|
||||
"disconnect": self._do_disconnect,
|
||||
})
|
||||
|
||||
@pyqtSlot()
|
||||
def run(self):
|
||||
self._running = True
|
||||
while self._running:
|
||||
try:
|
||||
cmd = self._cmd_q.get(timeout=0.1)
|
||||
t = cmd["type"]
|
||||
if t == "connect":
|
||||
self._do_connect(cmd["ip"])
|
||||
elif t == "disconnect":
|
||||
self._do_disconnect()
|
||||
elif t == "stop":
|
||||
self._running = False
|
||||
except queue.Empty:
|
||||
pass
|
||||
def _on_stop(self):
|
||||
if self.scope:
|
||||
try:
|
||||
self.scope.disconnect()
|
||||
@@ -314,67 +292,48 @@ class OscopeWorker(QObject):
|
||||
self.disconnected.emit()
|
||||
|
||||
def queue_connect(self, ip: str):
|
||||
self._cmd_q.put({"type": "connect", "ip": ip})
|
||||
self._enqueue("connect", ip=ip)
|
||||
|
||||
def queue_disconnect(self):
|
||||
self._cmd_q.put({"type": "disconnect"})
|
||||
|
||||
def stop_worker(self):
|
||||
self._cmd_q.put({"type": "stop"})
|
||||
self._enqueue("disconnect")
|
||||
|
||||
|
||||
# ── Helios laser worker ───────────────────────────────────────────────────────
|
||||
|
||||
class HeliosWorker(QObject):
|
||||
"""Manages HeliosLaser in a worker thread using a command queue."""
|
||||
connected = pyqtSignal()
|
||||
disconnected = pyqtSignal()
|
||||
connection_failed = pyqtSignal(str)
|
||||
class HeliosWorker(PollingQueueWorker):
|
||||
"""Manages HeliosLaser in a worker thread using a command queue.
|
||||
|
||||
Status polling is self-rescheduling: the next poll is queued only once
|
||||
the previous completes. A free-running 1 s timer used to queue a ~2 s
|
||||
job, so the queue grew without bound and the panel fell further behind
|
||||
the longer it stayed connected.
|
||||
"""
|
||||
enabled_updated = pyqtSignal(bool)
|
||||
current_updated = pyqtSignal(int)
|
||||
diode_temp_updated = pyqtSignal(float)
|
||||
pstage_temp_updated = pyqtSignal(float)
|
||||
qswitch_temp_updated = pyqtSignal(float)
|
||||
status_registers_updated = pyqtSignal(object, object, object) # LER, LCE, CCE (int|None)
|
||||
error_occurred = pyqtSignal(str)
|
||||
|
||||
POLL_INTERVAL_S = 1.0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
|
||||
self._laser: HeliosLaser | None = None
|
||||
self._cmd_q: queue.Queue = queue.Queue()
|
||||
self._running = False
|
||||
self.is_connected = False
|
||||
self._handlers.update({
|
||||
"connect": self._do_connect,
|
||||
"disconnect": self._do_disconnect,
|
||||
"set_enable": self._do_set_enable,
|
||||
"set_current": self._do_set_current,
|
||||
})
|
||||
|
||||
@pyqtSlot()
|
||||
def run(self):
|
||||
self._running = True
|
||||
while self._running:
|
||||
try:
|
||||
cmd = self._cmd_q.get(timeout=0.1)
|
||||
self._dispatch(cmd)
|
||||
except queue.Empty:
|
||||
pass
|
||||
def _on_stop(self):
|
||||
if self._laser:
|
||||
try:
|
||||
self._laser.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _dispatch(self, cmd: dict):
|
||||
t = cmd["type"]
|
||||
if t == "connect":
|
||||
self._do_connect(cmd["port"])
|
||||
elif t == "disconnect":
|
||||
self._do_disconnect()
|
||||
elif t == "set_enable":
|
||||
self._do_set_enable(cmd["enable"])
|
||||
elif t == "set_current":
|
||||
self._do_set_current(cmd["current_ma"])
|
||||
elif t == "poll_status":
|
||||
self._do_poll_status()
|
||||
elif t == "stop":
|
||||
self._running = False
|
||||
|
||||
def _do_connect(self, port: str):
|
||||
try:
|
||||
self._laser = HeliosLaser(timeout=1.0)
|
||||
@@ -384,12 +343,13 @@ class HeliosWorker(QObject):
|
||||
return
|
||||
self.is_connected = True
|
||||
self.connected.emit()
|
||||
self._do_poll_status()
|
||||
self.start_polling()
|
||||
except Exception as e:
|
||||
self._laser = None
|
||||
self.connection_failed.emit(str(e))
|
||||
|
||||
def _do_disconnect(self):
|
||||
self.stop_polling()
|
||||
if self._laser:
|
||||
try:
|
||||
self._laser.set_laser_enable(False)
|
||||
@@ -406,75 +366,52 @@ class HeliosWorker(QObject):
|
||||
def _do_set_enable(self, enable: bool):
|
||||
if not self._laser:
|
||||
return
|
||||
try:
|
||||
self._laser.set_laser_enable(enable)
|
||||
self.enabled_updated.emit(self._laser.is_laser_enabled())
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(str(e))
|
||||
self._laser.set_laser_enable(enable)
|
||||
self.enabled_updated.emit(self._laser.is_laser_enabled())
|
||||
|
||||
def _do_set_current(self, current_ma: int):
|
||||
if not self._laser:
|
||||
return
|
||||
try:
|
||||
self._laser.set_current_ma(current_ma)
|
||||
self.current_updated.emit(current_ma)
|
||||
except Exception as e:
|
||||
self.error_occurred.emit(str(e))
|
||||
self._laser.set_current_ma(current_ma)
|
||||
self.current_updated.emit(current_ma)
|
||||
|
||||
def _do_poll_status(self):
|
||||
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)."""
|
||||
if not self._laser or not self._laser.is_connected:
|
||||
return
|
||||
try:
|
||||
ler, lce, cce = self._laser.get_status_registers()
|
||||
self.status_registers_updated.emit(ler, lce, cce)
|
||||
self.status_registers_updated.emit(*self._laser.get_status_registers())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.enabled_updated.emit(self._laser.is_laser_enabled())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
current = self._laser.get_current_ma()
|
||||
if current is not None:
|
||||
self.current_updated.emit(current)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
t = self._laser.get_diode_temp_c()
|
||||
if t is not None:
|
||||
self.diode_temp_updated.emit(t)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
t = self._laser.get_power_stage_temp_c()
|
||||
if t is not None:
|
||||
self.pstage_temp_updated.emit(t)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
t = self._laser.get_qswitch_temp_c()
|
||||
if t is not None:
|
||||
self.qswitch_temp_updated.emit(t)
|
||||
except Exception:
|
||||
pass
|
||||
for getter, signal in (
|
||||
(self._laser.get_current_ma, self.current_updated),
|
||||
(self._laser.get_diode_temp_c, self.diode_temp_updated),
|
||||
(self._laser.get_power_stage_temp_c, self.pstage_temp_updated),
|
||||
(self._laser.get_qswitch_temp_c, self.qswitch_temp_updated),
|
||||
):
|
||||
try:
|
||||
value = getter()
|
||||
if value is not None:
|
||||
signal.emit(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def queue_connect(self, port: str):
|
||||
self._cmd_q.put({"type": "connect", "port": port})
|
||||
self._enqueue("connect", port=port)
|
||||
|
||||
def queue_disconnect(self):
|
||||
self._cmd_q.put({"type": "disconnect"})
|
||||
self._enqueue("disconnect")
|
||||
|
||||
def queue_set_enable(self, enable: bool):
|
||||
self._cmd_q.put({"type": "set_enable", "enable": enable})
|
||||
self._enqueue("set_enable", enable=enable)
|
||||
|
||||
def queue_set_current(self, current_ma: int):
|
||||
self._cmd_q.put({"type": "set_current", "current_ma": current_ma})
|
||||
|
||||
def queue_poll_status(self):
|
||||
self._cmd_q.put({"type": "poll_status"})
|
||||
|
||||
def stop_worker(self):
|
||||
self._cmd_q.put({"type": "stop"})
|
||||
self._enqueue("set_current", current_ma=current_ma)
|
||||
|
||||
|
||||
# ── Camera window popup ───────────────────────────────────────────────────────
|
||||
@@ -633,17 +570,13 @@ class HeliosWindow(QWidget):
|
||||
self.setWindowTitle("Helios Laser")
|
||||
|
||||
self._worker = worker
|
||||
|
||||
self._poll_timer = QTimer(self)
|
||||
self._poll_timer.setInterval(1000)
|
||||
self._poll_timer.timeout.connect(self._worker.queue_poll_status)
|
||||
|
||||
# Polling is driven by the worker itself (self-rescheduling), so
|
||||
# there is no timer here to outpace the device.
|
||||
self._wire_signals()
|
||||
self._update_controls(False)
|
||||
self._set_emission_indicator(False)
|
||||
|
||||
def closeEvent(self, event):
|
||||
self._poll_timer.stop()
|
||||
if self._worker.is_connected:
|
||||
self._worker.queue_disconnect()
|
||||
super().closeEvent(event)
|
||||
@@ -683,7 +616,6 @@ class HeliosWindow(QWidget):
|
||||
DEFAULTS.save()
|
||||
self._worker.queue_connect(port)
|
||||
else:
|
||||
self._poll_timer.stop()
|
||||
self._worker.queue_disconnect()
|
||||
|
||||
def _on_connected(self):
|
||||
@@ -691,10 +623,8 @@ class HeliosWindow(QWidget):
|
||||
self.helios_connect_toggle.setText("Disconnect")
|
||||
self.helios_status_label.setText("Connected")
|
||||
self._update_controls(True)
|
||||
self._poll_timer.start()
|
||||
|
||||
def _on_disconnected(self):
|
||||
self._poll_timer.stop()
|
||||
self.helios_connect_toggle.blockSignals(True)
|
||||
self.helios_connect_toggle.setChecked(False)
|
||||
self.helios_connect_toggle.setText("Connect")
|
||||
@@ -905,6 +835,10 @@ class ScanProgressWindow(QWidget):
|
||||
def update_dc_bias_plot(self, row: int, values):
|
||||
self.bias_image_widget.add_row(row, values)
|
||||
|
||||
def begin_scan(self, n_rows: int, n_frames: int):
|
||||
"""Size the DC preview for the scan about to run."""
|
||||
self.bias_image_widget.begin_scan(n_rows, n_frames)
|
||||
|
||||
|
||||
# ── Scan worker ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1342,6 +1276,8 @@ class MainWindow(QMainWindow):
|
||||
0, plan.per_angle[ai0].n_rows,
|
||||
0 if resume is None else ai0 + 1, plan.n_angles
|
||||
)
|
||||
self._scan_progress.begin_scan(plan.per_angle[ai0].n_rows,
|
||||
plan.per_angle[ai0].n_frames)
|
||||
self._scan_progress.show()
|
||||
self._scan_thread.start()
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""gui.qt_workers: the shared queue/poll worker base."""
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
from PyQt6.QtCore import QThread
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
|
||||
from gui.qt_workers import PollingQueueWorker, QueueWorker
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def qapp():
|
||||
yield QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
class _Recorder(QueueWorker):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.seen = []
|
||||
self.stopped = threading.Event()
|
||||
self._handlers.update({
|
||||
"note": self._note,
|
||||
"boom": self._boom,
|
||||
})
|
||||
|
||||
def _note(self, value):
|
||||
self.seen.append(value)
|
||||
|
||||
def _boom(self):
|
||||
raise RuntimeError("handler failed")
|
||||
|
||||
def _on_stop(self):
|
||||
self.stopped.set()
|
||||
|
||||
|
||||
def _run_until(worker, predicate, timeout=5.0):
|
||||
"""Run the worker loop on a plain thread until predicate() is true.
|
||||
|
||||
Pumps the Qt event loop while waiting: signals emitted from the worker
|
||||
thread are delivered as queued events on this (main) thread.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
t = threading.Thread(target=worker.run, daemon=True)
|
||||
t.start()
|
||||
deadline = time.monotonic() + timeout
|
||||
while not predicate() and time.monotonic() < deadline:
|
||||
app.processEvents()
|
||||
time.sleep(0.01)
|
||||
app.processEvents()
|
||||
return t
|
||||
|
||||
|
||||
def test_commands_dispatch_in_order(qapp):
|
||||
w = _Recorder()
|
||||
for i in range(5):
|
||||
w._enqueue("note", value=i)
|
||||
t = _run_until(w, lambda: len(w.seen) == 5)
|
||||
w.stop_worker()
|
||||
t.join(timeout=5)
|
||||
assert w.seen == [0, 1, 2, 3, 4]
|
||||
assert w.stopped.is_set()
|
||||
|
||||
|
||||
def test_handler_exception_is_reported_not_fatal(qapp):
|
||||
w = _Recorder()
|
||||
errors = []
|
||||
w.error_occurred.connect(errors.append)
|
||||
w._enqueue("boom")
|
||||
w._enqueue("note", value="after")
|
||||
t = _run_until(w, lambda: w.seen == ["after"])
|
||||
w.stop_worker()
|
||||
t.join(timeout=5)
|
||||
assert w.seen == ["after"], "loop died on a failing handler"
|
||||
assert errors and "handler failed" in errors[0]
|
||||
|
||||
|
||||
def test_unknown_command_reported(qapp):
|
||||
w = _Recorder()
|
||||
errors = []
|
||||
w.error_occurred.connect(errors.append)
|
||||
w._enqueue("nope")
|
||||
t = _run_until(w, lambda: bool(errors))
|
||||
w.stop_worker()
|
||||
t.join(timeout=5)
|
||||
assert errors and "Unknown command" in errors[0]
|
||||
|
||||
|
||||
def test_idle_worker_does_not_spin(qapp):
|
||||
"""The loop must block on the queue, not poll it on a timeout."""
|
||||
w = _Recorder()
|
||||
t = threading.Thread(target=w.run, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.3) # idle
|
||||
cpu_before = time.process_time()
|
||||
time.sleep(0.5) # still idle
|
||||
cpu_used = time.process_time() - cpu_before
|
||||
w.stop_worker()
|
||||
t.join(timeout=5)
|
||||
# A 10–20 Hz timeout-poll loop burns measurable CPU here; blocking uses ~0.
|
||||
assert cpu_used < 0.05, f"idle worker used {cpu_used:.3f}s CPU"
|
||||
|
||||
|
||||
class _Poller(PollingQueueWorker):
|
||||
def __init__(self):
|
||||
super().__init__(poll_interval_s=0.02)
|
||||
self.polls = 0
|
||||
self.in_flight = 0
|
||||
self.overlaps = 0
|
||||
self.is_connected = True
|
||||
|
||||
def _poll_once(self):
|
||||
self.in_flight += 1
|
||||
if self.in_flight > 1:
|
||||
self.overlaps += 1
|
||||
time.sleep(0.05) # deliberately slower than the poll interval
|
||||
self.polls += 1
|
||||
self.in_flight -= 1
|
||||
|
||||
|
||||
def test_polling_never_overlaps_or_backs_up(qapp):
|
||||
"""A device slower than the interval must not accumulate stale polls."""
|
||||
w = _Poller()
|
||||
t = threading.Thread(target=w.run, daemon=True)
|
||||
t.start()
|
||||
w.start_polling()
|
||||
time.sleep(0.6)
|
||||
w.stop_polling()
|
||||
time.sleep(0.15)
|
||||
queued = w._cmd_q.qsize()
|
||||
w.stop_worker()
|
||||
t.join(timeout=5)
|
||||
|
||||
assert w.polls >= 3, "polling did not run"
|
||||
assert w.overlaps == 0, "polls overlapped"
|
||||
# Self-rescheduling means at most one poll is ever pending.
|
||||
assert queued <= 1, f"{queued} stale polls queued up"
|
||||
|
||||
|
||||
def test_stop_polling_halts_the_cycle(qapp):
|
||||
w = _Poller()
|
||||
t = threading.Thread(target=w.run, daemon=True)
|
||||
t.start()
|
||||
w.start_polling()
|
||||
time.sleep(0.2)
|
||||
w.stop_polling()
|
||||
time.sleep(0.2)
|
||||
settled = w.polls
|
||||
time.sleep(0.2)
|
||||
w.stop_worker()
|
||||
t.join(timeout=5)
|
||||
assert w.polls == settled, "polling continued after stop_polling()"
|
||||
|
||||
|
||||
def test_worker_runs_on_its_qthread(qapp):
|
||||
"""Sanity check the intended usage: run() executes on the QThread."""
|
||||
w = _Recorder()
|
||||
thread = QThread()
|
||||
w.moveToThread(thread)
|
||||
thread.started.connect(w.run)
|
||||
ids = []
|
||||
w._handlers["note"] = lambda value: ids.append(threading.get_ident())
|
||||
thread.start()
|
||||
w._enqueue("note", value=None)
|
||||
deadline = time.monotonic() + 5
|
||||
while not ids and time.monotonic() < deadline:
|
||||
qapp.processEvents()
|
||||
time.sleep(0.01)
|
||||
w.stop_worker()
|
||||
thread.quit()
|
||||
assert thread.wait(5000)
|
||||
assert ids and ids[0] != threading.get_ident()
|
||||
Reference in New Issue
Block a user