UC2-REST 0.2.0.35__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.35 → uc2_rest-0.2.0.36}/PKG-INFO +1 -1
  2. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/PKG-INFO +1 -1
  3. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/UC2Client.py +3 -3
  4. {uc2_rest-0.2.0.35 → 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.35 → uc2_rest-0.2.0.36}/uc2rest/motor.py +57 -11
  7. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/mserial.py +88 -2
  8. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/state.py +35 -0
  9. uc2_rest-0.2.0.35/uc2rest/can.py +0 -157
  10. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/LICENSE +0 -0
  11. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/README.md +0 -0
  12. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/SOURCES.txt +0 -0
  13. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/dependency_links.txt +0 -0
  14. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/not-zip-safe +0 -0
  15. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/requires.txt +0 -0
  16. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/top_level.txt +0 -0
  17. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/setup.cfg +0 -0
  18. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/setup.py +0 -0
  19. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/MockSerial.py +0 -0
  20. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/__init__.py +0 -0
  21. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/analog.py +0 -0
  22. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/camera.py +0 -0
  23. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/camera_trigger.py +0 -0
  24. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/canota.py +0 -0
  25. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/cmdrecorder.py +0 -0
  26. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/digitalin.py +0 -0
  27. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/digitalout.py +0 -0
  28. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/fan.py +0 -0
  29. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/galvo.py +0 -0
  30. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/gripper.py +0 -0
  31. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/home.py +0 -0
  32. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/laser.py +0 -0
  33. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/lcddisplay.py +0 -0
  34. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/ledmatrix.py +0 -0
  35. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/logger.py +0 -0
  36. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/message.py +0 -0
  37. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/modules.py +0 -0
  38. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/motor_config.py +0 -0
  39. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/objective.py +0 -0
  40. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/pid.py +0 -0
  41. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/rotator.py +0 -0
  42. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/slm.py +0 -0
  43. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/temperature.py +0 -0
  44. {uc2_rest-0.2.0.35 → uc2_rest-0.2.0.36}/uc2rest/utils.py +0 -0
  45. {uc2_rest-0.2.0.35 → 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.35
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.35
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.35"
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)
@@ -613,10 +613,15 @@ class Motor(object):
613
613
  if isAbsoluteArray[iMotor]:
614
614
  # Compare current position (physical) with target (physical, already includes offset)
615
615
  self.currentDirection[iMotor] = 1 if (self.currentPosition[iMotor] > targetPositionPhysical[iMotor]) else -1
616
- # Calculate distance to travel in HARDWARE STEPS:
617
- # Current position (physical) -> convert to steps, then subtract target (already in steps)
618
- currentPosition_steps = self.currentPosition[iMotor] / stepSizes[iMotor]
619
- 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]
620
625
  else:
621
626
  self.currentDirection[iMotor] = np.sign(steps[iMotor])
622
627
  # For relative motion, steps[iMotor] is already the distance in hardware steps
@@ -629,19 +634,20 @@ class Motor(object):
629
634
  if not isAbsoluteArray[iMotor]:
630
635
  absoluteDistances_steps[iMotor] = abs(steps[iMotor])
631
636
 
632
- # 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.
633
642
  speed_steps = np.zeros(4)
634
643
  acceleration_steps = np.zeros(4)
635
644
  for iMotor in range(4):
636
645
  if speed[iMotor] != 0:
637
- # Speed: µm/s -> steps/s => divide by stepSize (µm/step)
638
- speed_steps[iMotor] = abs(speed[iMotor]) # TODO: This is actually given in steps/s / stepSizes[iMotor]
646
+ speed_steps[iMotor] = abs(speed[iMotor])
639
647
  if acceleration[iMotor] is not None and acceleration[iMotor] != 0:
640
- # Acceleration: µm/s² -> steps/s² => divide by stepSize
641
- acceleration_steps[iMotor] = abs(acceleration[iMotor]) # TODO: This is actually given in steps/s / stepSizes[iMotor]
648
+ acceleration_steps[iMotor] = abs(acceleration[iMotor])
642
649
  else:
643
- # Default acceleration in steps/s²
644
- 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)
645
651
 
646
652
  # Calculate travel time using HARDWARE STEPS and converted speed/acceleration
647
653
  # Find the axis that will take the longest (limits overall movement time)
@@ -696,8 +702,10 @@ class Motor(object):
696
702
  "redu": int(is_reduced)}
697
703
  if acceleration[iMotor] is not None:
698
704
  motorProp["accel"] = int(acceleration[iMotor])
705
+ motorProp["acceleration"] = int(acceleration[iMotor])
699
706
  else:
700
707
  motorProp["accel"] = self.DEFAULT_ACCELERATION
708
+ motorProp["acceleleration"] = self.DEFAULT_ACCELERATION
701
709
  motorPropList.append(motorProp)
702
710
  if len(motorPropList)==0:
703
711
  return "{'return':-1}"
@@ -1243,6 +1251,44 @@ class Motor(object):
1243
1251
  r = self._parent.post_json(path, payload, timeout=timeout)
1244
1252
  return r
1245
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
+
1246
1292
  def set_hard_limits(self, axis=1, enabled=True, polarity=0, timeout=1):
1247
1293
  '''
1248
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
@@ -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
File without changes