|
1 # -*- coding: utf-8 -*- |
|
2 |
|
3 # Copyright (c) 2019 - 2023 Detlev Offenbach <detlev@die-offenbachs.de> |
|
4 # |
|
5 |
|
6 """ |
|
7 Module implementing the device interface class for CircuitPython boards. |
|
8 """ |
|
9 |
|
10 import os |
|
11 import shutil |
|
12 |
|
13 from PyQt6.QtCore import QProcess, QUrl, pyqtSlot |
|
14 from PyQt6.QtNetwork import QNetworkRequest |
|
15 from PyQt6.QtWidgets import QMenu |
|
16 |
|
17 from eric7 import Globals, Preferences |
|
18 from eric7.EricWidgets import EricFileDialog, EricMessageBox |
|
19 from eric7.EricWidgets.EricApplication import ericApp |
|
20 from eric7.SystemUtilities import FileSystemUtilities |
|
21 |
|
22 from .CircuitPythonUpdater.CircuitPythonUpdaterInterface import ( |
|
23 CircuitPythonUpdaterInterface, |
|
24 isCircupAvailable, |
|
25 ) |
|
26 from . import FirmwareGithubUrls |
|
27 from .DeviceBase import BaseDevice |
|
28 from ..MicroPythonWidget import HAS_QTCHART |
|
29 |
|
30 |
|
31 class CircuitPythonDevice(BaseDevice): |
|
32 """ |
|
33 Class implementing the device for CircuitPython boards. |
|
34 """ |
|
35 |
|
36 DeviceVolumeName = "CIRCUITPY" |
|
37 |
|
38 def __init__(self, microPythonWidget, deviceType, boardName, parent=None): |
|
39 """ |
|
40 Constructor |
|
41 |
|
42 @param microPythonWidget reference to the main MicroPython widget |
|
43 @type MicroPythonWidget |
|
44 @param deviceType device type assigned to this device interface |
|
45 @type str |
|
46 @param boardName name of the board |
|
47 @type str |
|
48 @param parent reference to the parent object |
|
49 @type QObject |
|
50 """ |
|
51 super().__init__(microPythonWidget, deviceType, parent) |
|
52 |
|
53 self.__boardName = boardName |
|
54 self.__workspace = self.__findWorkspace() |
|
55 |
|
56 self.__updater = CircuitPythonUpdaterInterface(self) |
|
57 |
|
58 self.__createCPyMenu() |
|
59 |
|
60 def setButtons(self): |
|
61 """ |
|
62 Public method to enable the supported action buttons. |
|
63 """ |
|
64 super().setButtons() |
|
65 self.microPython.setActionButtons( |
|
66 run=True, repl=True, files=True, chart=HAS_QTCHART |
|
67 ) |
|
68 |
|
69 if self.__deviceVolumeMounted(): |
|
70 self.microPython.setActionButtons(open=True, save=True) |
|
71 |
|
72 def forceInterrupt(self): |
|
73 """ |
|
74 Public method to determine the need for an interrupt when opening the |
|
75 serial connection. |
|
76 |
|
77 @return flag indicating an interrupt is needed |
|
78 @rtype bool |
|
79 """ |
|
80 return False |
|
81 |
|
82 def deviceName(self): |
|
83 """ |
|
84 Public method to get the name of the device. |
|
85 |
|
86 @return name of the device |
|
87 @rtype str |
|
88 """ |
|
89 return self.tr("CircuitPython") |
|
90 |
|
91 def canStartRepl(self): |
|
92 """ |
|
93 Public method to determine, if a REPL can be started. |
|
94 |
|
95 @return tuple containing a flag indicating it is safe to start a REPL |
|
96 and a reason why it cannot. |
|
97 @rtype tuple of (bool, str) |
|
98 """ |
|
99 return True, "" |
|
100 |
|
101 def canStartPlotter(self): |
|
102 """ |
|
103 Public method to determine, if a Plotter can be started. |
|
104 |
|
105 @return tuple containing a flag indicating it is safe to start a |
|
106 Plotter and a reason why it cannot. |
|
107 @rtype tuple of (bool, str) |
|
108 """ |
|
109 return True, "" |
|
110 |
|
111 def canRunScript(self): |
|
112 """ |
|
113 Public method to determine, if a script can be executed. |
|
114 |
|
115 @return tuple containing a flag indicating it is safe to start a |
|
116 Plotter and a reason why it cannot. |
|
117 @rtype tuple of (bool, str) |
|
118 """ |
|
119 return True, "" |
|
120 |
|
121 def runScript(self, script): |
|
122 """ |
|
123 Public method to run the given Python script. |
|
124 |
|
125 @param script script to be executed |
|
126 @type str |
|
127 """ |
|
128 pythonScript = script.split("\n") |
|
129 self.sendCommands(pythonScript) |
|
130 |
|
131 def canStartFileManager(self): |
|
132 """ |
|
133 Public method to determine, if a File Manager can be started. |
|
134 |
|
135 @return tuple containing a flag indicating it is safe to start a |
|
136 File Manager and a reason why it cannot. |
|
137 @rtype tuple of (bool, str) |
|
138 """ |
|
139 return True, "" |
|
140 |
|
141 def supportsLocalFileAccess(self): |
|
142 """ |
|
143 Public method to indicate file access via a local directory. |
|
144 |
|
145 @return flag indicating file access via local directory |
|
146 @rtype bool |
|
147 """ |
|
148 return self.__deviceVolumeMounted() |
|
149 |
|
150 def __deviceVolumeMounted(self): |
|
151 """ |
|
152 Private method to check, if the device volume is mounted. |
|
153 |
|
154 @return flag indicated a mounted device |
|
155 @rtype bool |
|
156 """ |
|
157 if self.__workspace and not os.path.exists(self.__workspace): |
|
158 self.__workspace = "" # reset |
|
159 |
|
160 return self.DeviceVolumeName in self.getWorkspace(silent=True) |
|
161 |
|
162 def __findDeviceDirectories(self, directories): |
|
163 """ |
|
164 Private method to find the device directories associated with the |
|
165 current board name. |
|
166 |
|
167 @param directories list of directories to be checked |
|
168 @type list of str |
|
169 @return list of associated directories |
|
170 @rtype list of str |
|
171 """ |
|
172 boardDirectories = [] |
|
173 for directory in directories: |
|
174 bootFile = os.path.join(directory, "boot_out.txt") |
|
175 if os.path.exists(bootFile): |
|
176 with open(bootFile, "r") as f: |
|
177 line = f.readline() |
|
178 if self.__boardName in line: |
|
179 boardDirectories.append(directory) |
|
180 |
|
181 return boardDirectories |
|
182 |
|
183 def __findWorkspace(self, silent=False): |
|
184 """ |
|
185 Private method to find the workspace directory. |
|
186 |
|
187 @param silent flag indicating silent operations |
|
188 @type bool |
|
189 @return workspace directory used for saving files |
|
190 @rtype str |
|
191 """ |
|
192 # Attempts to find the paths on the filesystem that represents the |
|
193 # plugged in CIRCUITPY boards. |
|
194 deviceDirectories = FileSystemUtilities.findVolume( |
|
195 self.DeviceVolumeName, findAll=True |
|
196 ) |
|
197 |
|
198 if deviceDirectories: |
|
199 if len(deviceDirectories) == 1: |
|
200 return deviceDirectories[0] |
|
201 else: |
|
202 boardDirectories = self.__findDeviceDirectories(deviceDirectories) |
|
203 if len(boardDirectories) == 1: |
|
204 return boardDirectories[0] |
|
205 elif len(boardDirectories) > 1: |
|
206 return self.selectDeviceDirectory(boardDirectories) |
|
207 else: |
|
208 return self.selectDeviceDirectory(deviceDirectories) |
|
209 else: |
|
210 # return the default workspace and give the user a warning (unless |
|
211 # silent mode is selected) |
|
212 if not silent: |
|
213 EricMessageBox.warning( |
|
214 self.microPython, |
|
215 self.tr("Workspace Directory"), |
|
216 self.tr( |
|
217 "Python files for CircuitPython can be edited in" |
|
218 " place, if the device volume is locally" |
|
219 " available. Such a volume was not found. In" |
|
220 " place editing will not be available." |
|
221 ), |
|
222 ) |
|
223 |
|
224 return super().getWorkspace() |
|
225 |
|
226 def getWorkspace(self, silent=False): |
|
227 """ |
|
228 Public method to get the workspace directory. |
|
229 |
|
230 @param silent flag indicating silent operations |
|
231 @type bool |
|
232 @return workspace directory used for saving files |
|
233 @rtype str |
|
234 """ |
|
235 if self.__workspace: |
|
236 # return cached entry |
|
237 return self.__workspace |
|
238 else: |
|
239 self.__workspace = self.__findWorkspace(silent=silent) |
|
240 return self.__workspace |
|
241 |
|
242 def __createCPyMenu(self): |
|
243 """ |
|
244 Private method to create the CircuitPython submenu. |
|
245 """ |
|
246 self.__libraryMenu = QMenu(self.tr("Library Management")) |
|
247 self.__libraryMenu.aboutToShow.connect(self.__aboutToShowLibraryMenu) |
|
248 self.__libraryMenu.setTearOffEnabled(True) |
|
249 |
|
250 self.__cpyMenu = QMenu(self.tr("CircuitPython Functions")) |
|
251 |
|
252 self.__cpyMenu.addAction( |
|
253 self.tr("Show CircuitPython Versions"), self.__showCircuitPythonVersions |
|
254 ) |
|
255 self.__cpyMenu.addSeparator() |
|
256 |
|
257 boardName = self.microPython.getCurrentBoard() |
|
258 lBoardName = boardName.lower() if boardName else "" |
|
259 if "teensy" in lBoardName: |
|
260 # Teensy 4.0 and 4.1 don't support UF2 flashing |
|
261 self.__cpyMenu.addAction( |
|
262 self.tr("CircuitPython Flash Instructions"), |
|
263 self.__showTeensyFlashInstructions, |
|
264 ) |
|
265 self.__flashCpyAct = self.__cpyMenu.addAction( |
|
266 self.tr("Flash CircuitPython Firmware"), self.__startTeensyLoader |
|
267 ) |
|
268 self.__flashCpyAct.setToolTip( |
|
269 self.tr( |
|
270 "Start the 'Teensy Loader' application to flash the Teensy device." |
|
271 ) |
|
272 ) |
|
273 else: |
|
274 self.__flashCpyAct = self.__cpyMenu.addAction( |
|
275 self.tr("Flash CircuitPython Firmware"), self.__flashCircuitPython |
|
276 ) |
|
277 self.__cpyMenu.addSeparator() |
|
278 self.__cpyMenu.addMenu(self.__libraryMenu) |
|
279 |
|
280 def addDeviceMenuEntries(self, menu): |
|
281 """ |
|
282 Public method to add device specific entries to the given menu. |
|
283 |
|
284 @param menu reference to the context menu |
|
285 @type QMenu |
|
286 """ |
|
287 linkConnected = self.microPython.isLinkConnected() |
|
288 |
|
289 self.__flashCpyAct.setEnabled(not linkConnected) |
|
290 |
|
291 menu.addMenu(self.__cpyMenu) |
|
292 |
|
293 @pyqtSlot() |
|
294 def __aboutToShowLibraryMenu(self): |
|
295 """ |
|
296 Private slot to populate the 'Library Management' menu. |
|
297 """ |
|
298 self.__libraryMenu.clear() |
|
299 |
|
300 if isCircupAvailable(): |
|
301 self.__updater.populateMenu(self.__libraryMenu) |
|
302 else: |
|
303 act = self.__libraryMenu.addAction( |
|
304 self.tr("Install Library Files"), self.__installLibraryFiles |
|
305 ) |
|
306 act.setEnabled(self.__deviceVolumeMounted()) |
|
307 act = self.__libraryMenu.addAction( |
|
308 self.tr("Install Library Package"), |
|
309 lambda: self.__installLibraryFiles(packageMode=True), |
|
310 ) |
|
311 act.setEnabled(self.__deviceVolumeMounted()) |
|
312 self.__libraryMenu.addSeparator() |
|
313 self.__libraryMenu.addAction( |
|
314 self.tr("Install 'circup' Package"), |
|
315 self.__updater.installCircup, |
|
316 ) |
|
317 |
|
318 def hasFlashMenuEntry(self): |
|
319 """ |
|
320 Public method to check, if the device has its own flash menu entry. |
|
321 |
|
322 @return flag indicating a specific flash menu entry |
|
323 @rtype bool |
|
324 """ |
|
325 return True |
|
326 |
|
327 @pyqtSlot() |
|
328 def __flashCircuitPython(self): |
|
329 """ |
|
330 Private slot to flash a CircuitPython firmware to a device supporting UF2. |
|
331 """ |
|
332 from ..UF2FlashDialog import UF2FlashDialog |
|
333 |
|
334 dlg = UF2FlashDialog(boardType="circuitpython") |
|
335 dlg.exec() |
|
336 |
|
337 def __showTeensyFlashInstructions(self): |
|
338 """ |
|
339 Private method to show a message box because Teensy does not support |
|
340 the UF2 bootloader yet. |
|
341 """ |
|
342 EricMessageBox.information( |
|
343 self.microPython, |
|
344 self.tr("Flash CircuitPython Firmware"), |
|
345 self.tr( |
|
346 """<p>Teensy 4.0 and Teensy 4.1 do not support the UF2""" |
|
347 """ bootloader. Please use the 'Teensy Loader'""" |
|
348 """ application to flash CircuitPython. Make sure you""" |
|
349 """ downloaded the CircuitPython .hex file.</p>""" |
|
350 """<p>See <a href="{0}">the PJRC Teensy web site</a>""" |
|
351 """ for details.</p>""" |
|
352 ).format("https://www.pjrc.com/teensy/loader.html"), |
|
353 ) |
|
354 |
|
355 def __startTeensyLoader(self): |
|
356 """ |
|
357 Private method to start the 'Teensy Loader' application. |
|
358 |
|
359 Note: The application must be accessible via the application search path. |
|
360 """ |
|
361 ok, _ = QProcess.startDetached("teensy") |
|
362 if not ok: |
|
363 EricMessageBox.warning( |
|
364 self.microPython, |
|
365 self.tr("Start 'Teensy Loader'"), |
|
366 self.tr( |
|
367 """<p>The 'Teensy Loader' application <b>teensy</b> could not""" |
|
368 """ be started. Ensure it is in the application search path or""" |
|
369 """ start it manually.</p>""" |
|
370 ), |
|
371 ) |
|
372 |
|
373 @pyqtSlot() |
|
374 def __showCircuitPythonVersions(self): |
|
375 """ |
|
376 Private slot to show the CircuitPython version of a connected device and |
|
377 the latest available one (from Github). |
|
378 """ |
|
379 ui = ericApp().getObject("UserInterface") |
|
380 request = QNetworkRequest(QUrl(FirmwareGithubUrls["circuitpython"])) |
|
381 reply = ui.networkAccessManager().head(request) |
|
382 reply.finished.connect(lambda: self.__cpyVersionResponse(reply)) |
|
383 |
|
384 def __cpyVersionResponse(self, reply): |
|
385 """ |
|
386 Private method handling the response of the latest version request. |
|
387 |
|
388 @param reply reference to the reply object |
|
389 @type QNetworkReply |
|
390 """ |
|
391 latestUrl = reply.url().toString() |
|
392 tag = latestUrl.rsplit("/", 1)[-1] |
|
393 latestVersion = Globals.versionToTuple(tag) |
|
394 |
|
395 cpyVersionStr = self.tr("unknown") |
|
396 cpyVersion = (0, 0, 0) |
|
397 if self.supportsLocalFileAccess(): |
|
398 bootFile = os.path.join(self.getWorkspace(), "boot_out.txt") |
|
399 if os.path.exists(bootFile): |
|
400 with open(bootFile, "r") as f: |
|
401 line = f.readline() |
|
402 cpyVersionStr = line.split(";")[0].split()[2] |
|
403 cpyVersion = Globals.versionToTuple(cpyVersionStr) |
|
404 if ( |
|
405 cpyVersion == (0, 0, 0) |
|
406 and self._deviceData |
|
407 and self._deviceData["mpy_version"] != "unknown" |
|
408 ): |
|
409 # drive is not mounted or 'boot_out.txt' is missing but the device |
|
410 # is connected via the serial console |
|
411 cpyVersionStr = self._deviceData["mpy_version"] |
|
412 cpyVersion = Globals.versionToTuple(cpyVersionStr) |
|
413 |
|
414 msg = self.tr( |
|
415 "<h4>CircuitPython Version Information</h4>" |
|
416 "<table>" |
|
417 "<tr><td>Installed:</td><td>{0}</td></tr>" |
|
418 "<tr><td>Available:</td><td>{1}</td></tr>" |
|
419 "</table>" |
|
420 ).format(cpyVersionStr, tag) |
|
421 if cpyVersion < latestVersion and cpyVersion != (0, 0, 0): |
|
422 msg += self.tr("<p><b>Update available!</b></p>") |
|
423 |
|
424 EricMessageBox.information( |
|
425 None, |
|
426 self.tr("CircuitPython Version"), |
|
427 msg, |
|
428 ) |
|
429 |
|
430 @pyqtSlot() |
|
431 def __installLibraryFiles(self, packageMode=False): |
|
432 """ |
|
433 Private slot to install Python files into the onboard library. |
|
434 |
|
435 @param packageMode flag indicating to install a library package |
|
436 (defaults to False) |
|
437 @type bool (optional) |
|
438 """ |
|
439 title = ( |
|
440 self.tr("Install Library Package") |
|
441 if packageMode |
|
442 else self.tr("Install Library Files") |
|
443 ) |
|
444 if not self.__deviceVolumeMounted(): |
|
445 EricMessageBox.critical( |
|
446 self.microPython, |
|
447 title, |
|
448 self.tr( |
|
449 """The device volume "<b>{0}</b>" is not available.""" |
|
450 """ Ensure it is mounted properly and try again.""" |
|
451 ), |
|
452 ) |
|
453 return |
|
454 |
|
455 target = os.path.join(self.getWorkspace(), "lib") |
|
456 # ensure that the library directory exists on the device |
|
457 if not os.path.isdir(target): |
|
458 os.makedirs(target) |
|
459 |
|
460 if packageMode: |
|
461 libraryPackage = EricFileDialog.getExistingDirectory( |
|
462 self.microPython, |
|
463 title, |
|
464 os.path.expanduser("~"), |
|
465 EricFileDialog.Option(0), |
|
466 ) |
|
467 if libraryPackage: |
|
468 target = os.path.join(target, os.path.basename(libraryPackage)) |
|
469 shutil.rmtree(target, ignore_errors=True) |
|
470 shutil.copytree(libraryPackage, target) |
|
471 else: |
|
472 libraryFiles = EricFileDialog.getOpenFileNames( |
|
473 self.microPython, |
|
474 title, |
|
475 os.path.expanduser("~"), |
|
476 self.tr( |
|
477 "Compiled Python Files (*.mpy);;" |
|
478 "Python Files (*.py);;" |
|
479 "All Files (*)" |
|
480 ), |
|
481 ) |
|
482 |
|
483 for libraryFile in libraryFiles: |
|
484 if os.path.exists(libraryFile): |
|
485 shutil.copy2(libraryFile, target) |
|
486 |
|
487 def getDocumentationUrl(self): |
|
488 """ |
|
489 Public method to get the device documentation URL. |
|
490 |
|
491 @return documentation URL of the device |
|
492 @rtype str |
|
493 """ |
|
494 return Preferences.getMicroPython("CircuitPythonDocuUrl") |
|
495 |
|
496 def getDownloadMenuEntries(self): |
|
497 """ |
|
498 Public method to retrieve the entries for the downloads menu. |
|
499 |
|
500 @return list of tuples with menu text and URL to be opened for each |
|
501 entry |
|
502 @rtype list of tuple of (str, str) |
|
503 """ |
|
504 return [ |
|
505 ( |
|
506 self.tr("CircuitPython Firmware"), |
|
507 Preferences.getMicroPython("CircuitPythonFirmwareUrl"), |
|
508 ), |
|
509 ( |
|
510 self.tr("CircuitPython Libraries"), |
|
511 Preferences.getMicroPython("CircuitPythonLibrariesUrl"), |
|
512 ), |
|
513 ] |
|
514 |
|
515 |
|
516 def createDevice(microPythonWidget, deviceType, vid, pid, boardName, serialNumber): |
|
517 """ |
|
518 Function to instantiate a MicroPython device object. |
|
519 |
|
520 @param microPythonWidget reference to the main MicroPython widget |
|
521 @type MicroPythonWidget |
|
522 @param deviceType device type assigned to this device interface |
|
523 @type str |
|
524 @param vid vendor ID |
|
525 @type int |
|
526 @param pid product ID |
|
527 @type int |
|
528 @param boardName name of the board |
|
529 @type str |
|
530 @param serialNumber serial number of the board |
|
531 @type str |
|
532 @return reference to the instantiated device object |
|
533 @rtype CircuitPythonDevice |
|
534 """ |
|
535 return CircuitPythonDevice(microPythonWidget, deviceType, boardName) |