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.
Files changed (47) hide show
  1. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/PKG-INFO +1 -1
  2. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/PKG-INFO +1 -1
  3. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/SOURCES.txt +1 -0
  4. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/UC2Client.py +4 -0
  5. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/__version__.py +1 -1
  6. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/galvo.py +67 -15
  7. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/motor.py +188 -2
  8. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/mserial.py +4 -4
  9. uc2_rest-0.2.0.40/uc2rest/ptz.py +156 -0
  10. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/LICENSE +0 -0
  11. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/README.md +0 -0
  12. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/dependency_links.txt +0 -0
  13. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/not-zip-safe +0 -0
  14. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/requires.txt +0 -0
  15. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/UC2_REST.egg-info/top_level.txt +0 -0
  16. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/setup.cfg +0 -0
  17. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/setup.py +0 -0
  18. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/MockSerial.py +0 -0
  19. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/__init__.py +0 -0
  20. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/analog.py +0 -0
  21. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/camera.py +0 -0
  22. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/camera_trigger.py +0 -0
  23. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/can.py +0 -0
  24. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/canota.py +0 -0
  25. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/cmdrecorder.py +0 -0
  26. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/digitalin.py +0 -0
  27. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/digitalout.py +0 -0
  28. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/fan.py +0 -0
  29. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/gpio.py +0 -0
  30. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/gripper.py +0 -0
  31. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/home.py +0 -0
  32. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/i2c.py +0 -0
  33. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/laser.py +0 -0
  34. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/lcddisplay.py +0 -0
  35. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/ledmatrix.py +0 -0
  36. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/logger.py +0 -0
  37. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/message.py +0 -0
  38. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/modules.py +0 -0
  39. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/motor_config.py +0 -0
  40. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/objective.py +0 -0
  41. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/pid.py +0 -0
  42. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/rotator.py +0 -0
  43. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/slm.py +0 -0
  44. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/state.py +0 -0
  45. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/temperature.py +0 -0
  46. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/utils.py +0 -0
  47. {uc2_rest-0.2.0.38 → uc2_rest-0.2.0.40}/uc2rest/wifi.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UC2-REST
3
- Version: 0.2.0.38
3
+ Version: 0.2.0.40
4
4
  Summary: This pacage will help you to drive the ESP32-driven microscopy control modules from UC2
5
5
  Home-page: https://github.com/openUC2/UC2-REST
6
6
  Author: Benedict Diederich
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: UC2-REST
3
- Version: 0.2.0.38
3
+ Version: 0.2.0.40
4
4
  Summary: This pacage will help you to drive the ESP32-driven microscopy control modules from UC2
5
5
  Home-page: https://github.com/openUC2/UC2-REST
6
6
  Author: Benedict Diederich
@@ -36,6 +36,7 @@ uc2rest/motor_config.py
36
36
  uc2rest/mserial.py
37
37
  uc2rest/objective.py
38
38
  uc2rest/pid.py
39
+ uc2rest/ptz.py
39
40
  uc2rest/rotator.py
40
41
  uc2rest/slm.py
41
42
  uc2rest/state.py
@@ -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.38"
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, bidirectional=True)
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
- #"laser_trigger": laser_trigger,
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 += 1
379
- return self.identifier_counter % 255 # Wrap around after 1024 to avoid large numbers
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