Files
scanengine-3/helios_test_app.py
Thomas Ales [M S E] a0e0151b5d pre uc480 integration
2026-05-22 09:38:39 -05:00

986 lines
37 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Helios Laser Test Application
Simple PyQt6 GUI for testing and controlling the Helios laser.
"""
import sys
import logging
from typing import Optional
from enum import Enum
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QSpinBox,
QStatusBar, QMessageBox, QTabWidget, QTextEdit
)
from PyQt6.QtCore import Qt, QThread, pyqtSignal, QObject
from PyQt6.QtGui import QFont
from hardware.helios_laser import HeliosLaser, PulseMode
# ---------------------------------------------------------------------------
# Status register bit definitions (Tables 8-1, 8-2, 8-3 — Helios manual)
# Each entry: bit_number -> (severity, description, comment)
# severity: 'C' = critical error, 'S' = status, 'I' = input error, '' = none
# ---------------------------------------------------------------------------
_LER_FLAGS = {
0: ('C', 'Controller temperature failure (resonator/SHG/q-switch)',
'Check CCE register for details'),
1: ('S', 'Trigger input active',
'High when trigger signal applied or laser in continuous pulsing'),
2: ('I', 'Command error',
'Unknown command sent to controller'),
3: ('C', 'Laser disable pin open (utility connector)',
'Shuts down pump diodes; reset LER 0 required to restart'),
4: ('C', 'Internal hardware failure',
'Contact Coherent'),
5: ('C', 'Over voltage laser diode',
'Check for open circuit or voltage spikes'),
6: ('C', 'Internal hardware failure',
'Contact Coherent'),
7: ('C', 'Controller temperature failure at pump diodes',
'Check LCE register for details'),
8: ('S', 'Laser start delay (60 s warmup)',
'Laser cannot be started yet; status error LED flashing'),
9: ('C', 'Internal hardware failure',
'Check environment for strong EMI; contact Coherent'),
10: ('C', 'Internal hardware failure',
'Check environment for strong EMI; contact Coherent'),
11: ('C', 'Internal hardware failure',
'Check environment for strong EMI; contact Coherent'),
12: ('S', 'Slave controller error (remote input)',
'Valid only for master controller coupled with a slave'),
13: ('', 'Laserhead not found',
'Head not connected / not found; check EMI; ignore for double-electronic slave'),
14: ('', 'Laserhead I\u00b2C acknowledge error',
'Check environment for strong EMI; ignore for double-electronic slave'),
15: ('S', 'Range-Error (not critical)',
'Input value out of range'),
}
_LCE_FLAGS = {
0: ('C', 'Pump diode over/under temperature',
'Limit exceeded (<10\u00b0C or >60\u00b0C); check head cooling'),
1: ('C', 'Internal hardware failure',
'Contact Coherent'),
2: ('C', 'Pump diode temperature out of range',
'Actual temp >2\u00b0C off setpoint for >1 min'),
3: ('C', 'Pump diode current critical',
'Current set too close to current limit'),
4: ('C', 'Pump diode temperature out of limit',
'Pump diode temperature is out of limit'),
5: ('S', 'Door switch open',
'Close utility connector pin 2 permanently to pin 9 (GND)'),
7: ('C', 'Pump diode NTC error',
'Invalid temperature measured or NTC broken'),
8: ('C', 'Laser diode power stage over temperature',
'Temp <10\u00b0C or >65\u00b0C at controller; check controller cooling'),
9: ('C', 'Internal hardware failure',
'Check environment for strong EMI; contact Coherent'),
10: ('C', 'Internal hardware failure',
'Check environment for strong EMI; contact Coherent'),
11: ('C', 'Internal hardware failure',
'Check environment for strong EMI; contact Coherent'),
15: ('S', 'Range-Error (not critical)',
'Input value out of range'),
}
_CCE_FLAGS = {
0: ('C', 'Resonator/SHG under/over temperature',
'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'),
1: ('C', 'Resonator/SHG NTC failure',
'Temperature sensor broken or disconnected'),
2: ('C', 'Resonator/SHG temperature out of range',
'Actual temp >2\u00b0C off setpoint for >1 min'),
3: ('C', 'Q-switch ADC / temperature readout failure',
'Internal hardware error or no NTC connected'),
4: ('C', 'Q-switch temperature out of range',
'Actual temp >2\u00b0C off setpoint for >1 min'),
5: ('C', 'Q-switch under/over temperature',
'Limit exceeded (<10\u00b0C or >60\u00b0C); temperature controller deactivated'),
7: ('C', 'Q-switch NTC failure',
'Internal hardware error or no NTC connected'),
8: ('C', 'Internal hardware failure',
'Contact Coherent'),
15: ('S', 'Range-Error (not critical)',
'Input value out of range'),
}
_SEVERITY_LABEL = {'C': '[CRIT]', 'S': '[STAT]', 'I': '[INPT]', '': '[INFO]'}
def _decode_register(flags_dict: dict, value: int) -> list:
"""Return list of (bit, severity, description, comment) for each set bit."""
active = []
for bit, (sev, desc, comment) in flags_dict.items():
if value & (1 << bit):
active.append((bit, sev, desc, comment))
return active
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class LaserWorker(QObject):
"""Worker thread for laser operations to prevent UI blocking."""
# Signals
operation_complete = pyqtSignal(bool, str) # success, message
frequency_updated = pyqtSignal(int)
current_updated = pyqtSignal(int)
power_updated = pyqtSignal(float)
enabled_updated = pyqtSignal(bool)
serial_updated = pyqtSignal(str, str) # controller_sn, head_sn
status_registers_updated = pyqtSignal(object, object, object) # ler, lce, cce (int or None)
remote_enable_updated = pyqtSignal(object) # bool or None
raw_response_received = pyqtSignal(str, str) # command, response
def __init__(self, laser: HeliosLaser):
super().__init__()
self.laser = laser
def set_frequency(self, freq: int):
try:
success = self.laser.set_frequency_hz(freq)
msg = f"Frequency set to {freq} Hz" if success else "Failed to set frequency"
self.operation_complete.emit(success, msg)
except Exception as e:
self.operation_complete.emit(False, str(e))
def set_current(self, current: int):
try:
success = self.laser.set_current_ma(current)
msg = f"Current set to {current} mA" if success else "Failed to set current"
self.operation_complete.emit(success, msg)
except Exception as e:
self.operation_complete.emit(False, str(e))
def set_pulse_mode(self, mode: int):
try:
pulse_mode = PulseMode(mode)
success = self.laser.set_pulse_mode(pulse_mode)
msg = f"Pulse mode set to {pulse_mode.name}" if success else "Failed to set pulse mode"
self.operation_complete.emit(success, msg)
except Exception as e:
self.operation_complete.emit(False, str(e))
def set_laser_enable(self, enable: bool):
try:
success = self.laser.set_laser_enable(enable)
state = "enabled" if enable else "disabled"
msg = f"Laser {state}" if success else f"Failed to {state} laser"
self.operation_complete.emit(success, msg)
except Exception as e:
self.operation_complete.emit(False, str(e))
def query_frequency(self):
try:
freq = self.laser.get_frequency_hz()
if freq is not None:
self.frequency_updated.emit(freq)
self.operation_complete.emit(True, f"Frequency: {freq} Hz")
else:
self.operation_complete.emit(False, "Failed to query frequency")
except Exception as e:
self.operation_complete.emit(False, str(e))
def query_current(self):
try:
current = self.laser.get_current_ma()
if current is not None:
self.current_updated.emit(current)
self.operation_complete.emit(True, f"Current: {current} mA")
else:
self.operation_complete.emit(False, "Failed to query current")
except Exception as e:
self.operation_complete.emit(False, str(e))
def query_power(self):
try:
power = self.laser.get_power_mw()
if power is not None:
self.power_updated.emit(power)
self.operation_complete.emit(True, f"Power: {power:.2f} mW")
else:
self.operation_complete.emit(False, "Failed to query power")
except Exception as e:
self.operation_complete.emit(False, str(e))
def query_enabled(self):
try:
enabled = self.laser.is_laser_enabled()
self.enabled_updated.emit(enabled)
state = "enabled" if enabled else "disabled"
self.operation_complete.emit(True, f"Laser is {state}")
except Exception as e:
self.operation_complete.emit(False, str(e))
def query_serials(self):
try:
controller_sn = self.laser.get_controller_serial()
head_sn = self.laser.get_head_serial()
if controller_sn and head_sn:
self.serial_updated.emit(controller_sn, head_sn)
msg = f"Controller: {controller_sn}, Head: {head_sn}"
self.operation_complete.emit(True, msg)
else:
self.operation_complete.emit(False, "Failed to query serial numbers")
except Exception as e:
self.operation_complete.emit(False, str(e))
def query_status_registers(self):
try:
ler, lce, cce = self.laser.get_status_registers()
self.status_registers_updated.emit(ler, lce, cce)
self.operation_complete.emit(True, f"Status: LER={ler} LCE={lce} CCE={cce}")
except Exception as e:
self.operation_complete.emit(False, str(e))
def query_remote_enable(self):
try:
state = self.laser.get_remote_enable()
self.remote_enable_updated.emit(state)
if state is not None:
self.operation_complete.emit(True, f"Remote enable (LRE): {'ON' if state else 'OFF'}")
else:
self.operation_complete.emit(False, "Failed to query remote enable")
except Exception as e:
self.operation_complete.emit(False, str(e))
def set_remote_enable(self, enable: bool):
try:
success = self.laser.set_remote_enable(enable)
state = "ON" if enable else "OFF"
msg = f"Remote enable (LRE) set to {state}" if success else "Failed to set remote enable"
self.operation_complete.emit(success, msg)
except Exception as e:
self.operation_complete.emit(False, str(e))
def do_reset_faults(self):
try:
success = self.laser.reset_faults()
msg = "Fault reset sequence sent (CCE 0 → LCE 0 → LER 0)" if success else "Failed to send reset sequence"
self.operation_complete.emit(success, msg)
except Exception as e:
self.operation_complete.emit(False, str(e))
def do_ler_reset(self):
try:
success = self.laser._send_command("LER 0")
msg = "LER 0 sent" if success else "Failed to send LER 0"
self.operation_complete.emit(success, msg)
except Exception as e:
self.operation_complete.emit(False, str(e))
def send_raw(self, cmd: str):
try:
response = self.laser.send_raw_command(cmd)
if response is not None:
self.raw_response_received.emit(cmd, response)
self.operation_complete.emit(True, f"TX: {cmd!r} RX: {response!r}")
else:
self.operation_complete.emit(False, f"No response for: {cmd!r}")
except Exception as e:
self.operation_complete.emit(False, str(e))
class HeliosTestApp(QMainWindow):
"""Main application window for Helios laser testing."""
def __init__(self):
super().__init__()
self.laser = HeliosLaser()
self.worker = None
self.worker_thread = None
self.init_ui()
self.update_port_list()
def init_ui(self):
"""Initialize the user interface."""
self.setWindowTitle("Helios Laser Test Application")
self.setGeometry(100, 100, 900, 700)
# Main widget and layout
main_widget = QWidget()
self.setCentralWidget(main_widget)
main_layout = QVBoxLayout(main_widget)
# Connection group
connection_group = self.create_connection_group()
main_layout.addWidget(connection_group)
# Tabs for control and monitoring
tabs = QTabWidget()
tabs.addTab(self.create_control_tab(), "Control")
tabs.addTab(self.create_monitor_tab(), "Monitor")
tabs.addTab(self.create_terminal_tab(), "Terminal")
main_layout.addWidget(tabs)
# Status bar
self.statusBar().showMessage("Disconnected")
def create_connection_group(self) -> QGroupBox:
"""Create the connection control group."""
group = QGroupBox("Connection")
layout = QHBoxLayout()
# Port selection
layout.addWidget(QLabel("Serial Port:"))
self.combo_port = QComboBox()
self.combo_port.setMinimumWidth(150)
layout.addWidget(self.combo_port)
# Refresh ports button
btn_refresh = QPushButton("Refresh Ports")
btn_refresh.clicked.connect(self.update_port_list)
layout.addWidget(btn_refresh)
# Connect button
self.btn_connect = QPushButton("Connect")
self.btn_connect.clicked.connect(self.toggle_connection)
self.btn_connect.setMinimumWidth(100)
layout.addWidget(self.btn_connect)
# Connection status
self.lbl_status = QLabel("Status: Disconnected")
font = self.lbl_status.font()
font.setBold(True)
self.lbl_status.setFont(font)
layout.addWidget(self.lbl_status)
layout.addStretch()
group.setLayout(layout)
return group
def create_control_tab(self) -> QWidget:
"""Create the control tab."""
widget = QWidget()
layout = QVBoxLayout(widget)
# Frequency control
freq_group = QGroupBox("Frequency Control")
freq_layout = QHBoxLayout()
freq_layout.addWidget(QLabel("Frequency (Hz):"))
self.spin_frequency = QSpinBox()
self.spin_frequency.setRange(16700, 125000)
self.spin_frequency.setValue(50000)
self.spin_frequency.setSingleStep(1000)
freq_layout.addWidget(self.spin_frequency)
btn_set_freq = QPushButton("Set Frequency")
btn_set_freq.clicked.connect(self.on_set_frequency)
btn_set_freq.setMaximumWidth(150)
freq_layout.addWidget(btn_set_freq)
btn_get_freq = QPushButton("Query")
btn_get_freq.clicked.connect(self.on_query_frequency)
btn_get_freq.setMaximumWidth(100)
freq_layout.addWidget(btn_get_freq)
freq_layout.addStretch()
freq_group.setLayout(freq_layout)
layout.addWidget(freq_group)
# Current control
current_group = QGroupBox("Current Control")
current_layout = QHBoxLayout()
current_layout.addWidget(QLabel("Current (mA):"))
self.spin_current = QSpinBox()
self.spin_current.setRange(0, 2000)
self.spin_current.setValue(1000)
self.spin_current.setSingleStep(100)
current_layout.addWidget(self.spin_current)
btn_set_current = QPushButton("Set Current")
btn_set_current.clicked.connect(self.on_set_current)
btn_set_current.setMaximumWidth(150)
current_layout.addWidget(btn_set_current)
btn_get_current = QPushButton("Query")
btn_get_current.clicked.connect(self.on_query_current)
btn_get_current.setMaximumWidth(100)
current_layout.addWidget(btn_get_current)
current_layout.addStretch()
current_group.setLayout(current_layout)
layout.addWidget(current_group)
# Pulse mode control
mode_group = QGroupBox("Pulse Mode Control")
mode_layout = QHBoxLayout()
mode_layout.addWidget(QLabel("Pulse Mode:"))
self.combo_mode = QComboBox()
self.combo_mode.addItem("Single Pulse", PulseMode.SINGLE_PULSE.value)
self.combo_mode.addItem("Continuous Gating", PulseMode.CONTINUOUS_GATING.value)
self.combo_mode.addItem("Continuous Pulsing", PulseMode.CONTINUOUS_PULSING.value)
mode_layout.addWidget(self.combo_mode)
btn_set_mode = QPushButton("Set Mode")
btn_set_mode.clicked.connect(self.on_set_mode)
btn_set_mode.setMaximumWidth(150)
mode_layout.addWidget(btn_set_mode)
mode_layout.addStretch()
mode_group.setLayout(mode_layout)
layout.addWidget(mode_group)
# Laser enable/disable
enable_group = QGroupBox("Laser Control")
enable_layout = QHBoxLayout()
self.btn_enable = QPushButton("Enable Laser")
self.btn_enable.setStyleSheet("background-color: lightgreen")
self.btn_enable.clicked.connect(self.on_enable_laser)
self.btn_enable.setMaximumWidth(150)
enable_layout.addWidget(self.btn_enable)
self.btn_disable = QPushButton("Disable Laser")
self.btn_disable.setStyleSheet("background-color: lightcoral")
self.btn_disable.clicked.connect(self.on_disable_laser)
self.btn_disable.setMaximumWidth(150)
enable_layout.addWidget(self.btn_disable)
btn_check_enabled = QPushButton("Query Status")
btn_check_enabled.clicked.connect(self.on_query_enabled)
btn_check_enabled.setMaximumWidth(150)
enable_layout.addWidget(btn_check_enabled)
self.lbl_enabled = QLabel("Status: Unknown")
font = self.lbl_enabled.font()
font.setBold(True)
self.lbl_enabled.setFont(font)
enable_layout.addWidget(self.lbl_enabled)
enable_layout.addStretch()
enable_group.setLayout(enable_layout)
layout.addWidget(enable_group)
# Status & Safety
safety_group = QGroupBox("Status && Safety")
safety_layout = QVBoxLayout()
# Row 1: status registers + query/reset buttons
reg_row = QHBoxLayout()
btn_query_status = QPushButton("Query Status")
btn_query_status.clicked.connect(self.on_query_status)
btn_query_status.setMaximumWidth(130)
reg_row.addWidget(btn_query_status)
btn_reset_faults = QPushButton("Reset Faults")
btn_reset_faults.setStyleSheet("background-color: #FFD700")
btn_reset_faults.clicked.connect(self.on_reset_faults)
btn_reset_faults.setMaximumWidth(130)
reg_row.addWidget(btn_reset_faults)
btn_ler_reset = QPushButton("LER 0 Reset")
btn_ler_reset.setStyleSheet("background-color: #FFA500")
btn_ler_reset.setToolTip("Send LER 0 only (clears controller error register)")
btn_ler_reset.clicked.connect(self.on_ler_reset)
btn_ler_reset.setMaximumWidth(130)
reg_row.addWidget(btn_ler_reset)
reg_row.addSpacing(20)
reg_row.addWidget(QLabel("LER:"))
self.lbl_ler = QLabel("—")
bold_font = self.lbl_ler.font()
bold_font.setBold(True)
self.lbl_ler.setFont(bold_font)
reg_row.addWidget(self.lbl_ler)
reg_row.addWidget(QLabel("LCE:"))
self.lbl_lce = QLabel("—")
self.lbl_lce.setFont(bold_font)
reg_row.addWidget(self.lbl_lce)
reg_row.addWidget(QLabel("CCE:"))
self.lbl_cce = QLabel("—")
self.lbl_cce.setFont(bold_font)
reg_row.addWidget(self.lbl_cce)
reg_row.addSpacing(20)
reg_row.addWidget(QLabel("Interlock:"))
self.lbl_interlock = QLabel("UNKNOWN")
self.lbl_interlock.setFont(bold_font)
self.lbl_interlock.setMinimumWidth(80)
reg_row.addWidget(self.lbl_interlock)
reg_row.addStretch()
safety_layout.addLayout(reg_row)
# Row 2: remote enable (LRE / pin 8)
lre_row = QHBoxLayout()
btn_lre_on = QPushButton("Remote Enable ON")
btn_lre_on.setStyleSheet("background-color: lightgreen")
btn_lre_on.clicked.connect(lambda: self.on_set_remote_enable(True))
btn_lre_on.setMaximumWidth(160)
lre_row.addWidget(btn_lre_on)
btn_lre_off = QPushButton("Remote Enable OFF")
btn_lre_off.setStyleSheet("background-color: lightcoral")
btn_lre_off.clicked.connect(lambda: self.on_set_remote_enable(False))
btn_lre_off.setMaximumWidth(160)
lre_row.addWidget(btn_lre_off)
btn_lre_query = QPushButton("Query LRE")
btn_lre_query.clicked.connect(self.on_query_remote_enable)
btn_lre_query.setMaximumWidth(120)
lre_row.addWidget(btn_lre_query)
lre_row.addSpacing(20)
lre_row.addWidget(QLabel("Enable Pin (LRE):"))
self.lbl_lre = QLabel("UNKNOWN")
self.lbl_lre.setFont(bold_font)
self.lbl_lre.setMinimumWidth(80)
lre_row.addWidget(self.lbl_lre)
lre_row.addStretch()
safety_layout.addLayout(lre_row)
# Row 3: decoded register flags
self.text_register_decode = QTextEdit()
self.text_register_decode.setReadOnly(True)
self.text_register_decode.setMinimumHeight(110)
self.text_register_decode.setMaximumHeight(160)
self.text_register_decode.setPlaceholderText(
"Register flags will appear here after querying status…"
)
mono_font = QFont("Monospace")
mono_font.setStyleHint(QFont.StyleHint.Monospace)
self.text_register_decode.setFont(mono_font)
safety_layout.addWidget(self.text_register_decode)
safety_group.setLayout(safety_layout)
layout.addWidget(safety_group)
layout.addStretch()
return widget
def create_monitor_tab(self) -> QWidget:
"""Create the monitoring/information tab."""
widget = QWidget()
layout = QVBoxLayout(widget)
# System information
info_group = QGroupBox("System Information")
info_layout = QHBoxLayout()
btn_query_all = QPushButton("Query All")
btn_query_all.clicked.connect(self.on_query_all)
info_layout.addWidget(btn_query_all)
info_layout.addWidget(QLabel("Controller SN:"))
self.lbl_controller_sn = QLineEdit()
self.lbl_controller_sn.setReadOnly(True)
info_layout.addWidget(self.lbl_controller_sn)
info_layout.addWidget(QLabel("Head SN:"))
self.lbl_head_sn = QLineEdit()
self.lbl_head_sn.setReadOnly(True)
info_layout.addWidget(self.lbl_head_sn)
info_group.setLayout(info_layout)
layout.addWidget(info_group)
# Power monitoring
power_group = QGroupBox("Power Monitoring")
power_layout = QHBoxLayout()
btn_get_power = QPushButton("Query Power")
btn_get_power.clicked.connect(self.on_query_power)
power_layout.addWidget(btn_get_power)
power_layout.addWidget(QLabel("Output Power:"))
self.lbl_power = QLineEdit()
self.lbl_power.setReadOnly(True)
self.lbl_power.setMaximumWidth(150)
power_layout.addWidget(self.lbl_power)
power_layout.addWidget(QLabel("mW"))
power_layout.addStretch()
power_group.setLayout(power_layout)
layout.addWidget(power_group)
# Log/Message display
log_group = QGroupBox("Messages")
log_layout = QVBoxLayout()
self.text_log = QTextEdit()
self.text_log.setReadOnly(True)
self.text_log.setMaximumHeight(300)
log_layout.addWidget(self.text_log)
log_group.setLayout(log_layout)
layout.addWidget(log_group)
layout.addStretch()
return widget
def create_terminal_tab(self) -> QWidget:
"""Create the raw command terminal tab."""
widget = QWidget()
layout = QVBoxLayout(widget)
# Input row
cmd_group = QGroupBox("Raw Command")
cmd_layout = QHBoxLayout()
cmd_layout.addWidget(QLabel("Command:"))
self.le_raw_cmd = QLineEdit()
self.le_raw_cmd.setPlaceholderText("e.g. LDF or LDS 1000 or LER 0")
self.le_raw_cmd.returnPressed.connect(self.on_send_raw)
cmd_layout.addWidget(self.le_raw_cmd)
btn_send_raw = QPushButton("Send")
btn_send_raw.setMaximumWidth(80)
btn_send_raw.clicked.connect(self.on_send_raw)
cmd_layout.addWidget(btn_send_raw)
btn_clear_terminal = QPushButton("Clear")
btn_clear_terminal.setMaximumWidth(70)
btn_clear_terminal.clicked.connect(lambda: self.text_terminal.clear())
cmd_layout.addWidget(btn_clear_terminal)
cmd_group.setLayout(cmd_layout)
layout.addWidget(cmd_group)
# Terminal output
term_group = QGroupBox("Response Log")
term_layout = QVBoxLayout()
self.text_terminal = QTextEdit()
self.text_terminal.setReadOnly(True)
mono_font = QFont("Monospace")
mono_font.setStyleHint(QFont.StyleHint.Monospace)
self.text_terminal.setFont(mono_font)
self.text_terminal.setPlaceholderText(
"TX/RX pairs will appear here.\n"
"Commands are sent as-is with a trailing CR.\n"
"No ? suffix needed — send the command name to query (e.g. LDF)."
)
term_layout.addWidget(self.text_terminal)
term_group.setLayout(term_layout)
layout.addWidget(term_group)
return widget
def update_port_list(self):
"""Update the available serial ports."""
self.combo_port.clear()
try:
ports = HeliosLaser.list_available_ports()
if ports:
self.combo_port.addItems(ports)
else:
self.combo_port.addItem("No ports found")
except Exception as e:
self.combo_port.addItem(f"Error: {e}")
logger.error(f"Error listing ports: {e}")
def toggle_connection(self):
"""Toggle connection to the laser."""
if self.laser.is_connected:
self.disconnect_laser()
else:
self.connect_laser()
def connect_laser(self):
"""Connect to the laser."""
port = self.combo_port.currentText()
if not port or "No ports" in port or "Error" in port:
QMessageBox.warning(self, "Connection Error", "No valid port selected")
return
if self.laser.connect(port):
self.lbl_status.setText(f"Status: Connected to {port}")
self.lbl_status.setStyleSheet("color: green")
self.btn_connect.setText("Disconnect")
self.combo_port.setEnabled(False)
# Create worker for async operations
self.worker = LaserWorker(self.laser)
self.worker_thread = QThread()
self.worker.moveToThread(self.worker_thread)
self.worker.operation_complete.connect(self.on_operation_complete)
self.worker.frequency_updated.connect(self.on_frequency_updated)
self.worker.current_updated.connect(self.on_current_updated)
self.worker.power_updated.connect(self.on_power_updated)
self.worker.enabled_updated.connect(self.on_enabled_updated)
self.worker.serial_updated.connect(self.on_serial_updated)
self.worker.status_registers_updated.connect(self.on_status_registers_updated)
self.worker.remote_enable_updated.connect(self.on_remote_enable_updated)
self.worker.raw_response_received.connect(self.on_raw_response)
self.worker_thread.start()
self.statusBar().showMessage(f"Connected to {port}")
logger.info(f"Connected to {port}")
else:
QMessageBox.critical(self, "Connection Error", f"Failed to connect to {port}")
def disconnect_laser(self):
"""Disconnect from the laser."""
if self.worker_thread:
self.worker_thread.quit()
self.worker_thread.wait()
self.laser.disconnect()
self.lbl_status.setText("Status: Disconnected")
self.lbl_status.setStyleSheet("color: red")
self.btn_connect.setText("Connect")
self.combo_port.setEnabled(True)
self.statusBar().showMessage("Disconnected")
logger.info("Disconnected from laser")
def on_set_frequency(self):
"""Set the laser frequency."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
freq = self.spin_frequency.value()
self.worker.set_frequency(freq)
def on_query_frequency(self):
"""Query the laser frequency."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.query_frequency()
def on_set_current(self):
"""Set the laser current."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
current = self.spin_current.value()
self.worker.set_current(current)
def on_query_current(self):
"""Query the laser current."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.query_current()
def on_set_mode(self):
"""Set the laser pulse mode."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
mode = self.combo_mode.currentData()
self.worker.set_pulse_mode(mode)
def on_enable_laser(self):
"""Enable the laser."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.set_laser_enable(True)
def on_disable_laser(self):
"""Disable the laser."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.set_laser_enable(False)
def on_query_enabled(self):
"""Query if laser is enabled."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.query_enabled()
def on_query_power(self):
"""Query the laser output power."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.query_power()
def on_query_all(self):
"""Query all laser parameters."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.query_serials()
self.worker.query_frequency()
self.worker.query_current()
self.worker.query_power()
self.worker.query_enabled()
self.worker.query_status_registers()
self.worker.query_remote_enable()
def on_query_status(self):
"""Query LER/LCE/CCE status registers."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.query_status_registers()
def on_reset_faults(self):
"""Send the fault reset sequence."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.do_reset_faults()
def on_query_remote_enable(self):
"""Query the remote enable (LRE) state."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.query_remote_enable()
def on_set_remote_enable(self, enable: bool):
"""Set the remote enable (LRE) state."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.set_remote_enable(enable)
def on_ler_reset(self):
"""Send LER 0 only."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
self.worker.do_ler_reset()
def on_send_raw(self):
"""Send the raw command from the terminal input."""
if not self.laser.is_connected:
QMessageBox.warning(self, "Error", "Not connected to laser")
return
cmd = self.le_raw_cmd.text().strip()
if not cmd:
return
self.worker.send_raw(cmd)
def on_raw_response(self, cmd: str, response: str):
"""Display raw TX/RX pair in the terminal log."""
self.text_terminal.append(f"TX: {cmd}")
self.text_terminal.append(f"RX: {response if response else '<no response>'}")
self.text_terminal.append("")
self.text_terminal.verticalScrollBar().setValue(
self.text_terminal.verticalScrollBar().maximum()
)
def on_operation_complete(self, success: bool, message: str):
"""Handle operation completion."""
self.log_message(message)
if not success:
QMessageBox.warning(self, "Operation Failed", message)
def on_frequency_updated(self, freq: int):
"""Update frequency display."""
self.spin_frequency.setValue(freq)
def on_current_updated(self, current: int):
"""Update current display."""
self.spin_current.setValue(current)
def on_power_updated(self, power: float):
"""Update power display."""
self.lbl_power.setText(f"{power:.2f}")
def on_enabled_updated(self, enabled: bool):
"""Update enabled status display."""
state = "Enabled" if enabled else "Disabled"
color = "green" if enabled else "red"
self.lbl_enabled.setText(f"Status: {state}")
self.lbl_enabled.setStyleSheet(f"color: {color}")
def on_serial_updated(self, controller_sn: str, head_sn: str):
"""Update serial number displays."""
self.lbl_controller_sn.setText(controller_sn)
self.lbl_head_sn.setText(head_sn)
def on_status_registers_updated(self, ler, lce, cce):
"""Update status register displays, interlock indicator, and decoded flags."""
def _fmt(val):
return str(val) if val is not None else "ERR"
self.lbl_ler.setText(_fmt(ler))
self.lbl_lce.setText(_fmt(lce))
self.lbl_cce.setText(_fmt(cce))
# Interlock fault: any non-zero register value indicates an active fault
if any(v is None for v in (ler, lce, cce)):
self.lbl_interlock.setText("UNKNOWN")
self.lbl_interlock.setStyleSheet("color: gray")
elif any(v != 0 for v in (ler, lce, cce)):
self.lbl_interlock.setText("FAULT")
self.lbl_interlock.setStyleSheet("color: red; font-weight: bold")
else:
self.lbl_interlock.setText("OK")
self.lbl_interlock.setStyleSheet("color: green; font-weight: bold")
# Decode and display individual flags
lines = []
for reg_name, value, flags_dict in (
("LER", ler, _LER_FLAGS),
("LCE", lce, _LCE_FLAGS),
("CCE", cce, _CCE_FLAGS),
):
if value is None:
lines.append(f"{reg_name}: <read error>")
continue
active = _decode_register(flags_dict, value)
if not active:
lines.append(f"{reg_name} (raw={value}): OK — no flags set")
else:
lines.append(f"{reg_name} (raw={value}):")
for bit, sev, desc, comment in active:
label = _SEVERITY_LABEL.get(sev, '[ ]')
lines.append(f" {label} bit {bit:2d} ({1 << bit:>5}): {desc}")
lines.append(f" → {comment}")
self.text_register_decode.setPlainText("\n".join(lines))
def on_remote_enable_updated(self, state):
"""Update remote enable (LRE) indicator."""
if state is None:
self.lbl_lre.setText("UNKNOWN")
self.lbl_lre.setStyleSheet("color: gray")
elif state:
self.lbl_lre.setText("ACTIVE")
self.lbl_lre.setStyleSheet("color: green; font-weight: bold")
else:
self.lbl_lre.setText("INACTIVE")
self.lbl_lre.setStyleSheet("color: red; font-weight: bold")
def log_message(self, message: str):
"""Add message to log."""
self.text_log.append(message)
# Auto-scroll to bottom
self.text_log.verticalScrollBar().setValue(
self.text_log.verticalScrollBar().maximum()
)
def closeEvent(self, event):
"""Handle application close."""
if self.laser.is_connected:
self.disconnect_laser()
if self.worker_thread and self.worker_thread.isRunning():
self.worker_thread.quit()
self.worker_thread.wait()
event.accept()
def main():
"""Run the application."""
app = QApplication(sys.argv)
window = HeliosTestApp()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()