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.
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/PKG-INFO +1 -1
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/PKG-INFO +1 -1
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/UC2Client.py +3 -3
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/__version__.py +1 -1
- uc2_rest-0.2.0.36/uc2rest/can.py +282 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/canota.py +29 -1
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/home.py +2 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/motor.py +60 -12
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/mserial.py +91 -5
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/state.py +35 -0
- uc2_rest-0.2.0.34/uc2rest/can.py +0 -157
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/LICENSE +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/README.md +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/SOURCES.txt +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/dependency_links.txt +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/not-zip-safe +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/requires.txt +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/UC2_REST.egg-info/top_level.txt +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/setup.cfg +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/setup.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/MockSerial.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/__init__.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/analog.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/camera.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/camera_trigger.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/cmdrecorder.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/digitalin.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/digitalout.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/fan.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/galvo.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/gripper.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/laser.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/lcddisplay.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/ledmatrix.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/logger.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/message.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/modules.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/motor_config.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/objective.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/pid.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/rotator.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/slm.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/temperature.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/utils.py +0 -0
- {uc2_rest-0.2.0.34 → uc2_rest-0.2.0.36}/uc2rest/wifi.py +0 -0
|
@@ -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__ = "
|
|
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
|
-
#
|
|
615
|
-
#
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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 = {
|
uc2_rest-0.2.0.34/uc2rest/can.py
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|