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