Files
scanengine-3/tools/genesis_laser_gui.py
T
Thomas Ales 67aabde4b6 Phase 1c: prune remaining dead functions, unused imports, quarantine Genesis
- uc480_camera: drop never-called _capture_paused/get_framerate (the
  hardware question _capture_paused encoded is now in KNOWN_ISSUES.md)
- t3r_protocol: drop read_reg/write_reg/decode_reg/Reg (commands never
  wired into the driver)
- bbd20x: drop _update0x0212 (never dispatched) and 8 of 9 unused
  trigger convenience wrappers; apt_constants: drop TriggerBitsStepper
  (servo-only rig)
- ruff --fix: 35 unused imports across all apps; drop unused T3R_BAUD
- genesis_core.py: quarantine warning header; docs/genesis_verification.md
  bench checklist for the 7 divergences vs tools/genesis_laser_gui.py

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 10:08:48 -05:00

1445 lines
46 KiB
Python
Executable File

#!/opt/srasenv/bin/python3
"""
Genesis SLM MX 532 Laser Control Application
=============================================
This application controls a Genesis SLM MX 532 laser via I2C-over-serial protocol.
PROTOCOL OVERVIEW:
- Serial: /dev/ttyUSB0 @ 9600 8N1
- Protocol: NXP I2C tunneled over serial
- Packet format: [0x53][addr][len][cmd][data][0x50]
SAFETY WARNINGS:
- This laser has a MANUAL shutter - operate it manually
- Always close manual shutter before adjusting current
- Verify interlock status before opening manual shutter
- Use emergency stop if anything looks wrong
- Never bypass safety interlocks
USAGE:
1. Connect serial port in Configuration tab
2. Enable Remote and Keyswitch
3. Set current to desired level
4. Manually operate shutter as needed
5. Monitor temperature and current
6. Manually close shutter when done
For more information, see laser_control_implementation_guide.md
"""
import sys
import time
import serial
from datetime import datetime
from typing import Optional, List
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QTabWidget, QPushButton, QSlider, QSpinBox, QLabel, QComboBox,
QLineEdit, QTextEdit, QCheckBox, QMessageBox, QGroupBox, QGridLayout
)
from PyQt6.QtCore import QObject, pyqtSignal, QTimer, Qt
# ============================================================================
# CONSTANTS
# ============================================================================
# I2C Addresses (7-bit, write bit cleared)
ADDR_X9119_CURRENT = 0x52
ADDR_PCA9555_PS_DIO = 0x40
ADDR_PCA9555_HEAD_DIO = 0x44
ADDR_PCA9555_PS_GLUE_IN = 0x48
ADDR_PCA9555_PS_GLUE_OUT = 0x4a
ADDR_AD5254 = 0x58
ADDR_ADS7828 = 0x90
ADDR_EEPROM = 0xa4
ADDR_STM32 = 0xae
# PCA9555 Bit Masks (0x4a - PS Glue Out)
BIT_SHUTTER = 0x01
BIT_CURRENT_MODE = 0x04
BIT_REMOTE_ENABLE = 0x08
BIT_ANALOG_ENABLE = 0x10
BIT_KEYSWITCH = 0x20
# X9119 Commands
CMD_X9119_WRITE_WIPER = 0xa0
# ADS7828 Channels
CHAN_CURRENT_ACTUAL = 0x84
CHAN_PHOTO_ACTUAL = 0xe4
CHAN_MAIN_TEMP = 0x94
CHAN_ETALON_TEMP = 0xa4 # TODO: Verify channel
CHAN_SHG_TEMP = 0xb4 # TODO: Verify channel
# Scaling constants
AMPS_FULLSCALE = 12.0
WATTS_FULLSCALE = 2.0
POWER_LIMIT = 0.55
ADC_TO_VOLTS = 0.000244140625 # 1/4096
# Temperature calibration constants (for future use)
# Steinhart-Hart: A=0.0011279, B=0.00023429, C=8.7298e-8
# PT1000: Vref=12V, R1=10kΩ
# Currently displaying raw ADC values until circuit parameters are confirmed
# Serial Protocol
NXP_START_BYTE = 0x53
NXP_STOP_BYTE = 0x50
# ============================================================================
# SERIAL COMMUNICATION LAYER
# ============================================================================
class SerialComm(QObject):
"""Handles serial port communication with the laser controller."""
connected = pyqtSignal()
disconnected = pyqtSignal()
error = pyqtSignal(str)
data_received = pyqtSignal(bytes)
packet_sent = pyqtSignal(bytes)
def __init__(self):
super().__init__()
self.port: Optional[serial.Serial] = None
self.is_connected = False
self.timeout = 1.0
self.packet_log: List[str] = []
def connect(self, port_name: str, baudrate: int = 9600) -> bool:
"""
Connect to serial port.
Args:
port_name: Serial port path (e.g., '/dev/ttyUSB0')
baudrate: Baud rate (default: 9600)
Returns:
True if connection successful
"""
try:
self.port = serial.Serial(
port=port_name,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout
)
self.is_connected = True
self.connected.emit()
self._log_packet(f"Connected to {port_name} @ {baudrate}")
return True
except Exception as e:
self.error.emit(f"Connection failed: {str(e)}")
return False
def disconnect(self):
"""Disconnect from serial port."""
if self.port and self.port.is_open:
self.port.close()
self.is_connected = False
self.disconnected.emit()
self._log_packet("Disconnected")
def write_packet(self, data: bytes) -> bool:
"""
Write packet to serial port.
Args:
data: Bytes to write
Returns:
True if write successful
"""
if not self.is_connected or not self.port:
self.error.emit("Not connected")
return False
try:
self.port.write(data)
self.packet_sent.emit(data)
self._log_packet(f"TX: {data.hex(' ')}")
return True
except Exception as e:
self.error.emit(f"Write failed: {str(e)}")
return False
def read_packet(self, length: int) -> Optional[bytes]:
"""
Read packet from serial port.
Args:
length: Number of bytes to read
Returns:
Bytes read or None on error
"""
if not self.is_connected or not self.port:
self.error.emit("Not connected")
return None
try:
data = self.port.read(length)
if data:
self.data_received.emit(data)
self._log_packet(f"RX: {data.hex(' ')}")
return data
except Exception as e:
self.error.emit(f"Read failed: {str(e)}")
return None
def _log_packet(self, message: str):
"""Log packet with timestamp."""
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
log_entry = f"[{timestamp}] {message}"
self.packet_log.append(log_entry)
def get_packet_log(self) -> List[str]:
"""Get packet log."""
return self.packet_log.copy()
def clear_packet_log(self):
"""Clear packet log."""
self.packet_log.clear()
# ============================================================================
# I2C PROTOCOL LAYER
# ============================================================================
class I2CProtocol(QObject):
"""Implements NXP I2C-over-serial protocol."""
def __init__(self, serial_comm: SerialComm):
super().__init__()
self.serial = serial_comm
def nxp_write(self, i2c_addr_write: int, cmd: int, data: int, data_len: int) -> bool:
"""
Build and send NXP I2C write packet.
Args:
i2c_addr_write: I2C device address (write bit cleared)
cmd: Command byte(s) - int for 1 or 2 bytes
data: Data value to write
data_len: 1, 2, or 4 bytes
Returns:
True if successful
"""
# Determine command length
if cmd <= 0xFF:
cmd_bytes = bytes([cmd])
else:
cmd_bytes = cmd.to_bytes(2, 'big')
# Convert data to bytes (big-endian)
data_bytes = data.to_bytes(data_len, 'big')
# Calculate total length
total_len = len(cmd_bytes) + len(data_bytes)
# Build packet: [0x53][addr][len][cmd...][data...][0x50]
packet = bytes([
NXP_START_BYTE,
i2c_addr_write,
total_len
]) + cmd_bytes + data_bytes + bytes([NXP_STOP_BYTE])
return self.serial.write_packet(packet)
def nxp_read(self, i2c_addr_write: int, cmd: int, cmd_len: int, data_len: int) -> Optional[int]:
"""
Build and send NXP I2C read packet.
Args:
i2c_addr_write: I2C device address (write bit cleared)
cmd: Command byte(s)
cmd_len: 1 or 2 bytes
data_len: Number of bytes to read
Returns:
Integer value read from device or None on error
"""
# Convert command to bytes
if cmd_len == 1:
cmd_bytes = bytes([cmd])
else:
cmd_bytes = cmd.to_bytes(2, 'big')
# Build packet: [0x53][addr][cmd_len][cmd...][0x53][addr|0x01][data_len][0x50]
packet = bytes([
NXP_START_BYTE,
i2c_addr_write,
cmd_len
]) + cmd_bytes + bytes([
NXP_START_BYTE,
i2c_addr_write | 0x01, # Read address
data_len,
NXP_STOP_BYTE
])
# Send packet
if not self.serial.write_packet(packet):
return None
# Read response
response = self.serial.read_packet(data_len)
if not response or len(response) != data_len:
return None
# Convert bytes to integer (big-endian)
return int.from_bytes(response, 'big')
# Read strategies
def i2c_read_one(self, addr: int, cmd: int, data_len: int) -> Optional[int]:
"""
Single read, no filtering. Use for digital I/O.
Args:
addr: I2C address
cmd: Command byte(s)
data_len: Number of bytes to read
Returns:
Value read or None on error
"""
cmd_len = 1 if cmd <= 0xFF else 2
return self.nxp_read(addr, cmd, cmd_len, data_len)
def i2c_read_match_two(self, addr: int, cmd: int, data_len: int, max_attempts: int = 5) -> Optional[int]:
"""
Read until 2 values match (up to max_attempts). Most reliable.
Use for EEPROM reads, configuration values.
Args:
addr: I2C address
cmd: Command byte(s)
data_len: Number of bytes to read
max_attempts: Maximum read attempts
Returns:
Matched value or first value if no match
"""
cmd_len = 1 if cmd <= 0xFF else 2
readings = []
for attempt in range(max_attempts):
value = self.nxp_read(addr, cmd, cmd_len, data_len)
if value is None:
continue
readings.append(value)
if readings.count(value) >= 2:
return value
time.sleep(0.01)
return readings[0] if readings else None
def i2c_read_discard_high_low(self, addr: int, cmd: int, data_len: int) -> Optional[int]:
"""
Read 3 times, return median. Filters noise.
Use for ADC readings (current, temperature, voltage).
Args:
addr: I2C address
cmd: Command byte(s)
data_len: Number of bytes to read
Returns:
Median value or None on error
"""
cmd_len = 1 if cmd <= 0xFF else 2
readings = []
for _ in range(3):
value = self.nxp_read(addr, cmd, cmd_len, data_len)
if value is not None:
readings.append(value)
time.sleep(0.01)
if len(readings) < 3:
return readings[0] if readings else None
return sorted(readings)[1] # Median
# Device-specific functions
def pca9555_read_port(self, addr: int, port: int) -> Optional[int]:
"""Read PCA9555 port register (0x00-0x07)."""
return self.i2c_read_one(addr, port, 1)
def pca9555_write_port(self, addr: int, port: int, value: int) -> bool:
"""Write PCA9555 port register."""
return self.nxp_write(addr, port, value, 1)
def pca9555_set_bit(self, addr: int, port: int, bitmask: int, state: bool) -> bool:
"""
Set or clear specific bit on PCA9555.
Reads current value, modifies bit, writes back.
Args:
addr: I2C address
port: Port number (0 or 1)
bitmask: Bit mask
state: True to set, False to clear
Returns:
True if successful
"""
# Read current output port value
current = self.pca9555_read_port(addr, 0x02 + port)
if current is None:
return False
# Modify bit
if state:
new_value = current | bitmask
else:
new_value = current & ~bitmask
# Write back
return self.pca9555_write_port(addr, 0x02 + port, new_value)
def x9119_write_wiper(self, addr: int, value: int) -> bool:
"""
Set X9119 wiper position (0-1023).
Args:
addr: I2C address
value: Wiper position (0-1023)
Returns:
True if successful
"""
if value > 1023:
raise ValueError("X9119 value must be 0-1023")
return self.nxp_write(addr, CMD_X9119_WRITE_WIPER, value, 2)
def ads7828_read(self, addr: int, channel: int) -> Optional[int]:
"""
Read ADS7828 ADC channel.
Args:
addr: I2C address
channel: Channel command byte
Returns:
12-bit ADC value or None on error
"""
return self.i2c_read_discard_high_low(addr, channel, 2)
# ============================================================================
# LASER CONTROL LAYER
# ============================================================================
class LaserControl(QObject):
"""High-level laser control functions."""
status_changed = pyqtSignal(str)
def __init__(self, i2c_protocol: I2CProtocol):
super().__init__()
self.i2c = i2c_protocol
# Control functions
def set_current(self, value: int) -> bool:
"""
CCMD= - Set laser current (0-1023).
Args:
value: Current setting (0-1023)
Returns:
True if successful
"""
if not 0 <= value <= 1023:
return False
return self.i2c.x9119_write_wiper(ADDR_X9119_CURRENT, value)
def set_power_cmd(self, value: int) -> bool:
"""
PCMD= - Set power command (0-1023).
Args:
value: Power setting (0-1023)
Returns:
True if successful
"""
if not 0 <= value <= 1023:
return False
return self.i2c.x9119_write_wiper(ADDR_X9119_CURRENT, value)
def set_shutter(self, state: bool) -> bool:
"""
SHCMD= - Control shutter (True=open, False=closed).
Args:
state: True to open, False to close
Returns:
True if successful
"""
return self.i2c.pca9555_set_bit(ADDR_PCA9555_PS_GLUE_OUT, 0, BIT_SHUTTER, state)
def set_keyswitch(self, state: bool) -> bool:
"""
KSWCMD= - Control keyswitch (True=on, False=off).
Args:
state: True for on, False for off
Returns:
True if successful
"""
return self.i2c.pca9555_set_bit(ADDR_PCA9555_PS_GLUE_OUT, 0, BIT_KEYSWITCH, state)
def set_remote_enable(self, state: bool) -> bool:
"""
REM= - Enable remote control (True=enabled).
Args:
state: True to enable, False to disable
Returns:
True if successful
"""
return self.i2c.pca9555_set_bit(ADDR_PCA9555_PS_GLUE_OUT, 0, BIT_REMOTE_ENABLE, state)
def set_analog_enable(self, state: bool) -> bool:
"""
ANACMD= - Enable analog input (True=enabled).
Args:
state: True to enable, False to disable
Returns:
True if successful
"""
return self.i2c.pca9555_set_bit(ADDR_PCA9555_PS_GLUE_OUT, 0, BIT_ANALOG_ENABLE, state)
def set_current_mode(self, state: bool) -> bool:
"""
CMODECMD= - Set current mode (True=enabled).
Args:
state: True to enable, False to disable
Returns:
True if successful
"""
return self.i2c.pca9555_set_bit(ADDR_PCA9555_PS_GLUE_OUT, 0, BIT_CURRENT_MODE, state)
# Monitoring functions
def get_current_actual(self) -> Optional[float]:
"""
Read actual current from ADC.
Returns:
Current in Amps or None on error
"""
raw = self.i2c.ads7828_read(ADDR_ADS7828, CHAN_CURRENT_ACTUAL)
if raw is None:
return None
return raw * AMPS_FULLSCALE * ADC_TO_VOLTS
def get_power_actual(self) -> Optional[float]:
"""
Read actual power from ADC.
Returns:
Power in Watts or None on error
"""
raw = self.i2c.ads7828_read(ADDR_ADS7828, CHAN_PHOTO_ACTUAL)
if raw is None:
return None
return raw * WATTS_FULLSCALE * ADC_TO_VOLTS
def get_main_temp(self) -> Optional[int]:
"""
Read main crystal temperature (raw ADC value).
Returns:
Raw ADC value (0-4095) or None on error
"""
return self.i2c.ads7828_read(ADDR_ADS7828, CHAN_MAIN_TEMP)
def get_etalon_temp(self) -> Optional[int]:
"""
Read etalon temperature (raw ADC value).
Returns:
Raw ADC value (0-4095) or None on error
"""
return self.i2c.ads7828_read(ADDR_ADS7828, CHAN_ETALON_TEMP)
def get_shg_temp(self) -> Optional[int]:
"""
Read SHG temperature (raw ADC value).
Returns:
Raw ADC value (0-4095) or None on error
"""
return self.i2c.ads7828_read(ADDR_ADS7828, CHAN_SHG_TEMP)
def get_interlock_status(self) -> Optional[bool]:
"""
Check interlock status.
Returns:
True if OK, False if fault, None on error
"""
value = self.i2c.pca9555_read_port(ADDR_PCA9555_PS_GLUE_IN, 0x00)
if value is None:
return None
return bool(value & 0x01)
def get_ldd_enable(self) -> Optional[bool]:
"""
Check if laser diode driver is enabled.
Returns:
True if enabled, False if disabled, None on error
"""
value = self.i2c.pca9555_read_port(ADDR_PCA9555_PS_DIO, 0x00)
if value is None:
return None
return not bool(value & 0x01) # Inverted logic
def get_ps_glue_out_status(self) -> Optional[int]:
"""
Read PS glue output port status.
Returns:
Port value or None on error
"""
return self.i2c.pca9555_read_port(ADDR_PCA9555_PS_GLUE_OUT, 0x02)
# Safety functions
def emergency_stop(self) -> bool:
"""
Emergency stop - set current to 0, disable keyswitch.
Note: Shutter is manually operated on this laser.
Returns:
True if all operations successful
"""
success = True
success &= self.set_current(0)
success &= self.set_keyswitch(False)
self.status_changed.emit("EMERGENCY STOP ACTIVATED")
return success
def safe_state(self) -> bool:
"""
Set laser to safe state.
Note: Shutter is manually operated on this laser.
Returns:
True if successful
"""
success = True
success &= self.set_current(0)
success &= self.set_analog_enable(False)
return success
def pre_flight_check(self) -> tuple[bool, str]:
"""
Pre-flight safety check.
Note: Shutter is manually operated on this laser.
Returns:
(safe, message) tuple
"""
# Check remote enable
status = self.get_ps_glue_out_status()
if status is None:
return False, "Cannot read status"
if not (status & BIT_REMOTE_ENABLE):
return False, "Remote enable is OFF"
if not (status & BIT_KEYSWITCH):
return False, "Keyswitch is OFF"
# Check interlock
interlock = self.get_interlock_status()
if interlock is None:
return False, "Cannot read interlock status"
if not interlock:
return False, "Interlock is OPEN"
return True, "All checks passed"
# ============================================================================
# GUI - BASIC CONTROLS TAB
# ============================================================================
class BasicControlTab(QWidget):
"""Basic laser control interface."""
def __init__(self, laser: LaserControl):
super().__init__()
self.laser = laser
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
# Current control
current_group = QGroupBox("Current Control")
current_layout = QGridLayout()
self.current_slider = QSlider(Qt.Orientation.Horizontal)
self.current_slider.setRange(0, 1023)
self.current_slider.setValue(0)
self.current_slider.valueChanged.connect(self.on_current_changed)
self.current_spinbox = QSpinBox()
self.current_spinbox.setRange(0, 1023)
self.current_spinbox.setValue(0)
self.current_spinbox.valueChanged.connect(self.current_slider.setValue)
self.current_slider.valueChanged.connect(self.current_spinbox.setValue)
self.current_percent_label = QLabel("0%")
self.current_zero_btn = QPushButton("Set to 0")
self.current_zero_btn.clicked.connect(lambda: self.current_slider.setValue(0))
current_layout.addWidget(QLabel("Current:"), 0, 0)
current_layout.addWidget(self.current_slider, 0, 1)
current_layout.addWidget(self.current_spinbox, 0, 2)
current_layout.addWidget(self.current_percent_label, 0, 3)
current_layout.addWidget(self.current_zero_btn, 1, 1)
current_group.setLayout(current_layout)
layout.addWidget(current_group)
# Power command
power_group = QGroupBox("Power Command")
power_layout = QGridLayout()
self.power_slider = QSlider(Qt.Orientation.Horizontal)
self.power_slider.setRange(0, 1023)
self.power_slider.setValue(0)
self.power_slider.valueChanged.connect(self.on_power_changed)
self.power_spinbox = QSpinBox()
self.power_spinbox.setRange(0, 1023)
self.power_spinbox.setValue(0)
self.power_spinbox.valueChanged.connect(self.power_slider.setValue)
self.power_slider.valueChanged.connect(self.power_spinbox.setValue)
power_layout.addWidget(QLabel("Power:"), 0, 0)
power_layout.addWidget(self.power_slider, 0, 1)
power_layout.addWidget(self.power_spinbox, 0, 2)
power_group.setLayout(power_layout)
layout.addWidget(power_group)
# Digital controls
digital_group = QGroupBox("Digital Controls")
digital_layout = QGridLayout()
self.keyswitch_btn = QPushButton("Keyswitch: OFF")
self.keyswitch_btn.setCheckable(True)
self.keyswitch_btn.clicked.connect(self.on_keyswitch_clicked)
self.remote_btn = QPushButton("Remote: OFF")
self.remote_btn.setCheckable(True)
self.remote_btn.clicked.connect(self.on_remote_clicked)
self.analog_btn = QPushButton("Analog: OFF")
self.analog_btn.setCheckable(True)
self.analog_btn.clicked.connect(self.on_analog_clicked)
self.current_mode_btn = QPushButton("Current Mode: OFF")
self.current_mode_btn.setCheckable(True)
self.current_mode_btn.clicked.connect(self.on_current_mode_clicked)
digital_layout.addWidget(self.keyswitch_btn, 0, 0)
digital_layout.addWidget(self.remote_btn, 0, 1)
digital_layout.addWidget(self.analog_btn, 1, 0)
digital_layout.addWidget(self.current_mode_btn, 1, 1)
digital_group.setLayout(digital_layout)
layout.addWidget(digital_group)
# Emergency stop
self.emergency_btn = QPushButton("EMERGENCY STOP")
self.emergency_btn.setObjectName("emergency")
self.emergency_btn.clicked.connect(self.on_emergency_stop)
layout.addWidget(self.emergency_btn)
# Status display
status_group = QGroupBox("Status")
status_layout = QVBoxLayout()
self.current_actual_label = QLabel("Current Actual: --")
self.connection_status_label = QLabel("Connection: Disconnected")
status_layout.addWidget(self.current_actual_label)
status_layout.addWidget(self.connection_status_label)
status_group.setLayout(status_layout)
layout.addWidget(status_group)
layout.addStretch()
self.setLayout(layout)
def on_current_changed(self, value: int):
"""Handle current slider change."""
percent = (value / 1023.0) * 100
self.current_percent_label.setText(f"{percent:.1f}%")
self.laser.set_current(value)
def on_power_changed(self, value: int):
"""Handle power slider change."""
self.laser.set_power_cmd(value)
def on_keyswitch_clicked(self, checked: bool):
"""Handle keyswitch button click."""
self.laser.set_keyswitch(checked)
self.keyswitch_btn.setText(f"Keyswitch: {'ON' if checked else 'OFF'}")
def on_remote_clicked(self, checked: bool):
"""Handle remote enable button click."""
self.laser.set_remote_enable(checked)
self.remote_btn.setText(f"Remote: {'ON' if checked else 'OFF'}")
def on_analog_clicked(self, checked: bool):
"""Handle analog enable button click."""
self.laser.set_analog_enable(checked)
self.analog_btn.setText(f"Analog: {'ON' if checked else 'OFF'}")
def on_current_mode_clicked(self, checked: bool):
"""Handle current mode button click."""
self.laser.set_current_mode(checked)
self.current_mode_btn.setText(f"Current Mode: {'ON' if checked else 'OFF'}")
def on_emergency_stop(self):
"""Handle emergency stop button click."""
self.laser.emergency_stop()
self.reset_controls()
def reset_controls(self):
"""Reset all controls to safe state."""
self.current_slider.setValue(0)
self.keyswitch_btn.setChecked(False)
self.keyswitch_btn.setText("Keyswitch: OFF")
def update_current_actual(self, value: Optional[float]):
"""Update current actual display."""
if value is not None:
self.current_actual_label.setText(f"Current Actual: {value:.3f} A")
else:
self.current_actual_label.setText("Current Actual: --")
def set_connection_status(self, connected: bool):
"""Update connection status display."""
if connected:
self.connection_status_label.setText("Connection: Connected")
self.connection_status_label.setStyleSheet("color: #00ff00;")
else:
self.connection_status_label.setText("Connection: Disconnected")
self.connection_status_label.setStyleSheet("color: #ff0000;")
# ============================================================================
# GUI - MONITORING TAB
# ============================================================================
class MonitoringTab(QWidget):
"""Real-time monitoring interface."""
def __init__(self, laser: LaserControl):
super().__init__()
self.laser = laser
self.auto_refresh_enabled = False
self.refresh_timer = QTimer()
self.refresh_timer.timeout.connect(self.refresh_readings)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout()
# Real-time readings
readings_group = QGroupBox("Real-Time Readings")
readings_layout = QGridLayout()
self.current_actual_label = QLabel("--")
self.power_actual_label = QLabel("--")
self.main_temp_label = QLabel("--")
self.etalon_temp_label = QLabel("--")
self.shg_temp_label = QLabel("--")
self.interlock_label = QLabel("--")
self.ldd_enable_label = QLabel("--")
readings_layout.addWidget(QLabel("Current Actual:"), 0, 0)
readings_layout.addWidget(self.current_actual_label, 0, 1)
readings_layout.addWidget(QLabel("Power Actual:"), 1, 0)
readings_layout.addWidget(self.power_actual_label, 1, 1)
readings_layout.addWidget(QLabel("Main Temperature:"), 2, 0)
readings_layout.addWidget(self.main_temp_label, 2, 1)
readings_layout.addWidget(QLabel("Etalon Temperature:"), 3, 0)
readings_layout.addWidget(self.etalon_temp_label, 3, 1)
readings_layout.addWidget(QLabel("SHG Temperature:"), 4, 0)
readings_layout.addWidget(self.shg_temp_label, 4, 1)
readings_layout.addWidget(QLabel("Interlock Status:"), 5, 0)
readings_layout.addWidget(self.interlock_label, 5, 1)
readings_layout.addWidget(QLabel("LDD Enable:"), 6, 0)
readings_layout.addWidget(self.ldd_enable_label, 6, 1)
readings_group.setLayout(readings_layout)
layout.addWidget(readings_group)
# Controls
controls_group = QGroupBox("Controls")
controls_layout = QVBoxLayout()
refresh_rate_layout = QHBoxLayout()
refresh_rate_layout.addWidget(QLabel("Refresh Rate (ms):"))
self.refresh_rate_slider = QSlider(Qt.Orientation.Horizontal)
self.refresh_rate_slider.setRange(100, 2000)
self.refresh_rate_slider.setValue(500)
self.refresh_rate_slider.valueChanged.connect(self.on_refresh_rate_changed)
refresh_rate_layout.addWidget(self.refresh_rate_slider)
self.refresh_rate_label = QLabel("500")
refresh_rate_layout.addWidget(self.refresh_rate_label)
controls_layout.addLayout(refresh_rate_layout)
self.auto_refresh_checkbox = QCheckBox("Enable Auto-Refresh")
self.auto_refresh_checkbox.stateChanged.connect(self.on_auto_refresh_changed)
controls_layout.addWidget(self.auto_refresh_checkbox)
self.manual_refresh_btn = QPushButton("Manual Refresh")
self.manual_refresh_btn.clicked.connect(self.refresh_readings)
controls_layout.addWidget(self.manual_refresh_btn)
controls_group.setLayout(controls_layout)
layout.addWidget(controls_group)
layout.addStretch()
self.setLayout(layout)
def on_refresh_rate_changed(self, value: int):
"""Handle refresh rate change."""
self.refresh_rate_label.setText(str(value))
if self.auto_refresh_enabled:
self.refresh_timer.setInterval(value)
def on_auto_refresh_changed(self, state: int):
"""Handle auto-refresh checkbox change."""
self.auto_refresh_enabled = bool(state)
if self.auto_refresh_enabled:
interval = self.refresh_rate_slider.value()
self.refresh_timer.start(interval)
else:
self.refresh_timer.stop()
def refresh_readings(self):
"""Refresh all sensor readings."""
# Current actual
current = self.laser.get_current_actual()
if current is not None:
self.current_actual_label.setText(f"{current:.3f} A")
else:
self.current_actual_label.setText("--")
# Power actual
power = self.laser.get_power_actual()
if power is not None:
self.power_actual_label.setText(f"{power:.3f} W")
else:
self.power_actual_label.setText("--")
# Main temperature (raw ADC)
temp = self.laser.get_main_temp()
if temp is not None:
self.main_temp_label.setText(f"ADC: {temp}")
else:
self.main_temp_label.setText("--")
# Etalon temperature (raw ADC)
etalon_temp = self.laser.get_etalon_temp()
if etalon_temp is not None:
self.etalon_temp_label.setText(f"ADC: {etalon_temp}")
else:
self.etalon_temp_label.setText("--")
# SHG temperature (raw ADC)
shg_temp = self.laser.get_shg_temp()
if shg_temp is not None:
self.shg_temp_label.setText(f"ADC: {shg_temp}")
else:
self.shg_temp_label.setText("--")
# Interlock status
interlock = self.laser.get_interlock_status()
if interlock is not None:
if interlock:
self.interlock_label.setText("OK")
self.interlock_label.setStyleSheet("color: #00ff00; font-weight: bold;")
else:
self.interlock_label.setText("FAULT")
self.interlock_label.setStyleSheet("color: #ff0000; font-weight: bold;")
else:
self.interlock_label.setText("--")
self.interlock_label.setStyleSheet("")
# LDD enable
ldd = self.laser.get_ldd_enable()
if ldd is not None:
self.ldd_enable_label.setText("ON" if ldd else "OFF")
else:
self.ldd_enable_label.setText("--")
# ============================================================================
# GUI - ADVANCED TAB
# ============================================================================
class AdvancedTab(QWidget):
"""Advanced I2C interface and packet monitoring."""
def __init__(self, i2c: I2CProtocol, serial_comm: SerialComm):
super().__init__()
self.i2c = i2c
self.serial = serial_comm
self.init_ui()
# Update packet log periodically
self.log_timer = QTimer()
self.log_timer.timeout.connect(self.update_packet_log)
self.log_timer.start(500)
def init_ui(self):
layout = QVBoxLayout()
# Raw I2C interface
i2c_group = QGroupBox("Raw I2C Interface")
i2c_layout = QGridLayout()
self.addr_input = QLineEdit()
self.addr_input.setPlaceholderText("0x52")
self.cmd_input = QLineEdit()
self.cmd_input.setPlaceholderText("0xa0")
self.data_input = QLineEdit()
self.data_input.setPlaceholderText("0x0100")
self.data_len_combo = QComboBox()
self.data_len_combo.addItems(["1 byte", "2 bytes", "4 bytes"])
self.data_len_combo.setCurrentIndex(1)
self.write_btn = QPushButton("Write")
self.write_btn.clicked.connect(self.on_write_clicked)
self.read_btn = QPushButton("Read")
self.read_btn.clicked.connect(self.on_read_clicked)
self.response_display = QLineEdit()
self.response_display.setReadOnly(True)
self.response_display.setPlaceholderText("Response will appear here")
i2c_layout.addWidget(QLabel("Device Address (hex):"), 0, 0)
i2c_layout.addWidget(self.addr_input, 0, 1)
i2c_layout.addWidget(QLabel("Command (hex):"), 1, 0)
i2c_layout.addWidget(self.cmd_input, 1, 1)
i2c_layout.addWidget(QLabel("Data (hex):"), 2, 0)
i2c_layout.addWidget(self.data_input, 2, 1)
i2c_layout.addWidget(QLabel("Data Length:"), 3, 0)
i2c_layout.addWidget(self.data_len_combo, 3, 1)
i2c_layout.addWidget(self.write_btn, 4, 0)
i2c_layout.addWidget(self.read_btn, 4, 1)
i2c_layout.addWidget(QLabel("Response:"), 5, 0)
i2c_layout.addWidget(self.response_display, 5, 1)
i2c_group.setLayout(i2c_layout)
layout.addWidget(i2c_group)
# Packet monitor
monitor_group = QGroupBox("Packet Monitor")
monitor_layout = QVBoxLayout()
self.packet_log = QTextEdit()
self.packet_log.setReadOnly(True)
self.packet_log.setMaximumHeight(200)
monitor_layout.addWidget(self.packet_log)
log_buttons = QHBoxLayout()
self.clear_log_btn = QPushButton("Clear Log")
self.clear_log_btn.clicked.connect(self.on_clear_log)
log_buttons.addWidget(self.clear_log_btn)
self.export_log_btn = QPushButton("Export to File")
self.export_log_btn.clicked.connect(self.on_export_log)
log_buttons.addWidget(self.export_log_btn)
monitor_layout.addLayout(log_buttons)
monitor_group.setLayout(monitor_layout)
layout.addWidget(monitor_group)
layout.addStretch()
self.setLayout(layout)
def on_write_clicked(self):
"""Handle write button click."""
try:
addr = int(self.addr_input.text(), 16)
cmd = int(self.cmd_input.text(), 16)
data = int(self.data_input.text(), 16)
data_len = [1, 2, 4][self.data_len_combo.currentIndex()]
success = self.i2c.nxp_write(addr, cmd, data, data_len)
if success:
self.response_display.setText("Write successful")
else:
self.response_display.setText("Write failed")
except ValueError as e:
self.response_display.setText(f"Invalid input: {str(e)}")
def on_read_clicked(self):
"""Handle read button click."""
try:
addr = int(self.addr_input.text(), 16)
cmd = int(self.cmd_input.text(), 16)
data_len = [1, 2, 4][self.data_len_combo.currentIndex()]
cmd_len = 1 if cmd <= 0xFF else 2
value = self.i2c.nxp_read(addr, cmd, cmd_len, data_len)
if value is not None:
self.response_display.setText(f"0x{value:0{data_len*2}x}")
else:
self.response_display.setText("Read failed")
except ValueError as e:
self.response_display.setText(f"Invalid input: {str(e)}")
def update_packet_log(self):
"""Update packet log display."""
log_entries = self.serial.get_packet_log()
# Only show last 50 entries
display_entries = log_entries[-50:]
self.packet_log.setPlainText('\n'.join(display_entries))
# Scroll to bottom
scrollbar = self.packet_log.verticalScrollBar()
scrollbar.setValue(scrollbar.maximum())
def on_clear_log(self):
"""Clear packet log."""
self.serial.clear_packet_log()
self.packet_log.clear()
def on_export_log(self):
"""Export packet log to file."""
log_entries = self.serial.get_packet_log()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"laser_packet_log_{timestamp}.txt"
try:
with open(filename, 'w') as f:
f.write('\n'.join(log_entries))
QMessageBox.information(self, "Export Successful",
f"Log exported to {filename}")
except Exception as e:
QMessageBox.warning(self, "Export Failed",
f"Failed to export log: {str(e)}")
# ============================================================================
# GUI - CONFIGURATION TAB
# ============================================================================
class ConfigTab(QWidget):
"""Serial port configuration."""
connection_changed = pyqtSignal(bool)
def __init__(self, serial_comm: SerialComm):
super().__init__()
self.serial = serial_comm
self.init_ui()
# Connect signals
self.serial.connected.connect(self.on_connected)
self.serial.disconnected.connect(self.on_disconnected)
self.serial.error.connect(self.on_error)
def init_ui(self):
layout = QVBoxLayout()
# Serial port
port_group = QGroupBox("Serial Port")
port_layout = QGridLayout()
self.port_combo = QComboBox()
self.port_combo.addItems(["/dev/ttyUSB1", "/dev/ttyUSB0", "/dev/ttyACM0"])
self.port_combo.setEditable(True)
self.baudrate_combo = QComboBox()
self.baudrate_combo.addItems(["9600", "19200", "38400", "57600", "115200"])
self.baudrate_combo.setCurrentText("9600")
port_layout.addWidget(QLabel("Port:"), 0, 0)
port_layout.addWidget(self.port_combo, 0, 1)
port_layout.addWidget(QLabel("Baud Rate:"), 1, 0)
port_layout.addWidget(self.baudrate_combo, 1, 1)
port_layout.addWidget(QLabel("Data Bits:"), 2, 0)
port_layout.addWidget(QLabel("8 (fixed)"), 2, 1)
port_layout.addWidget(QLabel("Parity:"), 3, 0)
port_layout.addWidget(QLabel("None (fixed)"), 3, 1)
port_layout.addWidget(QLabel("Stop Bits:"), 4, 0)
port_layout.addWidget(QLabel("1 (fixed)"), 4, 1)
port_group.setLayout(port_layout)
layout.addWidget(port_group)
# Connection
conn_group = QGroupBox("Connection")
conn_layout = QVBoxLayout()
self.connect_btn = QPushButton("Connect")
self.connect_btn.clicked.connect(self.on_connect_clicked)
conn_layout.addWidget(self.connect_btn)
self.status_label = QLabel("Status: Disconnected")
self.status_label.setStyleSheet("color: #ff0000;")
conn_layout.addWidget(self.status_label)
conn_group.setLayout(conn_layout)
layout.addWidget(conn_group)
# Debug options
debug_group = QGroupBox("Debug Options")
debug_layout = QGridLayout()
self.timeout_input = QLineEdit("1000")
self.retry_input = QLineEdit("3")
debug_layout.addWidget(QLabel("Packet Timeout (ms):"), 0, 0)
debug_layout.addWidget(self.timeout_input, 0, 1)
debug_layout.addWidget(QLabel("Retry Count:"), 1, 0)
debug_layout.addWidget(self.retry_input, 1, 1)
debug_group.setLayout(debug_layout)
layout.addWidget(debug_group)
layout.addStretch()
self.setLayout(layout)
def on_connect_clicked(self):
"""Handle connect/disconnect button click."""
if self.serial.is_connected:
self.serial.disconnect()
else:
port = self.port_combo.currentText()
baudrate = int(self.baudrate_combo.currentText())
self.serial.connect(port, baudrate)
def on_connected(self):
"""Handle connection established."""
self.connect_btn.setText("Disconnect")
self.status_label.setText("Status: Connected")
self.status_label.setStyleSheet("color: #00ff00;")
self.connection_changed.emit(True)
def on_disconnected(self):
"""Handle disconnection."""
self.connect_btn.setText("Connect")
self.status_label.setText("Status: Disconnected")
self.status_label.setStyleSheet("color: #ff0000;")
self.connection_changed.emit(False)
def on_error(self, message: str):
"""Handle serial error."""
QMessageBox.warning(self, "Serial Error", message)
# ============================================================================
# MAIN WINDOW
# ============================================================================
class LaserControlApp(QMainWindow):
"""Main application window."""
def __init__(self):
super().__init__()
# Initialize communication layers
self.serial_comm = SerialComm()
self.i2c_protocol = I2CProtocol(self.serial_comm)
self.laser_control = LaserControl(self.i2c_protocol)
self.init_ui()
self.apply_stylesheet()
# Connect signals
self.serial_comm.disconnected.connect(self.on_disconnected)
def init_ui(self):
self.setWindowTitle("Genesis SLM MX 532 Laser Control")
self.setGeometry(100, 100, 800, 600)
# Create central widget
central_widget = QWidget()
self.setCentralWidget(central_widget)
layout = QVBoxLayout()
central_widget.setLayout(layout)
# Create tabs
self.tabs = QTabWidget()
self.basic_tab = BasicControlTab(self.laser_control)
self.monitoring_tab = MonitoringTab(self.laser_control)
self.advanced_tab = AdvancedTab(self.i2c_protocol, self.serial_comm)
self.config_tab = ConfigTab(self.serial_comm)
self.tabs.addTab(self.basic_tab, "Basic Controls")
self.tabs.addTab(self.monitoring_tab, "Monitoring")
self.tabs.addTab(self.advanced_tab, "Advanced")
self.tabs.addTab(self.config_tab, "Configuration")
layout.addWidget(self.tabs)
# Connect config tab signal
self.config_tab.connection_changed.connect(self.on_connection_changed)
def on_connection_changed(self, connected: bool):
"""Handle connection state change."""
self.basic_tab.set_connection_status(connected)
if not connected:
self.on_disconnected()
def on_disconnected(self):
"""Handle disconnection - set safe state."""
self.laser_control.safe_state()
self.basic_tab.reset_controls()
def apply_stylesheet(self):
"""Apply QSS stylesheet."""
stylesheet = """
QMainWindow {
background-color: #2b2b2b;
}
QWidget {
background-color: #2b2b2b;
color: white;
}
QPushButton {
background-color: #3c3c3c;
color: white;
border: 1px solid #555;
padding: 5px;
border-radius: 3px;
}
QPushButton:hover {
background-color: #4c4c4c;
}
QPushButton:pressed {
background-color: #2c2c2c;
}
QPushButton#emergency {
background-color: #cc0000;
font-weight: bold;
font-size: 14pt;
min-height: 50px;
}
QPushButton#emergency:hover {
background-color: #ff0000;
}
QGroupBox {
border: 1px solid #555;
border-radius: 5px;
margin-top: 10px;
padding-top: 10px;
}
QGroupBox::title {
subcontrol-origin: margin;
left: 10px;
padding: 0 5px;
}
QSlider::groove:horizontal {
background: #3c3c3c;
height: 8px;
border-radius: 4px;
}
QSlider::handle:horizontal {
background: #5c5c5c;
width: 16px;
margin: -4px 0;
border-radius: 8px;
}
QSlider::handle:horizontal:hover {
background: #6c6c6c;
}
QSpinBox, QLineEdit, QComboBox {
background-color: #3c3c3c;
border: 1px solid #555;
padding: 3px;
border-radius: 3px;
}
QTextEdit {
background-color: #1c1c1c;
border: 1px solid #555;
border-radius: 3px;
}
QTabWidget::pane {
border: 1px solid #555;
}
QTabBar::tab {
background-color: #3c3c3c;
border: 1px solid #555;
padding: 8px 16px;
margin-right: 2px;
}
QTabBar::tab:selected {
background-color: #4c4c4c;
}
QTabBar::tab:hover {
background-color: #5c5c5c;
}
"""
self.setStyleSheet(stylesheet)
def closeEvent(self, event):
"""Handle window close event."""
# Set safe state before closing
if self.serial_comm.is_connected:
self.laser_control.safe_state()
self.serial_comm.disconnect()
event.accept()
# ============================================================================
# MAIN
# ============================================================================
def main():
app = QApplication(sys.argv)
window = LaserControlApp()
window.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()