986 lines
37 KiB
Python
Executable File
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()
|