1# -*- coding: utf-8 -*-
2
3# Copyright (c) 2019 - 2021 Detlev Offenbach <detlev@die-offenbachs.de>
4#
5
6"""
7Module implementing the MicroPython REPL widget.
8"""
9
10import re
11import time
12import os
13import functools
14
15from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QPoint, QEvent
16from PyQt5.QtGui import QColor, QKeySequence, QTextCursor, QBrush, QClipboard
17from PyQt5.QtWidgets import (
18    QWidget, QMenu, QApplication, QHBoxLayout, QSpacerItem, QSizePolicy,
19    QTextEdit, QToolButton, QDialog
20)
21
22from E5Gui.E5ZoomWidget import E5ZoomWidget
23from E5Gui import E5MessageBox, E5FileDialog
24from E5Gui.E5Application import e5App
25from E5Gui.E5ProcessDialog import E5ProcessDialog
26from E5Gui.E5OverrideCursor import E5OverrideCursor, E5OverridenCursor
27
28from .Ui_MicroPythonWidget import Ui_MicroPythonWidget
29
30from . import MicroPythonDevices
31from . import UF2FlashDialog
32try:
33    from .MicroPythonGraphWidget import MicroPythonGraphWidget
34    HAS_QTCHART = True
35except ImportError:
36    HAS_QTCHART = False
37from .MicroPythonFileManagerWidget import MicroPythonFileManagerWidget
38try:
39    from .MicroPythonCommandsInterface import MicroPythonCommandsInterface
40    HAS_QTSERIALPORT = True
41except ImportError:
42    HAS_QTSERIALPORT = False
43
44import Globals
45import UI.PixmapCache
46import Preferences
47import Utilities
48
49from UI.Info import BugAddress
50
51# ANSI Colors (see https://en.wikipedia.org/wiki/ANSI_escape_code)
52AnsiColorSchemes = {
53    "Windows 7": {
54        0: QBrush(QColor(0, 0, 0)),
55        1: QBrush(QColor(128, 0, 0)),
56        2: QBrush(QColor(0, 128, 0)),
57        3: QBrush(QColor(128, 128, 0)),
58        4: QBrush(QColor(0, 0, 128)),
59        5: QBrush(QColor(128, 0, 128)),
60        6: QBrush(QColor(0, 128, 128)),
61        7: QBrush(QColor(192, 192, 192)),
62        10: QBrush(QColor(128, 128, 128)),
63        11: QBrush(QColor(255, 0, 0)),
64        12: QBrush(QColor(0, 255, 0)),
65        13: QBrush(QColor(255, 255, 0)),
66        14: QBrush(QColor(0, 0, 255)),
67        15: QBrush(QColor(255, 0, 255)),
68        16: QBrush(QColor(0, 255, 255)),
69        17: QBrush(QColor(255, 255, 255)),
70    },
71    "Windows 10": {
72        0: QBrush(QColor(12, 12, 12)),
73        1: QBrush(QColor(197, 15, 31)),
74        2: QBrush(QColor(19, 161, 14)),
75        3: QBrush(QColor(193, 156, 0)),
76        4: QBrush(QColor(0, 55, 218)),
77        5: QBrush(QColor(136, 23, 152)),
78        6: QBrush(QColor(58, 150, 221)),
79        7: QBrush(QColor(204, 204, 204)),
80        10: QBrush(QColor(118, 118, 118)),
81        11: QBrush(QColor(231, 72, 86)),
82        12: QBrush(QColor(22, 198, 12)),
83        13: QBrush(QColor(249, 241, 165)),
84        14: QBrush(QColor(59, 12, 255)),
85        15: QBrush(QColor(180, 0, 158)),
86        16: QBrush(QColor(97, 214, 214)),
87        17: QBrush(QColor(242, 242, 242)),
88    },
89    "PuTTY": {
90        0: QBrush(QColor(0, 0, 0)),
91        1: QBrush(QColor(187, 0, 0)),
92        2: QBrush(QColor(0, 187, 0)),
93        3: QBrush(QColor(187, 187, 0)),
94        4: QBrush(QColor(0, 0, 187)),
95        5: QBrush(QColor(187, 0, 187)),
96        6: QBrush(QColor(0, 187, 187)),
97        7: QBrush(QColor(187, 187, 187)),
98        10: QBrush(QColor(85, 85, 85)),
99        11: QBrush(QColor(255, 85, 85)),
100        12: QBrush(QColor(85, 255, 85)),
101        13: QBrush(QColor(255, 255, 85)),
102        14: QBrush(QColor(85, 85, 255)),
103        15: QBrush(QColor(255, 85, 255)),
104        16: QBrush(QColor(85, 255, 255)),
105        17: QBrush(QColor(255, 255, 255)),
106    },
107    "xterm": {
108        0: QBrush(QColor(0, 0, 0)),
109        1: QBrush(QColor(205, 0, 0)),
110        2: QBrush(QColor(0, 205, 0)),
111        3: QBrush(QColor(205, 205, 0)),
112        4: QBrush(QColor(0, 0, 238)),
113        5: QBrush(QColor(205, 0, 205)),
114        6: QBrush(QColor(0, 205, 205)),
115        7: QBrush(QColor(229, 229, 229)),
116        10: QBrush(QColor(127, 127, 127)),
117        11: QBrush(QColor(255, 0, 0)),
118        12: QBrush(QColor(0, 255, 0)),
119        13: QBrush(QColor(255, 255, 0)),
120        14: QBrush(QColor(0, 0, 255)),
121        15: QBrush(QColor(255, 0, 255)),
122        16: QBrush(QColor(0, 255, 255)),
123        17: QBrush(QColor(255, 255, 255)),
124    },
125    "Ubuntu": {
126        0: QBrush(QColor(1, 1, 1)),
127        1: QBrush(QColor(222, 56, 43)),
128        2: QBrush(QColor(57, 181, 74)),
129        3: QBrush(QColor(255, 199, 6)),
130        4: QBrush(QColor(0, 11, 184)),
131        5: QBrush(QColor(118, 38, 113)),
132        6: QBrush(QColor(44, 181, 233)),
133        7: QBrush(QColor(204, 204, 204)),
134        10: QBrush(QColor(128, 128, 128)),
135        11: QBrush(QColor(255, 0, 0)),
136        12: QBrush(QColor(0, 255, 0)),
137        13: QBrush(QColor(255, 255, 0)),
138        14: QBrush(QColor(0, 0, 255)),
139        15: QBrush(QColor(255, 0, 255)),
140        16: QBrush(QColor(0, 255, 255)),
141        17: QBrush(QColor(255, 255, 255)),
142    },
143    "Ubuntu (dark)": {
144        0: QBrush(QColor(96, 96, 96)),
145        1: QBrush(QColor(235, 58, 45)),
146        2: QBrush(QColor(57, 181, 74)),
147        3: QBrush(QColor(255, 199, 29)),
148        4: QBrush(QColor(25, 56, 230)),
149        5: QBrush(QColor(200, 64, 193)),
150        6: QBrush(QColor(48, 200, 255)),
151        7: QBrush(QColor(204, 204, 204)),
152        10: QBrush(QColor(128, 128, 128)),
153        11: QBrush(QColor(255, 0, 0)),
154        12: QBrush(QColor(0, 255, 0)),
155        13: QBrush(QColor(255, 255, 0)),
156        14: QBrush(QColor(0, 0, 255)),
157        15: QBrush(QColor(255, 0, 255)),
158        16: QBrush(QColor(0, 255, 255)),
159        17: QBrush(QColor(255, 255, 255)),
160    },
161    "Breeze (dark)": {
162        0: QBrush(QColor(35, 38, 39)),
163        1: QBrush(QColor(237, 21, 21)),
164        2: QBrush(QColor(17, 209, 22)),
165        3: QBrush(QColor(246, 116, 0)),
166        4: QBrush(QColor(29, 153, 243)),
167        5: QBrush(QColor(155, 89, 182)),
168        6: QBrush(QColor(26, 188, 156)),
169        7: QBrush(QColor(252, 252, 252)),
170        10: QBrush(QColor(127, 140, 141)),
171        11: QBrush(QColor(192, 57, 43)),
172        12: QBrush(QColor(28, 220, 154)),
173        13: QBrush(QColor(253, 188, 75)),
174        14: QBrush(QColor(61, 174, 233)),
175        15: QBrush(QColor(142, 68, 173)),
176        16: QBrush(QColor(22, 160, 133)),
177        17: QBrush(QColor(255, 255, 255)),
178    },
179}
180
181
182class MicroPythonWidget(QWidget, Ui_MicroPythonWidget):
183    """
184    Class implementing the MicroPython REPL widget.
185
186    @signal dataReceived(data) emitted to send data received via the serial
187        connection for further processing
188    """
189    ZoomMin = -10
190    ZoomMax = 20
191
192    DeviceTypeRole = Qt.ItemDataRole.UserRole
193    DeviceBoardRole = Qt.ItemDataRole.UserRole + 1
194    DevicePortRole = Qt.ItemDataRole.UserRole + 2
195    DeviceVidRole = Qt.ItemDataRole.UserRole + 3
196    DevicePidRole = Qt.ItemDataRole.UserRole + 4
197
198    dataReceived = pyqtSignal(bytes)
199
200    ManualMarker = "<manual>"
201
202    def __init__(self, parent=None):
203        """
204        Constructor
205
206        @param parent reference to the parent widget
207        @type QWidget
208        """
209        super().__init__(parent)
210        self.setupUi(self)
211
212        self.__ui = parent
213
214        self.__superMenu = QMenu(self)
215        self.__superMenu.aboutToShow.connect(self.__aboutToShowSuperMenu)
216
217        self.menuButton.setObjectName(
218            "micropython_supermenu_button")
219        self.menuButton.setIcon(UI.PixmapCache.getIcon("superMenu"))
220        self.menuButton.setToolTip(self.tr("MicroPython Menu"))
221        self.menuButton.setPopupMode(
222            QToolButton.ToolButtonPopupMode.InstantPopup)
223        self.menuButton.setToolButtonStyle(
224            Qt.ToolButtonStyle.ToolButtonIconOnly)
225        self.menuButton.setFocusPolicy(Qt.FocusPolicy.NoFocus)
226        self.menuButton.setAutoRaise(True)
227        self.menuButton.setShowMenuInside(True)
228        self.menuButton.setMenu(self.__superMenu)
229
230        self.deviceIconLabel.setPixmap(MicroPythonDevices.getDeviceIcon(
231            "", False))
232
233        self.openButton.setIcon(UI.PixmapCache.getIcon("open"))
234        self.saveButton.setIcon(UI.PixmapCache.getIcon("fileSaveAs"))
235
236        self.checkButton.setIcon(UI.PixmapCache.getIcon("question"))
237        self.runButton.setIcon(UI.PixmapCache.getIcon("start"))
238        self.replButton.setIcon(UI.PixmapCache.getIcon("terminal"))
239        self.filesButton.setIcon(UI.PixmapCache.getIcon("filemanager"))
240        self.chartButton.setIcon(UI.PixmapCache.getIcon("chart"))
241        self.connectButton.setIcon(UI.PixmapCache.getIcon("linkConnect"))
242
243        self.__zoomLayout = QHBoxLayout()
244        spacerItem = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding,
245                                 QSizePolicy.Policy.Minimum)
246        self.__zoomLayout.addSpacerItem(spacerItem)
247
248        self.__zoom0 = self.replEdit.fontPointSize()
249        self.__zoomWidget = E5ZoomWidget(
250            UI.PixmapCache.getPixmap("zoomOut"),
251            UI.PixmapCache.getPixmap("zoomIn"),
252            UI.PixmapCache.getPixmap("zoomReset"), self)
253        self.__zoomLayout.addWidget(self.__zoomWidget)
254        self.layout().insertLayout(
255            self.layout().count() - 1,
256            self.__zoomLayout)
257        self.__zoomWidget.setMinimum(self.ZoomMin)
258        self.__zoomWidget.setMaximum(self.ZoomMax)
259        self.__zoomWidget.valueChanged.connect(self.__doZoom)
260        self.__currentZoom = 0
261
262        self.__fileManagerWidget = None
263        self.__chartWidget = None
264
265        self.__unknownPorts = []
266        self.__lastPort = None
267        self.__lastDeviceType = None
268
269        if HAS_QTSERIALPORT:
270            self.__interface = MicroPythonCommandsInterface(self)
271        else:
272            self.__interface = None
273        self.__device = None
274        self.__connected = False
275        self.__setConnected(False)
276
277        if not HAS_QTSERIALPORT:
278            self.replEdit.setHtml(self.tr(
279                "<h3>The QtSerialPort package is not available.<br/>"
280                "MicroPython support is deactivated.</h3>"))
281            self.setEnabled(False)
282            return
283
284        self.__vt100Re = re.compile(
285            r'(?P<count>\d*)(?P<color>(?:;?\d*)*)(?P<action>[ABCDKm])')
286
287        self.__populateDeviceTypeComboBox()
288
289        self.replEdit.installEventFilter(self)
290        # Hack to intercept middle button paste
291        self.__origReplEditMouseReleaseEvent = self.replEdit.mouseReleaseEvent
292        self.replEdit.mouseReleaseEvent = self.__replEditMouseReleaseEvent
293
294        self.replEdit.customContextMenuRequested.connect(
295            self.__showContextMenu)
296        self.__ui.preferencesChanged.connect(self.__handlePreferencesChanged)
297        self.__ui.preferencesChanged.connect(
298            self.__interface.handlePreferencesChanged)
299
300        self.__handlePreferencesChanged()
301
302        charFormat = self.replEdit.currentCharFormat()
303        self.DefaultForeground = charFormat.foreground()
304        self.DefaultBackground = charFormat.background()
305
306    def __populateDeviceTypeComboBox(self):
307        """
308        Private method to populate the device type selector.
309        """
310        currentDevice = self.deviceTypeComboBox.currentText()
311
312        self.deviceTypeComboBox.clear()
313        self.deviceInfoLabel.clear()
314
315        self.deviceTypeComboBox.addItem("", "")
316        devices, unknownDevices, unknownPorts = (
317            MicroPythonDevices.getFoundDevices()
318        )
319        if devices:
320            supportedMessage = self.tr(
321                "%n supported device(s) detected.", "", len(devices))
322
323            for index, (boardType, boardName, description, portName,
324                        vid, pid) in enumerate(sorted(devices), 1):
325                self.deviceTypeComboBox.addItem(
326                    self.tr("{0} - {1} ({2})",
327                            "board name, description, port name")
328                    .format(boardName, description, portName)
329                )
330                self.deviceTypeComboBox.setItemData(
331                    index, boardType, self.DeviceTypeRole)
332                self.deviceTypeComboBox.setItemData(
333                    index, boardName, self.DeviceBoardRole)
334                self.deviceTypeComboBox.setItemData(
335                    index, portName, self.DevicePortRole)
336                self.deviceTypeComboBox.setItemData(
337                    index, vid, self.DeviceVidRole)
338                self.deviceTypeComboBox.setItemData(
339                    index, pid, self.DevicePidRole)
340
341        else:
342            supportedMessage = self.tr("No supported devices detected.")
343
344        self.__unknownPorts = unknownPorts
345        if self.__unknownPorts:
346            unknownMessage = self.tr(
347                "\n%n unknown device(s) for manual selection.", "",
348                len(self.__unknownPorts))
349            if self.deviceTypeComboBox.count():
350                self.deviceTypeComboBox.insertSeparator(
351                    self.deviceTypeComboBox.count())
352            self.deviceTypeComboBox.addItem(self.tr("Manual Selection"))
353            self.deviceTypeComboBox.setItemData(
354                self.deviceTypeComboBox.count() - 1,
355                self.ManualMarker, self.DeviceTypeRole)
356        else:
357            unknownMessage = ""
358
359        self.deviceInfoLabel.setText(supportedMessage + unknownMessage)
360
361        index = self.deviceTypeComboBox.findText(currentDevice,
362                                                 Qt.MatchFlag.MatchExactly)
363        if index == -1:
364            # entry is no longer present
365            index = 0
366            if self.__connected:
367                # we are still connected, so disconnect
368                self.on_connectButton_clicked()
369
370        self.on_deviceTypeComboBox_activated(index)
371        self.deviceTypeComboBox.setCurrentIndex(index)
372
373        if unknownDevices:
374            ignoredUnknown = {
375                tuple(d)
376                for d in Preferences.getMicroPython("IgnoredUnknownDevices")
377            }
378            uf2Devices = {(*x[2], x[1])
379                          for x in UF2FlashDialog.getFoundDevices()}
380            newUnknownDevices = (
381                set(unknownDevices) - ignoredUnknown - uf2Devices
382            )
383            if newUnknownDevices:
384                button = E5MessageBox.information(
385                    self,
386                    self.tr("Unknown MicroPython Device"),
387                    self.tr(
388                        '<p>Detected these unknown serial devices</p>'
389                        '<ul>'
390                        '<li>{0}</li>'
391                        '</ul>'
392                        '<p>Please report them together with the board name'
393                        ' and a short description to <a href="mailto:{1}">'
394                        ' the eric bug reporting address</a> if it is a'
395                        ' MicroPython board.</p>'
396                    ).format("</li><li>".join([
397                        self.tr("{0} (0x{1:04x}/0x{2:04x})",
398                                "description, VId, PId").format(
399                            desc, vid, pid)
400                        for vid, pid, desc in newUnknownDevices]),
401                        BugAddress),
402                    E5MessageBox.StandardButtons(
403                        E5MessageBox.Ignore |
404                        E5MessageBox.Ok
405                    )
406                )
407                if button == E5MessageBox.Ignore:
408                    ignoredUnknown = list(ignoredUnknown | newUnknownDevices)
409                    Preferences.setMicroPython("IgnoredUnknownDevices",
410                                               ignoredUnknown)
411                else:
412                    yes = E5MessageBox.yesNo(
413                        self,
414                        self.tr("Unknown MicroPython Device"),
415                        self.tr("""Would you like to add them to the list of"""
416                                """ manually configured devices?"""),
417                        yesDefault=True)
418                    if yes:
419                        self.__addUnknownDevices(list(newUnknownDevices))
420
421    def __handlePreferencesChanged(self):
422        """
423        Private slot to handle a change in preferences.
424        """
425        self.__colorScheme = Preferences.getMicroPython("ColorScheme")
426
427        self.__font = Preferences.getEditorOtherFonts("MonospacedFont")
428        self.replEdit.setFontFamily(self.__font.family())
429        self.replEdit.setFontPointSize(self.__font.pointSize())
430
431        if Preferences.getMicroPython("ReplLineWrap"):
432            self.replEdit.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth)
433        else:
434            self.replEdit.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap)
435
436        if self.__chartWidget is not None:
437            self.__chartWidget.preferencesChanged()
438
439    def commandsInterface(self):
440        """
441        Public method to get a reference to the commands interface object.
442
443        @return reference to the commands interface object
444        @rtype MicroPythonCommandsInterface
445        """
446        return self.__interface
447
448    def isMicrobit(self):
449        """
450        Public method to check, if the connected/selected device is a
451        BBC micro:bit or Calliope mini.
452
453        @return flag indicating a micro:bit device
454        rtype bool
455        """
456        if self.__device and (
457            "micro:bit" in self.__device.deviceName() or
458            "Calliope" in self.__device.deviceName()
459        ):
460            return True
461
462        return False
463
464    @pyqtSlot(int)
465    def on_deviceTypeComboBox_activated(self, index):
466        """
467        Private slot handling the selection of a device type.
468
469        @param index index of the selected device
470        @type int
471        """
472        deviceType = self.deviceTypeComboBox.itemData(
473            index, self.DeviceTypeRole)
474        if deviceType == self.ManualMarker:
475            self.connectButton.setEnabled(bool(self.__unknownPorts))
476        else:
477            self.deviceIconLabel.setPixmap(MicroPythonDevices.getDeviceIcon(
478                deviceType, False))
479
480            vid = self.deviceTypeComboBox.itemData(
481                index, self.DeviceVidRole)
482            pid = self.deviceTypeComboBox.itemData(
483                index, self.DevicePidRole)
484
485            self.__device = MicroPythonDevices.getDevice(deviceType, self,
486                                                         vid, pid)
487            self.__device.setButtons()
488
489            self.connectButton.setEnabled(bool(deviceType))
490
491    @pyqtSlot()
492    def on_checkButton_clicked(self):
493        """
494        Private slot to check for connected devices.
495        """
496        self.__populateDeviceTypeComboBox()
497
498    def setActionButtons(self, **kwargs):
499        """
500        Public method to set the enabled state of the various action buttons.
501
502        @keyparam kwargs keyword arguments containg the enabled states (keys
503            are 'run', 'repl', 'files', 'chart', 'open', 'save'
504        @type dict
505        """
506        if "open" in kwargs:
507            self.openButton.setEnabled(kwargs["open"])
508        if "save" in kwargs:
509            self.saveButton.setEnabled(kwargs["save"])
510        if "run" in kwargs:
511            self.runButton.setEnabled(kwargs["run"])
512        if "repl" in kwargs:
513            self.replButton.setEnabled(kwargs["repl"])
514        if "files" in kwargs:
515            self.filesButton.setEnabled(kwargs["files"])
516        if "chart" in kwargs:
517            self.chartButton.setEnabled(kwargs["chart"] and HAS_QTCHART)
518
519    @pyqtSlot(QPoint)
520    def __showContextMenu(self, pos):
521        """
522        Private slot to show the REPL context menu.
523
524        @param pos position to show the menu at
525        @type QPoint
526        """
527        if Globals.isMacPlatform():
528            copyKeys = QKeySequence(Qt.Modifier.CTRL + Qt.Key.Key_C)
529            pasteKeys = QKeySequence(Qt.Modifier.CTRL + Qt.Key.Key_V)
530        else:
531            copyKeys = QKeySequence(
532                Qt.Modifier.CTRL + Qt.Modifier.SHIFT + Qt.Key.Key_C)
533            pasteKeys = QKeySequence(
534                Qt.Modifier.CTRL + Qt.Modifier.SHIFT + Qt.Key.Key_V)
535        menu = QMenu(self)
536        menu.addAction(self.tr("Clear"), self.__clear)
537        menu.addSeparator()
538        menu.addAction(self.tr("Copy"), self.replEdit.copy, copyKeys)
539        menu.addAction(self.tr("Paste"), self.__paste, pasteKeys)
540        menu.addSeparator()
541        menu.exec(self.replEdit.mapToGlobal(pos))
542
543    def __setConnected(self, connected):
544        """
545        Private method to set the connection status LED.
546
547        @param connected connection state
548        @type bool
549        """
550        self.__connected = connected
551
552        self.deviceConnectedLed.setOn(connected)
553        if self.__fileManagerWidget:
554            self.__fileManagerWidget.deviceConnectedLed.setOn(connected)
555
556        self.deviceTypeComboBox.setEnabled(not connected)
557
558        if connected:
559            self.connectButton.setIcon(
560                UI.PixmapCache.getIcon("linkDisconnect"))
561            self.connectButton.setToolTip(self.tr(
562                "Press to disconnect the current device"))
563        else:
564            self.connectButton.setIcon(
565                UI.PixmapCache.getIcon("linkConnect"))
566            self.connectButton.setToolTip(self.tr(
567                "Press to connect the selected device"))
568
569    def isConnected(self):
570        """
571        Public method to get the connection state.
572
573        @return connection state
574        @rtype bool
575        """
576        return self.__connected
577
578    def __showNoDeviceMessage(self):
579        """
580        Private method to show a message dialog indicating a missing device.
581        """
582        E5MessageBox.critical(
583            self,
584            self.tr("No device attached"),
585            self.tr("""Please ensure the device is plugged into your"""
586                    """ computer and selected.\n\nIt must have a version"""
587                    """ of MicroPython (or CircuitPython) flashed onto"""
588                    """ it before anything will work.\n\nFinally press"""
589                    """ the device's reset button and wait a few seconds"""
590                    """ before trying again."""))
591
592    @pyqtSlot(bool)
593    def on_replButton_clicked(self, checked):
594        """
595        Private slot to connect to enable or disable the REPL widget.
596
597        If the selected device is not connected yet, this will be done now.
598
599        @param checked state of the button
600        @type bool
601        """
602        if not self.__device:
603            self.__showNoDeviceMessage()
604            return
605
606        if checked:
607            ok, reason = self.__device.canStartRepl()
608            if not ok:
609                E5MessageBox.warning(
610                    self,
611                    self.tr("Start REPL"),
612                    self.tr("""<p>The REPL cannot be started.</p><p>Reason:"""
613                            """ {0}</p>""").format(reason))
614                return
615
616            self.replEdit.clear()
617            self.__interface.dataReceived.connect(self.__processData)
618
619            if not self.__interface.isConnected():
620                self.__connectToDevice()
621                if self.__device.forceInterrupt():
622                    # send a Ctrl-B (exit raw mode)
623                    self.__interface.write(b'\x02')
624                    # send Ctrl-C (keyboard interrupt)
625                    self.__interface.write(b'\x03')
626
627            self.__device.setRepl(True)
628            self.replEdit.setFocus(Qt.FocusReason.OtherFocusReason)
629        else:
630            self.__interface.dataReceived.disconnect(self.__processData)
631            if (not self.chartButton.isChecked() and
632                    not self.filesButton.isChecked()):
633                self.__disconnectFromDevice()
634            self.__device.setRepl(False)
635        self.replButton.setChecked(checked)
636
637    @pyqtSlot()
638    def on_connectButton_clicked(self):
639        """
640        Private slot to connect to the selected device or disconnect from the
641        currently connected device.
642        """
643        if self.__connected:
644            with E5OverrideCursor():
645                self.__disconnectFromDevice()
646
647            if self.replButton.isChecked():
648                self.on_replButton_clicked(False)
649            if self.filesButton.isChecked():
650                self.on_filesButton_clicked(False)
651            if self.chartButton.isChecked():
652                self.on_chartButton_clicked(False)
653        else:
654            with E5OverrideCursor():
655                self.__connectToDevice()
656
657    @pyqtSlot()
658    def __clear(self):
659        """
660        Private slot to clear the REPL pane.
661        """
662        self.replEdit.clear()
663        self.__interface.isConnected() and self.__interface.write(b"\r")
664
665    @pyqtSlot()
666    def __paste(self, mode=QClipboard.Mode.Clipboard):
667        """
668        Private slot to perform a paste operation.
669
670        @param mode paste mode (defaults to QClipboard.Mode.Clipboard)
671        @type QClipboard.Mode (optional)
672        """
673        # add support for paste by mouse middle button
674        clipboard = QApplication.clipboard()
675        if clipboard:
676            pasteText = clipboard.text(mode=mode)
677            if pasteText:
678                pasteText = pasteText.replace('\n\r', '\r')
679                pasteText = pasteText.replace('\n', '\r')
680                self.__interface.isConnected() and self.__interface.write(
681                    pasteText.encode("utf-8"))
682
683    def eventFilter(self, obj, evt):
684        """
685        Public method to process events for the REPL pane.
686
687        @param obj reference to the object the event was meant for
688        @type QObject
689        @param evt reference to the event object
690        @type QEvent
691        @return flag to indicate that the event was handled
692        @rtype bool
693        """
694        if obj is self.replEdit and evt.type() == QEvent.Type.KeyPress:
695            # handle the key press event on behalve of the REPL pane
696            key = evt.key()
697            msg = bytes(evt.text(), 'utf8')
698            if key == Qt.Key.Key_Backspace:
699                msg = b'\b'
700            elif key == Qt.Key.Key_Delete:
701                msg = b'\x1B[\x33\x7E'
702            elif key == Qt.Key.Key_Up:
703                msg = b'\x1B[A'
704            elif key == Qt.Key.Key_Down:
705                msg = b'\x1B[B'
706            elif key == Qt.Key.Key_Right:
707                msg = b'\x1B[C'
708            elif key == Qt.Key.Key_Left:
709                msg = b'\x1B[D'
710            elif key == Qt.Key.Key_Home:
711                msg = b'\x1B[H'
712            elif key == Qt.Key.Key_End:
713                msg = b'\x1B[F'
714            elif ((Globals.isMacPlatform() and
715                   evt.modifiers() == Qt.KeyboardModifier.MetaModifier) or
716                  (not Globals.isMacPlatform() and
717                   evt.modifiers() == Qt.KeyboardModifier.ControlModifier)):
718                if Qt.Key.Key_A <= key <= Qt.Key.Key_Z:
719                    # devices treat an input of \x01 as Ctrl+A, etc.
720                    msg = bytes([1 + key - Qt.Key.Key_A])
721            elif (
722                evt.modifiers() == (
723                    Qt.KeyboardModifier.ControlModifier |
724                    Qt.KeyboardModifier.ShiftModifier) or
725                (Globals.isMacPlatform() and
726                 evt.modifiers() == Qt.KeyboardModifier.ControlModifier)
727            ):
728                if key == Qt.Key.Key_C:
729                    self.replEdit.copy()
730                    msg = b''
731                elif key == Qt.Key.Key_V:
732                    self.__paste()
733                    msg = b''
734            elif key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
735                tc = self.replEdit.textCursor()
736                tc.movePosition(QTextCursor.MoveOperation.EndOfLine)
737                self.replEdit.setTextCursor(tc)
738            self.__interface.isConnected() and self.__interface.write(msg)
739            return True
740        else:
741            # standard event processing
742            return super().eventFilter(obj, evt)
743
744    def __replEditMouseReleaseEvent(self, evt):
745        """
746        Private method handling mouse release events for the replEdit widget.
747
748        Note: this is a hack because QTextEdit does not allow filtering of
749        QEvent.Type.MouseButtonRelease. To make middle button paste work, we
750        had to intercept the protected event method (some kind of
751        reimplementing it).
752
753        @param evt reference to the event object
754        @type QMouseEvent
755        """
756        if evt.button() == Qt.MouseButton.MiddleButton:
757            self.__paste(mode=QClipboard.Mode.Selection)
758            msg = b''
759            if self.__interface.isConnected():
760                self.__interface.write(msg)
761            evt.accept()
762        else:
763            self.__origReplEditMouseReleaseEvent(evt)
764
765    def __processData(self, data):
766        """
767        Private slot to process bytes received from the device.
768
769        @param data bytes received from the device
770        @type bytes
771        """
772        tc = self.replEdit.textCursor()
773        # the text cursor must be on the last line
774        while tc.movePosition(QTextCursor.MoveOperation.Down):
775            pass
776
777        # set the font
778        charFormat = tc.charFormat()
779        charFormat.setFontFamily(self.__font.family())
780        charFormat.setFontPointSize(self.__font.pointSize())
781        tc.setCharFormat(charFormat)
782
783        index = 0
784        while index < len(data):
785            if data[index] == 8:        # \b
786                tc.movePosition(QTextCursor.MoveOperation.Left)
787                self.replEdit.setTextCursor(tc)
788            elif data[index] in (4, 13):     # EOT, \r
789                pass
790            elif (len(data) > index + 1 and
791                  data[index] == 27 and
792                  data[index + 1] == 91):
793                # VT100 cursor command detected: <Esc>[
794                index += 2      # move index to after the [
795                match = self.__vt100Re.search(data[index:].decode("utf-8"))
796                if match:
797                    # move to last position in control sequence
798                    # ++ will be done at end of loop
799                    index += match.end() - 1
800
801                    action = match.group("action")
802                    if action in "ABCD":
803                        if match.group("count") == "":
804                            count = 1
805                        else:
806                            count = int(match.group("count"))
807
808                        if action == "A":       # up
809                            tc.movePosition(QTextCursor.MoveOperation.Up,
810                                            n=count)
811                            self.replEdit.setTextCursor(tc)
812                        elif action == "B":     # down
813                            tc.movePosition(QTextCursor.MoveOperation.Down,
814                                            n=count)
815                            self.replEdit.setTextCursor(tc)
816                        elif action == "C":     # right
817                            tc.movePosition(QTextCursor.MoveOperation.Right,
818                                            n=count)
819                            self.replEdit.setTextCursor(tc)
820                        elif action == "D":     # left
821                            tc.movePosition(QTextCursor.MoveOperation.Left,
822                                            n=count)
823                            self.replEdit.setTextCursor(tc)
824                    elif action == "K":     # delete things
825                        if match.group("count") in ("", "0"):
826                            # delete to end of line
827                            tc.movePosition(
828                                QTextCursor.MoveOperation.EndOfLine,
829                                mode=QTextCursor.MoveMode.KeepAnchor)
830                            tc.removeSelectedText()
831                            self.replEdit.setTextCursor(tc)
832                        elif match.group("count") == "1":
833                            # delete to beinning of line
834                            tc.movePosition(
835                                QTextCursor.MoveOperation.StartOfLine,
836                                mode=QTextCursor.MoveMode.KeepAnchor)
837                            tc.removeSelectedText()
838                            self.replEdit.setTextCursor(tc)
839                        elif match.group("count") == "2":
840                            # delete whole line
841                            tc.movePosition(
842                                QTextCursor.MoveOperation.EndOfLine)
843                            tc.movePosition(
844                                QTextCursor.MoveOperation.StartOfLine,
845                                mode=QTextCursor.MoveMode.KeepAnchor)
846                            tc.removeSelectedText()
847                            self.replEdit.setTextCursor(tc)
848                    elif action == "m":
849                        self.__setCharFormat(match.group(0)[:-1].split(";"),
850                                             tc)
851            else:
852                tc.deleteChar()
853                self.replEdit.setTextCursor(tc)
854                self.replEdit.insertPlainText(chr(data[index]))
855
856            index += 1
857
858        self.replEdit.ensureCursorVisible()
859
860    def __setCharFormat(self, formatCodes, textCursor):
861        """
862        Private method setting the current text format of the REPL pane based
863        on the passed ANSI codes.
864
865        Following codes are used:
866        <ul>
867        <li>0: Reset</li>
868        <li>1: Bold font (weight 75)</li>
869        <li>2: Light font (weight 25)</li>
870        <li>3: Italic font</li>
871        <li>4: Underlined font</li>
872        <li>9: Strikeout font</li>
873        <li>21: Bold off (weight 50)</li>
874        <li>22: Light off (weight 50)</li>
875        <li>23: Italic off</li>
876        <li>24: Underline off</li>
877        <li>29: Strikeout off</li>
878        <li>30: foreground Black</li>
879        <li>31: foreground Dark Red</li>
880        <li>32: foreground Dark Green</li>
881        <li>33: foreground Dark Yellow</li>
882        <li>34: foreground Dark Blue</li>
883        <li>35: foreground Dark Magenta</li>
884        <li>36: foreground Dark Cyan</li>
885        <li>37: foreground Light Gray</li>
886        <li>39: reset foreground to default</li>
887        <li>40: background Black</li>
888        <li>41: background Dark Red</li>
889        <li>42: background Dark Green</li>
890        <li>43: background Dark Yellow</li>
891        <li>44: background Dark Blue</li>
892        <li>45: background Dark Magenta</li>
893        <li>46: background Dark Cyan</li>
894        <li>47: background Light Gray</li>
895        <li>49: reset background to default</li>
896        <li>53: Overlined font</li>
897        <li>55: Overline off</li>
898        <li>90: bright foreground Dark Gray</li>
899        <li>91: bright foreground Red</li>
900        <li>92: bright foreground Green</li>
901        <li>93: bright foreground Yellow</li>
902        <li>94: bright foreground Blue</li>
903        <li>95: bright foreground Magenta</li>
904        <li>96: bright foreground Cyan</li>
905        <li>97: bright foreground White</li>
906        <li>100: bright background Dark Gray</li>
907        <li>101: bright background Red</li>
908        <li>102: bright background Green</li>
909        <li>103: bright background Yellow</li>
910        <li>104: bright background Blue</li>
911        <li>105: bright background Magenta</li>
912        <li>106: bright background Cyan</li>
913        <li>107: bright background White</li>
914        </ul>
915
916        @param formatCodes list of format codes
917        @type list of str
918        @param textCursor reference to the text cursor
919        @type QTextCursor
920        """
921        if not formatCodes:
922            # empty format codes list is treated as a reset
923            formatCodes = ["0"]
924
925        charFormat = textCursor.charFormat()
926        for formatCode in formatCodes:
927            try:
928                formatCode = int(formatCode)
929            except ValueError:
930                # ignore non digit values
931                continue
932
933            if formatCode == 0:
934                charFormat.setFontWeight(50)
935                charFormat.setFontItalic(False)
936                charFormat.setFontUnderline(False)
937                charFormat.setFontStrikeOut(False)
938                charFormat.setFontOverline(False)
939                charFormat.setForeground(self.DefaultForeground)
940                charFormat.setBackground(self.DefaultBackground)
941            elif formatCode == 1:
942                charFormat.setFontWeight(75)
943            elif formatCode == 2:
944                charFormat.setFontWeight(25)
945            elif formatCode == 3:
946                charFormat.setFontItalic(True)
947            elif formatCode == 4:
948                charFormat.setFontUnderline(True)
949            elif formatCode == 9:
950                charFormat.setFontStrikeOut(True)
951            elif formatCode in (21, 22):
952                charFormat.setFontWeight(50)
953            elif formatCode == 23:
954                charFormat.setFontItalic(False)
955            elif formatCode == 24:
956                charFormat.setFontUnderline(False)
957            elif formatCode == 29:
958                charFormat.setFontStrikeOut(False)
959            elif formatCode == 53:
960                charFormat.setFontOverline(True)
961            elif formatCode == 55:
962                charFormat.setFontOverline(False)
963            elif formatCode in (30, 31, 32, 33, 34, 35, 36, 37):
964                charFormat.setForeground(
965                    AnsiColorSchemes[self.__colorScheme][formatCode - 30])
966            elif formatCode in (40, 41, 42, 43, 44, 45, 46, 47):
967                charFormat.setBackground(
968                    AnsiColorSchemes[self.__colorScheme][formatCode - 40])
969            elif formatCode in (90, 91, 92, 93, 94, 95, 96, 97):
970                charFormat.setForeground(
971                    AnsiColorSchemes[self.__colorScheme][formatCode - 80])
972            elif formatCode in (100, 101, 102, 103, 104, 105, 106, 107):
973                charFormat.setBackground(
974                    AnsiColorSchemes[self.__colorScheme][formatCode - 90])
975            elif formatCode == 39:
976                charFormat.setForeground(self.DefaultForeground)
977            elif formatCode == 49:
978                charFormat.setBackground(self.DefaultBackground)
979
980        textCursor.setCharFormat(charFormat)
981
982    def __doZoom(self, value):
983        """
984        Private slot to zoom the REPL pane.
985
986        @param value zoom value
987        @type int
988        """
989        if value < self.__currentZoom:
990            self.replEdit.zoomOut(self.__currentZoom - value)
991        elif value > self.__currentZoom:
992            self.replEdit.zoomIn(value - self.__currentZoom)
993        self.__currentZoom = value
994
995    def getCurrentPort(self):
996        """
997        Public method to determine the port path of the selected device.
998
999        @return path of the port of the selected device
1000        @rtype str
1001        """
1002        portName = self.deviceTypeComboBox.currentData(self.DevicePortRole)
1003        if portName:
1004            if Globals.isWindowsPlatform():
1005                # return it unchanged
1006                return portName
1007            else:
1008                # return with device path prepended
1009                return "/dev/{0}".format(portName)
1010        else:
1011            return ""
1012
1013    def getCurrentBoard(self):
1014        """
1015        Public method to get the board name of the selected device.
1016
1017        @return board name of the selected device
1018        @rtype str
1019        """
1020        boardName = self.deviceTypeComboBox.currentData(self.DeviceBoardRole)
1021        return boardName
1022
1023    def getDeviceWorkspace(self):
1024        """
1025        Public method to get the workspace directory of the device.
1026
1027        @return workspace directory of the device
1028        @rtype str
1029        """
1030        if self.__device:
1031            return self.__device.getWorkspace()
1032        else:
1033            return ""
1034
1035    def __connectToDevice(self):
1036        """
1037        Private method to connect to the selected device.
1038        """
1039        port = self.getCurrentPort()
1040        if not port:
1041            from .ConnectionSelectionDialog import ConnectionSelectionDialog
1042            with E5OverridenCursor():
1043                dlg = ConnectionSelectionDialog(
1044                    self.__unknownPorts, self.__lastPort, self.__lastDeviceType
1045                )
1046                if dlg.exec() == QDialog.DialogCode.Accepted:
1047                    vid, pid, port, deviceType = dlg.getData()
1048
1049                    self.deviceIconLabel.setPixmap(
1050                        MicroPythonDevices.getDeviceIcon(deviceType, False))
1051                    self.__device = MicroPythonDevices.getDevice(
1052                        deviceType, self, vid, pid)
1053                    self.__device.setButtons()
1054
1055                    self.__lastPort = port
1056                    self.__lastDeviceType = deviceType
1057                else:
1058                    return
1059
1060        if self.__interface.connectToDevice(port):
1061            self.__setConnected(True)
1062
1063            if (Preferences.getMicroPython("SyncTimeAfterConnect") and
1064                    self.__device.hasTimeCommands()):
1065                self.__synchronizeTime(quiet=True)
1066        else:
1067            with E5OverridenCursor():
1068                E5MessageBox.warning(
1069                    self,
1070                    self.tr("Serial Device Connect"),
1071                    self.tr("""<p>Cannot connect to device at serial"""
1072                            """ port <b>{0}</b>.</p>""").format(port))
1073
1074    def __disconnectFromDevice(self):
1075        """
1076        Private method to disconnect from the device.
1077        """
1078        self.__interface.disconnectFromDevice()
1079        self.__setConnected(False)
1080
1081    @pyqtSlot()
1082    def on_runButton_clicked(self):
1083        """
1084        Private slot to execute the script of the active editor on the
1085        selected device.
1086
1087        If the REPL is not active yet, it will be activated, which might cause
1088        an unconnected device to be connected.
1089        """
1090        if not self.__device:
1091            self.__showNoDeviceMessage()
1092            return
1093
1094        aw = e5App().getObject("ViewManager").activeWindow()
1095        if aw is None:
1096            E5MessageBox.critical(
1097                self,
1098                self.tr("Run Script"),
1099                self.tr("""There is no editor open. Abort..."""))
1100            return
1101
1102        script = aw.text()
1103        if not script:
1104            E5MessageBox.critical(
1105                self,
1106                self.tr("Run Script"),
1107                self.tr("""The current editor does not contain a script."""
1108                        """ Abort..."""))
1109            return
1110
1111        ok, reason = self.__device.canRunScript()
1112        if not ok:
1113            E5MessageBox.warning(
1114                self,
1115                self.tr("Run Script"),
1116                self.tr("""<p>Cannot run script.</p><p>Reason:"""
1117                        """ {0}</p>""").format(reason))
1118            return
1119
1120        if not self.replButton.isChecked():
1121            # activate on the REPL
1122            self.on_replButton_clicked(True)
1123        if self.replButton.isChecked():
1124            self.__device.runScript(script)
1125
1126    @pyqtSlot()
1127    def on_openButton_clicked(self):
1128        """
1129        Private slot to open a file of the connected device.
1130        """
1131        if not self.__device:
1132            self.__showNoDeviceMessage()
1133            return
1134
1135        workspace = self.getDeviceWorkspace()
1136        if workspace:
1137            fileName = E5FileDialog.getOpenFileName(
1138                self,
1139                self.tr("Open Python File"),
1140                workspace,
1141                self.tr("Python3 Files (*.py);;All Files (*)"))
1142            if fileName:
1143                e5App().getObject("ViewManager").openSourceFile(fileName)
1144
1145    @pyqtSlot()
1146    def on_saveButton_clicked(self):
1147        """
1148        Private slot to save the current editor to the connected device.
1149        """
1150        if not self.__device:
1151            self.__showNoDeviceMessage()
1152            return
1153
1154        aw = e5App().getObject("ViewManager").activeWindow()
1155        if aw:
1156            workspace = self.getDeviceWorkspace()
1157            if workspace:
1158                aw.saveFileAs(workspace)
1159
1160    @pyqtSlot(bool)
1161    def on_chartButton_clicked(self, checked):
1162        """
1163        Private slot to open a chart view to plot data received from the
1164        connected device.
1165
1166        If the selected device is not connected yet, this will be done now.
1167
1168        @param checked state of the button
1169        @type bool
1170        """
1171        if not HAS_QTCHART:
1172            # QtChart not available => fail silently
1173            return
1174
1175        if not self.__device:
1176            self.__showNoDeviceMessage()
1177            return
1178
1179        if checked:
1180            ok, reason = self.__device.canStartPlotter()
1181            if not ok:
1182                E5MessageBox.warning(
1183                    self,
1184                    self.tr("Start Chart"),
1185                    self.tr("""<p>The Chart cannot be started.</p><p>Reason:"""
1186                            """ {0}</p>""").format(reason))
1187                return
1188
1189            self.__chartWidget = MicroPythonGraphWidget(self)
1190            self.__interface.dataReceived.connect(
1191                self.__chartWidget.processData)
1192            self.__chartWidget.dataFlood.connect(
1193                self.handleDataFlood)
1194
1195            self.__ui.addSideWidget(self.__ui.BottomSide, self.__chartWidget,
1196                                    UI.PixmapCache.getIcon("chart"),
1197                                    self.tr("µPy Chart"))
1198            self.__ui.showSideWidget(self.__chartWidget)
1199
1200            if not self.__interface.isConnected():
1201                self.__connectToDevice()
1202                if self.__device.forceInterrupt():
1203                    # send a Ctrl-B (exit raw mode)
1204                    self.__interface.write(b'\x02')
1205                    # send Ctrl-C (keyboard interrupt)
1206                    self.__interface.write(b'\x03')
1207
1208            self.__device.setPlotter(True)
1209        else:
1210            if self.__chartWidget.isDirty():
1211                res = E5MessageBox.okToClearData(
1212                    self,
1213                    self.tr("Unsaved Chart Data"),
1214                    self.tr("""The chart contains unsaved data."""),
1215                    self.__chartWidget.saveData)
1216                if not res:
1217                    # abort
1218                    return
1219
1220            self.__interface.dataReceived.disconnect(
1221                self.__chartWidget.processData)
1222            self.__chartWidget.dataFlood.disconnect(
1223                self.handleDataFlood)
1224
1225            if (not self.replButton.isChecked() and
1226                    not self.filesButton.isChecked()):
1227                self.__disconnectFromDevice()
1228
1229            self.__device.setPlotter(False)
1230            self.__ui.removeSideWidget(self.__chartWidget)
1231
1232            self.__chartWidget.deleteLater()
1233            self.__chartWidget = None
1234
1235        self.chartButton.setChecked(checked)
1236
1237    @pyqtSlot()
1238    def handleDataFlood(self):
1239        """
1240        Public slot handling a data flood from the device.
1241        """
1242        self.on_connectButton_clicked()
1243        self.__device.handleDataFlood()
1244
1245    @pyqtSlot(bool)
1246    def on_filesButton_clicked(self, checked):
1247        """
1248        Private slot to open a file manager window to the connected device.
1249
1250        If the selected device is not connected yet, this will be done now.
1251
1252        @param checked state of the button
1253        @type bool
1254        """
1255        if not self.__device:
1256            self.__showNoDeviceMessage()
1257            return
1258
1259        if checked:
1260            ok, reason = self.__device.canStartFileManager()
1261            if not ok:
1262                E5MessageBox.warning(
1263                    self,
1264                    self.tr("Start File Manager"),
1265                    self.tr("""<p>The File Manager cannot be started.</p>"""
1266                            """<p>Reason: {0}</p>""").format(reason))
1267                return
1268
1269            with E5OverrideCursor():
1270                if not self.__interface.isConnected():
1271                    self.__connectToDevice()
1272                if self.__connected:
1273                    self.__fileManagerWidget = MicroPythonFileManagerWidget(
1274                        self.__interface,
1275                        self.__device.supportsLocalFileAccess(),
1276                        self)
1277
1278                    self.__ui.addSideWidget(
1279                        self.__ui.BottomSide,
1280                        self.__fileManagerWidget,
1281                        UI.PixmapCache.getIcon("filemanager"),
1282                        self.tr("µPy Files")
1283                    )
1284                    self.__ui.showSideWidget(self.__fileManagerWidget)
1285
1286                    self.__device.setFileManager(True)
1287
1288                    self.__fileManagerWidget.start()
1289        else:
1290            self.__fileManagerWidget.stop()
1291
1292            if (not self.replButton.isChecked() and
1293                    not self.chartButton.isChecked()):
1294                self.__disconnectFromDevice()
1295
1296            self.__device.setFileManager(False)
1297            self.__ui.removeSideWidget(self.__fileManagerWidget)
1298
1299            self.__fileManagerWidget.deleteLater()
1300            self.__fileManagerWidget = None
1301
1302        self.filesButton.setChecked(checked)
1303
1304    ##################################################################
1305    ## Super Menu related methods below
1306    ##################################################################
1307
1308    def __aboutToShowSuperMenu(self):
1309        """
1310        Private slot to populate the Super Menu before showing it.
1311        """
1312        self.__superMenu.clear()
1313
1314        # prepare the download menu
1315        if self.__device:
1316            menuEntries = self.__device.getDownloadMenuEntries()
1317            if menuEntries:
1318                downloadMenu = QMenu(self.tr("Downloads"), self.__superMenu)
1319                for text, url in menuEntries:
1320                    if text == "<separator>":
1321                        downloadMenu.addSeparator()
1322                    else:
1323                        downloadMenu.addAction(
1324                            text,
1325                            functools.partial(self.__downloadFromUrl, url)
1326                        )
1327            else:
1328                downloadMenu = None
1329
1330        # populate the super menu
1331        hasTime = self.__device.hasTimeCommands() if self.__device else False
1332
1333        act = self.__superMenu.addAction(
1334            self.tr("Show Version"), self.__showDeviceVersion)
1335        act.setEnabled(self.__connected)
1336        act = self.__superMenu.addAction(
1337            self.tr("Show Implementation"), self.__showImplementation)
1338        act.setEnabled(self.__connected)
1339        self.__superMenu.addSeparator()
1340        if hasTime:
1341            act = self.__superMenu.addAction(
1342                self.tr("Synchronize Time"), self.__synchronizeTime)
1343            act.setEnabled(self.__connected)
1344            act = self.__superMenu.addAction(
1345                self.tr("Show Device Time"), self.__showDeviceTime)
1346            act.setEnabled(self.__connected)
1347        self.__superMenu.addAction(
1348            self.tr("Show Local Time"), self.__showLocalTime)
1349        if hasTime:
1350            act = self.__superMenu.addAction(
1351                self.tr("Show Time"), self.__showLocalAndDeviceTime)
1352            act.setEnabled(self.__connected)
1353        self.__superMenu.addSeparator()
1354        if not Globals.isWindowsPlatform():
1355            available = self.__mpyCrossAvailable()
1356            act = self.__superMenu.addAction(
1357                self.tr("Compile Python File"), self.__compileFile2Mpy)
1358            act.setEnabled(available)
1359            act = self.__superMenu.addAction(
1360                self.tr("Compile Current Editor"), self.__compileEditor2Mpy)
1361            aw = e5App().getObject("ViewManager").activeWindow()
1362            act.setEnabled(available and bool(aw))
1363            self.__superMenu.addSeparator()
1364        if self.__device:
1365            self.__device.addDeviceMenuEntries(self.__superMenu)
1366            self.__superMenu.addSeparator()
1367            if downloadMenu is None:
1368                # generic download action
1369                act = self.__superMenu.addAction(
1370                    self.tr("Download Firmware"), self.__downloadFirmware)
1371                act.setEnabled(self.__device.hasFirmwareUrl())
1372            else:
1373                # download sub-menu
1374                self.__superMenu.addMenu(downloadMenu)
1375            self.__superMenu.addSeparator()
1376            act = self.__superMenu.addAction(
1377                self.tr("Show Documentation"), self.__showDocumentation)
1378            act.setEnabled(self.__device.hasDocumentationUrl())
1379            self.__superMenu.addSeparator()
1380        if not self.__device.hasFlashMenuEntry():
1381            self.__superMenu.addAction(self.tr("Flash UF2 Device"),
1382                                       self.__flashUF2)
1383            self.__superMenu.addSeparator()
1384        self.__superMenu.addAction(self.tr("Manage Unknown Devices"),
1385                                   self.__manageUnknownDevices)
1386        self.__superMenu.addAction(self.tr("Ignored Serial Devices"),
1387                                   self.__manageIgnored)
1388        self.__superMenu.addSeparator()
1389        self.__superMenu.addAction(self.tr("Configure"), self.__configure)
1390
1391    @pyqtSlot()
1392    def __showDeviceVersion(self):
1393        """
1394        Private slot to show some version info about MicroPython of the device.
1395        """
1396        try:
1397            versionInfo = self.__interface.version()
1398            if versionInfo:
1399                msg = self.tr(
1400                    "<h3>Device Version Information</h3>"
1401                )
1402                msg += "<table>"
1403                for key, value in versionInfo.items():
1404                    msg += "<tr><td><b>{0}</b></td><td>{1}</td></tr>".format(
1405                        key.capitalize(), value)
1406                msg += "</table>"
1407            else:
1408                msg = self.tr("No version information available.")
1409
1410            E5MessageBox.information(
1411                self,
1412                self.tr("Device Version Information"),
1413                msg)
1414        except Exception as exc:
1415            self.__showError("version()", str(exc))
1416
1417    @pyqtSlot()
1418    def __showImplementation(self):
1419        """
1420        Private slot to show some implementation related information.
1421        """
1422        try:
1423            impInfo = self.__interface.getImplementation()
1424            if impInfo["name"] == "micropython":
1425                name = "MicroPython"
1426            elif impInfo["name"] == "circuitpython":
1427                name = "CircuitPython"
1428            elif impInfo["name"] == "unknown":
1429                name = self.tr("unknown")
1430            else:
1431                name = impInfo["name"]
1432            version = (
1433                self.tr("unknown")
1434                if impInfo["version"] == "unknown" else
1435                impInfo["version"]
1436            )
1437
1438            E5MessageBox.information(
1439                self,
1440                self.tr("Device Implementation Information"),
1441                self.tr(
1442                    "<h3>Device Implementation Information</h3>"
1443                    "<p>This device contains <b>{0} {1}</b>.</p>"
1444                ).format(name, version)
1445            )
1446        except Exception as exc:
1447            self.__showError("getImplementation()", str(exc))
1448
1449    @pyqtSlot()
1450    def __synchronizeTime(self, quiet=False):
1451        """
1452        Private slot to set the time of the connected device to the local
1453        computer's time.
1454
1455        @param quiet flag indicating to not show a message
1456        @type bool
1457        """
1458        if self.__device and self.__device.hasTimeCommands():
1459            try:
1460                self.__interface.syncTime(self.__device.getDeviceType())
1461
1462                if not quiet:
1463                    with E5OverridenCursor():
1464                        E5MessageBox.information(
1465                            self,
1466                            self.tr("Synchronize Time"),
1467                            self.tr("<p>The time of the connected device was"
1468                                    " synchronized with the local time.</p>") +
1469                            self.__getDeviceTime()
1470                        )
1471            except Exception as exc:
1472                self.__showError("syncTime()", str(exc))
1473
1474    def __getDeviceTime(self):
1475        """
1476        Private method to get a string containing the date and time of the
1477        connected device.
1478
1479        @return date and time of the connected device
1480        @rtype str
1481        """
1482        if self.__device and self.__device.hasTimeCommands():
1483            try:
1484                dateTimeString = self.__interface.getTime()
1485                try:
1486                    date, time = dateTimeString.strip().split(None, 1)
1487                    return self.tr(
1488                        "<h3>Device Date and Time</h3>"
1489                        "<table>"
1490                        "<tr><td><b>Date</b></td><td>{0}</td></tr>"
1491                        "<tr><td><b>Time</b></td><td>{1}</td></tr>"
1492                        "</table>"
1493                    ).format(date, time)
1494                except ValueError:
1495                    return self.tr(
1496                        "<h3>Device Date and Time</h3>"
1497                        "<p>{0}</p>"
1498                    ).format(dateTimeString.strip())
1499            except Exception as exc:
1500                self.__showError("getTime()", str(exc))
1501                return ""
1502        else:
1503            return ""
1504
1505    @pyqtSlot()
1506    def __showDeviceTime(self):
1507        """
1508        Private slot to show the date and time of the connected device.
1509        """
1510        msg = self.__getDeviceTime()
1511        if msg:
1512            E5MessageBox.information(
1513                self,
1514                self.tr("Device Date and Time"),
1515                msg)
1516
1517    @pyqtSlot()
1518    def __showLocalTime(self):
1519        """
1520        Private slot to show the local date and time.
1521        """
1522        localdatetime = time.localtime()
1523        localdate = time.strftime('%Y-%m-%d', localdatetime)
1524        localtime = time.strftime('%H:%M:%S', localdatetime)
1525        E5MessageBox.information(
1526            self,
1527            self.tr("Local Date and Time"),
1528            self.tr("<h3>Local Date and Time</h3>"
1529                    "<table>"
1530                    "<tr><td><b>Date</b></td><td>{0}</td></tr>"
1531                    "<tr><td><b>Time</b></td><td>{1}</td></tr>"
1532                    "</table>"
1533                    ).format(localdate, localtime)
1534        )
1535
1536    @pyqtSlot()
1537    def __showLocalAndDeviceTime(self):
1538        """
1539        Private slot to show the local and device time side-by-side.
1540        """
1541        localdatetime = time.localtime()
1542        localdate = time.strftime('%Y-%m-%d', localdatetime)
1543        localtime = time.strftime('%H:%M:%S', localdatetime)
1544
1545        try:
1546            deviceDateTimeString = self.__interface.getTime()
1547            try:
1548                devicedate, devicetime = (
1549                    deviceDateTimeString.strip().split(None, 1)
1550                )
1551                E5MessageBox.information(
1552                    self,
1553                    self.tr("Date and Time"),
1554                    self.tr("<table>"
1555                            "<tr><th></th><th>Local Date and Time</th>"
1556                            "<th>Device Date and Time</th></tr>"
1557                            "<tr><td><b>Date</b></td>"
1558                            "<td align='center'>{0}</td>"
1559                            "<td align='center'>{2}</td></tr>"
1560                            "<tr><td><b>Time</b></td>"
1561                            "<td align='center'>{1}</td>"
1562                            "<td align='center'>{3}</td></tr>"
1563                            "</table>"
1564                            ).format(localdate, localtime,
1565                                     devicedate, devicetime)
1566                )
1567            except ValueError:
1568                E5MessageBox.information(
1569                    self,
1570                    self.tr("Date and Time"),
1571                    self.tr("<table>"
1572                            "<tr><th>Local Date and Time</th>"
1573                            "<th>Device Date and Time</th></tr>"
1574                            "<tr><td align='center'>{0} {1}</td>"
1575                            "<td align='center'>{2}</td></tr>"
1576                            "</table>"
1577                            ).format(localdate, localtime,
1578                                     deviceDateTimeString.strip())
1579                )
1580        except Exception as exc:
1581            self.__showError("getTime()", str(exc))
1582
1583    def __showError(self, method, error):
1584        """
1585        Private method to show some error message.
1586
1587        @param method name of the method the error occured in
1588        @type str
1589        @param error error message
1590        @type str
1591        """
1592        with E5OverridenCursor():
1593            E5MessageBox.warning(
1594                self,
1595                self.tr("Error handling device"),
1596                self.tr("<p>There was an error communicating with the"
1597                        " connected device.</p><p>Method: {0}</p>"
1598                        "<p>Message: {1}</p>")
1599                .format(method, error))
1600
1601    def __mpyCrossAvailable(self):
1602        """
1603        Private method to check the availability of mpy-cross.
1604
1605        @return flag indicating the availability of mpy-cross
1606        @rtype bool
1607        """
1608        available = False
1609        program = Preferences.getMicroPython("MpyCrossCompiler")
1610        if not program:
1611            program = "mpy-cross"
1612            if Utilities.isinpath(program):
1613                available = True
1614        else:
1615            if Utilities.isExecutable(program):
1616                available = True
1617
1618        return available
1619
1620    def __crossCompile(self, pythonFile="", title=""):
1621        """
1622        Private method to cross compile a Python file to a .mpy file.
1623
1624        @param pythonFile name of the Python file to be compiled
1625        @type str
1626        @param title title for the various dialogs
1627        @type str
1628        """
1629        program = Preferences.getMicroPython("MpyCrossCompiler")
1630        if not program:
1631            program = "mpy-cross"
1632            if not Utilities.isinpath(program):
1633                E5MessageBox.critical(
1634                    self,
1635                    title,
1636                    self.tr("""The MicroPython cross compiler"""
1637                            """ <b>mpy-cross</b> cannot be found. Ensure it"""
1638                            """ is in the search path or configure it on"""
1639                            """ the MicroPython configuration page."""))
1640                return
1641
1642        if not pythonFile:
1643            defaultDirectory = ""
1644            aw = e5App().getObject("ViewManager").activeWindow()
1645            if aw:
1646                fn = aw.getFileName()
1647                if fn:
1648                    defaultDirectory = os.path.dirname(fn)
1649            if not defaultDirectory:
1650                defaultDirectory = (
1651                    Preferences.getMicroPython("MpyWorkspace") or
1652                    Preferences.getMultiProject("Workspace") or
1653                    os.path.expanduser("~")
1654                )
1655            pythonFile = E5FileDialog.getOpenFileName(
1656                self,
1657                title,
1658                defaultDirectory,
1659                self.tr("Python Files (*.py);;All Files (*)"))
1660            if not pythonFile:
1661                # user cancelled
1662                return
1663
1664        if not os.path.exists(pythonFile):
1665            E5MessageBox.critical(
1666                self,
1667                title,
1668                self.tr("""The Python file <b>{0}</b> does not exist."""
1669                        """ Aborting...""").format(pythonFile))
1670            return
1671
1672        compileArgs = [
1673            pythonFile,
1674        ]
1675        dlg = E5ProcessDialog(self.tr("'mpy-cross' Output"), title)
1676        res = dlg.startProcess(program, compileArgs)
1677        if res:
1678            dlg.exec()
1679
1680    @pyqtSlot()
1681    def __compileFile2Mpy(self):
1682        """
1683        Private slot to cross compile a Python file (*.py) to a .mpy file.
1684        """
1685        self.__crossCompile(title=self.tr("Compile Python File"))
1686
1687    @pyqtSlot()
1688    def __compileEditor2Mpy(self):
1689        """
1690        Private slot to cross compile the current editor to a .mpy file.
1691        """
1692        aw = e5App().getObject("ViewManager").activeWindow()
1693        if not aw.checkDirty():
1694            # editor still has unsaved changes, abort...
1695            return
1696        if not aw.isPyFile():
1697            # no Python file
1698            E5MessageBox.critical(
1699                self,
1700                self.tr("Compile Current Editor"),
1701                self.tr("""The current editor does not contain a Python"""
1702                        """ file. Aborting..."""))
1703            return
1704
1705        self.__crossCompile(
1706            pythonFile=aw.getFileName(),
1707            title=self.tr("Compile Current Editor")
1708        )
1709
1710    @pyqtSlot()
1711    def __showDocumentation(self):
1712        """
1713        Private slot to open the documentation URL for the selected device.
1714        """
1715        if self.__device is None or not self.__device.hasDocumentationUrl():
1716            # abort silently
1717            return
1718
1719        url = self.__device.getDocumentationUrl()
1720        e5App().getObject("UserInterface").launchHelpViewer(url)
1721
1722    @pyqtSlot()
1723    def __downloadFirmware(self):
1724        """
1725        Private slot to open the firmware download page.
1726        """
1727        if self.__device is None or not self.__device.hasFirmwareUrl():
1728            # abort silently
1729            return
1730
1731        self.__device.downloadFirmware()
1732
1733    def __downloadFromUrl(self, url):
1734        """
1735        Private method to open a web browser for the given URL.
1736
1737        @param url URL to be opened
1738        @type str
1739        """
1740        if self.__device is None:
1741            # abort silently
1742            return
1743
1744        if url:
1745            e5App().getObject("UserInterface").launchHelpViewer(url)
1746
1747    @pyqtSlot()
1748    def __manageIgnored(self):
1749        """
1750        Private slot to manage the list of ignored serial devices.
1751        """
1752        from .IgnoredDevicesDialog import IgnoredDevicesDialog
1753
1754        dlg = IgnoredDevicesDialog(
1755            Preferences.getMicroPython("IgnoredUnknownDevices"),
1756            self)
1757        if dlg.exec() == QDialog.DialogCode.Accepted:
1758            ignoredDevices = dlg.getDevices()
1759            Preferences.setMicroPython("IgnoredUnknownDevices",
1760                                       ignoredDevices)
1761
1762    @pyqtSlot()
1763    def __configure(self):
1764        """
1765        Private slot to open the MicroPython configuration page.
1766        """
1767        e5App().getObject("UserInterface").showPreferences("microPythonPage")
1768
1769    @pyqtSlot()
1770    def __manageUnknownDevices(self):
1771        """
1772        Private slot to manage manually added boards (i.e. those not in the
1773        list of supported boards).
1774        """
1775        from .UnknownDevicesDialog import UnknownDevicesDialog
1776        dlg = UnknownDevicesDialog()
1777        dlg.exec()
1778
1779    def __addUnknownDevices(self, devices):
1780        """
1781        Private method to add devices to the list of manually added boards.
1782
1783        @param devices list of not ignored but unknown devices
1784        @type list of tuple of (int, int, str)
1785        """
1786        from .AddEditDevicesDialog import AddEditDevicesDialog
1787
1788        if len(devices) > 1:
1789            from E5Gui.E5ListSelectionDialog import E5ListSelectionDialog
1790            sdlg = E5ListSelectionDialog(
1791                [d[2] for d in devices],
1792                title=self.tr("Add Unknown Devices"),
1793                message=self.tr("Select the devices to be added:"),
1794                checkBoxSelection=True
1795            )
1796            if sdlg.exec() == QDialog.DialogCode.Accepted:
1797                selectedDevices = sdlg.getSelection()
1798        else:
1799            selectedDevices = devices[0][2]
1800
1801        if selectedDevices:
1802            manualDevices = Preferences.getMicroPython("ManualDevices")
1803            for vid, pid, description in devices:
1804                if description in selectedDevices:
1805                    dlg = AddEditDevicesDialog(vid, pid, description)
1806                    if dlg.exec() == QDialog.DialogCode.Accepted:
1807                        manualDevices.append(dlg.getDeviceDict())
1808            Preferences.setMicroPython("ManualDevices", manualDevices)
1809
1810            # rescan the ports
1811            self.__populateDeviceTypeComboBox()
1812
1813    @pyqtSlot()
1814    def __flashUF2(self):
1815        """
1816        Private slot to flash MicroPython/CircuitPython to a device
1817        support the UF2 bootloader.
1818        """
1819        dlg = UF2FlashDialog.UF2FlashDialog()
1820        dlg.exec()
1821