Files
scanengine-3/sc3-aui-focusing.py
T
2026-07-28 09:20:58 -05:00

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())