when'd i last commit this pos?

This commit is contained in:
Thomas Ales [M S E]
2026-02-09 14:40:34 -06:00
parent fc43fbe4b0
commit 23f6331ba2
94 changed files with 14427 additions and 12178 deletions
+6
View File
@@ -0,0 +1,6 @@
"""Hardware driver modules for ScanEngine-3"""
from .bbd202 import *
from .uc480_camera import *
from .tektronix_base import *
from .coherent_hops_laser import *
from .genesis_core import *
+3147
View File
File diff suppressed because it is too large Load Diff
+45
View File
@@ -0,0 +1,45 @@
"""
Coherent HOPS Laser Driver - Stub Module
This is a temporary stub to allow testing camera integration.
"""
class CoherentHOPSLaser:
"""Stub class for Coherent HOPS Laser"""
pass
class DummyLaser:
"""Dummy laser for testing without hardware"""
def connect(self):
"""Simulate connection"""
pass
def disconnect(self):
"""Simulate disconnection"""
pass
def get_hardware_id(self):
"""Return simulated hardware ID"""
return "SIM-12345"
def get_laser_model(self):
"""Return simulated model"""
return "Genesis Simulator"
def get_interlock_status(self):
"""Return simulated interlock status"""
return "OK"
def get_key_switch_status(self):
"""Return simulated key switch status"""
return "ON"
def get_temperature_main(self):
"""Return simulated main temperature"""
return 25.5
def get_temperature_eta(self):
"""Return simulated ETA temperature"""
return 26.3
+669
View File
@@ -0,0 +1,669 @@
"""
Genesis SLM MX 532 Laser Core Hardware Control Module
======================================================
This module provides low-level hardware control for the Genesis SLM MX 532 laser
using NXP I2C-over-serial protocol. It contains reusable classes for serial
communication, I2C protocol handling, device control, and laser operations.
This module is GUI-independent and can be used by both command-line and GUI applications.
Protocol Overview:
-----------------
The laser uses NXP I2C-over-serial protocol with packet format:
[0x53] [I2C_ADDR] [LENGTH] [COMMAND_BYTES] [DATA_BYTES] [0x50]
For reads:
[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50]
I2C Devices:
-----------
- X9119 digital potentiometer at 0x52 - laser current control
- PCA9555 I/O expander at 0x4a - digital I/O
- AD5254 digital potentiometer at 0x58 - limits control
- ADS7828 ADC at 0x90 - sensor readings
- M24C64 EEPROM at 0xa4 - configuration storage
Author: Claude
Date: 2026-01-24
"""
import time
from datetime import datetime
from typing import Optional, List
from enum import IntEnum
import serial
from serial.tools import list_ports
# ============================================================================
# Constants - I2C Addresses
# ============================================================================
class I2CAddress(IntEnum):
"""I2C device addresses (7-bit addresses shifted left by 1)"""
# X9119 Digital Potentiometer (current control)
X9119_CURRENT_WRITE = 0x52
X9119_CURRENT_READ = 0x53
# PCA9555 I/O Expanders
PCA9555_DIO_WRITE = 0x4a # Main control I/O at 0x14a
PCA9555_DIO_READ = 0x4b
PCA9555_PSGLUE_WRITE = 0x48 # Power supply glue at 0x148
PCA9555_PSGLUE_READ = 0x49
PCA9555_HEAD_WRITE = 0x44 # Head DIO at 0x144
PCA9555_HEAD_READ = 0x45
PCA9555_LDD_WRITE = 0x40 # LDD control at 0x140
PCA9555_LDD_READ = 0x41
# AD5254 Digital Potentiometer (limits)
AD5254_WRITE = 0x58
# ADS7828 ADC (sensor readings)
ADS7828_WRITE = 0x90
ADS7828_READ = 0x91
# M24C64 EEPROM (configuration)
EEPROM_WRITE = 0xa4
EEPROM_READ = 0xa5
# ============================================================================
# Constants - PCA9555 Register Addresses
# ============================================================================
class PCA9555Register(IntEnum):
"""PCA9555 I/O expander register addresses"""
INPUT_PORT_0 = 0x00
INPUT_PORT_1 = 0x01
OUTPUT_PORT_0 = 0x02
OUTPUT_PORT_1 = 0x03
POLARITY_INV_0 = 0x04
POLARITY_INV_1 = 0x05
CONFIG_PORT_0 = 0x06
CONFIG_PORT_1 = 0x07
# ============================================================================
# Constants - Control Bitmasks
# ============================================================================
class ControlBitmask(IntEnum):
"""Bitmasks for PCA9555 port 0 control bits"""
SHUTTER = 0x01 # Bit 0
CURRENT_MODE = 0x04 # Bit 2
REMOTE_ENABLE = 0x08 # Bit 3
ANALOG_ENABLE = 0x10 # Bit 4
KEYSWITCH = 0x20 # Bit 5
INTERLOCK = 0x01 # Bit 0 (on different PCA9555)
LDD_ENABLE = 0x01 # Bit 0 (on LDD PCA9555)
# ============================================================================
# Serial Communication Layer
# ============================================================================
class SerialComm:
"""Handles low-level serial port communication"""
def __init__(self):
self.port: Optional[serial.Serial] = None
self.port_name: str = "/dev/ttyUSB0"
self.baudrate: int = 9600
self.timeout: float = 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 device name
baudrate: Baud rate (default 9600)
Returns:
True if successful, False otherwise
"""
try:
self.port_name = port_name
self.baudrate = baudrate
self.port = serial.Serial(
port=port_name,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout
)
time.sleep(0.1) # Allow port to stabilize
return True
except Exception as e:
print(f"Serial connection error: {e}")
return False
def disconnect(self):
"""Disconnect from serial port"""
if self.port and self.port.is_open:
self.port.close()
self.port = None
def is_connected(self) -> bool:
"""Check if serial port is connected"""
return self.port is not None and self.port.is_open
def write(self, data: bytes) -> bool:
"""
Write data to serial port
Args:
data: Bytes to write
Returns:
True if successful, False otherwise
"""
if not self.is_connected():
return False
try:
self.port.write(data)
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
hex_str = " ".join(f"{b:02x}" for b in data)
self.packet_log.append(f"[{timestamp}] TX: {hex_str}")
return True
except Exception as e:
print(f"Serial write error: {e}")
return False
def read(self, size: int) -> Optional[bytes]:
"""
Read data from serial port
Args:
size: Number of bytes to read
Returns:
Bytes read or None on error
"""
if not self.is_connected():
return None
try:
data = self.port.read(size)
if data:
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
hex_str = " ".join(f"{b:02x}" for b in data)
self.packet_log.append(f"[{timestamp}] RX: {hex_str}")
return data
except Exception as e:
print(f"Serial read error: {e}")
return None
def flush(self):
"""Flush serial port buffers"""
if self.is_connected():
self.port.reset_input_buffer()
self.port.reset_output_buffer()
def get_packet_log(self, last_n: int = 100) -> List[str]:
"""Get last N entries from packet log"""
return self.packet_log[-last_n:]
def clear_packet_log(self):
"""Clear packet log"""
self.packet_log.clear()
# ============================================================================
# I2C Protocol Layer
# ============================================================================
class I2CProtocol:
"""Handles NXP I2C-over-serial protocol packet construction"""
NXP_START = 0x53
NXP_STOP = 0x50
def __init__(self, serial_comm: SerialComm):
self.serial = serial_comm
def write(self, i2c_addr_write: int, cmd: bytes, data: bytes = b'') -> bool:
"""
Build and send NXP I2C write packet
Packet format: [0x53] [I2C_ADDR] [LENGTH] [COMMAND] [DATA] [0x50]
Args:
i2c_addr_write: I2C write address
cmd: Command bytes
data: Data bytes (optional)
Returns:
True if successful, False otherwise
"""
if not isinstance(cmd, bytes):
cmd = bytes([cmd])
if not isinstance(data, bytes):
data = bytes(data)
length = len(cmd) + len(data)
packet = bytes([self.NXP_START, i2c_addr_write, length]) + cmd + data + bytes([self.NXP_STOP])
return self.serial.write(packet)
def read(self, i2c_addr_write: int, cmd: bytes, cmd_len: int, data_len: int) -> Optional[bytes]:
"""
Build and send NXP I2C read packet, return data
Packet format for read:
[0x53] [ADDR_WRITE] [CMD_LEN] [CMD] [0x53] [ADDR_READ] [DATA_LEN] [0x50]
Args:
i2c_addr_write: I2C write address
cmd: Command bytes
cmd_len: Length of command
data_len: Expected data length to read
Returns:
Data bytes read or None on error
"""
if not isinstance(cmd, bytes):
cmd = bytes([cmd])
i2c_addr_read = i2c_addr_write | 0x01 # Set read bit
# First part: write command
packet_write = bytes([self.NXP_START, i2c_addr_write, cmd_len]) + cmd
# Second part: read data
packet_read = bytes([self.NXP_START, i2c_addr_read, data_len, self.NXP_STOP])
packet = packet_write + packet_read
if not self.serial.write(packet):
return None
# Read response
# Expected response: [data_bytes]
time.sleep(0.05) # Give device time to respond
response = self.serial.read(data_len)
return response if response and len(response) == data_len else None
# ============================================================================
# I2C Device Functions
# ============================================================================
class I2CDevices:
"""High-level I2C device control functions"""
def __init__(self, protocol: I2CProtocol):
self.protocol = protocol
# ------------------------------------------------------------------------
# PCA9555 I/O Expander Functions
# ------------------------------------------------------------------------
def pca9555_read_port(self, addr_write: int, port: int) -> Optional[int]:
"""
Read PCA9555 port register
Args:
addr_write: I2C write address
port: Register address (0-7)
Returns:
Port value (0-255) or None on error
"""
data = self.protocol.read(addr_write, bytes([port]), 1, 1)
return data[0] if data else None
def pca9555_write_port(self, addr_write: int, port: int, value: int) -> bool:
"""
Write PCA9555 port register
Args:
addr_write: I2C write address
port: Register address (0-7)
value: Value to write (0-255)
Returns:
True if successful, False otherwise
"""
return self.protocol.write(addr_write, bytes([port]), bytes([value]))
def pca9555_set_bit(self, addr_write: int, port: int, bitmask: int, state: bool) -> bool:
"""
Set or clear specific bit on PCA9555
Args:
addr_write: I2C write address
port: Register address
bitmask: Bit mask (e.g., 0x01 for bit 0)
state: True to set bit, False to clear
Returns:
True if successful, False otherwise
"""
# Read current value
current = self.pca9555_read_port(addr_write, 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_write, port, new_value)
# ------------------------------------------------------------------------
# X9119 Digital Potentiometer Functions
# ------------------------------------------------------------------------
def x9119_write_wiper(self, addr_write: int, value: int) -> bool:
"""
Set X9119 wiper position
Args:
addr_write: I2C write address
value: Wiper position (0-1023, 10-bit)
Returns:
True if successful, False otherwise
"""
# X9119 write wiper command: 0xa0 followed by 2 bytes (10-bit value)
# Value is split into two bytes: MSB contains upper 2 bits, LSB contains lower 8 bits
value = max(0, min(1023, value)) # Clamp to valid range
msb = (value >> 8) & 0x03 # Upper 2 bits
lsb = value & 0xFF # Lower 8 bits
cmd = bytes([0xa0])
data = bytes([msb, lsb])
return self.protocol.write(addr_write, cmd, data)
# ------------------------------------------------------------------------
# ADS7828 ADC Functions
# ------------------------------------------------------------------------
def ads7828_read(self, addr_write: int, channel: int) -> Optional[int]:
"""
Read ADS7828 ADC channel
Args:
addr_write: I2C write address
channel: Channel to read (0-7)
Returns:
12-bit ADC value (0-4095) or None on error
"""
# ADS7828 command byte format:
# Bit 7: SD (single-ended=1)
# Bits 6-4: Channel select
# Bit 3: PD1 (power-down mode)
# Bit 2: PD0 (power-down mode)
# Bits 1-0: Don't care
# For single-ended read with internal reference on
cmd_byte = 0x80 | ((channel & 0x07) << 4) | 0x0c
data = self.protocol.read(addr_write, bytes([cmd_byte]), 1, 2)
if not data or len(data) != 2:
return None
# Combine two bytes into 12-bit value
value = (data[0] << 8) | data[1]
value = (value >> 4) & 0x0FFF # Extract 12-bit value
return value
# ============================================================================
# Laser Control Layer
# ============================================================================
class LaserControl:
"""High-level laser control functions"""
def __init__(self, devices: I2CDevices):
self.devices = devices
self._safe_state_active = False
# ------------------------------------------------------------------------
# SET Commands
# ------------------------------------------------------------------------
def set_current(self, value: int) -> bool:
"""
Set laser current command (CCMD=)
Args:
value: Current value (0-1023)
Returns:
True if successful, False otherwise
"""
return self.devices.x9119_write_wiper(I2CAddress.X9119_CURRENT_WRITE, value)
def set_power_cmd(self, value: int) -> bool:
"""
Set power command (PCMD=)
Note: Uses same device as current control, different channel
Args:
value: Power value (0-1023)
Returns:
True if successful, False otherwise
"""
return self.devices.x9119_write_wiper(I2CAddress.X9119_CURRENT_WRITE, value)
def set_shutter(self, state: bool) -> bool:
"""
Set shutter state (SHCMD=)
Args:
state: True for open, False for closed
Returns:
True if successful, False otherwise
"""
return self.devices.pca9555_set_bit(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0,
ControlBitmask.SHUTTER,
state
)
def set_keyswitch(self, state: bool) -> bool:
"""
Set keyswitch state (KSWCMD=)
Args:
state: True for on, False for off
Returns:
True if successful, False otherwise
"""
return self.devices.pca9555_set_bit(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0,
ControlBitmask.KEYSWITCH,
state
)
def set_current_mode(self, state: bool) -> bool:
"""
Set current mode (CMODECMD=)
Args:
state: True for on, False for off
Returns:
True if successful, False otherwise
"""
return self.devices.pca9555_set_bit(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0,
ControlBitmask.CURRENT_MODE,
state
)
def set_analog_enable(self, state: bool) -> bool:
"""
Set analog input enable (ANACMD=)
Args:
state: True for enabled, False for disabled
Returns:
True if successful, False otherwise
"""
return self.devices.pca9555_set_bit(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0,
ControlBitmask.ANALOG_ENABLE,
state
)
def set_remote_enable(self, state: bool) -> bool:
"""
Set remote enable (REM=)
Args:
state: True for enabled, False for disabled
Returns:
True if successful, False otherwise
"""
return self.devices.pca9555_set_bit(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0,
ControlBitmask.REMOTE_ENABLE,
state
)
# ------------------------------------------------------------------------
# GET/Query Commands
# ------------------------------------------------------------------------
def get_current_actual(self) -> Optional[float]:
"""
Get actual current reading
Returns:
Current in arbitrary units (0-4095) or None on error
"""
# Read from ADS7828, channel based on command 0x84
# Command 0x84 suggests channel 0
value = self.devices.ads7828_read(I2CAddress.ADS7828_WRITE, 0)
return value if value is not None else None
def get_interlock_status(self) -> Optional[bool]:
"""
Get interlock status
Returns:
True if interlock OK, False if fault, None on error
"""
value = self.devices.pca9555_read_port(
I2CAddress.PCA9555_PSGLUE_WRITE,
PCA9555Register.INPUT_PORT_0
)
if value is None:
return None
# Bit 0 indicates interlock status
return bool(value & ControlBitmask.INTERLOCK)
def get_ldd_enable_status(self) -> Optional[bool]:
"""
Get laser diode driver enable status
Returns:
True if enabled, False if disabled, None on error
"""
value = self.devices.pca9555_read_port(
I2CAddress.PCA9555_LDD_WRITE,
PCA9555Register.INPUT_PORT_0
)
if value is None:
return None
return bool(value & ControlBitmask.LDD_ENABLE)
def get_psglue_in_status(self) -> Optional[int]:
"""
Get power supply glue input status
Returns:
Port value or None on error
"""
return self.devices.pca9555_read_port(
I2CAddress.PCA9555_PSGLUE_WRITE,
PCA9555Register.INPUT_PORT_0
)
def get_psglue_out_status(self) -> Optional[int]:
"""
Get power supply glue output status
Returns:
Port value or None on error
"""
return self.devices.pca9555_read_port(
I2CAddress.PCA9555_DIO_WRITE,
PCA9555Register.OUTPUT_PORT_0
)
def get_head_dio_status(self) -> Optional[int]:
"""
Get head DIO status
Returns:
Port value or None on error
"""
return self.devices.pca9555_read_port(
I2CAddress.PCA9555_HEAD_WRITE,
PCA9555Register.INPUT_PORT_0
)
# ------------------------------------------------------------------------
# Safety Functions
# ------------------------------------------------------------------------
def emergency_stop(self) -> bool:
"""
Emergency stop: close shutter, set current to 0, disable keyswitch
Returns:
True if all operations successful, False otherwise
"""
success = True
success &= self.set_shutter(False)
success &= self.set_current(0)
success &= self.set_keyswitch(False)
self._safe_state_active = True
return success
def enter_safe_state(self) -> bool:
"""
Enter safe state (similar to emergency stop but also disables remote)
Returns:
True if successful, False otherwise
"""
success = True
success &= self.set_shutter(False)
success &= self.set_current(0)
success &= self.set_power_cmd(0)
success &= self.set_keyswitch(False)
success &= self.set_remote_enable(False)
self._safe_state_active = True
return success
+296
View File
@@ -0,0 +1,296 @@
"""
Helios Laser System Driver
Basic implementation for controlling the Helios pulsed laser.
"""
import serial
import time
import logging
from typing import Optional, List
from enum import Enum
logger = logging.getLogger(__name__)
class PulseMode(Enum):
"""Helios pulse mode enumeration"""
SINGLE_PULSE = 0
CONTINUOUS_GATING = 1
CONTINUOUS_PULSING = 2
class HeliosLaser:
"""
Driver for Helios pulsed laser system.
Communication: RS-232, 9600 baud, 8N1
Commands are ASCII strings terminated with CR
"""
def __init__(self, port: str = None, timeout: float = 1.0):
"""
Initialize Helios laser driver.
Args:
port: Serial port (e.g., '/dev/ttyUSB0' or 'COM5')
timeout: Serial timeout in seconds
"""
self.port = port
self.timeout = timeout
self.serial = None
self.is_connected = False
@staticmethod
def list_available_ports() -> List[str]:
"""List available serial ports"""
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
return [port.device for port in ports]
def connect(self, port: str = None) -> bool:
"""
Connect to the Helios laser.
Args:
port: Serial port (uses stored port if None)
Returns:
True if connection successful
"""
if port:
self.port = port
if not self.port:
logger.error("No port specified")
return False
try:
self.serial = serial.Serial(
port=self.port,
baudrate=9600,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout
)
time.sleep(0.1) # Allow time for connection to stabilize
self.is_connected = True
logger.info(f"Connected to Helios laser on {self.port}")
return True
except Exception as e:
logger.error(f"Failed to connect to Helios laser: {e}")
self.is_connected = False
return False
def disconnect(self):
"""Disconnect from the laser"""
if self.serial and self.serial.is_open:
try:
# Disable laser before disconnecting
self.set_laser_enable(False)
self.serial.close()
logger.info("Disconnected from Helios laser")
except Exception as e:
logger.error(f"Error during disconnect: {e}")
self.is_connected = False
self.serial = None
def _send_command(self, command: str) -> bool:
"""
Send a command to the laser.
Args:
command: ASCII command string (without CR)
Returns:
True if sent successfully
"""
if not self.is_connected or not self.serial:
logger.error("Not connected to laser")
return False
try:
cmd_bytes = (command + '\r').encode('ascii')
self.serial.write(cmd_bytes)
logger.debug(f"Sent command: {command}")
return True
except Exception as e:
logger.error(f"Failed to send command '{command}': {e}")
return False
def _query(self, command: str) -> Optional[str]:
"""
Send a query and read response.
Args:
command: ASCII query command (without CR)
Returns:
Response string or None if error
"""
if not self._send_command(command):
return None
try:
response = self.serial.readline().decode('ascii').strip()
logger.debug(f"Query '{command}' response: {response}")
return response
except Exception as e:
logger.error(f"Failed to read response for '{command}': {e}")
return None
def set_frequency_hz(self, frequency: int) -> bool:
"""
Set laser pulse frequency in Hz.
Args:
frequency: Frequency in Hz (16700 - 125000)
Returns:
True if successful
"""
if not (16700 <= frequency <= 125000):
logger.error(f"Frequency {frequency} Hz out of range (16700-125000)")
return False
# Convert frequency to period in nanoseconds
period_ns = int(1e9 / frequency)
command = f"FP={period_ns}"
return self._send_command(command)
def set_current_ma(self, current: int) -> bool:
"""
Set pump diode current in mA.
Args:
current: Current in mA (0 - 7000)
Returns:
True if successful
"""
if not (0 <= current <= 7000):
logger.error(f"Current {current} mA out of range (0-7000)")
return False
command = f"PC={current}"
return self._send_command(command)
def set_pulse_mode(self, mode: PulseMode) -> bool:
"""
Set pulse mode.
Args:
mode: PulseMode enumeration value
Returns:
True if successful
"""
command = f"PM={mode.value}"
return self._send_command(command)
def set_laser_enable(self, enable: bool) -> bool:
"""
Enable or disable laser emission.
Args:
enable: True to enable, False to disable
Returns:
True if successful
"""
command = f"LE={1 if enable else 0}"
success = self._send_command(command)
if success:
state = "enabled" if enable else "disabled"
logger.info(f"Laser {state}")
return success
def is_laser_enabled(self) -> bool:
"""
Check if laser is currently enabled.
Returns:
True if laser is enabled
"""
response = self._query("LE?")
if response:
try:
return int(response) == 1
except ValueError:
logger.error(f"Invalid response for LE?: {response}")
return False
def get_frequency_hz(self) -> Optional[int]:
"""
Get current laser frequency in Hz.
Returns:
Frequency in Hz or None if error
"""
response = self._query("FP?")
if response:
try:
period_ns = int(response)
return int(1e9 / period_ns)
except (ValueError, ZeroDivisionError):
logger.error(f"Invalid response for FP?: {response}")
return None
def get_current_ma(self) -> Optional[int]:
"""
Get current pump diode current in mA.
Returns:
Current in mA or None if error
"""
response = self._query("PC?")
if response:
try:
return int(response)
except ValueError:
logger.error(f"Invalid response for PC?: {response}")
return None
def get_power_mw(self) -> Optional[float]:
"""
Get laser output power in mW.
Returns:
Power in mW or None if error
"""
response = self._query("PO?")
if response:
try:
return float(response)
except ValueError:
logger.error(f"Invalid response for PO?: {response}")
return None
def get_controller_serial(self) -> Optional[str]:
"""
Get controller serial number.
Returns:
Serial number string or None if error
"""
return self._query("SN?")
def get_head_serial(self) -> Optional[str]:
"""
Get laser head serial number.
Returns:
Serial number string or None if error
"""
return self._query("HSN?")
def __del__(self):
"""Destructor - ensure cleanup"""
self.disconnect()
File diff suppressed because it is too large Load Diff
+475
View File
@@ -0,0 +1,475 @@
"""
uC480 Camera Driver
Driver for IDS/Thorlabs uEye uC480 cameras using pyueye library.
Provides camera control, live streaming, and image capture capabilities.
"""
import numpy as np
from pyueye import ueye
from PyQt6.QtCore import QThread, pyqtSignal, QObject
from PyQt6.QtGui import QImage
import logging
from typing import Optional, Tuple
logger = logging.getLogger(__name__)
class UC480Camera(QObject):
"""
Driver class for uC480 camera.
Handles initialization, configuration, and image acquisition.
"""
# Signals
frame_ready = pyqtSignal(QImage) # Emitted when a new frame is captured
error_occurred = pyqtSignal(str) # Emitted when an error occurs
def __init__(self, camera_id: int = 0):
"""
Initialize the uC480 camera driver.
Args:
camera_id: Camera ID (0 for first available camera)
"""
super().__init__()
self.camera_id = camera_id
self.h_cam = ueye.HIDS(camera_id)
self.is_initialized = False
self.is_capturing = False
# Memory and image info
self.mem_ptr = ueye.c_mem_p()
self.mem_id = ueye.int()
self.pitch = ueye.INT()
# Camera info
self.sensor_info = ueye.SENSORINFO()
self.cam_info = ueye.CAMINFO()
self.rect_aoi = ueye.IS_RECT()
# Image dimensions
self.width = 0
self.height = 0
self.bits_per_pixel = 24 # Default to 24-bit color
self.bytes_per_pixel = 3
self.color_mode = ueye.IS_CM_BGR8_PACKED
def initialize(self) -> bool:
"""
Initialize the camera and allocate memory.
Returns:
True if successful, False otherwise
"""
try:
# Initialize camera
ret = ueye.is_InitCamera(self.h_cam, None)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to initialize camera: {ret}")
self.error_occurred.emit(f"Failed to initialize camera: {ret}")
return False
# Get sensor info
ret = ueye.is_GetSensorInfo(self.h_cam, self.sensor_info)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to get sensor info: {ret}")
self.cleanup()
return False
# Get camera info
ret = ueye.is_GetCameraInfo(self.h_cam, self.cam_info)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to get camera info: {ret}")
self.cleanup()
return False
# Set color mode
ret = ueye.is_SetColorMode(self.h_cam, self.color_mode)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to set color mode: {ret}")
self.cleanup()
return False
# Get maximum image size
self.width = self.sensor_info.nMaxWidth.value
self.height = self.sensor_info.nMaxHeight.value
# Set Area of Interest (AOI) to maximum size
self.rect_aoi.s32X = ueye.int(0)
self.rect_aoi.s32Y = ueye.int(0)
self.rect_aoi.s32Width = ueye.int(self.width)
self.rect_aoi.s32Height = ueye.int(self.height)
ret = ueye.is_AOI(self.h_cam, ueye.IS_AOI_IMAGE_SET_AOI, self.rect_aoi, ueye.sizeof(self.rect_aoi))
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to set AOI: {ret}")
self.cleanup()
return False
# Allocate image memory
ret = ueye.is_AllocImageMem(
self.h_cam,
self.width,
self.height,
self.bits_per_pixel,
self.mem_ptr,
self.mem_id
)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to allocate image memory: {ret}")
self.cleanup()
return False
# Set active memory
ret = ueye.is_SetImageMem(self.h_cam, self.mem_ptr, self.mem_id)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to set active memory: {ret}")
self.cleanup()
return False
# Get pitch (bytes per line)
ret = ueye.is_GetImageMemPitch(self.h_cam, self.pitch)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to get pitch: {ret}")
self.cleanup()
return False
self.is_initialized = True
logger.info(f"Camera initialized: {self.width}x{self.height}, {self.bits_per_pixel}bpp")
# Set default settings
self.set_exposure(10.0) # 10ms default exposure
self.set_pixel_clock(30) # 30MHz default pixel clock
self.set_framerate(30.0) # 30fps default
return True
except Exception as e:
logger.error(f"Exception during camera initialization: {e}")
self.error_occurred.emit(f"Exception during initialization: {e}")
self.cleanup()
return False
def cleanup(self):
"""Release camera resources."""
if self.is_capturing:
self.stop_capture()
if self.mem_ptr:
ueye.is_FreeImageMem(self.h_cam, self.mem_ptr, self.mem_id)
self.mem_ptr = None
if self.is_initialized:
ueye.is_ExitCamera(self.h_cam)
self.is_initialized = False
logger.info("Camera resources released")
def start_capture(self) -> bool:
"""
Start continuous video capture.
Returns:
True if successful, False otherwise
"""
if not self.is_initialized:
logger.error("Camera not initialized")
return False
if self.is_capturing:
logger.warning("Camera already capturing")
return True
ret = ueye.is_CaptureVideo(self.h_cam, ueye.IS_DONT_WAIT)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to start capture: {ret}")
self.error_occurred.emit(f"Failed to start capture: {ret}")
return False
self.is_capturing = True
logger.info("Video capture started")
return True
def stop_capture(self) -> bool:
"""
Stop continuous video capture.
Returns:
True if successful, False otherwise
"""
if not self.is_capturing:
return True
ret = ueye.is_StopLiveVideo(self.h_cam, ueye.IS_WAIT)
if ret != ueye.IS_SUCCESS:
logger.error(f"Failed to stop capture: {ret}")
return False
self.is_capturing = False
logger.info("Video capture stopped")
return True
def get_frame(self) -> Optional[QImage]:
"""
Capture a single frame from the camera.
Returns:
QImage if successful, None otherwise
"""
if not self.is_initialized:
logger.error("Camera not initialized")
return None
# Create numpy array from image memory
try:
array = ueye.get_data(
self.mem_ptr,
self.width,
self.height,
self.bits_per_pixel,
self.pitch,
copy=True
)
# Reshape to image dimensions
frame = np.reshape(array, (self.height, self.width, self.bytes_per_pixel))
# Convert to QImage (BGR to RGB)
height, width, channel = frame.shape
bytes_per_line = self.bytes_per_pixel * width
# Convert BGR to RGB
rgb_frame = frame[:, :, ::-1].copy()
q_image = QImage(
rgb_frame.data,
width,
height,
bytes_per_line,
QImage.Format.Format_RGB888
)
# Make a copy since the numpy array will be deleted
return q_image.copy()
except Exception as e:
logger.error(f"Failed to get frame: {e}")
self.error_occurred.emit(f"Failed to get frame: {e}")
return None
def set_exposure(self, exposure_ms: float) -> bool:
"""
Set camera exposure time.
Args:
exposure_ms: Exposure time in milliseconds
Returns:
True if successful, False otherwise
"""
if not self.is_initialized:
return False
exposure = ueye.c_double(exposure_ms)
ret = ueye.is_Exposure(
self.h_cam,
ueye.IS_EXPOSURE_CMD_SET_EXPOSURE,
exposure,
ueye.sizeof(exposure)
)
if ret == ueye.IS_SUCCESS:
logger.debug(f"Exposure set to {exposure_ms}ms")
return True
else:
logger.error(f"Failed to set exposure: {ret}")
return False
def get_exposure(self) -> Optional[float]:
"""
Get current exposure time.
Returns:
Exposure time in milliseconds, or None if failed
"""
if not self.is_initialized:
return None
exposure = ueye.c_double()
ret = ueye.is_Exposure(
self.h_cam,
ueye.IS_EXPOSURE_CMD_GET_EXPOSURE,
exposure,
ueye.sizeof(exposure)
)
if ret == ueye.IS_SUCCESS:
return exposure.value
else:
return None
def set_pixel_clock(self, pixel_clock_mhz: int) -> bool:
"""
Set camera pixel clock.
Args:
pixel_clock_mhz: Pixel clock in MHz
Returns:
True if successful, False otherwise
"""
if not self.is_initialized:
return False
ret = ueye.is_PixelClock(
self.h_cam,
ueye.IS_PIXELCLOCK_CMD_SET,
ueye.c_uint(pixel_clock_mhz),
ueye.sizeof(ueye.c_uint)
)
if ret == ueye.IS_SUCCESS:
logger.debug(f"Pixel clock set to {pixel_clock_mhz}MHz")
return True
else:
logger.error(f"Failed to set pixel clock: {ret}")
return False
def set_framerate(self, fps: float) -> bool:
"""
Set camera framerate.
Args:
fps: Frames per second
Returns:
True if successful, False otherwise
"""
if not self.is_initialized:
return False
new_fps = ueye.c_double(fps)
actual_fps = ueye.c_double()
ret = ueye.is_SetFrameRate(self.h_cam, new_fps, actual_fps)
if ret == ueye.IS_SUCCESS:
logger.debug(f"Framerate set to {fps}fps")
return True
else:
logger.error(f"Failed to set framerate: {ret}")
return False
def get_framerate(self) -> Optional[float]:
"""
Get current framerate.
Returns:
Framerate in fps, or None if failed
"""
if not self.is_initialized:
return None
fps = ueye.c_double()
ret = ueye.is_GetFramesPerSecond(self.h_cam, fps)
if ret == ueye.IS_SUCCESS:
return fps.value
else:
return None
def set_gain(self, master_gain: int) -> bool:
"""
Set camera master gain.
Args:
master_gain: Gain value (0-100)
Returns:
True if successful, False otherwise
"""
if not self.is_initialized:
return False
if master_gain < 0 or master_gain > 100:
logger.error(f"Gain value {master_gain} out of range (0-100)")
return False
ret = ueye.is_SetHardwareGain(
self.h_cam,
master_gain,
ueye.IS_IGNORE_PARAMETER,
ueye.IS_IGNORE_PARAMETER,
ueye.IS_IGNORE_PARAMETER
)
if ret == ueye.IS_SUCCESS:
logger.debug(f"Master gain set to {master_gain}")
return True
else:
logger.error(f"Failed to set gain: {ret}")
return False
def get_sensor_info(self) -> dict:
"""
Get camera sensor information.
Returns:
Dictionary with sensor information
"""
if not self.is_initialized:
return {}
return {
'sensor_name': self.sensor_info.strSensorName.decode('utf-8'),
'max_width': self.sensor_info.nMaxWidth.value,
'max_height': self.sensor_info.nMaxHeight.value,
'color_mode': self.sensor_info.nColorMode.value,
'pixel_size': self.sensor_info.wPixelSize.value / 100.0, # in µm
}
def __del__(self):
"""Destructor - ensure cleanup."""
self.cleanup()
class CameraStreamThread(QThread):
"""
Thread for continuous camera frame acquisition and streaming.
"""
frame_ready = pyqtSignal(QImage)
error_occurred = pyqtSignal(str)
def __init__(self, camera: UC480Camera):
"""
Initialize the camera stream thread.
Args:
camera: UC480Camera instance
"""
super().__init__()
self.camera = camera
self.running = False
def run(self):
"""Main thread loop for frame acquisition."""
self.running = True
if not self.camera.start_capture():
self.error_occurred.emit("Failed to start camera capture")
return
while self.running:
frame = self.camera.get_frame()
if frame is not None:
self.frame_ready.emit(frame)
else:
# Small delay on error to prevent CPU spinning
self.msleep(10)
self.camera.stop_capture()
def stop(self):
"""Stop the streaming thread."""
self.running = False
self.wait()