UC2-REST 0.2.0.34__tar.gz → 0.2.0.36__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 (45) hide show
  1. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/PKG-INFO +1 -1
  2. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/PKG-INFO +1 -1
  3. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/UC2Client.py +3 -3
  4. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/__version__.py +1 -1
  5. uc2_rest-0.2.0.36/uc2rest/can.py +282 -0
  6. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/canota.py +29 -1
  7. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/home.py +2 -0
  8. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/motor.py +60 -12
  9. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/mserial.py +91 -5
  10. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/state.py +35 -0
  11. uc2_rest-0.2.0.34/uc2rest/can.py +0 -157
  12. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/LICENSE +0 -0
  13. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/README.md +0 -0
  14. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/SOURCES.txt +0 -0
  15. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/dependency_links.txt +0 -0
  16. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/not-zip-safe +0 -0
  17. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/requires.txt +0 -0
  18. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/top_level.txt +0 -0
  19. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/setup.cfg +0 -0
  20. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/setup.py +0 -0
  21. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/MockSerial.py +0 -0
  22. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/__init__.py +0 -0
  23. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/analog.py +0 -0
  24. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/camera.py +0 -0
  25. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/camera_trigger.py +0 -0
  26. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/cmdrecorder.py +0 -0
  27. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/digitalin.py +0 -0
  28. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/digitalout.py +0 -0
  29. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/fan.py +0 -0
  30. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/galvo.py +0 -0
  31. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/gripper.py +0 -0
  32. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/laser.py +0 -0
  33. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/lcddisplay.py +0 -0
  34. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/ledmatrix.py +0 -0
  35. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/logger.py +0 -0
  36. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/message.py +0 -0
  37. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/modules.py +0 -0
  38. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/motor_config.py +0 -0
  39. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/objective.py +0 -0
  40. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/pid.py +0 -0
  41. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/rotator.py +0 -0
  42. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/slm.py +0 -0
  43. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/temperature.py +0 -0
  44. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/utils.py +0 -0
  45. {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/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.34
3
+ Version: 0.2.0.36
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.34
3
+ Version: 0.2.0.36
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
@@ -47,9 +47,9 @@ class UC2Client(object):
47
47
  is_serial = False
48
48
  BAUDRATE = 115200
49
49
 
50
- def __init__(self, host=None, port=31950, serialport=None, identity="UC2_Feather", baudrate=BAUDRATE,
50
+ def __init__(self, host=None, port=31950, serialport=None, identity="UC2_Feather", baudrate=BAUDRATE,
51
51
  NLeds=64, SerialManager=None, DEBUG=False, logger=None, skipFirmwareCheck=False,
52
- isPyScript=False):
52
+ isPyScript=False, device_id=None, requireMaster=False):
53
53
  '''
54
54
  This client connects to the UC2-REST microcontroller that can be found here
55
55
  https://github.com/openUC2/UC2-REST
@@ -80,7 +80,7 @@ class UC2Client(object):
80
80
  # initialize communication channel (serial only)
81
81
  if serialport is not None:
82
82
  # use USB connection
83
- self.serial = Serial(serialport, baudrate, parent=self, identity=identity, DEBUG=DEBUG, skipFirmwareCheck=skipFirmwareCheck)
83
+ self.serial = Serial(serialport, baudrate, parent=self, identity=identity, DEBUG=DEBUG, skipFirmwareCheck=skipFirmwareCheck, device_id=device_id, requireMaster=requireMaster)
84
84
  self.is_serial = True
85
85
  self.is_connected = self.serial.is_connected
86
86
  self.serial.DEBUG = DEBUG
@@ -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.34"
9
+ __version__ = "0.2.0.36"
10
10
  __author__ = 'Benedict Diederich'
11
11
  __author_email__ = 'benedictdied@gmail.com'
12
12
  __license__ = 'GPL v3'
@@ -0,0 +1,282 @@
1
+ class CAN(object):
2
+ def __init__(self, parent):
3
+ """
4
+ CANController handles sending commands to a remote CAN device via the parent post_json interface.
5
+
6
+ :param parent: Parent object with post_json(path, payload, getReturn, timeout, nResponses)
7
+ """
8
+ self._parent = parent
9
+
10
+ # Store latest scan results
11
+ self.scanResults = []
12
+ self.deviceCount = 0
13
+
14
+ # Register a callback function for the CAN status on the serial loop
15
+ if hasattr(self._parent, "serial"):
16
+ self._parent.serial.register_callback(self._callback_can_status, pattern="scan")
17
+
18
+ # Announce a function that is called when we receive a CAN scan update through the callback
19
+ self._callbackPerKey = {}
20
+ self.nCallbacks = 10
21
+ self._callbackPerKey = self.init_callback_functions(nCallbacks=self.nCallbacks)
22
+ print(self._callbackPerKey)
23
+
24
+ def init_callback_functions(self, nCallbacks=10):
25
+ """Initialize the callback functions."""
26
+ _callbackPerKey = {}
27
+ self.nCallbacks = nCallbacks
28
+ for i in range(nCallbacks):
29
+ _callbackPerKey[i] = []
30
+ return _callbackPerKey
31
+
32
+ def _callback_can_status(self, data):
33
+ """
34
+ Cast the json in the form:
35
+ {
36
+ "scan": [
37
+ {"canId": 20, "deviceType": 1, "status": 0, "deviceTypeStr": "laser", "statusStr": "idle"},
38
+ {"canId": 10, "deviceType": 0, "status": 0, "deviceTypeStr": "motor", "statusStr": "idle"}
39
+ ],
40
+ "qid": 2,
41
+ "count": 1
42
+ }
43
+ into the scan results array.
44
+ """
45
+ try:
46
+ if "scan" in data:
47
+ self.scanResults = data["scan"]
48
+ self.deviceCount = data.get("count", len(self.scanResults))
49
+
50
+ # Call registered callback function with scan results
51
+ if callable(self._callbackPerKey[0]):
52
+ self._callbackPerKey[0](self.scanResults)
53
+ except Exception as e:
54
+ print("Error in _callback_can_status: ", e)
55
+
56
+ def register_callback(self, key, callbackfct):
57
+ """Register a callback function for a specific key."""
58
+ self._callbackPerKey[key] = callbackfct
59
+
60
+ def get_can_ids(self):
61
+ """Return CAN node IDs from the latest scan (e.g. for OTA flashing)."""
62
+ return [entry.get("canId") for entry in self.scanResults
63
+ if entry.get("canId") is not None]
64
+
65
+ def reboot_remote(self, qid=1, can_address=0, isBlocking=False, timeout=2):
66
+ """
67
+ Reboot a CAN device.
68
+
69
+ - ``can_address == 0`` reboots the master (this ESP32) itself.
70
+ - ``can_address in 1..127`` reboots a remote slave by SDO-writing 1
71
+ to OD index 0x2507 sub 0 on the target node. The slave's
72
+ CO_tmr_task observes the write and calls ESP.restart() ~200 ms
73
+ later.
74
+
75
+ :param qid: Query ID for the CAN command (unused by firmware, kept
76
+ for API compatibility)
77
+ :param can_address: 0 = master, 1..127 = remote slave nodeId
78
+ :param isBlocking: If True, wait for response
79
+ :param timeout: Timeout for the command in seconds
80
+ :return: Response from the device, e.g.
81
+ ``{"status":"ok","nodeId":11}`` or
82
+ ``{"status":"error","error":"SDO write failed","nodeId":11}``.
83
+ """
84
+ path = "/can_act"
85
+ payload = {
86
+ "task": path,
87
+ "restart": int(can_address)
88
+ }
89
+ nResponses = 1 if isBlocking else 0
90
+ # Send the payload to the parent, which handles the actual communication
91
+ return self._parent.post_json(
92
+ path,
93
+ payload,
94
+ getReturn=isBlocking,
95
+ timeout=timeout if isBlocking else 0,
96
+ nResponses=nResponses
97
+ )
98
+
99
+ def scan(self, qid=1, timeout=5, probe_range=False, id_from=1, id_to=127):
100
+ """
101
+ Scan the CAN bus for connected devices.
102
+
103
+ :param qid: Query ID for the CAN command (default: 1)
104
+ :param timeout: Timeout for the scan in seconds (default: 5)
105
+ :param probe_range: if True, additionally SDO-probe node-ids
106
+ ``id_from..id_to`` for their MAC so brand-new / unrouted nodes are
107
+ discovered (reported with deviceTypeStr "unrouted"). Absent ids
108
+ fast-fail on the firmware side. Use this for MAC-keyed provisioning.
109
+ :param id_from: first node-id to probe when ``probe_range`` is set
110
+ :param id_to: last node-id to probe when ``probe_range`` is set
111
+ :return: Response containing scan results with device information.
112
+ Reachable nodes are SDO-probed for their firmware build
113
+ timestamp (OD 0x2508), version (0x2500) and MAC (0x2509);
114
+ the master reports its own identity under the "master" key.
115
+ Example: {
116
+ "master": {"canId": 1, "build": "Jun 22 2026 14:30:11",
117
+ "fwVersion": "UC2-ESP v2.0", "mac": "AA:BB:CC:DD:EE:01"},
118
+ "scan": [
119
+ {"canId": 10, "deviceType": 0, "deviceTypeStr": "motor",
120
+ "status": 0, "statusStr": "idle",
121
+ "build": "Jun 22 2026 14:31:02", "fwVersion": "UC2-ESP v2.0",
122
+ "mac": "AA:BB:CC:DD:EE:10"},
123
+ {"canId": 20, "deviceType": 1, "deviceTypeStr": "laser",
124
+ "status": 1, "statusStr": "unreachable"}
125
+ ],
126
+ "qid": 1,
127
+ "count": 2
128
+ }
129
+ Note: "build"/"fwVersion"/"mac" are only present for nodes that
130
+ answered the SDO probe (i.e. statusStr == "idle").
131
+ """
132
+ path = "/can_act" # {"task":"/can_act", "scan": true}
133
+ payload = {
134
+ "task": path,
135
+ "scan": True,
136
+ "qid": qid
137
+ }
138
+ if probe_range:
139
+ payload["probeRange"] = True
140
+ payload["from"] = int(id_from)
141
+ payload["to"] = int(id_to)
142
+ return self._parent.post_json(
143
+ path,
144
+ payload,
145
+ getReturn=True,
146
+ timeout=timeout,
147
+ nResponses=1
148
+ )
149
+
150
+ def discover(self, qid=1, timeout=8, id_from=1, id_to=127):
151
+ """
152
+ Discover all nodes on the bus by SDO-probing a node-id range.
153
+
154
+ Convenience wrapper around ``scan(probe_range=True, ...)`` — finds nodes
155
+ that aren't in the master's routing table yet (e.g. freshly flashed
156
+ boards), reporting their MAC + build info so they can be provisioned by
157
+ MAC via :meth:`assign_node_id_by_mac`.
158
+
159
+ :return: same shape as :meth:`scan`; probed-but-unrouted nodes carry
160
+ deviceTypeStr "unrouted".
161
+ """
162
+ return self.scan(qid=qid, timeout=timeout,
163
+ probe_range=True, id_from=id_from, id_to=id_to)
164
+
165
+ def get_available_devices(self, timeout=2):
166
+ """
167
+ Get list of available CAN devices.
168
+
169
+ :param timeout: Timeout for the command in seconds (default: 2)
170
+ :return: Response containing available CAN IDs and other bus information
171
+ Example: {
172
+ "input": {"task": "/can_get"},
173
+ "address": 1,
174
+ "addresspref": 1,
175
+ "addressgetcan": 1,
176
+ "nonworking": [0,0,0,...],
177
+ "available": [20,0,0,...],
178
+ "rx": 18,
179
+ "tx": 17
180
+ }
181
+ """
182
+ path = "/can_act"
183
+ payload = {
184
+ "task": path,
185
+ "scan": True
186
+ }
187
+ return self._parent.post_json(
188
+ path,
189
+ payload,
190
+ getReturn=True,
191
+ timeout=timeout,
192
+ nResponses=2
193
+ )
194
+
195
+ def get_device_build_info(self):
196
+ """
197
+ Return per-node firmware build info from the latest scan results.
198
+
199
+ :return: dict keyed by CAN id, e.g.
200
+ {10: {"build": "Jun 22 2026 14:31:02",
201
+ "fwVersion": "UC2-ESP v2.0",
202
+ "mac": "AA:BB:CC:DD:EE:10"}}
203
+ Only nodes that answered the SDO probe during the last scan()
204
+ appear here. Call scan() first to refresh.
205
+ """
206
+ info = {}
207
+ for entry in self.scanResults:
208
+ cid = entry.get("canId")
209
+ if cid is None:
210
+ continue
211
+ fields = {k: entry[k] for k in ("build", "fwVersion", "mac")
212
+ if k in entry}
213
+ if fields:
214
+ info[cid] = fields
215
+ return info
216
+
217
+ def set_remote_node_id(self, new_id, target=None, by_mac=None,
218
+ expect_mac=None, qid=1, isBlocking=True, timeout=4):
219
+ """
220
+ Reassign a remote node's CAN id over the bus (SDO write to OD 0x250A).
221
+
222
+ Identify the node EITHER by its current id (``target``, optionally with
223
+ ``expect_mac`` for a safety check) OR purely by its MAC (``by_mac``), in
224
+ which case the firmware probes the bus to find which id currently has
225
+ that MAC — use this when you don't know the current id. ``new_id`` is
226
+ always the desired new id.
227
+
228
+ The slave persists the new id to NVS and performs a CANopen
229
+ communication reset, so it reappears at ``new_id`` after ~0.3 s.
230
+
231
+ :param new_id: desired CAN id (1..127)
232
+ :param target: current CAN id (1..127) of the node to reconfigure
233
+ :param by_mac: target MAC "AA:BB:CC:DD:EE:FF" — firmware finds the node
234
+ (use instead of ``target`` when the current id is unknown)
235
+ :param expect_mac: when using ``target``, verify the node's MAC matches
236
+ before committing (id only ever binds to the intended device)
237
+ :param qid: Query ID (kept for API symmetry)
238
+ :param isBlocking: wait for the firmware acknowledgement
239
+ :param timeout: command timeout in seconds
240
+ :return: firmware response, e.g. ``{"status":"ok","target":60,"newId":70}``
241
+ or ``{"status":"error","error":"MAC not found on bus","mac":"..."}``
242
+ """
243
+ if by_mac is None and target is None:
244
+ raise ValueError("set_remote_node_id requires either target or by_mac")
245
+ path = "/can_act"
246
+ payload = {
247
+ "task": path,
248
+ "setRemoteNodeId": int(new_id),
249
+ "qid": qid,
250
+ }
251
+ if by_mac is not None:
252
+ payload["byMac"] = str(by_mac)
253
+ else:
254
+ payload["target"] = int(target)
255
+ if expect_mac is not None:
256
+ payload["expectMac"] = str(expect_mac)
257
+ nResponses = 1 if isBlocking else 0
258
+ return self._parent.post_json(
259
+ path,
260
+ payload,
261
+ getReturn=isBlocking,
262
+ timeout=timeout if isBlocking else 0,
263
+ nResponses=nResponses,
264
+ )
265
+
266
+ def assign_node_id_by_mac(self, mac, new_id, qid=1, timeout=5):
267
+ """
268
+ Assign a CAN id to the node with the given MAC address.
269
+
270
+ The firmware probes the bus to locate whichever node currently
271
+ advertises ``mac`` and reassigns it to ``new_id`` in a single round-trip
272
+ (no need to know the node's current id). This is the canonical
273
+ MAC-keyed provisioning call.
274
+
275
+ :param mac: target MAC as "AA:BB:CC:DD:EE:FF" (case-insensitive)
276
+ :param new_id: desired CAN id (1..127)
277
+ :param qid: Query ID
278
+ :param timeout: request timeout in seconds
279
+ :return: firmware response, e.g. ``{"status":"ok","target":60,"newId":70,
280
+ "mac":"..."}`` or ``{"status":"error","error":"MAC not found on bus"}``
281
+ """
282
+ return self.set_remote_node_id(new_id, by_mac=mac, qid=qid, timeout=timeout)
@@ -58,7 +58,11 @@ class CANOTA(object):
58
58
  """
59
59
  self._parent = parent
60
60
  self.nCallbacks = nCallbacks
61
-
61
+
62
+ # Threading event: set to True to request cancellation of any running
63
+ # streaming upload.
64
+ self._cancel_event = threading.Event()
65
+
62
66
  # Initialize callback functions for different types of OTA events
63
67
  self.init_callback_functions(self.nCallbacks)
64
68
 
@@ -294,6 +298,15 @@ class CANOTA(object):
294
298
  # MD5) and wait for ``{"ota_status":"success"|"error"}``.
295
299
  # ========================================================================
296
300
 
301
+ def cancel_streaming_ota(self):
302
+ """Request cancellation of any running streaming upload.
303
+
304
+ The upload worker checks this flag at every chunk boundary and will
305
+ abort cleanly (restoring the parent serial connection) on the next
306
+ iteration.
307
+ """
308
+ self._cancel_event.set()
309
+
297
310
  def start_can_streaming_ota(self, can_id: int, firmware_path: str,
298
311
  progress_callback=None, status_callback=None,
299
312
  port: str = None, baud: int = STREAMING_BAUD):
@@ -349,6 +362,8 @@ class CANOTA(object):
349
362
  crc32, num_chunks, progress_callback, status_callback)
350
363
  )
351
364
  upload_thread.daemon = True
365
+ # Reset any previous cancellation before starting the new upload
366
+ self._cancel_event.clear()
352
367
  upload_thread.start()
353
368
 
354
369
  return upload_thread
@@ -434,6 +449,12 @@ class CANOTA(object):
434
449
  chunk_no = 0
435
450
 
436
451
  while sent < firmware_size:
452
+ # Honour cancellation requests between chunks
453
+ if self._cancel_event.is_set():
454
+ if status_callback:
455
+ status_callback("Upload cancelled by user", False)
456
+ return
457
+
437
458
  end = min(sent + CHUNK_SIZE, firmware_size)
438
459
  payload = firmware_data[sent:end]
439
460
  chunk_no += 1
@@ -445,6 +466,11 @@ class CANOTA(object):
445
466
  # Block until the master ACKs at least ``sent`` bytes.
446
467
  deadline = time.time() + ACK_TIMEOUT_S
447
468
  while last_ack < sent:
469
+ # Check for cancellation inside the ACK wait loop as well
470
+ if self._cancel_event.is_set():
471
+ if status_callback:
472
+ status_callback("Upload cancelled by user", False)
473
+ return
448
474
  last_ack, err = self._drain_acks(ser, ack_buf, last_ack)
449
475
  if err is not None:
450
476
  if status_callback:
@@ -545,6 +571,8 @@ class CANOTA(object):
545
571
  buf = bytearray()
546
572
  while time.time() < deadline:
547
573
  chunk = ser.read(ser.in_waiting or 1)
574
+ # debug print of chunks
575
+ # self._parent.logger.debug(f"Received chunk: {chunk}")
548
576
  if chunk:
549
577
  buf.extend(chunk)
550
578
  obj = self._try_extract_json_status(bytes(buf))
@@ -322,6 +322,8 @@ class Home(object):
322
322
 
323
323
  timeout = timeout if isBlocking else 0
324
324
  nResponses = 2 # one for command received, one for home reached
325
+ if timeout and timeout > 1000:
326
+ timeout = timeout / 1000.0
325
327
 
326
328
  # if we get a return, we will receive the latest position feedback from the driver by means of the axis that moves the longest
327
329
  r = self._parent.post_json(path, payload, getReturn=isBlocking, timeout=timeout, nResponses=nResponses)
@@ -402,8 +402,10 @@ class Motor(object):
402
402
  return self.move_axis_by_name(axis="A", steps=steps, speed=speed, acceleration=acceleration, is_blocking=is_blocking, is_absolute=is_absolute, is_enabled=is_enabled, timeout=timeout, is_reduced=is_reduced)
403
403
 
404
404
  def move_xyz(self, steps=(0,0,0), speed=(1000,1000,1000), acceleration=None, is_blocking=False, is_absolute=False, is_enabled=True, timeout=gTIMEOUT, is_reduced=False):
405
- if len(speed)!= 3:
405
+ if not (type(speed)==list) or len(speed)!= 3:
406
406
  speed = (speed,speed,speed)
407
+ if acceleration is None:
408
+ acceleration = (self.DEFAULT_ACCELERATION,self.DEFAULT_ACCELERATION,self.DEFAULT_ACCELERATION)
407
409
 
408
410
  # motor axis is 1,2,3,0 => X,Y,Z,T # FIXME: Hardcoded
409
411
  r = self.move_xyza(steps=(0,steps[0],steps[1],steps[2]), acceleration=(0,acceleration[0],acceleration[1],acceleration[2]), speed=(0,speed[0],speed[1],speed[2]), is_blocking=is_blocking, is_absolute=is_absolute, is_enabled=is_enabled, timeout=timeout, is_reduced=is_reduced)
@@ -611,10 +613,15 @@ class Motor(object):
611
613
  if isAbsoluteArray[iMotor]:
612
614
  # Compare current position (physical) with target (physical, already includes offset)
613
615
  self.currentDirection[iMotor] = 1 if (self.currentPosition[iMotor] > targetPositionPhysical[iMotor]) else -1
614
- # Calculate distance to travel in HARDWARE STEPS:
615
- # Current position (physical) -> convert to steps, then subtract target (already in steps)
616
- currentPosition_steps = self.currentPosition[iMotor] / stepSizes[iMotor]
617
- absoluteDistances_steps[iMotor] = abs(currentPosition_steps - steps[iMotor])
616
+ # Travel distance for the time estimate = |current - target| in
617
+ # PHYSICAL units, converted to hardware steps. Computing it in
618
+ # physical units keeps it direction-agnostic: the hardware `steps`
619
+ # target carries the per-axis direction sign, so subtracting it
620
+ # from an unsigned current-in-steps produced a *sum* (not a
621
+ # difference) on inverted axes, hugely inflating the estimate.
622
+ absoluteDistances_steps[iMotor] = abs(
623
+ self.currentPosition[iMotor] - targetPositionPhysical[iMotor]
624
+ ) / stepSizes[iMotor]
618
625
  else:
619
626
  self.currentDirection[iMotor] = np.sign(steps[iMotor])
620
627
  # For relative motion, steps[iMotor] is already the distance in hardware steps
@@ -627,19 +634,20 @@ class Motor(object):
627
634
  if not isAbsoluteArray[iMotor]:
628
635
  absoluteDistances_steps[iMotor] = abs(steps[iMotor])
629
636
 
630
- # Convert speed and acceleration from physical units to steps/second
637
+ # Speed and acceleration are already in firmware step units (the same raw
638
+ # values sent to the device), and absoluteDistances_steps is in hardware
639
+ # steps too, so the time estimate is unit-consistent WITHOUT any stepSize
640
+ # division — dividing here would desync it from the distance and break the
641
+ # estimate. Just take magnitudes.
631
642
  speed_steps = np.zeros(4)
632
643
  acceleration_steps = np.zeros(4)
633
644
  for iMotor in range(4):
634
645
  if speed[iMotor] != 0:
635
- # Speed: µm/s -> steps/s => divide by stepSize (µm/step)
636
- speed_steps[iMotor] = abs(speed[iMotor]) # TODO: This is actually given in steps/s / stepSizes[iMotor]
646
+ speed_steps[iMotor] = abs(speed[iMotor])
637
647
  if acceleration[iMotor] is not None and acceleration[iMotor] != 0:
638
- # Acceleration: µm/s² -> steps/s² => divide by stepSize
639
- acceleration_steps[iMotor] = abs(acceleration[iMotor]) # TODO: This is actually given in steps/s / stepSizes[iMotor]
648
+ acceleration_steps[iMotor] = abs(acceleration[iMotor])
640
649
  else:
641
- # Default acceleration in steps/s²
642
- acceleration_steps[iMotor] = 20000 # This should also be converted, but we use a safe default
650
+ acceleration_steps[iMotor] = 20000 # safe default (firmware steps/s^2)
643
651
 
644
652
  # Calculate travel time using HARDWARE STEPS and converted speed/acceleration
645
653
  # Find the axis that will take the longest (limits overall movement time)
@@ -694,8 +702,10 @@ class Motor(object):
694
702
  "redu": int(is_reduced)}
695
703
  if acceleration[iMotor] is not None:
696
704
  motorProp["accel"] = int(acceleration[iMotor])
705
+ motorProp["acceleration"] = int(acceleration[iMotor])
697
706
  else:
698
707
  motorProp["accel"] = self.DEFAULT_ACCELERATION
708
+ motorProp["acceleleration"] = self.DEFAULT_ACCELERATION
699
709
  motorPropList.append(motorProp)
700
710
  if len(motorPropList)==0:
701
711
  return "{'return':-1}"
@@ -1241,6 +1251,44 @@ class Motor(object):
1241
1251
  r = self._parent.post_json(path, payload, timeout=timeout)
1242
1252
  return r
1243
1253
 
1254
+ def get_tmc_parameters(self, axis=0, timeout=1):
1255
+ ''' Read the TMC parameters for a specific axis back from the device.
1256
+
1257
+ Sends {"task":"/tmc_get", "axis":<n>} and parses the response. Returns a
1258
+ dict with msteps/rms_current/sgthrs/semin/semax/blank_time/toff, or None
1259
+ if the firmware does not implement TMC readback (older firmwares only
1260
+ accept /tmc_act). Callers should fall back to their last-applied values
1261
+ in that case.
1262
+ '''
1263
+ if type(axis) == str:
1264
+ axis = self.xyztTo1230(axis)
1265
+ path = "/tmc_get"
1266
+ payload = {"task": path}
1267
+ if axis is not None:
1268
+ payload["axis"] = axis
1269
+ try:
1270
+ r = self._parent.post_json(path, payload, timeout=timeout)
1271
+ if isinstance(r, list):
1272
+ r = r[0] if r else {}
1273
+ if not isinstance(r, dict):
1274
+ return None
1275
+ # firmware may nest the values under "tmc" or return them flat
1276
+ tmc = r.get("tmc", r)
1277
+ if not isinstance(tmc, dict):
1278
+ return None
1279
+ keys = ("msteps", "rms_current", "sgthrs", "semin", "semax", "blank_time", "toff")
1280
+ if not any(k in tmc for k in keys):
1281
+ # nothing TMC-shaped came back -> readback unsupported
1282
+ return None
1283
+ return {k: tmc[k] for k in keys if k in tmc}
1284
+ except Exception as e:
1285
+ self._parent.logger.debug(f"get_tmc_parameters failed: {e}")
1286
+ return None
1287
+
1288
+ # camelCase alias used by the ImSwitch ESP32StageManager
1289
+ def getTMCSettings(self, axis=0, timeout=1):
1290
+ return self.get_tmc_parameters(axis=axis, timeout=timeout)
1291
+
1244
1292
  def set_hard_limits(self, axis=1, enabled=True, polarity=0, timeout=1):
1245
1293
  '''
1246
1294
  Configure hard limits (emergency stop) for a motor axis.
@@ -16,8 +16,8 @@ except ImportError:
16
16
  T_SERIAL_WARMUP = 2.5
17
17
  class Serial:
18
18
  def __init__(self, port, baudrate=115200, timeout=5,
19
- identity="UC2_Feather", parent=None, DEBUG=False,
20
- skipFirmwareCheck=False):
19
+ identity="UC2_Feather", parent=None, DEBUG=False,
20
+ skipFirmwareCheck=False, device_id=None, requireMaster=False):
21
21
 
22
22
  self.serialdevice = None
23
23
  self.serialport = port
@@ -26,6 +26,15 @@ class Serial:
26
26
  self._parent = parent
27
27
  self.manufacturer = ""
28
28
  self.skipFirmwareCheck = skipFirmwareCheck
29
+ # Connect only to a specific board / only to the CANopen master.
30
+ # device_id pins a physical board by its USB serial number (or a
31
+ # substring of the port path / hwid). requireMaster makes auto-discovery
32
+ # skip any board whose firmware does NOT report a "*_master" pindef, so a
33
+ # motor/slave board (ESP32-S3, native USB) is never picked accidentally.
34
+ self.device_id = device_id
35
+ self.requireMaster = requireMaster
36
+ # identity of the board we actually connected to (filled by checkFirmware)
37
+ self.firmware_info = {}
29
38
  if self._parent is None:
30
39
  import logging
31
40
  self._logger = logging.getLogger(__name__)
@@ -160,6 +169,64 @@ class Serial:
160
169
 
161
170
  return ser
162
171
 
172
+ def _portMatchesDeviceId(self, port):
173
+ '''True if `port` matches the configured device_id, or if no device_id
174
+ is set (then everything matches). The device_id is compared as a
175
+ case-insensitive substring against the USB serial number, the port path
176
+ and the hwid, so the user can pin a board by whichever is stable on
177
+ their OS.'''
178
+ if not self.device_id:
179
+ return True
180
+ did = str(self.device_id).lower()
181
+ candidates = [getattr(port, "serial_number", None),
182
+ getattr(port, "device", None),
183
+ getattr(port, "hwid", None)]
184
+ return any(c and did in str(c).lower() for c in candidates)
185
+
186
+ def _probeDeviceIdentity(self, ser, timeout=2):
187
+ '''Send /state_get and parse the firmware identity block into
188
+ self.firmware_info: {name, version, date, author, pindef, isMaster}.
189
+ Used by requireMaster to reject motor/slave boards. Best-effort: returns
190
+ an empty dict (and leaves firmware_info empty) if nothing parses.'''
191
+ info = {}
192
+ try:
193
+ self._write(ser, {"task": "/state_get"})
194
+ ser.write(b'\n')
195
+ buffer = ""
196
+ reading_json = False
197
+ t0 = time.time()
198
+ while time.time() - t0 < timeout:
199
+ raw = self._read(ser)
200
+ try:
201
+ line = raw.decode('utf-8').strip()
202
+ except Exception:
203
+ continue
204
+ if line == "":
205
+ continue
206
+ if line.find("++") >= 0:
207
+ reading_json = True
208
+ continue
209
+ if reading_json and line.find("--") >= 0:
210
+ break
211
+ if reading_json:
212
+ buffer += line
213
+ if buffer:
214
+ data = json.loads(buffer)
215
+ state = data.get("state", data) if isinstance(data, dict) else {}
216
+ pindef = state.get("pindef", "")
217
+ info = {
218
+ "name": state.get("identifier_name", ""),
219
+ "version": state.get("identifier_id", ""),
220
+ "date": state.get("identifier_date", ""),
221
+ "author": state.get("identifier_author", ""),
222
+ "pindef": pindef,
223
+ "isMaster": "master" in str(pindef).lower(),
224
+ }
225
+ except Exception as e:
226
+ self._logger.debug(f"_probeDeviceIdentity failed: {e}")
227
+ self.firmware_info = info
228
+ return info
229
+
163
230
  def findCorrectSerialDevice(self):
164
231
  '''
165
232
  This function tries to find the correct serial device from the list of available ports
@@ -183,6 +250,9 @@ class Serial:
183
250
  descriptions_to_check = ["CH340", "CP2102", "USB2.0-Serial", "USB-Serial"]
184
251
 
185
252
  for port in _available_ports:
253
+ # If a specific board was requested, ignore everything else.
254
+ if not self._portMatchesDeviceId(port):
255
+ continue
186
256
  if any(port.device.startswith(p) for p in ports_to_check) or \
187
257
  any(port.description.startswith(d) for d in descriptions_to_check):
188
258
  if current_os.startswith("darwin") and port.device.startswith("/dev/cu.usbserial-"):
@@ -217,6 +287,22 @@ class Serial:
217
287
  #time.sleep(T_SERIAL_WARMUP)
218
288
  self._freeSerialBuffer(self.serialdevice, timeout=2, timeMinimum=1)
219
289
  if self.skipFirmwareCheck or self.checkFirmware(self.serialdevice):
290
+ # When only the master may be used, read the firmware identity and
291
+ # reject boards that are not a CANopen master (e.g. ESP32-S3 motor
292
+ # boards on native USB that also speak the UC2 protocol).
293
+ if self.requireMaster:
294
+ self._probeDeviceIdentity(self.serialdevice)
295
+ if not self.firmware_info.get("isMaster", False):
296
+ self._logger.debug(
297
+ f"Skipping non-master board on {getattr(port, 'device', '?')} "
298
+ f"(pindef={self.firmware_info.get('pindef', '?')})"
299
+ )
300
+ try:
301
+ self.serialdevice.close()
302
+ except Exception:
303
+ pass
304
+ self.is_connected = False
305
+ return False
220
306
  self.is_connected = True
221
307
  self.NumberRetryReconnect = 0
222
308
  return True
@@ -490,8 +576,8 @@ class Serial:
490
576
  except Exception as e:
491
577
  if self.DEBUG:
492
578
  self._logger.error(e) # TODO: write failed: Device not configured - why?!
493
- # attempt to reconnect
494
- self.reconnect()
579
+ # attempt to reconnect # TODO: do we want that?
580
+ # self.reconnect()
495
581
  return "Failed to Send"
496
582
  self.commands[identifier]=command # FIXME: Need to clear this after the response is received
497
583
  if nResponses <= 0 or not self.is_connected or self.manufacturer=="UC2Mock":
@@ -526,7 +612,7 @@ class Serial:
526
612
  iRetry = 0
527
613
  while self.running:
528
614
  time.sleep(0.002)
529
- isTimeout = time.time()-t0>timeout
615
+ isTimeout = (time.time()-t0)>timeout
530
616
  if self.resetLastCommand or isTimeout or not self.is_connected:
531
617
  self.resetLastCommand = False
532
618
  if self.DEBUG:
@@ -137,6 +137,41 @@ class State(object):
137
137
  r = self._parent.get_json(path, timeout=timeout)
138
138
  return r
139
139
 
140
+ def get_firmware_info(self, timeout=3):
141
+ '''
142
+ Return the firmware identity of the USB-connected ESP32 as a flat dict:
143
+ {"name", "version", "date", "author", "pindef", "isMaster"}.
144
+
145
+ Parsed from /state_get, e.g.
146
+ {"state":{"identifier_name":"UC2_Feather","identifier_id":"V2.0",
147
+ "identifier_date":"Jun 17 2026 07:17:22","identifier_author":"BD",
148
+ "pindef":"UC2_canopen_master", ...},"qid":0}
149
+ The build date and pindef are the fields that matter for telling boards
150
+ apart. Returns an empty dict if nothing could be parsed.
151
+ '''
152
+ r = self.get_state(timeout=timeout)
153
+ try:
154
+ if isinstance(r, list):
155
+ r = r[0] if r else {}
156
+ state = r.get("state", r) if isinstance(r, dict) else {}
157
+ pindef = state.get("pindef", "")
158
+ return {
159
+ "name": state.get("identifier_name", ""),
160
+ "version": state.get("identifier_id", ""),
161
+ "date": state.get("identifier_date", ""),
162
+ "author": state.get("identifier_author", ""),
163
+ "pindef": pindef,
164
+ "isMaster": "master" in str(pindef).lower(),
165
+ }
166
+ except Exception as e:
167
+ self._parent.logger.debug(f"get_firmware_info failed: {e}")
168
+ return {}
169
+
170
+ def is_master(self, timeout=1):
171
+ '''True if the connected board reports a CANopen-master pindef.'''
172
+ info = self.get_firmware_info(timeout=timeout)
173
+ return bool(info.get("isMaster", False))
174
+
140
175
  def delay(self, delay=1, getReturn=True):
141
176
  path = "/state_act"
142
177
  payload = {
@@ -1,157 +0,0 @@
1
- class CAN(object):
2
- def __init__(self, parent):
3
- """
4
- CANController handles sending commands to a remote CAN device via the parent post_json interface.
5
-
6
- :param parent: Parent object with post_json(path, payload, getReturn, timeout, nResponses)
7
- """
8
- self._parent = parent
9
-
10
- # Store latest scan results
11
- self.scanResults = []
12
- self.deviceCount = 0
13
-
14
- # Register a callback function for the CAN status on the serial loop
15
- if hasattr(self._parent, "serial"):
16
- self._parent.serial.register_callback(self._callback_can_status, pattern="scan")
17
-
18
- # Announce a function that is called when we receive a CAN scan update through the callback
19
- self._callbackPerKey = {}
20
- self.nCallbacks = 10
21
- self._callbackPerKey = self.init_callback_functions(nCallbacks=self.nCallbacks)
22
- print(self._callbackPerKey)
23
-
24
- def init_callback_functions(self, nCallbacks=10):
25
- """Initialize the callback functions."""
26
- _callbackPerKey = {}
27
- self.nCallbacks = nCallbacks
28
- for i in range(nCallbacks):
29
- _callbackPerKey[i] = []
30
- return _callbackPerKey
31
-
32
- def _callback_can_status(self, data):
33
- """
34
- Cast the json in the form:
35
- {
36
- "scan": [
37
- {"canId": 20, "deviceType": 1, "status": 0, "deviceTypeStr": "laser", "statusStr": "idle"},
38
- {"canId": 10, "deviceType": 0, "status": 0, "deviceTypeStr": "motor", "statusStr": "idle"}
39
- ],
40
- "qid": 2,
41
- "count": 1
42
- }
43
- into the scan results array.
44
- """
45
- try:
46
- if "scan" in data:
47
- self.scanResults = data["scan"]
48
- self.deviceCount = data.get("count", len(self.scanResults))
49
-
50
- # Call registered callback function with scan results
51
- if callable(self._callbackPerKey[0]):
52
- self._callbackPerKey[0](self.scanResults)
53
- except Exception as e:
54
- print("Error in _callback_can_status: ", e)
55
-
56
- def register_callback(self, key, callbackfct):
57
- """Register a callback function for a specific key."""
58
- self._callbackPerKey[key] = callbackfct
59
-
60
- def get_can_ids(self):
61
- """Return CAN node IDs from the latest scan (e.g. for OTA flashing)."""
62
- return [entry.get("canId") for entry in self.scanResults
63
- if entry.get("canId") is not None]
64
-
65
- def reboot_remote(self, qid=1, can_address=0, isBlocking=False, timeout=2):
66
- """
67
- Reboot a CAN device.
68
-
69
- - ``can_address == 0`` reboots the master (this ESP32) itself.
70
- - ``can_address in 1..127`` reboots a remote slave by SDO-writing 1
71
- to OD index 0x2507 sub 0 on the target node. The slave's
72
- CO_tmr_task observes the write and calls ESP.restart() ~200 ms
73
- later.
74
-
75
- :param qid: Query ID for the CAN command (unused by firmware, kept
76
- for API compatibility)
77
- :param can_address: 0 = master, 1..127 = remote slave nodeId
78
- :param isBlocking: If True, wait for response
79
- :param timeout: Timeout for the command in seconds
80
- :return: Response from the device, e.g.
81
- ``{"status":"ok","nodeId":11}`` or
82
- ``{"status":"error","error":"SDO write failed","nodeId":11}``.
83
- """
84
- path = "/can_act"
85
- payload = {
86
- "task": path,
87
- "restart": int(can_address)
88
- }
89
- nResponses = 1 if isBlocking else 0
90
- # Send the payload to the parent, which handles the actual communication
91
- return self._parent.post_json(
92
- path,
93
- payload,
94
- getReturn=isBlocking,
95
- timeout=timeout if isBlocking else 0,
96
- nResponses=nResponses
97
- )
98
-
99
- def scan(self, qid=1, timeout=5):
100
- """
101
- Scan the CAN bus for connected devices.
102
-
103
- :param qid: Query ID for the CAN command (default: 1)
104
- :param timeout: Timeout for the scan in seconds (default: 5)
105
- :return: Response containing scan results with device information
106
- Example: {
107
- "scan": [
108
- {"canId": 10, "deviceType": 0, "deviceTypeStr": "motor", "status": 0, "statusStr": "idle"},
109
- {"canId": 20, "deviceType": 1, "deviceTypeStr": "laser", "status": 0, "statusStr": "idle"}
110
- ],
111
- "qid": 1,
112
- "count": 2
113
- }
114
- """
115
- path = "/can_act" # {"task":"/can_act", "scan": true}
116
- payload = {
117
- "task": path,
118
- "scan": True,
119
- "qid": qid
120
- }
121
- return self._parent.post_json(
122
- path,
123
- payload,
124
- getReturn=True,
125
- timeout=timeout,
126
- nResponses=1
127
- )
128
-
129
- def get_available_devices(self, timeout=2):
130
- """
131
- Get list of available CAN devices.
132
-
133
- :param timeout: Timeout for the command in seconds (default: 2)
134
- :return: Response containing available CAN IDs and other bus information
135
- Example: {
136
- "input": {"task": "/can_get"},
137
- "address": 1,
138
- "addresspref": 1,
139
- "addressgetcan": 1,
140
- "nonworking": [0,0,0,...],
141
- "available": [20,0,0,...],
142
- "rx": 18,
143
- "tx": 17
144
- }
145
- """
146
- path = "/can_act"
147
- payload = {
148
- "task": path,
149
- "scan": True
150
- }
151
- return self._parent.post_json(
152
- path,
153
- payload,
154
- getReturn=True,
155
- timeout=timeout,
156
- nResponses=2
157
- )
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes