Merge dev-camera-jog: jog controls in the camera window

This commit is contained in:
Thomas Ales
2026-09-04 08:48:33 -05:00
4 changed files with 670 additions and 16 deletions
+1
View File
@@ -84,6 +84,7 @@ scanengine-3/
│ ├── inspect_bridge.py # QtAngleInspector over core.angle_inspect
│ ├── qt_t3r.py # Qt adapter over the T3R driver
│ ├── qt_workers.py # QueueWorker / PollingQueueWorker bases
│ ├── jog_panel.py # T3R + BBD202 jog controls (camera window)
│ └── widgets.py # ConnectionBar, LogConsole, PortSelector…
│
├── sc3_aui_app.py # Main acquisition application
+392
View File
@@ -0,0 +1,392 @@
"""Jog controls for the T3R axes and the BBD202 stage.
These sit beside the camera image. Focusing the T-axis and framing the
sample on the XY stage are both done by eye, so the controls have to be
reachable without looking away from the video. Both panels drive the same
driver/worker the main window uses, so a jog here is the same command as a
jog there.
The T3R panel jogs while the button is held (the controller's JOG command is
a continuous velocity move, ended by STOP). The BBD202 has no such command,
so a held button repeats a short relative move, the way the main window
already does it.
"""
from __future__ import annotations
from PyQt6.QtCore import Qt, QTimer
from PyQt6.QtGui import QFont
from PyQt6.QtWidgets import (
QCheckBox, QComboBox, QDoubleSpinBox, QFrame, QGridLayout, QGroupBox,
QLabel, QPushButton, QSpinBox,
)
import hardware.t3r_protocol as proto
from hardware.t3r_driver import T3RDriver
# BBD202 jog defaults, shared with the main window's worker.
BBD_JOG_STEP_MM = 0.5 # default jog step
BBD_JOG_SPEED_MM_S = 10.0
BBD_JOG_ACCEL_MM_S2 = 50.0
# T3R jog defaults, matching the T3R control panel's own spin boxes.
T3R_JOG_VELOCITY = 8000 # steps/s
T3R_JOG_ACCEL = 4000 # steps/s²
T3R_MICROSTEPS = 16 # shown until the device reports its own
# A held BBD button repeats a relative move at this interval; the move itself
# is short, so a faster repeat would just queue up moves the stage can't
# finish (the main window uses the same 200 ms).
BBD_JOG_REPEAT_MS = 200
# Applying velocity on every spin-box click would flood the command queue, so
# the write is debounced until the operator stops adjusting.
BBD_VELOCITY_DEBOUNCE_MS = 300
def _mono(size: int = 11) -> QFont:
f = QFont("Menlo")
f.setStyleHint(QFont.StyleHint.Monospace)
f.setPointSize(size)
return f
def _hline() -> QFrame:
line = QFrame()
line.setFrameShape(QFrame.Shape.HLine)
line.setFrameShadow(QFrame.Shadow.Sunken)
return line
def _jog_button(text: str, tip: str) -> QPushButton:
btn = QPushButton(text)
btn.setToolTip(tip)
btn.setFixedWidth(38)
btn.setAutoRepeat(False) # the repeat is ours, on a timer
return btn
# ── T3R ───────────────────────────────────────────────────────────────────────
class T3RJogPanel(QGroupBox):
"""Enable, microstep and jog controls for the four T3R axes.
Jog velocity and acceleration are shared by every axis; microstepping is
per-channel, because that is how the controller stores it.
"""
def __init__(self, driver, parent=None):
super().__init__("T3R Axes", parent)
self._driver = driver
self._jogging: set[int] = set()
# Microsteps the operator picked but the device hasn't confirmed yet.
# Without this, an info poll already in flight carrying the old value
# would snap the combo back and look like the click was ignored.
self._pending_micro: dict[int, int] = {}
self._enable_chks: dict[int, QCheckBox] = {}
self._micro_combos: dict[int, QComboBox] = {}
self._pos_lbls: dict[int, QLabel] = {}
self._jog_btns: dict[tuple[int, int], QPushButton] = {}
self._build()
driver.handshake_ok.connect(lambda *_: self._set_online(True))
driver.disconnected.connect(lambda *_: self._set_online(False))
driver.info_updated.connect(self._on_info)
self._set_online(driver.is_open)
# ── Construction ──────────────────────────────────────────────────────────
def _build(self):
grid = QGridLayout(self)
grid.setVerticalSpacing(4)
grid.setHorizontalSpacing(6)
row = 0
grid.addWidget(QLabel("Velocity"), row, 0)
self.vel_spin = QSpinBox()
self.vel_spin.setRange(1, proto.MAX_VELOCITY)
self.vel_spin.setValue(T3R_JOG_VELOCITY)
self.vel_spin.setGroupSeparatorShown(True)
self.vel_spin.setSuffix(" steps/s")
grid.addWidget(self.vel_spin, row, 1, 1, 3)
row += 1
grid.addWidget(QLabel("Accel"), row, 0)
self.accel_spin = QSpinBox()
self.accel_spin.setRange(0, proto.MAX_ACCEL)
self.accel_spin.setValue(T3R_JOG_ACCEL)
self.accel_spin.setGroupSeparatorShown(True)
self.accel_spin.setSuffix(" steps/s²")
grid.addWidget(self.accel_spin, row, 1, 1, 3)
row += 1
grid.addWidget(_hline(), row, 0, 1, 5)
row += 1
hdr = QLabel("hold a jog button to move")
hdr.setStyleSheet("color: gray;")
grid.addWidget(hdr, row, 0, 1, 3)
grid.addWidget(QLabel("µsteps"), row, 3)
grid.addWidget(QLabel("pos"), row, 4)
row += 1
for ch, name in enumerate(T3RDriver.CHANNEL_NAMES):
chk = QCheckBox(name)
chk.setToolTip(f"Energise axis {ch} — a disabled axis ignores jogs")
chk.toggled.connect(lambda on, c=ch: self._on_enable_toggled(c, on))
grid.addWidget(chk, row, 0)
self._enable_chks[ch] = chk
for col, (text, direction, way) in enumerate(
(("◀", -1, "negative"), ("▶", +1, "positive")), start=1):
btn = _jog_button(text, f"Jog {way} while held")
btn.pressed.connect(
lambda c=ch, d=direction: self._start_jog(c, d))
btn.released.connect(lambda c=ch: self._stop_jog(c))
grid.addWidget(btn, row, col)
self._jog_btns[(ch, direction)] = btn
combo = QComboBox()
for m in proto.MICROSTEPS:
combo.addItem(str(m), m)
combo.setCurrentIndex(combo.findData(T3R_MICROSTEPS))
combo.setToolTip("SET_MICROSTEP — applied immediately, axis must be idle")
combo.activated.connect(lambda _i, c=ch: self._on_micro_selected(c))
grid.addWidget(combo, row, 3)
self._micro_combos[ch] = combo
pos_lbl = QLabel("—")
pos_lbl.setFont(_mono(11))
pos_lbl.setMinimumWidth(76)
pos_lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
grid.addWidget(pos_lbl, row, 4)
self._pos_lbls[ch] = pos_lbl
row += 1
# Recovery for a jog whose button-release never arrived (window hidden
# or focus stolen mid-press). It only stops axes this panel started,
# so it can never cut a scan's rotation short.
self.stop_btn = QPushButton("Stop jogging")
self.stop_btn.clicked.connect(self.stop_jogs)
grid.addWidget(self.stop_btn, row, 0, 1, 5)
grid.setColumnStretch(4, 1)
# ── Commands ──────────────────────────────────────────────────────────────
def _on_enable_toggled(self, ch: int, on: bool):
if on:
self._driver.enable(ch)
else:
self._driver.disable(ch)
def _on_micro_selected(self, ch: int):
micro = self._micro_combos[ch].currentData()
self._pending_micro[ch] = micro
self._driver.set_microstep(ch, micro)
def _start_jog(self, ch: int, direction: int):
self._jogging.add(ch)
self._driver.jog(ch, direction * self.vel_spin.value(),
self.accel_spin.value())
def _stop_jog(self, ch: int):
if ch in self._jogging:
self._jogging.discard(ch)
self._driver.stop(ch, False)
def stop_jogs(self):
"""Stop every axis this panel is jogging. Safe to call when idle."""
for ch in sorted(self._jogging):
self._driver.stop(ch, False)
self._jogging.clear()
# ── Device updates ────────────────────────────────────────────────────────
def _on_info(self, ch: int, info):
lbl = self._pos_lbls.get(ch)
if lbl is None:
return
lbl.setText(f"{info.position}")
chk = self._enable_chks[ch]
if chk.isChecked() != info.enabled:
chk.blockSignals(True)
chk.setChecked(info.enabled)
chk.blockSignals(False)
pending = self._pending_micro.get(ch)
if pending is not None:
if info.microsteps != pending:
return # change still in flight
del self._pending_micro[ch]
combo = self._micro_combos[ch]
idx = combo.findData(info.microsteps)
if idx >= 0 and idx != combo.currentIndex():
combo.blockSignals(True)
combo.setCurrentIndex(idx)
combo.blockSignals(False)
def _set_online(self, on: bool):
if not on:
self._jogging.clear()
self._pending_micro.clear()
for lbl in self._pos_lbls.values():
lbl.setText("—")
self.setEnabled(on)
# ── BBD202 ────────────────────────────────────────────────────────────────────
class BBDJogPanel(QGroupBox):
"""X/Y jog controls for the BBD202 stage.
``worker`` is the main window's BBD202Worker; it is duck-typed here to
keep this module free of an import cycle with the app. The stage runs
closed-loop brushless servos, so there is no microstepping to set —
velocity and acceleration are the equivalent knobs.
"""
def __init__(self, worker, parent=None):
super().__init__("BBD202 XY Stage", parent)
self._worker = worker
self._active: tuple[str, int] | None = None
self._repeat = QTimer(self)
self._repeat.setInterval(BBD_JOG_REPEAT_MS)
self._repeat.timeout.connect(self._jog_tick)
self._vel_debounce = QTimer(self)
self._vel_debounce.setSingleShot(True)
self._vel_debounce.setInterval(BBD_VELOCITY_DEBOUNCE_MS)
self._vel_debounce.timeout.connect(self.apply_velocity)
self._build()
worker.connected.connect(self._on_connected)
worker.disconnected.connect(lambda: self._set_online(False))
worker.position_updated.connect(self._on_position)
self._set_online(worker.is_connected)
# ── Construction ──────────────────────────────────────────────────────────
def _build(self):
grid = QGridLayout(self)
grid.setVerticalSpacing(4)
grid.setHorizontalSpacing(6)
row = 0
grid.addWidget(QLabel("X"), row, 0)
self.x_pos_lbl = QLabel("---.---")
self.x_pos_lbl.setFont(_mono(11))
grid.addWidget(self.x_pos_lbl, row, 1)
grid.addWidget(QLabel("Y"), row, 2)
self.y_pos_lbl = QLabel("---.---")
self.y_pos_lbl.setFont(_mono(11))
grid.addWidget(self.y_pos_lbl, row, 3)
row += 1
grid.addWidget(_hline(), row, 0, 1, 4)
row += 1
# Jog pad: Y over X, laid out the way the stage moves.
self.y_pos_btn = _jog_button("▲", "Jog +Y while held")
grid.addWidget(self.y_pos_btn, row, 1, 1, 2, Qt.AlignmentFlag.AlignHCenter)
row += 1
self.x_neg_btn = _jog_button("◀", "Jog −X while held")
grid.addWidget(self.x_neg_btn, row, 1, Qt.AlignmentFlag.AlignRight)
self.x_pos_btn = _jog_button("▶", "Jog +X while held")
grid.addWidget(self.x_pos_btn, row, 2, Qt.AlignmentFlag.AlignLeft)
row += 1
self.y_neg_btn = _jog_button("▼", "Jog −Y while held")
grid.addWidget(self.y_neg_btn, row, 1, 1, 2, Qt.AlignmentFlag.AlignHCenter)
row += 1
for btn, axis, direction in (
(self.x_pos_btn, "x", +1), (self.x_neg_btn, "x", -1),
(self.y_pos_btn, "y", +1), (self.y_neg_btn, "y", -1),
):
btn.pressed.connect(lambda a=axis, d=direction: self._start_jog(a, d))
btn.released.connect(self.stop_jogs)
grid.addWidget(QLabel("Step"), row, 0)
self.step_spin = QDoubleSpinBox()
self.step_spin.setRange(0.001, 25.0)
self.step_spin.setDecimals(3)
self.step_spin.setSingleStep(0.1)
self.step_spin.setValue(BBD_JOG_STEP_MM)
self.step_spin.setSuffix(" mm")
grid.addWidget(self.step_spin, row, 1, 1, 3)
row += 1
grid.addWidget(QLabel("Velocity"), row, 0)
self.vel_spin = QDoubleSpinBox()
self.vel_spin.setRange(0.1, 100.0)
self.vel_spin.setDecimals(1)
self.vel_spin.setValue(BBD_JOG_SPEED_MM_S)
self.vel_spin.setSuffix(" mm/s")
self.vel_spin.valueChanged.connect(lambda _v: self._vel_debounce.start())
grid.addWidget(self.vel_spin, row, 1, 1, 3)
row += 1
grid.addWidget(QLabel("Accel"), row, 0)
self.accel_spin = QDoubleSpinBox()
self.accel_spin.setRange(0.1, 500.0)
self.accel_spin.setDecimals(1)
self.accel_spin.setValue(BBD_JOG_ACCEL_MM_S2)
self.accel_spin.setSuffix(" mm/s²")
self.accel_spin.valueChanged.connect(lambda _v: self._vel_debounce.start())
grid.addWidget(self.accel_spin, row, 1, 1, 3)
row += 1
note = QLabel("Microstepping: n/a — closed-loop servo")
note.setStyleSheet("color: gray;")
note.setWordWrap(True)
grid.addWidget(note, row, 0, 1, 4)
grid.setColumnStretch(3, 1)
# ── Commands ──────────────────────────────────────────────────────────────
def _start_jog(self, axis: str, direction: int):
self._active = (axis, direction)
self._jog_tick()
self._repeat.start()
def _jog_tick(self):
if self._active is None:
return
axis, direction = self._active
self._worker.queue_jog(axis, direction, step_mm=self.step_spin.value())
def stop_jogs(self):
"""Stop the repeat. A move already sent runs to its (short) end."""
self._repeat.stop()
self._active = None
def apply_velocity(self):
"""Push the panel's velocity/acceleration to both axes."""
if self._worker.is_connected:
self._worker.queue_set_velocity(self.vel_spin.value(),
self.accel_spin.value())
# ── Device updates ────────────────────────────────────────────────────────
def _on_connected(self):
self._set_online(True)
self.apply_velocity()
def _on_position(self, x_mm: float, y_mm: float):
self.x_pos_lbl.setText(f"{x_mm:07.3f}")
self.y_pos_lbl.setText(f"{y_mm:07.3f}")
def _set_online(self, on: bool):
if not on:
self.stop_jogs()
self.x_pos_lbl.setText("---.---")
self.y_pos_lbl.setText("---.---")
self.setEnabled(on)
+61 -16
View File
@@ -40,6 +40,10 @@ from core.scope_sras import SAMPLE_RATE_HZ, configure_channels
from core.sras_format import (
SCAN_CHANNELS, VERSION, VERSION_SAW_CHECK, SrasFile, plan_from_header,
)
from gui.jog_panel import (
BBD_JOG_ACCEL_MM_S2, BBD_JOG_SPEED_MM_S, BBD_JOG_STEP_MM,
BBDJogPanel, T3RJogPanel,
)
from gui.qt_t3r import QtT3RAdapter
from gui.qt_workers import PollingQueueWorker, QueueWorker
from gui.inspect_bridge import QtAngleInspector
@@ -54,7 +58,6 @@ from t3r_control_panel import T3RControlPanel
DEFAULTS = ScanDefaults.load()
BBD_DEFAULT_JOG_MM = 0.5 # default jog step for BBD202
# A SAW check is written beside the scan it belongs to, under the same prefix.
# The suffix keeps it from overwriting the scan itself, which is the one file
# in the directory that cost hours to acquire.
@@ -136,9 +139,6 @@ class DCBiasImageWidget(FigureCanvas):
self.draw_idle()
BBD_JOG_SPEED_MM_S = 10.0
BBD_JOG_ACCEL_MM_S2 = 50.0
# ── BBD202 worker ─────────────────────────────────────────────────────────────
class BBD202Worker(PollingQueueWorker):
@@ -152,13 +152,14 @@ class BBD202Worker(PollingQueueWorker):
super().__init__(poll_interval_s=self.POLL_INTERVAL_S)
self.controller: ThorlabsServoDriver | None = None
self.scanning_active = False # pause polling during a scan
self._jog_step = BBD_DEFAULT_JOG_MM
self._jog_step = BBD_JOG_STEP_MM
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,
"set_velocity": self._do_set_velocity,
"home_all": self._do_home_all,
"enable_all": self._do_enable_all,
})
@@ -200,15 +201,23 @@ class BBD202Worker(PollingQueueWorker):
self.is_connected = False
self.disconnected.emit()
def _do_jog(self, axis: str, direction: int):
def _do_jog(self, axis: str, direction: int, step_mm: float | None = None):
if not self.controller:
return
dest = AXIS_X if axis == "x" else AXIS_Y
step = self._jog_step if step_mm is None else step_mm
try:
self.controller.move_axis_relative(dest, self._jog_step * direction, timeout=2.0)
self.controller.move_axis_relative(dest, step * direction, timeout=2.0)
except TimeoutError:
pass
def _do_set_velocity(self, max_velocity: float, acceleration: float):
if not self.controller:
return
for dest in (AXIS_X, AXIS_Y):
self.controller.set_velocity_params(dest, max_velocity=max_velocity,
acceleration=acceleration)
def _do_home_all(self):
if not self.controller:
return
@@ -250,8 +259,12 @@ class BBD202Worker(PollingQueueWorker):
def queue_disconnect(self):
self._enqueue("disconnect")
def queue_jog(self, axis: str, direction: int):
self._enqueue("jog", axis=axis, direction=direction)
def queue_jog(self, axis: str, direction: int, step_mm: float | None = None):
self._enqueue("jog", axis=axis, direction=direction, step_mm=step_mm)
def queue_set_velocity(self, max_velocity: float, acceleration: float):
self._enqueue("set_velocity", max_velocity=max_velocity,
acceleration=acceleration)
def queue_home_all(self):
self._enqueue("home_all")
@@ -426,11 +439,18 @@ class HeliosWorker(PollingQueueWorker):
# ── Camera window popup ───────────────────────────────────────────────────────
class CameraWindow(QWidget):
"""Camera display popup — auto-connects on show, auto-disconnects on close."""
"""Camera display popup — auto-connects on show, auto-disconnects on close.
Focusing and framing are done by eye, so the T3R and BBD202 jog controls
live beside the image. Both take the objects the main window already
owns; passing neither leaves the window as a plain viewer.
"""
closed = pyqtSignal()
def __init__(self, parent: QWidget | None = None):
def __init__(self, t3r_driver: QtT3RAdapter | None = None,
bbd_worker: BBD202Worker | None = None,
parent: QWidget | None = None):
super().__init__(parent, Qt.WindowType.Window)
uic.loadUi(ROOT / "sc3-aui-camera.ui", self)
self.setWindowTitle("uC480 Camera")
@@ -468,14 +488,37 @@ class CameraWindow(QWidget):
self.uc480_stop_btn.clicked.connect(self._stop_stream)
self.uc480_close_window_btn.clicked.connect(self.close)
self.t3r_jog_panel = self.bbd_jog_panel = None
self._build_jog_column(t3r_driver, bbd_worker)
self._update_controls()
def _build_jog_column(self, t3r_driver, bbd_worker):
"""Add the stage controls to the right of the image."""
if t3r_driver is None and bbd_worker is None:
return
column = QVBoxLayout()
column.setContentsMargins(0, 0, 0, 0)
if t3r_driver is not None:
self.t3r_jog_panel = T3RJogPanel(t3r_driver, self)
column.addWidget(self.t3r_jog_panel)
if bbd_worker is not None:
self.bbd_jog_panel = BBDJogPanel(bbd_worker, self)
column.addWidget(self.bbd_jog_panel)
column.addStretch()
self.horizontalLayout.addLayout(column)
def showEvent(self, event):
super().showEvent(event)
self._connect_camera()
self._start_stream()
def closeEvent(self, event):
# A jog whose button-release lands after the window is gone would
# otherwise leave an axis running.
for panel in (self.t3r_jog_panel, self.bbd_jog_panel):
if panel is not None:
panel.stop_jogs()
self._stop_stream()
self._disconnect_camera()
super().closeEvent(event)
@@ -980,7 +1023,7 @@ class MainWindow(QMainWindow):
self._helios_thread.started.connect(self._helios_worker.run)
# ── Popup windows ─────────────────────────────────────────────────────
self._camera_win = CameraWindow()
self._camera_win = CameraWindow(self._t3r_driver, self._bbd_worker)
self._helios_win = HeliosWindow(self._helios_worker)
self._scan_progress = ScanProgressWindow()
@@ -1022,7 +1065,7 @@ class MainWindow(QMainWindow):
self.bbd202_comport_edit.setText(DEFAULTS.bbd_port)
self.oscope_ip_edit.setText(DEFAULTS.oscope_ip)
self._helios_win.helios_port_edit.setText(DEFAULTS.helios_port)
self.lineEdit_10.setText(str(BBD_DEFAULT_JOG_MM))
self.lineEdit_10.setText(str(BBD_JOG_STEP_MM))
self.x_start_edit.setText("10.000")
self.y_start_edit.setText("10.000")
@@ -1188,10 +1231,12 @@ class MainWindow(QMainWindow):
return
axis, direction = self._bbd_active_jog
try:
self._bbd_worker._jog_step = float(self.lineEdit_10.text())
step = float(self.lineEdit_10.text())
except ValueError:
self._bbd_worker._jog_step = BBD_DEFAULT_JOG_MM
self._bbd_worker.queue_jog(axis, direction)
step = BBD_JOG_STEP_MM
# The step rides along with the command: writing it onto the worker
# from here would be a cross-thread poke at a field the worker reads.
self._bbd_worker.queue_jog(axis, direction, step_mm=step)
def _stop_bbd_jog(self):
self._bbd_jog_timer.stop()
+216
View File
@@ -0,0 +1,216 @@
"""gui.jog_panel: the camera window's T3R and BBD202 jog controls.
The panels are the only place these devices are driven by a held button, so
what matters here is that press/release map onto the right pair of commands
and that the operator's velocity/microstep settings ride along.
"""
import pytest
from PyQt6.QtCore import QObject, pyqtSignal
from PyQt6.QtWidgets import QApplication
import hardware.t3r_protocol as proto
from gui.jog_panel import BBDJogPanel, T3RJogPanel
@pytest.fixture(scope="module")
def qapp():
yield QApplication.instance() or QApplication([])
class FakeT3R(QObject):
"""The slice of QtT3RAdapter the T3R panel touches."""
handshake_ok = pyqtSignal(int, int, int)
disconnected = pyqtSignal(str)
info_updated = pyqtSignal(int, object)
def __init__(self, is_open=True):
super().__init__()
self.is_open = is_open
self.calls = []
def enable(self, ch):
self.calls.append(("enable", ch))
def disable(self, ch):
self.calls.append(("disable", ch))
def set_microstep(self, ch, microsteps):
self.calls.append(("set_microstep", ch, microsteps))
def jog(self, ch, velocity, accel):
self.calls.append(("jog", ch, velocity, accel))
def stop(self, ch, hard):
self.calls.append(("stop", ch, hard))
class FakeBBD(QObject):
"""The slice of BBD202Worker the BBD panel touches."""
connected = pyqtSignal()
disconnected = pyqtSignal()
position_updated = pyqtSignal(float, float)
def __init__(self, is_connected=True):
super().__init__()
self.is_connected = is_connected
self.calls = []
def queue_jog(self, axis, direction, step_mm=None):
self.calls.append(("jog", axis, direction, step_mm))
def queue_set_velocity(self, max_velocity, acceleration):
self.calls.append(("velocity", max_velocity, acceleration))
def _info(ch, position=0, microsteps=16, enabled=True):
return proto.Info(ch=ch, state=0, position=position, velocity=0,
microsteps=microsteps, run_ma=800, hold_ma=400,
enabled=enabled, comms_ok=True, fault_mask=0)
# ── T3R ───────────────────────────────────────────────────────────────────────
def test_t3r_jog_holds_then_stops(qapp):
drv = FakeT3R()
panel = T3RJogPanel(drv)
panel.vel_spin.setValue(1234)
panel.accel_spin.setValue(99)
btn = panel._jog_btns[(0, -1)]
btn.pressed.emit()
assert drv.calls == [("jog", 0, -1234, 99)]
btn.released.emit()
assert drv.calls[-1] == ("stop", 0, False)
def test_t3r_release_without_press_sends_nothing(qapp):
"""A stray release must not stop an axis a scan is driving."""
drv = FakeT3R()
panel = T3RJogPanel(drv)
panel._jog_btns[(3, 1)].released.emit()
assert drv.calls == []
def test_t3r_stop_jogs_covers_a_lost_release(qapp):
drv = FakeT3R()
panel = T3RJogPanel(drv)
panel._jog_btns[(1, 1)].pressed.emit()
drv.calls.clear()
panel.stop_jogs()
assert drv.calls == [("stop", 1, False)]
panel.stop_jogs() # already stopped: no repeat command
assert drv.calls == [("stop", 1, False)]
def test_t3r_microstep_applies_and_survives_a_stale_poll(qapp):
drv = FakeT3R()
panel = T3RJogPanel(drv)
combo = panel._micro_combos[2]
combo.setCurrentIndex(combo.findData(64))
combo.activated.emit(combo.currentIndex())
assert drv.calls == [("set_microstep", 2, 64)]
# An info frame already in flight still carries the old value.
drv.info_updated.emit(2, _info(2, microsteps=16))
assert combo.currentData() == 64
# Once the device confirms, the combo tracks it again.
drv.info_updated.emit(2, _info(2, microsteps=64))
assert combo.currentData() == 64
drv.info_updated.emit(2, _info(2, microsteps=8))
assert combo.currentData() == 8
def test_t3r_enable_checkbox_follows_the_device(qapp):
drv = FakeT3R()
panel = T3RJogPanel(drv)
panel._enable_chks[0].setChecked(True)
assert drv.calls == [("enable", 0)]
# A device-side state change updates the box without echoing a command.
drv.info_updated.emit(0, _info(0, position=-42, enabled=False))
assert not panel._enable_chks[0].isChecked()
assert drv.calls == [("enable", 0)]
assert panel._pos_lbls[0].text() == "-42"
def test_t3r_panel_tracks_connection(qapp):
drv = FakeT3R(is_open=False)
panel = T3RJogPanel(drv)
assert not panel.isEnabled()
drv.handshake_ok.emit(1, 1, 4)
assert panel.isEnabled()
drv.info_updated.emit(0, _info(0, position=7))
drv.disconnected.emit("cable")
assert not panel.isEnabled()
assert panel._pos_lbls[0].text() == "—"
# ── BBD202 ────────────────────────────────────────────────────────────────────
def test_bbd_jog_repeats_while_held(qapp):
worker = FakeBBD()
panel = BBDJogPanel(worker)
panel.step_spin.setValue(0.25)
panel.x_pos_btn.pressed.emit()
assert worker.calls == [("jog", "x", 1, 0.25)]
assert panel._repeat.isActive()
panel._jog_tick() # what the repeat timer fires
assert worker.calls[-1] == ("jog", "x", 1, 0.25)
panel.x_pos_btn.released.emit()
assert not panel._repeat.isActive()
panel._jog_tick() # a late tick moves nothing
assert len(worker.calls) == 2
def test_bbd_jog_directions(qapp):
worker = FakeBBD()
panel = BBDJogPanel(worker)
for btn, expected in ((panel.x_neg_btn, ("jog", "x", -1, 0.5)),
(panel.y_pos_btn, ("jog", "y", 1, 0.5)),
(panel.y_neg_btn, ("jog", "y", -1, 0.5))):
btn.pressed.emit()
btn.released.emit()
assert worker.calls[-1] == expected
def test_bbd_velocity_is_debounced_then_applied(qapp):
worker = FakeBBD()
panel = BBDJogPanel(worker)
panel.vel_spin.setValue(4.0)
panel.accel_spin.setValue(20.0)
assert worker.calls == [] # nothing sent mid-adjustment
assert panel._vel_debounce.isActive()
panel.apply_velocity()
assert worker.calls == [("velocity", 4.0, 20.0)]
def test_bbd_panel_tracks_connection(qapp):
worker = FakeBBD(is_connected=False)
panel = BBDJogPanel(worker)
assert not panel.isEnabled()
worker.position_updated.emit(12.0, 34.5)
assert panel.x_pos_lbl.text() == "012.000"
assert panel.y_pos_lbl.text() == "034.500"
worker.is_connected = True
worker.connected.emit()
assert panel.isEnabled()
assert worker.calls == [("velocity", panel.vel_spin.value(),
panel.accel_spin.value())]
worker.disconnected.emit()
assert not panel.isEnabled()
assert panel.x_pos_lbl.text() == "---.---"