1 # -*- coding: utf-8 -*- |
1 # -*- coding: utf-8 -*- |
2 |
2 |
3 # Copyright (c) 2019 - 2023 Detlev Offenbach <detlev@die-offenbachs.de> |
3 # Copyright (c) 2023 Detlev Offenbach <detlev@die-offenbachs.de> |
4 # |
4 # |
5 |
5 |
6 """ |
6 """ |
7 Module implementing some file system commands for MicroPython. |
7 Module implementing an interface base class to talk to a connected MicroPython device. |
8 """ |
8 """ |
9 |
9 |
10 from PyQt6.QtCore import ( |
10 from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot |
11 QCoreApplication, |
|
12 QEventLoop, |
|
13 QObject, |
|
14 QThread, |
|
15 QTimer, |
|
16 pyqtSignal, |
|
17 pyqtSlot, |
|
18 ) |
|
19 |
|
20 from eric7 import Preferences |
|
21 |
|
22 from .MicroPythonSerialPort import MicroPythonSerialPort |
|
23 |
11 |
24 |
12 |
25 class MicroPythonDeviceInterface(QObject): |
13 class MicroPythonDeviceInterface(QObject): |
26 """ |
14 """ |
27 Class implementing an interface to talk to a connected MicroPython device. |
15 Class implementing an interface to talk to a connected MicroPython device. |
28 |
16 |
29 @signal executeAsyncFinished() emitted to indicate the end of an |
17 @signal executeAsyncFinished() emitted to indicate the end of an |
30 asynchronously executed list of commands (e.g. a script) |
18 asynchronously executed list of commands (e.g. a script) |
31 @signal dataReceived(data) emitted to send data received via the serial |
19 @signal dataReceived(data) emitted to send data received via the connection |
32 connection for further processing |
20 for further processing |
33 """ |
21 """ |
34 |
22 |
35 executeAsyncFinished = pyqtSignal() |
23 executeAsyncFinished = pyqtSignal() |
36 dataReceived = pyqtSignal(bytes) |
24 dataReceived = pyqtSignal(bytes) |
37 |
|
38 PasteModePrompt = b"=== " |
|
39 TracebackMarker = b"Traceback (most recent call last):" |
|
40 |
25 |
41 def __init__(self, parent=None): |
26 def __init__(self, parent=None): |
42 """ |
27 """ |
43 Constructor |
28 Constructor |
44 |
29 |
45 @param parent reference to the parent object |
30 @param parent reference to the parent object |
46 @type QObject |
31 @type QObject |
47 """ |
32 """ |
48 super().__init__(parent) |
33 super().__init__(parent) |
49 |
34 |
50 self.__repl = parent |
35 @pyqtSlot() |
|
36 def connectToDevice(self, connection): |
|
37 """ |
|
38 Public slot to connect to the device. |
51 |
39 |
52 self.__blockReadyRead = False |
40 @param connection name of the connection to be used |
53 |
|
54 self.__serial = MicroPythonSerialPort( |
|
55 timeout=Preferences.getMicroPython("SerialTimeout"), parent=self |
|
56 ) |
|
57 self.__serial.readyRead.connect(self.__readSerial) |
|
58 |
|
59 @pyqtSlot() |
|
60 def __readSerial(self): |
|
61 """ |
|
62 Private slot to read all available serial data and emit it with the |
|
63 "dataReceived" signal for further processing. |
|
64 """ |
|
65 if not self.__blockReadyRead: |
|
66 data = bytes(self.__serial.readAll()) |
|
67 self.dataReceived.emit(data) |
|
68 |
|
69 @pyqtSlot() |
|
70 def connectToDevice(self, port): |
|
71 """ |
|
72 Public slot to start the manager. |
|
73 |
|
74 @param port name of the port to be used |
|
75 @type str |
41 @type str |
76 @return flag indicating success |
42 @return flag indicating success |
77 @rtype bool |
43 @rtype bool |
|
44 @exception NotImplementedError raised to indicate that this method needs to |
|
45 be implemented in a derived class |
78 """ |
46 """ |
79 return self.__serial.openSerialLink(port) |
47 raise NotImplementedError( |
|
48 "This method needs to be implemented in a derived class." |
|
49 ) |
|
50 |
|
51 return False |
80 |
52 |
81 @pyqtSlot() |
53 @pyqtSlot() |
82 def disconnectFromDevice(self): |
54 def disconnectFromDevice(self): |
83 """ |
55 """ |
84 Public slot to stop the thread. |
56 Public slot to disconnect from the device. |
|
57 |
|
58 @exception NotImplementedError raised to indicate that this method needs to |
|
59 be implemented in a derived class |
85 """ |
60 """ |
86 self.__serial.closeSerialLink() |
61 raise NotImplementedError( |
|
62 "This method needs to be implemented in a derived class." |
|
63 ) |
87 |
64 |
88 def isConnected(self): |
65 def isConnected(self): |
89 """ |
66 """ |
90 Public method to get the connection status. |
67 Public method to get the connection status. |
91 |
68 |
92 @return flag indicating the connection status |
69 @return flag indicating the connection status |
93 @rtype bool |
70 @rtype bool |
|
71 @exception NotImplementedError raised to indicate that this method needs to |
|
72 be implemented in a derived class |
94 """ |
73 """ |
95 return self.__serial.isConnected() |
74 raise NotImplementedError( |
|
75 "This method needs to be implemented in a derived class." |
|
76 ) |
|
77 |
|
78 return False |
96 |
79 |
97 @pyqtSlot() |
80 @pyqtSlot() |
98 def handlePreferencesChanged(self): |
81 def handlePreferencesChanged(self): |
99 """ |
82 """ |
100 Public slot to handle a change of the preferences. |
83 Public slot to handle a change of the preferences. |
101 """ |
84 """ |
102 self.__serial.setTimeout(Preferences.getMicroPython("SerialTimeout")) |
85 pass |
103 |
86 |
104 def write(self, data): |
87 def write(self, data): |
105 """ |
88 """ |
106 Public method to write data to the connected device. |
89 Public method to write data to the connected device. |
107 |
90 |
108 @param data data to be written |
91 @param data data to be written |
109 @type bytes or bytearray |
92 @type bytes or bytearray |
|
93 @exception NotImplementedError raised to indicate that this method needs to |
|
94 be implemented in a derived class |
110 """ |
95 """ |
111 self.__serial.isConnected() and self.__serial.write(data) |
96 raise NotImplementedError( |
112 |
97 "This method needs to be implemented in a derived class." |
113 def __pasteOn(self): |
|
114 """ |
|
115 Private method to switch the connected device to 'paste' mode. |
|
116 |
|
117 Note: switching to paste mode is done with synchronous writes. |
|
118 |
|
119 @return flag indicating success |
|
120 @rtype bool |
|
121 """ |
|
122 if not self.__serial: |
|
123 return False |
|
124 |
|
125 pasteMessage = b"paste mode; Ctrl-C to cancel, Ctrl-D to finish\r\n=== " |
|
126 |
|
127 self.__serial.clear() # clear any buffered output before entering paste mode |
|
128 self.__serial.write(b"\x02") # end raw mode if required |
|
129 written = self.__serial.waitForBytesWritten(500) |
|
130 # time out after 500ms if device is not responding |
|
131 if not written: |
|
132 return False |
|
133 for _i in range(3): |
|
134 # CTRL-C three times to break out of loops |
|
135 self.__serial.write(b"\r\x03") |
|
136 written = self.__serial.waitForBytesWritten(500) |
|
137 # time out after 500ms if device is not responding |
|
138 if not written: |
|
139 return False |
|
140 QThread.msleep(10) |
|
141 self.__serial.readAll() # read all data and discard it |
|
142 self.__serial.write(b"\r\x05") # send CTRL-E to enter paste mode |
|
143 self.__serial.readUntil(pasteMessage) |
|
144 |
|
145 if self.__serial.hasTimedOut(): |
|
146 # it timed out; try it again and than fail |
|
147 self.__serial.write(b"\r\x05") # send CTRL-E again |
|
148 self.__serial.readUntil(pasteMessage) |
|
149 if self.__serial.hasTimedOut(): |
|
150 return False |
|
151 |
|
152 QCoreApplication.processEvents( |
|
153 QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents |
|
154 ) |
98 ) |
155 self.__serial.readAll() # read all data and discard it |
|
156 return True |
|
157 |
|
158 def __pasteOff(self): |
|
159 """ |
|
160 Private method to switch 'paste' mode off. |
|
161 """ |
|
162 if self.__serial: |
|
163 self.__serial.write(b"\x04") # send CTRL-D to cancel paste mode |
|
164 |
|
165 def __rawOn(self): |
|
166 """ |
|
167 Private method to switch the connected device to 'raw' mode. |
|
168 |
|
169 Note: switching to raw mode is done with synchronous writes. |
|
170 |
|
171 @return flag indicating success |
|
172 @rtype bool |
|
173 """ |
|
174 if not self.__serial: |
|
175 return False |
|
176 |
|
177 rawReplMessage = b"raw REPL; CTRL-B to exit\r\n>" |
|
178 |
|
179 self.__serial.write(b"\x02") # end raw mode if required |
|
180 written = self.__serial.waitForBytesWritten(500) |
|
181 # time out after 500ms if device is not responding |
|
182 if not written: |
|
183 return False |
|
184 for _i in range(3): |
|
185 # CTRL-C three times to break out of loops |
|
186 self.__serial.write(b"\r\x03") |
|
187 written = self.__serial.waitForBytesWritten(500) |
|
188 # time out after 500ms if device is not responding |
|
189 if not written: |
|
190 return False |
|
191 QThread.msleep(10) |
|
192 self.__serial.readAll() # read all data and discard it |
|
193 self.__serial.write(b"\r\x01") # send CTRL-A to enter raw mode |
|
194 self.__serial.readUntil(rawReplMessage) |
|
195 if self.__serial.hasTimedOut(): |
|
196 # it timed out; try it again and than fail |
|
197 self.__serial.write(b"\r\x01") # send CTRL-A again |
|
198 self.__serial.readUntil(rawReplMessage) |
|
199 if self.__serial.hasTimedOut(): |
|
200 return False |
|
201 |
|
202 QCoreApplication.processEvents( |
|
203 QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents |
|
204 ) |
|
205 self.__serial.readAll() # read all data and discard it |
|
206 return True |
|
207 |
|
208 def __rawOff(self): |
|
209 """ |
|
210 Private method to switch 'raw' mode off. |
|
211 """ |
|
212 if self.__serial: |
|
213 self.__serial.write(b"\x02") # send CTRL-B to cancel raw mode |
|
214 self.__serial.readUntil(b">>> ") # read until Python prompt |
|
215 self.__serial.readAll() # read all data and discard it |
|
216 |
99 |
217 def probeDevice(self): |
100 def probeDevice(self): |
218 """ |
101 """ |
219 Public method to check the device is responding. |
102 Public method to check the device is responding. |
220 |
103 |
221 If the device has not been flashed with a MicroPython formware, the |
104 If the device has not been flashed with a MicroPython firmware, the |
222 probe will fail. |
105 probe will fail. |
223 |
106 |
224 @return flag indicating a communicating MicroPython device |
107 @return flag indicating a communicating MicroPython device |
225 @rtype bool |
108 @rtype bool |
|
109 @exception NotImplementedError raised to indicate that this method needs to |
|
110 be implemented in a derived class |
226 """ |
111 """ |
227 if not self.__serial: |
112 raise NotImplementedError( |
228 return False |
113 "This method needs to be implemented in a derived class." |
|
114 ) |
229 |
115 |
230 if not self.__serial.isConnected(): |
116 return False |
231 return False |
|
232 |
|
233 # switch on raw mode |
|
234 self.__blockReadyRead = True |
|
235 ok = self.__pasteOn() |
|
236 if not ok: |
|
237 self.__blockReadyRead = False |
|
238 return False |
|
239 |
|
240 # switch off raw mode |
|
241 QThread.msleep(10) |
|
242 self.__pasteOff() |
|
243 self.__blockReadyRead = False |
|
244 |
|
245 return True |
|
246 |
117 |
247 def execute(self, commands, *, mode="raw", timeout=0): |
118 def execute(self, commands, *, mode="raw", timeout=0): |
248 """ |
119 """ |
249 Public method to send commands to the connected device and return the |
120 Public method to send commands to the connected device and return the |
250 result. |
121 result. |
251 |
122 |
252 If no serial connection is available, empty results will be returned. |
123 If no connection is available, empty results will be returned. |
253 |
124 |
254 @param commands list of commands to be executed |
125 @param commands list of commands to be executed |
255 @type str or list of str |
126 @type str or list of str |
256 @keyparam mode submit mode to be used (one of 'raw' or 'paste') (defaults to |
127 @keyparam mode submit mode to be used (one of 'raw' or 'paste') (defaults to |
257 'raw') |
128 'raw') |
259 @keyparam timeout per command timeout in milliseconds (0 for configured default) |
130 @keyparam timeout per command timeout in milliseconds (0 for configured default) |
260 (defaults to 0) |
131 (defaults to 0) |
261 @type int (optional) |
132 @type int (optional) |
262 @return tuple containing stdout and stderr output of the device |
133 @return tuple containing stdout and stderr output of the device |
263 @rtype tuple of (bytes, bytes) |
134 @rtype tuple of (bytes, bytes) |
|
135 @exception NotImplementedError raised to indicate that this method needs to |
|
136 be implemented in a derived class |
264 @exception ValueError raised in case of an unsupported submit mode |
137 @exception ValueError raised in case of an unsupported submit mode |
265 """ |
138 """ |
|
139 raise NotImplementedError( |
|
140 "This method needs to be implemented in a derived class." |
|
141 ) |
|
142 |
266 if mode not in ("paste", "raw"): |
143 if mode not in ("paste", "raw"): |
267 raise ValueError("Unsupported submit mode given ('{0}').".format(mode)) |
144 raise ValueError("Unsupported submit mode given ('{0}').".format(mode)) |
268 |
145 |
269 if mode == "raw": |
146 return b"", b"" |
270 return self.__execute_raw(commands, timeout=timeout) |
|
271 elif mode == "paste": |
|
272 return self.__execute_paste(commands, timeout=timeout) |
|
273 else: |
|
274 # just in case |
|
275 return b"", b"" |
|
276 |
|
277 def __execute_raw(self, commands, timeout=0): |
|
278 """ |
|
279 Private method to send commands to the connected device using 'raw REPL' mode |
|
280 and return the result. |
|
281 |
|
282 If no serial connection is available, empty results will be returned. |
|
283 |
|
284 @param commands list of commands to be executed |
|
285 @type str or list of str |
|
286 @param timeout per command timeout in milliseconds (0 for configured default) |
|
287 (defaults to 0) |
|
288 @type int (optional) |
|
289 @return tuple containing stdout and stderr output of the device |
|
290 @rtype tuple of (bytes, bytes) |
|
291 """ |
|
292 if not self.__serial: |
|
293 return b"", b"" |
|
294 |
|
295 if not self.__serial.isConnected(): |
|
296 return b"", b"Device not connected or not switched on." |
|
297 |
|
298 result = bytearray() |
|
299 err = b"" |
|
300 |
|
301 if isinstance(commands, str): |
|
302 commands = [commands] |
|
303 |
|
304 # switch on raw mode |
|
305 self.__blockReadyRead = True |
|
306 ok = self.__rawOn() |
|
307 if not ok: |
|
308 self.__blockReadyRead = False |
|
309 return (b"", b"Could not switch to raw mode. Is the device switched on?") |
|
310 |
|
311 # send commands |
|
312 QThread.msleep(10) |
|
313 for command in commands: |
|
314 if command: |
|
315 commandBytes = command.encode("utf-8") |
|
316 self.__serial.write(commandBytes + b"\x04") |
|
317 QCoreApplication.processEvents( |
|
318 QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents |
|
319 ) |
|
320 ok = self.__serial.readUntil(b"OK") |
|
321 if ok != b"OK": |
|
322 self.__blockReadyRead = False |
|
323 return ( |
|
324 b"", |
|
325 "Expected 'OK', got '{0}', followed by '{1}'".format( |
|
326 ok, self.__serial.readAll() |
|
327 ).encode("utf-8"), |
|
328 ) |
|
329 |
|
330 # read until prompt |
|
331 response = self.__serial.readUntil(b"\x04>", timeout=timeout) |
|
332 if self.__serial.hasTimedOut(): |
|
333 self.__blockReadyRead = False |
|
334 return b"", b"Timeout while processing commands." |
|
335 if b"\x04" in response[:-2]: |
|
336 # split stdout, stderr |
|
337 out, err = response[:-2].split(b"\x04") |
|
338 result += out |
|
339 else: |
|
340 err = b"invalid response received: " + response |
|
341 if err: |
|
342 result = b"" |
|
343 break |
|
344 |
|
345 # switch off raw mode |
|
346 QThread.msleep(10) |
|
347 self.__rawOff() |
|
348 self.__blockReadyRead = False |
|
349 |
|
350 return bytes(result), err |
|
351 |
|
352 def __execute_paste(self, commands, timeout=0): |
|
353 """ |
|
354 Private method to send commands to the connected device using 'paste' mode |
|
355 and return the result. |
|
356 |
|
357 If no serial connection is available, empty results will be returned. |
|
358 |
|
359 @param commands list of commands to be executed |
|
360 @type str or list of str |
|
361 @param timeout per command timeout in milliseconds (0 for configured default) |
|
362 (defaults to 0) |
|
363 @type int (optional) |
|
364 @return tuple containing stdout and stderr output of the device |
|
365 @rtype tuple of (bytes, bytes) |
|
366 """ |
|
367 if not self.__serial: |
|
368 return b"", b"" |
|
369 |
|
370 if not self.__serial.isConnected(): |
|
371 return b"", b"Device not connected or not switched on." |
|
372 |
|
373 if isinstance(commands, list): |
|
374 commands = "\n".join(commands) |
|
375 |
|
376 # switch on paste mode |
|
377 self.__blockReadyRead = True |
|
378 ok = self.__pasteOn() |
|
379 if not ok: |
|
380 self.__blockReadyRead = False |
|
381 return (b"", b"Could not switch to paste mode. Is the device switched on?") |
|
382 |
|
383 # send commands |
|
384 QThread.msleep(10) |
|
385 for command in commands.splitlines(keepends=True): |
|
386 # send the data as single lines |
|
387 commandBytes = command.encode("utf-8") |
|
388 self.__serial.write(commandBytes) |
|
389 QCoreApplication.processEvents( |
|
390 QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents |
|
391 ) |
|
392 QThread.msleep(10) |
|
393 ok = self.__serial.readUntil(commandBytes) |
|
394 if ok != commandBytes: |
|
395 self.__blockReadyRead = False |
|
396 return ( |
|
397 b"", |
|
398 "Expected '{0}', got '{1}', followed by '{2}'".format( |
|
399 commandBytes, ok, self.__serial.readAll() |
|
400 ).encode("utf-8"), |
|
401 ) |
|
402 |
|
403 # switch off paste mode causing the commands to be executed |
|
404 self.__pasteOff() |
|
405 QThread.msleep(10) |
|
406 # read until Python prompt |
|
407 result = ( |
|
408 self.__serial.readUntil(b">>> ", timeout=timeout) |
|
409 .replace(b">>> ", b"") |
|
410 .strip() |
|
411 ) |
|
412 if self.__serial.hasTimedOut(): |
|
413 self.__blockReadyRead = False |
|
414 return b"", b"Timeout while processing commands." |
|
415 |
|
416 # get rid of any OSD string |
|
417 if result.startswith(b"\x1b]0;"): |
|
418 result = result.split(b"\x1b\\")[-1] |
|
419 |
|
420 if self.TracebackMarker in result: |
|
421 errorIndex = result.find(self.TracebackMarker) |
|
422 out, err = result[:errorIndex], result[errorIndex:] |
|
423 else: |
|
424 out = result |
|
425 err = b"" |
|
426 |
|
427 self.__blockReadyRead = False |
|
428 return out, err |
|
429 |
147 |
430 def executeAsync(self, commandsList, submitMode): |
148 def executeAsync(self, commandsList, submitMode): |
431 """ |
149 """ |
432 Public method to execute a series of commands over a period of time |
150 Public method to execute a series of commands over a period of time |
433 without returning any result (asynchronous execution). |
151 without returning any result (asynchronous execution). |
434 |
152 |
435 @param commandsList list of commands to be execute on the device |
153 @param commandsList list of commands to be execute on the device |
436 @type list of str |
154 @type list of str |
437 @param submitMode mode to be used to submit the commands |
155 @param submitMode mode to be used to submit the commands (one of 'raw' |
438 @type str (one of 'raw' or 'paste') |
156 or 'paste') |
|
157 @type str |
|
158 @exception NotImplementedError raised to indicate that this method needs to |
|
159 be implemented in a derived class |
439 @exception ValueError raised to indicate an unknown submit mode |
160 @exception ValueError raised to indicate an unknown submit mode |
440 """ |
161 """ |
|
162 raise NotImplementedError( |
|
163 "This method needs to be implemented in a derived class." |
|
164 ) |
|
165 |
441 if submitMode not in ("raw", "paste"): |
166 if submitMode not in ("raw", "paste"): |
442 raise ValueError("Illegal submit mode given ({0})".format(submitMode)) |
167 raise ValueError( |
443 |
168 "Unsupported submit mode given ('{0}').".format(submitMode) |
444 if submitMode == "raw": |
|
445 startSequence = [ # sequence of commands to enter raw mode |
|
446 b"\x02", # Ctrl-B: exit raw repl (just in case) |
|
447 b"\r\x03\x03\x03", # Ctrl-C three times: interrupt any running program |
|
448 b"\r\x01", # Ctrl-A: enter raw REPL |
|
449 b'print("\\n")\r', |
|
450 ] |
|
451 endSequence = [ |
|
452 b"\r", |
|
453 b"\x04", |
|
454 ] |
|
455 self.__executeAsyncRaw( |
|
456 startSequence |
|
457 + [c.encode("utf-8") + b"\r" for c in commandsList] |
|
458 + endSequence |
|
459 ) |
169 ) |
460 elif submitMode == "paste": |
|
461 self.__executeAsyncPaste(commandsList) |
|
462 |
|
463 def __executeAsyncRaw(self, commandsList): |
|
464 """ |
|
465 Private method to execute a series of commands over a period of time |
|
466 without returning any result (asynchronous execution). |
|
467 |
|
468 @param commandsList list of commands to be execute on the device |
|
469 @type list of bytes |
|
470 """ |
|
471 if commandsList: |
|
472 command = commandsList.pop(0) |
|
473 self.__serial.write(command) |
|
474 QTimer.singleShot(2, lambda: self.__executeAsyncRaw(commandsList)) |
|
475 else: |
|
476 self.__rawOff() |
|
477 self.executeAsyncFinished.emit() |
|
478 |
|
479 def __executeAsyncPaste(self, commandsList): |
|
480 """ |
|
481 Private method to execute a series of commands over a period of time |
|
482 without returning any result (asynchronous execution). |
|
483 |
|
484 @param commandsList list of commands to be execute on the device |
|
485 @type list of str |
|
486 """ |
|
487 self.__blockReadyRead = True |
|
488 self.__pasteOn() |
|
489 command = b"\n".join(c.encode("utf-8)") for c in commandsList) |
|
490 self.__serial.write(command) |
|
491 self.__serial.readUntil(command) |
|
492 self.__blockReadyRead = False |
|
493 self.__pasteOff() |
|
494 self.executeAsyncFinished.emit |
|