"""Focusing Control Panel — Qt UI module for ScanEngine-3. This module provides a dialog-based control panel for the T3R stepper controller, allowing users to home, step, jog, and monitor the focus axis in real time. It integrates with scanengine via the hardware abstraction layer (hardware.t3r_driver). Usage: from sc3_aui_focusing import FocusingControlPanel panel = FocusingControlPanel(parent_window) panel.show() Signals: focus_position_changed(ch, position) — emitted after each move/jog completes focus_error(ch, message) — emitted on controller errors """ from __future__ import annotations import logging from typing import Optional try: from PyQt6.QtWidgets import ( QDialog, QDoubleSpinBox, QPushButton, QLabel, QGroupBox, QVBoxLayout, QHBoxLayout, QSpacerItem, QSizePolicy, QErrorMessage, QMessageBox, ) from PyQt6.QtCore import Qt, pyqtSignal except ImportError: from PyQt5.QtWidgets import ( QDialog, QDoubleSpinBox, QPushButton, QLabel, QGroupBox, QVBoxLayout, QHBoxLayout, QSpacerItem, QSizePolicy, QErrorMessage, QMessageBox, ) from PyQt5.QtCore import Qt, pyqtSignal from hardware import T3RStepperDriver logger = logging.getLogger(__name__) class FocusingControlPanel(QDialog): """Qt dialog for controlling the T3R focus stepper.""" focus_position_changed = pyqtSignal(int, float) focus_error = pyqtSignal(int, str) def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Focusing Control Panel") self.setWindowFlags(Qt.Window | Qt.WindowCloseButtonHint) self.setMinimumWidth(780) self.setMinimumHeight(560) self._driver = None self._connected = False self._build_ui() def _build_ui(self): main_layout = QVBoxLayout(self) main_layout.setContentsMargins(20, 20, 20, 20) main_layout.setSpacing(10) title_label = QLabel("Focusing Control Panel") title_label.setFont(title_label.font().copy(size=Qt.FontSize.Fixed)) title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) main_layout.addWidget(title_label) status_groupbox = QGroupBox("Status") status_layout = QHBoxLayout(status_groupbox) self.status_label = QLabel("Not connected") self.status_label.setMinimumWidth(200) status_layout.addWidget(self.status_label) main_layout.addWidget(status_groupbox) axis_groupbox = QGroupBox("T-axis (Focus)") axis_layout = QHBoxLayout(axis_groupbox) self.position_label = QLabel("Position: 0") self.position_label.setAlignment(Qt.AlignmentFlag.AlignCenter) axis_layout.addWidget(self.position_label) main_layout.addWidget(axis_groupbox) controls_groupbox = QGroupBox("Controls") controls_layout = QVBoxLayout(controls_groupbox) controls_layout.setSpacing(6) home_row = QHBoxLayout() self.home_button = QPushButton("Home") home_row.addWidget(self.home_button) spacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) home_row.addSpacerItem(spacer) controls_layout.addLayout(home_row) step_row = QHBoxLayout() self.step_minus_button = QPushButton("< Step (-)") self.steps_spinbox = QDoubleSpinBox() self.steps_spinbox.setMinimum(-10000.0) self.steps_spinbox.setMaximum(10000.0) self.steps_spinbox.setSingleStep(100.0) self.step_plus_button = QPushButton("Step (+) >") step_row.addWidget(self.step_minus_button) step_row.addWidget(self.steps_spinbox) step_row.addWidget(self.step_plus_button) controls_layout.addLayout(step_row) jog_row = QHBoxLayout() self.jog_minus_button = QPushButton("< Jog (-)") self.velocity_spinbox = QDoubleSpinBox() self.velocity_spinbox.setMinimum(-10000.0) self.velocity_spinbox.setMaximum(10000.0) self.velocity_spinbox.setSingleStep(500.0) self.jog_plus_button = QPushButton("Jog (+) >") jog_row.addWidget(self.jog_minus_button) jog_row.addWidget(self.velocity_spinbox) jog_row.addWidget(self.jog_plus_button) controls_layout.addLayout(jog_row) accel_row = QHBoxLayout() self.accel_minus_button = QPushButton("< Accel (-)") self.accel_spinbox = QDoubleSpinBox() def connect(self, port="/dev/ttyUSB0", spd=115200): """Connect to the T3R controller and start polling.""" try: self._driver = T3RStepperDriver() self._driver.connect(port=port, spd=spd) self._connected = True self.status_label.setText("Connected") self.position_label.setText(f"Position: {self._driver.get_position(0):,.0f}") return True except Exception as e: logger.exception("Failed to connect to T3R controller") self.status_label.setText(f"Connection error: {e!s}") return False def disconnect(self): """Disconnect from the controller.""" if self._driver is not None: try: self._driver.disconnect() except Exception as e: logger.warning("Error disconnecting: %s", e) self._driver = None self._connected = False self.status_label.setText("Not connected") def home(self): """Home the T-axis.""" if not self._connected or self._driver is None: return try: self._driver.move(0, steps=0, velocity=8000, accel=4000) self.position_label.setText("Position: 0") except Exception as e: logger.exception("Home failed") self.focus_error.emit(0, f"Home error: {e!s}") def step(self, direction=1): """Step by the amount in steps_spinbox.""" if not self._connected or self._driver is None: return try: steps = int(round(self.steps_spinbox.value())) if steps == 0: return sign = 1 if direction > 0 else -1 self._driver.move(0, steps=sign * steps, velocity=8000, accel=4000) pos = self._driver.get_position(0) self.position_label.setText(f"Position: {pos:,}") except Exception as e: logger.exception("Step failed") self.focus_error.emit(0, f"Step error: {e!s}") def jog(self, direction=1): """Jog at the velocity in velocity_spinbox.""" if not self._connected or self._driver is None: return try: vel = int(round(self.velocity_spinbox.value())) if vel == 0: return sign = 1 if direction > 0 else -1 accel = int(round(self.accel_spinbox.value())) self._driver.jog(0, velocity=sign * vel, accel=accel) except Exception as e: logger.exception("Jog failed") self.focus_error.emit(0, f"Jog error: {e!s}") def stop(self): """Stop any motion.""" if not self._connected or self._driver is None: return try: self._driver.stop(0) except Exception as e: logger.exception("Stop failed") self.focus_error.emit(0, f"Stop error: {e!s}") def refresh_position(self): """Update the position label (called from polling thread).""" if not self._connected or self._driver is None: return try: pos = self._driver.get_position(0) self.position_label.setText(f"Position: {pos:,}") except Exception: pass # ── Polling integration ────────────────────────────────────────────────── def start_polling(self, interval=0.3): """Start periodic position polling (called after connect).""" if self._driver is None: return self._driver.start_polling(interval) def stop_polling(self): """Stop polling.""" if self._driver is not None: self._driver.stop_polling() def poll_loop(self): """Run the polling loop (typically in a separate thread).""" self.refresh_position() import time while self._connected and self._driver is not None: try: self._driver.start_polling(0.3) except Exception: pass time.sleep(0.5) # ── Signal handlers ────────────────────────────────────────────────────── def _on_home_clicked(self): self.home() def _on_step_minus_clicked(self): self.step(-1) def _on_step_plus_clicked(self): self.step(+1) def _on_jog_minus_clicked(self): self.jog(-1) def _on_jog_plus_clicked(self): self.jog(+1) def _on_accel_minus_clicked(self): pass # accel is just a jog parameter, handled by jog() def _on_accel_plus_clicked(self): pass def _on_stop_clicked(self): self.stop() def _on_close_clicked(self): self.close() # Wire up all buttons to their handlers self.home_button.clicked.connect(_on_home_clicked) self.step_minus_button.clicked.connect(_on_step_minus_clicked) self.step_plus_button.clicked.connect(_on_step_plus_clicked) self.jog_minus_button.clicked.connect(_on_jog_minus_clicked) self.jog_plus_button.clicked.connect(_on_jog_plus_clicked) self.accel_minus_button.clicked.connect(_on_accel_minus_clicked) self.accel_plus_button.clicked.connect(_on_accel_plus_clicked) self.stop_button.clicked.connect(_on_stop_clicked) self.close_button.clicked.connect(_on_close_clicked) # ── Signal handlers ────────────────────────────────────────────────────── def _on_home_clicked(self): self.home() def _on_step_minus_clicked(self): self.step(-1) def _on_step_plus_clicked(self): self.step(+1) def _on_jog_minus_clicked(self): self.jog(-1) def _on_jog_plus_clicked(self): self.jog(+1) def _on_accel_minus_clicked(self): pass # accel is just a jog parameter, handled by jog() def _on_accel_plus_clicked(self): pass def _on_stop_clicked(self): self.stop() def _on_close_clicked(self): self.close() # Wire up all buttons to their handlers self.home_button.clicked.connect(_on_home_clicked) self.step_minus_button.clicked.connect(_on_step_minus_clicked) self.step_plus_button.clicked.connect(_on_step_plus_clicked) self.jog_minus_button.clicked.connect(_on_jog_minus_clicked) self.jog_plus_button.clicked.connect(_on_jog_plus_clicked) self.accel_minus_button.clicked.connect(_on_accel_minus_clicked) self.accel_plus_button.clicked.connect(_on_accel_plus_clicked) self.stop_button.clicked.connect(_on_stop_clicked) self.close_button.clicked.connect(_on_close_clicked) def closeEvent(self, event): """Save state and cleanup.""" self.disconnect() # ── Polling integration ────────────────────────────────────────────────── def start_polling(self, interval=0.3): """Start periodic position polling (called after connect).""" if self._driver is None: return self._driver.start_polling(interval) def stop_polling(self): """Stop polling.""" if self._driver is not None: self._driver.stop_polling() def poll_loop(self): """Run the polling loop (typically in a separate thread).""" self.refresh_position() import time while self._connected and self._driver is not None: try: self._driver.start_polling(0.3) except Exception: pass time.sleep(0.5) # ── Module documentation ─────────────────────────────────────────────────── __all__ = ["FocusingControlPanel"] if __name__ == "__main__": import sys from PyQt6.QtWidgets import QApplication app = QApplication(sys.argv) panel = FocusingControlPanel() panel.show() sys.exit(app.exec()) # ── Polling integration ────────────────────────────────────────────────── def start_polling(self, interval=0.3): """Start periodic position polling (called after connect).""" if self._driver is None: return self._driver.start_polling(interval) def stop_polling(self): """Stop polling.""" if self._driver is not None: self._driver.stop_polling() def poll_loop(self): """Run the polling loop (typically in a separate thread).""" self.refresh_position() import time while self._connected and self._driver is not None: try: self._driver.start_polling(0.3) except Exception: pass time.sleep(0.5) event.accept() self.accel_spinbox.setMinimum(-10000.0) self.accel_spinbox.setMaximum(10000.0) self.accel_spinbox.setSingleStep(500.0) self.accel_plus_button = QPushButton("Accel (+) >") accel_row.addWidget(self.accel_minus_button) accel_row.addWidget(self.accel_spinbox) accel_row.addWidget(self.accel_plus_button) controls_layout.addLayout(accel_row) stop_row = QHBoxLayout() self.stop_button = QPushButton("Stop") stop_row.addWidget(self.stop_button) controls_layout.addLayout(stop_row) main_layout.addWidget(controls_groupbox) self.close_button = QPushButton("Close") main_layout.addWidget(self.close_button) # ── Module documentation ─────────────────────────────────────────────────── __all__ = ["FocusingControlPanel"] if __name__ == "__main__": import sys from PyQt6.QtWidgets import QApplication app = QApplication(sys.argv) panel = FocusingControlPanel() panel.show() sys.exit(app.exec())