src/eric7/MicroPython/Devices/MicrobitDevices.py

branch
eric7
changeset 9756
9854647c8c5c
parent 9752
2b9546c0cbd9
child 9763
52f982c08301
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)

eric ide

mercurial