]> sigrok.org Git - sigrok-meter.git/blob - mainwindow.py
be39a4ec59f31a5ffb23efb51412d07682a1493b
[sigrok-meter.git] / mainwindow.py
1 ##
2 ## This file is part of the sigrok-meter project.
3 ##
4 ## Copyright (C) 2013 Uwe Hermann <uwe@hermann-uwe.de>
5 ## Copyright (C) 2014 Jens Steinhauser <jens.steinhauser@gmail.com>
6 ##
7 ## This program is free software; you can redistribute it and/or modify
8 ## it under the terms of the GNU General Public License as published by
9 ## the Free Software Foundation; either version 2 of the License, or
10 ## (at your option) any later version.
11 ##
12 ## This program is distributed in the hope that it will be useful,
13 ## but WITHOUT ANY WARRANTY; without even the implied warranty of
14 ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 ## GNU General Public License for more details.
16 ##
17 ## You should have received a copy of the GNU General Public License
18 ## along with this program; if not, write to the Free Software
19 ## Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301 USA
20 ##
21
22 import acquisition
23 import datamodel
24 import datetime
25 import icons
26 import multiplotwidget
27 import os.path
28 import qtcompat
29 import settings
30 import sigrok.core as sr
31 import sys
32 import textwrap
33 import time
34 import util
35
36 QtCore = qtcompat.QtCore
37 QtGui = qtcompat.QtGui
38 pyqtgraph = qtcompat.pyqtgraph
39
40 class EmptyMessageListView(QtGui.QListView):
41     '''List view that shows a message if the model im empty.'''
42
43     def __init__(self, message, parent=None):
44         super(self.__class__, self).__init__(parent)
45
46         self._message = message
47
48     def paintEvent(self, event):
49         m = self.model()
50         if m and m.rowCount():
51             super(self.__class__, self).paintEvent(event)
52             return
53
54         painter = QtGui.QPainter(self.viewport())
55         painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
56
57 class MainWindow(QtGui.QMainWindow):
58     '''The main window of the application.'''
59
60     # Update interval of the plots in milliseconds.
61     UPDATEINTERVAL = 100
62
63     def __init__(self, context, drivers):
64         super(self.__class__, self).__init__()
65
66         # Used to coordinate the stopping of the acquisition and
67         # the closing of the window.
68         self._closing = False
69
70         self.context = context
71         self.drivers = drivers
72
73         self.logModel = QtGui.QStringListModel(self)
74         self.context.set_log_callback(self._log_callback)
75
76         self.delegate = datamodel.MultimeterDelegate(self, self.font())
77         self.model = datamodel.MeasurementDataModel(self)
78
79         # Maps from 'unit' to the corresponding plot.
80         self._plots = {}
81         # Maps from '(plot, device)' to the corresponding curve.
82         self._curves = {}
83
84         self._setup_ui()
85
86         self._plot_update_timer = QtCore.QTimer()
87         self._plot_update_timer.setInterval(MainWindow.UPDATEINTERVAL)
88         self._plot_update_timer.timeout.connect(self._updatePlots)
89
90         settings.graph.backlog.changed.connect(self.on_setting_graph_backlog_changed)
91
92         QtCore.QTimer.singleShot(0, self._start_acquisition)
93
94     def _start_acquisition(self):
95         self.acquisition = acquisition.Acquisition(self.context)
96         self.acquisition.measured.connect(self.model.update)
97         self.acquisition.stopped.connect(self._stopped)
98
99         try:
100             for (ds, cs) in self.drivers:
101                 self.acquisition.add_device(ds, cs)
102         except Exception as e:
103             QtGui.QMessageBox.critical(self, 'Error', str(e))
104             self.close()
105             return
106
107         self.start_stop_acquisition()
108
109     def _log_callback(self, level, message):
110         if level.id > settings.logging.level.value().id:
111             return
112
113         t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')
114         message = '[{}] sr: {}'.format(t, message)
115
116         sys.stderr.write(message + '\n')
117
118         scrollBar = self.logView.verticalScrollBar()
119         bottom = scrollBar.value() == scrollBar.maximum()
120
121         rows = self.logModel.rowCount()
122         maxrows = settings.logging.lines.value()
123         while rows > maxrows:
124             self.logModel.removeRows(0, 1)
125             rows -= 1
126
127         if self.logModel.insertRow(rows):
128             index = self.logModel.index(rows)
129             self.logModel.setData(index, message, QtCore.Qt.DisplayRole)
130
131             if bottom:
132                 self.logView.scrollToBottom()
133
134     def _setup_ui(self):
135         self.setWindowTitle('sigrok-meter')
136         # Resizing the listView below will increase this again.
137         self.resize(350, 10)
138
139         self.setWindowIcon(QtGui.QIcon(':/logo.png'))
140
141         self._setup_graphPage()
142         self._setup_addDevicePage()
143         self._setup_logPage()
144         self._setup_preferencesPage()
145
146         self._pages = [
147             self.graphPage,
148             self.addDevicePage,
149             self.logPage,
150             self.preferencesPage
151         ]
152
153         self.stackedWidget = QtGui.QStackedWidget(self)
154         for page in self._pages:
155             self.stackedWidget.addWidget(page)
156
157         self._setup_sidebar()
158
159         self.setCentralWidget(QtGui.QWidget())
160         self.centralWidget().setContentsMargins(0, 0, 0, 0)
161
162         layout = QtGui.QHBoxLayout(self.centralWidget())
163         layout.addWidget(self.sideBar)
164         layout.addWidget(self.stackedWidget)
165         layout.setSpacing(0)
166         layout.setContentsMargins(0, 0, 0, 0)
167
168         self.resize(settings.mainwindow.size.value())
169         if settings.mainwindow.pos.value():
170             self.move(settings.mainwindow.pos.value())
171
172     def _setup_sidebar(self):
173         self.sideBar = QtGui.QToolBar(self)
174         self.sideBar.setOrientation(QtCore.Qt.Vertical)
175
176         actionGraph = self.sideBar.addAction('Instantaneous Values and Graphs')
177         actionGraph.setCheckable(True)
178         actionGraph.setIcon(icons.graph)
179         actionGraph.triggered.connect(self.showGraphPage)
180
181         #actionAdd = self.sideBar.addAction('Add Device')
182         #actionAdd.setCheckable(True)
183         #actionAdd.setIcon(icons.add)
184         #actionAdd.triggered.connect(self.showAddDevicePage)
185
186         actionLog = self.sideBar.addAction('Logs')
187         actionLog.setCheckable(True)
188         actionLog.setIcon(icons.log)
189         actionLog.triggered.connect(self.showLogPage)
190
191         actionPreferences = self.sideBar.addAction('Preferences')
192         actionPreferences.setCheckable(True)
193         actionPreferences.setIcon(icons.preferences)
194         actionPreferences.triggered.connect(self.showPreferencesPage)
195
196         # make the buttons at the top exclusive
197         self.actionGroup = QtGui.QActionGroup(self)
198         self.actionGroup.addAction(actionGraph)
199         #self.actionGroup.addAction(actionAdd)
200         self.actionGroup.addAction(actionLog)
201         self.actionGroup.addAction(actionPreferences)
202
203         # show graph at startup
204         actionGraph.setChecked(True)
205
206         # fill space between buttons on the top and on the bottom
207         fill = QtGui.QWidget(self)
208         fill.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Expanding)
209         self.sideBar.addWidget(fill)
210
211         self.actionStartStop = self.sideBar.addAction('Start Acquisition')
212         self.actionStartStop.setIcon(icons.start)
213         self.actionStartStop.triggered.connect(self.start_stop_acquisition)
214
215         actionAbout = self.sideBar.addAction('About')
216         actionAbout.setIcon(icons.about)
217         actionAbout.triggered.connect(self.show_about)
218
219         actionQuit = self.sideBar.addAction('Quit')
220         actionQuit.setIcon(icons.exit)
221         actionQuit.triggered.connect(self.close)
222
223         s = self.style().pixelMetric(QtGui.QStyle.PM_LargeIconSize)
224         self.sideBar.setIconSize(QtCore.QSize(s, s))
225
226         self.sideBar.setStyleSheet('''
227             QToolBar {
228                 background-color: white;
229                 margin: 0px;
230                 border: 0px;
231                 border-right: 1px solid black;
232             }
233
234             QToolButton {
235                 padding: 10px;
236                 border: 0px;
237                 border-right: 1px solid black;
238             }
239
240             QToolButton:checked,
241             QToolButton[checkable="false"]:hover {
242                 background-color: #c0d0e8;
243             }
244         ''')
245
246     def _setup_graphPage(self):
247         listView = EmptyMessageListView('waiting for data...')
248         listView.setFrameShape(QtGui.QFrame.NoFrame)
249         listView.viewport().setBackgroundRole(QtGui.QPalette.Window)
250         listView.viewport().setAutoFillBackground(True)
251         listView.setMinimumWidth(260)
252         listView.setSelectionMode(QtGui.QAbstractItemView.NoSelection)
253         listView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
254         listView.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel)
255         listView.setItemDelegate(self.delegate)
256         listView.setModel(self.model)
257         listView.setUniformItemSizes(True)
258         listView.setMinimumSize(self.delegate.sizeHint())
259
260         self.plotwidget = multiplotwidget.MultiPlotWidget(self)
261         self.plotwidget.plotHidden.connect(self._on_plotHidden)
262
263         self.graphPage = QtGui.QSplitter(QtCore.Qt.Horizontal, self)
264         self.graphPage.addWidget(listView)
265         self.graphPage.addWidget(self.plotwidget)
266         self.graphPage.setStretchFactor(0, 0)
267         self.graphPage.setStretchFactor(1, 1)
268
269     def _setup_addDevicePage(self):
270         self.addDevicePage = QtGui.QWidget(self)
271         layout = QtGui.QVBoxLayout(self.addDevicePage)
272         label = QtGui.QLabel('add device page')
273         layout.addWidget(label)
274
275     def _setup_logPage(self):
276         self.logPage = QtGui.QWidget(self)
277         layout = QtGui.QVBoxLayout(self.logPage)
278
279         self.logView = QtGui.QListView(self)
280         self.logView.setModel(self.logModel)
281         self.logView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
282         self.logView.setSelectionMode(QtGui.QAbstractItemView.NoSelection)
283         layout.addWidget(self.logView)
284
285         btn = QtGui.QPushButton('Save to file...', self)
286         btn.clicked.connect(self.on_save_log_clicked)
287         layout.addWidget(btn)
288
289     def _setup_preferencesPage(self):
290         self.preferencesPage = QtGui.QWidget(self)
291         layout = QtGui.QGridLayout(self.preferencesPage)
292
293         layout.addWidget(QtGui.QLabel('<b>Graph</b>'), 0, 0)
294         layout.addWidget(QtGui.QLabel('Recording time (seconds):'), 1, 0)
295
296         spin = QtGui.QSpinBox(self)
297         spin.setMinimum(10)
298         spin.setMaximum(3600)
299         spin.setSingleStep(10)
300         spin.setValue(settings.graph.backlog.value())
301         spin.valueChanged[int].connect(settings.graph.backlog.setValue)
302         layout.addWidget(spin, 1, 1)
303
304         layout.addWidget(QtGui.QLabel('<b>Logging</b>'), 2, 0)
305         layout.addWidget(QtGui.QLabel('Log level:'), 3, 0)
306
307         cbox = QtGui.QComboBox()
308         descriptions = [
309             'no messages at all',
310             'error messages',
311             'warnings',
312             'informational messages',
313             'debug messages',
314             'very noisy debug messages'
315         ]
316         for i, desc in enumerate(descriptions):
317             level = sr.LogLevel.get(i)
318             text = '{} ({})'.format(level.name, desc)
319             # The numeric log level corresponds to the index of the text in the
320             # combo box. Should this ever change, we could use the 'userData'
321             # that can also be stored in the item.
322             cbox.addItem(text)
323
324         cbox.setCurrentIndex(settings.logging.level.value().id)
325         cbox.currentIndexChanged[int].connect(
326             (lambda i: settings.logging.level.setValue(sr.LogLevel.get(i))))
327         layout.addWidget(cbox, 3, 1)
328
329         layout.addWidget(QtGui.QLabel('Number of lines to log:'), 4, 0)
330
331         spin = QtGui.QSpinBox(self)
332         spin.setMinimum(100)
333         spin.setMaximum(10 * 1000 * 1000)
334         spin.setSingleStep(100)
335         spin.setValue(settings.logging.lines.value())
336         spin.valueChanged[int].connect(settings.logging.lines.setValue)
337         layout.addWidget(spin, 4, 1)
338
339         layout.setRowStretch(layout.rowCount(), 100)
340
341     def showPage(self, page):
342         self.stackedWidget.setCurrentIndex(self._pages.index(page))
343
344     @QtCore.Slot(bool)
345     def showGraphPage(self):
346         self.showPage(self.graphPage)
347
348     @QtCore.Slot(bool)
349     def showAddDevicePage(self):
350         self.showPage(self.addDevicePage)
351
352     @QtCore.Slot(bool)
353     def showLogPage(self):
354         self.showPage(self.logPage)
355
356     @QtCore.Slot(bool)
357     def showPreferencesPage(self):
358         self.showPage(self.preferencesPage)
359
360     @QtCore.Slot(int)
361     def on_setting_graph_backlog_changed(self, bl):
362         for unit in self._plots:
363             plot = self._plots[unit]
364
365             # Remove the limits first, otherwise the range update would
366             # be ignored.
367             plot.view.setLimits(xMin=None, xMax=None)
368
369             # Now change the range, and then use the calculated limits
370             # (also see the comment in '_getPlot()').
371             plot.view.setXRange(-bl, 0, update=True)
372             r = plot.view.viewRange()
373             plot.view.setLimits(xMin=r[0][0], xMax=r[0][1])
374
375     def _getPlot(self, unit):
376         '''Looks up or creates a new plot for 'unit'.'''
377
378         if unit in self._plots:
379             return self._plots[unit]
380
381         # create a new plot for the unit
382         plot = self.plotwidget.addPlot()
383         plot.yaxis.setLabel(util.quantity_from_unit(unit), units=util.format_unit(unit))
384         plot.view.setXRange(-settings.graph.backlog.value(), 0, update=False)
385         plot.view.setYRange(-1, 1)
386         plot.view.enableAutoRange(axis=pyqtgraph.ViewBox.YAxis)
387         # lock to the range calculated by the view using additional padding,
388         # looks nicer this way
389         r = plot.view.viewRange()
390         plot.view.setLimits(xMin=r[0][0], xMax=r[0][1])
391
392         self._plots[unit] = plot
393         return plot
394
395     def _getCurve(self, plot, deviceID):
396         '''Looks up or creates a new curve for '(plot, deviceID)'.'''
397
398         key = (plot, deviceID)
399         if key in self._curves:
400             return self._curves[key]
401
402         # create a new curve
403         curve = pyqtgraph.PlotDataItem(
404             antialias=True,
405             symbolPen=pyqtgraph.mkPen(QtGui.QColor(QtCore.Qt.black)),
406             symbolBrush=pyqtgraph.mkBrush(QtGui.QColor(QtCore.Qt.black)),
407             symbolSize=1
408         )
409         plot.view.addItem(curve)
410
411         self._curves[key] = curve
412         return curve
413
414     def _updatePlots(self):
415         '''Updates all plots.'''
416
417         # loop over all devices and channels
418         for row in range(self.model.rowCount()):
419             idx = self.model.index(row, 0)
420             deviceID = self.model.data(idx,
421                             datamodel.MeasurementDataModel.idRole)
422             deviceID = tuple(deviceID) # PySide returns a list.
423             traces = self.model.data(idx,
424                             datamodel.MeasurementDataModel.tracesRole)
425
426             for unit, trace in traces.items():
427                 now = time.time()
428
429                 # remove old samples
430                 l = now - settings.graph.backlog.value()
431                 while trace.samples and trace.samples[0][0] < l:
432                     trace.samples.pop(0)
433
434                 plot = self._getPlot(unit)
435                 if not plot.visible:
436                     if trace.new:
437                         self.plotwidget.showPlot(plot)
438
439                 if plot.visible:
440                     xdata = [s[0] - now for s in trace.samples]
441                     ydata = [s[1]       for s in trace.samples]
442
443                     color = self.model.data(idx,
444                                 datamodel.MeasurementDataModel.colorRole)
445
446                     curve = self._getCurve(plot, deviceID)
447                     curve.setPen(pyqtgraph.mkPen(color=color))
448                     curve.setData(xdata, ydata)
449
450     @QtCore.Slot(multiplotwidget.Plot)
451     def _on_plotHidden(self, plot):
452         plotunit = [u for u, p in self._plots.items() if p == plot][0]
453
454         # Mark all traces of all devices/channels with the same unit as the
455         # plot as "old" ('trace.new = False'). As soon as a new sample arrives
456         # on one trace, the plot will be shown again
457         for row in range(self.model.rowCount()):
458             idx = self.model.index(row, 0)
459             traces = self.model.data(idx, datamodel.MeasurementDataModel.tracesRole)
460
461             for traceunit, trace in traces.items():
462                 if traceunit == plotunit:
463                     trace.new = False
464
465     @QtCore.Slot()
466     def _stopped(self):
467         if self._closing:
468             # The acquisition was stopped by the 'closeEvent()', close the
469             # window again now that the acquisition has stopped.
470             self.close()
471
472     def closeEvent(self, event):
473         if self.acquisition.is_running():
474             # Stop the acquisition before closing the window.
475             self._closing = True
476             self.start_stop_acquisition()
477             event.ignore()
478         else:
479             settings.mainwindow.size.setValue(self.size())
480             settings.mainwindow.pos.setValue(self.pos())
481             event.accept()
482
483     @QtCore.Slot()
484     def start_stop_acquisition(self):
485         if self.acquisition.is_running():
486             self.acquisition.stop()
487             self._plot_update_timer.stop()
488             self.actionStartStop.setText('Start Acquisition')
489             self.actionStartStop.setIcon(icons.start)
490         else:
491             # before starting (again), remove all old samples and old curves
492             self.model.clear_samples()
493
494             for key in self._curves:
495                 plot, _ = key
496                 curve = self._curves[key]
497                 plot.view.removeItem(curve)
498             self._curves = {}
499
500             self.acquisition.start()
501             self._plot_update_timer.start()
502             self.actionStartStop.setText('Stop Acquisition')
503             self.actionStartStop.setIcon(icons.stop)
504
505     @QtCore.Slot()
506     def on_save_log_clicked(self):
507         filename = QtGui.QFileDialog.getSaveFileName(self,
508                     'Save Log File', settings.logging.filename.value())
509
510         if not filename:
511             # User pressed 'cancel'.
512             return
513
514         try:
515             with open(filename, 'w') as f:
516                 for line in self.logModel.stringList():
517                     f.write(line)
518                     f.write('\n')
519         except Exception as e:
520             QtGui.QMessageBox.critical(self, 'Error saving log file',
521                'Unable to save the log messages:\n{}'.format(e))
522
523         settings.logging.filename.setValue(filename)
524
525     @QtCore.Slot()
526     def show_about(self):
527         text = textwrap.dedent('''\
528             <div align="center">
529                 <b>sigrok-meter 0.1.0</b><br/><br/>
530                 Using libsigrok {} (lib version {}).<br/><br/>
531                 <a href='http://www.sigrok.org'>
532                          http://www.sigrok.org</a><br/>
533                 <br/>
534                 License: GNU GPL, version 3 or later<br/>
535                 <br/>
536                 This program comes with ABSOLUTELY NO WARRANTY;<br/>
537                 for details visit
538                 <a href='http://www.gnu.org/licenses/gpl.html'>
539                          http://www.gnu.org/licenses/gpl.html</a><br/>
540                 <br/>
541                 Some icons by <a href='https://www.gnome.org'>
542                               the GNOME project</a>
543             </div>
544         '''.format(self.context.package_version, self.context.lib_version))
545
546         QtGui.QMessageBox.about(self, 'About sigrok-meter', text)