407 lines
14 KiB
Python
Executable File
407 lines
14 KiB
Python
Executable File
"""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()) |