X-Git-Url: http://sigrok.org/gitweb/?a=blobdiff_plain;f=mainwindow.py;h=26f1005bdbe5558c68edbd212d8d2768b1afcccf;hb=1dc3ae069fa8649774fb6d97f0d9e974318d6577;hp=19ce4d3ce79061cdcd0a0635f7d074d3c04249b7;hpb=3010b5a00bb129bb0e710a6513f425a05258a6d8;p=sigrok-meter.git diff --git a/mainwindow.py b/mainwindow.py index 19ce4d3..26f1005 100644 --- a/mainwindow.py +++ b/mainwindow.py @@ -15,15 +15,19 @@ ## GNU General Public License for more details. ## ## You should have received a copy of the GNU General Public License -## along with this program; if not, write to the Free Software -## Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +## along with this program; if not, see . ## +import acquisition import datamodel +import datetime +import icons import multiplotwidget import os.path import qtcompat -import samplingthread +import settings +import sigrok.core as sr +import sys import textwrap import time import util @@ -33,7 +37,7 @@ QtGui = qtcompat.QtGui pyqtgraph = qtcompat.pyqtgraph class EmptyMessageListView(QtGui.QListView): - '''List view that shows a message if the model im empty.''' + '''List view that shows a message if the model is empty.''' def __init__(self, message, parent=None): super(self.__class__, self).__init__(parent) @@ -52,85 +56,320 @@ class EmptyMessageListView(QtGui.QListView): class MainWindow(QtGui.QMainWindow): '''The main window of the application.''' - # Number of seconds that the plots display. - BACKLOG = 30 - # Update interval of the plots in milliseconds. UPDATEINTERVAL = 100 def __init__(self, context, drivers): super(self.__class__, self).__init__() + # Used to coordinate the stopping of the acquisition and + # the closing of the window. + self._closing = False + self.context = context + self.drivers = drivers + + self.logModel = QtGui.QStringListModel(self) + self.context.set_log_callback(self._log_callback) self.delegate = datamodel.MultimeterDelegate(self, self.font()) self.model = datamodel.MeasurementDataModel(self) - self.model.rowsInserted.connect(self.modelRowsInserted) - self.setup_ui() + # Maps from 'unit' to the corresponding plot. + self._plots = {} + # Maps from '(plot, device)' to the corresponding curve. + self._curves = {} + + self._setup_ui() + + self._plot_update_timer = QtCore.QTimer() + self._plot_update_timer.setInterval(MainWindow.UPDATEINTERVAL) + self._plot_update_timer.timeout.connect(self._updatePlots) - self.thread = samplingthread.SamplingThread(self.context, drivers) - self.thread.measured.connect(self.model.update) - self.thread.error.connect(self.error) - self.thread.start() + settings.graph.backlog.changed.connect(self.on_setting_graph_backlog_changed) - def setup_ui(self): + QtCore.QTimer.singleShot(0, self._start_acquisition) + + def _start_acquisition(self): + self.acquisition = acquisition.Acquisition(self.context) + self.acquisition.measured.connect(self.model.update) + self.acquisition.stopped.connect(self._stopped) + + try: + for (ds, cs) in self.drivers: + self.acquisition.add_device(ds, cs) + except Exception as e: + QtGui.QMessageBox.critical(self, 'Error', str(e)) + self.close() + return + + self.start_stop_acquisition() + + def _log_callback(self, level, message): + if level.id > settings.logging.level.value().id: + return + + t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f') + message = '[{}] sr: {}'.format(t, message) + + sys.stderr.write(message + '\n') + + scrollBar = self.logView.verticalScrollBar() + bottom = scrollBar.value() == scrollBar.maximum() + + rows = self.logModel.rowCount() + maxrows = settings.logging.lines.value() + while rows > maxrows: + self.logModel.removeRows(0, 1) + rows -= 1 + + if self.logModel.insertRow(rows): + index = self.logModel.index(rows) + self.logModel.setData(index, message, QtCore.Qt.DisplayRole) + + if bottom: + self.logView.scrollToBottom() + + def _setup_ui(self): self.setWindowTitle('sigrok-meter') # Resizing the listView below will increase this again. self.resize(350, 10) - p = os.path.abspath(os.path.dirname(__file__)) - p = os.path.join(p, 'sigrok-logo-notext.png') - self.setWindowIcon(QtGui.QIcon(p)) + self.setWindowIcon(QtGui.QIcon(':/logo.png')) - actionQuit = QtGui.QAction(self) - actionQuit.setText('&Quit') - actionQuit.setIcon(QtGui.QIcon.fromTheme('application-exit')) - actionQuit.setShortcut('Ctrl+Q') - actionQuit.triggered.connect(self.close) + self._setup_graphPage() + self._setup_addDevicePage() + self._setup_logPage() + self._setup_preferencesPage() - actionAbout = QtGui.QAction(self) - actionAbout.setText('&About') - actionAbout.setIcon(QtGui.QIcon.fromTheme('help-about')) - actionAbout.triggered.connect(self.show_about) + self._pages = [ + self.graphPage, + self.addDevicePage, + self.logPage, + self.preferencesPage + ] - menubar = self.menuBar() - menuFile = menubar.addMenu('&File') - menuFile.addAction(actionQuit) - menuHelp = menubar.addMenu('&Help') - menuHelp.addAction(actionAbout) - - self.listView = EmptyMessageListView('waiting for data...') - self.listView.setFrameShape(QtGui.QFrame.NoFrame) - self.listView.viewport().setBackgroundRole(QtGui.QPalette.Window) - self.listView.viewport().setAutoFillBackground(True) - self.listView.setMinimumWidth(260) - self.listView.setSelectionMode(QtGui.QAbstractItemView.NoSelection) - self.listView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) - self.listView.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel) - self.listView.setItemDelegate(self.delegate) - self.listView.setModel(self.model) - self.listView.setUniformItemSizes(True) - self.listView.setMinimumSize(self.delegate.sizeHint()) + self.stackedWidget = QtGui.QStackedWidget(self) + for page in self._pages: + self.stackedWidget.addWidget(page) - self.plotwidget = multiplotwidget.MultiPlotWidget(self) + self._setup_sidebar() - # Maps from 'unit' to the corresponding plot. - self._plots = {} - # Maps from '(plot, device)' to the corresponding curve. - self._curves = {} + self.setCentralWidget(QtGui.QWidget()) + self.centralWidget().setContentsMargins(0, 0, 0, 0) - self.splitter = QtGui.QSplitter(QtCore.Qt.Horizontal); - self.splitter.addWidget(self.listView) - self.splitter.addWidget(self.plotwidget) - self.splitter.setStretchFactor(0, 0) - self.splitter.setStretchFactor(1, 1) + layout = QtGui.QHBoxLayout(self.centralWidget()) + layout.addWidget(self.sideBar) + layout.addWidget(self.stackedWidget) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + + self.resize(settings.mainwindow.size.value()) + if settings.mainwindow.pos.value(): + self.move(settings.mainwindow.pos.value()) + + def _setup_sidebar(self): + self.sideBar = QtGui.QToolBar(self) + self.sideBar.setOrientation(QtCore.Qt.Vertical) + + actionGraph = self.sideBar.addAction('Instantaneous Values and Graphs') + actionGraph.setCheckable(True) + actionGraph.setIcon(icons.graph) + actionGraph.triggered.connect(self.showGraphPage) + + #actionAdd = self.sideBar.addAction('Add Device') + #actionAdd.setCheckable(True) + #actionAdd.setIcon(icons.add) + #actionAdd.triggered.connect(self.showAddDevicePage) + + actionLog = self.sideBar.addAction('Logs') + actionLog.setCheckable(True) + actionLog.setIcon(icons.log) + actionLog.triggered.connect(self.showLogPage) + + actionPreferences = self.sideBar.addAction('Preferences') + actionPreferences.setCheckable(True) + actionPreferences.setIcon(icons.preferences) + actionPreferences.triggered.connect(self.showPreferencesPage) + + # Make the buttons at the top exclusive. + self.actionGroup = QtGui.QActionGroup(self) + self.actionGroup.addAction(actionGraph) + #self.actionGroup.addAction(actionAdd) + self.actionGroup.addAction(actionLog) + self.actionGroup.addAction(actionPreferences) + + # Show graph at startup. + actionGraph.setChecked(True) + + # Fill space between buttons on the top and on the bottom. + fill = QtGui.QWidget(self) + fill.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Expanding) + self.sideBar.addWidget(fill) + + self.actionStartStop = self.sideBar.addAction('Start Acquisition') + self.actionStartStop.setIcon(icons.start) + self.actionStartStop.triggered.connect(self.start_stop_acquisition) + + actionAbout = self.sideBar.addAction('About') + actionAbout.setIcon(icons.about) + actionAbout.triggered.connect(self.show_about) - self.setCentralWidget(self.splitter) - self.centralWidget().setContentsMargins(0, 0, 0, 0) - self.resize(800, 500) + actionQuit = self.sideBar.addAction('Quit') + actionQuit.setIcon(icons.exit) + actionQuit.triggered.connect(self.close) + + s = self.style().pixelMetric(QtGui.QStyle.PM_LargeIconSize) + self.sideBar.setIconSize(QtCore.QSize(s, s)) + + self.sideBar.setStyleSheet(''' + QToolBar { + background-color: white; + margin: 0px; + border: 0px; + border-right: 1px solid black; + } + + QToolButton { + padding: 10px; + border: 0px; + border-right: 1px solid black; + } + + QToolButton:checked, + QToolButton[checkable="false"]:hover { + background-color: #c0d0e8; + } + ''') + + def _setup_graphPage(self): + listView = EmptyMessageListView('waiting for data...') + listView.setFrameShape(QtGui.QFrame.NoFrame) + listView.viewport().setBackgroundRole(QtGui.QPalette.Window) + listView.viewport().setAutoFillBackground(True) + listView.setMinimumWidth(260) + listView.setSelectionMode(QtGui.QAbstractItemView.NoSelection) + listView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) + listView.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel) + listView.setItemDelegate(self.delegate) + listView.setModel(self.model) + listView.setUniformItemSizes(True) + listView.setMinimumSize(self.delegate.sizeHint()) - self.startTimer(MainWindow.UPDATEINTERVAL) + self.plotwidget = multiplotwidget.MultiPlotWidget(self) + self.plotwidget.plotHidden.connect(self._on_plotHidden) + + self.graphPage = QtGui.QSplitter(QtCore.Qt.Horizontal, self) + self.graphPage.addWidget(listView) + self.graphPage.addWidget(self.plotwidget) + self.graphPage.setStretchFactor(0, 0) + self.graphPage.setStretchFactor(1, 1) + + def _setup_addDevicePage(self): + self.addDevicePage = QtGui.QWidget(self) + layout = QtGui.QVBoxLayout(self.addDevicePage) + label = QtGui.QLabel('add device page') + layout.addWidget(label) + + def _setup_logPage(self): + self.logPage = QtGui.QWidget(self) + layout = QtGui.QVBoxLayout(self.logPage) + + self.logView = QtGui.QListView(self) + self.logView.setModel(self.logModel) + self.logView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) + self.logView.setSelectionMode(QtGui.QAbstractItemView.NoSelection) + layout.addWidget(self.logView) + + btn = QtGui.QPushButton('Save to file...', self) + btn.clicked.connect(self.on_save_log_clicked) + layout.addWidget(btn) + + def _setup_preferencesPage(self): + self.preferencesPage = QtGui.QWidget(self) + layout = QtGui.QGridLayout(self.preferencesPage) + + layout.addWidget(QtGui.QLabel('Graph'), 0, 0) + layout.addWidget(QtGui.QLabel('Recording time (seconds):'), 1, 0) + + spin = QtGui.QSpinBox(self) + spin.setMinimum(10) + spin.setMaximum(3600) + spin.setSingleStep(10) + spin.setValue(settings.graph.backlog.value()) + spin.valueChanged[int].connect(settings.graph.backlog.setValue) + layout.addWidget(spin, 1, 1) + + layout.addWidget(QtGui.QLabel('Logging'), 2, 0) + layout.addWidget(QtGui.QLabel('Log level:'), 3, 0) + + cbox = QtGui.QComboBox() + descriptions = [ + 'no messages at all', + 'error messages', + 'warnings', + 'informational messages', + 'debug messages', + 'very noisy debug messages' + ] + for i, desc in enumerate(descriptions): + level = sr.LogLevel.get(i) + text = '{} ({})'.format(level.name, desc) + # The numeric log level corresponds to the index of the text in the + # combo box. Should this ever change, we could use the 'userData' + # that can also be stored in the item. + cbox.addItem(text) + + cbox.setCurrentIndex(settings.logging.level.value().id) + cbox.currentIndexChanged[int].connect( + (lambda i: settings.logging.level.setValue(sr.LogLevel.get(i)))) + layout.addWidget(cbox, 3, 1) + + layout.addWidget(QtGui.QLabel('Number of lines to log:'), 4, 0) + + spin = QtGui.QSpinBox(self) + spin.setMinimum(100) + spin.setMaximum(10 * 1000 * 1000) + spin.setSingleStep(100) + spin.setValue(settings.logging.lines.value()) + spin.valueChanged[int].connect(settings.logging.lines.setValue) + layout.addWidget(spin, 4, 1) + + layout.setRowStretch(layout.rowCount(), 100) + + def showPage(self, page): + self.stackedWidget.setCurrentIndex(self._pages.index(page)) + + @QtCore.Slot(bool) + def showGraphPage(self): + self.showPage(self.graphPage) + + @QtCore.Slot(bool) + def showAddDevicePage(self): + self.showPage(self.addDevicePage) + + @QtCore.Slot(bool) + def showLogPage(self): + self.showPage(self.logPage) + + @QtCore.Slot(bool) + def showPreferencesPage(self): + self.showPage(self.preferencesPage) + + @QtCore.Slot(int) + def on_setting_graph_backlog_changed(self, bl): + for unit in self._plots: + plot = self._plots[unit] + + # Remove the limits first, otherwise the range update would + # be ignored. + plot.view.setLimits(xMin=None, xMax=None) + + # Now change the range, and then use the calculated limits + # (also see the comment in '_getPlot()'). + plot.view.setXRange(-bl, 0, update=True) + r = plot.view.viewRange() + plot.view.setLimits(xMin=r[0][0], xMax=r[0][1]) def _getPlot(self, unit): '''Looks up or creates a new plot for 'unit'.''' @@ -138,14 +377,14 @@ class MainWindow(QtGui.QMainWindow): if unit in self._plots: return self._plots[unit] - # create a new plot for the unit + # Create a new plot for the unit. plot = self.plotwidget.addPlot() plot.yaxis.setLabel(util.quantity_from_unit(unit), units=util.format_unit(unit)) - plot.view.setXRange(-MainWindow.BACKLOG, 0, update=False) + plot.view.setXRange(-settings.graph.backlog.value(), 0, update=False) plot.view.setYRange(-1, 1) plot.view.enableAutoRange(axis=pyqtgraph.ViewBox.YAxis) - # lock to the range calculated by the view using additional padding, - # looks nicer this way + # Lock to the range calculated by the view using additional padding, + # looks nicer this way. r = plot.view.viewRange() plot.view.setLimits(xMin=r[0][0], xMax=r[0][1]) @@ -155,11 +394,11 @@ class MainWindow(QtGui.QMainWindow): def _getCurve(self, plot, deviceID): '''Looks up or creates a new curve for '(plot, deviceID)'.''' - key = (id(plot), deviceID) + key = (plot, deviceID) if key in self._curves: return self._curves[key] - # create a new curve + # Create a new curve. curve = pyqtgraph.PlotDataItem( antialias=True, symbolPen=pyqtgraph.mkPen(QtGui.QColor(QtCore.Qt.black)), @@ -171,40 +410,116 @@ class MainWindow(QtGui.QMainWindow): self._curves[key] = curve return curve - def timerEvent(self, event): - '''Periodically updates all graphs.''' + def _updatePlots(self): + '''Updates all plots.''' + # Loop over all devices and channels. for row in range(self.model.rowCount()): idx = self.model.index(row, 0) - deviceID = self.model.data(idx, datamodel.MeasurementDataModel.idRole) - sampledict = self.model.data(idx, datamodel.MeasurementDataModel.samplesRole) - color = self.model.data(idx, datamodel.MeasurementDataModel.colorRole) - for unit in sampledict: - self._updatePlot(deviceID, unit, sampledict[unit], color) + deviceID = self.model.data(idx, + datamodel.MeasurementDataModel.idRole) + deviceID = tuple(deviceID) # PySide returns a list. + traces = self.model.data(idx, + datamodel.MeasurementDataModel.tracesRole) + + for unit, trace in traces.items(): + now = time.time() + + # Remove old samples. + l = now - settings.graph.backlog.value() + while trace.samples and trace.samples[0][0] < l: + trace.samples.pop(0) + + plot = self._getPlot(unit) + if not plot.visible: + if trace.new: + self.plotwidget.showPlot(plot) + + if plot.visible: + xdata = [s[0] - now for s in trace.samples] + ydata = [s[1] for s in trace.samples] + + color = self.model.data(idx, + datamodel.MeasurementDataModel.colorRole) + + curve = self._getCurve(plot, deviceID) + curve.setPen(pyqtgraph.mkPen(color=color)) + curve.setData(xdata, ydata) + + @QtCore.Slot(multiplotwidget.Plot) + def _on_plotHidden(self, plot): + plotunit = [u for u, p in self._plots.items() if p == plot][0] + + # Mark all traces of all devices/channels with the same unit as the + # plot as "old" ('trace.new = False'). As soon as a new sample arrives + # on one trace, the plot will be shown again. + for row in range(self.model.rowCount()): + idx = self.model.index(row, 0) + traces = self.model.data(idx, datamodel.MeasurementDataModel.tracesRole) - def _updatePlot(self, deviceID, unit, samples, color): - '''Updates the curve of device 'deviceID' and 'unit' with 'samples', - changes the color of the curve to 'color'.''' + for traceunit, trace in traces.items(): + if traceunit == plotunit: + trace.new = False - plot = self._getPlot(unit) - curve = self._getCurve(plot, deviceID) - curve.setPen(pyqtgraph.mkPen(color=color, width=1)) + @QtCore.Slot() + def _stopped(self): + if self._closing: + # The acquisition was stopped by the 'closeEvent()', close the + # window again now that the acquisition has stopped. + self.close() - now = time.time() + def closeEvent(self, event): + if self.acquisition.is_running(): + # Stop the acquisition before closing the window. + self._closing = True + self.start_stop_acquisition() + event.ignore() + else: + settings.mainwindow.size.setValue(self.size()) + settings.mainwindow.pos.setValue(self.pos()) + event.accept() - # remove old samples - l = now - MainWindow.BACKLOG - while samples and samples[0][0] < l: - samples.pop(0) + @QtCore.Slot() + def start_stop_acquisition(self): + if self.acquisition.is_running(): + self.acquisition.stop() + self._plot_update_timer.stop() + self.actionStartStop.setText('Start Acquisition') + self.actionStartStop.setIcon(icons.start) + else: + # Before starting (again), remove all old samples and old curves. + self.model.clear_samples() + + for key in self._curves: + plot, _ = key + curve = self._curves[key] + plot.view.removeItem(curve) + self._curves = {} + + self.acquisition.start() + self._plot_update_timer.start() + self.actionStartStop.setText('Stop Acquisition') + self.actionStartStop.setIcon(icons.stop) - xdata = [s[0] - now for s in samples] - ydata = [s[1] for s in samples] + @QtCore.Slot() + def on_save_log_clicked(self): + filename = QtGui.QFileDialog.getSaveFileName(self, + 'Save Log File', settings.logging.filename.value()) - curve.setData(xdata, ydata) + if not filename: + # User pressed 'cancel'. + return - def closeEvent(self, event): - self.thread.stop() - event.accept() + try: + with open(filename, 'w') as f: + for line in self.logModel.stringList(): + f.write(line) + f.write('\n') + except Exception as e: + QtGui.QMessageBox.critical(self, 'Error saving log file', + 'Unable to save the log messages:\n{}'.format(e)) + + settings.logging.filename.setValue(filename) @QtCore.Slot() def show_about(self): @@ -220,21 +535,11 @@ class MainWindow(QtGui.QMainWindow): This program comes with ABSOLUTELY NO WARRANTY;
for details visit - http://www.gnu.org/licenses/gpl.html + http://www.gnu.org/licenses/gpl.html
+
+ Some icons by + the GNOME project '''.format(self.context.package_version, self.context.lib_version)) QtGui.QMessageBox.about(self, 'About sigrok-meter', text) - - @QtCore.Slot(str) - def error(self, msg): - '''Error handler for the sampling thread.''' - QtGui.QMessageBox.critical(self, 'Error', msg) - self.close() - - @QtCore.Slot(object, int, int) - def modelRowsInserted(self, parent, start, end): - '''Resize the list view to the size of the content.''' - rows = self.model.rowCount() - dh = self.delegate.sizeHint().height() - self.listView.setMinimumHeight(dh * rows)