1446 lines
46 KiB
Python
Executable File
1446 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
|
|
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()
|