UC2-REST 0.2.0.38__tar.gz → 0.2.0.40__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/PKG-INFO +1 -1
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/PKG-INFO +1 -1
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/SOURCES.txt +1 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/UC2Client.py +4 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/__version__.py +1 -1
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/galvo.py +67 -15
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/motor.py +188 -2
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/mserial.py +4 -4
- uc2_rest-0.2.0.40/uc2rest/ptz.py +156 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/LICENSE +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/README.md +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/dependency_links.txt +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/not-zip-safe +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/requires.txt +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/top_level.txt +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/setup.cfg +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/setup.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/MockSerial.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/__init__.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/analog.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/camera.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/camera_trigger.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/can.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/canota.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/cmdrecorder.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/digitalin.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/digitalout.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/fan.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/gpio.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/gripper.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/home.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/i2c.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/laser.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/lcddisplay.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/ledmatrix.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/logger.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/message.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/modules.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/motor_config.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/objective.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/pid.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/rotator.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/slm.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/state.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/temperature.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/utils.py +0 -0
- {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/wifi.py +0 -0
|
@@ -23,6 +23,7 @@ from .modules import Modules
|
|
|
23
23
|
from .digitalout import DigitalOut
|
|
24
24
|
from .digitalin import DigitalIn
|
|
25
25
|
from .gpio import GPIO
|
|
26
|
+
from .ptz import PTZ
|
|
26
27
|
from .i2c import I2C
|
|
27
28
|
from .rotator import Rotator
|
|
28
29
|
from .logger import Logger
|
|
@@ -161,6 +162,9 @@ class UC2Client(object):
|
|
|
161
162
|
# initialize GPIO / collision-detector slave interface
|
|
162
163
|
self.gpio = GPIO(self)
|
|
163
164
|
|
|
165
|
+
# initialize PTZ keyboard bridge interface (CAN node-id 61)
|
|
166
|
+
self.ptz = PTZ(self)
|
|
167
|
+
|
|
164
168
|
# initialize generic I2C passthrough (sensors on the GPIO slave bus)
|
|
165
169
|
self.i2c = I2C(self)
|
|
166
170
|
|
|
@@ -6,7 +6,7 @@ __version__.py
|
|
|
6
6
|
|
|
7
7
|
__title__ = 'UC2-REST'
|
|
8
8
|
__description__ = 'This pacage will help you to drive the ESP32-driven microscopy control modules from UC2'
|
|
9
|
-
__version__ = "v0.2.0.
|
|
9
|
+
__version__ = "v0.2.0.40"
|
|
10
10
|
__author__ = 'Benedict Diederich'
|
|
11
11
|
__author_email__ = 'benedictdied@gmail.com'
|
|
12
12
|
__license__ = 'GPL v3'
|
|
@@ -37,16 +37,17 @@ class Galvo(object):
|
|
|
37
37
|
SCANNER
|
|
38
38
|
##############################################################################################################################
|
|
39
39
|
'''
|
|
40
|
-
def set_galvo_scan(self, nx=256, ny=256, x_min=500, x_max=3500,
|
|
41
|
-
y_min=500, y_max=3500, sample_period_us=1,
|
|
42
|
-
frame_count=0, bidirectional=False,
|
|
43
|
-
pre_samples=0, fly_samples=0, trig_delay_us=0,
|
|
44
|
-
trig_width_us=0, line_settle_samples=0,
|
|
40
|
+
def set_galvo_scan(self, nx=256, ny=256, x_min=500, x_max=3500,
|
|
41
|
+
y_min=500, y_max=3500, sample_period_us=1,
|
|
42
|
+
frame_count=0, bidirectional=False,
|
|
43
|
+
pre_samples=0, fly_samples=0, trig_delay_us=0,
|
|
44
|
+
trig_width_us=0, line_settle_samples=0,
|
|
45
45
|
enable_trigger=1, apply_x_lut=0,
|
|
46
|
+
overscan_samples=0, laser_blanking=0, hw_pixel_clock=0,
|
|
46
47
|
timeout=1):
|
|
47
48
|
"""
|
|
48
49
|
Start galvo scanner with new API (HighSpeedScannerCore)
|
|
49
|
-
|
|
50
|
+
|
|
50
51
|
Args:
|
|
51
52
|
nx: Number of X samples per line (default: 256)
|
|
52
53
|
ny: Number of Y lines (default: 256)
|
|
@@ -56,14 +57,36 @@ class Galvo(object):
|
|
|
56
57
|
y_max: Max Y position 0-4095 (default: 3500)
|
|
57
58
|
sample_period_us: Microseconds per sample, 0=max speed (default: 1)
|
|
58
59
|
frame_count: Number of frames, 0=infinite (default: 0)
|
|
59
|
-
bidirectional: Enable bidirectional scanning (default: False)
|
|
60
|
+
bidirectional: Enable bidirectional scanning (default: False).
|
|
61
|
+
Odd lines are scanned with a mirrored profile; the trigger
|
|
62
|
+
window stays identical, so pixel counts remain monotonic and
|
|
63
|
+
equidistant. The host must flip odd lines when reassembling.
|
|
64
|
+
trig_delay_us: Gap between the frame marker and the line marker
|
|
65
|
+
at frame start (both fire during pre-blanking, ahead of the
|
|
66
|
+
first pixel).
|
|
67
|
+
trig_width_us: Marker/pixel trigger pulse width in microseconds
|
|
68
|
+
(pixel width is capped at half the dwell time; 0 = fastest
|
|
69
|
+
possible pulse).
|
|
70
|
+
overscan_samples: Linear ramp extension (same per-pixel slope) on
|
|
71
|
+
both sides of the imaging window. Compensates galvo lag: the
|
|
72
|
+
mirror is already moving at constant velocity when triggers
|
|
73
|
+
and laser start (default: 0)
|
|
74
|
+
laser_blanking: 1 = gate the galvo laser pin HIGH only during the
|
|
75
|
+
imaging window (off during pre/overscan/flyback/settle)
|
|
76
|
+
(default: 0)
|
|
77
|
+
hw_pixel_clock: 1 = generate the pixel clock with the RMT
|
|
78
|
+
peripheral (hardware-equidistant, decoupled from the DAC/SPI
|
|
79
|
+
loop; ESP32-S3 only, silently falls back to software pulses
|
|
80
|
+
elsewhere) (default: 0)
|
|
60
81
|
timeout: Request timeout in seconds (default: 1)
|
|
61
|
-
|
|
82
|
+
|
|
62
83
|
sends:
|
|
63
|
-
{"task": "/galvo_act", "config": {"nx":512,"ny":512,"x_min":500,"x_max":3500,"y_min":500,"y_max":3500,"pre_samples":0,"fly_samples":0,"sample_period_us":0,"trig_delay_us":0,"trig_width_us":0,"line_settle_samples":0,"enable_trigger":1,"apply_x_lut":0,"frame_count":0,"bidirectional":true}}
|
|
64
|
-
|
|
84
|
+
{"task": "/galvo_act", "config": {"nx":512,"ny":512,"x_min":500,"x_max":3500,"y_min":500,"y_max":3500,"pre_samples":0,"fly_samples":0,"sample_period_us":0,"trig_delay_us":0,"trig_width_us":0,"line_settle_samples":0,"enable_trigger":1,"apply_x_lut":0,"frame_count":0,"bidirectional":true,"overscan_samples":0,"laser_blanking":0,"hw_pixel_clock":0}}
|
|
85
|
+
|
|
65
86
|
Example:
|
|
66
|
-
>>> galvo.set_galvo_scan(nx=64, ny=64, frame_count=10,
|
|
87
|
+
>>> galvo.set_galvo_scan(nx=64, ny=64, frame_count=10,
|
|
88
|
+
... overscan_samples=8, laser_blanking=1,
|
|
89
|
+
... hw_pixel_clock=1)
|
|
67
90
|
"""
|
|
68
91
|
path = '/galvo_act'
|
|
69
92
|
payload = {
|
|
@@ -84,10 +107,13 @@ class Galvo(object):
|
|
|
84
107
|
"trig_width_us": trig_width_us,
|
|
85
108
|
"line_settle_samples": line_settle_samples,
|
|
86
109
|
"enable_trigger": enable_trigger,
|
|
87
|
-
"apply_x_lut": apply_x_lut
|
|
110
|
+
"apply_x_lut": apply_x_lut,
|
|
111
|
+
"overscan_samples": overscan_samples,
|
|
112
|
+
"laser_blanking": 1 if laser_blanking else 0,
|
|
113
|
+
"hw_pixel_clock": 1 if hw_pixel_clock else 0
|
|
88
114
|
}
|
|
89
115
|
}
|
|
90
|
-
|
|
116
|
+
|
|
91
117
|
return self._parent.post_json(path, payload, timeout=timeout)
|
|
92
118
|
|
|
93
119
|
def stop_galvo_scan(self, timeout=1):
|
|
@@ -108,6 +134,32 @@ class Galvo(object):
|
|
|
108
134
|
|
|
109
135
|
return self._parent.post_json(path, payload, timeout=timeout)
|
|
110
136
|
|
|
137
|
+
def set_position(self, x, y, timeout=1):
|
|
138
|
+
"""
|
|
139
|
+
Move the galvo to a static XY position (DAC counts 0-4095) without scanning.
|
|
140
|
+
|
|
141
|
+
Sends the direct-positioning form of /galvo_act, which the firmware maps
|
|
142
|
+
to a "goto" command (single-point dwell). Useful e.g. for parking the
|
|
143
|
+
beam at a defined position when a scan is stopped.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
x: X DAC coordinate (0-4095)
|
|
147
|
+
y: Y DAC coordinate (0-4095)
|
|
148
|
+
timeout: Request timeout in seconds (default: 1)
|
|
149
|
+
|
|
150
|
+
Example:
|
|
151
|
+
>>> galvo.set_position(2048, 2048) # park at center
|
|
152
|
+
"""
|
|
153
|
+
path = '/galvo_act'
|
|
154
|
+
payload = {
|
|
155
|
+
"task": path,
|
|
156
|
+
"galvo": {
|
|
157
|
+
"x": int(x),
|
|
158
|
+
"y": int(y)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return self._parent.post_json(path, payload, timeout=timeout)
|
|
162
|
+
|
|
111
163
|
def get_galvo_status(self, timeout=1):
|
|
112
164
|
"""
|
|
113
165
|
Get galvo scanner status
|
|
@@ -178,10 +230,10 @@ class Galvo(object):
|
|
|
178
230
|
path = '/galvo_act'
|
|
179
231
|
payload = {
|
|
180
232
|
"task": path,
|
|
181
|
-
|
|
233
|
+
"laser_trigger": laser_trigger,
|
|
182
234
|
"points": points
|
|
183
235
|
}
|
|
184
|
-
|
|
236
|
+
|
|
185
237
|
return self._parent.post_json(path, payload, timeout=timeout)
|
|
186
238
|
|
|
187
239
|
def stop_arbitrary_points(self, timeout=1):
|
|
@@ -55,6 +55,9 @@ class Motor(object):
|
|
|
55
55
|
self._parent.serial.register_callback(self._callback_motor_status, pattern="steppers")
|
|
56
56
|
# Register callback for stagescan completion signal: {"stagescan":{},"qid":0,"success":1}
|
|
57
57
|
self._parent.serial.register_callback(self._callback_stagescan_complete, pattern="stagescan")
|
|
58
|
+
# Register callback for async closed-loop axis fault events (design v2):
|
|
59
|
+
# {"axisEvent":{"axis":n,"fault":"STALL","posErrSteps":-142,...}}
|
|
60
|
+
self._parent.serial.register_callback(self._callback_axis_event, pattern="axisEvent")
|
|
58
61
|
# announce a function that is called when we receive a position update through the callback
|
|
59
62
|
self._callbackPerKey = {}
|
|
60
63
|
self.nCallbacks = 10
|
|
@@ -64,6 +67,8 @@ class Motor(object):
|
|
|
64
67
|
# Stage scan completion state
|
|
65
68
|
self._stagescan_complete = False
|
|
66
69
|
self._stagescan_callbacks = [] # List of callbacks to call when stagescan completes
|
|
70
|
+
# Closed-loop axis fault event listeners (design v2, WP9)
|
|
71
|
+
self._axis_event_callbacks = [] # called with the fault dict on STALL/LOST_STEPS/…
|
|
67
72
|
# move motor to wake them up #FIXME: Should not be necessary!
|
|
68
73
|
#self.move_stepper(steps=(1,1,1,1), speed=(1000,1000,1000,1000), is_absolute=(False,False,False,False))
|
|
69
74
|
#self.move_stepper(steps=(-1,-1,-1,-1), speed=(1000,1000,1000,1000), is_absolute=(False,False,False,False))
|
|
@@ -192,8 +197,189 @@ class Motor(object):
|
|
|
192
197
|
def register_callback(self, key, callbackfct):
|
|
193
198
|
''' register a callback function for a specific key '''
|
|
194
199
|
self._callbackPerKey[key] = callbackfct
|
|
195
|
-
|
|
196
|
-
|
|
200
|
+
|
|
201
|
+
# ========================================================================
|
|
202
|
+
# Closed-loop / encoder feedback (design v2, WP9)
|
|
203
|
+
#
|
|
204
|
+
# All values are in STEPS — do NOT convert to µm here; µm conversion stays
|
|
205
|
+
# in the higher-level ImSwitch code. Every method is safe when the firmware
|
|
206
|
+
# lacks encoder support: it returns sensible defaults and never raises.
|
|
207
|
+
#
|
|
208
|
+
# The firmware exposes per-axis feedback in the CANopen AXIS OD block
|
|
209
|
+
# (0x2040-0x204B) on each motor slave, addressed as (node, sub-index). The
|
|
210
|
+
# sub-index is the slave's motor axis id (usually 1). Reach it through the
|
|
211
|
+
# master's generic SDO bridge (/can_act {"sdo":{...}}).
|
|
212
|
+
# ========================================================================
|
|
213
|
+
|
|
214
|
+
# AXIS OD indices (mirror lib/uc2_od + tools/canopen registry, base 0x2040)
|
|
215
|
+
_AXIS_MEASURED_STEPS = 0x2040
|
|
216
|
+
_AXIS_POSITION_ERROR_STEPS = 0x2041
|
|
217
|
+
_AXIS_MODE = 0x2042
|
|
218
|
+
_AXIS_HEALTH = 0x2043
|
|
219
|
+
_AXIS_FAULT = 0x2044
|
|
220
|
+
_AXIS_RESET = 0x2045
|
|
221
|
+
_AXIS_CALIBRATED = 0x2046
|
|
222
|
+
_AXIS_REFERENCED = 0x2047
|
|
223
|
+
_AXIS_CALIBRATE = 0x2048
|
|
224
|
+
_AXIS_COUNTS_PER_STEP_Q16 = 0x2049
|
|
225
|
+
_AXIS_BACKLASH_STEPS = 0x204A
|
|
226
|
+
_AXIS_RAW_COUNTS = 0x204B
|
|
227
|
+
|
|
228
|
+
_AXIS_MODE_NAMES = {0: "OPEN_LOOP", 1: "MONITOR", 2: "CORRECT", 3: "SERVO"}
|
|
229
|
+
_AXIS_HEALTH_NAMES = {0: "OK", 1: "DEGRADED", 2: "FAULT"}
|
|
230
|
+
_AXIS_FAULT_NAMES = {0: "NONE", 1: "STALL", 2: "LOST_STEPS", 3: "DIVERGENCE",
|
|
231
|
+
4: "TIMEOUT", 5: "CAL_INVALID", 6: "CAL_FAILED", 7: "ENC_NOISE"}
|
|
232
|
+
_AXIS_RESET_POLICIES = {"TRUST_ENCODER": 1, "TRUST_STEPS": 2, "FORCE_REHOME": 3}
|
|
233
|
+
|
|
234
|
+
def _axis_sdo(self, node, index, sub, op, ctype="i32", value=None, timeout=2):
|
|
235
|
+
"""Low-level SDO read/write to a slave AXIS OD entry via the master.
|
|
236
|
+
|
|
237
|
+
Returns the integer value on a successful read, True on a successful
|
|
238
|
+
write, and None on any failure (never raises), so callers can degrade
|
|
239
|
+
gracefully when the firmware has no encoder/axis support.
|
|
240
|
+
"""
|
|
241
|
+
sdo = {"node": int(node), "index": int(index), "sub": int(sub),
|
|
242
|
+
"op": op, "type": ctype}
|
|
243
|
+
if op == "w":
|
|
244
|
+
sdo["value"] = int(value)
|
|
245
|
+
payload = {"task": "/can_act", "sdo": sdo}
|
|
246
|
+
try:
|
|
247
|
+
resp = self._parent.post_json("/can_act", payload, getReturn=True,
|
|
248
|
+
timeout=timeout, nResponses=1)
|
|
249
|
+
except Exception as e:
|
|
250
|
+
self._parent.logger.debug(f"axis SDO transport error: {e}")
|
|
251
|
+
return None
|
|
252
|
+
if not isinstance(resp, dict) or resp.get("status") != "ok":
|
|
253
|
+
return None
|
|
254
|
+
return True if op == "w" else resp.get("value", None)
|
|
255
|
+
|
|
256
|
+
def _callback_axis_event(self, data):
|
|
257
|
+
"""Serial callback for async axis fault events from firmware:
|
|
258
|
+
{"axisEvent":{"axis":n,"fault":"STALL","posErrSteps":-142,
|
|
259
|
+
"commandedSteps":..,"measuredSteps":..,"node":..}}
|
|
260
|
+
Forwards the inner fault dict to every registered listener so ImSwitch
|
|
261
|
+
can react to STALL / LOST_STEPS / DIVERGENCE during long acquisitions.
|
|
262
|
+
"""
|
|
263
|
+
try:
|
|
264
|
+
event = data.get("axisEvent", data) if isinstance(data, dict) else data
|
|
265
|
+
for callback in list(self._axis_event_callbacks):
|
|
266
|
+
try:
|
|
267
|
+
callback(event)
|
|
268
|
+
except Exception as e:
|
|
269
|
+
self._parent.logger.error(f"Error in axis event callback: {e}")
|
|
270
|
+
except Exception as e:
|
|
271
|
+
print(f"Error in _callback_axis_event: {e}")
|
|
272
|
+
|
|
273
|
+
def register_axis_event_callback(self, callback):
|
|
274
|
+
"""Register a callback invoked with the fault dict on every axis event."""
|
|
275
|
+
if callback not in self._axis_event_callbacks:
|
|
276
|
+
self._axis_event_callbacks.append(callback)
|
|
277
|
+
|
|
278
|
+
def unregister_axis_event_callback(self, callback):
|
|
279
|
+
"""Unregister a previously registered axis-event callback."""
|
|
280
|
+
if callback in self._axis_event_callbacks:
|
|
281
|
+
self._axis_event_callbacks.remove(callback)
|
|
282
|
+
|
|
283
|
+
def setAxisMode(self, node, mode, axis=1):
|
|
284
|
+
"""Set the closed-loop mode of a motor-slave axis.
|
|
285
|
+
|
|
286
|
+
:param node: CAN node id of the motor slave
|
|
287
|
+
:param mode: 0/1/2/3 or "OPEN_LOOP"/"MONITOR"/"CORRECT"/"SERVO"
|
|
288
|
+
:param axis: OD sub-index (the slave's motor axis id, usually 1)
|
|
289
|
+
:return: True on success, False otherwise (never raises)
|
|
290
|
+
"""
|
|
291
|
+
if isinstance(mode, str):
|
|
292
|
+
inv = {v: k for k, v in self._AXIS_MODE_NAMES.items()}
|
|
293
|
+
mode = inv.get(mode.upper(), 0)
|
|
294
|
+
ok = self._axis_sdo(node, self._AXIS_MODE, axis, "w", "u8", int(mode))
|
|
295
|
+
return ok is True
|
|
296
|
+
|
|
297
|
+
def getAxisFeedback(self, node, axis=1):
|
|
298
|
+
"""Read a motor-slave axis's live feedback (all values in STEPS).
|
|
299
|
+
|
|
300
|
+
:return: dict with commandedSteps, measuredSteps, positionErrorSteps,
|
|
301
|
+
mode, health, fault (ints + *_name), calibrated, referenced,
|
|
302
|
+
rawCounts. When the firmware has no encoder support the read fails
|
|
303
|
+
and OPEN_LOOP / zero-error defaults are returned; never raises.
|
|
304
|
+
"""
|
|
305
|
+
measured = self._axis_sdo(node, self._AXIS_MEASURED_STEPS, axis, "r", "i32")
|
|
306
|
+
if measured is None:
|
|
307
|
+
# No encoder / no axis support — uniform, non-raising default.
|
|
308
|
+
return {"commandedSteps": 0, "measuredSteps": 0, "positionErrorSteps": 0,
|
|
309
|
+
"mode": 0, "mode_name": "OPEN_LOOP", "health": 0, "health_name": "OK",
|
|
310
|
+
"fault": 0, "fault_name": "NONE", "calibrated": False,
|
|
311
|
+
"referenced": False, "rawCounts": 0}
|
|
312
|
+
posErr = self._axis_sdo(node, self._AXIS_POSITION_ERROR_STEPS, axis, "r", "i32") or 0
|
|
313
|
+
mode = self._axis_sdo(node, self._AXIS_MODE, axis, "r", "u8") or 0
|
|
314
|
+
health = self._axis_sdo(node, self._AXIS_HEALTH, axis, "r", "u8") or 0
|
|
315
|
+
fault = self._axis_sdo(node, self._AXIS_FAULT, axis, "r", "u8") or 0
|
|
316
|
+
calibrated = self._axis_sdo(node, self._AXIS_CALIBRATED, axis, "r", "u8") or 0
|
|
317
|
+
referenced = self._axis_sdo(node, self._AXIS_REFERENCED, axis, "r", "u8") or 0
|
|
318
|
+
rawCounts = self._axis_sdo(node, self._AXIS_RAW_COUNTS, axis, "r", "i32") or 0
|
|
319
|
+
return {
|
|
320
|
+
"commandedSteps": int(measured) - int(posErr),
|
|
321
|
+
"measuredSteps": int(measured),
|
|
322
|
+
"positionErrorSteps": int(posErr),
|
|
323
|
+
"mode": int(mode), "mode_name": self._AXIS_MODE_NAMES.get(int(mode), "?"),
|
|
324
|
+
"health": int(health), "health_name": self._AXIS_HEALTH_NAMES.get(int(health), "?"),
|
|
325
|
+
"fault": int(fault), "fault_name": self._AXIS_FAULT_NAMES.get(int(fault), "?"),
|
|
326
|
+
"calibrated": bool(calibrated), "referenced": bool(referenced),
|
|
327
|
+
"rawCounts": int(rawCounts),
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
def resetAxis(self, node, policy="TRUST_ENCODER", axis=1):
|
|
331
|
+
"""Clear a latched fault and re-establish the axis origin.
|
|
332
|
+
|
|
333
|
+
:param policy: "TRUST_ENCODER"|"TRUST_STEPS"|"FORCE_REHOME" or 1/2/3
|
|
334
|
+
:return: True on success (never raises)
|
|
335
|
+
"""
|
|
336
|
+
if isinstance(policy, str):
|
|
337
|
+
policy = self._AXIS_RESET_POLICIES.get(policy.upper(), 1)
|
|
338
|
+
ok = self._axis_sdo(node, self._AXIS_RESET, axis, "w", "u8", int(policy))
|
|
339
|
+
return ok is True
|
|
340
|
+
|
|
341
|
+
def getAxisCalibration(self, node, axis=1):
|
|
342
|
+
"""Read the persisted calibration of a motor-slave axis.
|
|
343
|
+
|
|
344
|
+
:return: dict with countsPerStepQ16 (signed Q16.16), backlashSteps,
|
|
345
|
+
calibrated. None fields default to 0/False; never raises.
|
|
346
|
+
"""
|
|
347
|
+
cps = self._axis_sdo(node, self._AXIS_COUNTS_PER_STEP_Q16, axis, "r", "i32")
|
|
348
|
+
backlash = self._axis_sdo(node, self._AXIS_BACKLASH_STEPS, axis, "r", "i32")
|
|
349
|
+
calibrated = self._axis_sdo(node, self._AXIS_CALIBRATED, axis, "r", "u8")
|
|
350
|
+
return {
|
|
351
|
+
"countsPerStepQ16": int(cps) if cps is not None else 0,
|
|
352
|
+
"countsPerStep": (int(cps) / 65536.0) if cps is not None else 0.0,
|
|
353
|
+
"backlashSteps": int(backlash) if backlash is not None else 0,
|
|
354
|
+
"calibrated": bool(calibrated) if calibrated is not None else False,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
def calibrateAxis(self, node, axis=1, wait=True, timeout=30, **params):
|
|
358
|
+
"""Trigger the firmware sign/scale/backlash calibration routine.
|
|
359
|
+
|
|
360
|
+
The routine runs ON the slave (it moves the motor through probe moves);
|
|
361
|
+
`params` are accepted for API forward-compatibility but the firmware
|
|
362
|
+
currently uses its built-in defaults. With ``wait=True`` this polls until
|
|
363
|
+
the axis reports calibrated (or ``timeout`` s) and returns the resulting
|
|
364
|
+
calibration dict; otherwise returns {"status": "triggered"}.
|
|
365
|
+
"""
|
|
366
|
+
ok = self._axis_sdo(node, self._AXIS_CALIBRATE, axis, "w", "u8", 1)
|
|
367
|
+
if ok is not True:
|
|
368
|
+
return {"status": "error", "error": "calibration trigger failed (no encoder?)"}
|
|
369
|
+
if not wait:
|
|
370
|
+
return {"status": "triggered"}
|
|
371
|
+
start = time.time()
|
|
372
|
+
while time.time() - start < timeout:
|
|
373
|
+
cal = self.getAxisCalibration(node, axis)
|
|
374
|
+
fb = self.getAxisFeedback(node, axis)
|
|
375
|
+
if cal["calibrated"] or fb["fault_name"] in ("CAL_FAILED",):
|
|
376
|
+
cal["status"] = "ok" if cal["calibrated"] else "failed"
|
|
377
|
+
cal["fault"] = fb["fault_name"]
|
|
378
|
+
return cal
|
|
379
|
+
time.sleep(0.5)
|
|
380
|
+
return {"status": "timeout"}
|
|
381
|
+
|
|
382
|
+
|
|
197
383
|
def setTrigger(self, axis="X", pin=1, offset=0, period=1):
|
|
198
384
|
# {"task": "/motor_act", "setTrig": {"steppers": [{"stepperid": 1, "trigPin": 1, "trigOff":0, "trigPer":1}]}}
|
|
199
385
|
if type(axis) is not int:
|
|
@@ -373,10 +373,10 @@ class Serial:
|
|
|
373
373
|
except Exception as e:
|
|
374
374
|
self._logger.error("Error in checkFirmware: "+str(e))
|
|
375
375
|
return False
|
|
376
|
-
|
|
376
|
+
|
|
377
377
|
def _generate_identifier(self):
|
|
378
|
-
self.identifier_counter
|
|
379
|
-
return self.identifier_counter
|
|
378
|
+
self.identifier_counter = (self.identifier_counter % 254) + 1
|
|
379
|
+
return self.identifier_counter # 1..254, never 0
|
|
380
380
|
|
|
381
381
|
def _process_commands(self):
|
|
382
382
|
buffer = ""
|
|
@@ -836,4 +836,4 @@ if __name__ == "__main__":
|
|
|
836
836
|
#response = monitor.waitForResponse(command_id)
|
|
837
837
|
|
|
838
838
|
if response:
|
|
839
|
-
print("Response:", response)
|
|
839
|
+
print("Response:", response)
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""PTZ keyboard interface for the UC2 CAN PTZ-bridge slave.
|
|
2
|
+
|
|
3
|
+
A low-cost CCTV PTZ joystick keyboard (Pelco-D/P over RS-485) is read by a
|
|
4
|
+
dedicated CAN bridge node (XIAO, default CAN node-id 61). The bridge maps the
|
|
5
|
+
joystick to motor moves directly on the bus, and forwards every DISCRETE key
|
|
6
|
+
(presets, AUX, iris) to the master, which prints an asynchronous frame:
|
|
7
|
+
|
|
8
|
+
{"ptz":{"event":1,"node":61,"type":6,"name":"iris_open","arg":0,"seq":21}}
|
|
9
|
+
|
|
10
|
+
type/name: 1 preset_call 2 preset_set 3 preset_clear
|
|
11
|
+
4 aux_on 5 aux_off 6 iris_open
|
|
12
|
+
7 iris_close 8 camera_onoff 9 autoscan
|
|
13
|
+
arg: preset number (1..255) or AUX number (1..6); 0 otherwise
|
|
14
|
+
seq: wraps 0..255, one per key event (the master de-dupes on it)
|
|
15
|
+
|
|
16
|
+
This mirrors the collision "gpio" event plumbing (see gpio.py): a single
|
|
17
|
+
serial callback on the top-level "ptz" key, an "event":1 marker to tell a
|
|
18
|
+
pushed key from a /ptz_get response, and a list of user callbacks. ImSwitch
|
|
19
|
+
registers one such callback and maps each key to a microscope function
|
|
20
|
+
(snap image, switch objective, toggle an LED …) — see UC2ConfigController.
|
|
21
|
+
|
|
22
|
+
Debug / config endpoints (handled locally on the bridge node):
|
|
23
|
+
{"task":"/ptz_act","debug":2} raw RS-485 hex dump (wiring bring-up)
|
|
24
|
+
{"task":"/ptz_act","debug":1} decoded frame JSON
|
|
25
|
+
{"task":"/ptz_act","addr":3} accept only camera address 3 (0=any)
|
|
26
|
+
{"task":"/ptz_act","timeout":2000} motion watchdog ms (0=off)
|
|
27
|
+
{"task":"/ptz_get"} parser stats + last frame + motion
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PTZ(object):
|
|
32
|
+
def __init__(self, parent):
|
|
33
|
+
self._parent = parent
|
|
34
|
+
|
|
35
|
+
# last known state (merged from /ptz_get responses and async events)
|
|
36
|
+
self.status = {}
|
|
37
|
+
# last discrete key event pushed by the bridge
|
|
38
|
+
self.last_event = {}
|
|
39
|
+
|
|
40
|
+
# register a callback for any {"ptz":{...}} frame on the serial loop
|
|
41
|
+
if hasattr(self._parent, "serial"):
|
|
42
|
+
self._parent.serial.register_callback(self._callback_ptz, pattern="ptz")
|
|
43
|
+
|
|
44
|
+
# user-registered callbacks
|
|
45
|
+
self._event_callbacks = [] # fired on async key events (event==1)
|
|
46
|
+
self._status_callbacks = [] # fired on every ptz frame
|
|
47
|
+
|
|
48
|
+
# ─────────────────────────────────────────────────────────────────
|
|
49
|
+
# Async event plumbing
|
|
50
|
+
# ─────────────────────────────────────────────────────────────────
|
|
51
|
+
def _callback_ptz(self, data):
|
|
52
|
+
"""Handle any serial frame with a top-level "ptz" key.
|
|
53
|
+
|
|
54
|
+
Pushed key events carry "event":1 and fire the event callbacks;
|
|
55
|
+
/ptz_get responses only refresh the cached status."""
|
|
56
|
+
try:
|
|
57
|
+
ptz = data.get("ptz", None)
|
|
58
|
+
if not isinstance(ptz, dict):
|
|
59
|
+
return
|
|
60
|
+
self.status.update(ptz)
|
|
61
|
+
|
|
62
|
+
for cb in self._status_callbacks:
|
|
63
|
+
try:
|
|
64
|
+
cb(dict(self.status))
|
|
65
|
+
except Exception as e:
|
|
66
|
+
print(f"Error in ptz status callback: {e}")
|
|
67
|
+
|
|
68
|
+
if ptz.get("event", 0):
|
|
69
|
+
self.last_event = dict(ptz)
|
|
70
|
+
for cb in self._event_callbacks:
|
|
71
|
+
try:
|
|
72
|
+
cb(dict(ptz))
|
|
73
|
+
except Exception as e:
|
|
74
|
+
print(f"Error in ptz event callback: {e}")
|
|
75
|
+
except Exception as e:
|
|
76
|
+
print("Error in _callback_ptz: ", e)
|
|
77
|
+
|
|
78
|
+
def register_event_callback(self, callbackfct):
|
|
79
|
+
"""Register a function called on every asynchronously pushed PTZ key
|
|
80
|
+
event (preset call/set/clear, AUX on/off, iris, …). The callback
|
|
81
|
+
receives the event dict, e.g.
|
|
82
|
+
{"event":1,"node":61,"type":1,"name":"preset_call","arg":1,"seq":7}.
|
|
83
|
+
Use ["name"] and ["arg"] to decide which microscope function to run."""
|
|
84
|
+
if callbackfct not in self._event_callbacks:
|
|
85
|
+
self._event_callbacks.append(callbackfct)
|
|
86
|
+
|
|
87
|
+
def register_status_callback(self, callbackfct):
|
|
88
|
+
"""Register a function called on EVERY ptz frame (async key events and
|
|
89
|
+
/ptz_get responses alike) with the merged status dict."""
|
|
90
|
+
if callbackfct not in self._status_callbacks:
|
|
91
|
+
self._status_callbacks.append(callbackfct)
|
|
92
|
+
|
|
93
|
+
# ─────────────────────────────────────────────────────────────────
|
|
94
|
+
# Query / configuration
|
|
95
|
+
# ─────────────────────────────────────────────────────────────────
|
|
96
|
+
@staticmethod
|
|
97
|
+
def _extract_ptz(response):
|
|
98
|
+
"""Unwrap a post_json return into the "ptz" dict.
|
|
99
|
+
|
|
100
|
+
mserial.sendMessage returns a LIST of response dicts on success, a
|
|
101
|
+
plain string on timeout, or an int qid — handle all shapes (same
|
|
102
|
+
defensive pattern as GPIO._extract_gpio)."""
|
|
103
|
+
candidates = response if isinstance(response, list) else [response]
|
|
104
|
+
for item in candidates:
|
|
105
|
+
if isinstance(item, dict):
|
|
106
|
+
ptz = item.get("ptz")
|
|
107
|
+
if isinstance(ptz, dict):
|
|
108
|
+
return ptz
|
|
109
|
+
return {}
|
|
110
|
+
|
|
111
|
+
def get_status(self, node=None, timeout=1):
|
|
112
|
+
"""Poll the bridge: parser stats (bytesIn/framesOk/resyncs), the last
|
|
113
|
+
decoded frame and the current motion snapshot. Returns the "ptz" dict.
|
|
114
|
+
Note: discrete key events are PUSHED (see register_event_callback);
|
|
115
|
+
polling is only for diagnostics."""
|
|
116
|
+
path = "/ptz_get"
|
|
117
|
+
payload = {"task": path}
|
|
118
|
+
if node is not None:
|
|
119
|
+
payload["node"] = node
|
|
120
|
+
r = self._parent.post_json(path, payload, getReturn=True, timeout=timeout)
|
|
121
|
+
ptz = self._extract_ptz(r)
|
|
122
|
+
if ptz:
|
|
123
|
+
self.status.update(ptz)
|
|
124
|
+
return ptz
|
|
125
|
+
return dict(self.status)
|
|
126
|
+
|
|
127
|
+
def _act(self, node=None, timeout=1, **fields):
|
|
128
|
+
path = "/ptz_act"
|
|
129
|
+
payload = {"task": path}
|
|
130
|
+
if node is not None:
|
|
131
|
+
payload["node"] = node
|
|
132
|
+
payload.update(fields)
|
|
133
|
+
return self._parent.post_json(path, payload, getReturn=True, timeout=timeout)
|
|
134
|
+
|
|
135
|
+
def set_debug(self, level, node=None, timeout=1):
|
|
136
|
+
"""Bridge serial debug verbosity: 0 quiet, 1 decoded frame JSON,
|
|
137
|
+
2 + raw RS-485 hex dump (use for wiring / baud / A-B-swap bring-up)."""
|
|
138
|
+
return self._act(node=node, timeout=timeout, debug=int(level))
|
|
139
|
+
|
|
140
|
+
def set_address(self, addr, node=None, timeout=1):
|
|
141
|
+
"""Only accept keyboard frames for this Pelco camera address
|
|
142
|
+
(0 = accept any). Handy when several bridges share one keyboard."""
|
|
143
|
+
return self._act(node=node, timeout=timeout, addr=int(addr))
|
|
144
|
+
|
|
145
|
+
def set_motion_timeout(self, timeout_ms, node=None, serial_timeout=1):
|
|
146
|
+
"""Watchdog (ms): stop all axes if the keyboard goes silent while an
|
|
147
|
+
axis is moving (0 = off). Guards against a lost stop frame.
|
|
148
|
+
|
|
149
|
+
Built explicitly (not via _act) because the firmware field is itself
|
|
150
|
+
named "timeout", which would clash with the serial-call timeout kwarg."""
|
|
151
|
+
path = "/ptz_act"
|
|
152
|
+
payload = {"task": path, "timeout": int(timeout_ms)}
|
|
153
|
+
if node is not None:
|
|
154
|
+
payload["node"] = node
|
|
155
|
+
return self._parent.post_json(path, payload, getReturn=True,
|
|
156
|
+
timeout=serial_timeout)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|