eff589d901
_value_in()'s last resort was to accept any line it could not attribute as the answer to whatever had just been asked. That fallback exists for the serial numbers, which come back bare — but it applied to every query, so a stray "32" could be read as a diode current of 32 mA. 32 is also what a status register reads with bit 5 set (LER "Over voltage laser diode", LCE "Door switch open", CCE "Q-switch under/over temperature"), which is exactly the value the panel is stuck on. Only CSR and HSR now accept an unlabelled reply; every other read has to see its own mnemonic in the line. A read that cannot be attributed returns None, and the panel shows "laser: ? mA" instead of leaving the last good value on screen looking live — a stale reading and a setpoint that refuses to move are indistinguishable otherwise. tools/helios_lds_probe.py is the diagnostic for the underlying question: it talks to the controller with no reply parsing at all and prints every byte, so the transcript says whether LDS answers for itself, whether the write is taken, and which flags the registers hold before and after. The status-register tables move to hardware/helios_registers.py so the probe can decode them without importing the Qt app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
909 lines
33 KiB
Python
Executable File
909 lines
33 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 PyQt6.QtWidgets import (
|
|
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
|
QGroupBox, QLabel, QLineEdit, QPushButton, QComboBox, QSpinBox,
|
|
QMessageBox, QTabWidget, QTextEdit
|
|
)
|
|
from PyQt6.QtCore import QThread, pyqtSignal, pyqtSlot, QObject
|
|
from PyQt6.QtGui import QFont
|
|
|
|
from hardware.helios_laser import HeliosLaser, PulseMode
|
|
from hardware.helios_registers import (
|
|
CCE_FLAGS, LCE_FLAGS, LER_FLAGS, SEVERITY_LABEL, decode_register,
|
|
)
|
|
|
|
# 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
|
|
|
|
@pyqtSlot(str, object)
|
|
def invoke(self, method_name: str, args: tuple):
|
|
"""Run one of this worker's methods on the worker thread.
|
|
|
|
Reached through a queued signal connection, so the serial I/O (and
|
|
its blocking reads) stays off the GUI thread. Calling the methods
|
|
directly, as this app used to, executes them in the caller's thread
|
|
and freezes the UI for the duration.
|
|
"""
|
|
try:
|
|
getattr(self, method_name)(*args)
|
|
except Exception as exc:
|
|
self.operation_complete.emit(False, str(exc))
|
|
|
|
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):
|
|
# HeliosLaser has no power query: the driver README advertises
|
|
# get_power_mw(), but no such method exists and the protocol
|
|
# mnemonic for an output-power read is not documented anywhere in
|
|
# this repo. This used to raise AttributeError into a popup.
|
|
# See KNOWN_ISSUES.md — needs the command from the Helios manual.
|
|
self.operation_complete.emit(
|
|
False, "Power query is not implemented (no known protocol command)")
|
|
|
|
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."""
|
|
|
|
# Dispatches a worker method name + args across the thread boundary.
|
|
worker_call = pyqtSignal(str, object)
|
|
|
|
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.setEnabled(False)
|
|
btn_get_power.setToolTip(
|
|
"Not implemented: no documented Helios protocol command for "
|
|
"output power (see KNOWN_ISSUES.md).")
|
|
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)
|
|
# Queued (cross-thread) connection: the worker's methods run on
|
|
# the worker thread, not on whichever thread emits.
|
|
self.worker_call.connect(self.worker.invoke)
|
|
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 _require_connection(self) -> bool:
|
|
"""Warn and return False when no laser is connected."""
|
|
if self.laser.is_connected and self.worker is not None:
|
|
return True
|
|
QMessageBox.warning(self, "Error", "Not connected to laser")
|
|
return False
|
|
|
|
def _call_worker(self, method_name: str, *args):
|
|
"""Run a worker method on the worker thread via a queued signal."""
|
|
if self.worker is not None:
|
|
self.worker_call.emit(method_name, args)
|
|
|
|
def disconnect_laser(self):
|
|
"""Disconnect from the laser."""
|
|
if self.worker_thread:
|
|
self.worker_thread.quit()
|
|
self.worker_thread.wait(2000)
|
|
# Drop both references: a reconnect used to leak the previous
|
|
# QThread, worker, and all nine signal connections.
|
|
self.worker = None
|
|
self.worker_thread = None
|
|
|
|
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._require_connection():
|
|
return
|
|
|
|
freq = self.spin_frequency.value()
|
|
self._call_worker("set_frequency", freq)
|
|
|
|
def on_query_frequency(self):
|
|
"""Query the laser frequency."""
|
|
if not self._require_connection():
|
|
return
|
|
|
|
self._call_worker("query_frequency")
|
|
|
|
def on_set_current(self):
|
|
"""Set the laser current."""
|
|
if not self._require_connection():
|
|
return
|
|
|
|
current = self.spin_current.value()
|
|
self._call_worker("set_current", current)
|
|
|
|
def on_query_current(self):
|
|
"""Query the laser current."""
|
|
if not self._require_connection():
|
|
return
|
|
|
|
self._call_worker("query_current")
|
|
|
|
def on_set_mode(self):
|
|
"""Set the laser pulse mode."""
|
|
if not self._require_connection():
|
|
return
|
|
|
|
mode = self.combo_mode.currentData()
|
|
self._call_worker("set_pulse_mode", mode)
|
|
|
|
def on_enable_laser(self):
|
|
"""Enable the laser."""
|
|
if not self._require_connection():
|
|
return
|
|
|
|
self._call_worker("set_laser_enable", True)
|
|
|
|
def on_disable_laser(self):
|
|
"""Disable the laser."""
|
|
if not self._require_connection():
|
|
return
|
|
|
|
self._call_worker("set_laser_enable", False)
|
|
|
|
def on_query_enabled(self):
|
|
"""Query if laser is enabled."""
|
|
if not self._require_connection():
|
|
return
|
|
|
|
self._call_worker("query_enabled")
|
|
|
|
def on_query_power(self):
|
|
"""Query the laser output power."""
|
|
if not self._require_connection():
|
|
return
|
|
|
|
self._call_worker("query_power")
|
|
|
|
def on_query_all(self):
|
|
"""Query all laser parameters."""
|
|
if not self._require_connection():
|
|
return
|
|
|
|
self._call_worker("query_serials")
|
|
self._call_worker("query_frequency")
|
|
self._call_worker("query_current")
|
|
self._call_worker("query_power")
|
|
self._call_worker("query_enabled")
|
|
self._call_worker("query_status_registers")
|
|
self._call_worker("query_remote_enable")
|
|
|
|
def on_query_status(self):
|
|
"""Query LER/LCE/CCE status registers."""
|
|
if not self._require_connection():
|
|
return
|
|
self._call_worker("query_status_registers")
|
|
|
|
def on_reset_faults(self):
|
|
"""Send the fault reset sequence."""
|
|
if not self._require_connection():
|
|
return
|
|
self._call_worker("do_reset_faults")
|
|
|
|
def on_query_remote_enable(self):
|
|
"""Query the remote enable (LRE) state."""
|
|
if not self._require_connection():
|
|
return
|
|
self._call_worker("query_remote_enable")
|
|
|
|
def on_set_remote_enable(self, enable: bool):
|
|
"""Set the remote enable (LRE) state."""
|
|
if not self._require_connection():
|
|
return
|
|
self._call_worker("set_remote_enable", enable)
|
|
|
|
def on_ler_reset(self):
|
|
"""Send LER 0 only."""
|
|
if not self._require_connection():
|
|
return
|
|
self._call_worker("do_ler_reset")
|
|
|
|
def on_send_raw(self):
|
|
"""Send the raw command from the terminal input."""
|
|
if not self._require_connection():
|
|
return
|
|
cmd = self.le_raw_cmd.text().strip()
|
|
if not cmd:
|
|
return
|
|
self._call_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()
|