Continued implementing the MicroPython support. micropython

Tue, 16 Jul 2019 20:12:53 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Tue, 16 Jul 2019 20:12:53 +0200
branch
micropython
changeset 7065
e3d04faced34
parent 7062
ac12da95958b
child 7066
e3d034e65afc

Continued implementing the MicroPython support.

eric6.e4p file | annotate | diff | comparison | revisions
eric6/MicroPython/CircuitPythonDevices.py file | annotate | diff | comparison | revisions
eric6/MicroPython/EspDevices.py file | annotate | diff | comparison | revisions
eric6/MicroPython/MicroPythonDevices.py file | annotate | diff | comparison | revisions
eric6/MicroPython/MicroPythonGraphWidget.py file | annotate | diff | comparison | revisions
eric6/MicroPython/MicroPythonReplWidget.py file | annotate | diff | comparison | revisions
eric6/MicroPython/MicrobitDevices.py file | annotate | diff | comparison | revisions
eric6/UI/UserInterface.py file | annotate | diff | comparison | revisions
--- a/eric6.e4p	Thu Jul 11 19:48:14 2019 +0200
+++ b/eric6.e4p	Tue Jul 16 20:12:53 2019 +0200
@@ -457,6 +457,7 @@
     <Source>eric6/MicroPython/CircuitPythonDevices.py</Source>
     <Source>eric6/MicroPython/EspDevices.py</Source>
     <Source>eric6/MicroPython/MicroPythonDevices.py</Source>
+    <Source>eric6/MicroPython/MicroPythonGraphWidget.py</Source>
     <Source>eric6/MicroPython/MicroPythonReplWidget.py</Source>
     <Source>eric6/MicroPython/MicrobitDevices.py</Source>
     <Source>eric6/MicroPython/__init__.py</Source>
--- a/eric6/MicroPython/CircuitPythonDevices.py	Thu Jul 11 19:48:14 2019 +0200
+++ b/eric6/MicroPython/CircuitPythonDevices.py	Tue Jul 16 20:12:53 2019 +0200
@@ -132,7 +132,7 @@
             oldMode = ctypes.windll.kernel32.SetErrorMode(1)
             try:
                 for disk in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ':
-                    path = '{}:\\'.format(disk)
+                    path = '{0}:\\'.format(disk)
                     if (os.path.exists(path) and
                             getVolumeName(path) == 'CIRCUITPY'):
                         return path
--- a/eric6/MicroPython/EspDevices.py	Thu Jul 11 19:48:14 2019 +0200
+++ b/eric6/MicroPython/EspDevices.py	Tue Jul 16 20:12:53 2019 +0200
@@ -10,6 +10,8 @@
 
 from __future__ import unicode_literals
 
+from PyQt5.QtCore import pyqtSlot
+
 from .MicroPythonDevices import MicroPythonDevice
 from .MicroPythonReplWidget import HAS_QTCHART
 
@@ -75,7 +77,8 @@
         @type bool
         """
         self.__replActive = on
-        self.microPython.setActionButtons(files=not on)
+        self.microPython.setActionButtons(
+            files=not (on or self.__plotterActive))
     
     def canStartPlotter(self):
         """
@@ -101,7 +104,8 @@
         @type bool
         """
         self.__plotterActive = on
-        self.microPython.setActionButtons(files=not on)
+        self.microPython.setActionButtons(
+            files=not (on or self.__replActive))
     
     def canRunScript(self):
         """
@@ -143,3 +147,10 @@
         @type bool
         """
         pass
+    
+    @pyqtSlot()
+    def handleDataFlood(self):
+        """
+        Public slot handling a data floof from the device.
+        """
+        self.microPython.setActionButtons(files=True)
--- a/eric6/MicroPython/MicroPythonDevices.py	Thu Jul 11 19:48:14 2019 +0200
+++ b/eric6/MicroPython/MicroPythonDevices.py	Tue Jul 16 20:12:53 2019 +0200
@@ -13,7 +13,7 @@
 import logging
 import os
 
-from PyQt5.QtCore import QObject
+from PyQt5.QtCore import pyqtSlot, QObject
 
 import UI.PixmapCache
 import Preferences
@@ -284,10 +284,17 @@
             b'\r\x03\x03',  # Ctrl-C twice: interrupt any running program
             b'\r\x01',      # Ctrl-A: enter raw REPL
         ]
-        newLine = [b'print("\\n")\r',]
+        newLine = [b'print("\\n")\r', ]
         commands = [c.encode("utf-8)") + b'\r' for c in commandsList]
         commands.append(b'\r')
         commands.append(b'\x04')
         rawOff = [b'\x02']
         commandSequence = rawOn + newLine + commands + rawOff
         self.microPython.execute(commandSequence)
+    
+    @pyqtSlot()
+    def handleDataFlood(self):
+        """
+        Public slot handling a data floof from the device.
+        """
+        pass
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/eric6/MicroPython/MicroPythonGraphWidget.py	Tue Jul 16 20:12:53 2019 +0200
@@ -0,0 +1,250 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2019 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing the MicroPython graph widget.
+"""
+
+from __future__ import unicode_literals
+
+from collections import deque
+import bisect
+
+from PyQt5.QtCore import pyqtSignal, pyqtSlot
+from PyQt5.QtGui import QPainter
+from PyQt5.QtWidgets import (
+    QWidget, QHBoxLayout, QVBoxLayout, QToolButton, QSizePolicy, QSpacerItem
+)
+from PyQt5.QtChart import QChartView, QChart, QLineSeries, QValueAxis
+
+import UI.PixmapCache
+
+
+class MicroPythonGraphWidget(QWidget):
+    """
+    Class implementing the MicroPython graph widget.
+    
+    @signal dataFlood emitted to indicate, that too much data is received
+    """
+    dataFlood = pyqtSignal()
+    
+    def __init__(self, parent=None):
+        """
+        Constructor
+        
+        @param parent reference to the parent widget
+        @type QWidget
+        """
+        super(MicroPythonGraphWidget, self).__init__(parent)
+        
+        self.__layout = QHBoxLayout()
+        self.__layout.setContentsMargins(0, 0, 0, 0)
+        self.setLayout(self.__layout)
+        
+        self.__chartView = QChartView(self)
+        self.__chartView.setSizePolicy(
+            QSizePolicy.Expanding, QSizePolicy.Expanding)
+        self.__layout.addWidget(self.__chartView)
+        
+        self.__buttonsLayout = QVBoxLayout()
+        self.__buttonsLayout.setContentsMargins(0, 0, 0, 0)
+        self.__layout.addLayout(self.__buttonsLayout)
+        
+        self.__saveButton = QToolButton(self)
+        self.__saveButton.setIcon(UI.PixmapCache.getIcon("fileSave"))
+        self.__saveButton.setToolTip(self.tr("Press to save the raw data"))
+        self.__saveButton.clicked.connect(self.on_saveButton_clicked)
+        self.__buttonsLayout.addWidget(self.__saveButton)
+        
+        spacerItem = QSpacerItem(20, 20, QSizePolicy.Minimum,
+                                 QSizePolicy.Expanding)
+        self.__buttonsLayout.addItem(spacerItem)
+        
+        # holds the data to be checked for plotable data
+        self.__inputBuffer = []
+        # holds the raw data
+        self.__rawData = []
+        
+        self.__maxX = 100
+        self.__maxY = 1000
+        self.__flooded = False  # flag indicating a data flood
+        
+        self.__data = [deque([0] * self.__maxX)]
+        self.__series = [QLineSeries()]
+        
+        # Y-axis ranges
+        self.__yRanges = [1, 5, 10, 25, 50, 100, 250, 500, 1000]
+        
+        # setup the chart
+        self.__chart = QChart()
+        self.__chart.legend().hide()
+        self.__chart.addSeries(self.__series[0])
+        self.__axisX = QValueAxis()
+        self.__axisX.setRange(0, self.__maxX)
+        self.__axisX.setLabelFormat("time")
+        self.__axisY = QValueAxis()
+        self.__axisY.setRange(-self.__maxY, self.__maxY)
+        self.__axisY.setLabelFormat("%d")
+        self.__chart.setAxisX(self.__axisX, self.__series[0])
+        self.__chart.setAxisY(self.__axisY, self.__series[0])
+        self.__chartView.setChart(self.__chart)
+        self.__chartView.setRenderHint(QPainter.Antialiasing)
+    
+    @pyqtSlot(bytes)
+    def processData(self, data):
+        """
+        Public slot to process the raw data.
+        
+        It takes raw bytes, checks the data for a valid tuple of ints or
+        floats and adds the data to the graph. If the the length of the bytes
+        data is greater than 1024 then a dataFlood signal is emitted to ensure
+        eric can take action to remain responsive.
+        
+        @param data raw data received from the connected device via the main
+            device widget
+        @type bytes
+        """
+        # flooding guard
+        if self.__flooded:
+            return
+        
+        if len(data) > 1024:
+            self.__flooded = True
+            self.dataFlood.emit()
+            return
+        
+        data = data.replace(b"\r\n", b"\n").replace(b"\r", b"\n")
+        self.__inputBuffer.append(data)
+        
+        # check if the data contains a Python tuple containing numbers (int
+        # or float) on a single line
+        inputBytes = b"".join(self.__inputBuffer)
+        lines = inputBytes.splitlines(True)
+        for line in lines:
+            if not line.endswith(b"\n"):
+                # incomplete line (last line); skip it
+                continue
+            
+            line = line.strip()
+            if line.startswith(b"(") and line.endswith(b")"):
+                # it may be a tuple we are interested in
+                rawValues = [val.strip() for val in line[1:-1].split(b",")]
+                values = []
+                for raw in rawValues:
+                    try:
+                        values.append(int(raw))
+                        # ok, it is an integer
+                        continue
+                    except ValueError:
+                        # test for a float
+                        pass
+                    try:
+                        values.append(float(raw))
+                    except ValueError:
+                        # it is not an int or float, ignore it
+                        continue
+                if values:
+                    self.__addData(tuple(values))
+        
+        self.__inputBuffer = []
+        if lines[-1] and not lines[-1].endswith(b"\n"):
+            # Append any left over bytes for processing next time data is
+            # received.
+            self.__inputBuffer.append(lines[-1])
+    
+    def __addData(self, values):
+        """
+        Private method to add a tuple of values to the graph.
+        
+        It ensures there are the required number of line series, adds the data
+        to the line series and updates the range of the chart so the chart
+        displays nicely.
+        
+        @param values tuple containing the data to be added
+        @type tuple of int or float
+        """
+        # store incoming data to be able to dump it as CSV upon request
+        self.__rawData.append(values)
+        
+        # check number of incoming values and adjust line series accordingly
+        if len(values) != len(self.__series):
+            valuesLen = len(values)
+            seriesLen = len(self.__series)
+            if valuesLen > seriesLen:
+                # add a nwe line series
+                for _index in range(valuesLen - seriesLen):
+                    newSeries = QLineSeries()
+                    self.__chart.addSeries(newSeries)
+                    self.__chart.setAxisX(self.__axisX, newSeries)
+                    self.__chart.setAxisY(self.__axisY, newSeries)
+                    self.__series.append(newSeries)
+                    self.__data.append(deque([0] * self.__maxX))
+            else:
+                # remove obsolete line series
+                for oldSeries in self.__series[valuesLen:]:
+                    self.__chart.removeSeries(oldSeries)
+                self.__series = self.__series[:valuesLen]
+                self.__data = self.__data[:valuesLen]
+        
+        # add the new values to the display and compute the maximum range
+        maxRanges = []
+        for index, value in enumerate(values):
+            self.__data[index].appendleft(value)
+            maxRanges.append(max([max(self.__data[index]),
+                                  abs(min(self.__data[index]))]))
+            if len(self.__data[index]) > self.__maxX:
+                self.__data[index].pop()
+        
+        # re-scale the y-axis
+        maxYRange = max(maxRanges)
+        yRange = bisect.bisect_left(self.__yRanges, maxYRange)
+        if yRange < len(self.__yRanges):
+            self.__maxY = self.__yRanges[yRange]
+        elif maxYRange > self.__maxY:
+            self.__maxY += self.__maxY
+        elif maxYRange < self.__maxY / 2:
+            self.__maxY /= 2
+        self.__axisY.setRange(-self.__maxY, self.__maxY)
+        
+        # ensure that floats are used to label the y-axis if the range is small
+        if self.__maxY <= 5:
+            self.__axisY.setLabelFormat("%2.2f")
+        else:
+            self.__axisY.setLabelFormat("%d")
+        
+        # update the line series
+        for index, series in enumerate(self.__series):
+            series.clear()
+            xyValues = []
+            for x in range(self.__maxX):
+                value = self.__data[index][self.__maxX - 1 - x]
+                xyValues.append((x, value))
+            for xy in xyValues:
+                series.append(*xy)
+    
+    @pyqtSlot()
+    def on_saveButton_clicked(self):
+        """
+        Private slot to save the raw data to a CSV file.
+        """
+        self.saveData()
+    
+    def hasData(self):
+        """
+        Public method to check, if the chart contains some valid data.
+        
+        @return flag indicating valid data
+        @rtype bool
+        """
+        # TODO: not implemented yet
+    
+    def saveData(self):
+        """
+        Public method to save the dialog's raw data.
+        
+        @return flag indicating success
+        @rtype bool
+        """
+        # TODO: not implemented yet
--- a/eric6/MicroPython/MicroPythonReplWidget.py	Thu Jul 11 19:48:14 2019 +0200
+++ b/eric6/MicroPython/MicroPythonReplWidget.py	Tue Jul 16 20:12:53 2019 +0200
@@ -22,11 +22,6 @@
     HAS_QTSERIALPORT = True
 except ImportError:
     HAS_QTSERIALPORT = False
-try:
-    from PyQt5.QtChart import QChart    # __IGNORE_WARNING__
-    HAS_QTCHART = True
-except ImportError:
-    HAS_QTCHART = False
 
 from E5Gui.E5ZoomWidget import E5ZoomWidget
 from E5Gui import E5MessageBox, E5FileDialog
@@ -35,6 +30,11 @@
 from .Ui_MicroPythonReplWidget import Ui_MicroPythonReplWidget
 
 from . import MicroPythonDevices
+try:
+    from .MicroPythonGraphWidget import MicroPythonGraphWidget
+    HAS_QTCHART = True
+except ImportError:
+    HAS_QTCHART = False
 
 import Globals
 import UI.PixmapCache
@@ -97,6 +97,8 @@
         self.__zoomWidget.valueChanged.connect(self.__doZoom)
         self.__currentZoom = 0
         
+        self.__ui = None
+        
         self.__serial = None
         self.__device = None
         self.setConnected(False)
@@ -182,8 +184,8 @@
         """
         Public method to set the enabled state of the various action buttons.
         
-        @param kwargs keyword arguments containg the enabled states (keys are
-            'run', 'repl', 'files', 'chart', 'open', 'save'
+        @keyparam kwargs keyword arguments containg the enabled states (keys
+            are 'run', 'repl', 'files', 'chart', 'open', 'save'
         @type dict
         """
         if "open" in kwargs:
@@ -202,7 +204,7 @@
     @pyqtSlot(QPoint)
     def __showContextMenu(self, pos):
         """
-        Privat slot to show the REPL context menu.
+        Private slot to show the REPL context menu.
         
         @param pos position to show the menu at
         @type QPoint
@@ -259,7 +261,8 @@
         
         if self.__replRunning:
             self.dataReceived.disconnect(self.__processData)
-            self.__disconnect()
+            if not self.__plotterRunning:
+                self.__disconnectSerial()
             self.__replRunning = False
             self.__device.setRepl(False)
         else:
@@ -271,10 +274,11 @@
                     self.tr("""<p>The REPL cannot be started.</p><p>Reason:"""
                             """ {0}</p>""").format(reason))
                 return
-                
+            
+            self.replEdit.clear()
+            self.dataReceived.connect(self.__processData)
+            
             if not self.__serial:
-                self.replEdit.clear()
-                self.dataReceived.connect(self.__processData)
                 self.__openSerialLink()
                 if self.__serial:
                     if self.__device.forceInterrupt():
@@ -294,9 +298,12 @@
         if self.__replRunning:
             self.on_replButton_clicked()
         
+        if self.__plotterRunning:
+            self.on_chartButton_clicked()
+        
         # TODO: add more
     
-    def __disconnect(self):
+    def __disconnectSerial(self):
         """
         Private slot to disconnect the serial connection.
         """
@@ -453,7 +460,7 @@
         Private slot to zoom the REPL pane.
         
         @param value zoom value
-        @param int
+        @type int
         """
         if value < self.__currentZoom:
             self.replEdit.zoomOut(self.__currentZoom - value)
@@ -473,7 +480,7 @@
             self.DevicePortRole)
         
         if Globals.isWindowsPlatform():
-            # return unchanged
+            # return it unchanged
             return portName
         else:
             # return with device path prepended
@@ -527,12 +534,14 @@
         @param commandsList list of commands to be execute on the device
         @type list of bytes
         """
+        def remainingTask(commands):
+            self.execute(commands)
+        
         if commandsList:
             command = commandsList[0]
             self.__serial.write(command)
             remainder = commandsList[1:]
-            remainingTask = lambda commands=remainder: self.execute(commands)
-            QTimer.singleShot(2, remainingTask)
+            QTimer.singleShot(2, lambda: remainingTask(remainder))
     
     @pyqtSlot()
     def on_runButton_clicked(self):
@@ -605,3 +614,79 @@
         workspace = self.__device.getWorkspace()
         aw = e5App().getObject("ViewManager").activeWindow()
         aw.saveFileAs(workspace)
+    
+    @pyqtSlot()
+    def on_chartButton_clicked(self):
+        """
+        Private slot to open a chart view to plot data received from the
+        connected device.
+        """
+        if not HAS_QTCHART:
+            # QtChart not available => fail silently
+            return
+        
+        if not self.__device:
+            self.__showNoDeviceMessage()
+            return
+        
+        if self.__ui is None:
+            self.__ui = e5App().getObject("UserInterface")
+        
+        if self.__plotterRunning:
+            if self.__chartWidget.hasData():
+                res = E5MessageBox.okToClearData(
+                    self,
+                    self.tr("Unsaved Chart Data"),
+                    self.tr("""The chart contains unsaved data."""),
+                    self.__chartWidget.saveData)
+                if not res:
+                    # abort
+                    return
+            
+            self.dataReceived.disconnect(self.__chartWidget.processData)
+            self.__chartWidget.dataFlood.disconnect(self.handleDataFlood)
+            
+            if not self.__replRunning:
+                self.__disconnectSerial()
+            
+            self.__plotterRunning = False
+            self.__device.setPlotter(False)
+            self.__ui.removeSideWidget(self.__chartWidget)
+        else:
+            ok, reason = self.__device.canStartPlotter()
+            if not ok:
+                E5MessageBox.warning(
+                    self,
+                    self.tr("Start Chart"),
+                    self.tr("""<p>The Chart cannot be started.</p><p>Reason:"""
+                            """ {0}</p>""").format(reason))
+                return
+            
+            self.__chartWidget = MicroPythonGraphWidget(self)
+            self.dataReceived.connect(self.__chartWidget.processData)
+            self.__chartWidget.dataFlood.connect(self.handleDataFlood)
+            
+            self.__ui.addSideWidget(self.__ui.BottomSide, self.__chartWidget,
+                                    UI.PixmapCache.getIcon("chart"),
+                                    self.tr("Chart"))
+            self.__ui.showSideWidget(self.__chartWidget)
+            
+            if not self.__serial:
+                self.__openSerialLink()
+                if self.__serial:
+                    if self.__device.forceInterrupt():
+                        # send a Ctrl-B (exit raw mode)
+                        self.__serial.write(b'\x02')
+                        # send Ctrl-C (keyboard interrupt)
+                        self.__serial.write(b'\x03')
+            
+            self.__plotterRunning = True
+            self.__device.setPlotter(True)
+    
+    @pyqtSlot()
+    def handleDataFlood(self):
+        """
+        Public slot handling a data flood from the device.
+        """
+        self.on_disconnectButton_clicked()
+        self.__device.handleDataFlood()
--- a/eric6/MicroPython/MicrobitDevices.py	Thu Jul 11 19:48:14 2019 +0200
+++ b/eric6/MicroPython/MicrobitDevices.py	Tue Jul 16 20:12:53 2019 +0200
@@ -9,6 +9,8 @@
 
 from __future__ import unicode_literals
 
+from PyQt5.QtCore import pyqtSlot
+
 from .MicroPythonDevices import MicroPythonDevice
 from .MicroPythonReplWidget import HAS_QTCHART
 
@@ -122,3 +124,10 @@
         @type bool
         """
         pass
+    
+    @pyqtSlot()
+    def handleDataFlood(self):
+        """
+        Public slot handling a data floof from the device.
+        """
+        self.microPython.setActionButtons(files=True)
--- a/eric6/UI/UserInterface.py	Thu Jul 11 19:48:14 2019 +0200
+++ b/eric6/UI/UserInterface.py	Tue Jul 16 20:12:53 2019 +0200
@@ -1211,11 +1211,15 @@
         """
         Public method to add a widget to the sides.
         
-        @param side side to add the widget to (UserInterface.LeftSide,
-            UserInterface.BottomSide)
-        @param widget reference to the widget to add (QWidget)
-        @param icon icon to be used (QIcon)
-        @param label label text to be shown (string)
+        @param side side to add the widget to
+        @type int (one of UserInterface.LeftSide, UserInterface.BottomSide,
+            UserInterface.RightSide)
+        @param widget reference to the widget to add
+        @type QWidget
+        @param icon icon to be used
+        @type QIcon
+        @param label label text to be shown
+        @type str
         """
         assert side in [UserInterface.LeftSide, UserInterface.BottomSide,
                         UserInterface.RightSide]
@@ -1234,12 +1238,13 @@
                 self.bottomSidebar.addTab(widget, icon, label)
             elif side == UserInterface.RightSide:
                 self.rightSidebar.addTab(widget, icon, label)
-        
+    
     def removeSideWidget(self, widget):
         """
         Public method to remove a widget added using addSideWidget().
         
-        @param widget reference to the widget to remove (QWidget)
+        @param widget reference to the widget to remove
+        @type QWidget
         """
         if self.__layoutType == "Toolboxes":
             for container in [self.lToolbox, self.hToolbox, self.rToolbox]:
@@ -1252,7 +1257,34 @@
                 index = container.indexOf(widget)
                 if index != -1:
                     container.removeTab(index)
-        
+    
+    def showSideWidget(self, widget):
+        """
+        Public method to show a specific widget placed in the side widgets.
+        
+        @param widget reference to the widget to be shown
+        @type QWidget
+        """
+        if self.__layoutType == "Toolboxes":
+            for dock in [self.lToolboxDock, self.hToolboxDock,
+                              self.rToolboxDock]:
+                container = dock.widget()
+                index = container.indexOf(widget)
+                if index != -1:
+                    dock.show()
+                    container.setCurrentIndex(index)
+                    dock.raise_()
+        elif self.__layoutType == "Sidebars":
+            for container in [self.leftSidebar, self.bottomSidebar,
+                              self.rightSidebar]:
+                index = container.indexOf(widget)
+                if index != -1:
+                    container.show()
+                    container.setCurrentIndex(index)
+                    container.raise_()
+                    if container.isAutoHiding():
+                        container.setFocus()
+    
     def showLogViewer(self):
         """
         Public method to show the Log-Viewer.

eric ide

mercurial