src/eric7/MicroPython/Devices/CircuitPythonDevices.py

branch
eric7
changeset 9756
9854647c8c5c
parent 9755
1a09700229e7
child 9763
52f982c08301
diff -r 1a09700229e7 -r 9854647c8c5c src/eric7/MicroPython/Devices/CircuitPythonDevices.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/eric7/MicroPython/Devices/CircuitPythonDevices.py	Mon Feb 13 17:49:52 2023 +0100
@@ -0,0 +1,535 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2019 - 2023 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing the device interface class for CircuitPython boards.
+"""
+
+import os
+import shutil
+
+from PyQt6.QtCore import QProcess, QUrl, pyqtSlot
+from PyQt6.QtNetwork import QNetworkRequest
+from PyQt6.QtWidgets import QMenu
+
+from eric7 import Globals, Preferences
+from eric7.EricWidgets import EricFileDialog, EricMessageBox
+from eric7.EricWidgets.EricApplication import ericApp
+from eric7.SystemUtilities import FileSystemUtilities
+
+from .CircuitPythonUpdater.CircuitPythonUpdaterInterface import (
+    CircuitPythonUpdaterInterface,
+    isCircupAvailable,
+)
+from . import FirmwareGithubUrls
+from .DeviceBase import BaseDevice
+from ..MicroPythonWidget import HAS_QTCHART
+
+
+class CircuitPythonDevice(BaseDevice):
+    """
+    Class implementing the device for CircuitPython boards.
+    """
+
+    DeviceVolumeName = "CIRCUITPY"
+
+    def __init__(self, microPythonWidget, deviceType, boardName, parent=None):
+        """
+        Constructor
+
+        @param microPythonWidget reference to the main MicroPython widget
+        @type MicroPythonWidget
+        @param deviceType device type assigned to this device interface
+        @type str
+        @param boardName name of the board
+        @type str
+        @param parent reference to the parent object
+        @type QObject
+        """
+        super().__init__(microPythonWidget, deviceType, parent)
+
+        self.__boardName = boardName
+        self.__workspace = self.__findWorkspace()
+
+        self.__updater = CircuitPythonUpdaterInterface(self)
+
+        self.__createCPyMenu()
+
+    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
+        )
+
+        if self.__deviceVolumeMounted():
+            self.microPython.setActionButtons(open=True, save=True)
+
+    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 False
+
+    def deviceName(self):
+        """
+        Public method to get the name of the device.
+
+        @return name of the device
+        @rtype str
+        """
+        return self.tr("CircuitPython")
+
+    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 supportsLocalFileAccess(self):
+        """
+        Public method to indicate file access via a local directory.
+
+        @return flag indicating file access via local directory
+        @rtype bool
+        """
+        return self.__deviceVolumeMounted()
+
+    def __deviceVolumeMounted(self):
+        """
+        Private method to check, if the device volume is mounted.
+
+        @return flag indicated a mounted device
+        @rtype bool
+        """
+        if self.__workspace and not os.path.exists(self.__workspace):
+            self.__workspace = ""  # reset
+
+        return self.DeviceVolumeName in self.getWorkspace(silent=True)
+
+    def __findDeviceDirectories(self, directories):
+        """
+        Private method to find the device directories associated with the
+        current board name.
+
+        @param directories list of directories to be checked
+        @type list of str
+        @return list of associated directories
+        @rtype list of str
+        """
+        boardDirectories = []
+        for directory in directories:
+            bootFile = os.path.join(directory, "boot_out.txt")
+            if os.path.exists(bootFile):
+                with open(bootFile, "r") as f:
+                    line = f.readline()
+                if self.__boardName in line:
+                    boardDirectories.append(directory)
+
+        return boardDirectories
+
+    def __findWorkspace(self, silent=False):
+        """
+        Private method to find the workspace directory.
+
+        @param silent flag indicating silent operations
+        @type bool
+        @return workspace directory used for saving files
+        @rtype str
+        """
+        # Attempts to find the paths on the filesystem that represents the
+        # plugged in CIRCUITPY boards.
+        deviceDirectories = FileSystemUtilities.findVolume(
+            self.DeviceVolumeName, findAll=True
+        )
+
+        if deviceDirectories:
+            if len(deviceDirectories) == 1:
+                return deviceDirectories[0]
+            else:
+                boardDirectories = self.__findDeviceDirectories(deviceDirectories)
+                if len(boardDirectories) == 1:
+                    return boardDirectories[0]
+                elif len(boardDirectories) > 1:
+                    return self.selectDeviceDirectory(boardDirectories)
+                else:
+                    return self.selectDeviceDirectory(deviceDirectories)
+        else:
+            # return the default workspace and give the user a warning (unless
+            # silent mode is selected)
+            if not silent:
+                EricMessageBox.warning(
+                    self.microPython,
+                    self.tr("Workspace Directory"),
+                    self.tr(
+                        "Python files for CircuitPython can be edited in"
+                        " place, if the device volume is locally"
+                        " available. Such a volume was not found. In"
+                        " place editing will not be available."
+                    ),
+                )
+
+            return super().getWorkspace()
+
+    def getWorkspace(self, silent=False):
+        """
+        Public method to get the workspace directory.
+
+        @param silent flag indicating silent operations
+        @type bool
+        @return workspace directory used for saving files
+        @rtype str
+        """
+        if self.__workspace:
+            # return cached entry
+            return self.__workspace
+        else:
+            self.__workspace = self.__findWorkspace(silent=silent)
+            return self.__workspace
+
+    def __createCPyMenu(self):
+        """
+        Private method to create the CircuitPython submenu.
+        """
+        self.__libraryMenu = QMenu(self.tr("Library Management"))
+        self.__libraryMenu.aboutToShow.connect(self.__aboutToShowLibraryMenu)
+        self.__libraryMenu.setTearOffEnabled(True)
+
+        self.__cpyMenu = QMenu(self.tr("CircuitPython Functions"))
+
+        self.__cpyMenu.addAction(
+            self.tr("Show CircuitPython Versions"), self.__showCircuitPythonVersions
+        )
+        self.__cpyMenu.addSeparator()
+
+        boardName = self.microPython.getCurrentBoard()
+        lBoardName = boardName.lower() if boardName else ""
+        if "teensy" in lBoardName:
+            # Teensy 4.0 and 4.1 don't support UF2 flashing
+            self.__cpyMenu.addAction(
+                self.tr("CircuitPython Flash Instructions"),
+                self.__showTeensyFlashInstructions,
+            )
+            self.__flashCpyAct = self.__cpyMenu.addAction(
+                self.tr("Flash CircuitPython Firmware"), self.__startTeensyLoader
+            )
+            self.__flashCpyAct.setToolTip(
+                self.tr(
+                    "Start the 'Teensy Loader' application to flash the Teensy device."
+                )
+            )
+        else:
+            self.__flashCpyAct = self.__cpyMenu.addAction(
+                self.tr("Flash CircuitPython Firmware"), self.__flashCircuitPython
+            )
+        self.__cpyMenu.addSeparator()
+        self.__cpyMenu.addMenu(self.__libraryMenu)
+
+    def addDeviceMenuEntries(self, menu):
+        """
+        Public method to add device specific entries to the given menu.
+
+        @param menu reference to the context menu
+        @type QMenu
+        """
+        linkConnected = self.microPython.isLinkConnected()
+
+        self.__flashCpyAct.setEnabled(not linkConnected)
+
+        menu.addMenu(self.__cpyMenu)
+
+    @pyqtSlot()
+    def __aboutToShowLibraryMenu(self):
+        """
+        Private slot to populate the 'Library Management' menu.
+        """
+        self.__libraryMenu.clear()
+
+        if isCircupAvailable():
+            self.__updater.populateMenu(self.__libraryMenu)
+        else:
+            act = self.__libraryMenu.addAction(
+                self.tr("Install Library Files"), self.__installLibraryFiles
+            )
+            act.setEnabled(self.__deviceVolumeMounted())
+            act = self.__libraryMenu.addAction(
+                self.tr("Install Library Package"),
+                lambda: self.__installLibraryFiles(packageMode=True),
+            )
+            act.setEnabled(self.__deviceVolumeMounted())
+            self.__libraryMenu.addSeparator()
+            self.__libraryMenu.addAction(
+                self.tr("Install 'circup' Package"),
+                self.__updater.installCircup,
+            )
+
+    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 __flashCircuitPython(self):
+        """
+        Private slot to flash a CircuitPython firmware to a device supporting UF2.
+        """
+        from ..UF2FlashDialog import UF2FlashDialog
+
+        dlg = UF2FlashDialog(boardType="circuitpython")
+        dlg.exec()
+
+    def __showTeensyFlashInstructions(self):
+        """
+        Private method to show a message box because Teensy does not support
+        the UF2 bootloader yet.
+        """
+        EricMessageBox.information(
+            self.microPython,
+            self.tr("Flash CircuitPython Firmware"),
+            self.tr(
+                """<p>Teensy 4.0 and Teensy 4.1 do not support the UF2"""
+                """ bootloader. Please use the 'Teensy Loader'"""
+                """ application to flash CircuitPython. Make sure you"""
+                """ downloaded the CircuitPython .hex file.</p>"""
+                """<p>See <a href="{0}">the PJRC Teensy web site</a>"""
+                """ for details.</p>"""
+            ).format("https://www.pjrc.com/teensy/loader.html"),
+        )
+
+    def __startTeensyLoader(self):
+        """
+        Private method to start the 'Teensy Loader' application.
+
+        Note: The application must be accessible via the application search path.
+        """
+        ok, _ = QProcess.startDetached("teensy")
+        if not ok:
+            EricMessageBox.warning(
+                self.microPython,
+                self.tr("Start 'Teensy Loader'"),
+                self.tr(
+                    """<p>The 'Teensy Loader' application <b>teensy</b> could not"""
+                    """ be started. Ensure it is in the application search path or"""
+                    """ start it manually.</p>"""
+                ),
+            )
+
+    @pyqtSlot()
+    def __showCircuitPythonVersions(self):
+        """
+        Private slot to show the CircuitPython version of a connected device and
+        the latest available one (from Github).
+        """
+        ui = ericApp().getObject("UserInterface")
+        request = QNetworkRequest(QUrl(FirmwareGithubUrls["circuitpython"]))
+        reply = ui.networkAccessManager().head(request)
+        reply.finished.connect(lambda: self.__cpyVersionResponse(reply))
+
+    def __cpyVersionResponse(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]
+        latestVersion = Globals.versionToTuple(tag)
+
+        cpyVersionStr = self.tr("unknown")
+        cpyVersion = (0, 0, 0)
+        if self.supportsLocalFileAccess():
+            bootFile = os.path.join(self.getWorkspace(), "boot_out.txt")
+            if os.path.exists(bootFile):
+                with open(bootFile, "r") as f:
+                    line = f.readline()
+                cpyVersionStr = line.split(";")[0].split()[2]
+                cpyVersion = Globals.versionToTuple(cpyVersionStr)
+        if (
+            cpyVersion == (0, 0, 0)
+            and self._deviceData
+            and self._deviceData["mpy_version"] != "unknown"
+        ):
+            # drive is not mounted or 'boot_out.txt' is missing but the device
+            # is connected via the serial console
+            cpyVersionStr = self._deviceData["mpy_version"]
+            cpyVersion = Globals.versionToTuple(cpyVersionStr)
+
+        msg = self.tr(
+            "<h4>CircuitPython Version Information</h4>"
+            "<table>"
+            "<tr><td>Installed:</td><td>{0}</td></tr>"
+            "<tr><td>Available:</td><td>{1}</td></tr>"
+            "</table>"
+        ).format(cpyVersionStr, tag)
+        if cpyVersion < latestVersion and cpyVersion != (0, 0, 0):
+            msg += self.tr("<p><b>Update available!</b></p>")
+
+        EricMessageBox.information(
+            None,
+            self.tr("CircuitPython Version"),
+            msg,
+        )
+
+    @pyqtSlot()
+    def __installLibraryFiles(self, packageMode=False):
+        """
+        Private slot to install Python files into the onboard library.
+
+        @param packageMode flag indicating to install a library package
+            (defaults to False)
+        @type bool (optional)
+        """
+        title = (
+            self.tr("Install Library Package")
+            if packageMode
+            else self.tr("Install Library Files")
+        )
+        if not self.__deviceVolumeMounted():
+            EricMessageBox.critical(
+                self.microPython,
+                title,
+                self.tr(
+                    """The device volume "<b>{0}</b>" is not available."""
+                    """ Ensure it is mounted properly and try again."""
+                ),
+            )
+            return
+
+        target = os.path.join(self.getWorkspace(), "lib")
+        # ensure that the library directory exists on the device
+        if not os.path.isdir(target):
+            os.makedirs(target)
+
+        if packageMode:
+            libraryPackage = EricFileDialog.getExistingDirectory(
+                self.microPython,
+                title,
+                os.path.expanduser("~"),
+                EricFileDialog.Option(0),
+            )
+            if libraryPackage:
+                target = os.path.join(target, os.path.basename(libraryPackage))
+                shutil.rmtree(target, ignore_errors=True)
+                shutil.copytree(libraryPackage, target)
+        else:
+            libraryFiles = EricFileDialog.getOpenFileNames(
+                self.microPython,
+                title,
+                os.path.expanduser("~"),
+                self.tr(
+                    "Compiled Python Files (*.mpy);;"
+                    "Python Files (*.py);;"
+                    "All Files (*)"
+                ),
+            )
+
+            for libraryFile in libraryFiles:
+                if os.path.exists(libraryFile):
+                    shutil.copy2(libraryFile, target)
+
+    def getDocumentationUrl(self):
+        """
+        Public method to get the device documentation URL.
+
+        @return documentation URL of the device
+        @rtype str
+        """
+        return Preferences.getMicroPython("CircuitPythonDocuUrl")
+
+    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)
+        """
+        return [
+            (
+                self.tr("CircuitPython Firmware"),
+                Preferences.getMicroPython("CircuitPythonFirmwareUrl"),
+            ),
+            (
+                self.tr("CircuitPython Libraries"),
+                Preferences.getMicroPython("CircuitPythonLibrariesUrl"),
+            ),
+        ]
+
+
+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 CircuitPythonDevice
+    """
+    return CircuitPythonDevice(microPythonWidget, deviceType, boardName)

eric ide

mercurial