src/eric7/MicroPython/Devices/MicrobitDevices.py

branch
eric7
changeset 9756
9854647c8c5c
parent 9752
2b9546c0cbd9
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 BBC micro:bit and
8 Calliope mini boards.
9 """
10
11 import contextlib
12 import os
13 import shutil
14
15 from PyQt6.QtCore import QStandardPaths, QUrl, pyqtSlot
16 from PyQt6.QtNetwork import QNetworkRequest
17 from PyQt6.QtWidgets import QInputDialog, QLineEdit, QMenu
18
19 from eric7 import Globals, Preferences
20 from eric7.EricWidgets import EricFileDialog, EricMessageBox
21 from eric7.EricWidgets.EricApplication import ericApp
22 from eric7.SystemUtilities import FileSystemUtilities
23
24 from . import FirmwareGithubUrls
25 from .DeviceBase import BaseDevice
26 from ..MicroPythonWidget import HAS_QTCHART
27
28
29 class MicrobitDevice(BaseDevice):
30 """
31 Class implementing the device for BBC micro:bit and Calliope mini boards.
32 """
33
34 def __init__(self, microPythonWidget, deviceType, serialNumber, parent=None):
35 """
36 Constructor
37
38 @param microPythonWidget reference to the main MicroPython widget
39 @type MicroPythonWidget
40 @param deviceType type of the device
41 @type str
42 @param serialNumber serial number of the board
43 @type str
44 @param parent reference to the parent object
45 @type QObject
46 """
47 super().__init__(microPythonWidget, deviceType, parent)
48
49 self.__boardId = 0 # illegal ID
50 if serialNumber:
51 with contextlib.suppress(ValueError):
52 self.__boardId = int(serialNumber[:4], 16)
53
54 self.__createMicrobitMenu()
55
56 def setButtons(self):
57 """
58 Public method to enable the supported action buttons.
59 """
60 super().setButtons()
61 self.microPython.setActionButtons(
62 run=True, repl=True, files=True, chart=HAS_QTCHART
63 )
64
65 def forceInterrupt(self):
66 """
67 Public method to determine the need for an interrupt when opening the
68 serial connection.
69
70 @return flag indicating an interrupt is needed
71 @rtype bool
72 """
73 return True
74
75 def deviceName(self):
76 """
77 Public method to get the name of the device.
78
79 @return name of the device
80 @rtype str
81 """
82 if self.getDeviceType() == "bbc_microbit":
83 # BBC micro:bit
84 return self.tr("BBC micro:bit")
85 else:
86 # Calliope mini
87 return self.tr("Calliope mini")
88
89 def canStartRepl(self):
90 """
91 Public method to determine, if a REPL can be started.
92
93 @return tuple containing a flag indicating it is safe to start a REPL
94 and a reason why it cannot.
95 @rtype tuple of (bool, str)
96 """
97 return True, ""
98
99 def canStartPlotter(self):
100 """
101 Public method to determine, if a Plotter can be started.
102
103 @return tuple containing a flag indicating it is safe to start a
104 Plotter and a reason why it cannot.
105 @rtype tuple of (bool, str)
106 """
107 return True, ""
108
109 def canRunScript(self):
110 """
111 Public method to determine, if a script can be executed.
112
113 @return tuple containing a flag indicating it is safe to start a
114 Plotter and a reason why it cannot.
115 @rtype tuple of (bool, str)
116 """
117 return True, ""
118
119 def runScript(self, script):
120 """
121 Public method to run the given Python script.
122
123 @param script script to be executed
124 @type str
125 """
126 pythonScript = script.split("\n")
127 self.sendCommands(pythonScript)
128
129 def canStartFileManager(self):
130 """
131 Public method to determine, if a File Manager can be started.
132
133 @return tuple containing a flag indicating it is safe to start a
134 File Manager and a reason why it cannot.
135 @rtype tuple of (bool, str)
136 """
137 return True, ""
138
139 def hasTimeCommands(self):
140 """
141 Public method to check, if the device supports time commands.
142
143 The default returns True.
144
145 @return flag indicating support for time commands
146 @rtype bool
147 """
148 if (
149 self.microPython.isConnected()
150 and self.checkDeviceData()
151 and self._deviceData["mpy_name"] == "circuitpython"
152 ):
153 return True
154
155 return False
156
157 def __isMicroBitV1(self):
158 """
159 Private method to check, if the device is a BBC micro:bit v1.
160
161 @return falg indicating a BBC micro:bit v1
162 @rtype bool
163 """
164 return self.__boardId in (0x9900, 0x9901)
165
166 def __isMicroBitV2(self):
167 """
168 Private method to check, if the device is a BBC micro:bit v2.
169
170 @return falg indicating a BBC micro:bit v2
171 @rtype bool
172 """
173 return self.__boardId in (0x9903, 0x9904, 0x9905, 0x9906)
174
175 def __isCalliope(self):
176 """
177 Private method to check, if the device is a Calliope mini.
178
179 @return flag indicating a Calliope mini
180 @rtype bool
181 """
182 return self.__boardId in (0x12A0,)
183
184 def __createMicrobitMenu(self):
185 """
186 Private method to create the microbit submenu.
187 """
188 self.__microbitMenu = QMenu(self.tr("BBC micro:bit/Calliope Functions"))
189
190 self.__showMpyAct = self.__microbitMenu.addAction(
191 self.tr("Show MicroPython Versions"), self.__showFirmwareVersions
192 )
193 self.__microbitMenu.addSeparator()
194 self.__flashMpyAct = self.__microbitMenu.addAction(
195 self.tr("Flash MicroPython"), self.__flashMicroPython
196 )
197 self.__flashDAPLinkAct = self.__microbitMenu.addAction(
198 self.tr("Flash Firmware"), lambda: self.__flashMicroPython(firmware=True)
199 )
200 self.__microbitMenu.addSeparator()
201 self.__saveScripAct = self.__microbitMenu.addAction(
202 self.tr("Save Script"), self.__saveScriptToDevice
203 )
204 self.__saveScripAct.setToolTip(
205 self.tr("Save the current script to the selected device")
206 )
207 self.__saveMainScriptAct = self.__microbitMenu.addAction(
208 self.tr("Save Script as 'main.py'"), self.__saveMain
209 )
210 self.__saveMainScriptAct.setToolTip(
211 self.tr("Save the current script as 'main.py' on the connected device")
212 )
213 self.__microbitMenu.addSeparator()
214 self.__resetAct = self.__microbitMenu.addAction(
215 self.tr("Reset {0}").format(self.deviceName()), self.__resetDevice
216 )
217
218 def addDeviceMenuEntries(self, menu):
219 """
220 Public method to add device specific entries to the given menu.
221
222 @param menu reference to the context menu
223 @type QMenu
224 """
225 connected = self.microPython.isConnected()
226 linkConnected = self.microPython.isLinkConnected()
227
228 self.__showMpyAct.setEnabled(connected and self.getDeviceType() != "calliope")
229 self.__flashMpyAct.setEnabled(not linkConnected)
230 self.__flashDAPLinkAct.setEnabled(not linkConnected)
231 self.__saveScripAct.setEnabled(connected)
232 self.__saveMainScriptAct.setEnabled(connected)
233 self.__resetAct.setEnabled(connected)
234
235 menu.addMenu(self.__microbitMenu)
236
237 def hasFlashMenuEntry(self):
238 """
239 Public method to check, if the device has its own flash menu entry.
240
241 @return flag indicating a specific flash menu entry
242 @rtype bool
243 """
244 return True
245
246 @pyqtSlot()
247 def __flashMicroPython(self, firmware=False):
248 """
249 Private slot to flash MicroPython or the DAPLink firmware to the
250 device.
251
252 @param firmware flag indicating to flash the DAPLink firmware
253 @type bool
254 """
255 # Attempts to find the path on the file system that represents the
256 # plugged in micro:bit board. To flash the DAPLink firmware, it must be
257 # in maintenance mode, for MicroPython in standard mode.
258 if self.getDeviceType() == "bbc_microbit":
259 # BBC micro:bit
260 if firmware:
261 deviceDirectories = FileSystemUtilities.findVolume(
262 "MAINTENANCE", findAll=True
263 )
264 else:
265 deviceDirectories = FileSystemUtilities.findVolume(
266 "MICROBIT", findAll=True
267 )
268 else:
269 # Calliope mini
270 if firmware:
271 deviceDirectories = FileSystemUtilities.findVolume(
272 "MAINTENANCE", findAll=True
273 )
274 else:
275 deviceDirectories = FileSystemUtilities.findVolume("MINI", findAll=True)
276 if len(deviceDirectories) == 0:
277 if self.getDeviceType() == "bbc_microbit":
278 # BBC micro:bit is not ready or not mounted
279 if firmware:
280 EricMessageBox.critical(
281 self.microPython,
282 self.tr("Flash MicroPython/Firmware"),
283 self.tr(
284 "<p>The BBC micro:bit is not ready for flashing"
285 " the DAPLink firmware. Follow these"
286 " instructions. </p>"
287 "<ul>"
288 "<li>unplug USB cable and any batteries</li>"
289 "<li>keep RESET button pressed and plug USB cable"
290 " back in</li>"
291 "<li>a drive called MAINTENANCE should be"
292 " available</li>"
293 "</ul>"
294 "<p>See the "
295 '<a href="https://microbit.org/guide/firmware/">'
296 "micro:bit web site</a> for details.</p>"
297 ),
298 )
299 else:
300 EricMessageBox.critical(
301 self.microPython,
302 self.tr("Flash MicroPython/Firmware"),
303 self.tr(
304 "<p>The BBC micro:bit is not ready for flashing"
305 " the MicroPython firmware. Please make sure,"
306 " that a drive called MICROBIT is available."
307 "</p>"
308 ),
309 )
310 else:
311 # Calliope mini is not ready or not mounted
312 if firmware:
313 EricMessageBox.critical(
314 self.microPython,
315 self.tr("Flash MicroPython/Firmware"),
316 self.tr(
317 '<p>The "Calliope mini" is not ready for flashing'
318 " the DAPLink firmware. Follow these"
319 " instructions. </p>"
320 "<ul>"
321 "<li>unplug USB cable and any batteries</li>"
322 "<li>keep RESET button pressed an plug USB cable"
323 " back in</li>"
324 "<li>a drive called MAINTENANCE should be"
325 " available</li>"
326 "</ul>"
327 ),
328 )
329 else:
330 EricMessageBox.critical(
331 self.microPython,
332 self.tr("Flash MicroPython/Firmware"),
333 self.tr(
334 '<p>The "Calliope mini" is not ready for flashing'
335 " the MicroPython firmware. Please make sure,"
336 " that a drive called MINI is available."
337 "</p>"
338 ),
339 )
340 elif len(deviceDirectories) == 1:
341 downloadsPath = QStandardPaths.standardLocations(
342 QStandardPaths.StandardLocation.DownloadLocation
343 )[0]
344 firmware = EricFileDialog.getOpenFileName(
345 self.microPython,
346 self.tr("Flash MicroPython/Firmware"),
347 downloadsPath,
348 self.tr("MicroPython/Firmware Files (*.hex *.bin);;All Files (*)"),
349 )
350 if firmware and os.path.exists(firmware):
351 shutil.copy2(firmware, deviceDirectories[0])
352 else:
353 EricMessageBox.warning(
354 self,
355 self.tr("Flash MicroPython/Firmware"),
356 self.tr(
357 "There are multiple devices ready for flashing."
358 " Please make sure, that only one device is prepared."
359 ),
360 )
361
362 @pyqtSlot()
363 def __showFirmwareVersions(self):
364 """
365 Private slot to show the firmware version of the connected device and the
366 available firmware version.
367 """
368 if self.microPython.isConnected() and self.checkDeviceData():
369 if self._deviceData["mpy_name"] not in ("micropython", "circuitpython"):
370 EricMessageBox.critical(
371 None,
372 self.tr("Show MicroPython Versions"),
373 self.tr(
374 """The firmware of the connected device cannot be"""
375 """ determined or the board does not run MicroPython"""
376 """ or CircuitPython. Aborting..."""
377 ),
378 )
379 else:
380 if self.getDeviceType() == "bbc_microbit":
381 if self._deviceData["mpy_name"] == "micropython":
382 if self.__isMicroBitV1():
383 url = QUrl(FirmwareGithubUrls["microbit_v1"])
384 elif self.__isMicroBitV2():
385 url = QUrl(FirmwareGithubUrls["microbit_v2"])
386 else:
387 EricMessageBox.critical(
388 None,
389 self.tr("Show MicroPython Versions"),
390 self.tr(
391 """<p>The BBC micro:bit generation cannot be"""
392 """ determined. Aborting...</p>"""
393 ),
394 )
395 return
396 elif self._deviceData["mpy_name"] == "circuitpython":
397 url = QUrl(FirmwareGithubUrls["circuitpython"])
398 else:
399 EricMessageBox.critical(
400 None,
401 self.tr("Show MicroPython Versions"),
402 self.tr(
403 """<p>The firmware URL for the device type <b>{0}</b>"""
404 """ is not known. Aborting...</p>"""
405 ).format(self.getDeviceType()),
406 )
407 return
408
409 ui = ericApp().getObject("UserInterface")
410 request = QNetworkRequest(url)
411 reply = ui.networkAccessManager().head(request)
412 reply.finished.connect(lambda: self.__firmwareVersionResponse(reply))
413
414 def __firmwareVersionResponse(self, reply):
415 """
416 Private method handling the response of the latest version request.
417
418 @param reply reference to the reply object
419 @type QNetworkReply
420 """
421 latestUrl = reply.url().toString()
422 tag = latestUrl.rsplit("/", 1)[-1]
423 while tag and not tag[0].isdecimal():
424 # get rid of leading non-decimal characters
425 tag = tag[1:]
426 latestVersion = Globals.versionToTuple(tag)
427
428 if self._deviceData["release"] == "unknown":
429 currentVersionStr = self.tr("unknown")
430 currentVersion = (0, 0, 0)
431 else:
432 currentVersionStr = self._deviceData["release"]
433 currentVersion = Globals.versionToTuple(currentVersionStr)
434
435 if self._deviceData["mpy_name"] == "circuitpython":
436 kind = "CircuitPython"
437 microbitVersion = "2" # only v2 device can run CircuitPython
438 elif self._deviceData["mpy_name"] == "micropython":
439 kind = "MicroPython"
440 if self.__isMicroBitV1():
441 microbitVersion = "1"
442 elif self.__isMicroBitV2():
443 microbitVersion = "2"
444 else:
445 kind = self.tr("Firmware")
446 microbitVersion = "?"
447
448 msg = self.tr(
449 "<h4>{0} Version Information<br/>"
450 "(BBC micro:bit v{1})</h4>"
451 "<table>"
452 "<tr><td>Installed:</td><td>{2}</td></tr>"
453 "<tr><td>Available:</td><td>{3}</td></tr>"
454 "</table>"
455 ).format(kind, microbitVersion, currentVersionStr, tag)
456 if currentVersion < latestVersion:
457 msg += self.tr("<p><b>Update available!</b></p>")
458
459 EricMessageBox.information(
460 None,
461 self.tr("{0} Version").format(kind),
462 msg,
463 )
464
465 @pyqtSlot()
466 def __saveMain(self):
467 """
468 Private slot to copy the current script as 'main.py' onto the
469 connected device.
470 """
471 self.__saveScriptToDevice("main.py")
472
473 @pyqtSlot()
474 def __saveScriptToDevice(self, scriptName=""):
475 """
476 Private method to save the current script onto the connected
477 device.
478
479 @param scriptName name of the file on the device
480 @type str
481 """
482 aw = ericApp().getObject("ViewManager").activeWindow()
483 if not aw:
484 return
485
486 title = (
487 self.tr("Save Script as '{0}'").format(scriptName)
488 if scriptName
489 else self.tr("Save Script")
490 )
491
492 if not (aw.isPyFile() or aw.isMicroPythonFile()):
493 yes = EricMessageBox.yesNo(
494 self.microPython,
495 title,
496 self.tr(
497 """The current editor does not contain a Python"""
498 """ script. Write it anyway?"""
499 ),
500 )
501 if not yes:
502 return
503
504 script = aw.text().strip()
505 if not script:
506 EricMessageBox.warning(
507 self.microPython, title, self.tr("""The script is empty. Aborting.""")
508 )
509 return
510
511 if not scriptName:
512 scriptName = os.path.basename(aw.getFileName())
513 scriptName, ok = QInputDialog.getText(
514 self.microPython,
515 title,
516 self.tr("Enter a file name on the device:"),
517 QLineEdit.EchoMode.Normal,
518 scriptName,
519 )
520 if not ok or not bool(scriptName):
521 return
522
523 title = self.tr("Save Script as '{0}'").format(scriptName)
524
525 commands = [
526 "fd = open('{0}', 'wb')".format(scriptName),
527 "f = fd.write",
528 ]
529 for line in script.splitlines():
530 commands.append("f(" + repr(line + "\n") + ")")
531 commands.append("fd.close()")
532 out, err = self.microPython.commandsInterface().execute(commands)
533 if err:
534 EricMessageBox.critical(
535 self.microPython,
536 title,
537 self.tr(
538 """<p>The script could not be saved to the"""
539 """ device.</p><p>Reason: {0}</p>"""
540 ).format(err.decode("utf-8")),
541 )
542
543 # reset the device
544 self.__resetDevice()
545
546 @pyqtSlot()
547 def __resetDevice(self):
548 """
549 Private slot to reset the connected device.
550 """
551 if self.getDeviceType() == "bbc_microbit":
552 # BBC micro:bit
553 self.microPython.commandsInterface().execute(
554 [
555 "import microbit",
556 "microbit.reset()",
557 ]
558 )
559 else:
560 # Calliope mini
561 self.microPython.commandsInterface().execute(
562 [
563 "import calliope_mini",
564 "calliope_mini.reset()",
565 ]
566 )
567
568 def getDocumentationUrl(self):
569 """
570 Public method to get the device documentation URL.
571
572 @return documentation URL of the device
573 @rtype str
574 """
575 if self.getDeviceType() == "bbc_microbit":
576 # BBC micro:bit
577 if self._deviceData and self._deviceData["mpy_name"] == "circuitpython":
578 return Preferences.getMicroPython("CircuitPythonDocuUrl")
579 else:
580 return Preferences.getMicroPython("MicrobitDocuUrl")
581 else:
582 # Calliope mini
583 return Preferences.getMicroPython("CalliopeDocuUrl")
584
585 def getDownloadMenuEntries(self):
586 """
587 Public method to retrieve the entries for the downloads menu.
588
589 @return list of tuples with menu text and URL to be opened for each
590 entry
591 @rtype list of tuple of (str, str)
592 """
593 if self.getDeviceType() == "bbc_microbit":
594 if self.__isMicroBitV1():
595 return [
596 (
597 self.tr("MicroPython Firmware for BBC micro:bit V1"),
598 Preferences.getMicroPython("MicrobitMicroPythonUrl"),
599 ),
600 (
601 self.tr("DAPLink Firmware"),
602 Preferences.getMicroPython("MicrobitFirmwareUrl"),
603 ),
604 ]
605 elif self.__isMicroBitV2():
606 return [
607 (
608 self.tr("MicroPython Firmware for BBC micro:bit V2"),
609 Preferences.getMicroPython("MicrobitV2MicroPythonUrl"),
610 ),
611 (
612 self.tr("CircuitPython Firmware for BBC micro:bit V2"),
613 "https://circuitpython.org/board/microbit_v2/",
614 ),
615 (
616 self.tr("DAPLink Firmware"),
617 Preferences.getMicroPython("MicrobitFirmwareUrl"),
618 ),
619 ]
620 else:
621 return []
622 else:
623 return [
624 (
625 self.tr("MicroPython Firmware"),
626 Preferences.getMicroPython("CalliopeMicroPythonUrl"),
627 ),
628 (
629 self.tr("DAPLink Firmware"),
630 Preferences.getMicroPython("CalliopeDAPLinkUrl"),
631 ),
632 ]
633
634
635 def createDevice(microPythonWidget, deviceType, vid, pid, boardName, serialNumber):
636 """
637 Function to instantiate a MicroPython device object.
638
639 @param microPythonWidget reference to the main MicroPython widget
640 @type MicroPythonWidget
641 @param deviceType device type assigned to this device interface
642 @type str
643 @param vid vendor ID
644 @type int
645 @param pid product ID
646 @type int
647 @param boardName name of the board
648 @type str
649 @param serialNumber serial number of the board
650 @type str
651 @return reference to the instantiated device object
652 @rtype MicrobitDevice
653 """
654 return MicrobitDevice(microPythonWidget, deviceType, serialNumber)

eric ide

mercurial