diff -r 1a09700229e7 -r 9854647c8c5c src/eric7/MicroPython/Devices/MicrobitDevices.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/eric7/MicroPython/Devices/MicrobitDevices.py Mon Feb 13 17:49:52 2023 +0100 @@ -0,0 +1,654 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 - 2023 Detlev Offenbach <detlev@die-offenbachs.de> +# + +""" +Module implementing the device interface class for BBC micro:bit and +Calliope mini boards. +""" + +import contextlib +import os +import shutil + +from PyQt6.QtCore import QStandardPaths, QUrl, pyqtSlot +from PyQt6.QtNetwork import QNetworkRequest +from PyQt6.QtWidgets import QInputDialog, QLineEdit, QMenu + +from eric7 import Globals, Preferences +from eric7.EricWidgets import EricFileDialog, EricMessageBox +from eric7.EricWidgets.EricApplication import ericApp +from eric7.SystemUtilities import FileSystemUtilities + +from . import FirmwareGithubUrls +from .DeviceBase import BaseDevice +from ..MicroPythonWidget import HAS_QTCHART + + +class MicrobitDevice(BaseDevice): + """ + Class implementing the device for BBC micro:bit and Calliope mini boards. + """ + + def __init__(self, microPythonWidget, deviceType, serialNumber, parent=None): + """ + Constructor + + @param microPythonWidget reference to the main MicroPython widget + @type MicroPythonWidget + @param deviceType type of the device + @type str + @param serialNumber serial number of the board + @type str + @param parent reference to the parent object + @type QObject + """ + super().__init__(microPythonWidget, deviceType, parent) + + self.__boardId = 0 # illegal ID + if serialNumber: + with contextlib.suppress(ValueError): + self.__boardId = int(serialNumber[:4], 16) + + self.__createMicrobitMenu() + + def setButtons(self): + """ + Public method to enable the supported action buttons. + """ + super().setButtons() + self.microPython.setActionButtons( + run=True, repl=True, files=True, chart=HAS_QTCHART + ) + + def forceInterrupt(self): + """ + Public method to determine the need for an interrupt when opening the + serial connection. + + @return flag indicating an interrupt is needed + @rtype bool + """ + return True + + def deviceName(self): + """ + Public method to get the name of the device. + + @return name of the device + @rtype str + """ + if self.getDeviceType() == "bbc_microbit": + # BBC micro:bit + return self.tr("BBC micro:bit") + else: + # Calliope mini + return self.tr("Calliope mini") + + def canStartRepl(self): + """ + Public method to determine, if a REPL can be started. + + @return tuple containing a flag indicating it is safe to start a REPL + and a reason why it cannot. + @rtype tuple of (bool, str) + """ + return True, "" + + def canStartPlotter(self): + """ + Public method to determine, if a Plotter can be started. + + @return tuple containing a flag indicating it is safe to start a + Plotter and a reason why it cannot. + @rtype tuple of (bool, str) + """ + return True, "" + + def canRunScript(self): + """ + Public method to determine, if a script can be executed. + + @return tuple containing a flag indicating it is safe to start a + Plotter and a reason why it cannot. + @rtype tuple of (bool, str) + """ + return True, "" + + def runScript(self, script): + """ + Public method to run the given Python script. + + @param script script to be executed + @type str + """ + pythonScript = script.split("\n") + self.sendCommands(pythonScript) + + def canStartFileManager(self): + """ + Public method to determine, if a File Manager can be started. + + @return tuple containing a flag indicating it is safe to start a + File Manager and a reason why it cannot. + @rtype tuple of (bool, str) + """ + return True, "" + + def hasTimeCommands(self): + """ + Public method to check, if the device supports time commands. + + The default returns True. + + @return flag indicating support for time commands + @rtype bool + """ + if ( + self.microPython.isConnected() + and self.checkDeviceData() + and self._deviceData["mpy_name"] == "circuitpython" + ): + return True + + return False + + def __isMicroBitV1(self): + """ + Private method to check, if the device is a BBC micro:bit v1. + + @return falg indicating a BBC micro:bit v1 + @rtype bool + """ + return self.__boardId in (0x9900, 0x9901) + + def __isMicroBitV2(self): + """ + Private method to check, if the device is a BBC micro:bit v2. + + @return falg indicating a BBC micro:bit v2 + @rtype bool + """ + return self.__boardId in (0x9903, 0x9904, 0x9905, 0x9906) + + def __isCalliope(self): + """ + Private method to check, if the device is a Calliope mini. + + @return flag indicating a Calliope mini + @rtype bool + """ + return self.__boardId in (0x12A0,) + + def __createMicrobitMenu(self): + """ + Private method to create the microbit submenu. + """ + self.__microbitMenu = QMenu(self.tr("BBC micro:bit/Calliope Functions")) + + self.__showMpyAct = self.__microbitMenu.addAction( + self.tr("Show MicroPython Versions"), self.__showFirmwareVersions + ) + self.__microbitMenu.addSeparator() + self.__flashMpyAct = self.__microbitMenu.addAction( + self.tr("Flash MicroPython"), self.__flashMicroPython + ) + self.__flashDAPLinkAct = self.__microbitMenu.addAction( + self.tr("Flash Firmware"), lambda: self.__flashMicroPython(firmware=True) + ) + self.__microbitMenu.addSeparator() + self.__saveScripAct = self.__microbitMenu.addAction( + self.tr("Save Script"), self.__saveScriptToDevice + ) + self.__saveScripAct.setToolTip( + self.tr("Save the current script to the selected device") + ) + self.__saveMainScriptAct = self.__microbitMenu.addAction( + self.tr("Save Script as 'main.py'"), self.__saveMain + ) + self.__saveMainScriptAct.setToolTip( + self.tr("Save the current script as 'main.py' on the connected device") + ) + self.__microbitMenu.addSeparator() + self.__resetAct = self.__microbitMenu.addAction( + self.tr("Reset {0}").format(self.deviceName()), self.__resetDevice + ) + + def addDeviceMenuEntries(self, menu): + """ + Public method to add device specific entries to the given menu. + + @param menu reference to the context menu + @type QMenu + """ + connected = self.microPython.isConnected() + linkConnected = self.microPython.isLinkConnected() + + self.__showMpyAct.setEnabled(connected and self.getDeviceType() != "calliope") + self.__flashMpyAct.setEnabled(not linkConnected) + self.__flashDAPLinkAct.setEnabled(not linkConnected) + self.__saveScripAct.setEnabled(connected) + self.__saveMainScriptAct.setEnabled(connected) + self.__resetAct.setEnabled(connected) + + menu.addMenu(self.__microbitMenu) + + def hasFlashMenuEntry(self): + """ + Public method to check, if the device has its own flash menu entry. + + @return flag indicating a specific flash menu entry + @rtype bool + """ + return True + + @pyqtSlot() + def __flashMicroPython(self, firmware=False): + """ + Private slot to flash MicroPython or the DAPLink firmware to the + device. + + @param firmware flag indicating to flash the DAPLink firmware + @type bool + """ + # Attempts to find the path on the file system that represents the + # plugged in micro:bit board. To flash the DAPLink firmware, it must be + # in maintenance mode, for MicroPython in standard mode. + if self.getDeviceType() == "bbc_microbit": + # BBC micro:bit + if firmware: + deviceDirectories = FileSystemUtilities.findVolume( + "MAINTENANCE", findAll=True + ) + else: + deviceDirectories = FileSystemUtilities.findVolume( + "MICROBIT", findAll=True + ) + else: + # Calliope mini + if firmware: + deviceDirectories = FileSystemUtilities.findVolume( + "MAINTENANCE", findAll=True + ) + else: + deviceDirectories = FileSystemUtilities.findVolume("MINI", findAll=True) + if len(deviceDirectories) == 0: + if self.getDeviceType() == "bbc_microbit": + # BBC micro:bit is not ready or not mounted + if firmware: + EricMessageBox.critical( + self.microPython, + self.tr("Flash MicroPython/Firmware"), + self.tr( + "<p>The BBC micro:bit is not ready for flashing" + " the DAPLink firmware. Follow these" + " instructions. </p>" + "<ul>" + "<li>unplug USB cable and any batteries</li>" + "<li>keep RESET button pressed and plug USB cable" + " back in</li>" + "<li>a drive called MAINTENANCE should be" + " available</li>" + "</ul>" + "<p>See the " + '<a href="https://microbit.org/guide/firmware/">' + "micro:bit web site</a> for details.</p>" + ), + ) + else: + EricMessageBox.critical( + self.microPython, + self.tr("Flash MicroPython/Firmware"), + self.tr( + "<p>The BBC micro:bit is not ready for flashing" + " the MicroPython firmware. Please make sure," + " that a drive called MICROBIT is available." + "</p>" + ), + ) + else: + # Calliope mini is not ready or not mounted + if firmware: + EricMessageBox.critical( + self.microPython, + self.tr("Flash MicroPython/Firmware"), + self.tr( + '<p>The "Calliope mini" is not ready for flashing' + " the DAPLink firmware. Follow these" + " instructions. </p>" + "<ul>" + "<li>unplug USB cable and any batteries</li>" + "<li>keep RESET button pressed an plug USB cable" + " back in</li>" + "<li>a drive called MAINTENANCE should be" + " available</li>" + "</ul>" + ), + ) + else: + EricMessageBox.critical( + self.microPython, + self.tr("Flash MicroPython/Firmware"), + self.tr( + '<p>The "Calliope mini" is not ready for flashing' + " the MicroPython firmware. Please make sure," + " that a drive called MINI is available." + "</p>" + ), + ) + elif len(deviceDirectories) == 1: + downloadsPath = QStandardPaths.standardLocations( + QStandardPaths.StandardLocation.DownloadLocation + )[0] + firmware = EricFileDialog.getOpenFileName( + self.microPython, + self.tr("Flash MicroPython/Firmware"), + downloadsPath, + self.tr("MicroPython/Firmware Files (*.hex *.bin);;All Files (*)"), + ) + if firmware and os.path.exists(firmware): + shutil.copy2(firmware, deviceDirectories[0]) + else: + EricMessageBox.warning( + self, + self.tr("Flash MicroPython/Firmware"), + self.tr( + "There are multiple devices ready for flashing." + " Please make sure, that only one device is prepared." + ), + ) + + @pyqtSlot() + def __showFirmwareVersions(self): + """ + Private slot to show the firmware version of the connected device and the + available firmware version. + """ + if self.microPython.isConnected() and self.checkDeviceData(): + if self._deviceData["mpy_name"] not in ("micropython", "circuitpython"): + EricMessageBox.critical( + None, + self.tr("Show MicroPython Versions"), + self.tr( + """The firmware of the connected device cannot be""" + """ determined or the board does not run MicroPython""" + """ or CircuitPython. Aborting...""" + ), + ) + else: + if self.getDeviceType() == "bbc_microbit": + if self._deviceData["mpy_name"] == "micropython": + if self.__isMicroBitV1(): + url = QUrl(FirmwareGithubUrls["microbit_v1"]) + elif self.__isMicroBitV2(): + url = QUrl(FirmwareGithubUrls["microbit_v2"]) + else: + EricMessageBox.critical( + None, + self.tr("Show MicroPython Versions"), + self.tr( + """<p>The BBC micro:bit generation cannot be""" + """ determined. Aborting...</p>""" + ), + ) + return + elif self._deviceData["mpy_name"] == "circuitpython": + url = QUrl(FirmwareGithubUrls["circuitpython"]) + else: + EricMessageBox.critical( + None, + self.tr("Show MicroPython Versions"), + self.tr( + """<p>The firmware URL for the device type <b>{0}</b>""" + """ is not known. Aborting...</p>""" + ).format(self.getDeviceType()), + ) + return + + ui = ericApp().getObject("UserInterface") + request = QNetworkRequest(url) + reply = ui.networkAccessManager().head(request) + reply.finished.connect(lambda: self.__firmwareVersionResponse(reply)) + + def __firmwareVersionResponse(self, reply): + """ + Private method handling the response of the latest version request. + + @param reply reference to the reply object + @type QNetworkReply + """ + latestUrl = reply.url().toString() + tag = latestUrl.rsplit("/", 1)[-1] + while tag and not tag[0].isdecimal(): + # get rid of leading non-decimal characters + tag = tag[1:] + latestVersion = Globals.versionToTuple(tag) + + if self._deviceData["release"] == "unknown": + currentVersionStr = self.tr("unknown") + currentVersion = (0, 0, 0) + else: + currentVersionStr = self._deviceData["release"] + currentVersion = Globals.versionToTuple(currentVersionStr) + + if self._deviceData["mpy_name"] == "circuitpython": + kind = "CircuitPython" + microbitVersion = "2" # only v2 device can run CircuitPython + elif self._deviceData["mpy_name"] == "micropython": + kind = "MicroPython" + if self.__isMicroBitV1(): + microbitVersion = "1" + elif self.__isMicroBitV2(): + microbitVersion = "2" + else: + kind = self.tr("Firmware") + microbitVersion = "?" + + msg = self.tr( + "<h4>{0} Version Information<br/>" + "(BBC micro:bit v{1})</h4>" + "<table>" + "<tr><td>Installed:</td><td>{2}</td></tr>" + "<tr><td>Available:</td><td>{3}</td></tr>" + "</table>" + ).format(kind, microbitVersion, currentVersionStr, tag) + if currentVersion < latestVersion: + msg += self.tr("<p><b>Update available!</b></p>") + + EricMessageBox.information( + None, + self.tr("{0} Version").format(kind), + msg, + ) + + @pyqtSlot() + def __saveMain(self): + """ + Private slot to copy the current script as 'main.py' onto the + connected device. + """ + self.__saveScriptToDevice("main.py") + + @pyqtSlot() + def __saveScriptToDevice(self, scriptName=""): + """ + Private method to save the current script onto the connected + device. + + @param scriptName name of the file on the device + @type str + """ + aw = ericApp().getObject("ViewManager").activeWindow() + if not aw: + return + + title = ( + self.tr("Save Script as '{0}'").format(scriptName) + if scriptName + else self.tr("Save Script") + ) + + if not (aw.isPyFile() or aw.isMicroPythonFile()): + yes = EricMessageBox.yesNo( + self.microPython, + title, + self.tr( + """The current editor does not contain a Python""" + """ script. Write it anyway?""" + ), + ) + if not yes: + return + + script = aw.text().strip() + if not script: + EricMessageBox.warning( + self.microPython, title, self.tr("""The script is empty. Aborting.""") + ) + return + + if not scriptName: + scriptName = os.path.basename(aw.getFileName()) + scriptName, ok = QInputDialog.getText( + self.microPython, + title, + self.tr("Enter a file name on the device:"), + QLineEdit.EchoMode.Normal, + scriptName, + ) + if not ok or not bool(scriptName): + return + + title = self.tr("Save Script as '{0}'").format(scriptName) + + commands = [ + "fd = open('{0}', 'wb')".format(scriptName), + "f = fd.write", + ] + for line in script.splitlines(): + commands.append("f(" + repr(line + "\n") + ")") + commands.append("fd.close()") + out, err = self.microPython.commandsInterface().execute(commands) + if err: + EricMessageBox.critical( + self.microPython, + title, + self.tr( + """<p>The script could not be saved to the""" + """ device.</p><p>Reason: {0}</p>""" + ).format(err.decode("utf-8")), + ) + + # reset the device + self.__resetDevice() + + @pyqtSlot() + def __resetDevice(self): + """ + Private slot to reset the connected device. + """ + if self.getDeviceType() == "bbc_microbit": + # BBC micro:bit + self.microPython.commandsInterface().execute( + [ + "import microbit", + "microbit.reset()", + ] + ) + else: + # Calliope mini + self.microPython.commandsInterface().execute( + [ + "import calliope_mini", + "calliope_mini.reset()", + ] + ) + + def getDocumentationUrl(self): + """ + Public method to get the device documentation URL. + + @return documentation URL of the device + @rtype str + """ + if self.getDeviceType() == "bbc_microbit": + # BBC micro:bit + if self._deviceData and self._deviceData["mpy_name"] == "circuitpython": + return Preferences.getMicroPython("CircuitPythonDocuUrl") + else: + return Preferences.getMicroPython("MicrobitDocuUrl") + else: + # Calliope mini + return Preferences.getMicroPython("CalliopeDocuUrl") + + def getDownloadMenuEntries(self): + """ + Public method to retrieve the entries for the downloads menu. + + @return list of tuples with menu text and URL to be opened for each + entry + @rtype list of tuple of (str, str) + """ + if self.getDeviceType() == "bbc_microbit": + if self.__isMicroBitV1(): + return [ + ( + self.tr("MicroPython Firmware for BBC micro:bit V1"), + Preferences.getMicroPython("MicrobitMicroPythonUrl"), + ), + ( + self.tr("DAPLink Firmware"), + Preferences.getMicroPython("MicrobitFirmwareUrl"), + ), + ] + elif self.__isMicroBitV2(): + return [ + ( + self.tr("MicroPython Firmware for BBC micro:bit V2"), + Preferences.getMicroPython("MicrobitV2MicroPythonUrl"), + ), + ( + self.tr("CircuitPython Firmware for BBC micro:bit V2"), + "https://circuitpython.org/board/microbit_v2/", + ), + ( + self.tr("DAPLink Firmware"), + Preferences.getMicroPython("MicrobitFirmwareUrl"), + ), + ] + else: + return [] + else: + return [ + ( + self.tr("MicroPython Firmware"), + Preferences.getMicroPython("CalliopeMicroPythonUrl"), + ), + ( + self.tr("DAPLink Firmware"), + Preferences.getMicroPython("CalliopeDAPLinkUrl"), + ), + ] + + +def createDevice(microPythonWidget, deviceType, vid, pid, boardName, serialNumber): + """ + Function to instantiate a MicroPython device object. + + @param microPythonWidget reference to the main MicroPython widget + @type MicroPythonWidget + @param deviceType device type assigned to this device interface + @type str + @param vid vendor ID + @type int + @param pid product ID + @type int + @param boardName name of the board + @type str + @param serialNumber serial number of the board + @type str + @return reference to the instantiated device object + @rtype MicrobitDevice + """ + return MicrobitDevice(microPythonWidget, deviceType, serialNumber)