src/eric7/MicroPython/Devices/CircuitPythonDevices.py

branch
eric7
changeset 9756
9854647c8c5c
parent 9755
1a09700229e7
child 9763
52f982c08301
equal deleted inserted replaced
9755:1a09700229e7 9756:9854647c8c5c
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)

eric ide

mercurial