#!/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 from PyQt6.QtGui import QPalette, QColor # ============================================================================ # 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()