Files
scanengine-3/hardware/bbd202.py
T
2026-02-09 14:40:34 -06:00

3148 lines
120 KiB
Python

'''
MLS203/BBD202 Thorlabs Controller Driver
Thomas Ales | Rev 0 | Jan 2026
This does contain LLM generated code. Cause if you think
I am gonna copy 443 pages of the protocol manual by hand
you're crazy.
Only supports two axes, but easily extendable to 3 if you have a 203 controller
'''
from pyftdi.ftdi import Ftdi
import time
import struct
import threading
import queue
from typing import Union, Optional, Callable
from dataclasses import dataclass
from enum import IntEnum, IntFlag
class MsgId(IntEnum):
"""APT message IDs."""
HW_DISCONNECT = 0x0002
HW_REQ_INFO = 0x0005
HW_GET_INFO = 0x0006
HW_STOP_UPDATEMSGS = 0x0012
HW_START_UPDATEMSGS = 0x0011
MOD_IDENTIFY = 0x0223
MOD_SET_CHANENABLESTATE = 0x0210
MOD_REQ_CHANENABLESTATE = 0x0211
MOD_GET_CHANENABLESTATE = 0x0212
MOT_MOVE_HOME = 0x0443
MOT_MOVE_HOMED = 0x0444
MOT_MOVE_ABSOLUTE = 0x0453
MOT_MOVE_COMPLETED = 0x0464
MOT_REQ_USTATUSUPDATE = 0x0490
MOT_GET_USTATUSUPDATE = 0x0491
MOT_GET_DCSTATUSUPDATE = 0x0491 # Alias for USTATUSUPDATE
MOT_ACK_USTATUSUPDATE = 0x0492
MOT_SET_ENCCOUNTER = 0x0409
MOT_REQ_ENCCOUNTER = 0x040A
MOT_GET_ENCCOUNTER = 0x040B
MOT_REQ_POSCOUNTER = 0x0411
MOT_GET_POSCOUNTER = 0x0412
MOT_SET_VELPARAMS = 0x0413
MOT_REQ_VELPARAMS = 0x0414
MOT_GET_VELPARAMS = 0x0415
MOT_SET_JOGPARAMS = 0x0416
MOT_REQ_JOGPARAMS = 0x0417
MOT_GET_JOGPARAMS = 0x0418
MOT_SET_MOVERELPARAMS = 0x0445
MOT_REQ_MOVERELPARAMS = 0x0446
MOT_GET_MOVERELPARAMS = 0x0447
MOT_MOVE_RELATIVE = 0x0448
MOT_SET_MOVEABSPARAMS = 0x0450
MOT_REQ_MOVEABSPARAMS = 0x0451
MOT_GET_MOVEABSPARAMS = 0x0452
MOT_MOVE_STOP = 0x0465
MOT_MOVE_STOPPED = 0x0466
MOT_SET_TRIGGER = 0x0500
MOT_REQ_TRIGGER = 0x0501
MOT_GET_TRIGGER = 0x0502
class ChannelEnableState(IntEnum):
"""Channel enable state values."""
DISABLED = 0x02
ENABLED = 0x01
class JogMode(IntEnum):
"""Jog mode values."""
CONTINUOUS = 0x01
SINGLE_STEP = 0x02
class StopMode(IntEnum):
"""Stop mode values."""
IMMEDIATE = 0x01
CONTROLLED = 0x02
class TriggerMode(IntEnum):
"""Trigger mode values."""
DISABLED = 0x00
IN_OUT_RELATIVE_MOVE = 0x01
IN_OUT_ABSOLUTE_MOVE = 0x02
IN_OUT_HOME = 0x03
IN_OUT_STOP = 0x04
OUT_ONLY = 0x10
class MotorStatusBits(IntFlag):
"""Motor status bit flags."""
CWHARDLIMIT = 0x00000001 # Clockwise hard limit triggered
CCWHARDLIMIT = 0x00000002 # Counter-clockwise hard limit triggered
CWSOFTLIMIT = 0x00000004 # Clockwise software limit triggered
CCWSOFTLIMIT = 0x00000008 # Counter-clockwise software limit triggered
INMOTIONCW = 0x00000010 # Stage is in motion clockwise
INMOTIONCCW = 0x00000020 # Stage is in motion counter-clockwise
CONNECTED = 0x00000100 # Motor recognized by controller
HOMING = 0x00000200 # Motor is homing
HOMED = 0x00000400 # Motor completed homing, position valid
INITIALIZING = 0x00000800 # Motor is performing phase initialization
TRACKING = 0x00001000 # Position is within tracking window
SETTLED = 0x00002000 # Motor not moving and settled at target
POSITIONERROR = 0x00004000 # Actual position outside tracking window
OVERTEMP = 0x00200000 # Overtemperature error
BUSVOLTFAULT = 0x00400000 # Supply voltage too low
COMMUTATIONERROR = 0x00800000 # Motor commutation error (power cycle required)
OVERLOAD = 0x01000000 # Motor overload/overcurrent condition
ENCODERFAULT = 0x02000000 # Encoder error
OVERCURRENT = 0x04000000 # Continuous current limit exceeded
POWEROK = 0x10000000 # Power supply is OK
ACTIVE = 0x20000000 # Controller executing motion command
ERROR = 0x40000000 # Other errors
ENABLED = 0x80000000 # Motor output enabled, maintaining position
@dataclass
class AptMessage:
"""Represents a parsed APT message."""
msg_id: int
param1: int
param2: int
dest: int
source: int
data: bytes
raw: bytes
timestamp: float
class MotionController:
"""Controller class for Thorlabs APT motion controllers via FTDI interface."""
TYPE_FORMATS = {
'word': '<H',
'short': '<h',
'dword': '<I',
'long': '<l',
'char': '<B',
}
# Channel/axis destination addresses
DEST_CONTROLLER = 0x11 # Motherboard/controller
DEST_X_AXIS = 0x21
DEST_Y_AXIS = 0x22
# Scaling factors for BBD202/203
ENCODER_COUNTS_PER_MM = 20000 # Position scaling
VELOCITY_SCALING = 13421.77 # Velocity (mm/s) scaling
ACCELERATION_SCALING = 13.744 # Acceleration (mm/s^2) scaling
# Class variable to track if FTDI product is registered
_ftdi_registered = False
def __init__(self, url: str = 'ftdi://0x0403:0xfaf0/1', baudrate: int = 115200):
# Only register the custom product once globally
if not MotionController._ftdi_registered:
try:
Ftdi.add_custom_product(0x0403, 0xfaf0, 'Thorlabs')
MotionController._ftdi_registered = True
except ValueError:
# Already registered, ignore
pass
self.ftdi = Ftdi()
self.url = url
self.baudrate = baudrate
self._connected = False
self._hw_info = None
self._encoder_counts = {} # dest -> encoder count (int)
self._stage_positions = {} # dest -> position in mm (float)
self._position_lock = threading.Lock()
self._velocity_params = {} # dest -> {'min_velocity': float, 'acceleration': float, 'max_velocity': float}
self._velocity_lock = threading.Lock()
self._jog_params = {} # dest -> {'jog_mode': int, 'step_size': int, 'min_velocity': float, 'acceleration': float, 'max_velocity': float, 'stop_mode': int}
self._jog_lock = threading.Lock()
self._move_rel_params = {} # dest -> {'relative_distance': float, 'relative_distance_counts': int}
self._move_rel_lock = threading.Lock()
self._move_abs_params = {} # dest -> {'absolute_position': float, 'absolute_position_counts': int}
self._move_abs_lock = threading.Lock()
self._move_completed = {} # dest -> {'position': float, 'position_counts': int, 'velocity': int, 'motor_current': int, 'status_bits': int}
self._move_completed_lock = threading.Lock()
self._move_stopped = {} # dest -> {'position': float, 'position_counts': int, 'velocity': int, 'motor_current': int, 'status_bits': int}
self._move_stopped_lock = threading.Lock()
self._status_bits = {} # dest -> int (updated on every MOVE_COMPLETED or MOVE_STOPPED)
self._status_bits_lock = threading.Lock()
# TX queue for serialized command sending - all outgoing data goes through this queue
self._tx_queue: queue.Queue = queue.Queue()
self._tx_thread: Optional[threading.Thread] = None
self._tx_running = False
self._rx_thread: Optional[threading.Thread] = None
self._rx_running = False
self._rx_queue: queue.Queue[AptMessage] = queue.Queue()
self._rx_lock = threading.Lock()
self._callbacks: dict[int, list[Callable[[AptMessage], None]]] = {}
self._waiters: dict[int, tuple[threading.Event, list]] = {}
self._waiter_lock = threading.Lock()
# Pending move tracking for high-level motion control
self._pending_moves = {} # dest -> target_position (float)
self._pending_moves_lock = threading.Lock()
# Last error tracking
self._last_error: Optional[str] = None
self._last_error_lock = threading.Lock()
# Auto-ACK control - when True, ACKs are sent automatically in RX loop
# Set to False via stop_status_ack() for manual ACK mode during scanning
self._auto_ack_enabled = True
self._status_update_count_x = 0 # Track X status updates
self._status_update_count_y = 0 # Track Y status updates
def connect(self, enable_updates: bool = True) -> None:
"""
Open connection to the controller.
Args:
enable_updates: If True, enable status update messages. ACKs are sent
reactively when status updates are received.
Note:
When enable_updates=True, the controller will send periodic status updates.
ACKs are automatically sent in response to each status update through
the TX queue, ensuring proper serialization with other commands.
"""
self.ftdi.open_from_url(self.url)
self.ftdi.set_baudrate(self.baudrate)
self.ftdi.set_line_property(8, 1, 'N')
self.ftdi.set_flowctrl('hw')
self.ftdi.set_rts(True)
self.ftdi.purge_buffers()
self._connected = True
time.sleep(0.1)
# Start TX thread for serialized command sending
self._tx_running = True
self._tx_thread = threading.Thread(target=self._tx_loop, daemon=True)
self._tx_thread.start()
self._rx_running = True
self._rx_thread = threading.Thread(target=self._rx_loop, daemon=True)
self._rx_thread.start()
if enable_updates:
# Send to both axis channels to enable updates from each
self._send_raw(self._build_short_message(MsgId.HW_START_UPDATEMSGS, dest=self.DEST_X_AXIS))
self._send_raw(self._build_short_message(MsgId.HW_START_UPDATEMSGS, dest=self.DEST_Y_AXIS))
else:
self._send_raw(self._build_short_message(MsgId.HW_STOP_UPDATEMSGS, dest=self.DEST_X_AXIS))
self._send_raw(self._build_short_message(MsgId.HW_STOP_UPDATEMSGS, dest=self.DEST_Y_AXIS))
time.sleep(0.1)
def disconnect(self) -> None:
"""Close connection to the controller."""
if self._connected:
self._rx_running = False
if self._rx_thread:
self._rx_thread.join(timeout=1.0)
self._rx_thread = None
try:
# Send disconnect directly to FTDI since we're shutting down TX thread
with self._rx_lock:
self.ftdi.write_data(self._build_short_message(MsgId.HW_DISCONNECT))
time.sleep(0.05)
except:
pass
# Stop TX thread after sending disconnect
self._tx_running = False
if self._tx_thread:
self._tx_thread.join(timeout=1.0)
self._tx_thread = None
self.ftdi.close()
self._connected = False
self._hw_info = None
def _build_short_message(self, msg_id: int, param1: int = 0, param2: int = 0,
dest: int = 0x11, source: int = 0x01) -> bytes:
"""Build a 6-byte APT header-only message."""
return struct.pack('<HBBBB', msg_id, param1, param2, dest, source)
def _build_long_message(self, msg_id: int, data: bytes,
dest: int = 0x11, source: int = 0x01) -> bytes:
"""Build an APT message with data payload."""
data_len = len(data)
header = struct.pack('<HHBB', msg_id, data_len, dest | 0x80, source)
return header + data
def _send_raw(self, data: bytes) -> int:
"""Queue raw bytes to be sent to the controller via the TX thread."""
if not self._connected:
raise ConnectionError("Not connected to controller")
self._tx_queue.put(data)
return len(data)
def _tx_loop(self) -> None:
"""TX thread - processes all outgoing commands from the queue."""
while self._tx_running:
try:
data = self._tx_queue.get(timeout=0.1)
if data is not None:
with self._rx_lock:
self.ftdi.write_data(data)
except queue.Empty:
pass
except Exception as e:
if self._tx_running:
print(f"TX error: {e}")
def _rx_loop(self) -> None:
"""Receiver thread loop - continuously reads and parses messages."""
buffer = b''
while self._rx_running:
try:
with self._rx_lock:
chunk = self.ftdi.read_data(512)
if chunk:
buffer += chunk
buffer = self._parse_buffer(buffer)
else:
time.sleep(0.01)
except Exception as e:
if self._rx_running:
print(f"RX error: {e}")
time.sleep(0.1)
def _parse_buffer(self, buffer: bytes) -> bytes:
"""Parse complete messages from buffer, return remaining bytes."""
while len(buffer) >= 6:
msg_id = struct.unpack('<H', buffer[0:2])[0]
if buffer[4] & 0x80:
# Long message: bytes 2-3 are data length
data_len = struct.unpack('<H', buffer[2:4])[0]
total_len = 6 + data_len
if len(buffer) < total_len:
break
dest = buffer[4] & 0x7F
source = buffer[5]
data = buffer[6:total_len]
raw = buffer[:total_len]
msg = AptMessage(
msg_id=msg_id,
param1=0,
param2=0,
dest=dest,
source=source,
data=data,
raw=raw,
timestamp=time.time()
)
else:
# Short message: bytes 2-3 are param1, param2
param1 = buffer[2]
param2 = buffer[3]
dest = buffer[4]
source = buffer[5]
msg = AptMessage(
msg_id=msg_id,
param1=param1,
param2=param2,
dest=dest,
source=source,
data=b'',
raw=buffer[:6],
timestamp=time.time()
)
total_len = 6
buffer = buffer[total_len:]
self._rx_queue.put(msg)
with self._waiter_lock:
if msg.msg_id in self._waiters:
event, responses = self._waiters[msg.msg_id]
responses.append(msg)
event.set()
# Also check for composite keys (msg_id, source)
# GET messages always have dest=0x01 (us)
if msg.msg_id in (MsgId.MOD_GET_CHANENABLESTATE, MsgId.HW_GET_INFO, MsgId.MOT_GET_ENCCOUNTER, MsgId.MOT_GET_VELPARAMS, MsgId.MOT_GET_JOGPARAMS, MsgId.MOT_GET_MOVERELPARAMS, MsgId.MOT_GET_MOVEABSPARAMS):
if msg.dest != 0x01:
print(f"Warning: GET message {msg.msg_id:04X} has unexpected dest 0x{msg.dest:02X}, expected 0x01")
composite_key = (msg.msg_id, msg.source)
if composite_key in self._waiters:
event, responses = self._waiters[composite_key]
responses.append(msg)
event.set()
if msg.msg_id == MsgId.MOT_GET_ENCCOUNTER:
if len(msg.raw) >= 12:
encoder_counts = struct.unpack('<i', msg.raw[8:12])[0]
position_mm = encoder_counts / self.ENCODER_COUNTS_PER_MM
with self._position_lock:
self._encoder_counts[msg.source] = encoder_counts
self._stage_positions[msg.source] = position_mm
# Process status updates - parse position and status data
if msg.msg_id == MsgId.MOT_GET_USTATUSUPDATE:
# Track per-axis counts
if msg.source == 0x21:
self._status_update_count_x += 1
count = self._status_update_count_x
elif msg.source == 0x22:
self._status_update_count_y += 1
count = self._status_update_count_y
else:
count = 0
# Debug: print every 10th status update with position
if count > 0 and count % 10 == 0:
axis = "X" if msg.source == 0x21 else "Y"
if len(msg.data) >= 14:
pos_counts = struct.unpack('<i', msg.data[2:6])[0]
status_bits = struct.unpack('<I', msg.data[10:14])[0]
pos_mm = pos_counts / self.ENCODER_COUNTS_PER_MM
moving = "MOVING" if (status_bits & 0x00000030) else "idle"
print(f" [STATUS] {axis}#{count} pos={pos_mm:.3f}mm {moving} (0x{status_bits:08X})")
# Parse status update data (14 bytes: chan_ident + position + velocity + motor_current + status_bits)
if len(msg.data) >= 14:
position_counts = struct.unpack('<i', msg.data[2:6])[0]
position_mm = position_counts / self.ENCODER_COUNTS_PER_MM
status_bits = struct.unpack('<I', msg.data[10:14])[0]
with self._position_lock:
self._encoder_counts[msg.source] = position_counts
self._stage_positions[msg.source] = position_mm
with self._status_bits_lock:
self._status_bits[msg.source] = status_bits
# Send ACK for every status update
if self._auto_ack_enabled:
ack_msg = self._build_short_message(
MsgId.MOT_ACK_USTATUSUPDATE,
param1=0x01, # chan_ident
param2=0x00,
dest=0x11, # Generic USB destination
source=0x01
)
self._tx_queue.put(ack_msg)
# MOT_MOVE_HOMED is a short message - positions auto-reset to 0
if msg.msg_id == MsgId.MOT_MOVE_HOMED:
with self._position_lock:
self._encoder_counts[msg.source] = 0
self._stage_positions[msg.source] = 0.0
if msg.msg_id == MsgId.MOT_GET_VELPARAMS:
if len(msg.raw) >= 20:
min_vel_counts = struct.unpack('<l', msg.raw[8:12])[0]
accel_counts = struct.unpack('<L', msg.raw[12:16])[0]
max_vel_counts = struct.unpack('<L', msg.raw[16:20])[0]
min_velocity = min_vel_counts / self.VELOCITY_SCALING
acceleration = accel_counts / self.ACCELERATION_SCALING
max_velocity = max_vel_counts / self.VELOCITY_SCALING
with self._velocity_lock:
self._velocity_params[msg.source] = {
'min_velocity': min_velocity,
'acceleration': acceleration,
'max_velocity': max_velocity
}
if msg.msg_id == MsgId.MOT_GET_JOGPARAMS:
if len(msg.raw) >= 28:
jog_mode = struct.unpack('<H', msg.raw[8:10])[0]
step_size_counts = struct.unpack('<L', msg.raw[10:14])[0]
min_vel_counts = struct.unpack('<L', msg.raw[14:18])[0]
accel_counts = struct.unpack('<L', msg.raw[18:22])[0]
max_vel_counts = struct.unpack('<L', msg.raw[22:26])[0]
stop_mode = struct.unpack('<H', msg.raw[26:28])[0]
step_size_mm = step_size_counts / self.ENCODER_COUNTS_PER_MM
min_velocity = min_vel_counts / self.VELOCITY_SCALING
acceleration = accel_counts / self.ACCELERATION_SCALING
max_velocity = max_vel_counts / self.VELOCITY_SCALING
with self._jog_lock:
self._jog_params[msg.source] = {
'jog_mode': jog_mode,
'step_size': step_size_mm,
'step_size_counts': step_size_counts,
'min_velocity': min_velocity,
'acceleration': acceleration,
'max_velocity': max_velocity,
'stop_mode': stop_mode
}
if msg.msg_id == MsgId.MOT_GET_MOVERELPARAMS:
if len(msg.raw) >= 12:
relative_distance_counts = struct.unpack('<i', msg.raw[8:12])[0]
relative_distance_mm = relative_distance_counts / self.ENCODER_COUNTS_PER_MM
with self._move_rel_lock:
self._move_rel_params[msg.source] = {
'relative_distance': relative_distance_mm,
'relative_distance_counts': relative_distance_counts
}
if msg.msg_id == MsgId.MOT_GET_MOVEABSPARAMS:
if len(msg.raw) >= 12:
absolute_position_counts = struct.unpack('<i', msg.raw[8:12])[0]
absolute_position_mm = absolute_position_counts / self.ENCODER_COUNTS_PER_MM
with self._move_abs_lock:
self._move_abs_params[msg.source] = {
'absolute_position': absolute_position_mm,
'absolute_position_counts': absolute_position_counts
}
if msg.msg_id == MsgId.MOT_MOVE_COMPLETED:
if len(msg.raw) >= 20:
position_counts = struct.unpack('<i', msg.raw[8:12])[0]
velocity = struct.unpack('<H', msg.raw[12:14])[0]
motor_current = struct.unpack('<h', msg.raw[14:16])[0]
status_bits = struct.unpack('<I', msg.raw[16:20])[0]
position_mm = position_counts / self.ENCODER_COUNTS_PER_MM
with self._move_completed_lock:
self._move_completed[msg.source] = {
'position': position_mm,
'position_counts': position_counts,
'velocity': velocity,
'motor_current': motor_current,
'status_bits': status_bits
}
with self._status_bits_lock:
self._status_bits[msg.source] = status_bits
if msg.msg_id == MsgId.MOT_MOVE_STOPPED:
if len(msg.raw) >= 20:
position_counts = struct.unpack('<i', msg.raw[8:12])[0]
velocity = struct.unpack('<H', msg.raw[12:14])[0]
motor_current = struct.unpack('<h', msg.raw[14:16])[0]
status_bits = struct.unpack('<I', msg.raw[16:20])[0]
position_mm = position_counts / self.ENCODER_COUNTS_PER_MM
with self._move_stopped_lock:
self._move_stopped[msg.source] = {
'position': position_mm,
'position_counts': position_counts,
'velocity': velocity,
'motor_current': motor_current,
'status_bits': status_bits
}
with self._status_bits_lock:
self._status_bits[msg.source] = status_bits
# Call registered callbacks
if msg.msg_id in self._callbacks:
for callback in self._callbacks[msg.msg_id]:
try:
callback(msg)
except Exception as e:
print(f"Callback error: {e}")
return buffer
def register_callback(self, msg_id: int, callback: Callable[[AptMessage], None]) -> None:
"""
Register a callback for a specific message ID.
Args:
msg_id: Message ID to listen for
callback: Function to call when message is received
"""
if msg_id not in self._callbacks:
self._callbacks[msg_id] = []
self._callbacks[msg_id].append(callback)
def unregister_callback(self, msg_id: int, callback: Callable[[AptMessage], None]) -> None:
"""Remove a callback for a specific message ID."""
if msg_id in self._callbacks:
self._callbacks[msg_id].remove(callback)
def wait_for_message(self, msg_id: int, timeout: float = 5.0) -> Optional[AptMessage]:
"""
Wait for a specific message ID.
Args:
msg_id: Message ID to wait for
timeout: Timeout in seconds
Returns:
The received message, or None if timeout
"""
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[msg_id] = (event, responses)
try:
if event.wait(timeout=timeout):
return responses[0] if responses else None
return None
finally:
with self._waiter_lock:
del self._waiters[msg_id]
def send_command(self, msg_id: int, param1: int = 0, param2: int = 0,
data: Optional[bytes] = None, dest: int = 0x11, source: int = 0x01) -> None:
"""
Send a command to the controller.
Args:
msg_id: Message ID
param1: Parameter 1 (for short messages)
param2: Parameter 2 (for short messages)
data: Data payload (for long messages)
dest: Destination address
source: Source address
"""
if data is not None:
msg = self._build_long_message(msg_id, data, dest, source)
else:
msg = self._build_short_message(msg_id, param1, param2, dest, source)
self._send_raw(msg)
def send_and_wait(self, msg_id: int, response_id: int, param1: int = 0, param2: int = 0,
data: Optional[bytes] = None, timeout: float = 5.0) -> Optional[AptMessage]:
"""
Send a command and wait for a response.
Args:
msg_id: Message ID to send
response_id: Message ID to wait for
param1: Parameter 1 (for short messages)
param2: Parameter 2 (for short messages)
data: Data payload (for long messages)
timeout: Timeout in seconds
Returns:
The response message, or None if timeout
"""
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[response_id] = (event, responses)
try:
self.send_command(msg_id, param1, param2, data)
if event.wait(timeout=timeout):
return responses[0] if responses else None
return None
finally:
with self._waiter_lock:
del self._waiters[response_id]
def get_message(self, timeout: float = 0.1) -> Optional[AptMessage]:
"""
Get the next message from the receive queue.
Args:
timeout: Timeout in seconds
Returns:
The next message, or None if queue is empty
"""
try:
return self._rx_queue.get(timeout=timeout)
except queue.Empty:
return None
def get_all_messages(self) -> list[AptMessage]:
"""Get all messages currently in the receive queue."""
messages = []
while True:
try:
messages.append(self._rx_queue.get_nowait())
except queue.Empty:
break
return messages
def convert(self, data: bytes, dtype: str) -> Union[int, str]:
"""
Convert a byte slice to the specified type.
Args:
data: Byte slice to convert
dtype: Type specification:
- 'word': unsigned 16-bit integer
- 'short': signed 16-bit integer (2's complement)
- 'dword': unsigned 32-bit integer
- 'long': signed 32-bit integer (2's complement)
- 'char': single byte
- 'char[n]': string of n characters (e.g., 'char[8]')
Returns:
Converted value (int for numeric types, str for char[n])
"""
if dtype.startswith('char[') and dtype.endswith(']'):
n = int(dtype[5:-1])
if len(data) < n:
raise ValueError(f"Data length {len(data)} < expected {n} for {dtype}")
return data[:n].decode('ascii').rstrip('\x00')
if dtype not in self.TYPE_FORMATS:
raise ValueError(f"Unknown type: {dtype}")
fmt = self.TYPE_FORMATS[dtype]
expected_size = struct.calcsize(fmt)
if len(data) < expected_size:
raise ValueError(f"Data length {len(data)} < expected {expected_size} for {dtype}")
return struct.unpack(fmt, data[:expected_size])[0]
# Hardware info methods
def _refresh_hw_info(self) -> None:
"""Fetch and cache hardware info from the controller."""
response = self.send_and_wait(MsgId.HW_REQ_INFO, MsgId.HW_GET_INFO, timeout=2.0)
if response is None:
raise IOError("No response to HW_REQ_INFO")
if len(response.raw) < 90:
raise IOError(f"Invalid HW_GET_INFO response: expected 90 bytes, got {len(response.raw)}")
self._hw_info = response.raw
def get_serial_number(self) -> int:
"""Get the controller serial number."""
if self._hw_info is None:
self._refresh_hw_info()
return self.convert(self._hw_info[6:10], 'dword')
def get_model(self) -> str:
"""Get the controller model name."""
if self._hw_info is None:
self._refresh_hw_info()
return self.convert(self._hw_info[10:18], 'char[8]')
def get_hw_type(self) -> int:
"""Get the hardware type identifier."""
if self._hw_info is None:
self._refresh_hw_info()
return self.convert(self._hw_info[18:20], 'word')
def get_firmware_version(self) -> str:
"""Get the firmware version as a string (major.interim.minor)."""
if self._hw_info is None:
self._refresh_hw_info()
fw_minor = self.convert(self._hw_info[20:21], 'char')
fw_interim = self.convert(self._hw_info[21:22], 'char')
fw_major = self.convert(self._hw_info[22:23], 'char')
return f"{fw_major}.{fw_interim}.{fw_minor}"
def get_firmware_version_tuple(self) -> tuple:
"""Get the firmware version as a tuple (major, interim, minor)."""
if self._hw_info is None:
self._refresh_hw_info()
fw_minor = self.convert(self._hw_info[20:21], 'char')
fw_interim = self.convert(self._hw_info[21:22], 'char')
fw_major = self.convert(self._hw_info[22:23], 'char')
return (fw_major, fw_interim, fw_minor)
def get_notes(self) -> str:
"""Get the controller notes/description string."""
if self._hw_info is None:
self._refresh_hw_info()
return self.convert(self._hw_info[24:84], 'char[60]')
def get_hw_version(self) -> int:
"""Get the hardware version number."""
if self._hw_info is None:
self._refresh_hw_info()
return self.convert(self._hw_info[84:86], 'word')
def get_mod_state(self) -> int:
"""Get the module state."""
if self._hw_info is None:
self._refresh_hw_info()
return self.convert(self._hw_info[86:88], 'word')
def get_num_channels(self) -> int:
"""Get the number of channels."""
if self._hw_info is None:
self._refresh_hw_info()
return self.convert(self._hw_info[88:90], 'word')
def get_hw_info(self) -> dict:
"""
Get all hardware info as a dictionary.
Returns:
Dictionary containing all hardware info fields
"""
if self._hw_info is None:
self._refresh_hw_info()
return {
'serial_number': self.get_serial_number(),
'model': self.get_model(),
'hw_type': self.get_hw_type(),
'firmware_version': self.get_firmware_version(),
'notes': self.get_notes(),
'hw_version': self.get_hw_version(),
'mod_state': self.get_mod_state(),
'num_channels': self.get_num_channels(),
}
# Position methods
def get_position(self, dest: int, timeout: float = 5.0) -> Optional[float]:
"""
Get the current position of an axis by sending REQ and waiting for GET response.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
timeout: Timeout in seconds
Returns:
The position in mm as a float, or None if timeout
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Create a unique waiter key combining message ID and expected source
waiter_key = (MsgId.MOT_GET_ENCCOUNTER, dest)
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[waiter_key] = (event, responses)
try:
# Send REQ message to the axis, device will respond with GET
self.send_command(
MsgId.MOT_REQ_ENCCOUNTER,
param1=0x01,
param2=0x00,
dest=dest, # Send to the axis
source=0x01 # From us
)
# Wait for GET response from device
if event.wait(timeout=timeout):
response = responses[0] if responses else None
if response is None:
return None
# Bytes 8-11 of message contain encoder counts as 32-bit signed little-endian integer
if len(response.raw) >= 12:
encoder_counts = struct.unpack('<i', response.raw[8:12])[0]
position_mm = encoder_counts / self.ENCODER_COUNTS_PER_MM
return position_mm
return None
finally:
with self._waiter_lock:
if waiter_key in self._waiters:
del self._waiters[waiter_key]
def get_stage_position_x(self) -> Optional[float]:
"""
Get the current cached position of the X-axis.
Returns:
The position in mm as a float, or None if not yet known
"""
with self._position_lock:
return self._stage_positions.get(self.DEST_X_AXIS)
def get_stage_position_y(self) -> Optional[float]:
"""
Get the current cached position of the Y-axis.
Returns:
The position in mm as a float, or None if not yet known
"""
with self._position_lock:
return self._stage_positions.get(self.DEST_Y_AXIS)
def get_stage_positions(self) -> dict:
"""
Get all cached stage positions.
Returns:
Dictionary mapping axis destination (0x21, 0x22) to position in mm
"""
with self._position_lock:
return dict(self._stage_positions)
# Encoder count properties
@property
def encoder_count_x(self) -> Optional[int]:
"""
Get the current cached encoder count of the X-axis.
Returns:
The encoder count as an int, or None if not yet known
"""
with self._position_lock:
return self._encoder_counts.get(self.DEST_X_AXIS)
@property
def encoder_count_y(self) -> Optional[int]:
"""
Get the current cached encoder count of the Y-axis.
Returns:
The encoder count as an int, or None if not yet known
"""
with self._position_lock:
return self._encoder_counts.get(self.DEST_Y_AXIS)
@property
def position_x(self) -> Optional[float]:
"""
Get the current cached position of the X-axis in mm.
Returns:
The position in mm as a float, or None if not yet known
"""
with self._position_lock:
return self._stage_positions.get(self.DEST_X_AXIS)
@property
def position_y(self) -> Optional[float]:
"""
Get the current cached position of the Y-axis in mm.
Returns:
The position in mm as a float, or None if not yet known
"""
with self._position_lock:
return self._stage_positions.get(self.DEST_Y_AXIS)
def poll_status(self) -> None:
"""Request status updates from all axes (non-blocking)."""
# Request status from both axes
self.send_command(MsgId.MOT_REQ_USTATUSUPDATE, param1=0x01, param2=0x00,
dest=self.DEST_X_AXIS, source=0x01)
self.send_command(MsgId.MOT_REQ_USTATUSUPDATE, param1=0x01, param2=0x00,
dest=self.DEST_Y_AXIS, source=0x01)
def request_status_update(self, dest: int) -> None:
"""Request status update from specific axis (non-blocking)."""
self.send_command(MsgId.MOT_REQ_USTATUSUPDATE, param1=0x01, param2=0x00,
dest=dest, source=0x01)
def check_for_errors(self) -> Optional[str]:
"""Check if any axis has error flags set and store as last error."""
error_msg = None
with self._status_bits_lock:
x_bits = self._status_bits.get(self.DEST_X_AXIS, 0)
y_bits = self._status_bits.get(self.DEST_Y_AXIS, 0)
if x_bits & MotorStatusBits.ERROR:
error_msg = "X-axis error detected"
elif y_bits & MotorStatusBits.ERROR:
error_msg = "Y-axis error detected"
if error_msg:
with self._last_error_lock:
self._last_error = error_msg
return error_msg
def clear_last_error(self) -> None:
"""Clear the stored last error message."""
with self._last_error_lock:
self._last_error = None
@property
def last_error(self) -> Optional[str]:
"""Get the last error message that was detected."""
with self._last_error_lock:
return self._last_error
def is_move_pending(self) -> bool:
"""Check if any moves are pending."""
with self._pending_moves_lock:
return len(self._pending_moves) > 0
def get_pending_targets(self) -> dict:
"""Get pending move targets as dict mapping dest -> target_position."""
with self._pending_moves_lock:
return dict(self._pending_moves)
# Velocity parameter properties
@property
def velocity_params_x(self) -> Optional[dict]:
"""
Get the current cached velocity parameters for the X-axis.
Returns:
Dictionary with 'min_velocity', 'acceleration', 'max_velocity' (mm/s or mm/s²),
or None if not yet known
"""
with self._velocity_lock:
params = self._velocity_params.get(self.DEST_X_AXIS)
return dict(params) if params else None
@property
def velocity_params_y(self) -> Optional[dict]:
"""
Get the current cached velocity parameters for the Y-axis.
Returns:
Dictionary with 'min_velocity', 'acceleration', 'max_velocity' (mm/s or mm/s²),
or None if not yet known
"""
with self._velocity_lock:
params = self._velocity_params.get(self.DEST_Y_AXIS)
return dict(params) if params else None
@property
def min_velocity_x(self) -> Optional[float]:
"""Get the cached minimum velocity for X-axis in mm/s."""
with self._velocity_lock:
params = self._velocity_params.get(self.DEST_X_AXIS)
return params['min_velocity'] if params else None
@property
def min_velocity_y(self) -> Optional[float]:
"""Get the cached minimum velocity for Y-axis in mm/s."""
with self._velocity_lock:
params = self._velocity_params.get(self.DEST_Y_AXIS)
return params['min_velocity'] if params else None
@property
def acceleration_x(self) -> Optional[float]:
"""Get the cached acceleration for X-axis in mm/s²."""
with self._velocity_lock:
params = self._velocity_params.get(self.DEST_X_AXIS)
return params['acceleration'] if params else None
@property
def acceleration_y(self) -> Optional[float]:
"""Get the cached acceleration for Y-axis in mm/s²."""
with self._velocity_lock:
params = self._velocity_params.get(self.DEST_Y_AXIS)
return params['acceleration'] if params else None
@property
def max_velocity_x(self) -> Optional[float]:
"""Get the cached maximum velocity for X-axis in mm/s."""
with self._velocity_lock:
params = self._velocity_params.get(self.DEST_X_AXIS)
return params['max_velocity'] if params else None
@property
def max_velocity_y(self) -> Optional[float]:
"""Get the cached maximum velocity for Y-axis in mm/s."""
with self._velocity_lock:
params = self._velocity_params.get(self.DEST_Y_AXIS)
return params['max_velocity'] if params else None
# Jog parameter properties
@property
def jog_params_x(self) -> Optional[dict]:
"""
Get the current cached jog parameters for the X-axis.
Returns:
Dictionary with 'jog_mode', 'step_size', 'min_velocity', 'acceleration',
'max_velocity', 'stop_mode', or None if not yet known
"""
with self._jog_lock:
params = self._jog_params.get(self.DEST_X_AXIS)
return dict(params) if params else None
@property
def jog_params_y(self) -> Optional[dict]:
"""
Get the current cached jog parameters for the Y-axis.
Returns:
Dictionary with 'jog_mode', 'step_size', 'min_velocity', 'acceleration',
'max_velocity', 'stop_mode', or None if not yet known
"""
with self._jog_lock:
params = self._jog_params.get(self.DEST_Y_AXIS)
return dict(params) if params else None
# Move relative parameter properties
@property
def move_rel_params_x(self) -> Optional[dict]:
"""
Get the current cached move relative parameters for the X-axis.
Returns:
Dictionary with 'relative_distance' (mm) and 'relative_distance_counts',
or None if not yet known
"""
with self._move_rel_lock:
params = self._move_rel_params.get(self.DEST_X_AXIS)
return dict(params) if params else None
@property
def move_rel_params_y(self) -> Optional[dict]:
"""
Get the current cached move relative parameters for the Y-axis.
Returns:
Dictionary with 'relative_distance' (mm) and 'relative_distance_counts',
or None if not yet known
"""
with self._move_rel_lock:
params = self._move_rel_params.get(self.DEST_Y_AXIS)
return dict(params) if params else None
@property
def relative_distance_x(self) -> Optional[float]:
"""Get the cached relative move distance for X-axis in mm."""
with self._move_rel_lock:
params = self._move_rel_params.get(self.DEST_X_AXIS)
return params['relative_distance'] if params else None
@property
def relative_distance_y(self) -> Optional[float]:
"""Get the cached relative move distance for Y-axis in mm."""
with self._move_rel_lock:
params = self._move_rel_params.get(self.DEST_Y_AXIS)
return params['relative_distance'] if params else None
# Move absolute parameter properties
@property
def move_abs_params_x(self) -> Optional[dict]:
"""
Get the current cached move absolute parameters for the X-axis.
Returns:
Dictionary with 'absolute_position' (mm) and 'absolute_position_counts',
or None if not yet known
"""
with self._move_abs_lock:
params = self._move_abs_params.get(self.DEST_X_AXIS)
return dict(params) if params else None
@property
def move_abs_params_y(self) -> Optional[dict]:
"""
Get the current cached move absolute parameters for the Y-axis.
Returns:
Dictionary with 'absolute_position' (mm) and 'absolute_position_counts',
or None if not yet known
"""
with self._move_abs_lock:
params = self._move_abs_params.get(self.DEST_Y_AXIS)
return dict(params) if params else None
@property
def absolute_position_x(self) -> Optional[float]:
"""Get the cached absolute move position for X-axis in mm."""
with self._move_abs_lock:
params = self._move_abs_params.get(self.DEST_X_AXIS)
return params['absolute_position'] if params else None
@property
def absolute_position_y(self) -> Optional[float]:
"""Get the cached absolute move position for Y-axis in mm."""
with self._move_abs_lock:
params = self._move_abs_params.get(self.DEST_Y_AXIS)
return params['absolute_position'] if params else None
# Move completed status properties
@property
def move_completed_x(self) -> Optional[dict]:
"""
Get the most recent move completion status for the X-axis.
Returns:
Dictionary with 'position' (mm), 'position_counts', 'velocity',
'motor_current', 'status_bits', or None if no move completed yet
"""
with self._move_completed_lock:
status = self._move_completed.get(self.DEST_X_AXIS)
return dict(status) if status else None
@property
def move_completed_y(self) -> Optional[dict]:
"""
Get the most recent move completion status for the Y-axis.
Returns:
Dictionary with 'position' (mm), 'position_counts', 'velocity',
'motor_current', 'status_bits', or None if no move completed yet
"""
with self._move_completed_lock:
status = self._move_completed.get(self.DEST_Y_AXIS)
return dict(status) if status else None
# Move stopped status properties
@property
def move_stopped_x(self) -> Optional[dict]:
"""
Get the most recent move stopped status for the X-axis.
Returns:
Dictionary with 'position' (mm), 'position_counts', 'velocity',
'motor_current', 'status_bits', or None if no move stopped event yet
"""
with self._move_stopped_lock:
status = self._move_stopped.get(self.DEST_X_AXIS)
return dict(status) if status else None
@property
def move_stopped_y(self) -> Optional[dict]:
"""
Get the most recent move stopped status for the Y-axis.
Returns:
Dictionary with 'position' (mm), 'position_counts', 'velocity',
'motor_current', 'status_bits', or None if no move stopped event yet
"""
with self._move_stopped_lock:
status = self._move_stopped.get(self.DEST_Y_AXIS)
return dict(status) if status else None
# Status bits properties
@property
def status_bits_x(self) -> Optional[int]:
"""
Get the current status bits for the X-axis.
Returns:
Current status bits as int, or None if not yet known
"""
with self._status_bits_lock:
return self._status_bits.get(self.DEST_X_AXIS)
@property
def status_bits_y(self) -> Optional[int]:
"""
Get the current status bits for the Y-axis.
Returns:
Current status bits as int, or None if not yet known
"""
with self._status_bits_lock:
return self._status_bits.get(self.DEST_Y_AXIS)
# X-axis status bit properties
@property
def is_enabled_x(self) -> bool:
"""Check if X-axis motor output is enabled."""
bits = self.status_bits_x
return bool(bits and (MotorStatusBits.ENABLED in MotorStatusBits(bits)))
@property
def is_homed_x(self) -> bool:
"""Check if X-axis has been homed (position count is valid)."""
bits = self.status_bits_x
return bool(bits and (MotorStatusBits.HOMED in MotorStatusBits(bits)))
@property
def is_homing_x(self) -> bool:
"""Check if X-axis is currently homing."""
bits = self.status_bits_x
return bool(bits and (MotorStatusBits.HOMING in MotorStatusBits(bits)))
@property
def is_in_motion_x(self) -> bool:
"""Check if X-axis is currently in motion."""
bits = self.status_bits_x
return bool(bits and self.is_in_motion(bits))
@property
def is_settled_x(self) -> bool:
"""Check if X-axis is settled at target position."""
bits = self.status_bits_x
return bool(bits and (MotorStatusBits.SETTLED in MotorStatusBits(bits)))
@property
def is_tracking_x(self) -> bool:
"""Check if X-axis position is within tracking window."""
bits = self.status_bits_x
return bool(bits and (MotorStatusBits.TRACKING in MotorStatusBits(bits)))
@property
def is_connected_x(self) -> bool:
"""Check if X-axis motor is recognized by controller."""
bits = self.status_bits_x
return bool(bits and (MotorStatusBits.CONNECTED in MotorStatusBits(bits)))
@property
def has_errors_x(self) -> bool:
"""Check if X-axis has any error conditions."""
bits = self.status_bits_x
return bool(bits and self.has_errors(bits))
@property
def power_ok_x(self) -> bool:
"""Check if X-axis power supply is OK."""
bits = self.status_bits_x
return bool(bits and (MotorStatusBits.POWEROK in MotorStatusBits(bits)))
@property
def is_active_x(self) -> bool:
"""Check if X-axis controller is executing a motion command."""
bits = self.status_bits_x
return bool(bits and (MotorStatusBits.ACTIVE in MotorStatusBits(bits)))
@property
def at_cw_limit_x(self) -> bool:
"""Check if X-axis is at clockwise limit (hard or soft)."""
bits = self.status_bits_x
if not bits:
return False
status = MotorStatusBits(bits)
return bool((MotorStatusBits.CWHARDLIMIT in status) or (MotorStatusBits.CWSOFTLIMIT in status))
@property
def at_ccw_limit_x(self) -> bool:
"""Check if X-axis is at counter-clockwise limit (hard or soft)."""
bits = self.status_bits_x
if not bits:
return False
status = MotorStatusBits(bits)
return bool((MotorStatusBits.CCWHARDLIMIT in status) or (MotorStatusBits.CCWSOFTLIMIT in status))
# Y-axis status bit properties
@property
def is_enabled_y(self) -> bool:
"""Check if Y-axis motor output is enabled."""
bits = self.status_bits_y
return bool(bits and (MotorStatusBits.ENABLED in MotorStatusBits(bits)))
@property
def is_homed_y(self) -> bool:
"""Check if Y-axis has been homed (position count is valid)."""
bits = self.status_bits_y
return bool(bits and (MotorStatusBits.HOMED in MotorStatusBits(bits)))
@property
def is_homing_y(self) -> bool:
"""Check if Y-axis is currently homing."""
bits = self.status_bits_y
return bool(bits and (MotorStatusBits.HOMING in MotorStatusBits(bits)))
@property
def is_in_motion_y(self) -> bool:
"""Check if Y-axis is currently in motion."""
bits = self.status_bits_y
return bool(bits and self.is_in_motion(bits))
@property
def is_settled_y(self) -> bool:
"""Check if Y-axis is settled at target position."""
bits = self.status_bits_y
return bool(bits and (MotorStatusBits.SETTLED in MotorStatusBits(bits)))
@property
def is_tracking_y(self) -> bool:
"""Check if Y-axis position is within tracking window."""
bits = self.status_bits_y
return bool(bits and (MotorStatusBits.TRACKING in MotorStatusBits(bits)))
@property
def is_connected_y(self) -> bool:
"""Check if Y-axis motor is recognized by controller."""
bits = self.status_bits_y
return bool(bits and (MotorStatusBits.CONNECTED in MotorStatusBits(bits)))
@property
def has_errors_y(self) -> bool:
"""Check if Y-axis has any error conditions."""
bits = self.status_bits_y
return bool(bits and self.has_errors(bits))
@property
def power_ok_y(self) -> bool:
"""Check if Y-axis power supply is OK."""
bits = self.status_bits_y
return bool(bits and (MotorStatusBits.POWEROK in MotorStatusBits(bits)))
@property
def is_active_y(self) -> bool:
"""Check if Y-axis controller is executing a motion command."""
bits = self.status_bits_y
return bool(bits and (MotorStatusBits.ACTIVE in MotorStatusBits(bits)))
@property
def at_cw_limit_y(self) -> bool:
"""Check if Y-axis is at clockwise limit (hard or soft)."""
bits = self.status_bits_y
if not bits:
return False
status = MotorStatusBits(bits)
return bool((MotorStatusBits.CWHARDLIMIT in status) or (MotorStatusBits.CWSOFTLIMIT in status))
@property
def at_ccw_limit_y(self) -> bool:
"""Check if Y-axis is at counter-clockwise limit (hard or soft)."""
bits = self.status_bits_y
if not bits:
return False
status = MotorStatusBits(bits)
return bool((MotorStatusBits.CCWHARDLIMIT in status) or (MotorStatusBits.CCWSOFTLIMIT in status))
def start_update_messages(self) -> None:
"""
Start automatic status update messages from the controller.
Once started, the controller will periodically send status update messages
containing position, velocity, and status information. These can be captured
by registering a callback for the update message type.
Note: On BBD202/BBD203, HW_START_UPDATEMSGS must be sent to each axis
channel (0x21, 0x22) rather than the generic destination (0x11).
"""
# Send to both axis channels to enable updates from each
self.send_command(
MsgId.HW_START_UPDATEMSGS,
param1=0x00,
param2=0x00,
dest=self.DEST_X_AXIS, # 0x21
source=0x01
)
self.send_command(
MsgId.HW_START_UPDATEMSGS,
param1=0x00,
param2=0x00,
dest=self.DEST_Y_AXIS, # 0x22
source=0x01
)
print("Started automatic status update messages")
def stop_update_messages(self) -> None:
"""
Stop automatic status update messages from the controller.
This stops the periodic status updates that were started with
start_update_messages().
"""
self.send_command(
MsgId.HW_STOP_UPDATEMSGS,
param1=0x00,
param2=0x00,
dest=0x11, # Generic destination
source=0x01
)
print("Stopped automatic status update messages")
def start_status_ack(self) -> None:
"""
Enable automatic ACK mode.
When enabled, ACKs are sent automatically in the RX loop whenever
a status update message is received from the controller.
Use this after scanning operations that require manual ACK mode.
"""
self._auto_ack_enabled = True
def stop_status_ack(self) -> None:
"""
Disable automatic ACK mode (switch to manual ACK mode).
When disabled, ACKs are NOT sent automatically. You must call
ack_status_update() manually after each move completes.
Use this during scanning operations for more precise control over
when ACKs are sent. The snake test pattern is:
mc.stop_status_ack()
for each move:
mc.move_to_fast(...)
while not mc.poll_until_idle(...):
...
mc.ack_status_update() # Manual ACK after move completes
mc.start_status_ack()
"""
self._auto_ack_enabled = False
def ack_status_update(self) -> None:
"""
Send a single status update ACK manually.
Note: ACKs are now sent automatically in response to status update messages.
This method is retained for debugging or manual control if needed.
"""
self.send_command(
MsgId.MOT_ACK_USTATUSUPDATE,
param1=0x00,
param2=0x00,
dest=0x11,
source=0x01
)
def set_channel_enable_state(self, dest: int, enabled: bool) -> None:
"""
Set the channel enable state.
Args:
dest: Destination address (0x11 for X-axis, 0x22 for Y-axis)
enabled: True to enable, False to disable
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x11 (X-axis) or 0x22 (Y-axis)")
en_state = ChannelEnableState.ENABLED if enabled else ChannelEnableState.DISABLED
self.send_command(
MsgId.MOD_SET_CHANENABLESTATE,
param1=0x02, # Reserved byte
param2=en_state,
dest=dest,
source=0x01
)
def get_channel_enable_state(self, dest: int, timeout: float = 5.0) -> Optional[bool]:
"""
Get the channel enable state by sending REQ and waiting for GET response.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
timeout: Timeout in seconds
Returns:
True if enabled, False if disabled, None if timeout
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Create a unique waiter key combining message ID and expected source
waiter_key = (MsgId.MOD_GET_CHANENABLESTATE, dest)
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[waiter_key] = (event, responses)
try:
# Send REQ message to the axis, device will respond with GET
self.send_command(
MsgId.MOD_REQ_CHANENABLESTATE,
param1=0x01,
param2=0x00,
dest=dest, # Send to the axis
source=0x01 # From us
)
# Wait for GET response from device
if event.wait(timeout=timeout):
response = responses[0] if responses else None
if response is None:
return None
en_state = response.param2
return en_state == ChannelEnableState.ENABLED
return None
finally:
with self._waiter_lock:
if waiter_key in self._waiters:
del self._waiters[waiter_key]
# Homing methods
def home_axis(self, dest: int, timeout: float = 20.0) -> bool:
"""
Home an axis by sending MOVE_HOME and waiting for MOVE_HOMED response.
The controller will not respond until the axis is fully homed, which can
take 10-15 seconds or more depending on the axis position. After homing,
the position automatically resets to 0.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
timeout: Timeout in seconds (default 20s to account for homing time)
Returns:
True if homing succeeded, False if timeout
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Create a unique waiter key combining message ID and expected source
waiter_key = (MsgId.MOT_MOVE_HOMED, dest)
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[waiter_key] = (event, responses)
try:
# Send MOVE_HOME message to the axis
self.send_command(
MsgId.MOT_MOVE_HOME,
param1=0x01, # Channel ID
param2=0x00,
dest=dest, # Send to the axis
source=0x01 # From us
)
print(f"Homing axis 0x{dest:02X}... (this may take 10-15 seconds)")
# Wait for MOVE_HOMED response from device
if event.wait(timeout=timeout):
return True
else:
print(f"Timeout waiting for homing response from axis 0x{dest:02X}")
return False
finally:
with self._waiter_lock:
if waiter_key in self._waiters:
del self._waiters[waiter_key]
def home_x_axis(self, timeout: float = 20.0) -> bool:
"""
Home the X-axis. Position will reset to 0 after homing.
Args:
timeout: Timeout in seconds (default 20s)
Returns:
True if homing succeeded, False if timeout
"""
return self.home_axis(self.DEST_X_AXIS, timeout=timeout)
def home_y_axis(self, timeout: float = 20.0) -> bool:
"""
Home the Y-axis. Position will reset to 0 after homing.
Args:
timeout: Timeout in seconds (default 20s)
Returns:
True if homing succeeded, False if timeout
"""
return self.home_axis(self.DEST_Y_AXIS, timeout=timeout)
# Velocity parameter methods
def set_velocity_params(self, dest: int, min_velocity: float, acceleration: float,
max_velocity: float) -> None:
"""
Set velocity parameters for an axis.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
min_velocity: Minimum velocity in mm/s
acceleration: Acceleration in mm/s²
max_velocity: Maximum velocity in mm/s
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Convert from mm/s and mm/s² to encoder counts
min_vel_counts = int(min_velocity * self.VELOCITY_SCALING)
accel_counts = int(acceleration * self.ACCELERATION_SCALING)
max_vel_counts = int(max_velocity * self.VELOCITY_SCALING)
# Build the 14-byte data payload
data = struct.pack('<HlLL',
0x0001, # Channel ID (bytes 6-7)
min_vel_counts, # Minimum velocity (bytes 8-11)
accel_counts, # Acceleration (bytes 12-15)
max_vel_counts # Maximum velocity (bytes 16-19)
)
self.send_command(
MsgId.MOT_SET_VELPARAMS,
data=data,
dest=dest,
source=0x01
)
def get_velocity_params(self, dest: int, timeout: float = 5.0) -> Optional[dict]:
"""
Get velocity parameters for an axis.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
timeout: Timeout in seconds
Returns:
Dictionary with keys: 'min_velocity', 'acceleration', 'max_velocity' (all in mm/s or mm/s²),
or None if timeout
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Create a unique waiter key combining message ID and expected source
waiter_key = (MsgId.MOT_GET_VELPARAMS, dest)
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[waiter_key] = (event, responses)
try:
# Send REQ message to the axis
self.send_command(
MsgId.MOT_REQ_VELPARAMS,
param1=0x01,
param2=0x00,
dest=dest,
source=0x01
)
# Wait for GET response
if event.wait(timeout=timeout):
response = responses[0] if responses else None
if response is None:
return None
# Parse the 20-byte message
if len(response.raw) >= 20:
channel_id = struct.unpack('<H', response.raw[6:8])[0]
min_vel_counts = struct.unpack('<l', response.raw[8:12])[0]
accel_counts = struct.unpack('<L', response.raw[12:16])[0]
max_vel_counts = struct.unpack('<L', response.raw[16:20])[0]
# Convert from encoder counts to mm/s and mm/s²
return {
'min_velocity': min_vel_counts / self.VELOCITY_SCALING,
'acceleration': accel_counts / self.ACCELERATION_SCALING,
'max_velocity': max_vel_counts / self.VELOCITY_SCALING
}
return None
finally:
with self._waiter_lock:
if waiter_key in self._waiters:
del self._waiters[waiter_key]
def set_acceleration(self, dest: int, acceleration: float, timeout: float = 5.0) -> bool:
"""
Set acceleration for an axis while preserving current velocity settings.
This is a convenience method that retrieves current velocity parameters,
then updates only the acceleration value.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
acceleration: Acceleration in mm/s²
timeout: Timeout for retrieving current parameters
Returns:
True if successful, False if unable to retrieve current parameters
Raises:
ValueError: If dest is invalid
"""
# Get current velocity parameters
current_params = self.get_velocity_params(dest, timeout=timeout)
if current_params is None:
return False
# Set velocity parameters with new acceleration
self.set_velocity_params(
dest,
min_velocity=current_params['min_velocity'],
acceleration=acceleration,
max_velocity=current_params['max_velocity']
)
return True
def get_acceleration(self, dest: int, timeout: float = 5.0) -> Optional[float]:
"""
Get acceleration for an axis.
This is a convenience method that retrieves velocity parameters
and returns only the acceleration value.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
timeout: Timeout in seconds
Returns:
Acceleration in mm/s², or None if timeout
Raises:
ValueError: If dest is invalid
"""
params = self.get_velocity_params(dest, timeout=timeout)
if params is None:
return None
return params['acceleration']
# Jog parameter methods
def set_jog_params(self, dest: int, jog_mode: int, step_size: float,
min_velocity: float, acceleration: float, max_velocity: float,
stop_mode: int) -> None:
"""
Set jog parameters for an axis.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
jog_mode: Jog mode (JogMode.CONTINUOUS=1 or JogMode.SINGLE_STEP=2)
step_size: Step size in mm (for single step mode)
min_velocity: Minimum velocity in mm/s
acceleration: Acceleration in mm/s²
max_velocity: Maximum velocity in mm/s
stop_mode: Stop mode (StopMode.IMMEDIATE=1 or StopMode.CONTROLLED=2)
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Convert from mm and mm/s and mm/s² to encoder counts
step_size_counts = int(step_size * self.ENCODER_COUNTS_PER_MM)
min_vel_counts = int(min_velocity * self.VELOCITY_SCALING)
accel_counts = int(acceleration * self.ACCELERATION_SCALING)
max_vel_counts = int(max_velocity * self.VELOCITY_SCALING)
# Build the 22-byte data payload
data = struct.pack('<HHLLLLH',
0x0001, # Channel ID (bytes 6-7)
jog_mode, # Jog mode (bytes 8-9)
step_size_counts, # Step size (bytes 10-13)
min_vel_counts, # Minimum velocity (bytes 14-17)
accel_counts, # Acceleration (bytes 18-21)
max_vel_counts, # Maximum velocity (bytes 22-25)
stop_mode # Stop mode (bytes 26-27)
)
self.send_command(
MsgId.MOT_SET_JOGPARAMS,
data=data,
dest=dest,
source=0x01
)
def get_jog_params(self, dest: int, timeout: float = 5.0) -> Optional[dict]:
"""
Get jog parameters for an axis.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
timeout: Timeout in seconds
Returns:
Dictionary with keys: 'jog_mode', 'step_size' (mm), 'min_velocity' (mm/s),
'acceleration' (mm/s²), 'max_velocity' (mm/s), 'stop_mode',
or None if timeout
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Create a unique waiter key combining message ID and expected source
waiter_key = (MsgId.MOT_GET_JOGPARAMS, dest)
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[waiter_key] = (event, responses)
try:
# Send REQ message to the axis
self.send_command(
MsgId.MOT_REQ_JOGPARAMS,
param1=0x01,
param2=0x00,
dest=dest,
source=0x01
)
# Wait for GET response
if event.wait(timeout=timeout):
response = responses[0] if responses else None
if response is None:
return None
# Parse the 28-byte message
if len(response.raw) >= 28:
channel_id = struct.unpack('<H', response.raw[6:8])[0]
jog_mode = struct.unpack('<H', response.raw[8:10])[0]
step_size_counts = struct.unpack('<L', response.raw[10:14])[0]
min_vel_counts = struct.unpack('<L', response.raw[14:18])[0]
accel_counts = struct.unpack('<L', response.raw[18:22])[0]
max_vel_counts = struct.unpack('<L', response.raw[22:26])[0]
stop_mode = struct.unpack('<H', response.raw[26:28])[0]
# Convert from encoder counts to mm and mm/s and mm/s²
return {
'jog_mode': jog_mode,
'step_size': step_size_counts / self.ENCODER_COUNTS_PER_MM,
'step_size_counts': step_size_counts,
'min_velocity': min_vel_counts / self.VELOCITY_SCALING,
'acceleration': accel_counts / self.ACCELERATION_SCALING,
'max_velocity': max_vel_counts / self.VELOCITY_SCALING,
'stop_mode': stop_mode
}
return None
finally:
with self._waiter_lock:
if waiter_key in self._waiters:
del self._waiters[waiter_key]
# Trigger configuration methods
def set_trigger(self, dest: int, trigger_mode: int, polarity: int = 0x01) -> None:
"""
Configure trigger output for an axis.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
trigger_mode: Trigger mode from TriggerMode enum
polarity: Trigger polarity (0x01 = active high, 0x02 = active low)
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Build 14-byte trigger configuration packet
data = struct.pack('<HBBBBBBBBBBBB',
0x0001, # Channel ID (bytes 6-7)
trigger_mode, # Trigger mode (byte 8)
polarity, # Polarity (byte 9)
0, 0, 0, 0, 0, 0, 0, 0, 0, 0 # Reserved bytes 10-19
)
self.send_command(
MsgId.MOT_SET_TRIGGER,
data=data,
dest=dest,
source=0x01
)
# Move relative parameter methods
def set_move_rel_params(self, dest: int, relative_distance: float) -> None:
"""
Set relative move distance for an axis.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
relative_distance: Relative distance to move in mm (positive or negative)
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Convert from mm to encoder counts (signed)
relative_distance_counts = int(relative_distance * self.ENCODER_COUNTS_PER_MM)
# Build the 6-byte data payload
data = struct.pack('<Hi',
0x0001, # Channel ID (bytes 6-7)
relative_distance_counts # Relative distance (bytes 8-11)
)
self.send_command(
MsgId.MOT_SET_MOVERELPARAMS,
data=data,
dest=dest,
source=0x01
)
def get_move_rel_params(self, dest: int, timeout: float = 5.0) -> Optional[dict]:
"""
Get relative move parameters for an axis.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
timeout: Timeout in seconds
Returns:
Dictionary with keys: 'relative_distance' (mm), 'relative_distance_counts',
or None if timeout
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Create a unique waiter key combining message ID and expected source
waiter_key = (MsgId.MOT_GET_MOVERELPARAMS, dest)
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[waiter_key] = (event, responses)
try:
# Send REQ message to the axis
self.send_command(
MsgId.MOT_REQ_MOVERELPARAMS,
param1=0x01,
param2=0x00,
dest=dest,
source=0x01
)
# Wait for GET response
if event.wait(timeout=timeout):
response = responses[0] if responses else None
if response is None:
return None
# Parse the 12-byte message
if len(response.raw) >= 12:
channel_id = struct.unpack('<H', response.raw[6:8])[0]
relative_distance_counts = struct.unpack('<i', response.raw[8:12])[0]
# Convert from encoder counts to mm
return {
'relative_distance': relative_distance_counts / self.ENCODER_COUNTS_PER_MM,
'relative_distance_counts': relative_distance_counts
}
return None
finally:
with self._waiter_lock:
if waiter_key in self._waiters:
del self._waiters[waiter_key]
# Move absolute parameter methods
def set_move_abs_params(self, dest: int, absolute_position: float) -> None:
"""
Set absolute move position for an axis.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
absolute_position: Absolute position to move to in mm
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Convert from mm to encoder counts (signed)
absolute_position_counts = int(absolute_position * self.ENCODER_COUNTS_PER_MM)
# Build the 6-byte data payload
data = struct.pack('<Hi',
0x0001, # Channel ID (bytes 6-7)
absolute_position_counts # Absolute position (bytes 8-11)
)
self.send_command(
MsgId.MOT_SET_MOVEABSPARAMS,
data=data,
dest=dest,
source=0x01
)
def get_move_abs_params(self, dest: int, timeout: float = 5.0) -> Optional[dict]:
"""
Get absolute move parameters for an axis.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
timeout: Timeout in seconds
Returns:
Dictionary with keys: 'absolute_position' (mm), 'absolute_position_counts',
or None if timeout
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Create a unique waiter key combining message ID and expected source
waiter_key = (MsgId.MOT_GET_MOVEABSPARAMS, dest)
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[waiter_key] = (event, responses)
try:
# Send REQ message to the axis
self.send_command(
MsgId.MOT_REQ_MOVEABSPARAMS,
param1=0x01,
param2=0x00,
dest=dest,
source=0x01
)
# Wait for GET response
if event.wait(timeout=timeout):
response = responses[0] if responses else None
if response is None:
return None
# Parse the 12-byte message
if len(response.raw) >= 12:
channel_id = struct.unpack('<H', response.raw[6:8])[0]
absolute_position_counts = struct.unpack('<i', response.raw[8:12])[0]
# Convert from encoder counts to mm
return {
'absolute_position': absolute_position_counts / self.ENCODER_COUNTS_PER_MM,
'absolute_position_counts': absolute_position_counts
}
return None
finally:
with self._waiter_lock:
if waiter_key in self._waiters:
del self._waiters[waiter_key]
# Move execution methods
def move_relative(self, dest: int, timeout: float = 30.0) -> Optional[dict]:
"""
Execute a relative move using the previously set relative move parameters.
The controller will not respond until the move is completed. This command
uses the relative distance that was previously set with set_move_rel_params().
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
timeout: Timeout in seconds (default 30s to account for move time)
Returns:
Dictionary with move completion data: 'position' (mm), 'position_counts',
'velocity', 'motor_current', 'status_bits', or None if timeout
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Create a unique waiter key combining message ID and expected source
waiter_key = (MsgId.MOT_MOVE_COMPLETED, dest)
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[waiter_key] = (event, responses)
try:
# Send MOVE_RELATIVE command (header only)
self.send_command(
MsgId.MOT_MOVE_RELATIVE,
param1=0x01,
param2=0x00,
dest=dest,
source=0x01
)
print(f"Executing relative move on axis 0x{dest:02X}... (this may take some time)")
# Wait for MOVE_COMPLETED response from device
if event.wait(timeout=timeout):
response = responses[0] if responses else None
if response is None:
return None
# The move completion data should already be stored in _move_completed by _parse_buffer
with self._move_completed_lock:
return self._move_completed.get(dest)
else:
print(f"Timeout waiting for move completion from axis 0x{dest:02X}")
return None
finally:
with self._waiter_lock:
if waiter_key in self._waiters:
del self._waiters[waiter_key]
def move_absolute(self, dest: int, timeout: float = 30.0) -> Optional[dict]:
"""
Execute an absolute move using the previously set absolute move parameters.
The controller will not respond until the move is completed. This command
uses the absolute position that was previously set with set_move_abs_params().
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
timeout: Timeout in seconds (default 30s to account for move time)
Returns:
Dictionary with move completion data: 'position' (mm), 'position_counts',
'velocity', 'motor_current', 'status_bits', or None if timeout
Raises:
ValueError: If dest is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
# Create a unique waiter key combining message ID and expected source
waiter_key = (MsgId.MOT_MOVE_COMPLETED, dest)
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[waiter_key] = (event, responses)
try:
# Send MOVE_ABSOLUTE command (header only)
self.send_command(
MsgId.MOT_MOVE_ABSOLUTE,
param1=0x01,
param2=0x00,
dest=dest,
source=0x01
)
print(f"Executing absolute move on axis 0x{dest:02X}... (this may take some time)")
# Wait for MOVE_COMPLETED response from device
if event.wait(timeout=timeout):
response = responses[0] if responses else None
if response is None:
return None
# The move completion data should already be stored in _move_completed by _parse_buffer
with self._move_completed_lock:
return self._move_completed.get(dest)
else:
print(f"Timeout waiting for move completion from axis 0x{dest:02X}")
return None
finally:
with self._waiter_lock:
if waiter_key in self._waiters:
del self._waiters[waiter_key]
def stop_move(self, dest: int, stop_mode: int = StopMode.CONTROLLED,
wait_for_stopped: bool = True, timeout: float = 5.0) -> Optional[dict]:
"""
Stop a moving axis immediately or with controlled deceleration.
Args:
dest: Destination address (0x21 for X-axis, 0x22 for Y-axis)
stop_mode: StopMode.IMMEDIATE (1) for instant stop,
StopMode.CONTROLLED (2) for controlled deceleration (default)
wait_for_stopped: If True, wait for MOVE_STOPPED message (default True)
timeout: Timeout in seconds when waiting for MOVE_STOPPED (default 5s)
Returns:
If wait_for_stopped=True: Dictionary with stopped status data, or None if timeout
If wait_for_stopped=False: None immediately after sending command
Raises:
ValueError: If dest or stop_mode is invalid
"""
if dest not in (self.DEST_X_AXIS, self.DEST_Y_AXIS):
raise ValueError(f"Invalid destination: 0x{dest:02X}. Must be 0x21 (X-axis) or 0x22 (Y-axis)")
if stop_mode not in (StopMode.IMMEDIATE, StopMode.CONTROLLED):
raise ValueError(f"Invalid stop_mode: {stop_mode}. Must be StopMode.IMMEDIATE (1) or StopMode.CONTROLLED (2)")
if wait_for_stopped:
# Create a unique waiter key combining message ID and expected source
waiter_key = (MsgId.MOT_MOVE_STOPPED, dest)
event = threading.Event()
responses = []
with self._waiter_lock:
self._waiters[waiter_key] = (event, responses)
try:
# Send MOVE_STOP command (header only)
self.send_command(
MsgId.MOT_MOVE_STOP,
param1=0x01, # Channel ID (always 1)
param2=stop_mode, # Stop mode
dest=dest,
source=0x01
)
print(f"Stopping axis 0x{dest:02X} with {'immediate' if stop_mode == StopMode.IMMEDIATE else 'controlled'} stop...")
# Wait for MOVE_STOPPED response from device
if event.wait(timeout=timeout):
response = responses[0] if responses else None
if response is None:
return None
# The move stopped data should already be stored in _move_stopped by _parse_buffer
with self._move_stopped_lock:
return self._move_stopped.get(dest)
else:
print(f"Timeout waiting for stop confirmation from axis 0x{dest:02X}")
return None
finally:
with self._waiter_lock:
if waiter_key in self._waiters:
del self._waiters[waiter_key]
else:
# Just send the command without waiting
self.send_command(
MsgId.MOT_MOVE_STOP,
param1=0x01, # Channel ID (always 1)
param2=stop_mode, # Stop mode
dest=dest,
source=0x01
)
return None
def stop_x_axis(self, stop_mode: int = StopMode.CONTROLLED,
wait_for_stopped: bool = True, timeout: float = 5.0) -> Optional[dict]:
"""
Stop the X-axis.
Args:
stop_mode: StopMode.IMMEDIATE (1) or StopMode.CONTROLLED (2, default)
wait_for_stopped: If True, wait for MOVE_STOPPED message (default True)
timeout: Timeout in seconds when waiting (default 5s)
Returns:
Dictionary with stopped status data if wait_for_stopped=True, None otherwise
"""
return self.stop_move(self.DEST_X_AXIS, stop_mode, wait_for_stopped, timeout)
def stop_y_axis(self, stop_mode: int = StopMode.CONTROLLED,
wait_for_stopped: bool = True, timeout: float = 5.0) -> Optional[dict]:
"""
Stop the Y-axis.
Args:
stop_mode: StopMode.IMMEDIATE (1) or StopMode.CONTROLLED (2, default)
wait_for_stopped: If True, wait for MOVE_STOPPED message (default True)
timeout: Timeout in seconds when waiting (default 5s)
Returns:
Dictionary with stopped status data if wait_for_stopped=True, None otherwise
"""
return self.stop_move(self.DEST_Y_AXIS, stop_mode, wait_for_stopped, timeout)
def stop_all_axes(self, stop_mode: int = StopMode.CONTROLLED,
wait_for_stopped: bool = True, timeout: float = 5.0) -> dict:
"""
Stop all axes simultaneously.
Args:
stop_mode: StopMode.IMMEDIATE (1) or StopMode.CONTROLLED (2, default)
wait_for_stopped: If True, wait for MOVE_STOPPED messages (default True)
timeout: Timeout in seconds when waiting (default 5s)
Returns:
Dictionary with keys 'x' and 'y' containing stopped status data or None
"""
if wait_for_stopped:
# Use threading to stop both axes simultaneously
x_result = [None]
y_result = [None]
def stop_x():
x_result[0] = self.stop_move(self.DEST_X_AXIS, stop_mode, True, timeout)
def stop_y():
y_result[0] = self.stop_move(self.DEST_Y_AXIS, stop_mode, True, timeout)
x_thread = threading.Thread(target=stop_x)
y_thread = threading.Thread(target=stop_y)
x_thread.start()
y_thread.start()
x_thread.join()
y_thread.join()
return {'x': x_result[0], 'y': y_result[0]}
else:
# Just send commands without waiting
self.stop_move(self.DEST_X_AXIS, stop_mode, False)
self.stop_move(self.DEST_Y_AXIS, stop_mode, False)
return {'x': None, 'y': None}
# High-level motion control methods
def poll_positions(self) -> None:
"""
Request position updates for both X and Y axes.
This actively queries the controller for the current positions of both axes.
The positions are stored internally and can be accessed via position_x and position_y properties.
"""
try:
self.get_position(self.DEST_X_AXIS, timeout=1.5)
except Exception as e:
print(f"Error polling X position: {e}")
try:
self.get_position(self.DEST_Y_AXIS, timeout=1.5)
except Exception as e:
print(f"Error polling Y position: {e}")
def move_to_fast(self, x: Optional[float] = None, y: Optional[float] = None) -> None:
"""
Move to absolute position(s) using fast non-blocking moves.
Args:
x: Target X position in mm (None to leave X unchanged)
y: Target Y position in mm (None to leave Y unchanged)
"""
# Track pending moves
with self._pending_moves_lock:
if x is not None:
self._pending_moves[self.DEST_X_AXIS] = x
if y is not None:
self._pending_moves[self.DEST_Y_AXIS] = y
if y is not None:
y_counts = int(y * self.ENCODER_COUNTS_PER_MM)
y_data = struct.pack('<Hi', 0x0001, y_counts)
self.send_command(
MsgId.MOT_MOVE_ABSOLUTE,
data=y_data,
dest=self.DEST_Y_AXIS,
source=0x01
)
# Delay between commands - controller may need time to process
if x is not None and y is not None:
time.sleep(0.100) # 100ms delay
if x is not None:
x_counts = int(x * self.ENCODER_COUNTS_PER_MM)
x_data = struct.pack('<Hi', 0x0001, x_counts)
self.send_command(
MsgId.MOT_MOVE_ABSOLUTE,
data=x_data,
dest=self.DEST_X_AXIS,
source=0x01
)
def move_and_wait_for_completed(self, x: float = None, y: float = None,
timeout: float = 30.0) -> bool:
"""
Move to position and wait for MOT_MOVE_COMPLETED message(s).
This method sends absolute move commands and polls for MOT_MOVE_COMPLETED
messages by checking the _move_completed dictionary which is updated by
the RX thread when completion messages arrive.
Args:
x: Target X position in mm, or None to skip X axis
y: Target Y position in mm, or None to skip Y axis
timeout: Maximum wait time in seconds (default 30s)
Returns:
True if all moves completed successfully, False if timeout
"""
if x is None and y is None:
return True # Nothing to do
# Determine which axes we're moving
axes_to_move = []
if x is not None:
axes_to_move.append(self.DEST_X_AXIS)
if y is not None:
axes_to_move.append(self.DEST_Y_AXIS)
# Clear any previous completion data for axes we're about to move
with self._move_completed_lock:
for dest in axes_to_move:
if dest in self._move_completed:
del self._move_completed[dest]
# Track pending moves
with self._pending_moves_lock:
if x is not None:
self._pending_moves[self.DEST_X_AXIS] = x
if y is not None:
self._pending_moves[self.DEST_Y_AXIS] = y
move_type = "X+Y" if (x is not None and y is not None) else "X-only" if x is not None else "Y-only"
print(f" [move_and_wait] {move_type}: x={x}, y={y}")
# Send Y move first if needed
if y is not None:
y_counts = int(y * self.ENCODER_COUNTS_PER_MM)
y_data = struct.pack('<Hi', 0x0001, y_counts)
self.send_command(
MsgId.MOT_MOVE_ABSOLUTE,
data=y_data,
dest=self.DEST_Y_AXIS,
source=0x01
)
# Small delay between commands if moving both axes
if x is not None and y is not None:
time.sleep(0.020)
# Send X move if needed
if x is not None:
x_counts = int(x * self.ENCODER_COUNTS_PER_MM)
x_data = struct.pack('<Hi', 0x0001, x_counts)
self.send_command(
MsgId.MOT_MOVE_ABSOLUTE,
data=x_data,
dest=self.DEST_X_AXIS,
source=0x01
)
# Poll for MOT_MOVE_COMPLETED messages
start_time = time.time()
completed_axes = set()
while len(completed_axes) < len(axes_to_move):
elapsed = time.time() - start_time
if elapsed > timeout:
missing = [("X" if d == self.DEST_X_AXIS else "Y") for d in axes_to_move if d not in completed_axes]
print(f" [move_and_wait] Timeout waiting for {', '.join(missing)} MOT_MOVE_COMPLETED")
return False
# Check for completions
with self._move_completed_lock:
for dest in axes_to_move:
if dest not in completed_axes and dest in self._move_completed:
axis_name = "X" if dest == self.DEST_X_AXIS else "Y"
print(f" [move_and_wait] {axis_name}-axis move completed")
completed_axes.add(dest)
# Clear pending move
with self._pending_moves_lock:
if dest in self._pending_moves:
del self._pending_moves[dest]
if len(completed_axes) < len(axes_to_move):
time.sleep(0.010) # 10ms poll interval
return True
def poll_until_idle(self, tolerance: float = 0.005, timeout: float = 0.1) -> bool:
"""
Poll positions and check if all pending moves are complete within tolerance.
This method checks if the stage has settled at the target positions for all pending moves.
A move is considered complete when the current position is within 'tolerance' of the target.
Args:
tolerance: Position tolerance in mm (default 0.005mm = 5 microns)
timeout: Time to spend polling in this call (default 0.1s)
Returns:
True if all pending moves are complete (within tolerance), False otherwise
"""
# Get current pending targets (snapshot)
with self._pending_moves_lock:
pending = dict(self._pending_moves)
if not pending:
# No pending moves
return True
# Check if all axes are within tolerance
all_settled = True
completed_axes = []
# Query positions using get_position which properly waits for responses
# Use short timeout to avoid blocking too long
query_timeout = min(timeout, 0.5)
for dest, target in pending.items():
try:
# Get current position with timeout
current = self.get_position(dest, timeout=query_timeout)
if current is None:
# Position query failed or timed out - not settled
all_settled = False
continue
error = abs(current - target)
if error <= tolerance:
# This axis is settled
completed_axes.append(dest)
else:
# Still moving
all_settled = False
except Exception as e:
# Position query failed - assume not settled
print(f"Error querying position for axis 0x{dest:02X}: {e}")
all_settled = False
# Remove completed axes from pending list
if completed_axes:
with self._pending_moves_lock:
for dest in completed_axes:
if dest in self._pending_moves:
del self._pending_moves[dest]
return all_settled
def poll_until_idle_passive(self, tolerance: float = 0.005, debug: bool = False) -> bool:
"""
Check if all pending moves are complete using cached positions from automatic updates.
Unlike poll_until_idle(), this does NOT send position request commands. It only
reads the cached positions that are updated from automatic status updates
(MOT_GET_USTATUSUPDATE). This avoids command traffic that might interfere with
move execution.
Args:
tolerance: Position tolerance in mm (default 0.005mm = 5 microns)
debug: If True, print debug info about positions
Returns:
True if all pending moves are complete (within tolerance), False otherwise
"""
# Get current pending targets (snapshot)
with self._pending_moves_lock:
pending = dict(self._pending_moves)
if not pending:
return True
# Check if all axes are within tolerance using cached positions
all_settled = True
completed_axes = []
for dest, target in pending.items():
# Get cached position (updated by automatic status updates)
with self._position_lock:
current = self._stage_positions.get(dest)
axis_name = "X" if dest == self.DEST_X_AXIS else "Y"
if current is None:
# No cached position yet - not settled
if debug:
print(f" [poll] {axis_name}: no cached position")
all_settled = False
continue
error = abs(current - target)
if debug:
print(f" [poll] {axis_name}: current={current:.3f}, target={target:.3f}, error={error*1000:.1f}um")
if error <= tolerance:
completed_axes.append(dest)
else:
all_settled = False
# Remove completed axes from pending list
if completed_axes:
with self._pending_moves_lock:
for dest in completed_axes:
if dest in self._pending_moves:
del self._pending_moves[dest]
return all_settled
def clear_pending_moves(self) -> None:
"""
Clear all pending move targets.
This clears the internal tracking of pending moves. It does NOT stop the motors -
use stop_all_axes() if you want to halt motion.
"""
with self._pending_moves_lock:
self._pending_moves.clear()
# Status bit decoding methods
@staticmethod
def decode_status_bits(status_bits: int) -> MotorStatusBits:
"""
Decode status bits into a MotorStatusBits IntFlag.
Args:
status_bits: Raw 32-bit status bits value
Returns:
MotorStatusBits IntFlag with all active bits set
"""
return MotorStatusBits(status_bits)
@staticmethod
def get_status_description(status_bits: int) -> str:
"""
Get a human-readable description of all active status bits.
Args:
status_bits: Raw 32-bit status bits value
Returns:
Multi-line string describing all active status bits
"""
status = MotorStatusBits(status_bits)
descriptions = []
# Error conditions (highest priority)
if MotorStatusBits.COMMUTATIONERROR in status:
descriptions.append("ERROR: Motor commutation error - power cycle required")
if MotorStatusBits.OVERTEMP in status:
descriptions.append("ERROR: Overtemperature detected")
if MotorStatusBits.BUSVOLTFAULT in status:
descriptions.append("ERROR: Supply voltage too low")
if MotorStatusBits.OVERLOAD in status:
descriptions.append("ERROR: Motor overload/overcurrent")
if MotorStatusBits.ENCODERFAULT in status:
descriptions.append("ERROR: Encoder fault")
if MotorStatusBits.OVERCURRENT in status:
descriptions.append("ERROR: Continuous current limit exceeded")
if MotorStatusBits.ERROR in status:
descriptions.append("ERROR: Other error condition")
# Limit switches
if MotorStatusBits.CWHARDLIMIT in status:
descriptions.append("WARNING: Clockwise hard limit triggered")
if MotorStatusBits.CCWHARDLIMIT in status:
descriptions.append("WARNING: Counter-clockwise hard limit triggered")
if MotorStatusBits.CWSOFTLIMIT in status:
descriptions.append("WARNING: Clockwise software limit triggered")
if MotorStatusBits.CCWSOFTLIMIT in status:
descriptions.append("WARNING: Counter-clockwise software limit triggered")
if MotorStatusBits.POSITIONERROR in status:
descriptions.append("WARNING: Position error - outside tracking window")
# Motion state
if MotorStatusBits.HOMING in status:
descriptions.append("STATUS: Motor is homing")
elif MotorStatusBits.INMOTIONCW in status:
descriptions.append("STATUS: Moving clockwise")
elif MotorStatusBits.INMOTIONCCW in status:
descriptions.append("STATUS: Moving counter-clockwise")
elif MotorStatusBits.ACTIVE in status:
descriptions.append("STATUS: Executing motion command")
elif MotorStatusBits.SETTLED in status:
descriptions.append("STATUS: Settled at target position")
# Operational state
if MotorStatusBits.INITIALIZING in status:
descriptions.append("STATUS: Performing phase initialization")
if MotorStatusBits.HOMED in status:
descriptions.append("STATUS: Homed (position count valid)")
if MotorStatusBits.TRACKING in status:
descriptions.append("STATUS: Position within tracking window")
if MotorStatusBits.CONNECTED in status:
descriptions.append("STATUS: Motor recognized by controller")
if MotorStatusBits.ENABLED in status:
descriptions.append("STATUS: Motor output enabled")
if MotorStatusBits.POWEROK in status:
descriptions.append("STATUS: Power supply OK")
if not descriptions:
descriptions.append("STATUS: No status bits set")
return "\n".join(descriptions)
@staticmethod
def has_errors(status_bits: int) -> bool:
"""
Check if any error conditions are present in status bits.
Args:
status_bits: Raw 32-bit status bits value
Returns:
True if any error bits are set, False otherwise
"""
status = MotorStatusBits(status_bits)
error_flags = (
MotorStatusBits.OVERTEMP |
MotorStatusBits.BUSVOLTFAULT |
MotorStatusBits.COMMUTATIONERROR |
MotorStatusBits.OVERLOAD |
MotorStatusBits.ENCODERFAULT |
MotorStatusBits.OVERCURRENT |
MotorStatusBits.ERROR
)
return bool(status & error_flags)
@staticmethod
def is_in_motion(status_bits: int) -> bool:
"""
Check if motor is currently in motion.
Args:
status_bits: Raw 32-bit status bits value
Returns:
True if motor is moving, False otherwise
"""
status = MotorStatusBits(status_bits)
motion_flags = (
MotorStatusBits.INMOTIONCW |
MotorStatusBits.INMOTIONCCW |
MotorStatusBits.HOMING |
MotorStatusBits.ACTIVE
)
return bool(status & motion_flags)
@staticmethod
def is_settled(status_bits: int) -> bool:
"""
Check if motor is settled at target position.
Args:
status_bits: Raw 32-bit status bits value
Returns:
True if motor is settled, False otherwise
"""
status = MotorStatusBits(status_bits)
return MotorStatusBits.SETTLED in status
def __enter__(self):
self.connect()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.disconnect()
return False
if __name__ == '__main__':
# Example: monitor status updates and control channel enable state
def on_status_update(msg: AptMessage):
print(f"Status update: msg_id=0x{msg.msg_id:04X}, data={msg.data.hex() if msg.data else 'none'}")
def on_move_stopped(msg: AptMessage):
"""Callback for unexpected motor stop events."""
print(f"\n!!! MOVE STOPPED EVENT DETECTED !!!")
print(f" Source: 0x{msg.source:02X} ({'X-axis' if msg.source == 0x21 else 'Y-axis' if msg.source == 0x22 else 'Unknown'})")
# The status will already be parsed and stored in _move_stopped
# Note: You can access mc.move_stopped_x or mc.move_stopped_y properties
# after this callback to get the full stopped status including status bits
with MotionController() as mc:
# Register callbacks
mc.register_callback(MsgId.MOT_GET_DCSTATUSUPDATE, on_status_update)
mc.register_callback(MsgId.MOT_MOVE_STOPPED, on_move_stopped)
# Print hardware info
print("Hardware Info:")
print(f" Serial Number: {mc.get_serial_number()}")
print(f" Model: {mc.get_model()}")
print(f" Firmware: {mc.get_firmware_version()}")
print(f" Channels: {mc.get_num_channels()}")
print("\n=== Homing Test ===")
print("Homing X-axis...")
try:
print(" Enabling X-axis channel...")
mc.set_channel_enable_state(mc.DEST_X_AXIS, enabled=True)
time.sleep(0.5)
if mc.home_x_axis(timeout=20.0):
print(f" X-axis homed successfully")
print(f" Position: {mc.position_x} mm (encoder count: {mc.encoder_count_x})")
else:
print(f" X-axis homing timed out")
except Exception as e:
print(f" Error homing X-axis: {e}")
print("\nHoming Y-axis...")
try:
print(" Enabling Y-axis channel...")
mc.set_channel_enable_state(mc.DEST_Y_AXIS, enabled=True)
time.sleep(0.5)
if mc.home_y_axis(timeout=20.0):
print(f" Y-axis homed successfully")
print(f" Position: {mc.position_y} mm (encoder count: {mc.encoder_count_y})")
else:
print(f" Y-axis homing timed out")
except Exception as e:
print(f" Error homing Y-axis: {e}")
print("\n=== Jog Parameters Test ===")
print("Getting current jog parameters for X-axis...")
try:
jog_params = mc.get_jog_params(mc.DEST_X_AXIS, timeout=2.0)
if jog_params:
print(f" Jog mode: {jog_params['jog_mode']} ({'Continuous' if jog_params['jog_mode'] == JogMode.CONTINUOUS else 'Single Step'})")
print(f" Step size: {jog_params['step_size']:.3f} mm ({jog_params['step_size_counts']} counts)")
print(f" Min velocity: {jog_params['min_velocity']:.3f} mm/s")
print(f" Acceleration: {jog_params['acceleration']:.3f} mm/s²")
print(f" Max velocity: {jog_params['max_velocity']:.3f} mm/s")
print(f" Stop mode: {jog_params['stop_mode']} ({'Immediate' if jog_params['stop_mode'] == StopMode.IMMEDIATE else 'Controlled'})")
else:
print(f" Timeout getting jog parameters")
except Exception as e:
print(f" Error getting jog parameters: {e}")
print("\nUsing jog parameter properties:")
print(f" X-axis jog params: {mc.jog_params_x}")
print("\nSetting new jog parameters for X-axis...")
try:
# Set: single step mode, 1mm step, min_vel=0.5 mm/s, accel=50 mm/s², max_vel=10 mm/s, controlled stop
mc.set_jog_params(
mc.DEST_X_AXIS,
jog_mode=JogMode.SINGLE_STEP,
step_size=1.0,
min_velocity=0.5,
acceleration=50.0,
max_velocity=10.0,
stop_mode=StopMode.CONTROLLED
)
time.sleep(0.2)
# Read back the parameters
jog_params = mc.get_jog_params(mc.DEST_X_AXIS, timeout=2.0)
if jog_params:
print(f" New jog mode: {jog_params['jog_mode']} ({'Continuous' if jog_params['jog_mode'] == JogMode.CONTINUOUS else 'Single Step'})")
print(f" New step size: {jog_params['step_size']:.3f} mm")
print(f" New min velocity: {jog_params['min_velocity']:.3f} mm/s")
print(f" New acceleration: {jog_params['acceleration']:.3f} mm/s²")
print(f" New max velocity: {jog_params['max_velocity']:.3f} mm/s")
print(f" New stop mode: {jog_params['stop_mode']} ({'Immediate' if jog_params['stop_mode'] == StopMode.IMMEDIATE else 'Controlled'})")
else:
print(f" Timeout reading back jog parameters")
# Show cached properties updated
print(f"\n Cached properties after update:")
print(f" X jog params: {mc.jog_params_x}")
except Exception as e:
print(f" Error setting jog parameters: {e}")
print("\n=== Move Relative Parameters Test ===")
print("Getting current move relative parameters for X-axis...")
try:
move_rel_params = mc.get_move_rel_params(mc.DEST_X_AXIS, timeout=2.0)
if move_rel_params:
print(f" Relative distance: {move_rel_params['relative_distance']:.3f} mm ({move_rel_params['relative_distance_counts']} counts)")
else:
print(f" Timeout getting move relative parameters")
except Exception as e:
print(f" Error getting move relative parameters: {e}")
print("\nUsing move relative parameter properties:")
print(f" X-axis move rel params: {mc.move_rel_params_x}")
print(f" X relative distance: {mc.relative_distance_x} mm")
print("\nSetting new move relative parameters for X-axis...")
try:
# Set: relative distance of 5mm
mc.set_move_rel_params(mc.DEST_X_AXIS, relative_distance=5.0)
time.sleep(0.2)
# Read back the parameters
move_rel_params = mc.get_move_rel_params(mc.DEST_X_AXIS, timeout=2.0)
if move_rel_params:
print(f" New relative distance: {move_rel_params['relative_distance']:.3f} mm ({move_rel_params['relative_distance_counts']} counts)")
else:
print(f" Timeout reading back move relative parameters")
# Show cached properties updated
print(f"\n Cached properties after update:")
print(f" X relative distance: {mc.relative_distance_x} mm")
except Exception as e:
print(f" Error setting move relative parameters: {e}")
print("\nTesting negative relative move...")
try:
# Set: relative distance of -2.5mm
mc.set_move_rel_params(mc.DEST_X_AXIS, relative_distance=-2.5)
time.sleep(0.2)
# Read back the parameters
move_rel_params = mc.get_move_rel_params(mc.DEST_X_AXIS, timeout=2.0)
if move_rel_params:
print(f" New relative distance: {move_rel_params['relative_distance']:.3f} mm ({move_rel_params['relative_distance_counts']} counts)")
print(f" Cached X relative distance: {mc.relative_distance_x} mm")
else:
print(f" Timeout reading back move relative parameters")
except Exception as e:
print(f" Error setting negative move relative parameters: {e}")
print("\n=== Move Absolute Parameters Test ===")
print("Getting current move absolute parameters for X-axis...")
try:
move_abs_params = mc.get_move_abs_params(mc.DEST_X_AXIS, timeout=2.0)
if move_abs_params:
print(f" Absolute position: {move_abs_params['absolute_position']:.3f} mm ({move_abs_params['absolute_position_counts']} counts)")
else:
print(f" Timeout getting move absolute parameters")
except Exception as e:
print(f" Error getting move absolute parameters: {e}")
print("\nUsing move absolute parameter properties:")
print(f" X-axis move abs params: {mc.move_abs_params_x}")
print(f" X absolute position: {mc.absolute_position_x} mm")
print("\nSetting new move absolute parameters for X-axis...")
try:
# Set: absolute position of 10mm
mc.set_move_abs_params(mc.DEST_X_AXIS, absolute_position=10.0)
time.sleep(0.2)
# Read back the parameters
move_abs_params = mc.get_move_abs_params(mc.DEST_X_AXIS, timeout=2.0)
if move_abs_params:
print(f" New absolute position: {move_abs_params['absolute_position']:.3f} mm ({move_abs_params['absolute_position_counts']} counts)")
else:
print(f" Timeout reading back move absolute parameters")
# Show cached properties updated
print(f"\n Cached properties after update:")
print(f" X absolute position: {mc.absolute_position_x} mm")
except Exception as e:
print(f" Error setting move absolute parameters: {e}")
print("\nTesting different absolute position...")
try:
# Set: absolute position of 45mm (near the home position)
mc.set_move_abs_params(mc.DEST_X_AXIS, absolute_position=45.0)
time.sleep(0.2)
# Read back the parameters
move_abs_params = mc.get_move_abs_params(mc.DEST_X_AXIS, timeout=2.0)
if move_abs_params:
print(f" New absolute position: {move_abs_params['absolute_position']:.3f} mm ({move_abs_params['absolute_position_counts']} counts)")
print(f" Cached X absolute position: {mc.absolute_position_x} mm")
else:
print(f" Timeout reading back move absolute parameters")
except Exception as e:
print(f" Error setting absolute position: {e}")
print("\n=== Move Relative Execution Test ===")
print("Setting up relative move of 2mm for X-axis...")
try:
# First, set the relative move parameters
mc.set_move_rel_params(mc.DEST_X_AXIS, relative_distance=2.0)
time.sleep(0.2)
# Verify parameters were set
rel_params = mc.get_move_rel_params(mc.DEST_X_AXIS, timeout=2.0)
if rel_params:
print(f" Relative move parameters set: {rel_params['relative_distance']:.3f} mm")
else:
print(f" Failed to verify relative move parameters")
# Execute the relative move
print("\n Executing relative move...")
move_result = mc.move_relative(mc.DEST_X_AXIS, timeout=30.0)
if move_result:
print(f" Move completed successfully!")
print(f" Final position: {move_result['position']:.3f} mm ({move_result['position_counts']} counts)")
print(f" Final velocity: {move_result['velocity']} encoder units")
print(f" Motor current: {move_result['motor_current']}")
print(f" Status bits: 0x{move_result['status_bits']:08X}")
# Decode and display status bits
print(f"\n Status bit analysis:")
status_desc = MotionController.get_status_description(move_result['status_bits'])
for line in status_desc.split('\n'):
print(f" {line}")
print(f"\n Status bit checks:")
print(f" Has errors: {MotionController.has_errors(move_result['status_bits'])}")
print(f" Is in motion: {MotionController.is_in_motion(move_result['status_bits'])}")
print(f" Is settled: {MotionController.is_settled(move_result['status_bits'])}")
# Show cached property
print(f"\n Cached move completed status:")
print(f" X: {mc.move_completed_x}")
# Demonstrate status bit properties
print(f"\n Status bit properties (automatically updated):")
print(f" Raw status bits: 0x{mc.status_bits_x:08X}" if mc.status_bits_x else " Raw status bits: Not available")
print(f" Is enabled: {mc.is_enabled_x}")
print(f" Is homed: {mc.is_homed_x}")
print(f" Is in motion: {mc.is_in_motion_x}")
print(f" Is settled: {mc.is_settled_x}")
print(f" Is tracking: {mc.is_tracking_x}")
print(f" Is connected: {mc.is_connected_x}")
print(f" Power OK: {mc.power_ok_x}")
print(f" Is active: {mc.is_active_x}")
print(f" Has errors: {mc.has_errors_x}")
print(f" At CW limit: {mc.at_cw_limit_x}")
print(f" At CCW limit: {mc.at_ccw_limit_x}")
else:
print(f" Move timed out or failed")
except Exception as e:
print(f" Error executing relative move: {e}")
print("\n=== Status Bit Properties Summary ===")
print("X-axis status:")
if mc.status_bits_x:
print(f" Raw bits: 0x{mc.status_bits_x:08X}")
print(f" Enabled: {mc.is_enabled_x}, Homed: {mc.is_homed_x}, Connected: {mc.is_connected_x}")
print(f" In motion: {mc.is_in_motion_x}, Settled: {mc.is_settled_x}, Active: {mc.is_active_x}")
print(f" Has errors: {mc.has_errors_x}, Power OK: {mc.power_ok_x}")
else:
print(" No status available yet")
print("\nY-axis status:")
if mc.status_bits_y:
print(f" Raw bits: 0x{mc.status_bits_y:08X}")
print(f" Enabled: {mc.is_enabled_y}, Homed: {mc.is_homed_y}, Connected: {mc.is_connected_y}")
print(f" In motion: {mc.is_in_motion_y}, Settled: {mc.is_settled_y}, Active: {mc.is_active_y}")
print(f" Has errors: {mc.has_errors_y}, Power OK: {mc.power_ok_y}")
else:
print(" No status available yet")
print("\n=== Test Complete ===")
print("Waiting 1 second before exit to allow final messages...")
time.sleep(1.0)