fixed app to use new thorlabs driver
This commit is contained in:
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"scan_info": {
|
||||||
|
"friendly_name": "stest",
|
||||||
|
"waveform_prefix": "aaa",
|
||||||
|
"data_directory": "/opt/scanengine-3",
|
||||||
|
"timestamp": "2026-02-09T15:57:49.753094",
|
||||||
|
"scan_finished": true,
|
||||||
|
"completion_timestamp": "2026-02-09T15:57:58.301940"
|
||||||
|
},
|
||||||
|
"scan_parameters": {
|
||||||
|
"number_of_angles": 4,
|
||||||
|
"row_spacing_mm": 0.5,
|
||||||
|
"scan_type": "standalone"
|
||||||
|
},
|
||||||
|
"scan_area": {
|
||||||
|
"x_start_mm": 48.5,
|
||||||
|
"x_delta_mm": 15.83,
|
||||||
|
"y_start_mm": 42.17,
|
||||||
|
"y_delta_mm": -15.67,
|
||||||
|
"sample_size": "1.25\""
|
||||||
|
},
|
||||||
|
"scan_boxes": [
|
||||||
|
{
|
||||||
|
"angle_index": 0,
|
||||||
|
"angle_degrees": 0.0,
|
||||||
|
"start": [
|
||||||
|
48.5,
|
||||||
|
26.5
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
64.33,
|
||||||
|
42.17
|
||||||
|
],
|
||||||
|
"original_corners": [
|
||||||
|
[
|
||||||
|
48.5,
|
||||||
|
42.17
|
||||||
|
],
|
||||||
|
[
|
||||||
|
64.33,
|
||||||
|
42.17
|
||||||
|
],
|
||||||
|
[
|
||||||
|
64.33,
|
||||||
|
26.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
48.5,
|
||||||
|
26.5
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"angle_index": 1,
|
||||||
|
"angle_degrees": 45.0,
|
||||||
|
"start": [
|
||||||
|
45.3339,
|
||||||
|
24.3934
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
67.6077,
|
||||||
|
46.6673
|
||||||
|
],
|
||||||
|
"original_corners": [
|
||||||
|
[
|
||||||
|
45.3339,
|
||||||
|
35.4738
|
||||||
|
],
|
||||||
|
[
|
||||||
|
56.5274,
|
||||||
|
46.6673
|
||||||
|
],
|
||||||
|
[
|
||||||
|
67.6077,
|
||||||
|
35.5869
|
||||||
|
],
|
||||||
|
[
|
||||||
|
56.4142,
|
||||||
|
24.3934
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"angle_index": 2,
|
||||||
|
"angle_degrees": 90.0,
|
||||||
|
"start": [
|
||||||
|
47.83,
|
||||||
|
28.5
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
63.5,
|
||||||
|
44.33
|
||||||
|
],
|
||||||
|
"original_corners": [
|
||||||
|
[
|
||||||
|
47.83,
|
||||||
|
28.5
|
||||||
|
],
|
||||||
|
[
|
||||||
|
47.83,
|
||||||
|
44.33
|
||||||
|
],
|
||||||
|
[
|
||||||
|
63.5,
|
||||||
|
44.33
|
||||||
|
],
|
||||||
|
[
|
||||||
|
63.5,
|
||||||
|
28.5
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"angle_index": 3,
|
||||||
|
"angle_degrees": 135.0,
|
||||||
|
"start": [
|
||||||
|
43.3327,
|
||||||
|
25.3339
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
65.6066,
|
||||||
|
47.6077
|
||||||
|
],
|
||||||
|
"original_corners": [
|
||||||
|
[
|
||||||
|
54.5262,
|
||||||
|
25.3339
|
||||||
|
],
|
||||||
|
[
|
||||||
|
43.3327,
|
||||||
|
36.5274
|
||||||
|
],
|
||||||
|
[
|
||||||
|
54.4131,
|
||||||
|
47.6077
|
||||||
|
],
|
||||||
|
[
|
||||||
|
65.6066,
|
||||||
|
36.4142
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"scan_info": {
|
||||||
|
"friendly_name": "test2",
|
||||||
|
"waveform_prefix": "aaa",
|
||||||
|
"data_directory": "/opt/scanengine-3",
|
||||||
|
"timestamp": "2026-02-09T16:06:28.364606",
|
||||||
|
"scan_finished": true,
|
||||||
|
"completion_timestamp": "2026-02-09T16:27:37.121723"
|
||||||
|
},
|
||||||
|
"scan_parameters": {
|
||||||
|
"number_of_angles": 4,
|
||||||
|
"row_spacing_mm": 0.25,
|
||||||
|
"scan_type": "standalone"
|
||||||
|
},
|
||||||
|
"scan_area": {
|
||||||
|
"x_start_mm": 54.83,
|
||||||
|
"x_delta_mm": 9.83,
|
||||||
|
"y_start_mm": 43.83,
|
||||||
|
"y_delta_mm": -9.17,
|
||||||
|
"sample_size": "1.25\""
|
||||||
|
},
|
||||||
|
"scan_boxes": [
|
||||||
|
{
|
||||||
|
"angle_index": 0,
|
||||||
|
"angle_degrees": 0.0,
|
||||||
|
"start": [
|
||||||
|
54.83,
|
||||||
|
34.66
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
64.66,
|
||||||
|
43.83
|
||||||
|
],
|
||||||
|
"original_corners": [
|
||||||
|
[
|
||||||
|
54.83,
|
||||||
|
43.83
|
||||||
|
],
|
||||||
|
[
|
||||||
|
64.66,
|
||||||
|
43.83
|
||||||
|
],
|
||||||
|
[
|
||||||
|
64.66,
|
||||||
|
34.66
|
||||||
|
],
|
||||||
|
[
|
||||||
|
54.83,
|
||||||
|
34.66
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"angle_index": 1,
|
||||||
|
"angle_degrees": 45.0,
|
||||||
|
"start": [
|
||||||
|
48.636,
|
||||||
|
34.6394
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
62.0711,
|
||||||
|
48.0744
|
||||||
|
],
|
||||||
|
"original_corners": [
|
||||||
|
[
|
||||||
|
48.636,
|
||||||
|
41.1235
|
||||||
|
],
|
||||||
|
[
|
||||||
|
55.5869,
|
||||||
|
48.0744
|
||||||
|
],
|
||||||
|
[
|
||||||
|
62.0711,
|
||||||
|
41.5902
|
||||||
|
],
|
||||||
|
[
|
||||||
|
55.1202,
|
||||||
|
34.6394
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"angle_index": 2,
|
||||||
|
"angle_degrees": 90.0,
|
||||||
|
"start": [
|
||||||
|
46.17,
|
||||||
|
34.83
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
55.34,
|
||||||
|
44.66
|
||||||
|
],
|
||||||
|
"original_corners": [
|
||||||
|
[
|
||||||
|
46.17,
|
||||||
|
34.83
|
||||||
|
],
|
||||||
|
[
|
||||||
|
46.17,
|
||||||
|
44.66
|
||||||
|
],
|
||||||
|
[
|
||||||
|
55.34,
|
||||||
|
44.66
|
||||||
|
],
|
||||||
|
[
|
||||||
|
55.34,
|
||||||
|
34.83
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"angle_index": 3,
|
||||||
|
"angle_degrees": 135.0,
|
||||||
|
"start": [
|
||||||
|
41.9256,
|
||||||
|
28.636
|
||||||
|
],
|
||||||
|
"end": [
|
||||||
|
55.3606,
|
||||||
|
42.0711
|
||||||
|
],
|
||||||
|
"original_corners": [
|
||||||
|
[
|
||||||
|
48.8765,
|
||||||
|
28.636
|
||||||
|
],
|
||||||
|
[
|
||||||
|
41.9256,
|
||||||
|
35.5869
|
||||||
|
],
|
||||||
|
[
|
||||||
|
48.4098,
|
||||||
|
42.0711
|
||||||
|
],
|
||||||
|
[
|
||||||
|
55.3606,
|
||||||
|
35.1202
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+3
-3
@@ -11,8 +11,8 @@
|
|||||||
},
|
},
|
||||||
"scanning_stage": {
|
"scanning_stage": {
|
||||||
"scan_velocity_mm_s": "200",
|
"scan_velocity_mm_s": "200",
|
||||||
"scan_acceleration_mm_s2": "500",
|
"scan_acceleration_mm_s2": "1500",
|
||||||
"x_trigger_mode": 5,
|
"x_trigger_mode": 6,
|
||||||
"y_trigger_mode": 0,
|
"y_trigger_mode": 0,
|
||||||
"optical_axis_x_mm": "55",
|
"optical_axis_x_mm": "55",
|
||||||
"optical_axis_y_mm": "37.5"
|
"optical_axis_y_mm": "37.5"
|
||||||
@@ -25,4 +25,4 @@
|
|||||||
"scratch_directory": "/opt/",
|
"scratch_directory": "/opt/",
|
||||||
"save_location": "pc"
|
"save_location": "pc"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
"""Hardware driver modules for ScanEngine-3"""
|
"""Hardware driver modules for ScanEngine-3"""
|
||||||
from .bbd202 import *
|
from .pybbd202 import ThorlabsServoDriver, TriggerBitsServo, AXIS_X, AXIS_Y, CONTROLLER
|
||||||
from .uc480_camera import *
|
from .uc480_camera import *
|
||||||
from .tektronix_base import *
|
from .tektronix_base import *
|
||||||
from .coherent_hops_laser import *
|
from .coherent_hops_laser import *
|
||||||
|
|||||||
-3147
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,9 @@
|
|||||||
|
"""pybbd202 - Thorlabs BBD202 servo stage driver (pyserial-based)"""
|
||||||
|
|
||||||
|
from .bbd20x import ThorlabsServoDriver
|
||||||
|
from .apt_constants import TriggerBitsServo, StatusBits
|
||||||
|
|
||||||
|
# Axis address constants
|
||||||
|
AXIS_X = 0x21
|
||||||
|
AXIS_Y = 0x22
|
||||||
|
CONTROLLER = 0x11
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
'''
|
||||||
|
APT Constants and Registries
|
||||||
|
Thomas Ales | Feb 2026
|
||||||
|
'''
|
||||||
|
from enum import IntFlag
|
||||||
|
|
||||||
|
class StatusBits(IntFlag):
|
||||||
|
MOT_SB_CWHARDLIMIT = 0x01
|
||||||
|
MOT_SB_CCWHARDLIMIT = 0x02
|
||||||
|
MOT_SB_INMOTIONCW = 0x10
|
||||||
|
MOT_SB_INMOTIONCCW = 0x20
|
||||||
|
MOT_SB_HOMING = 0x200
|
||||||
|
MOT_SB_HOMED = 0x400
|
||||||
|
MOT_SB_TRACKING = 0x1000
|
||||||
|
MOT_SB_SETTLED = 0x2000
|
||||||
|
MOT_SB_POSITIONERROR = 0x4000
|
||||||
|
MOT_SB_INSTRERROR = 0x8000
|
||||||
|
MOT_SB_INTERLOCK = 0x10000
|
||||||
|
MOT_SB_OVERTEMP = 0x20000
|
||||||
|
MOT_SB_BUSVOLTFAULT = 0x40000
|
||||||
|
MOT_SB_COMMUTATIONERROR = 0x80000
|
||||||
|
MOT_SB_DIGIP1 = 0x100000
|
||||||
|
MOT_SB_OVERLOAD = 0x1000000
|
||||||
|
MOT_SB_POWEROK = 0x10000000
|
||||||
|
MOT_SB_ERROR = 0x40000000
|
||||||
|
MOT_SB_ENABLED = 0x80000000
|
||||||
|
|
||||||
|
# combined masks for checking various
|
||||||
|
# states
|
||||||
|
MOT_ANY_MOVE = MOT_SB_INMOTIONCW | MOT_SB_INMOTIONCCW
|
||||||
|
MOT_ANY_ERR = (MOT_SB_OVERTEMP | MOT_SB_BUSVOLTFAULT |
|
||||||
|
MOT_SB_COMMUTATIONERROR | MOT_SB_OVERLOAD |
|
||||||
|
MOT_SB_ERROR | MOT_SB_INSTRERROR)
|
||||||
|
|
||||||
|
class TriggerBitsStepper(IntFlag):
|
||||||
|
TRIGIN_ENABLE = 0x01,
|
||||||
|
TRIGOUT_ENABLE = 0x02,
|
||||||
|
TRIGOUT_MODEFOLLOW = 0x04,
|
||||||
|
TRIGOUT_MODEMOVEEND = 0x08,
|
||||||
|
TRIG_RELMOVE = 0x10,
|
||||||
|
TRIG_ABSMOVE = 0x20,
|
||||||
|
TRIG_HOMEMOVE = 0x40,
|
||||||
|
TRIGOUT_NOTRIGIN = 0x80
|
||||||
|
|
||||||
|
class TriggerBitsServo(IntFlag):
|
||||||
|
TRIGIN_HIGH = 0x01
|
||||||
|
TRIGIN_RELMOVE = 0x02
|
||||||
|
TRIGIN_ABSMOVE = 0x04
|
||||||
|
TRIGIN_HOMEMOVE = 0x08
|
||||||
|
TRIGOUT_HIGH = 0x10
|
||||||
|
TRIGOUT_INMOTION = 0x20
|
||||||
|
TRIGOUT_MOTIONCOMPLETE = 0x40
|
||||||
|
TRIGOUT_MAXVELOCITY = 0x80
|
||||||
|
|
||||||
|
TRIGOUT_MAXV = TRIGOUT_HIGH | TRIGOUT_MAXVELOCITY
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
'''
|
||||||
|
ThorLABS APT Protocol Message Registry
|
||||||
|
Thomas Ales | Feb 2026
|
||||||
|
Version 1
|
||||||
|
'''
|
||||||
|
import struct
|
||||||
|
from .apt_constants import StatusBits as sb
|
||||||
|
|
||||||
|
class APTProtocol():
|
||||||
|
ADDRESSES = { 'HOST_PC': 0x01, 'CONTROLLER': 0x11,
|
||||||
|
'X_AXIS': 0x21, 'Y_AXIS': 0x22
|
||||||
|
}
|
||||||
|
|
||||||
|
# N.B.: If the format is anything other than the following
|
||||||
|
# two, it is considered a 'long' message. In this case
|
||||||
|
# the format key refers only to the payload part of the
|
||||||
|
# message.
|
||||||
|
# BB - Short Message, Only Source/Destination
|
||||||
|
# BBBB - Short Message, Using Parameters 1 & 2
|
||||||
|
|
||||||
|
# Dictionary entries MUST BE in the order they
|
||||||
|
# appear in the thorlabs documentation, if you don't
|
||||||
|
# the unpacking logic goes all to 💩 and 'fun' things
|
||||||
|
# happen.
|
||||||
|
|
||||||
|
MSGS = {
|
||||||
|
0x0002: {
|
||||||
|
'name': 'MGMSG_HW_DISCONNECT',
|
||||||
|
'format': 'BB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0005: {
|
||||||
|
'name': 'MGMSG_HW_REQ_INFO',
|
||||||
|
'format': 'BB',
|
||||||
|
'response': 0x0006,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0006: {
|
||||||
|
'name': 'MGMSG_HW_GET_INFO',
|
||||||
|
'format': '<I8sH3B61xHHH',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': ['serial', 'model', 'type',
|
||||||
|
'fw_minor', 'fw_interim', 'fw_major',
|
||||||
|
'hw_ver', 'mod_state', 'num_channels']
|
||||||
|
},
|
||||||
|
0x0011: {
|
||||||
|
'name': 'MGMSG_HW_START_UPDATEMSGS',
|
||||||
|
'format': 'BB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0012: {
|
||||||
|
'name': 'MGMSG_HW_STOP_UPDATEMSGS',
|
||||||
|
'format': 'BB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0060: {
|
||||||
|
'name': 'MGMSG_RACK_REQ_BAYUSED',
|
||||||
|
'format': 'BBBB',
|
||||||
|
'response': 0x0061,
|
||||||
|
'fields': ['bay_id', 'destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0061: {
|
||||||
|
'name': 'MGMSG_RACK_GET_BAYUSED',
|
||||||
|
'format': 'BBBB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['bay_id', 'bay_state', 'destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0210: {
|
||||||
|
'name': 'MGMSG_MOD_SET_CHANENABLESTATE',
|
||||||
|
'format': 'BBBB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['chan_ident', 'enable_state', 'destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0211: {
|
||||||
|
'name': 'MGMSG_MOD_REQ_CHANENABLESTATE',
|
||||||
|
'format': 'BB',
|
||||||
|
'response': 0x0212,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0212: {
|
||||||
|
'name': 'MGMSG_MOD_GET_CHANENABLESTATE',
|
||||||
|
'format': 'BBBB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['chan_ident', 'enable_state', 'destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0413: {
|
||||||
|
'name': 'MGMSG_MOT_SET_VELPARAMS',
|
||||||
|
'format': '<Hlll',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': ['chan_ident', 'min_velocity', 'acceleration',
|
||||||
|
'max_velocity']
|
||||||
|
},
|
||||||
|
0x0414: {
|
||||||
|
'name': 'MGMSG_MOT_REQ_VELPARAMS',
|
||||||
|
'format': 'BBBB',
|
||||||
|
'response': 0x0415,
|
||||||
|
'fields': ['chan_ident', 'zero_this', 'destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0415: {
|
||||||
|
'name': 'MGMSG_MOT_GET_VELPARAMS',
|
||||||
|
'format': '<Hlll',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': ['chan_ident', 'min_velocity', 'acceleration',
|
||||||
|
'max_velocity']
|
||||||
|
},
|
||||||
|
0x0443: {
|
||||||
|
'name': 'MGMSG_MOT_MOVE_HOME',
|
||||||
|
'format': 'BBBB',
|
||||||
|
'response': 0x0444,
|
||||||
|
'fields': ['chan_ident', 'destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0444: {
|
||||||
|
'name': 'MGMSG_MOT_MOVE_HOMED',
|
||||||
|
'format': 'BBBB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['chan_ident', 'destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0448: {
|
||||||
|
'name': 'MGMSG_MOT_MOVE_RELATIVE',
|
||||||
|
'format': '<Hl',
|
||||||
|
'response': 0x0464,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': ['chan_ident', 'relative_distance']
|
||||||
|
},
|
||||||
|
0x0453: {
|
||||||
|
'name': 'MGMSG_MOT_MOVE_ABSOLUTE',
|
||||||
|
'format': '<Hl',
|
||||||
|
'response': 0x0464,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': ['chan_ident', 'absolute_distance']
|
||||||
|
},
|
||||||
|
0x0464: {
|
||||||
|
'name': 'MGMSG_MOT_MOVE_COMPLETED',
|
||||||
|
'format': '<Hl2HI',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': ['chan_ident', 'position', 'velocity',
|
||||||
|
'motor_current', 'status_bits']
|
||||||
|
},
|
||||||
|
0x0490: {
|
||||||
|
'name': 'MGMSG_MOT_REQ_USTATUSUPDATE',
|
||||||
|
'format': 'BB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0491: {
|
||||||
|
'name': 'MGMSG_MOT_GET_USTATUSUPDATE',
|
||||||
|
'format': '<Hl2HI',
|
||||||
|
'response': 0x0492,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': ['chan_ident', 'position',
|
||||||
|
'velocity', 'motor_current', 'status_bits']
|
||||||
|
},
|
||||||
|
0x0492: {
|
||||||
|
'name': 'MGMSG_MOT_ACK_USTATUSUPDATE',
|
||||||
|
'format': 'BB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0500: {
|
||||||
|
'name': 'MGMSG_MOT_SET_TRIGGER',
|
||||||
|
'format': 'BBBB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['chan_ident', 'mode', 'destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0501: {
|
||||||
|
'name': 'MGMSG_MOT_REQ_TRIGGER',
|
||||||
|
'format': 'BBBB',
|
||||||
|
'response': 0x0502,
|
||||||
|
'fields': ['chan_ident', 'mode', 'destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
},
|
||||||
|
0x0502: {
|
||||||
|
'name': 'MGMSG_MOT_GET_TRIGGER',
|
||||||
|
'format': 'BBBB',
|
||||||
|
'response': None,
|
||||||
|
'fields': ['chan_ident', 'mode', 'destination', 'source'],
|
||||||
|
'data_fields': None
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def build_message(cls, msg_id, **kwargs):
|
||||||
|
if msg_id not in APTProtocol.MSGS:
|
||||||
|
raise ValueError(f'Unknown Message ID: {hex(msg_id)}')
|
||||||
|
|
||||||
|
msg_spec = APTProtocol.MSGS[msg_id]
|
||||||
|
|
||||||
|
for param in msg_spec['fields']:
|
||||||
|
if param not in kwargs:
|
||||||
|
raise ValueError(f"Missing required parameter '{param}' for {msg_spec['name']}.")
|
||||||
|
|
||||||
|
msg_format = msg_spec.get('format')
|
||||||
|
|
||||||
|
if msg_format == 'BB':
|
||||||
|
# this is a simple source/destination command
|
||||||
|
dest = kwargs['destination']
|
||||||
|
src = kwargs['source']
|
||||||
|
message = struct.pack('<HBBBB', msg_id,
|
||||||
|
0x00, 0x00,
|
||||||
|
dest, src)
|
||||||
|
|
||||||
|
elif msg_format == 'BBBB':
|
||||||
|
# this is a source/destination plus parameters
|
||||||
|
# fields order: [param1, param2, destination, source] or
|
||||||
|
# [param1, destination, source] (param2 = 0)
|
||||||
|
fields = msg_spec['fields']
|
||||||
|
dest = kwargs['destination']
|
||||||
|
src = kwargs['source']
|
||||||
|
# Get param fields (everything except destination/source)
|
||||||
|
param_fields = [f for f in fields if f not in ('destination', 'source')]
|
||||||
|
p1 = kwargs.get(param_fields[0], 0x00) if len(param_fields) > 0 else 0x00
|
||||||
|
p2 = kwargs.get(param_fields[1], 0x00) if len(param_fields) > 1 else 0x00
|
||||||
|
message = struct.pack('<HBBBB', msg_id,
|
||||||
|
p1, p2, dest, src)
|
||||||
|
|
||||||
|
else:
|
||||||
|
data_fields = msg_spec.get('data_fields')
|
||||||
|
# verify data fields are available
|
||||||
|
if data_fields is None:
|
||||||
|
raise ValueError("I need data for a long message!")
|
||||||
|
|
||||||
|
# Extract the message specific values and
|
||||||
|
# calculate payload size
|
||||||
|
df_values = [kwargs[field] for field in data_fields]
|
||||||
|
data_payload = struct.pack(msg_format, *df_values)
|
||||||
|
payload_len = len(data_payload)
|
||||||
|
header = struct.pack('<HHBB', msg_id, payload_len,
|
||||||
|
kwargs['destination'] | 0x80,
|
||||||
|
kwargs['source'])
|
||||||
|
# put message together
|
||||||
|
message = header + data_payload
|
||||||
|
return message
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def unpack_message(cls, bdata):
|
||||||
|
# Get the message ID
|
||||||
|
msg_id = struct.unpack("<H", bdata[0:2])[0]
|
||||||
|
|
||||||
|
# See if the message ID has been implemented
|
||||||
|
# in the registry
|
||||||
|
if msg_id not in APTProtocol.MSGS:
|
||||||
|
raise ValueError(f"This op-code {msg_id} isn't in the registry!")
|
||||||
|
|
||||||
|
msg_spec = APTProtocol.MSGS[msg_id]
|
||||||
|
fmt_string = msg_spec.get('format')
|
||||||
|
# Check if it's a long message, or just a header
|
||||||
|
if bdata[4] & 0x80:
|
||||||
|
# this is a long message.
|
||||||
|
payload_size = struct.unpack("<H", bdata[2:4])[0]
|
||||||
|
src = bdata[5]
|
||||||
|
dest = bdata[4] & 0x7F
|
||||||
|
payload = bdata[6:6+payload_size]
|
||||||
|
data_fields = msg_spec.get('data_fields')
|
||||||
|
if data_fields is None:
|
||||||
|
raise ValueError(f"No data fields have been defined for {msg_spec['name']}!")
|
||||||
|
|
||||||
|
unpacked_payload = struct.unpack(fmt_string, payload)
|
||||||
|
data = {field: value for field, value in zip(data_fields,
|
||||||
|
unpacked_payload)}
|
||||||
|
data['destination'] = dest
|
||||||
|
data['source'] = src
|
||||||
|
|
||||||
|
return msg_id, data
|
||||||
|
|
||||||
|
else:
|
||||||
|
# it is a header only message
|
||||||
|
# determine type
|
||||||
|
dest = bdata[4]
|
||||||
|
src = bdata[5]
|
||||||
|
if fmt_string == 'BB':
|
||||||
|
# simple source/dest command
|
||||||
|
data = {'destination': dest, 'source': src}
|
||||||
|
return msg_id, data
|
||||||
|
|
||||||
|
elif fmt_string == 'BBBB':
|
||||||
|
# simple command with parameters
|
||||||
|
# Use field names from spec
|
||||||
|
fields = msg_spec['fields']
|
||||||
|
param_fields = [f for f in fields if f not in ('destination', 'source')]
|
||||||
|
data = {'destination': dest, 'source': src}
|
||||||
|
if len(param_fields) > 0:
|
||||||
|
data[param_fields[0]] = bdata[2]
|
||||||
|
if len(param_fields) > 1:
|
||||||
|
data[param_fields[1]] = bdata[3]
|
||||||
|
return msg_id, data
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Message format {fmt_string} isn't defined/valid!")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_name(cls, msg_id):
|
||||||
|
return cls.MSGS.get(msg_id, {}).get('name', f'UNKNOWN_{hex(msg_id)}')
|
||||||
|
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
'''
|
||||||
|
BBD20X Stage Driver for SRAS
|
||||||
|
Thomas Ales | Feb 2026
|
||||||
|
Version 2
|
||||||
|
'''
|
||||||
|
|
||||||
|
import time
|
||||||
|
from threading import Thread, Event
|
||||||
|
from queue import Queue, Empty
|
||||||
|
from .apt_constants import StatusBits, TriggerBitsServo
|
||||||
|
from .apt_messages import APTProtocol
|
||||||
|
from .serial_comms import SerialSnooper
|
||||||
|
|
||||||
|
|
||||||
|
class ThorlabsServoDriver():
|
||||||
|
# These are specific to the MLS203-1
|
||||||
|
# change for a different application
|
||||||
|
counts_per_mm = 20000
|
||||||
|
accel_scaling = 13.744
|
||||||
|
velocity_scaling = 134217.73
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.am_connected = False
|
||||||
|
self.am_enabled = [False, False]
|
||||||
|
self.am_homed = [False, False]
|
||||||
|
self.am_moving = [False, False]
|
||||||
|
self.am_error = [False, False]
|
||||||
|
self.serial_port = "/dev/ttyUSB1"
|
||||||
|
self.serial_spd = 115200
|
||||||
|
self.max_velocities = [100.0, 100.0] # mm/s
|
||||||
|
self.max_accels = [500.0, 500.0] # mm/s2
|
||||||
|
self.positions = [-1.0, -1.0]
|
||||||
|
self.act_velocities = [-1.0, -1.0]
|
||||||
|
self.current_demand = [0, 0]
|
||||||
|
self.serial_snoop = None
|
||||||
|
self.am_listening = False
|
||||||
|
self.pending_responses = {} # msg_id -> {'event': Event, 'data': None}
|
||||||
|
self.bays_present = [] # list of bay addresses that responded
|
||||||
|
self._tx_queue = Queue()
|
||||||
|
self._polling_active = False
|
||||||
|
self._poll_interval = 0.2 # seconds between poll cycles
|
||||||
|
|
||||||
|
def connect(self, port=None, spd=None):
|
||||||
|
if port is None:
|
||||||
|
port = self.serial_port
|
||||||
|
else:
|
||||||
|
self.serial_port = port
|
||||||
|
|
||||||
|
if spd is None:
|
||||||
|
spd = self.serial_spd
|
||||||
|
else:
|
||||||
|
self.serial_spd = spd
|
||||||
|
|
||||||
|
self.serial_snoop = SerialSnooper(port, spd)
|
||||||
|
self.serial_snoop.start()
|
||||||
|
self.am_listening = True
|
||||||
|
|
||||||
|
# Start worker threads
|
||||||
|
self._tx_thread = Thread(target=self._tx_worker, daemon=True)
|
||||||
|
self._tx_thread.start()
|
||||||
|
self._rx_thread = Thread(target=self._rx_worker, daemon=True)
|
||||||
|
self._rx_thread.start()
|
||||||
|
self._poll_thread = Thread(target=self._poll_worker, daemon=True)
|
||||||
|
self._poll_thread.start()
|
||||||
|
|
||||||
|
# Give threads time to start
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
# Query which bays are present
|
||||||
|
self.bays_present = []
|
||||||
|
for bay_id in range(10): # bays 0-9
|
||||||
|
try:
|
||||||
|
result = self.send_and_wait(0x0060, timeout=0.5,
|
||||||
|
bay_id=bay_id,
|
||||||
|
destination=0x11,
|
||||||
|
source=0x01)
|
||||||
|
if result and result.get('bay_state') == 0x01:
|
||||||
|
bay_addr = 0x21 + bay_id
|
||||||
|
self.bays_present.append(bay_addr)
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
# Bay not present or not responding
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not self.bays_present:
|
||||||
|
print(" [WARN] No bays detected!")
|
||||||
|
|
||||||
|
self.am_connected = True
|
||||||
|
|
||||||
|
# ── Worker threads ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def _tx_worker(self):
|
||||||
|
'''Single serial writer. All outbound messages flow through
|
||||||
|
_tx_queue so there are no write races on the serial port.'''
|
||||||
|
while self.am_listening:
|
||||||
|
try:
|
||||||
|
msg = self._tx_queue.get(timeout=0.05)
|
||||||
|
self.serial_snoop.serial_connection.write(msg)
|
||||||
|
self.serial_snoop.serial_connection.flush()
|
||||||
|
except Empty:
|
||||||
|
continue
|
||||||
|
except (OSError, TypeError):
|
||||||
|
break
|
||||||
|
|
||||||
|
def _rx_worker(self):
|
||||||
|
'''Listens for messages on the serial RX queue and dispatches them.'''
|
||||||
|
while self.am_listening:
|
||||||
|
try:
|
||||||
|
_msg = self.serial_snoop.rx_msg_queue.get(timeout=0.05)
|
||||||
|
|
||||||
|
# Try to parse the message, skip if unknown
|
||||||
|
try:
|
||||||
|
_msgid, data = APTProtocol.unpack_message(_msg)
|
||||||
|
except ValueError:
|
||||||
|
print(f" [WARN] Unknown message: {_msg.hex()}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if someone is waiting for this response
|
||||||
|
if _msgid in self.pending_responses:
|
||||||
|
self.pending_responses[_msgid]['data'] = data
|
||||||
|
self.pending_responses[_msgid]['event'].set()
|
||||||
|
|
||||||
|
# Status update → ACK via TX queue, then update state
|
||||||
|
if _msgid == 0x0491:
|
||||||
|
if self.am_listening:
|
||||||
|
_ack = APTProtocol.build_message(0x0492, source=0x01,
|
||||||
|
destination=_msg[5])
|
||||||
|
self._tx_queue.put(_ack)
|
||||||
|
self._update0x491(data)
|
||||||
|
# Move completed → update state
|
||||||
|
elif _msgid == 0x0464:
|
||||||
|
self._update0x0464(data)
|
||||||
|
except Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def _poll_worker(self):
|
||||||
|
'''Periodically sends REQ_USTATUSUPDATE (0x0490) to each bay.
|
||||||
|
The 0x0491 responses are ACKed by _rx_worker through the
|
||||||
|
TX queue, which keeps the controller's comms watchdog alive.'''
|
||||||
|
while self.am_listening:
|
||||||
|
if self._polling_active:
|
||||||
|
for axis_addr in (self.bays_present or [0x21, 0x22]):
|
||||||
|
if not self.am_listening:
|
||||||
|
break
|
||||||
|
msg = APTProtocol.build_message(0x0490, source=0x01,
|
||||||
|
destination=axis_addr)
|
||||||
|
self._tx_queue.put(msg)
|
||||||
|
time.sleep(self._poll_interval)
|
||||||
|
else:
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
# ── Polling control ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def start_polling(self, interval=0.2):
|
||||||
|
'''Start periodic status polling (interval in seconds).'''
|
||||||
|
self._poll_interval = interval
|
||||||
|
self._polling_active = True
|
||||||
|
|
||||||
|
def stop_polling(self):
|
||||||
|
'''Stop periodic status polling.'''
|
||||||
|
self._polling_active = False
|
||||||
|
|
||||||
|
# ── Core messaging ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def send_and_wait(self, msg_id, timeout=10.0, retries=1, **kwargs):
|
||||||
|
"""Send a message and wait for its expected response.
|
||||||
|
On timeout, drains serial buffer and retries up to `retries` times."""
|
||||||
|
msg_spec = APTProtocol.MSGS.get(msg_id)
|
||||||
|
if not msg_spec:
|
||||||
|
raise ValueError(f"Unknown message: {hex(msg_id)}")
|
||||||
|
|
||||||
|
expected = msg_spec.get('response')
|
||||||
|
msg = APTProtocol.build_message(msg_id, **kwargs)
|
||||||
|
|
||||||
|
for attempt in range(1 + retries):
|
||||||
|
# Set up listener before sending
|
||||||
|
if expected:
|
||||||
|
evt = Event()
|
||||||
|
self.pending_responses[expected] = {'event': evt, 'data': None}
|
||||||
|
|
||||||
|
self._tx_queue.put(msg)
|
||||||
|
|
||||||
|
if not expected:
|
||||||
|
return None # no response expected
|
||||||
|
|
||||||
|
# Wait for response
|
||||||
|
if evt.wait(timeout=timeout):
|
||||||
|
data = self.pending_responses[expected]['data']
|
||||||
|
del self.pending_responses[expected]
|
||||||
|
return data
|
||||||
|
else:
|
||||||
|
del self.pending_responses[expected]
|
||||||
|
if attempt < retries:
|
||||||
|
# Drain serial input buffer and message queue, then retry
|
||||||
|
self.serial_snoop.serial_connection.reset_input_buffer()
|
||||||
|
time.sleep(0.05)
|
||||||
|
while not self.serial_snoop.rx_msg_queue.empty():
|
||||||
|
try:
|
||||||
|
self.serial_snoop.rx_msg_queue.get_nowait()
|
||||||
|
except Empty:
|
||||||
|
break
|
||||||
|
print(f" [RETRY] {APTProtocol.get_name(msg_id)} attempt {attempt+2}")
|
||||||
|
|
||||||
|
raise TimeoutError(f"Timeout waiting for {hex(expected)}")
|
||||||
|
|
||||||
|
def send_message(self, msg_id, **kwargs):
|
||||||
|
'''Build and queue a message for transmission (fire-and-forget).'''
|
||||||
|
msg = APTProtocol.build_message(msg_id, **kwargs)
|
||||||
|
self._tx_queue.put(msg)
|
||||||
|
|
||||||
|
# ── Connection management ────────────────────────────────────
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
if (self.serial_snoop and
|
||||||
|
self.am_listening is True):
|
||||||
|
# Stop polling first
|
||||||
|
self._polling_active = False
|
||||||
|
# Queue disconnect messages for the TX worker to send
|
||||||
|
for addr in [0x11, 0x21, 0x22]:
|
||||||
|
self._tx_queue.put(
|
||||||
|
APTProtocol.build_message(0x0002, destination=addr,
|
||||||
|
source=0x01))
|
||||||
|
time.sleep(0.2) # let TX worker flush them out
|
||||||
|
# Stop all worker loops, then wait for threads to exit
|
||||||
|
self.am_listening = False
|
||||||
|
self.serial_snoop.stop()
|
||||||
|
self._rx_thread.join()
|
||||||
|
self._tx_thread.join()
|
||||||
|
self._poll_thread.join()
|
||||||
|
self.serial_snoop.join()
|
||||||
|
# Close port only after all threads are done
|
||||||
|
self.serial_snoop.close()
|
||||||
|
|
||||||
|
# ── State update handlers ────────────────────────────────────
|
||||||
|
|
||||||
|
def _update0x491(self, msg):
|
||||||
|
'''
|
||||||
|
_update0x491 - Internal function for handling USTATUSUPDATE
|
||||||
|
messages and updating the data for that particular axis sending
|
||||||
|
the message.
|
||||||
|
'''
|
||||||
|
if msg['source'] == 0x21:
|
||||||
|
ch = 0
|
||||||
|
elif msg['source'] == 0x22:
|
||||||
|
ch = 1
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.positions[ch] = msg['position'] / self.counts_per_mm
|
||||||
|
self.act_velocities[ch] = msg['velocity'] / self.velocity_scaling
|
||||||
|
self.current_demand[ch] = msg['motor_current']
|
||||||
|
|
||||||
|
if(msg['status_bits'] & StatusBits.MOT_ANY_ERR):
|
||||||
|
self.am_error[ch] = True
|
||||||
|
else:
|
||||||
|
self.am_error[ch] = False
|
||||||
|
|
||||||
|
if(msg['status_bits'] & StatusBits.MOT_ANY_MOVE):
|
||||||
|
self.am_moving[ch] = True
|
||||||
|
else:
|
||||||
|
self.am_moving[ch] = False
|
||||||
|
|
||||||
|
if(msg['status_bits'] & StatusBits.MOT_SB_HOMED):
|
||||||
|
self.am_homed[ch] = True
|
||||||
|
|
||||||
|
def _update0x0464(self, msg):
|
||||||
|
'''
|
||||||
|
_update0x0464 - internal function for MOVE_COMPLETED messages.
|
||||||
|
Updates position, velocity, and moving state.
|
||||||
|
'''
|
||||||
|
if msg['source'] == 0x21:
|
||||||
|
ch = 0
|
||||||
|
elif msg['source'] == 0x22:
|
||||||
|
ch = 1
|
||||||
|
else:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.positions[ch] = msg['position'] / self.counts_per_mm
|
||||||
|
self.act_velocities[ch] = msg['velocity'] / self.velocity_scaling
|
||||||
|
self.current_demand[ch] = msg['motor_current']
|
||||||
|
self.am_moving[ch] = False
|
||||||
|
return
|
||||||
|
|
||||||
|
def _update0x0212(self, msg):
|
||||||
|
'''
|
||||||
|
_update0x0212 - internal function that listens for CHANENABLESTATE
|
||||||
|
messages.
|
||||||
|
'''
|
||||||
|
if msg['source'] == 0x21:
|
||||||
|
ch = 0
|
||||||
|
elif msg['source'] == 0x22:
|
||||||
|
ch = 1
|
||||||
|
else:
|
||||||
|
raise ValueError("Wherever this message came from, it's WRONG!")
|
||||||
|
|
||||||
|
if msg['enable_state'] == 0x01:
|
||||||
|
self.am_enabled[ch] = True # enabled
|
||||||
|
elif msg['enable_state'] == 0x02:
|
||||||
|
self.am_enabled[ch] = False # disabled
|
||||||
|
else:
|
||||||
|
raise ValueError("Am I a joke to you? WTF did this even come from?!")
|
||||||
|
|
||||||
|
# ── Axis control ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def enable_axis(self, axis):
|
||||||
|
'''Enable the specified axis (0x21 = X, 0x22 = Y).'''
|
||||||
|
self.send_message(0x0210, chan_ident=1, enable_state=0x01,
|
||||||
|
destination=axis, source=0x01)
|
||||||
|
|
||||||
|
def disable_axis(self, axis):
|
||||||
|
'''Disable the specified axis (0x21 = X, 0x22 = Y).'''
|
||||||
|
self.send_message(0x0210, chan_ident=1, enable_state=0x02,
|
||||||
|
destination=axis, source=0x01)
|
||||||
|
|
||||||
|
def toggle_enabled_state(self, axis):
|
||||||
|
'''
|
||||||
|
toggle_enabled_state(axis) - enables the axis if disabled. disables
|
||||||
|
if enabled. not much more to it.
|
||||||
|
'''
|
||||||
|
if axis == 0x21:
|
||||||
|
ch = 0
|
||||||
|
elif axis == 0x22:
|
||||||
|
ch = 1
|
||||||
|
else:
|
||||||
|
raise ValueError("I don't know that axis!")
|
||||||
|
# get the old state and flip it like a sample
|
||||||
|
new_state = not self.am_enabled[ch]
|
||||||
|
self.send_message(0x0210, chan_ident=1,
|
||||||
|
enable_state=0x01 if new_state else 0x02,
|
||||||
|
destination=axis, source=0x01)
|
||||||
|
|
||||||
|
def home_axis(self, axis, timeout=60.0):
|
||||||
|
'''
|
||||||
|
home_axis(axis, timeout=60): Blocking home command. Required at
|
||||||
|
power up. Default timeout is 60s, but 20-30s is fine as well if
|
||||||
|
you're in that much of a hurry.
|
||||||
|
'''
|
||||||
|
if axis == 0x21:
|
||||||
|
ch = 0
|
||||||
|
elif axis == 0x22:
|
||||||
|
ch = 1
|
||||||
|
else:
|
||||||
|
raise ValueError("I don't know that axis!")
|
||||||
|
|
||||||
|
self.send_and_wait(0x0443, timeout=timeout, chan_ident=1,
|
||||||
|
destination=axis, source=0x01)
|
||||||
|
return
|
||||||
|
|
||||||
|
def move_axis_relative(self, axis, distance_in_mm, timeout=10.0):
|
||||||
|
'''
|
||||||
|
move_axis_relative(axis, distance_in_mm, timeout=10):
|
||||||
|
moves the specified axis a specified distance in mm.
|
||||||
|
Timeout defaults to ten seconds.
|
||||||
|
'''
|
||||||
|
# sanity check
|
||||||
|
if axis == 0x21 and abs(distance_in_mm) > 110.0:
|
||||||
|
raise ValueError("You can't move farther than the stage is long.")
|
||||||
|
elif axis == 0x22 and abs(distance_in_mm) > 75.0:
|
||||||
|
raise ValueError("You can't move farther than the stage is wide.")
|
||||||
|
|
||||||
|
_distance_in_encoder = int(round(distance_in_mm * self.counts_per_mm))
|
||||||
|
self.send_and_wait(0x0448, timeout=timeout, chan_ident=1,
|
||||||
|
relative_distance=_distance_in_encoder,
|
||||||
|
destination=axis, source=0x01)
|
||||||
|
return
|
||||||
|
|
||||||
|
def move_axis_absolute(self, axis, position_in_mm, timeout=10.0):
|
||||||
|
'''
|
||||||
|
move_axis_absolute(axis, position_in_mm, timeout=10):
|
||||||
|
moves the specified axis to an absolute position in mm.
|
||||||
|
Timeout defaults to ten seconds.
|
||||||
|
'''
|
||||||
|
if axis == 0x21 and (position_in_mm < 0.0 or position_in_mm > 110.0):
|
||||||
|
raise ValueError("Position out of range for X axis (0-110 mm).")
|
||||||
|
elif axis == 0x22 and (position_in_mm < 0.0 or position_in_mm > 75.0):
|
||||||
|
raise ValueError("Position out of range for Y axis (0-75 mm).")
|
||||||
|
|
||||||
|
_position_in_encoder = int(round(position_in_mm * self.counts_per_mm))
|
||||||
|
self.send_and_wait(0x0453, timeout=timeout, chan_ident=1,
|
||||||
|
absolute_distance=_position_in_encoder,
|
||||||
|
destination=axis, source=0x01)
|
||||||
|
return
|
||||||
|
|
||||||
|
# ── Velocity parameters ──────────────────────────────────────
|
||||||
|
|
||||||
|
def get_velocity_params(self, axis, timeout=5.0):
|
||||||
|
'''
|
||||||
|
get_velocity_params(axis): Queries the current velocity parameters
|
||||||
|
for the specified axis. Returns a dict with keys:
|
||||||
|
min_velocity (mm/s), acceleration (mm/s2), max_velocity (mm/s)
|
||||||
|
'''
|
||||||
|
if axis == 0x21:
|
||||||
|
ch = 0
|
||||||
|
elif axis == 0x22:
|
||||||
|
ch = 1
|
||||||
|
else:
|
||||||
|
raise ValueError("I don't know that axis!")
|
||||||
|
|
||||||
|
result = self.send_and_wait(0x0414, timeout=timeout, chan_ident=1,
|
||||||
|
zero_this=0x00, destination=axis,
|
||||||
|
source=0x01)
|
||||||
|
params = {
|
||||||
|
'min_velocity': result['min_velocity'] / self.velocity_scaling,
|
||||||
|
'acceleration': result['acceleration'] / self.accel_scaling,
|
||||||
|
'max_velocity': result['max_velocity'] / self.velocity_scaling,
|
||||||
|
}
|
||||||
|
|
||||||
|
self.max_velocities[ch] = params['max_velocity']
|
||||||
|
self.max_accels[ch] = params['acceleration']
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
def set_velocity_params(self, axis, max_velocity=None, acceleration=None):
|
||||||
|
'''
|
||||||
|
set_velocity_params(axis, max_velocity=None, acceleration=None):
|
||||||
|
Sets velocity and/or acceleration for the specified axis.
|
||||||
|
Values are in mm/s and mm/s2 respectively. Any parameter
|
||||||
|
left as None keeps its current value.
|
||||||
|
'''
|
||||||
|
if axis == 0x21:
|
||||||
|
ch = 0
|
||||||
|
elif axis == 0x22:
|
||||||
|
ch = 1
|
||||||
|
else:
|
||||||
|
raise ValueError("I don't know that axis!")
|
||||||
|
|
||||||
|
# Only query current params if we need to fill in a missing value
|
||||||
|
if max_velocity is None or acceleration is None:
|
||||||
|
current = self.get_velocity_params(axis)
|
||||||
|
if max_velocity is None:
|
||||||
|
max_velocity = current['max_velocity']
|
||||||
|
if acceleration is None:
|
||||||
|
acceleration = current['acceleration']
|
||||||
|
|
||||||
|
# Update the cached values
|
||||||
|
self.max_velocities[ch] = max_velocity
|
||||||
|
self.max_accels[ch] = acceleration
|
||||||
|
|
||||||
|
_min_v = 0
|
||||||
|
_accel = int(round(acceleration * self.accel_scaling))
|
||||||
|
_max_v = int(round(max_velocity * self.velocity_scaling))
|
||||||
|
|
||||||
|
self.send_message(0x0413, chan_ident=1,
|
||||||
|
min_velocity=_min_v,
|
||||||
|
acceleration=_accel,
|
||||||
|
max_velocity=_max_v,
|
||||||
|
destination=axis, source=0x01)
|
||||||
|
|
||||||
|
# ── Trigger control ───────────────────────────────────────
|
||||||
|
|
||||||
|
def set_trigger(self, axis, mode):
|
||||||
|
'''
|
||||||
|
set_trigger(axis, mode): Sets the trigger mode for the specified
|
||||||
|
axis. Mode should be a TriggerBitsServo value or combination.
|
||||||
|
'''
|
||||||
|
self.send_message(0x0500, chan_ident=1, mode=int(mode),
|
||||||
|
destination=axis, source=0x01)
|
||||||
|
|
||||||
|
def get_trigger(self, axis, timeout=5.0):
|
||||||
|
'''
|
||||||
|
get_trigger(axis): Queries the current trigger mode for the
|
||||||
|
specified axis. Returns the mode byte as a TriggerBitsServo.
|
||||||
|
'''
|
||||||
|
result = self.send_and_wait(0x0501, timeout=timeout,
|
||||||
|
chan_ident=1, mode=0x00,
|
||||||
|
destination=axis, source=0x01)
|
||||||
|
return TriggerBitsServo(result['mode'])
|
||||||
|
|
||||||
|
def set_trigger_trigin_high(self, axis):
|
||||||
|
'''Set trigger input to logic high.'''
|
||||||
|
self.set_trigger(axis, TriggerBitsServo.TRIGIN_HIGH)
|
||||||
|
|
||||||
|
def set_trigger_trigin_relmove(self, axis):
|
||||||
|
'''Set trigger input to initiate a relative move.'''
|
||||||
|
self.set_trigger(axis, TriggerBitsServo.TRIGIN_RELMOVE)
|
||||||
|
|
||||||
|
def set_trigger_trigin_absmove(self, axis):
|
||||||
|
'''Set trigger input to initiate an absolute move.'''
|
||||||
|
self.set_trigger(axis, TriggerBitsServo.TRIGIN_ABSMOVE)
|
||||||
|
|
||||||
|
def set_trigger_trigin_homemove(self, axis):
|
||||||
|
'''Set trigger input to initiate a home move.'''
|
||||||
|
self.set_trigger(axis, TriggerBitsServo.TRIGIN_HOMEMOVE)
|
||||||
|
|
||||||
|
def set_trigger_trigout_high(self, axis):
|
||||||
|
'''Set trigger output to logic high.'''
|
||||||
|
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_HIGH)
|
||||||
|
|
||||||
|
def set_trigger_trigout_inmotion(self, axis):
|
||||||
|
'''Set trigger output high while axis is in motion.'''
|
||||||
|
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_INMOTION)
|
||||||
|
|
||||||
|
def set_trigger_trigout_motioncomplete(self, axis):
|
||||||
|
'''Set trigger output to pulse when motion completes.'''
|
||||||
|
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_MOTIONCOMPLETE)
|
||||||
|
|
||||||
|
def set_trigger_trigout_maxvelocity(self, axis):
|
||||||
|
'''Set trigger output to pulse at max velocity.'''
|
||||||
|
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_MAXVELOCITY)
|
||||||
|
|
||||||
|
def set_trigger_trigout_maxv(self, axis):
|
||||||
|
'''Set trigger output high + pulse at max velocity (TRIGOUT_MAXV).'''
|
||||||
|
self.set_trigger(axis, TriggerBitsServo.TRIGOUT_MAXV)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'''
|
||||||
|
SRAS Serial Communication Tools
|
||||||
|
Thomas Ales | Feb 2026
|
||||||
|
Version 1
|
||||||
|
'''
|
||||||
|
from threading import Thread
|
||||||
|
from queue import Queue
|
||||||
|
import serial
|
||||||
|
import struct
|
||||||
|
import time
|
||||||
|
|
||||||
|
class SerialSnooper(Thread):
|
||||||
|
|
||||||
|
def __init__(self, _port, _spd):
|
||||||
|
super().__init__()
|
||||||
|
self.serial_connection = None
|
||||||
|
self.serial_port = _port
|
||||||
|
self.serial_speed = _spd
|
||||||
|
self.am_listening = False
|
||||||
|
self.rx_msg_queue = Queue()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
'''
|
||||||
|
run() - Starts up the serial listener, checks if the port was
|
||||||
|
opened successfully, and if so begins
|
||||||
|
listening for APT messages.
|
||||||
|
'''
|
||||||
|
self.serial_connection = serial.Serial(port=self.serial_port,
|
||||||
|
baudrate=self.serial_speed,
|
||||||
|
rtscts=True, timeout=0.05)
|
||||||
|
if self.serial_connection.is_open is True:
|
||||||
|
# Send disconnect to stop any ongoing auto-updates from
|
||||||
|
# a previous session
|
||||||
|
for addr in [0x11, 0x21, 0x22]:
|
||||||
|
disconnect_msg = struct.pack('<HBBBB', 0x0002,
|
||||||
|
0x00, 0x00, addr, 0x01)
|
||||||
|
self.serial_connection.write(disconnect_msg)
|
||||||
|
|
||||||
|
# Wait for controller to process, then flush everything
|
||||||
|
time.sleep(0.2)
|
||||||
|
self.serial_connection.reset_output_buffer()
|
||||||
|
self.serial_connection.reset_input_buffer()
|
||||||
|
|
||||||
|
# Discard any remaining data that arrived
|
||||||
|
time.sleep(0.1)
|
||||||
|
if self.serial_connection.in_waiting > 0:
|
||||||
|
self.serial_connection.read(self.serial_connection.in_waiting)
|
||||||
|
|
||||||
|
self.am_listening = True
|
||||||
|
_rxbuf = bytearray()
|
||||||
|
while self.am_listening is True:
|
||||||
|
try:
|
||||||
|
# Read whatever is available (or wait up to timeout)
|
||||||
|
_chunk = self.serial_connection.read(
|
||||||
|
max(1, self.serial_connection.in_waiting))
|
||||||
|
if _chunk:
|
||||||
|
_rxbuf.extend(_chunk)
|
||||||
|
|
||||||
|
# Process complete messages from the buffer
|
||||||
|
while len(_rxbuf) >= 6:
|
||||||
|
# Check if this is a long message (bit 7 of byte 4)
|
||||||
|
if _rxbuf[4] & 0x80:
|
||||||
|
_msglen = struct.unpack("<H", _rxbuf[2:4])[0]
|
||||||
|
total = 6 + _msglen
|
||||||
|
if len(_rxbuf) < total:
|
||||||
|
break # need more bytes
|
||||||
|
_packet = bytes(_rxbuf[:total])
|
||||||
|
del _rxbuf[:total]
|
||||||
|
else:
|
||||||
|
_packet = bytes(_rxbuf[:6])
|
||||||
|
del _rxbuf[:6]
|
||||||
|
|
||||||
|
self.rx_msg_queue.put_nowait(_packet)
|
||||||
|
|
||||||
|
except (serial.SerialException, TypeError, OSError):
|
||||||
|
break
|
||||||
|
return
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
'''
|
||||||
|
stop() - Signals the listener loop to stop. Call join() after
|
||||||
|
this to wait for the thread to exit, then call close() to
|
||||||
|
release the serial port.
|
||||||
|
'''
|
||||||
|
self.am_listening = False
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
'''
|
||||||
|
close() - Closes the serial port. Only call after the
|
||||||
|
thread has been joined.
|
||||||
|
'''
|
||||||
|
if self.serial_connection and self.serial_connection.is_open:
|
||||||
|
self.serial_connection.close()
|
||||||
|
|
||||||
+139
-93
@@ -21,7 +21,7 @@ from hardware.helios_laser import HeliosLaser, PulseMode
|
|||||||
from hardware.uc480_camera import UC480Camera, CameraStreamThread
|
from hardware.uc480_camera import UC480Camera, CameraStreamThread
|
||||||
|
|
||||||
# Import stage controller and motion worker
|
# Import stage controller and motion worker
|
||||||
from hardware.bbd202 import MotionController
|
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y, TriggerBitsServo
|
||||||
from scanengine.motion_worker import MotionWorker
|
from scanengine.motion_worker import MotionWorker
|
||||||
|
|
||||||
# Import scan planning tool
|
# Import scan planning tool
|
||||||
@@ -54,33 +54,7 @@ class ScanWorker(QtCore.QObject):
|
|||||||
|
|
||||||
@QtCore.pyqtSlot()
|
@QtCore.pyqtSlot()
|
||||||
def run_scan(self):
|
def run_scan(self):
|
||||||
"""
|
"""Execute the full scanning process."""
|
||||||
Execute the full scanning process.
|
|
||||||
|
|
||||||
TODO: Implement your own motion control logic here.
|
|
||||||
|
|
||||||
Available data:
|
|
||||||
self.scan_params - dict containing:
|
|
||||||
'scan_boxes' - list of scan box dicts with:
|
|
||||||
'angle_degrees' - rotation angle
|
|
||||||
'start' - (x_start, y_start) in mm
|
|
||||||
'end' - (x_end, y_end) in mm
|
|
||||||
'row_spacing' - spacing between rows in mm
|
|
||||||
|
|
||||||
self.motion_worker.controller - MotionController instance (if connected)
|
|
||||||
|
|
||||||
self.should_stop - set to True when user requests abort
|
|
||||||
|
|
||||||
Signals to emit:
|
|
||||||
self.scan_started.emit() - at start
|
|
||||||
self.angle_started.emit(angle_idx, total_angles) - when starting each angle
|
|
||||||
self.line_started.emit(line_idx, total_lines, y_pos) - when starting each line
|
|
||||||
self.current_progress.emit(percent) - progress for current angle
|
|
||||||
self.overall_progress.emit(percent) - overall progress
|
|
||||||
self.status_message.emit(message) - status updates
|
|
||||||
self.scan_completed.emit() - on successful completion
|
|
||||||
self.scan_failed.emit(error_msg) - on failure
|
|
||||||
"""
|
|
||||||
# Pause motion worker polling during scan to avoid conflicts
|
# Pause motion worker polling during scan to avoid conflicts
|
||||||
if self.motion_worker:
|
if self.motion_worker:
|
||||||
self.motion_worker.scanning_active = True
|
self.motion_worker.scanning_active = True
|
||||||
@@ -88,40 +62,114 @@ class ScanWorker(QtCore.QObject):
|
|||||||
try:
|
try:
|
||||||
self.scan_started.emit()
|
self.scan_started.emit()
|
||||||
|
|
||||||
|
# Extract parameters
|
||||||
scan_boxes = self.scan_params.get('scan_boxes', [])
|
scan_boxes = self.scan_params.get('scan_boxes', [])
|
||||||
num_angles = len(scan_boxes)
|
num_angles = len(scan_boxes)
|
||||||
row_spacing = self.scan_params.get('row_spacing', 0.1)
|
row_spacing = self.scan_params.get('row_spacing', 0.1)
|
||||||
|
scan_velocity = self.scan_params.get('scan_velocity_mm_s', 200.0)
|
||||||
|
scan_accel = self.scan_params.get('scan_acceleration_mm_s2', 500.0)
|
||||||
|
|
||||||
print(f"Scan worker: Starting scan with {num_angles} angles")
|
print(f"Scan worker: Starting scan with {num_angles} angles")
|
||||||
print(f"Row spacing: {row_spacing} mm")
|
print(f"Row spacing: {row_spacing} mm, velocity: {scan_velocity} mm/s")
|
||||||
|
|
||||||
controller = self.motion_worker.controller if self.motion_worker else None
|
controller = self.motion_worker.controller if self.motion_worker else None
|
||||||
if not controller:
|
if not controller:
|
||||||
self.scan_failed.emit("No motion controller connected")
|
self.scan_failed.emit("No motion controller connected")
|
||||||
return
|
return
|
||||||
|
|
||||||
# TODO: Implement your motion control logic here
|
# Store original velocity params for restoration
|
||||||
#
|
orig_x_velocity = controller.max_velocities[0]
|
||||||
# For each angle in scan_boxes:
|
orig_x_accel = controller.max_accels[0]
|
||||||
# - Move to start position (x_start, y_start)
|
orig_y_velocity = controller.max_velocities[1]
|
||||||
# - For each row from y_start to y_end with row_spacing:
|
orig_y_accel = controller.max_accels[1]
|
||||||
# - Move X from x_start to x_end (flying scan)
|
|
||||||
# - Move to next row (X back to x_start, Y to next row)
|
|
||||||
#
|
|
||||||
# The MotionController provides these methods:
|
|
||||||
# controller.move_to_fast(x=mm, y=mm) - send move commands
|
|
||||||
# controller.get_position(dest, timeout) - get current position
|
|
||||||
# controller.poll_until_idle(tolerance, timeout) - poll until settled
|
|
||||||
# controller.set_velocity_params(dest, min_vel, accel, max_vel)
|
|
||||||
# controller.start_update_messages() - enable status updates
|
|
||||||
# etc.
|
|
||||||
|
|
||||||
self.scan_failed.emit("Motion control not implemented - please implement run_scan()")
|
# Configure stage for high-speed scanning
|
||||||
|
controller.set_velocity_params(AXIS_X, max_velocity=scan_velocity, acceleration=scan_accel)
|
||||||
|
controller.set_velocity_params(AXIS_Y, max_velocity=scan_velocity, acceleration=scan_accel)
|
||||||
|
|
||||||
|
# Set X-axis trigger output HIGH during motion (for oscilloscope sync)
|
||||||
|
controller.set_trigger(AXIS_X, TriggerBitsServo.TRIGOUT_INMOTION)
|
||||||
|
|
||||||
|
self.status_message.emit("Scan configured, starting raster...")
|
||||||
|
|
||||||
|
# Main scan loop
|
||||||
|
for angle_idx, scan_box in enumerate(scan_boxes):
|
||||||
|
if self.should_stop:
|
||||||
|
self.scan_failed.emit("Scan aborted by user")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.angle_started.emit(angle_idx, num_angles)
|
||||||
|
angle_deg = scan_box.get('angle_degrees', 0)
|
||||||
|
self.status_message.emit(f"Angle {angle_idx + 1}/{num_angles} ({angle_deg:.1f} deg)")
|
||||||
|
|
||||||
|
# Extract scan area boundaries
|
||||||
|
x_start, y_start = scan_box['start']
|
||||||
|
x_end, y_end = scan_box['end']
|
||||||
|
|
||||||
|
# Calculate scan lines
|
||||||
|
y_range = y_end - y_start
|
||||||
|
num_lines = max(1, int(y_range / row_spacing) + 1)
|
||||||
|
|
||||||
|
# Move to start position only for first angle
|
||||||
|
if angle_idx == 0:
|
||||||
|
self.status_message.emit("Moving to scan start position...")
|
||||||
|
controller.move_axis_absolute(AXIS_X, x_start, timeout=30.0)
|
||||||
|
controller.move_axis_absolute(AXIS_Y, y_start, timeout=30.0)
|
||||||
|
|
||||||
|
# Scan each line (snake/boustrophedon pattern)
|
||||||
|
for line_idx in range(num_lines):
|
||||||
|
if self.should_stop:
|
||||||
|
self.scan_failed.emit("Scan aborted by user")
|
||||||
|
return
|
||||||
|
|
||||||
|
y_current = y_start + line_idx * row_spacing
|
||||||
|
if y_current > y_end:
|
||||||
|
y_current = y_end
|
||||||
|
|
||||||
|
# Alternate scan direction for snake pattern
|
||||||
|
if line_idx % 2 == 0:
|
||||||
|
scan_start_x, scan_end_x = x_start, x_end
|
||||||
|
else:
|
||||||
|
scan_start_x, scan_end_x = x_end, x_start
|
||||||
|
|
||||||
|
self.line_started.emit(line_idx, num_lines, y_current)
|
||||||
|
|
||||||
|
# Position to line start (diagonal move - X and Y simultaneously)
|
||||||
|
# Y move issued first (non-blocking from stage perspective),
|
||||||
|
# then X move; both complete before scan line begins
|
||||||
|
controller.move_axis_absolute(AXIS_Y, y_current, timeout=20.0)
|
||||||
|
controller.move_axis_absolute(AXIS_X, scan_start_x, timeout=20.0)
|
||||||
|
|
||||||
|
# Perform scan line (trigger is HIGH during this move)
|
||||||
|
scan_distance = abs(scan_end_x - scan_start_x)
|
||||||
|
scan_timeout = max(10.0, scan_distance / scan_velocity * 3)
|
||||||
|
controller.move_axis_absolute(AXIS_X, scan_end_x, timeout=scan_timeout)
|
||||||
|
|
||||||
|
# Update progress
|
||||||
|
line_progress = int(100 * (line_idx + 1) / num_lines)
|
||||||
|
self.current_progress.emit(line_progress)
|
||||||
|
|
||||||
|
overall = int(100 * (angle_idx + (line_idx + 1) / num_lines) / num_angles)
|
||||||
|
self.overall_progress.emit(overall)
|
||||||
|
|
||||||
|
# Cleanup: disable triggers and restore original velocity
|
||||||
|
controller.set_trigger(AXIS_X, 0)
|
||||||
|
controller.set_velocity_params(AXIS_X, max_velocity=orig_x_velocity, acceleration=orig_x_accel)
|
||||||
|
controller.set_velocity_params(AXIS_Y, max_velocity=orig_y_velocity, acceleration=orig_y_accel)
|
||||||
|
|
||||||
|
self.status_message.emit("Scan complete")
|
||||||
|
self.scan_completed.emit()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR in scan worker: {e}")
|
print(f"ERROR in scan worker: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
# Best-effort cleanup on error
|
||||||
|
try:
|
||||||
|
if self.motion_worker and self.motion_worker.controller:
|
||||||
|
self.motion_worker.controller.set_trigger(AXIS_X, 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.scan_failed.emit(str(e))
|
self.scan_failed.emit(str(e))
|
||||||
finally:
|
finally:
|
||||||
# Re-enable motion worker polling
|
# Re-enable motion worker polling
|
||||||
@@ -2395,8 +2443,8 @@ class ScanVisualizationDialog(QtWidgets.QDialog):
|
|||||||
self.current_scan_label.setText("Checking home status...")
|
self.current_scan_label.setText("Checking home status...")
|
||||||
|
|
||||||
# Get home status for both axes
|
# Get home status for both axes
|
||||||
x_homed = controller.is_homed_x
|
x_homed = controller.am_homed[0]
|
||||||
y_homed = controller.is_homed_y
|
y_homed = controller.am_homed[1]
|
||||||
|
|
||||||
print(f"Home status - X: {x_homed}, Y: {y_homed}")
|
print(f"Home status - X: {x_homed}, Y: {y_homed}")
|
||||||
|
|
||||||
@@ -2430,26 +2478,25 @@ class ScanVisualizationDialog(QtWidgets.QDialog):
|
|||||||
QtWidgets.QApplication.processEvents()
|
QtWidgets.QApplication.processEvents()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = controller.home_axis(controller.DEST_X_AXIS, timeout=60.0)
|
controller.home_axis(AXIS_X, timeout=60.0)
|
||||||
if success:
|
print("X-axis homed successfully")
|
||||||
print("X-axis homed successfully")
|
self.current_scan_progress.setValue(50)
|
||||||
self.current_scan_progress.setValue(50)
|
|
||||||
|
|
||||||
# Small delay to let system stabilize
|
# Small delay to let system stabilize
|
||||||
import time
|
import time
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
else:
|
except TimeoutError:
|
||||||
print("ERROR: X-axis homing timeout")
|
print("ERROR: X-axis homing timeout")
|
||||||
QtWidgets.QMessageBox.critical(
|
QtWidgets.QMessageBox.critical(
|
||||||
self,
|
self,
|
||||||
"Homing Error",
|
"Homing Error",
|
||||||
"X-axis homing timed out after 60 seconds.\n\n"
|
"X-axis homing timed out after 60 seconds.\n\n"
|
||||||
"Please check:\n"
|
"Please check:\n"
|
||||||
"• Stage can move freely\n"
|
"• Stage can move freely\n"
|
||||||
"• No obstructions\n"
|
"• No obstructions\n"
|
||||||
"• Stage is connected properly"
|
"• Stage is connected properly"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Failed to home X-axis: {e}")
|
print(f"ERROR: Failed to home X-axis: {e}")
|
||||||
QtWidgets.QMessageBox.critical(
|
QtWidgets.QMessageBox.critical(
|
||||||
@@ -2467,26 +2514,25 @@ class ScanVisualizationDialog(QtWidgets.QDialog):
|
|||||||
QtWidgets.QApplication.processEvents()
|
QtWidgets.QApplication.processEvents()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
success = controller.home_axis(controller.DEST_Y_AXIS, timeout=60.0)
|
controller.home_axis(AXIS_Y, timeout=60.0)
|
||||||
if success:
|
print("Y-axis homed successfully")
|
||||||
print("Y-axis homed successfully")
|
self.current_scan_progress.setValue(90)
|
||||||
self.current_scan_progress.setValue(90)
|
|
||||||
|
|
||||||
# Small delay to let system stabilize
|
# Small delay to let system stabilize
|
||||||
import time
|
import time
|
||||||
time.sleep(0.5)
|
time.sleep(0.5)
|
||||||
else:
|
except TimeoutError:
|
||||||
print("ERROR: Y-axis homing timeout")
|
print("ERROR: Y-axis homing timeout")
|
||||||
QtWidgets.QMessageBox.critical(
|
QtWidgets.QMessageBox.critical(
|
||||||
self,
|
self,
|
||||||
"Homing Error",
|
"Homing Error",
|
||||||
"Y-axis homing timed out after 60 seconds.\n\n"
|
"Y-axis homing timed out after 60 seconds.\n\n"
|
||||||
"Please check:\n"
|
"Please check:\n"
|
||||||
"• Stage can move freely\n"
|
"• Stage can move freely\n"
|
||||||
"• No obstructions\n"
|
"• No obstructions\n"
|
||||||
"• Stage is connected properly"
|
"• Stage is connected properly"
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"ERROR: Failed to home Y-axis: {e}")
|
print(f"ERROR: Failed to home Y-axis: {e}")
|
||||||
QtWidgets.QMessageBox.critical(
|
QtWidgets.QMessageBox.critical(
|
||||||
@@ -2689,16 +2735,16 @@ class OptionsDialog(QtWidgets.QDialog):
|
|||||||
|
|
||||||
def populate_trigger_modes(self):
|
def populate_trigger_modes(self):
|
||||||
"""Populate the trigger mode combo boxes with available options"""
|
"""Populate the trigger mode combo boxes with available options"""
|
||||||
from hardware.bbd202 import TriggerMode
|
from hardware.pybbd202 import TriggerBitsServo
|
||||||
|
|
||||||
trigger_options = [
|
trigger_options = [
|
||||||
("Disabled", TriggerMode.DISABLED),
|
("Disabled", 0),
|
||||||
("In/Out Relative Move", TriggerMode.IN_OUT_RELATIVE_MOVE),
|
("Trigger In: Relative Move", TriggerBitsServo.TRIGIN_RELMOVE),
|
||||||
("In/Out Absolute Move", TriggerMode.IN_OUT_ABSOLUTE_MOVE),
|
("Trigger In: Absolute Move", TriggerBitsServo.TRIGIN_ABSMOVE),
|
||||||
("In/Out Home", TriggerMode.IN_OUT_HOME),
|
("Trigger In: Home", TriggerBitsServo.TRIGIN_HOMEMOVE),
|
||||||
("In/Out Stop", TriggerMode.IN_OUT_STOP),
|
("Trigger Out: In Motion", TriggerBitsServo.TRIGOUT_INMOTION),
|
||||||
("Out Only (HIGH during motion)", TriggerMode.OUT_ONLY),
|
("Trigger Out: Motion Complete", TriggerBitsServo.TRIGOUT_MOTIONCOMPLETE),
|
||||||
("Out Position", TriggerMode.OUT_POSITION),
|
("Trigger Out: Max Velocity", TriggerBitsServo.TRIGOUT_MAXVELOCITY),
|
||||||
]
|
]
|
||||||
|
|
||||||
for label, mode in trigger_options:
|
for label, mode in trigger_options:
|
||||||
|
|||||||
+58
-89
@@ -6,7 +6,7 @@ Provides async command queueing and position updates via Qt signals.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from PyQt6 import QtCore
|
from PyQt6 import QtCore
|
||||||
from hardware.bbd202 import MotionController
|
from hardware.pybbd202 import ThorlabsServoDriver, AXIS_X, AXIS_Y
|
||||||
import queue
|
import queue
|
||||||
import time
|
import time
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
@@ -45,7 +45,7 @@ class MotionWorker(QtCore.QObject):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.controller: Optional[MotionController] = None
|
self.controller: Optional[ThorlabsServoDriver] = None
|
||||||
self.is_connected = False
|
self.is_connected = False
|
||||||
self.command_queue = queue.Queue()
|
self.command_queue = queue.Queue()
|
||||||
self.running = True
|
self.running = True
|
||||||
@@ -65,9 +65,9 @@ class MotionWorker(QtCore.QObject):
|
|||||||
self.last_x_moving = None
|
self.last_x_moving = None
|
||||||
self.last_y_moving = None
|
self.last_y_moving = None
|
||||||
|
|
||||||
# Position update throttling (active requests can be slow)
|
# Position update throttling
|
||||||
self.last_position_update_time = 0
|
self.last_position_update_time = 0
|
||||||
self.position_update_interval = 0.2 # seconds between position requests
|
self.position_update_interval = 0.2 # seconds between position reads
|
||||||
|
|
||||||
# Flag to pause polling during scanning (scan worker handles its own position queries)
|
# Flag to pause polling during scanning (scan worker handles its own position queries)
|
||||||
self.scanning_active = False
|
self.scanning_active = False
|
||||||
@@ -133,27 +133,25 @@ class MotionWorker(QtCore.QObject):
|
|||||||
def do_connect(self):
|
def do_connect(self):
|
||||||
"""Connect to the motion controller"""
|
"""Connect to the motion controller"""
|
||||||
try:
|
try:
|
||||||
self.controller = MotionController()
|
self.controller = ThorlabsServoDriver()
|
||||||
self.controller.connect() # ACKs are sent reactively when enable_updates=True
|
self.controller.connect()
|
||||||
|
|
||||||
# Enable channels
|
# Enable channels
|
||||||
self.controller.set_channel_enable_state(self.controller.DEST_X_AXIS, True)
|
self.controller.enable_axis(AXIS_X)
|
||||||
self.controller.set_channel_enable_state(self.controller.DEST_Y_AXIS, True)
|
self.controller.enable_axis(AXIS_Y)
|
||||||
|
|
||||||
# Request status updates for both axes to populate status bits (including homed state)
|
# Start polling to populate cached state (positions, homed, moving, errors)
|
||||||
# This is necessary because status bits are not sent automatically after connect
|
self.controller.start_polling(interval=0.2)
|
||||||
self.controller.request_status_update(self.controller.DEST_X_AXIS)
|
|
||||||
self.controller.request_status_update(self.controller.DEST_Y_AXIS)
|
# Wait for first polling cycle to populate status
|
||||||
# Wait a moment for the asynchronous status update responses
|
time.sleep(0.3)
|
||||||
time.sleep(0.2)
|
|
||||||
|
|
||||||
# Set initial velocity parameters
|
# Set initial velocity parameters
|
||||||
for dest in [self.controller.DEST_X_AXIS, self.controller.DEST_Y_AXIS]:
|
for dest in [AXIS_X, AXIS_Y]:
|
||||||
self.controller.set_velocity_params(
|
self.controller.set_velocity_params(
|
||||||
dest,
|
dest,
|
||||||
min_velocity=0.0,
|
max_velocity=self.jog_speed,
|
||||||
acceleration=self.acceleration,
|
acceleration=self.acceleration
|
||||||
max_velocity=self.jog_speed
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.is_connected = True
|
self.is_connected = True
|
||||||
@@ -188,29 +186,25 @@ class MotionWorker(QtCore.QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Determine destination
|
dest = AXIS_X if axis == 'x' else AXIS_Y
|
||||||
dest = self.controller.DEST_X_AXIS if axis == 'x' else self.controller.DEST_Y_AXIS
|
|
||||||
|
|
||||||
# Calculate relative distance
|
# Calculate relative distance
|
||||||
distance = self.step_size * direction
|
distance = self.step_size * direction
|
||||||
|
|
||||||
# Set relative move parameters
|
# Execute the move (blocking, with short timeout for continuous jogging)
|
||||||
self.controller.set_move_rel_params(dest, distance)
|
self.controller.move_axis_relative(dest, distance, timeout=0.5)
|
||||||
|
|
||||||
# Execute the move (non-blocking - we don't wait for completion)
|
|
||||||
# Use a very short timeout since we're doing continuous jogging
|
|
||||||
self.controller.move_relative(dest, timeout=0.5)
|
|
||||||
|
|
||||||
# Update position
|
# Update position
|
||||||
self.update_position()
|
self.update_position()
|
||||||
|
|
||||||
self.move_completed.emit(axis)
|
self.move_completed.emit(axis)
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
# Timeout is expected during continuous jog - don't report as error
|
||||||
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Don't emit errors for timeout - that's expected during continuous jog
|
print(f"Jog error: {e}")
|
||||||
if "timeout" not in str(e).lower():
|
self.error_occurred.emit(f"Jog failed: {str(e)}")
|
||||||
print(f"Jog error: {e}")
|
|
||||||
self.error_occurred.emit(f"Jog failed: {str(e)}")
|
|
||||||
|
|
||||||
def do_home(self, axis: str):
|
def do_home(self, axis: str):
|
||||||
"""Home an axis"""
|
"""Home an axis"""
|
||||||
@@ -218,7 +212,7 @@ class MotionWorker(QtCore.QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dest = self.controller.DEST_X_AXIS if axis == 'x' else self.controller.DEST_Y_AXIS
|
dest = AXIS_X if axis == 'x' else AXIS_Y
|
||||||
|
|
||||||
print(f"Homing {axis.upper()} axis...")
|
print(f"Homing {axis.upper()} axis...")
|
||||||
self.controller.home_axis(dest, timeout=20.0)
|
self.controller.home_axis(dest, timeout=20.0)
|
||||||
@@ -229,6 +223,9 @@ class MotionWorker(QtCore.QObject):
|
|||||||
|
|
||||||
print(f"{axis.upper()} axis homed successfully")
|
print(f"{axis.upper()} axis homed successfully")
|
||||||
|
|
||||||
|
except TimeoutError:
|
||||||
|
print(f"Home timeout: {axis.upper()} axis")
|
||||||
|
self.error_occurred.emit(f"Homing {axis.upper()} timed out")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Home error: {e}")
|
print(f"Home error: {e}")
|
||||||
self.error_occurred.emit(f"Homing {axis.upper()} failed: {str(e)}")
|
self.error_occurred.emit(f"Homing {axis.upper()} failed: {str(e)}")
|
||||||
@@ -244,12 +241,11 @@ class MotionWorker(QtCore.QObject):
|
|||||||
self.jog_speed = speed
|
self.jog_speed = speed
|
||||||
self.acceleration = accel
|
self.acceleration = accel
|
||||||
|
|
||||||
for dest in [self.controller.DEST_X_AXIS, self.controller.DEST_Y_AXIS]:
|
for dest in [AXIS_X, AXIS_Y]:
|
||||||
self.controller.set_velocity_params(
|
self.controller.set_velocity_params(
|
||||||
dest,
|
dest,
|
||||||
min_velocity=0.0,
|
max_velocity=self.jog_speed,
|
||||||
acceleration=self.acceleration,
|
acceleration=self.acceleration
|
||||||
max_velocity=self.jog_speed
|
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -261,8 +257,11 @@ class MotionWorker(QtCore.QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dest = self.controller.DEST_X_AXIS if axis == 'x' else self.controller.DEST_Y_AXIS
|
dest = AXIS_X if axis == 'x' else AXIS_Y
|
||||||
self.controller.set_channel_enable_state(dest, enabled)
|
if enabled:
|
||||||
|
self.controller.enable_axis(dest)
|
||||||
|
else:
|
||||||
|
self.controller.disable_axis(dest)
|
||||||
state_str = "enabled" if enabled else "disabled"
|
state_str = "enabled" if enabled else "disabled"
|
||||||
print(f"{axis.upper()} axis {state_str}")
|
print(f"{axis.upper()} axis {state_str}")
|
||||||
|
|
||||||
@@ -275,35 +274,25 @@ class MotionWorker(QtCore.QObject):
|
|||||||
if not self.is_connected or not self.controller:
|
if not self.is_connected or not self.controller:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Throttle position requests to avoid slowing down the main loop
|
# Throttle position reads to avoid excessive signal emission
|
||||||
current_time = time.time()
|
current_time = time.time()
|
||||||
if current_time - self.last_position_update_time < self.position_update_interval:
|
if current_time - self.last_position_update_time < self.position_update_interval:
|
||||||
return
|
return
|
||||||
self.last_position_update_time = current_time
|
self.last_position_update_time = current_time
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Actively request positions from the controller instead of relying on cached values
|
# Read cached positions (populated by polling worker)
|
||||||
# This ensures we always have up-to-date position data
|
x_pos = self.controller.positions[0]
|
||||||
# Use longer timeout (1.5s) to accommodate high-speed scanning at 200mm/s
|
y_pos = self.controller.positions[1]
|
||||||
x_pos = self.controller.get_position(self.controller.DEST_X_AXIS, timeout=1.5)
|
|
||||||
y_pos = self.controller.get_position(self.controller.DEST_Y_AXIS, timeout=1.5)
|
|
||||||
|
|
||||||
if x_pos is not None and y_pos is not None:
|
# Always emit on first update, or if position changed significantly (> 0.001mm)
|
||||||
# Always emit on first update, or if position changed significantly (> 0.001mm)
|
if (self.last_x is None or self.last_y is None or
|
||||||
if (self.last_x is None or self.last_y is None or
|
abs(x_pos - self.last_x) > 0.001 or abs(y_pos - self.last_y) > 0.001):
|
||||||
abs(x_pos - self.last_x) > 0.001 or abs(y_pos - self.last_y) > 0.001):
|
self.last_x = x_pos
|
||||||
self.last_x = x_pos
|
self.last_y = y_pos
|
||||||
self.last_y = y_pos
|
print(f"Position update: X={x_pos:.3f}mm, Y={y_pos:.3f}mm")
|
||||||
print(f"Position update: X={x_pos:.3f}mm, Y={y_pos:.3f}mm")
|
self.position_updated.emit(x_pos, y_pos)
|
||||||
self.position_updated.emit(x_pos, y_pos)
|
|
||||||
# else: silently skip incomplete position data during busy scanning
|
|
||||||
|
|
||||||
except RuntimeError as e:
|
|
||||||
# RuntimeError indicates actual hardware error (overtemp, encoder fault, etc.)
|
|
||||||
print(f"CRITICAL: Motor error detected: {e}")
|
|
||||||
self.error_occurred.emit(str(e))
|
|
||||||
# Stop requesting position updates to avoid spam
|
|
||||||
self.is_connected = False
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error updating position: {e}")
|
print(f"Error updating position: {e}")
|
||||||
import traceback
|
import traceback
|
||||||
@@ -315,8 +304,8 @@ class MotionWorker(QtCore.QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
x_homed = self.controller.is_homed_x
|
x_homed = self.controller.am_homed[0]
|
||||||
y_homed = self.controller.is_homed_y
|
y_homed = self.controller.am_homed[1]
|
||||||
|
|
||||||
# Only emit if status changed
|
# Only emit if status changed
|
||||||
if x_homed != self.last_x_homed or y_homed != self.last_y_homed:
|
if x_homed != self.last_x_homed or y_homed != self.last_y_homed:
|
||||||
@@ -332,42 +321,22 @@ class MotionWorker(QtCore.QObject):
|
|||||||
def update_motion_status(self):
|
def update_motion_status(self):
|
||||||
"""Update motion status and emit signal if changed.
|
"""Update motion status and emit signal if changed.
|
||||||
|
|
||||||
NOTE: On BBD202 firmware v2.1.5, the is_in_motion_x/y properties
|
The new driver's polling worker keeps am_moving[], am_error[]
|
||||||
do NOT reliably detect motion - they may always return False even
|
up to date automatically via status update messages.
|
||||||
during active movement. This is a known firmware limitation.
|
|
||||||
|
|
||||||
For scan execution, use the ScanWorker.move_and_wait() method which
|
|
||||||
uses position-based motion detection instead of status bits.
|
|
||||||
|
|
||||||
This UI status display is best-effort only.
|
|
||||||
"""
|
"""
|
||||||
if not self.is_connected or not self.controller:
|
if not self.is_connected or not self.controller:
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Actively poll for status updates
|
|
||||||
self.controller.poll_status()
|
|
||||||
|
|
||||||
# Check for any error conditions
|
# Check for any error conditions
|
||||||
error_msg = self.controller.check_for_errors()
|
if self.controller.am_error[0]:
|
||||||
if error_msg:
|
self.error_occurred.emit("X-axis error detected")
|
||||||
print(f"Motor error detected: {error_msg}")
|
if self.controller.am_error[1]:
|
||||||
self.error_occurred.emit(error_msg)
|
self.error_occurred.emit("Y-axis error detected")
|
||||||
self.controller.clear_last_error()
|
|
||||||
|
|
||||||
# Read the cached status (may not accurately reflect motion on some firmware)
|
# Read cached motion status (updated by polling worker)
|
||||||
x_moving = self.controller.is_in_motion_x
|
x_moving = self.controller.am_moving[0]
|
||||||
y_moving = self.controller.is_in_motion_y
|
y_moving = self.controller.am_moving[1]
|
||||||
|
|
||||||
# Also check if there are pending moves (more reliable)
|
|
||||||
if self.controller.is_move_pending():
|
|
||||||
# If there are pending moves, we're likely still moving
|
|
||||||
# This provides a backup indication when status bits fail
|
|
||||||
pending = self.controller.get_pending_targets()
|
|
||||||
if self.controller.DEST_X_AXIS in pending:
|
|
||||||
x_moving = True
|
|
||||||
if self.controller.DEST_Y_AXIS in pending:
|
|
||||||
y_moving = True
|
|
||||||
|
|
||||||
# Only emit if status changed
|
# Only emit if status changed
|
||||||
if x_moving != self.last_x_moving or y_moving != self.last_y_moving:
|
if x_moving != self.last_x_moving or y_moving != self.last_y_moving:
|
||||||
|
|||||||
Reference in New Issue
Block a user