--- a/src/eric7/MicroPython/MicrobitDevices.py Sun Feb 12 18:11:20 2023 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,653 +0,0 @@ -# -*- 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 .MicroPythonDevices import FirmwareGithubUrls, MicroPythonDevice -from .MicroPythonWidget import HAS_QTCHART - - -class MicrobitDevice(MicroPythonDevice): - """ - 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)