Initial commit: merge nuescan, pymso, pybbd202, and pypewpewhops into scanengine-3

- Merged four separate hardware control projects into unified platform
- Created unified requirements.txt with all dependencies
- Added comprehensive .gitignore
- Added project overview README

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Ales [M S E]
2026-01-16 20:11:31 -06:00
commit fc43fbe4b0
65 changed files with 19127 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
"""
Hardware communication modules for nueScan
Handles communication with ThorLabs stage, T3R device, and microscopes
"""
+928
View File
@@ -0,0 +1,928 @@
"""
ThorLabs BBD203 3-Channel Motor Controller Driver
Complete implementation of the APT protocol for BBD203
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import serial
import serial.tools.list_ports
import time
import threading
from typing import Dict, List, Optional, Callable, Tuple
from queue import Queue, Empty
from hardware.bbd203_protocol import (
APTProtocol, APTMessage, MessageID, StatusBits, TriggerMode, Destination
)
class BBD203Channel:
"""Represents a single channel on the BBD203"""
def __init__(self, channel_num: int):
"""
Initialize channel
Args:
channel_num: Channel number (1, 2, or 3)
"""
self.channel_num = channel_num
self.enabled = False
self.homed = False
self.position_mm = 0.0
self.encoder_count = 0
self.status_bits = 0
self.moving = False
self.homing = False
self.error = False
def update_from_status(self, position: int, encoder: int, status: int,
protocol: APTProtocol):
"""Update channel state from status update"""
self.position_mm = protocol.apt_to_position(position)
self.encoder_count = encoder
self.status_bits = status
# Parse status bits
self.homed = bool(status & StatusBits.HOMED)
self.homing = bool(status & StatusBits.HOMING)
self.enabled = bool(status & StatusBits.MOTOR_ENABLED)
self.error = bool(status & StatusBits.MOTION_ERROR)
# Check if moving
self.moving = bool(status & (
StatusBits.IN_MOTION_FORWARD |
StatusBits.IN_MOTION_REVERSE |
StatusBits.JOGGING_FORWARD |
StatusBits.JOGGING_REVERSE |
StatusBits.HOMING
))
def is_ready(self) -> bool:
"""Check if channel is ready for operation"""
return self.enabled and self.homed and not self.error
class BBD203Driver:
"""
Complete driver for ThorLabs BBD203 3-Channel Motor Controller
Features:
- 3 independent motor channels
- Binary APT protocol communication
- Automatic status updates
- Thread-safe operation
- Position and velocity control
"""
def __init__(self, encoder_counts_per_mm: int = 20000, timeout: float = 1.0):
"""
Initialize BBD203 driver
Args:
encoder_counts_per_mm: Encoder resolution (default: 20000 for MLS203)
timeout: Serial communication timeout in seconds
"""
self.protocol = APTProtocol(encoder_counts_per_mm)
self.timeout = timeout
# Serial connection
self._serial: Optional[serial.Serial] = None
self._port_name = ""
self._connected = False
# Channels
self.channels = {
1: BBD203Channel(1),
2: BBD203Channel(2),
3: BBD203Channel(3)
}
# Communication thread
self._rx_thread: Optional[threading.Thread] = None
self._stop_thread = threading.Event()
self._rx_queue = Queue()
# Callbacks for asynchronous events
self._move_complete_callbacks: Dict[int, List[Callable]] = {1: [], 2: [], 3: []}
self._home_complete_callbacks: Dict[int, List[Callable]] = {1: [], 2: [], 3: []}
# Hardware info
self._hw_info = {}
# ==================== Connection Management ====================
@staticmethod
def list_available_ports() -> List[str]:
"""
List available serial ports
Returns:
list: Available port names
"""
ports = serial.tools.list_ports.comports()
return [port.device for port in ports]
@staticmethod
def list_thorlabs_devices() -> List[Dict[str, str]]:
"""
List all ThorLabs APT devices connected via USB
Returns:
list: List of dictionaries containing device information
Each dict has: 'serial', 'port', 'description', 'vid', 'pid'
"""
thorlabs_devices = []
ports = serial.tools.list_ports.comports()
# ThorLabs devices typically use FTDI chips
# Common VID/PID combinations:
# - FTDI: VID=0x0403, various PIDs
thorlabs_vids = [0x0403] # FTDI vendor ID
for port in ports:
# Check if this is a ThorLabs device by VID
if port.vid in thorlabs_vids:
device_info = {
'serial': port.serial_number or 'Unknown',
'port': port.device,
'description': port.description or 'Unknown',
'manufacturer': port.manufacturer or 'Unknown',
'vid': f"0x{port.vid:04X}" if port.vid else 'Unknown',
'pid': f"0x{port.pid:04X}" if port.pid else 'Unknown'
}
thorlabs_devices.append(device_info)
print(f"DEBUG: Found ThorLabs device - Serial: {device_info['serial']}, "
f"Port: {device_info['port']}")
return thorlabs_devices
@staticmethod
def find_device_by_serial(serial_number: str) -> Optional[str]:
"""
Find ThorLabs device by serial number and return its port
Args:
serial_number: Device serial number (e.g., '83123456')
Returns:
str: COM port name if found, None otherwise
"""
devices = BBD203Driver.list_thorlabs_devices()
for device in devices:
if device['serial'] == serial_number:
print(f"INFO: Found device {serial_number} on port {device['port']}")
return device['port']
print(f"WARNING: Device with serial number {serial_number} not found")
print(f"Available devices: {[d['serial'] for d in devices]}")
return None
def connect_by_serial(self, serial_number: str, baudrate: int = 115200) -> bool:
"""
Connect to BBD203 controller by serial number (auto-find port)
This is the preferred connection method - automatically finds the
device by serial number over USB, similar to Kinesis library.
Args:
serial_number: Device serial number (e.g., '83123456')
baudrate: Baud rate (default: 115200)
Returns:
bool: True if connection successful
Example:
driver.connect_by_serial('83123456')
"""
# Find device port by serial number
port = self.find_device_by_serial(serial_number)
if port is None:
print(f"ERROR: Could not find BBD203 with serial number {serial_number}")
print("Available ThorLabs devices:")
for device in self.list_thorlabs_devices():
print(f" Serial: {device['serial']}, Port: {device['port']}, "
f"Description: {device['description']}")
return False
# Connect using the found port
return self.connect(port, baudrate)
def connect(self, port: str, baudrate: int = 115200) -> bool:
"""
Connect to BBD203 controller by port name
Note: It's recommended to use connect_by_serial() instead, which
automatically finds the device by serial number.
Args:
port: Serial port name (e.g., 'COM3' or '/dev/ttyUSB0')
baudrate: Baud rate (default: 115200)
Returns:
bool: True if connection successful
"""
try:
print(f"INFO: Connecting to BBD203 on {port}")
self._serial = serial.Serial(
port=port,
baudrate=baudrate,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=self.timeout,
rtscts=False, # Disable hardware flow control
xonxoff=False # Disable software flow control
)
# Set DTR and RTS for ThorLabs FTDI devices
# For BBD203, RTS should be LOW to enable communication
self._serial.dtr = False
self._serial.rts = False
self._port_name = port
self._connected = True
# Give controller time to initialize after connection
time.sleep(0.5)
# Start receive thread
self._stop_thread.clear()
self._rx_thread = threading.Thread(target=self._receive_loop, daemon=True)
self._rx_thread.start()
# Initialize controller
time.sleep(0.5) # Allow thread to start and controller to be ready
# Request hardware info
self._send_command(self.protocol.cmd_req_hw_info())
time.sleep(0.5)
# Start automatic status updates
self._send_command(self.protocol.cmd_start_update_msgs())
time.sleep(0.5)
print(f"INFO: Successfully connected to BBD203 on {port}")
return True
except serial.SerialException as e:
print(f"ERROR: Failed to connect to {port}: {e}")
self._connected = False
return False
def disconnect(self) -> bool:
"""
Disconnect from BBD203 controller
Returns:
bool: True if disconnection successful
"""
if not self._connected:
return True
try:
print("INFO: Disconnecting from BBD203")
# Stop status updates
self._send_command(self.protocol.cmd_stop_update_msgs())
time.sleep(0.1)
# Stop receive thread
self._stop_thread.set()
if self._rx_thread:
self._rx_thread.join(timeout=2.0)
# Close serial port
if self._serial and self._serial.is_open:
self._serial.close()
self._connected = False
print("INFO: Disconnected from BBD203")
return True
except Exception as e:
print(f"ERROR: Error during disconnect: {e}")
return False
def is_connected(self) -> bool:
"""Check if controller is connected"""
return self._connected and self._serial and self._serial.is_open
# ==================== Communication Methods ====================
def _send_command(self, cmd: bytes) -> bool:
"""
Send command to controller
Args:
cmd: Command bytes to send
Returns:
bool: True if send successful
"""
if not self.is_connected():
print("ERROR: Cannot send command - not connected")
return False
try:
print(f"DEBUG: Sending {len(cmd)} bytes: {cmd.hex()}")
self._serial.write(cmd)
self._serial.flush() # Ensure data is sent
return True
except serial.SerialException as e:
print(f"ERROR: Failed to send command: {e}")
return False
def _receive_loop(self):
"""Background thread to receive messages from controller"""
buffer = bytearray()
print("DEBUG: Receive thread started")
while not self._stop_thread.is_set():
try:
if self._serial.in_waiting > 0:
data = self._serial.read(self._serial.in_waiting)
print(f"DEBUG: Received {len(data)} bytes: {data.hex()}")
buffer.extend(data)
# Process complete messages
while len(buffer) >= 6:
# Parse header
msg_id, data_len, dest, source = APTMessage.parse_header(buffer)
# Determine total message length
if data_len == 0 or data_len > 255:
# Header-only message
msg_len = 6
else:
# Message with data
msg_len = 6 + data_len
# Wait for complete message
if len(buffer) < msg_len:
break
# Extract message
msg = bytes(buffer[:msg_len])
buffer = buffer[msg_len:]
# Process message
self._process_message(msg_id, msg)
else:
time.sleep(0.001) # Small delay to prevent busy waiting
except Exception as e:
if not self._stop_thread.is_set():
print(f"ERROR: Exception in receive loop: {e}")
time.sleep(0.1)
def _process_message(self, msg_id: int, msg: bytes):
"""
Process received message
Args:
msg_id: Message ID
msg: Complete message bytes
"""
try:
print(f"DEBUG: Processing message ID 0x{msg_id:04X}, len={len(msg)}, data={msg.hex()}")
if msg_id == MessageID.MGMSG_MOT_GET_STATUSUPDATE:
# Status update
channel, position, encoder, status = APTMessage.parse_status_update(msg)
# Determine which channel this is for (from destination byte)
dest = msg[4]
channel_num = dest - 0x20 # 0x21->1, 0x22->2, 0x23->3
if channel_num in self.channels:
self.channels[channel_num].update_from_status(
position, encoder, status, self.protocol
)
elif msg_id == MessageID.MGMSG_MOT_MOVE_COMPLETED:
# Move completed
dest = msg[4]
channel_num = dest - 0x20
if channel_num in self.channels:
self.channels[channel_num].moving = False
# Call callbacks
for callback in self._move_complete_callbacks.get(channel_num, []):
callback(channel_num)
elif msg_id == MessageID.MGMSG_MOT_MOVE_HOMED:
# Homing completed
dest = msg[4]
channel_num = dest - 0x20
if channel_num in self.channels:
self.channels[channel_num].homed = True
self.channels[channel_num].homing = False
# Call callbacks
for callback in self._home_complete_callbacks.get(channel_num, []):
callback(channel_num)
elif msg_id == MessageID.MGMSG_MOT_MOVE_STOPPED:
# Motion stopped
dest = msg[4]
channel_num = dest - 0x20
if channel_num in self.channels:
self.channels[channel_num].moving = False
elif msg_id == MessageID.MGMSG_MOD_GET_CHANENABLESTATE:
# Channel enable state
channel, enabled = APTMessage.parse_channel_enable_state(msg)
print(f"DEBUG: Received CHANENABLESTATE - parsed channel={channel}, enabled={enabled}, msg={msg.hex()}")
# Use the channel number from the parsed message
if channel in self.channels:
self.channels[channel].enabled = enabled
print(f"DEBUG: Set channel {channel} enabled={enabled}")
elif msg_id == MessageID.MGMSG_HW_RESPONSE:
# Hardware response (error or acknowledgement)
print(f"DEBUG: Received HW_RESPONSE: {msg.hex()}")
elif msg_id == MessageID.MGMSG_HW_GET_INFO:
# Hardware info
print(f"DEBUG: Received hardware info")
except Exception as e:
print(f"ERROR: Failed to process message {msg_id:04X}: {e}")
def _set_and_verify_enable(self, channel: int, enable: bool, retries: int = 3) -> bool:
"""
Set channel enable state and verify it was set correctly
Args:
channel: Channel number (1, 2, or 3)
enable: True to enable, False to disable
retries: Number of retry attempts
Returns:
bool: True if value was set and verified
"""
for attempt in range(retries):
# Send enable command
cmd = self.protocol.cmd_enable_channel(channel, enable)
if not self._send_command(cmd):
continue
time.sleep(0.5) # Wait for controller to process
# Request channel enable state to verify
req_cmd = self.protocol.cmd_req_channel_enable_state(channel)
self._send_command(req_cmd)
time.sleep(0.5) # Wait for response
# Check if state matches expected
if self.channels[channel].enabled == enable:
return True
if attempt < retries - 1:
print(f"DEBUG: Enable verification failed for channel {channel}, "
f"retrying ({attempt + 1}/{retries})")
time.sleep(0.2)
print(f"ERROR: Failed to set and verify enable state for channel {channel} "
f"after {retries} attempts")
return False
# ==================== Channel Control ====================
def enable_channel(self, channel: int, enable: bool = True) -> bool:
"""
Enable or disable a motor channel
Args:
channel: Channel number (1, 2, or 3)
enable: True to enable, False to disable
Returns:
bool: True if command sent successfully
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
action = "Enabling" if enable else "Disabling"
print(f"DEBUG: {action} channel {channel}")
# Use set and verify to ensure command was processed
return self._set_and_verify_enable(channel, enable)
def identify(self, channel: int) -> bool:
"""
Flash front panel LEDs to identify controller
Args:
channel: Channel number (1, 2, or 3)
Returns:
bool: True if command sent successfully
"""
print(f"DEBUG: Identifying channel {channel}")
cmd = self.protocol.cmd_identify(channel)
return self._send_command(cmd)
# ==================== Homing ====================
def home_channel(self, channel: int, wait: bool = False, timeout: float = 30.0) -> bool:
"""
Home a motor channel
Args:
channel: Channel number (1, 2, or 3)
wait: If True, block until homing complete
timeout: Timeout in seconds if waiting
Returns:
bool: True if homing initiated (or completed if wait=True)
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
print(f"DEBUG: Homing channel {channel}")
self.channels[channel].homing = True
self.channels[channel].homed = False
cmd = self.protocol.cmd_move_home(channel)
if not self._send_command(cmd):
return False
if wait:
# Wait for homing to complete
start_time = time.time()
while time.time() - start_time < timeout:
if self.channels[channel].homed and not self.channels[channel].homing:
print(f"INFO: Channel {channel} homing completed")
return True
time.sleep(0.1)
print(f"ERROR: Homing timeout for channel {channel}")
return False
return True
def home_all_channels(self, wait: bool = False, timeout: float = 30.0) -> bool:
"""
Home all enabled channels
Args:
wait: If True, block until all homing complete
timeout: Timeout in seconds if waiting
Returns:
bool: True if all homing operations successful
"""
success = True
for channel in [1, 2, 3]:
if self.channels[channel].enabled:
if not self.home_channel(channel, wait=False):
success = False
if wait:
start_time = time.time()
while time.time() - start_time < timeout:
all_homed = all(
ch.homed for ch in self.channels.values() if ch.enabled
)
if all_homed:
print("INFO: All channels homed successfully")
return True
time.sleep(0.1)
print("ERROR: Timeout waiting for all channels to home")
return False
return success
# ==================== Motion Control ====================
def move_absolute(self, channel: int, position_mm: float,
wait: bool = False, timeout: float = 30.0) -> bool:
"""
Move to absolute position
Args:
channel: Channel number (1, 2, or 3)
position_mm: Target position in mm
wait: If True, block until move complete
timeout: Timeout in seconds if waiting
Returns:
bool: True if move initiated (or completed if wait=True)
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
if not self.channels[channel].is_ready():
print(f"ERROR: Channel {channel} not ready for movement")
return False
print(f"DEBUG: Moving channel {channel} to {position_mm} mm")
self.channels[channel].moving = True
cmd = self.protocol.cmd_move_absolute(channel, position_mm)
if not self._send_command(cmd):
return False
if wait:
# Wait for move to complete
start_time = time.time()
while time.time() - start_time < timeout:
if not self.channels[channel].moving:
print(f"INFO: Channel {channel} move completed")
return True
time.sleep(0.01)
print(f"ERROR: Move timeout for channel {channel}")
return False
return True
def move_relative(self, channel: int, distance_mm: float,
wait: bool = False, timeout: float = 30.0) -> bool:
"""
Move relative distance
Args:
channel: Channel number (1, 2, or 3)
distance_mm: Distance to move in mm (positive or negative)
wait: If True, block until move complete
timeout: Timeout in seconds if waiting
Returns:
bool: True if move initiated (or completed if wait=True)
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
if not self.channels[channel].is_ready():
print(f"ERROR: Channel {channel} not ready for movement")
return False
print(f"DEBUG: Moving channel {channel} by {distance_mm} mm")
self.channels[channel].moving = True
cmd = self.protocol.cmd_move_relative(channel, distance_mm)
if not self._send_command(cmd):
return False
if wait:
# Wait for move to complete
start_time = time.time()
while time.time() - start_time < timeout:
if not self.channels[channel].moving:
print(f"INFO: Channel {channel} move completed")
return True
time.sleep(0.01)
print(f"ERROR: Move timeout for channel {channel}")
return False
return True
def stop(self, channel: int, immediate: bool = True) -> bool:
"""
Stop motion
Args:
channel: Channel number (1, 2, or 3), or 0 for all channels
immediate: If True, stop immediately; if False, decelerate
Returns:
bool: True if stop command sent successfully
"""
if channel == 0:
# Stop all channels
success = True
for ch in [1, 2, 3]:
if not self.stop(ch, immediate):
success = False
return success
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
print(f"DEBUG: Stopping channel {channel}")
cmd = self.protocol.cmd_move_stop(channel, immediate)
return self._send_command(cmd)
# ==================== Parameter Setting ====================
def set_velocity_params(self, channel: int, max_vel_mm_s: float,
accel_mm_s2: float) -> bool:
"""
Set velocity and acceleration parameters
Args:
channel: Channel number (1, 2, or 3)
max_vel_mm_s: Maximum velocity in mm/s
accel_mm_s2: Acceleration in mm/s²
Returns:
bool: True if parameters set successfully
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
print(f"DEBUG: Setting velocity params for channel {channel}: "
f"vel={max_vel_mm_s} mm/s, accel={accel_mm_s2} mm/s²")
cmd = self.protocol.cmd_set_velocity_params(channel, max_vel_mm_s, accel_mm_s2)
if self._send_command(cmd):
time.sleep(0.1) # Wait for controller to process
# Request status update to confirm parameters were accepted
self.request_status_update(channel)
time.sleep(0.1) # Wait for status response
return True
return False
# ==================== Status and Position ====================
def get_position(self, channel: int) -> Optional[float]:
"""
Get current position of channel
Args:
channel: Channel number (1, 2, or 3)
Returns:
float: Current position in mm, or None if unavailable
"""
if channel not in [1, 2, 3]:
return None
return self.channels[channel].position_mm
def get_channel_status(self, channel: int) -> Optional[Dict]:
"""
Get detailed status of channel
Args:
channel: Channel number (1, 2, or 3)
Returns:
dict: Channel status dictionary
"""
if channel not in [1, 2, 3]:
return None
ch = self.channels[channel]
return {
'channel': channel,
'enabled': ch.enabled,
'homed': ch.homed,
'homing': ch.homing,
'moving': ch.moving,
'error': ch.error,
'ready': ch.is_ready(),
'position_mm': ch.position_mm,
'encoder_count': ch.encoder_count,
'status_bits': ch.status_bits
}
def request_status_update(self, channel: int) -> bool:
"""
Request immediate status update for channel
Args:
channel: Channel number (1, 2, or 3)
Returns:
bool: True if request sent successfully
"""
if channel not in [1, 2, 3]:
return False
cmd = self.protocol.cmd_req_status_update(channel)
return self._send_command(cmd)
# ==================== Callbacks ====================
def register_move_complete_callback(self, channel: int, callback: Callable):
"""Register callback for move complete event"""
if channel in [1, 2, 3]:
self._move_complete_callbacks[channel].append(callback)
def register_home_complete_callback(self, channel: int, callback: Callable):
"""Register callback for home complete event"""
if channel in [1, 2, 3]:
self._home_complete_callbacks[channel].append(callback)
# ==================== Trigger Configuration ====================
def set_trigger_mode(self, channel: int, mode: int, polarity: int = 0x01,
start_pos_fwd: float = 0.0, start_pos_rev: float = 0.0,
interval_fwd: float = 0.0, interval_rev: float = 0.0) -> bool:
"""
Set trigger configuration for a channel
Args:
channel: Channel number (1, 2, or 3)
mode: Trigger mode (TriggerMode enum value)
polarity: Trigger polarity (0x01 = active high, 0x02 = active low)
start_pos_fwd: Start position for forward trigger (mm)
start_pos_rev: Start position for reverse trigger (mm)
interval_fwd: Interval for forward trigger (mm)
interval_rev: Interval for reverse trigger (mm)
Returns:
bool: True if trigger configuration set successfully
Example:
# Disable trigger
driver.set_trigger_mode(1, TriggerMode.DISABLED)
# Enable trigger output on motion
driver.set_trigger_mode(1, TriggerMode.OUT_ONLY)
# Trigger at specific positions
driver.set_trigger_mode(1, TriggerMode.OUT_POSITION,
start_pos_fwd=10.0, interval_fwd=1.0)
"""
if channel not in [1, 2, 3]:
print(f"ERROR: Invalid channel number: {channel}")
return False
print(f"DEBUG: Setting trigger mode for channel {channel}: mode={mode}")
cmd = self.protocol.cmd_set_trigger(
channel, mode, polarity, start_pos_fwd, start_pos_rev,
interval_fwd, interval_rev
)
if self._send_command(cmd):
time.sleep(0.1) # Wait for controller to process
return True
return False
def get_trigger_config(self, channel: int) -> Optional[Dict]:
"""
Get current trigger configuration for a channel
Args:
channel: Channel number (1, 2, or 3)
Returns:
dict: Trigger configuration or None if unavailable
"""
if channel not in [1, 2, 3]:
return None
cmd = self.protocol.cmd_req_trigger(channel)
if not self._send_command(cmd):
return None
# Note: In a complete implementation, would wait for response
# For now, returning None as response handling would need queue
print("WARNING: get_trigger_config not fully implemented (requires response queue)")
return None
# ==================== Digital I/O ====================
def set_digital_outputs(self, output_bits: int) -> bool:
"""
Set digital output states
Args:
output_bits: Bit pattern for outputs (0x00 to 0xFF)
Returns:
bool: True if digital outputs set successfully
Note:
Digital outputs share pins with trigger outputs. Ensure
trigger mode is disabled before using digital outputs.
"""
if not (0 <= output_bits <= 0xFF):
print(f"ERROR: Invalid output bits: {output_bits}")
return False
print(f"DEBUG: Setting digital outputs: 0x{output_bits:02X}")
cmd = self.protocol.cmd_set_digital_outputs(output_bits)
if self._send_command(cmd):
time.sleep(0.05)
return True
return False
+532
View File
@@ -0,0 +1,532 @@
"""
ThorLabs BBD203 APT Protocol Handler
Binary message protocol for BBD203 motor controller
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import struct
from enum import IntEnum
from typing import Tuple, Optional, List
# Message IDs
class MessageID(IntEnum):
"""APT Protocol Message IDs for BBD203"""
# Module Control
MGMSG_MOD_IDENTIFY = 0x0223
MGMSG_MOD_SET_CHANENABLESTATE = 0x0210
MGMSG_MOD_REQ_CHANENABLESTATE = 0x0211
MGMSG_MOD_GET_CHANENABLESTATE = 0x0212
# Hardware Control
MGMSG_HW_DISCONNECT = 0x0002
MGMSG_HW_RESPONSE = 0x0080
MGMSG_HW_RICHRESPONSE = 0x0081
MGMSG_HW_START_UPDATEMSGS = 0x0011
MGMSG_HW_STOP_UPDATEMSGS = 0x0012
MGMSG_HW_REQ_INFO = 0x0005
MGMSG_HW_GET_INFO = 0x0006
# Motor Control - Basic
MGMSG_MOT_SET_POSCOUNTER = 0x0410
MGMSG_MOT_REQ_POSCOUNTER = 0x0411
MGMSG_MOT_GET_POSCOUNTER = 0x0412
MGMSG_MOT_SET_ENCCOUNTER = 0x0409
MGMSG_MOT_REQ_ENCCOUNTER = 0x040A
MGMSG_MOT_GET_ENCCOUNTER = 0x040B
# Motor Control - Homing
MGMSG_MOT_SET_HOMEPARAMS = 0x0440
MGMSG_MOT_REQ_HOMEPARAMS = 0x0441
MGMSG_MOT_GET_HOMEPARAMS = 0x0442
MGMSG_MOT_MOVE_HOME = 0x0443
MGMSG_MOT_MOVE_HOMED = 0x0444
# Motor Control - Movement
MGMSG_MOT_SET_MOVERELPARAMS = 0x0445
MGMSG_MOT_REQ_MOVERELPARAMS = 0x0446
MGMSG_MOT_GET_MOVERELPARAMS = 0x0447
MGMSG_MOT_MOVE_RELATIVE = 0x0448
MGMSG_MOT_SET_MOVEABSPARAMS = 0x0450
MGMSG_MOT_REQ_MOVEABSPARAMS = 0x0451
MGMSG_MOT_GET_MOVEABSPARAMS = 0x0452
MGMSG_MOT_MOVE_ABSOLUTE = 0x0453
MGMSG_MOT_MOVE_COMPLETED = 0x0464
MGMSG_MOT_MOVE_VELOCITY = 0x0457
MGMSG_MOT_MOVE_STOP = 0x0465
MGMSG_MOT_MOVE_STOPPED = 0x0466
# Motor Control - Velocity
MGMSG_MOT_SET_VELPARAMS = 0x0413
MGMSG_MOT_REQ_VELPARAMS = 0x0414
MGMSG_MOT_GET_VELPARAMS = 0x0415
# Motor Control - Status
MGMSG_MOT_REQ_STATUSUPDATE = 0x0480
MGMSG_MOT_GET_STATUSUPDATE = 0x0481
MGMSG_MOT_REQ_STATUSBITS = 0x0429
MGMSG_MOT_GET_STATUSBITS = 0x042A
# Digital I/O and Trigger
MGMSG_RACK_SET_DIGOUTPUTS = 0x0228
MGMSG_RACK_REQ_DIGOUTPUTS = 0x0229
MGMSG_RACK_GET_DIGOUTPUTS = 0x0230
MGMSG_MOT_SET_TRIGGER = 0x0500
MGMSG_MOT_REQ_TRIGGER = 0x0501
MGMSG_MOT_GET_TRIGGER = 0x0502
# Destination addresses
class Destination(IntEnum):
"""BBD203 Destination addresses"""
USB = 0x50
ALL_CHANNELS = 0x11
CHANNEL_1 = 0x21
CHANNEL_2 = 0x22
CHANNEL_3 = 0x23
# Source addresses
class Source(IntEnum):
"""Source addresses"""
HOST = 0x01
# Status bits
class StatusBits(IntEnum):
"""Motor status bit definitions"""
HOMING = 0x00000200
HOMED = 0x00000400
TRACKING = 0x00001000
SETTLED = 0x00002000
MOTION_ERROR = 0x00004000
MOTOR_ENABLED = 0x80000000
FORWARD_LIMIT = 0x00000001
REVERSE_LIMIT = 0x00000002
IN_MOTION_FORWARD = 0x00000010
IN_MOTION_REVERSE = 0x00000020
JOGGING_FORWARD = 0x00000040
JOGGING_REVERSE = 0x00000080
# Trigger modes
class TriggerMode(IntEnum):
"""Trigger mode definitions"""
DISABLED = 0x00
IN_OUT_RELATIVE_MOVE = 0x01
IN_OUT_ABSOLUTE_MOVE = 0x02
IN_OUT_HOME = 0x03
IN_OUT_STOP = 0x04
OUT_ONLY = 0x10
OUT_POSITION = 0x11
class APTMessage:
"""
APT Protocol Message Builder and Parser
Handles construction and parsing of binary APT messages
"""
@staticmethod
def build_header_only(msg_id: int, param1: int, param2: int,
dest: int, source: int = Source.HOST) -> bytes:
"""
Build a 6-byte header-only message
Args:
msg_id: Message ID (16-bit)
param1: Parameter 1 (8-bit)
param2: Parameter 2 (8-bit)
dest: Destination address
source: Source address (default: HOST)
Returns:
bytes: 6-byte message
"""
return struct.pack('<HBBBB', msg_id, param1, param2, dest, source)
@staticmethod
def build_with_data(msg_id: int, dest: int, data: bytes,
source: int = Source.HOST) -> bytes:
"""
Build a message with data packet
Args:
msg_id: Message ID (16-bit)
dest: Destination address
data: Data packet bytes
source: Source address (default: HOST)
Returns:
bytes: Complete message (header + data)
"""
data_len = len(data)
header = struct.pack('<HHBB', msg_id, data_len, dest, source)
return header + data
@staticmethod
def parse_header(data: bytes) -> Tuple[int, int, int, int, int]:
"""
Parse message header
Args:
data: At least 6 bytes of message data
Returns:
tuple: (msg_id, data_len, dest, source, has_data)
"""
if len(data) < 6:
raise ValueError("Insufficient data for header")
msg_id, byte2, byte3, dest, source = struct.unpack('<HBBBB', data[:6])
# Determine if this is header-only or has data
# Header-only messages use bytes 2-3 as parameters
# Messages with data use bytes 2-3 as data length
data_len = (byte3 << 8) | byte2
return msg_id, data_len, dest, source
@staticmethod
def parse_position_counter(data: bytes) -> Tuple[int, int]:
"""Parse MGMSG_MOT_GET_POSCOUNTER response"""
if len(data) < 12:
raise ValueError("Insufficient data for position counter")
_, channel, position = struct.unpack('<HHI', data[6:12])
return channel, position
@staticmethod
def parse_encoder_counter(data: bytes) -> Tuple[int, int]:
"""Parse MGMSG_MOT_GET_ENCCOUNTER response"""
if len(data) < 12:
raise ValueError("Insufficient data for encoder counter")
_, channel, encoder = struct.unpack('<HHI', data[6:12])
return channel, encoder
@staticmethod
def parse_status_update(data: bytes) -> Tuple[int, int, int, int]:
"""
Parse MGMSG_MOT_GET_STATUSUPDATE response
Returns:
tuple: (channel, position, enc_count, status_bits)
"""
if len(data) < 20:
raise ValueError("Insufficient data for status update")
# Skip 6-byte header, parse data packet
channel, position, enc_count, status = struct.unpack('<HIII', data[6:20])
return channel, position, enc_count, status
@staticmethod
def parse_velocity_params(data: bytes) -> Tuple[int, int, int, int]:
"""
Parse MGMSG_MOT_GET_VELPARAMS response
Returns:
tuple: (channel, min_vel, max_vel, accel)
"""
if len(data) < 20:
raise ValueError("Insufficient data for velocity params")
channel, min_vel, max_vel, accel = struct.unpack('<HIII', data[6:20])
return channel, min_vel, max_vel, accel
@staticmethod
def parse_channel_enable_state(data: bytes) -> Tuple[int, bool]:
"""Parse MGMSG_MOD_GET_CHANENABLESTATE response"""
if len(data) < 6:
raise ValueError("Insufficient data for channel enable state")
# Header only message, params in bytes 2-3
_, enable_state, channel, _, _ = struct.unpack('<HBBBB', data[:6])
return channel, (enable_state == 0x01)
@staticmethod
def parse_trigger_config(data: bytes) -> Tuple[int, int, int, int, int, int, int]:
"""
Parse MGMSG_MOT_GET_TRIGGER response
Returns:
tuple: (channel, trigger_mode, polarity, start_pos_fwd, start_pos_rev,
interval_fwd, interval_rev, num_pulses, pulse_width, num_cycles)
"""
if len(data) < 28:
raise ValueError("Insufficient data for trigger config")
# Parse data packet (22 bytes starting at byte 6)
channel, mode, polarity, start_fwd, start_rev, interval_fwd, interval_rev = \
struct.unpack('<HBBIIIi', data[6:28])
# Extended parameters if available
num_pulses = 0
pulse_width = 0
num_cycles = 0
if len(data) >= 40:
num_pulses, pulse_width, num_cycles = struct.unpack('<III', data[28:40])
return (channel, mode, polarity, start_fwd, start_rev,
interval_fwd, interval_rev, num_pulses, pulse_width, num_cycles)
@staticmethod
def parse_digital_outputs(data: bytes) -> Tuple[int, int]:
"""Parse MGMSG_RACK_GET_DIGOUTPUTS response"""
if len(data) < 6:
raise ValueError("Insufficient data for digital outputs")
# Header only message, params in bytes 2-3
_, output_state, _, _, _ = struct.unpack('<HBBBB', data[:6])
return output_state
class APTProtocol:
"""
High-level APT Protocol interface for BBD203
Provides methods to build common command messages
"""
# Scaling constants
T_SAMPLE = 102.4e-6 # Controller sample time
VELOCITY_SCALE = int(T_SAMPLE * 65536)
ACCEL_SCALE = int((T_SAMPLE ** 2) * 65536)
def __init__(self, encoder_counts_per_mm: int = 20000):
"""
Initialize APT Protocol handler
Args:
encoder_counts_per_mm: Encoder resolution (default: 20000 for MLS203)
"""
self.enc_cnt = encoder_counts_per_mm
def position_to_apt(self, pos_mm: float) -> int:
"""Convert position in mm to APT units"""
return int(pos_mm * self.enc_cnt)
def apt_to_position(self, apt_units: int) -> float:
"""Convert APT units to position in mm"""
return apt_units / self.enc_cnt
def velocity_to_apt(self, vel_mm_s: float) -> int:
"""Convert velocity in mm/s to APT units"""
return int(self.enc_cnt * self.T_SAMPLE * 65536 * vel_mm_s)
def apt_to_velocity(self, apt_units: int) -> float:
"""Convert APT units to velocity in mm/s"""
return apt_units / (self.enc_cnt * self.T_SAMPLE * 65536)
def accel_to_apt(self, accel_mm_s2: float) -> int:
"""Convert acceleration in mm/s² to APT units"""
return int(self.enc_cnt * (self.T_SAMPLE ** 2) * 65536 * accel_mm_s2)
def apt_to_accel(self, apt_units: int) -> float:
"""Convert APT units to acceleration in mm/s²"""
return apt_units / (self.enc_cnt * (self.T_SAMPLE ** 2) * 65536)
# Command builders
def cmd_identify(self, channel: int) -> bytes:
"""Build identify command (flash LEDs)"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOD_IDENTIFY, 0x00, 0x00, dest
)
def cmd_enable_channel(self, channel: int, enable: bool = True) -> bytes:
"""Build enable/disable channel command"""
dest = Destination.CHANNEL_1 + (channel - 1)
state = 0x01 if enable else 0x02
return APTMessage.build_header_only(
MessageID.MGMSG_MOD_SET_CHANENABLESTATE, state, channel, dest
)
def cmd_req_channel_enable_state(self, channel: int) -> bytes:
"""Build request channel enable state command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOD_REQ_CHANENABLESTATE, 0x01, 0x00, dest
)
def cmd_start_update_msgs(self) -> bytes:
"""Build start automatic status updates command"""
return APTMessage.build_header_only(
MessageID.MGMSG_HW_START_UPDATEMSGS, 0x00, 0x00, Destination.USB
)
def cmd_stop_update_msgs(self) -> bytes:
"""Build stop automatic status updates command"""
return APTMessage.build_header_only(
MessageID.MGMSG_HW_STOP_UPDATEMSGS, 0x00, 0x00, Destination.USB
)
def cmd_req_hw_info(self) -> bytes:
"""Build request hardware info command"""
return APTMessage.build_header_only(
MessageID.MGMSG_HW_REQ_INFO, 0x00, 0x00, Destination.USB
)
def cmd_move_home(self, channel: int) -> bytes:
"""Build move home command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_MOVE_HOME, 0x01, 0x00, dest
)
def cmd_move_absolute(self, channel: int, position_mm: float) -> bytes:
"""Build move absolute command"""
dest = Destination.CHANNEL_1 + (channel - 1)
pos_apt = self.position_to_apt(position_mm)
data = struct.pack('<HI', channel, pos_apt)
return APTMessage.build_with_data(
MessageID.MGMSG_MOT_MOVE_ABSOLUTE, dest, data
)
def cmd_move_relative(self, channel: int, distance_mm: float) -> bytes:
"""Build move relative command"""
dest = Destination.CHANNEL_1 + (channel - 1)
dist_apt = self.position_to_apt(distance_mm)
data = struct.pack('<Hi', channel, dist_apt)
return APTMessage.build_with_data(
MessageID.MGMSG_MOT_MOVE_RELATIVE, dest, data
)
def cmd_move_stop(self, channel: int, immediate: bool = True) -> bytes:
"""Build stop motion command"""
dest = Destination.CHANNEL_1 + (channel - 1)
stop_mode = 0x01 if immediate else 0x02
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_MOVE_STOP, 0x01, stop_mode, dest
)
def cmd_set_velocity_params(self, channel: int, max_vel_mm_s: float,
accel_mm_s2: float) -> bytes:
"""Build set velocity parameters command"""
dest = Destination.CHANNEL_1 + (channel - 1)
max_vel_apt = self.velocity_to_apt(max_vel_mm_s)
accel_apt = self.accel_to_apt(accel_mm_s2)
data = struct.pack('<HIII',
channel, # Channel number
0, # Min velocity (0)
max_vel_apt, # Max velocity
accel_apt # Acceleration
)
return APTMessage.build_with_data(
MessageID.MGMSG_MOT_SET_VELPARAMS, dest, data
)
def cmd_req_velocity_params(self, channel: int) -> bytes:
"""Build request velocity parameters command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_VELPARAMS, 0x01, 0x00, dest
)
def cmd_req_position(self, channel: int) -> bytes:
"""Build request position counter command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_POSCOUNTER, 0x01, 0x00, dest
)
def cmd_req_encoder(self, channel: int) -> bytes:
"""Build request encoder counter command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_ENCCOUNTER, 0x01, 0x00, dest
)
def cmd_req_status_update(self, channel: int) -> bytes:
"""Build request status update command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_STATUSUPDATE, 0x01, 0x00, dest
)
def cmd_req_status_bits(self, channel: int) -> bytes:
"""Build request status bits command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_STATUSBITS, 0x01, 0x00, dest
)
def cmd_set_position_counter(self, channel: int, position_mm: float) -> bytes:
"""Build set position counter command"""
dest = Destination.CHANNEL_1 + (channel - 1)
pos_apt = self.position_to_apt(position_mm)
data = struct.pack('<HI', channel, pos_apt)
return APTMessage.build_with_data(
MessageID.MGMSG_MOT_SET_POSCOUNTER, dest, data
)
def cmd_set_trigger(self, channel: int, mode: int, polarity: int = 0x01,
start_pos_fwd: float = 0.0, start_pos_rev: float = 0.0,
interval_fwd: float = 0.0, interval_rev: float = 0.0) -> bytes:
"""
Build set trigger configuration command
Args:
channel: Channel number (1, 2, or 3)
mode: Trigger mode (TriggerMode enum value)
polarity: Trigger polarity (0x01 = active high, 0x02 = active low)
start_pos_fwd: Start position for forward trigger (mm)
start_pos_rev: Start position for reverse trigger (mm)
interval_fwd: Interval for forward trigger (mm)
interval_rev: Interval for reverse trigger (mm)
Returns:
bytes: Complete trigger configuration command
"""
dest = Destination.CHANNEL_1 + (channel - 1)
# Convert positions to APT units
start_fwd_apt = self.position_to_apt(start_pos_fwd)
start_rev_apt = self.position_to_apt(start_pos_rev)
interval_fwd_apt = self.position_to_apt(interval_fwd)
interval_rev_apt = int(self.position_to_apt(interval_rev)) # Signed
data = struct.pack('<HBBIIIi',
channel, # Channel number
mode, # Trigger mode
polarity, # Polarity
start_fwd_apt, # Start position forward
start_rev_apt, # Start position reverse
interval_fwd_apt, # Interval forward
interval_rev_apt # Interval reverse (signed)
)
return APTMessage.build_with_data(
MessageID.MGMSG_MOT_SET_TRIGGER, dest, data
)
def cmd_req_trigger(self, channel: int) -> bytes:
"""Build request trigger configuration command"""
dest = Destination.CHANNEL_1 + (channel - 1)
return APTMessage.build_header_only(
MessageID.MGMSG_MOT_REQ_TRIGGER, 0x01, 0x00, dest
)
def cmd_set_digital_outputs(self, output_bits: int) -> bytes:
"""
Build set digital outputs command
Args:
output_bits: Bit pattern for digital outputs (0x00 to 0xFF)
Returns:
bytes: Digital output command
"""
return APTMessage.build_header_only(
MessageID.MGMSG_RACK_SET_DIGOUTPUTS, output_bits, 0x00, Destination.USB
)
def cmd_req_digital_outputs(self) -> bytes:
"""Build request digital outputs command"""
return APTMessage.build_header_only(
MessageID.MGMSG_RACK_REQ_DIGOUTPUTS, 0x00, 0x00, Destination.USB
)
+627
View File
@@ -0,0 +1,627 @@
"""
Helios Laser Driver
Complete RS-232 driver for Helios laser systems
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import serial
import serial.tools.list_ports
import time
import threading
from typing import Dict, List, Optional, Tuple
from hardware.helios_protocol import (
HeliosProtocol, HeliosCommand, HeliosStatus, PulseMode
)
class HeliosDriver:
"""
Complete driver for Helios laser system
Features:
- RS-232 communication at 9600 baud
- All protocol commands supported
- Thread-safe operation
- Temperature monitoring
- Status monitoring
- Power monitoring
"""
# RS-232 Settings (from protocol document)
BAUDRATE = 9600
DATABITS = 8
PARITY = 'N'
STOPBITS = 1
def __init__(self, timeout: float = 2.0):
"""
Initialize Helios driver
Args:
timeout: Serial communication timeout in seconds
"""
self.protocol = HeliosProtocol()
self.timeout = timeout
# Serial connection
self._serial: Optional[serial.Serial] = None
self._port_name = ""
self._connected = False
# Communication lock for thread safety
self._comm_lock = threading.Lock()
# Cached state
self._laser_enabled = False
self._pulse_mode = PulseMode.CONTINUOUS_PULSING
self._frequency_hz = 10000.0
self._current_ma = 500.0
self._controller_serial = ""
self._head_serial = ""
# Temperature monitoring (in °C)
self._pump_temp_c = 0.0
self._resonator_temp_c = 0.0
self._qswitch_temp_c = 0.0
self._power_stage_temp_c = 0.0
# Status
self._status = HeliosStatus(0)
self._operation_hours = 0.0
self._power_mw = 0.0
print("INFO: Helios Laser driver initialized")
# ==================== Connection Management ====================
@staticmethod
def list_available_ports() -> List[str]:
"""
List available serial ports
Returns:
list: Available port names
"""
ports = serial.tools.list_ports.comports()
return [port.device for port in ports]
def connect(self, port: str) -> bool:
"""
Connect to Helios laser
Args:
port: Serial port name (e.g., 'COM3', '/dev/ttyUSB0')
Returns:
bool: True if connection successful
"""
try:
print(f"INFO: Connecting to Helios laser on {port}")
self._serial = serial.Serial(
port=port,
baudrate=self.BAUDRATE,
bytesize=self.DATABITS,
parity=self.PARITY,
stopbits=self.STOPBITS,
timeout=self.timeout
)
self._port_name = port
self._connected = True
# Read serial numbers
time.sleep(0.5)
self._controller_serial = self.query_controller_serial()
time.sleep(0.5)
self._head_serial = self.query_head_serial()
time.sleep(0.5)
# Read initial state
self._update_cached_state()
print(f"INFO: Connected to Helios laser on {port}")
print(f" Controller S/N: {self._controller_serial}")
print(f" Head S/N: {self._head_serial}")
return True
except serial.SerialException as e:
print(f"ERROR: Failed to connect to {port}: {e}")
self._connected = False
return False
def disconnect(self) -> bool:
"""
Disconnect from Helios laser
Returns:
bool: True if disconnection successful
"""
if not self._connected:
return True
try:
print("INFO: Disconnecting from Helios laser")
# Turn off laser before disconnecting
self.set_laser_enable(False)
time.sleep(0.5)
# Close serial port
if self._serial and self._serial.is_open:
self._serial.close()
self._connected = False
print("INFO: Disconnected from Helios laser")
return True
except Exception as e:
print(f"ERROR: Error during disconnect: {e}")
return False
def is_connected(self) -> bool:
"""Check if laser is connected"""
return self._connected and self._serial and self._serial.is_open
# ==================== Communication Methods ====================
def _send_command(self, command: bytes) -> bool:
"""
Send command to laser (no response expected)
Args:
command: Command bytes to send
Returns:
bool: True if send successful
"""
if not self.is_connected():
print("ERROR: Cannot send command - not connected")
return False
try:
with self._comm_lock:
self._serial.write(command)
self._serial.flush()
return True
except serial.SerialException as e:
print(f"ERROR: Failed to send command: {e}")
return False
def _query(self, command: bytes) -> Optional[str]:
"""
Send query and read response
Args:
command: Query command bytes
Returns:
str: Response string, or None if error
"""
if not self.is_connected():
print("ERROR: Cannot query - not connected")
return None
try:
with self._comm_lock:
# Clear input buffer
self._serial.reset_input_buffer()
# Send query
self._serial.write(command)
self._serial.flush()
# Read response (terminated by CR)
response = self._serial.read_until(b'\r')
if not response:
print("ERROR: No response from laser")
return None
return HeliosCommand.parse_response(response)
except serial.SerialException as e:
print(f"ERROR: Query failed: {e}")
return None
def _set_and_verify(self, set_cmd: bytes, query_cmd: bytes,
expected_value: str, retries: int = 3) -> bool:
"""
Set a value and verify it was set correctly
Args:
set_cmd: Command to set value
query_cmd: Command to query value
expected_value: Expected response
retries: Number of retry attempts
Returns:
bool: True if value was set and verified
"""
for attempt in range(retries):
# Send set command
if not self._send_command(set_cmd):
continue
time.sleep(0.5) # Wait 500ms for laser to process (per documentation)
# Query to verify
response = self._query(query_cmd)
if response and response == expected_value:
return True
if attempt < retries - 1:
print(f"DEBUG: Verification failed, retrying ({attempt + 1}/{retries})")
time.sleep(0.5)
print(f"ERROR: Failed to set and verify value after {retries} attempts")
return False
# ==================== Laser Control ====================
def set_laser_enable(self, enabled: bool) -> bool:
"""
Enable or disable laser
Args:
enabled: True to enable, False to disable
Returns:
bool: True if command successful
"""
print(f"DEBUG: {'Enabling' if enabled else 'Disabling'} laser")
cmd = self.protocol.cmd_set_laser_enable(enabled)
query_cmd = self.protocol.cmd_query_laser_enable()
expected = "1" if enabled else "0"
if self._set_and_verify(cmd, query_cmd, expected):
self._laser_enabled = enabled
return True
return False
def is_laser_enabled(self) -> bool:
"""Check if laser is currently enabled"""
return self._laser_enabled
def query_laser_enable(self) -> bool:
"""Query laser enable state from hardware"""
cmd = self.protocol.cmd_query_laser_enable()
response = self._query(cmd)
if response:
self._laser_enabled = (response == "1")
return self._laser_enabled
return False
# ==================== Pulse Mode Control ====================
def set_pulse_mode(self, mode: PulseMode) -> bool:
"""
Set pulse mode
Args:
mode: PulseMode enum value
Returns:
bool: True if command successful
"""
print(f"DEBUG: Setting pulse mode to {mode.name}")
cmd = self.protocol.cmd_set_pulse_mode(mode)
query_cmd = self.protocol.cmd_query_pulse_mode()
expected = str(mode.value)
if self._set_and_verify(cmd, query_cmd, expected):
self._pulse_mode = mode
return True
return False
def get_pulse_mode(self) -> PulseMode:
"""Get current pulse mode"""
return self._pulse_mode
# ==================== Frequency Control ====================
def set_frequency_hz(self, freq_hz: float) -> bool:
"""
Set laser frequency in Hz
Args:
freq_hz: Frequency in Hz (16.7 kHz to 125 kHz)
Returns:
bool: True if command successful
"""
print(f"DEBUG: Setting laser frequency to {freq_hz} Hz")
try:
cmd = self.protocol.cmd_set_frequency_hz(freq_hz)
period_ns = self.protocol.frequency_to_period_ns(freq_hz)
query_cmd = self.protocol.cmd_query_frequency()
expected = str(period_ns)
if self._set_and_verify(cmd, query_cmd, expected):
self._frequency_hz = freq_hz
return True
except ValueError as e:
print(f"ERROR: {e}")
return False
def get_frequency_hz(self) -> float:
"""Get current frequency in Hz"""
return self._frequency_hz
def query_frequency_hz(self) -> Optional[float]:
"""Query frequency from hardware (returns Hz)"""
cmd = self.protocol.cmd_query_frequency()
response = self._query(cmd)
if response:
try:
period_ns = int(response)
freq_hz = self.protocol.period_ns_to_frequency(period_ns)
self._frequency_hz = freq_hz
return freq_hz
except (ValueError, ZeroDivisionError) as e:
print(f"ERROR: Failed to parse frequency: {e}")
return None
# ==================== Current Control ====================
def set_current_ma(self, current_ma: float) -> bool:
"""
Set laser diode current in mA
Args:
current_ma: Current in mA (0-7000)
Returns:
bool: True if command successful
"""
print(f"DEBUG: Setting laser current to {current_ma} mA")
try:
cmd = self.protocol.cmd_set_current_ma(current_ma)
query_cmd = self.protocol.cmd_query_current()
expected = str(int(current_ma))
if self._set_and_verify(cmd, query_cmd, expected):
self._current_ma = current_ma
return True
except ValueError as e:
print(f"ERROR: {e}")
return False
def get_current_ma(self) -> float:
"""Get current setting in mA"""
return self._current_ma
# ==================== Temperature Monitoring ====================
def query_pump_temperature_c(self) -> Optional[float]:
"""Query pump diode temperature in °C"""
cmd = self.protocol.cmd_query_pump_temp()
response = self._query(cmd)
if response:
try:
temp_mc = int(response)
temp_c = self.protocol.millicelsius_to_celsius(temp_mc)
self._pump_temp_c = temp_c
return temp_c
except ValueError as e:
print(f"ERROR: Failed to parse temperature: {e}")
return None
def query_resonator_temperature_c(self) -> Optional[float]:
"""Query resonator/SHG temperature in °C"""
cmd = self.protocol.cmd_query_resonator_temp()
response = self._query(cmd)
if response:
try:
temp_mc = int(response)
temp_c = self.protocol.millicelsius_to_celsius(temp_mc)
self._resonator_temp_c = temp_c
return temp_c
except ValueError as e:
print(f"ERROR: Failed to parse temperature: {e}")
return None
def query_qswitch_temperature_c(self) -> Optional[float]:
"""Query q-switch temperature in °C"""
cmd = self.protocol.cmd_query_qswitch_temp()
response = self._query(cmd)
if response:
try:
temp_mc = int(response)
temp_c = self.protocol.millicelsius_to_celsius(temp_mc)
self._qswitch_temp_c = temp_c
return temp_c
except ValueError as e:
print(f"ERROR: Failed to parse temperature: {e}")
return None
def query_power_stage_temperature_c(self) -> Optional[float]:
"""Query controller power stage temperature in °C"""
cmd = self.protocol.cmd_query_power_stage_temp()
response = self._query(cmd)
if response:
try:
temp_mc = int(response)
temp_c = self.protocol.millicelsius_to_celsius(temp_mc)
self._power_stage_temp_c = temp_c
return temp_c
except ValueError as e:
print(f"ERROR: Failed to parse temperature: {e}")
return None
def query_all_temperatures(self) -> Dict[str, float]:
"""
Query all temperatures
Returns:
dict: Temperature readings in °C
"""
temps = {}
temps['pump'] = self.query_pump_temperature_c()
time.sleep(0.5)
temps['resonator'] = self.query_resonator_temperature_c()
time.sleep(0.5)
temps['qswitch'] = self.query_qswitch_temperature_c()
time.sleep(0.5)
temps['power_stage'] = self.query_power_stage_temperature_c()
return temps
# ==================== Status and Monitoring ====================
def query_status(self) -> HeliosStatus:
"""Query status register"""
cmd = self.protocol.cmd_query_status()
response = self._query(cmd)
if response:
try:
status_value = int(response)
self._status = HeliosStatus(status_value)
return self._status
except ValueError as e:
print(f"ERROR: Failed to parse status: {e}")
return self._status
def clear_status(self) -> bool:
"""Clear status register"""
cmd = self.protocol.cmd_clear_status()
return self._send_command(cmd)
def clear_errors(self) -> bool:
"""Clear controller errors"""
cmd = self.protocol.cmd_clear_errors()
return self._send_command(cmd)
def query_power_monitor_mw(self) -> Optional[float]:
"""Query laser power monitor in mW"""
cmd = self.protocol.cmd_query_power_monitor()
response = self._query(cmd)
if response:
try:
power_mw = float(response)
self._power_mw = power_mw
return power_mw
except ValueError as e:
print(f"ERROR: Failed to parse power: {e}")
return None
def query_operation_hours(self) -> Optional[float]:
"""Query laser diode operation time in hours"""
cmd = self.protocol.cmd_query_operation_time()
response = self._query(cmd)
if response:
try:
hours = float(response)
self._operation_hours = hours
return hours
except ValueError as e:
print(f"ERROR: Failed to parse operation time: {e}")
return None
# ==================== Serial Numbers ====================
def query_controller_serial(self) -> str:
"""Query controller serial number"""
cmd = self.protocol.cmd_query_controller_serial()
response = self._query(cmd)
if response:
self._controller_serial = response
return response
return ""
def query_head_serial(self) -> str:
"""Query laser head serial number"""
cmd = self.protocol.cmd_query_head_serial()
response = self._query(cmd)
if response:
self._head_serial = response
return response
return ""
def get_controller_serial(self) -> str:
"""Get cached controller serial number"""
return self._controller_serial
def get_head_serial(self) -> str:
"""Get cached head serial number"""
return self._head_serial
# ==================== Factory Reset ====================
def restore_factory_settings(self) -> bool:
"""
Restore factory settings
WARNING: This will reset all parameters to factory defaults.
Laser must be disabled before calling this method.
After calling, wait 2 seconds before power cycling.
Returns:
bool: True if command sent successfully
"""
if self._laser_enabled:
print("ERROR: Laser must be disabled before factory reset")
return False
print("WARNING: Restoring factory settings")
cmd = self.protocol.cmd_restore_factory()
if self._send_command(cmd):
print("INFO: Factory settings restored. Wait 2s before power cycle.")
time.sleep(2)
return True
return False
# ==================== State Management ====================
def _update_cached_state(self):
"""Update all cached state from hardware"""
self.query_laser_enable()
time.sleep(0.5)
self.query_frequency_hz()
time.sleep(0.5)
# Current is write-only in some modes, skip query
self.query_status()
def get_status(self) -> Dict:
"""
Get complete laser status
Returns:
dict: Comprehensive status dictionary
"""
return {
'connected': self.is_connected(),
'laser_enabled': self._laser_enabled,
'pulse_mode': self._pulse_mode.name,
'frequency_hz': self._frequency_hz,
'current_ma': self._current_ma,
'power_mw': self._power_mw,
'operation_hours': self._operation_hours,
'temperatures': {
'pump_c': self._pump_temp_c,
'resonator_c': self._resonator_temp_c,
'qswitch_c': self._qswitch_temp_c,
'power_stage_c': self._power_stage_temp_c
},
'status_value': self._status.value,
'has_errors': self._status.has_errors(),
'controller_serial': self._controller_serial,
'head_serial': self._head_serial
}
def update_status(self):
"""Update status information from hardware"""
if not self.is_connected():
return
self.query_status()
time.sleep(0.5)
self.query_power_monitor_mw()
time.sleep(0.5)
self.query_all_temperatures()
+302
View File
@@ -0,0 +1,302 @@
"""
Helios Laser Protocol Handler
ASCII-based RS-232 communication protocol for Helios laser systems
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
from enum import IntEnum
from typing import Union, Optional
class PulseMode(IntEnum):
"""Helios pulse mode settings"""
SINGLE_PULSE = 1
GATING = 4
CONTINUOUS_PULSING = 14
class HeliosCommand:
"""
Helios laser command constants and builders
All commands are ASCII strings terminated with carriage return <CR>
Format: COMMAND value<CR> for setting
COMMAND<CR> for querying
"""
# Command constants
LDO = "LDO" # Laser enabled (0/1)
LDG = "LDG" # Pulse mode (1/4/14)
LDF = "LDF" # Period between pulses (ns)
LRE = "LRE" # Laser remote enable (0/1) - Single electronic only
LDS = "LDS" # Laser diode pulse current (mA)
LTA = "LTA" # Actual pump diode temperature (m°C)
LMA = "LMA" # Actual resonator/SHG temperature (mA)
EOA = "EOA" # Actual q-switch temperature (m°C)
ELT = "ELT" # Pump diode temp control deviation (m°C)
ELM = "ELM" # Resonator/SHG temp control deviation (m°C)
EEO = "EEO" # Q-switch temp control deviation (m°C)
LTT = "LTT" # Controller power stage temperature (m°C)
LER = "LER" # Status register (read)
LCE = "LCE" # Clear status register
CCE = "CCE" # Clear controller errors
CSR = "CSR" # Controller serial number
HSR = "HSR" # Laser head serial number
HTR = "HTR" # Laser diode operation time (hours)
HPR = "HPR" # Restore factory settings
HMP = "HMP" # Laser power monitor (mW)
@staticmethod
def build_command(command: str, value: Optional[Union[int, float]] = None) -> bytes:
"""
Build a Helios command string
Args:
command: Command string (e.g., "LDO", "LDF")
value: Optional value to set (None for query)
Returns:
bytes: Command ready to send over serial
Example:
build_command("LDO", 1) -> b"LDO 1\r"
build_command("LDO") -> b"LDO\r"
"""
if value is not None:
cmd_str = f"{command} {value}\r"
else:
cmd_str = f"{command}\r"
return cmd_str.encode('ascii')
@staticmethod
def parse_response(response: bytes) -> str:
"""
Parse response from Helios laser
Args:
response: Raw bytes from serial port
Returns:
str: Parsed response string (stripped of CR/LF)
"""
return response.decode('ascii').strip()
class HeliosStatus:
"""
Helios status register decoder
Status is sum of multiple bit flags:
Example: 1*2^0 + 0*2^1 + 1*2^2 = 5
"""
# Status bit definitions (from LER/LCE/CCE commands)
# These are example flags - actual flags depend on controller model
# Refer to "Troubleshooting" section in manual for complete list
def __init__(self, status_value: int):
"""
Initialize status decoder
Args:
status_value: Numeric status value from controller
"""
self.value = status_value
self.flags = self._decode_flags(status_value)
def _decode_flags(self, value: int) -> list:
"""Decode status value into list of active bit positions"""
flags = []
bit_pos = 0
while value > 0:
if value & 1:
flags.append(bit_pos)
value >>= 1
bit_pos += 1
return flags
def has_errors(self) -> bool:
"""Check if any error flags are set"""
return self.value > 0
def __str__(self) -> str:
return f"Status: {self.value} (flags: {self.flags})"
class HeliosProtocol:
"""
High-level Helios protocol handler
Provides parameter validation and unit conversions
"""
# Parameter ranges (from protocol document)
RANGE_LDO = (0, 1)
RANGE_LDG = (0, 14)
RANGE_LDF = (8000, 60000) # ns
RANGE_LRE = (0, 1)
RANGE_LDS = (0, 7000) # mA
RANGE_LTA = (5000, 50000) # m°C
RANGE_LMA = (0, 4000) # mA (seems like error in doc, should be m°C)
RANGE_EOA = (5000, 50000) # m°C
RANGE_ELT = (-32768, 32767) # m°C
RANGE_ELM = (-32768, 32767) # m°C
RANGE_EEO = (-32768, 32767) # m°C
RANGE_LTT = (5000, 65535) # m°C
RANGE_HTR = (0, 65535) # hours
RANGE_HMP = (0, 5000) # mW
def __init__(self):
"""Initialize protocol handler"""
pass
# Temperature conversions (m°C <-> °C)
@staticmethod
def celsius_to_millicelsius(temp_c: float) -> int:
"""Convert temperature from °C to m°C (milli-Celsius)"""
return int(temp_c * 1000)
@staticmethod
def millicelsius_to_celsius(temp_mc: int) -> float:
"""Convert temperature from m°C to °C"""
return temp_mc / 1000.0
# Frequency conversions (Hz <-> ns period)
@staticmethod
def frequency_to_period_ns(freq_hz: float) -> int:
"""
Convert frequency in Hz to period in nanoseconds
Args:
freq_hz: Frequency in Hz
Returns:
int: Period in nanoseconds
Example:
50000 Hz -> 20000 ns (50 kHz)
"""
if freq_hz <= 0:
raise ValueError("Frequency must be positive")
period_ns = int(1e9 / freq_hz)
return period_ns
@staticmethod
def period_ns_to_frequency(period_ns: int) -> float:
"""
Convert period in nanoseconds to frequency in Hz
Args:
period_ns: Period in nanoseconds
Returns:
float: Frequency in Hz
"""
if period_ns <= 0:
raise ValueError("Period must be positive")
freq_hz = 1e9 / period_ns
return freq_hz
# Command builders with validation
def cmd_set_laser_enable(self, enabled: bool) -> bytes:
"""Build command to enable/disable laser"""
value = 1 if enabled else 0
return HeliosCommand.build_command(HeliosCommand.LDO, value)
def cmd_query_laser_enable(self) -> bytes:
"""Build query for laser enable state"""
return HeliosCommand.build_command(HeliosCommand.LDO)
def cmd_set_pulse_mode(self, mode: PulseMode) -> bytes:
"""Build command to set pulse mode"""
if mode not in [PulseMode.SINGLE_PULSE, PulseMode.GATING,
PulseMode.CONTINUOUS_PULSING]:
raise ValueError(f"Invalid pulse mode: {mode}")
return HeliosCommand.build_command(HeliosCommand.LDG, mode)
def cmd_query_pulse_mode(self) -> bytes:
"""Build query for pulse mode"""
return HeliosCommand.build_command(HeliosCommand.LDG)
def cmd_set_frequency_hz(self, freq_hz: float) -> bytes:
"""
Build command to set laser frequency (Hz)
Converts to period in ns internally
"""
period_ns = self.frequency_to_period_ns(freq_hz)
if not (self.RANGE_LDF[0] <= period_ns <= self.RANGE_LDF[1]):
raise ValueError(f"Frequency results in period {period_ns}ns, "
f"valid range: {self.RANGE_LDF[0]}-{self.RANGE_LDF[1]}ns")
return HeliosCommand.build_command(HeliosCommand.LDF, period_ns)
def cmd_query_frequency(self) -> bytes:
"""Build query for laser frequency (returns period in ns)"""
return HeliosCommand.build_command(HeliosCommand.LDF)
def cmd_set_current_ma(self, current_ma: float) -> bytes:
"""Build command to set laser diode current (mA)"""
if not (self.RANGE_LDS[0] <= current_ma <= self.RANGE_LDS[1]):
raise ValueError(f"Current {current_ma}mA outside valid range: "
f"{self.RANGE_LDS[0]}-{self.RANGE_LDS[1]}mA")
return HeliosCommand.build_command(HeliosCommand.LDS, int(current_ma))
def cmd_query_current(self) -> bytes:
"""Build query for laser diode current"""
return HeliosCommand.build_command(HeliosCommand.LDS)
def cmd_query_pump_temp(self) -> bytes:
"""Build query for actual pump diode temperature"""
return HeliosCommand.build_command(HeliosCommand.LTA)
def cmd_query_resonator_temp(self) -> bytes:
"""Build query for actual resonator/SHG temperature"""
return HeliosCommand.build_command(HeliosCommand.LMA)
def cmd_query_qswitch_temp(self) -> bytes:
"""Build query for actual q-switch temperature"""
return HeliosCommand.build_command(HeliosCommand.EOA)
def cmd_query_power_stage_temp(self) -> bytes:
"""Build query for controller power stage temperature"""
return HeliosCommand.build_command(HeliosCommand.LTT)
def cmd_query_status(self) -> bytes:
"""Build query for status register"""
return HeliosCommand.build_command(HeliosCommand.LER)
def cmd_clear_status(self) -> bytes:
"""Build command to clear status register"""
return HeliosCommand.build_command(HeliosCommand.LCE, 0)
def cmd_clear_errors(self) -> bytes:
"""Build command to clear controller errors"""
return HeliosCommand.build_command(HeliosCommand.CCE, 0)
def cmd_query_controller_serial(self) -> bytes:
"""Build query for controller serial number"""
return HeliosCommand.build_command(HeliosCommand.CSR)
def cmd_query_head_serial(self) -> bytes:
"""Build query for laser head serial number"""
return HeliosCommand.build_command(HeliosCommand.HSR)
def cmd_query_operation_time(self) -> bytes:
"""Build query for laser diode operation time (hours)"""
return HeliosCommand.build_command(HeliosCommand.HTR)
def cmd_query_power_monitor(self) -> bytes:
"""Build query for laser power monitor (mW)"""
return HeliosCommand.build_command(HeliosCommand.HMP)
def cmd_restore_factory(self) -> bytes:
"""
Build command to restore factory settings
WARNING: Laser must be disabled (LDO 0) before this command
After sending, wait 2 seconds before rebooting/power cycling
"""
return HeliosCommand.build_command(HeliosCommand.HPR)
+387
View File
@@ -0,0 +1,387 @@
"""
Microscope Controller
Handles communication with Genesis and Helios microscope/laser systems
Genesis: Laser scanning microscope system (stub)
Helios: Laser control system with frequency and current control (full implementation)
Both systems communicate via USB/Serial interfaces
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import time
from typing import Dict, Optional
from hardware.helios_driver import HeliosDriver, PulseMode
class MicroscopeController:
"""
Controller for Genesis and Helios microscope systems
Provides methods for:
- System connection and initialization
- Interlock status monitoring
- Parameter configuration
- Safety checks
"""
def __init__(self):
"""Initialize microscope controller"""
# Genesis state (stub)
self._genesis_connected = False
self._genesis_ready = False
self._genesis_interlocked = False
self._genesis_power_mw = 0.0
# Helios driver (full implementation)
self._helios_driver = HeliosDriver()
self._helios_port = None
# System interlock (master safety)
self._system_interlocked = False
print("INFO: Microscope controller initialized")
print(" - Genesis: Stub implementation")
print(" - Helios: Full RS-232 driver")
# ==================== Genesis Methods ====================
def connect_genesis(self) -> bool:
"""
Connect to Genesis laser scanning microscope
Returns:
bool: True if connection successful
"""
print("DEBUG: Connecting to Genesis microscope")
# Stub implementation
time.sleep(0.1)
self._genesis_connected = True
self._genesis_ready = True
self._genesis_interlocked = True # Assume interlocks OK
print("INFO: Genesis microscope connected")
return True
def disconnect_genesis(self) -> bool:
"""
Disconnect from Genesis microscope
Returns:
bool: True if disconnection successful
"""
print("DEBUG: Disconnecting from Genesis microscope")
self._genesis_connected = False
self._genesis_ready = False
print("INFO: Genesis microscope disconnected")
return True
def apply_genesis_settings(self, settings: Dict) -> bool:
"""
Apply Genesis configuration settings
Args:
settings: Dictionary containing Genesis parameters
Returns:
bool: True if settings applied successfully
"""
print("DEBUG: Applying Genesis settings")
print(f" Power: {settings.get('power_mw', 0)} mW")
if not self._genesis_connected:
print("ERROR: Genesis not connected")
return False
self._genesis_power_mw = settings.get('power_mw', 0.0)
# Stub - would send commands to actual hardware
time.sleep(0.05)
print("INFO: Genesis settings applied")
return True
def get_genesis_status(self) -> Dict[str, any]:
"""
Get Genesis microscope status
Returns:
dict: Genesis status information
"""
return {
'connected': self._genesis_connected,
'ready': self._genesis_ready,
'interlocked': self._genesis_interlocked,
'power_mw': self._genesis_power_mw
}
# ==================== Helios Methods ====================
def connect_helios(self, port: str) -> bool:
"""
Connect to Helios laser system
Args:
port: COM port for Helios device
Returns:
bool: True if connection successful
"""
print(f"DEBUG: Connecting to Helios on {port}")
if not self._helios_driver.connect(port):
return False
self._helios_port = port
print(f"INFO: Helios connected on {port}")
print(f" Controller S/N: {self._helios_driver.get_controller_serial()}")
print(f" Head S/N: {self._helios_driver.get_head_serial()}")
return True
def disconnect_helios(self) -> bool:
"""
Disconnect from Helios laser system
Returns:
bool: True if disconnection successful
"""
print("DEBUG: Disconnecting from Helios")
return self._helios_driver.disconnect()
def is_helios_connected(self) -> bool:
"""Check if Helios is connected"""
return self._helios_driver.is_connected()
def apply_helios_settings(self, settings: Dict) -> bool:
"""
Apply Helios configuration settings
This method connects and configures the Helios laser with the
specified parameters from the settings dialog.
Args:
settings: Dictionary containing:
- com_port: COM port name
- frequency_hz: Laser frequency in Hz
- current_ma: Laser diode current in mA
Returns:
bool: True if settings applied successfully
"""
if settings is None:
print("ERROR: No settings provided")
return False
print("DEBUG: Applying Helios settings")
print(f" Port: {settings.get('com_port', 'N/A')}")
print(f" Frequency: {settings.get('frequency_hz', 0)} Hz")
print(f" Current: {settings.get('current_ma', 0)} mA")
# Connect if not already connected or port changed
port = settings.get('com_port')
if not port:
print("ERROR: No COM port specified")
return False
if not self.is_helios_connected() or port != self._helios_port:
if self.is_helios_connected():
self.disconnect_helios()
if not self.connect_helios(port):
return False
# Set frequency
freq_hz = settings.get('frequency_hz', 0.0)
if freq_hz > 0:
if not self._helios_driver.set_frequency_hz(freq_hz):
print("ERROR: Failed to set frequency")
return False
time.sleep(0.05)
# Set current
current_ma = settings.get('current_ma', 0.0)
if current_ma > 0:
if not self._helios_driver.set_current_ma(current_ma):
print("ERROR: Failed to set current")
return False
time.sleep(0.05)
# Set to continuous pulsing mode by default
if not self._helios_driver.set_pulse_mode(PulseMode.CONTINUOUS_PULSING):
print("WARNING: Failed to set pulse mode")
print("INFO: Helios settings applied successfully")
return True
def helios_enable_laser(self, enabled: bool) -> bool:
"""
Enable or disable Helios laser
Args:
enabled: True to enable, False to disable
Returns:
bool: True if command successful
"""
if not self.is_helios_connected():
print("ERROR: Helios not connected")
return False
return self._helios_driver.set_laser_enable(enabled)
def is_helios_laser_enabled(self) -> bool:
"""Check if Helios laser is currently enabled"""
if not self.is_helios_connected():
return False
return self._helios_driver.is_laser_enabled()
def get_helios_status(self) -> Dict[str, any]:
"""
Get Helios laser system status
Returns:
dict: Helios status information
"""
if not self.is_helios_connected():
return {
'connected': False,
'ready': False,
'interlocked': False,
'port': None,
'frequency_hz': 0.0,
'current_ma': 0.0,
'laser_enabled': False,
'power_mw': 0.0
}
# Get comprehensive status from driver
status = self._helios_driver.get_status()
return {
'connected': status['connected'],
'ready': not status['has_errors'],
'interlocked': not status['has_errors'], # Use error state as interlock
'port': self._helios_port,
'frequency_hz': status['frequency_hz'],
'current_ma': status['current_ma'],
'laser_enabled': status['laser_enabled'],
'power_mw': status['power_mw'],
'operation_hours': status['operation_hours'],
'controller_serial': status['controller_serial'],
'head_serial': status['head_serial']
}
def helios_update_status(self):
"""Update Helios status from hardware"""
if self.is_helios_connected():
self._helios_driver.update_status()
# ==================== Combined Status Methods ====================
def get_status(self) -> Dict[str, any]:
"""
Get complete microscope system status
Returns:
dict: Combined status for Genesis, Helios, and interlocks
"""
# Get Helios status from driver
helios_status = self.get_helios_status()
helios_ready = helios_status.get('ready', False)
helios_interlocked = helios_status.get('interlocked', False)
return {
# System-wide
'interlocked': self._system_interlocked or (
self._genesis_interlocked and helios_interlocked
),
# Genesis
'genesis_ready': self._genesis_ready,
'genesis_interlocked': self._genesis_interlocked,
# Helios
'helios_ready': helios_ready,
'helios_interlocked': helios_interlocked
}
def check_interlocks(self) -> bool:
"""
Check all safety interlocks
Returns:
bool: True if all interlocks are satisfied
"""
# Check Genesis interlocks
if self._genesis_connected and not self._genesis_interlocked:
print("WARNING: Genesis interlock not satisfied")
return False
# Check Helios interlocks
if self.is_helios_connected():
helios_status = self.get_helios_status()
if not helios_status.get('interlocked', False):
print("WARNING: Helios interlock not satisfied")
return False
return True
# ==================== Scan Preparation ====================
def prepare_for_scan(self, params: Dict) -> bool:
"""
Prepare microscope systems for scanning
Args:
params: Scan parameters dictionary
Returns:
bool: True if preparation successful
"""
print("DEBUG: Preparing microscope systems for scan")
# Check interlocks
if not self.check_interlocks():
print("ERROR: Interlock check failed")
return False
# Verify systems are ready
if self._genesis_connected and not self._genesis_ready:
print("ERROR: Genesis not ready")
return False
if self.is_helios_connected():
helios_status = self.get_helios_status()
if not helios_status.get('ready', False):
print("ERROR: Helios not ready")
return False
# Configure for scan
# Stub implementation
time.sleep(0.1)
print("INFO: Microscope systems ready for scan")
return True
def emergency_stop(self) -> bool:
"""
Emergency stop all microscope operations
Returns:
bool: True if stop successful
"""
print("WARNING: Emergency stop triggered")
# Disable all systems
if self._genesis_connected:
self._genesis_ready = False
if self.is_helios_connected():
# Disable Helios laser immediately
self._helios_driver.set_laser_enable(False)
return True
+244
View File
@@ -0,0 +1,244 @@
"""
ThorLabs Stage Settings Management
Handles saving and loading of stage configuration parameters
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import json
import os
from typing import Dict, Optional
from pathlib import Path
class StageSettings:
"""
Manages stage configuration settings including velocity, acceleration,
and trigger I/O configuration
"""
DEFAULT_SETTINGS = {
'velocity': {
'x_axis': 1.0, # mm/s
'y_axis': 1.0, # mm/s
'z_axis': 1.0, # mm/s
},
'acceleration': {
'x_axis': 5.0, # mm/s²
'y_axis': 5.0, # mm/s²
'z_axis': 5.0, # mm/s²
},
'trigger': {
'x_axis': {
'mode': 0x00, # Disabled
'polarity': 0x01, # Active high
'start_pos_fwd': 0.0,
'start_pos_rev': 0.0,
'interval_fwd': 0.0,
'interval_rev': 0.0
},
'y_axis': {
'mode': 0x00, # Disabled
'polarity': 0x01, # Active high
'start_pos_fwd': 0.0,
'start_pos_rev': 0.0,
'interval_fwd': 0.0,
'interval_rev': 0.0
},
'z_axis': {
'mode': 0x00, # Disabled
'polarity': 0x01, # Active high
'start_pos_fwd': 0.0,
'start_pos_rev': 0.0,
'interval_fwd': 0.0,
'interval_rev': 0.0
}
},
'digital_io': {
'output_bits': 0x00
}
}
def __init__(self, settings_file: Optional[str] = None):
"""
Initialize stage settings manager
Args:
settings_file: Path to settings file (default: ~/.nuescan/stage_settings.json)
"""
if settings_file is None:
# Default to user home directory
home = Path.home()
settings_dir = home / '.nuescan'
settings_dir.mkdir(exist_ok=True)
settings_file = str(settings_dir / 'stage_settings.json')
self.settings_file = settings_file
self.settings = self.DEFAULT_SETTINGS.copy()
# Load existing settings if available
self.load()
def load(self) -> bool:
"""
Load settings from file
Returns:
bool: True if settings loaded successfully, False otherwise
"""
if not os.path.exists(self.settings_file):
print(f"INFO: Settings file not found, using defaults: {self.settings_file}")
return False
try:
with open(self.settings_file, 'r') as f:
loaded_settings = json.load(f)
# Merge with defaults to ensure all keys exist
self._merge_settings(loaded_settings)
print(f"INFO: Loaded stage settings from {self.settings_file}")
return True
except Exception as e:
print(f"ERROR: Failed to load settings from {self.settings_file}: {e}")
return False
def save(self) -> bool:
"""
Save settings to file
Returns:
bool: True if settings saved successfully, False otherwise
"""
try:
# Ensure directory exists
os.makedirs(os.path.dirname(self.settings_file), exist_ok=True)
with open(self.settings_file, 'w') as f:
json.dump(self.settings, f, indent=4)
print(f"INFO: Saved stage settings to {self.settings_file}")
return True
except Exception as e:
print(f"ERROR: Failed to save settings to {self.settings_file}: {e}")
return False
def _merge_settings(self, loaded_settings: Dict):
"""Merge loaded settings with defaults"""
# Velocity
if 'velocity' in loaded_settings:
self.settings['velocity'].update(loaded_settings['velocity'])
# Acceleration
if 'acceleration' in loaded_settings:
self.settings['acceleration'].update(loaded_settings['acceleration'])
# Trigger
if 'trigger' in loaded_settings:
for axis in ['x_axis', 'y_axis', 'z_axis']:
if axis in loaded_settings['trigger']:
self.settings['trigger'][axis].update(loaded_settings['trigger'][axis])
# Digital I/O
if 'digital_io' in loaded_settings:
self.settings['digital_io'].update(loaded_settings['digital_io'])
# ==================== Velocity Settings ====================
def get_velocity(self, axis: str) -> float:
"""Get velocity for specific axis (x_axis, y_axis, z_axis)"""
return self.settings['velocity'].get(axis, 1.0)
def set_velocity(self, axis: str, velocity: float):
"""Set velocity for specific axis"""
if axis in ['x_axis', 'y_axis', 'z_axis']:
self.settings['velocity'][axis] = velocity
def get_all_velocities(self) -> Dict[str, float]:
"""Get all axis velocities"""
return self.settings['velocity'].copy()
def set_all_velocities(self, x: float, y: float, z: float):
"""Set all axis velocities"""
self.settings['velocity']['x_axis'] = x
self.settings['velocity']['y_axis'] = y
self.settings['velocity']['z_axis'] = z
# ==================== Acceleration Settings ====================
def get_acceleration(self, axis: str) -> float:
"""Get acceleration for specific axis"""
return self.settings['acceleration'].get(axis, 5.0)
def set_acceleration(self, axis: str, acceleration: float):
"""Set acceleration for specific axis"""
if axis in ['x_axis', 'y_axis', 'z_axis']:
self.settings['acceleration'][axis] = acceleration
def get_all_accelerations(self) -> Dict[str, float]:
"""Get all axis accelerations"""
return self.settings['acceleration'].copy()
def set_all_accelerations(self, x: float, y: float, z: float):
"""Set all axis accelerations"""
self.settings['acceleration']['x_axis'] = x
self.settings['acceleration']['y_axis'] = y
self.settings['acceleration']['z_axis'] = z
# ==================== Trigger Settings ====================
def get_trigger_config(self, axis: str) -> Dict:
"""Get trigger configuration for specific axis"""
return self.settings['trigger'].get(axis, {}).copy()
def set_trigger_config(self, axis: str, mode: int, polarity: int = 0x01,
start_pos_fwd: float = 0.0, start_pos_rev: float = 0.0,
interval_fwd: float = 0.0, interval_rev: float = 0.0):
"""Set trigger configuration for specific axis"""
if axis in ['x_axis', 'y_axis', 'z_axis']:
self.settings['trigger'][axis] = {
'mode': mode,
'polarity': polarity,
'start_pos_fwd': start_pos_fwd,
'start_pos_rev': start_pos_rev,
'interval_fwd': interval_fwd,
'interval_rev': interval_rev
}
def get_trigger_mode(self, axis: str) -> int:
"""Get trigger mode for specific axis"""
return self.settings['trigger'].get(axis, {}).get('mode', 0x00)
def set_trigger_mode(self, axis: str, mode: int):
"""Set trigger mode for specific axis"""
if axis in ['x_axis', 'y_axis', 'z_axis']:
if axis not in self.settings['trigger']:
self.settings['trigger'][axis] = self.DEFAULT_SETTINGS['trigger'][axis].copy()
self.settings['trigger'][axis]['mode'] = mode
# ==================== Digital I/O Settings ====================
def get_digital_outputs(self) -> int:
"""Get digital output bits"""
return self.settings['digital_io'].get('output_bits', 0x00)
def set_digital_outputs(self, output_bits: int):
"""Set digital output bits"""
self.settings['digital_io']['output_bits'] = output_bits
# ==================== Utility Methods ====================
def reset_to_defaults(self):
"""Reset all settings to defaults"""
self.settings = self.DEFAULT_SETTINGS.copy()
def get_all_settings(self) -> Dict:
"""Get copy of all settings"""
return json.loads(json.dumps(self.settings)) # Deep copy via JSON
def update_from_dict(self, settings_dict: Dict):
"""Update settings from dictionary"""
self._merge_settings(settings_dict)
+259
View File
@@ -0,0 +1,259 @@
"""
T3R-SL Device Controller
Handles communication with T3R-SL device via USB/Serial
The T3R-SL is a specialized instrument control device that provides
timing, triggering, and coordination for the SRAS scanning system.
"""
import time
from typing import Dict, List, Optional
class T3RDevice:
"""
Controller for T3R-SL device
Provides methods for:
- Connecting/disconnecting
- Device initialization and homing
- Status monitoring
- Trigger and timing control
"""
def __init__(self):
"""Initialize T3R device controller"""
self._connected = False
self._port = None
self._homed = False
self._ready = False
# Device state
self._initialized = False
self._error_state = False
print("INFO: T3R-SL Device controller initialized (stub)")
def get_available_ports(self) -> List[str]:
"""
Get list of available COM ports
Returns:
list: Available port names
"""
# Stub implementation - return dummy ports
# In production, would scan for actual serial ports
return [
"COM1", "COM2", "COM3", "COM4", "COM5",
"/dev/ttyUSB0", "/dev/ttyUSB1", "/dev/ttyUSB2"
]
def connect(self, port: str) -> bool:
"""
Connect to T3R device on specified port
Args:
port: COM port name
Returns:
bool: True if connection successful
"""
print(f"DEBUG: Connecting to T3R device on {port}")
# Stub implementation
time.sleep(0.1)
self._port = port
self._connected = True
self._ready = False # Need to initialize after connect
print(f"INFO: Connected to T3R device on {port}")
# Auto-initialize
return self._initialize()
def disconnect(self) -> bool:
"""
Disconnect from T3R device
Returns:
bool: True if disconnection successful
"""
print("DEBUG: Disconnecting from T3R device")
self._connected = False
self._homed = False
self._ready = False
self._initialized = False
print("INFO: Disconnected from T3R device")
return True
def is_connected(self) -> bool:
"""Check if device is connected"""
return self._connected
def _initialize(self) -> bool:
"""
Initialize T3R device after connection
Returns:
bool: True if initialization successful
"""
print("DEBUG: Initializing T3R device")
if not self._connected:
print("ERROR: Cannot initialize - device not connected")
return False
# Stub - simulate initialization
time.sleep(0.2)
self._initialized = True
self._error_state = False
# Perform homing
return self.home()
def home(self) -> bool:
"""
Home/zero T3R device
Returns:
bool: True if homing successful
"""
print("DEBUG: Homing T3R device")
if not self._connected or not self._initialized:
print("ERROR: Cannot home - device not initialized")
return False
# Stub - simulate homing
time.sleep(0.3)
self._homed = True
self._ready = True
print("INFO: T3R device homed successfully")
return True
def get_status(self) -> Dict[str, bool]:
"""
Get current device status
Returns:
dict: Status information
"""
return {
'connected': self._connected,
'initialized': self._initialized,
'homed': self._homed,
'ready': self._ready,
'error': self._error_state
}
def prepare_for_scan(self, params: Dict) -> bool:
"""
Prepare T3R device for scanning operation
Args:
params: Scan parameters dictionary
Returns:
bool: True if preparation successful
"""
print("DEBUG: Preparing T3R device for scan")
print(f" Number of scans: {params.get('num_scans', 1)}")
print(f" Trigger voltage: {params.get('trigger_voltage', 0)}V")
if not self._ready:
print("ERROR: T3R device not ready for scanning")
return False
# Configure device for scan parameters
# Stub implementation
time.sleep(0.1)
print("INFO: T3R device ready for scanning")
return True
def trigger_acquisition(self) -> bool:
"""
Trigger a data acquisition event
Returns:
bool: True if trigger successful
"""
if not self._ready:
print("ERROR: Cannot trigger - device not ready")
return False
print("DEBUG: Triggering acquisition")
# Stub - would send trigger command
return True
def read_position(self) -> Optional[float]:
"""
Read current position from T3R device
Returns:
float: Current position value, or None if error
"""
if not self._ready:
return None
# Stub - return dummy position
return 0.0
def set_timing_parameters(self, acquisition_time: float,
delay_time: float) -> bool:
"""
Set timing parameters for acquisition
Args:
acquisition_time: Acquisition window time in seconds
delay_time: Delay before acquisition in seconds
Returns:
bool: True if parameters set successfully
"""
print(f"DEBUG: Setting timing - acq: {acquisition_time}s, delay: {delay_time}s")
if not self._connected:
print("ERROR: Device not connected")
return False
# Stub implementation
return True
def get_error_status(self) -> Dict[str, any]:
"""
Get detailed error status
Returns:
dict: Error status information
"""
return {
'has_error': self._error_state,
'error_code': 0,
'error_message': 'No error'
}
def reset(self) -> bool:
"""
Reset T3R device to initial state
Returns:
bool: True if reset successful
"""
print("DEBUG: Resetting T3R device")
if not self._connected:
return False
self._error_state = False
self._homed = False
self._ready = False
# Re-initialize
return self._initialize()
+651
View File
@@ -0,0 +1,651 @@
"""
ThorLabs MLS Stage Controller
Handles communication with ThorLabs MLS 3-axis positioning stage via BBD203 motor controller
The ThorLabs MLS stage provides precision X/Y/Z positioning for scanning operations.
This implementation uses the BBD203 3-channel motor controller with the APT protocol.
Channel Mapping:
- Channel 1: X-axis
- Channel 2: Y-axis
- Channel 3: Z-axis (optional)
Copyright (C) 2025 Thomas Ales
Licensed under GNU General Public License v2.0
"""
import time
from typing import Dict, Optional
from hardware.bbd203_driver import BBD203Driver
from hardware.bbd203_protocol import TriggerMode
from hardware.stage_settings import StageSettings
class ThorLabsStage:
"""
Controller for ThorLabs MLS positioning stage using BBD203 motor controller
Provides methods for:
- Connecting/disconnecting from stage
- Homing axes
- Position control and readout
- Status monitoring
"""
# Channel mapping
X_AXIS = 1
Y_AXIS = 2
Z_AXIS = 3
def __init__(self, encoder_counts_per_mm: int = 20000, settings_file: Optional[str] = None):
"""
Initialize ThorLabs stage controller
Args:
encoder_counts_per_mm: Encoder resolution (default: 20000 for MLS203)
settings_file: Path to settings file (default: ~/.nuescan/stage_settings.json)
"""
self._driver = BBD203Driver(encoder_counts_per_mm)
self._port = None
# Settings manager
self.settings = StageSettings(settings_file)
# Scanning state
self._scanning = False
# Default velocity and acceleration (can be overridden by settings)
self._default_velocity = 1.0 # mm/s
self._default_accel = 5.0 # mm/s²
print("INFO: ThorLabs Stage controller initialized (BBD203 driver)")
def connect(self, serial_number: str, baudrate: int = 115200) -> bool:
"""
Connect to ThorLabs stage via BBD203 controller using serial number
The serial number is printed on the BBD203 controller (e.g., '83123456').
The driver will automatically find the USB device and connect.
Args:
serial_number: BBD203 device serial number
baudrate: Baud rate (default: 115200)
Returns:
bool: True if connection successful
"""
print(f"DEBUG: Connecting to BBD203/MLS Stage with serial number {serial_number}")
# Connect by serial number - driver will auto-find the port
if not self._driver.connect_by_serial(serial_number, baudrate):
return False
self._port = serial_number # Store serial for reference
# Enable all channels
time.sleep(0.2)
self._driver.enable_channel(self.X_AXIS, True)
time.sleep(0.1)
self._driver.enable_channel(self.Y_AXIS, True)
time.sleep(0.1)
self._driver.enable_channel(self.Z_AXIS, True)
time.sleep(0.1)
# Apply startup settings from configuration
self.apply_startup_settings()
print("INFO: Stage connected and channels enabled")
return True
def disconnect(self) -> bool:
"""
Disconnect from ThorLabs stage
Returns:
bool: True if disconnection successful
"""
self._scanning = False
return self._driver.disconnect()
def is_connected(self) -> bool:
"""Check if stage is connected"""
return self._driver.is_connected()
# ==================== Homing ====================
def home_all_axes(self, wait: bool = True, timeout: float = 60.0) -> bool:
"""
Home all axes (X, Y, Z)
Args:
wait: If True, block until homing complete
timeout: Timeout in seconds
Returns:
bool: True if homing successful
"""
print("DEBUG: Homing all axes")
if not self.is_connected():
print("ERROR: Cannot home - stage not connected")
return False
return self._driver.home_all_channels(wait=wait, timeout=timeout)
def home_axis(self, axis: str, wait: bool = True, timeout: float = 30.0) -> bool:
"""
Home a specific axis
Args:
axis: Axis to home ('X', 'Y', or 'Z')
wait: If True, block until homing complete
timeout: Timeout in seconds
Returns:
bool: True if homing successful
"""
axis = axis.upper()
if axis not in ['X', 'Y', 'Z']:
print(f"ERROR: Invalid axis: {axis}")
return False
channel = {'X': self.X_AXIS, 'Y': self.Y_AXIS, 'Z': self.Z_AXIS}[axis]
print(f"DEBUG: Homing {axis} axis (channel {channel})")
return self._driver.home_channel(channel, wait=wait, timeout=timeout)
# ==================== Motion Control ====================
def move_absolute(self, x: Optional[float] = None,
y: Optional[float] = None,
z: Optional[float] = None,
wait: bool = False) -> bool:
"""
Move to absolute position
Args:
x: X position in mm (None to leave unchanged)
y: Y position in mm (None to leave unchanged)
z: Z position in mm (None to leave unchanged)
wait: If True, block until move complete
Returns:
bool: True if move successful
"""
if not self.is_connected():
print("ERROR: Stage not connected")
return False
success = True
# Move each axis that was specified
if x is not None:
if not self._driver.move_absolute(self.X_AXIS, x, wait=wait):
success = False
if y is not None:
if not self._driver.move_absolute(self.Y_AXIS, y, wait=wait):
success = False
if z is not None:
if not self._driver.move_absolute(self.Z_AXIS, z, wait=wait):
success = False
return success
def move_relative(self, dx: float = 0.0, dy: float = 0.0, dz: float = 0.0,
wait: bool = False) -> bool:
"""
Move relative to current position
Args:
dx: X displacement in mm
dy: Y displacement in mm
dz: Z displacement in mm
wait: If True, block until move complete
Returns:
bool: True if move successful
"""
if not self.is_connected():
print("ERROR: Stage not connected")
return False
success = True
if dx != 0.0:
if not self._driver.move_relative(self.X_AXIS, dx, wait=wait):
success = False
if dy != 0.0:
if not self._driver.move_relative(self.Y_AXIS, dy, wait=wait):
success = False
if dz != 0.0:
if not self._driver.move_relative(self.Z_AXIS, dz, wait=wait):
success = False
return success
def stop_all(self, immediate: bool = True) -> bool:
"""
Stop all motion
Args:
immediate: If True, stop immediately; if False, decelerate
Returns:
bool: True if stop successful
"""
return self._driver.stop(0, immediate) # Channel 0 = all channels
# ==================== Position and Status ====================
def get_position(self) -> Dict[str, float]:
"""
Get current position
Returns:
dict: Current X, Y, Z positions in mm
"""
return {
'x': self._driver.get_position(self.X_AXIS) or 0.0,
'y': self._driver.get_position(self.Y_AXIS) or 0.0,
'z': self._driver.get_position(self.Z_AXIS) or 0.0
}
def get_status(self) -> Dict[str, bool]:
"""
Get current stage status
Returns:
dict: Status information compatible with main_window expectations
"""
x_status = self._driver.get_channel_status(self.X_AXIS) or {}
y_status = self._driver.get_channel_status(self.Y_AXIS) or {}
z_status = self._driver.get_channel_status(self.Z_AXIS) or {}
# Determine if any axis is moving
moving = (x_status.get('moving', False) or
y_status.get('moving', False) or
z_status.get('moving', False))
# Determine if stage is ready (all enabled axes are homed)
x_ready = x_status.get('enabled', False) and x_status.get('homed', False)
y_ready = y_status.get('enabled', False) and y_status.get('homed', False)
ready = x_ready and y_ready # Z is optional
return {
'connected': self.is_connected(),
'x_homed': x_status.get('homed', False),
'y_homed': y_status.get('homed', False),
'z_homed': z_status.get('homed', False),
'ready': ready,
'scanning': self._scanning,
'moving': moving
}
# ==================== Velocity Control ====================
def set_velocity(self, velocity_mm_s: float, accel_mm_s2: float,
axis: Optional[str] = None) -> bool:
"""
Set velocity and acceleration parameters
Args:
velocity_mm_s: Maximum velocity in mm/s
accel_mm_s2: Acceleration in mm/s²
axis: Specific axis ('X', 'Y', 'Z'), or None for all axes
Returns:
bool: True if parameters set successfully
"""
if axis:
axis = axis.upper()
if axis not in ['X', 'Y', 'Z']:
print(f"ERROR: Invalid axis: {axis}")
return False
channel = {'X': self.X_AXIS, 'Y': self.Y_AXIS, 'Z': self.Z_AXIS}[axis]
return self._driver.set_velocity_params(channel, velocity_mm_s, accel_mm_s2)
else:
# Set for all axes
success = True
for channel in [self.X_AXIS, self.Y_AXIS, self.Z_AXIS]:
if not self._driver.set_velocity_params(channel, velocity_mm_s, accel_mm_s2):
success = False
time.sleep(0.05)
return success
# ==================== Scan Support ====================
def prepare_for_scan(self, params: Dict) -> bool:
"""
Prepare stage for scanning operation
Args:
params: Scan parameters dictionary
Returns:
bool: True if preparation successful
"""
print("DEBUG: Preparing stage for scan")
status = self.get_status()
if not status['ready']:
print("ERROR: Stage not ready for scanning")
return False
# Move to start position
x_start = params.get('x_start', 0)
y_start = params.get('y_start', 0)
print(f" Moving to scan start: X={x_start} mm, Y={y_start} mm")
if not self.move_absolute(x=x_start, y=y_start, wait=True):
print("ERROR: Failed to move to start position")
return False
self._scanning = True
print("INFO: Stage ready for scanning")
return True
def stop_scan(self) -> bool:
"""
Stop current scan operation
Returns:
bool: True if stop successful
"""
print("DEBUG: Stopping scan")
self._scanning = False
return self.stop_all(immediate=True)
# ==================== Utility Methods ====================
def identify(self, channel: Optional[int] = None) -> bool:
"""
Flash front panel LEDs to identify controller
Args:
channel: Specific channel (1, 2, 3), or None for all
Returns:
bool: True if command sent successfully
"""
if channel is None:
# Identify all channels
for ch in [1, 2, 3]:
self._driver.identify(ch)
time.sleep(0.1)
return True
else:
return self._driver.identify(channel)
@staticmethod
def list_available_ports():
"""
List available serial ports (deprecated - use list_devices instead)
Returns:
list: Available port names
"""
return BBD203Driver.list_available_ports()
@staticmethod
def list_devices():
"""
List all connected ThorLabs BBD203 devices
Returns:
list: List of dicts with device info including 'serial' and 'port'
"""
return BBD203Driver.list_thorlabs_devices()
# ==================== Settings Management ====================
def apply_startup_settings(self) -> bool:
"""
Apply saved settings to the stage on startup
This includes:
- Velocity parameters for all axes
- Acceleration parameters for all axes
- Trigger configuration for all axes
Returns:
bool: True if all settings applied successfully
"""
print("INFO: Applying startup settings to stage")
success = True
# Apply velocity and acceleration settings
velocities = self.settings.get_all_velocities()
accelerations = self.settings.get_all_accelerations()
print(f" Velocity settings: X={velocities['x_axis']} mm/s, "
f"Y={velocities['y_axis']} mm/s, Z={velocities['z_axis']} mm/s")
print(f" Acceleration settings: X={accelerations['x_axis']} mm/s², "
f"Y={accelerations['y_axis']} mm/s², Z={accelerations['z_axis']} mm/s²")
# Set velocity/acceleration for each axis
if not self._driver.set_velocity_params(
self.X_AXIS, velocities['x_axis'], accelerations['x_axis']
):
success = False
time.sleep(0.05)
if not self._driver.set_velocity_params(
self.Y_AXIS, velocities['y_axis'], accelerations['y_axis']
):
success = False
time.sleep(0.05)
if not self._driver.set_velocity_params(
self.Z_AXIS, velocities['z_axis'], accelerations['z_axis']
):
success = False
time.sleep(0.05)
# Apply trigger configuration for each axis
for axis_name, channel in [('x_axis', self.X_AXIS),
('y_axis', self.Y_AXIS),
('z_axis', self.Z_AXIS)]:
trigger_config = self.settings.get_trigger_config(axis_name)
if not self._driver.set_trigger_mode(
channel,
trigger_config['mode'],
trigger_config['polarity'],
trigger_config['start_pos_fwd'],
trigger_config['start_pos_rev'],
trigger_config['interval_fwd'],
trigger_config['interval_rev']
):
success = False
time.sleep(0.05)
if success:
print("INFO: All startup settings applied successfully")
else:
print("WARNING: Some startup settings failed to apply")
return success
def save_current_settings(self) -> bool:
"""
Save current settings to file
Returns:
bool: True if saved successfully
"""
return self.settings.save()
def reload_settings(self) -> bool:
"""
Reload settings from file
Returns:
bool: True if reloaded successfully
"""
return self.settings.load()
def configure_velocity(self, x: Optional[float] = None,
y: Optional[float] = None,
z: Optional[float] = None,
save: bool = True) -> bool:
"""
Configure velocity for one or more axes
Args:
x: X-axis velocity in mm/s (None to keep current)
y: Y-axis velocity in mm/s (None to keep current)
z: Z-axis velocity in mm/s (None to keep current)
save: Save settings to file after updating
Returns:
bool: True if configuration successful
"""
success = True
if x is not None:
self.settings.set_velocity('x_axis', x)
accel = self.settings.get_acceleration('x_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.X_AXIS, x, accel)
time.sleep(0.05)
if y is not None:
self.settings.set_velocity('y_axis', y)
accel = self.settings.get_acceleration('y_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.Y_AXIS, y, accel)
time.sleep(0.05)
if z is not None:
self.settings.set_velocity('z_axis', z)
accel = self.settings.get_acceleration('z_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.Z_AXIS, z, accel)
time.sleep(0.05)
if save:
self.settings.save()
return success
def configure_acceleration(self, x: Optional[float] = None,
y: Optional[float] = None,
z: Optional[float] = None,
save: bool = True) -> bool:
"""
Configure acceleration for one or more axes
Args:
x: X-axis acceleration in mm/s² (None to keep current)
y: Y-axis acceleration in mm/s² (None to keep current)
z: Z-axis acceleration in mm/s² (None to keep current)
save: Save settings to file after updating
Returns:
bool: True if configuration successful
"""
success = True
if x is not None:
self.settings.set_acceleration('x_axis', x)
vel = self.settings.get_velocity('x_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.X_AXIS, vel, x)
time.sleep(0.05)
if y is not None:
self.settings.set_acceleration('y_axis', y)
vel = self.settings.get_velocity('y_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.Y_AXIS, vel, y)
time.sleep(0.05)
if z is not None:
self.settings.set_acceleration('z_axis', z)
vel = self.settings.get_velocity('z_axis')
if self.is_connected():
success &= self._driver.set_velocity_params(self.Z_AXIS, vel, z)
time.sleep(0.05)
if save:
self.settings.save()
return success
def configure_trigger(self, axis: str, mode: int,
polarity: int = 0x01,
start_pos_fwd: float = 0.0,
start_pos_rev: float = 0.0,
interval_fwd: float = 0.0,
interval_rev: float = 0.0,
save: bool = True) -> bool:
"""
Configure trigger for specific axis
Args:
axis: Axis name ('X', 'Y', or 'Z')
mode: Trigger mode (TriggerMode enum value)
polarity: Trigger polarity (0x01 = active high, 0x02 = active low)
start_pos_fwd: Start position for forward trigger (mm)
start_pos_rev: Start position for reverse trigger (mm)
interval_fwd: Interval for forward trigger (mm)
interval_rev: Interval for reverse trigger (mm)
save: Save settings to file after updating
Returns:
bool: True if configuration successful
"""
axis = axis.upper()
if axis not in ['X', 'Y', 'Z']:
print(f"ERROR: Invalid axis: {axis}")
return False
axis_name = f"{axis.lower()}_axis"
channel = {'X': self.X_AXIS, 'Y': self.Y_AXIS, 'Z': self.Z_AXIS}[axis]
# Update settings
self.settings.set_trigger_config(
axis_name, mode, polarity,
start_pos_fwd, start_pos_rev,
interval_fwd, interval_rev
)
# Apply to hardware if connected
success = True
if self.is_connected():
success = self._driver.set_trigger_mode(
channel, mode, polarity,
start_pos_fwd, start_pos_rev,
interval_fwd, interval_rev
)
if save:
self.settings.save()
return success
def get_detailed_status(self) -> Dict:
"""
Get detailed status of all channels
Returns:
dict: Detailed status information
"""
return {
'connected': self.is_connected(),
'scanning': self._scanning,
'x_axis': self._driver.get_channel_status(self.X_AXIS),
'y_axis': self._driver.get_channel_status(self.Y_AXIS),
'z_axis': self._driver.get_channel_status(self.Z_AXIS),
'position': self.get_position(),
'settings': self.settings.get_all_settings()
}