Tue, 16 Jul 2019 20:12:53 +0200
Continued implementing the MicroPython support.
--- 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.