"""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.QtWidgets import ( QCheckBox, QComboBox, QDoubleSpinBox, QFrame, QGridLayout, QGroupBox, QLabel, QPushButton, QSpinBox, ) import hardware.t3r_protocol as proto from gui.widgets import mono_font 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 _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_font(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_font(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_font(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)