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)