]> sigrok.org Git - sigrok-meter.git/blame - mainwindow.py
Show log messages in the GUI.
[sigrok-meter.git] / mainwindow.py
CommitLineData
48723bbb
JS
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
2e8c2e6e 22import acquisition
48723bbb 23import datamodel
a6fe45e1 24import datetime
68348e5a 25import icons
f76b9df8 26import multiplotwidget
48723bbb
JS
27import os.path
28import qtcompat
2abf5a93 29import settings
a6fe45e1
JS
30import sigrok.core as sr
31import sys
48723bbb 32import textwrap
f76b9df8
JS
33import time
34import util
48723bbb
JS
35
36QtCore = qtcompat.QtCore
37QtGui = qtcompat.QtGui
f76b9df8 38pyqtgraph = qtcompat.pyqtgraph
48723bbb
JS
39
40class 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
57class MainWindow(QtGui.QMainWindow):
58 '''The main window of the application.'''
59
f76b9df8
JS
60 # Update interval of the plots in milliseconds.
61 UPDATEINTERVAL = 100
62
48723bbb
JS
63 def __init__(self, context, drivers):
64 super(self.__class__, self).__init__()
65
2e8c2e6e
JS
66 # Used to coordinate the stopping of the acquisition and
67 # the closing of the window.
68 self._closing = False
69
48723bbb 70 self.context = context
2e8c2e6e 71 self.drivers = drivers
48723bbb 72
a6fe45e1
JS
73 self.logModel = QtGui.QStringListModel(self)
74 self.context.set_log_callback(self._log_callback)
75
48723bbb
JS
76 self.delegate = datamodel.MultimeterDelegate(self, self.font())
77 self.model = datamodel.MeasurementDataModel(self)
48723bbb 78
68348e5a
JS
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)
48723bbb 89
ac584e86
JS
90 settings.graph.backlog.changed.connect(self.on_setting_graph_backlog_changed)
91
2e8c2e6e
JS
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
68348e5a 107 self.start_stop_acquisition()
48723bbb 108
a6fe45e1
JS
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
68348e5a 134 def _setup_ui(self):
48723bbb 135 self.setWindowTitle('sigrok-meter')
480cdb7b 136 # Resizing the listView below will increase this again.
0e810ddf 137 self.resize(350, 10)
48723bbb 138
68348e5a 139 self.setWindowIcon(QtGui.QIcon(':/logo.png'))
48723bbb 140
68348e5a
JS
141 self._setup_graphPage()
142 self._setup_addDevicePage()
143 self._setup_logPage()
144 self._setup_preferencesPage()
48723bbb 145
68348e5a
JS
146 self._pages = [
147 self.graphPage,
148 self.addDevicePage,
149 self.logPage,
150 self.preferencesPage
151 ]
48723bbb 152
68348e5a
JS
153 self.stackedWidget = QtGui.QStackedWidget(self)
154 for page in self._pages:
155 self.stackedWidget.addWidget(page)
48723bbb 156
68348e5a 157 self._setup_sidebar()
f76b9df8 158
68348e5a
JS
159 self.setCentralWidget(QtGui.QWidget())
160 self.centralWidget().setContentsMargins(0, 0, 0, 0)
f76b9df8 161
68348e5a
JS
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
2abf5a93
JS
168 self.resize(settings.mainwindow.size.value())
169 if settings.mainwindow.pos.value():
170 self.move(settings.mainwindow.pos.value())
68348e5a
JS
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
a6fe45e1
JS
186 actionLog = self.sideBar.addAction('Logs')
187 actionLog.setCheckable(True)
188 actionLog.setIcon(icons.log)
189 actionLog.triggered.connect(self.showLogPage)
68348e5a 190
ac584e86
JS
191 actionPreferences = self.sideBar.addAction('Preferences')
192 actionPreferences.setCheckable(True)
193 actionPreferences.setIcon(icons.preferences)
194 actionPreferences.triggered.connect(self.showPreferencesPage)
68348e5a
JS
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)
a6fe45e1 200 self.actionGroup.addAction(actionLog)
ac584e86 201 self.actionGroup.addAction(actionPreferences)
68348e5a
JS
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)
f76b9df8 218
68348e5a
JS
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())
f76b9df8 259
68348e5a
JS
260 self.plotwidget = multiplotwidget.MultiPlotWidget(self)
261 self.plotwidget.plotHidden.connect(self._on_plotHidden)
f76b9df8 262
68348e5a
JS
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):
a6fe45e1
JS
276 self.logView = QtGui.QListView(self)
277 self.logView.setModel(self.logModel)
278 self.logView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
279 self.logView.setSelectionMode(QtGui.QAbstractItemView.NoSelection)
280
68348e5a
JS
281 self.logPage = QtGui.QWidget(self)
282 layout = QtGui.QVBoxLayout(self.logPage)
a6fe45e1 283 layout.addWidget(self.logView)
68348e5a
JS
284
285 def _setup_preferencesPage(self):
286 self.preferencesPage = QtGui.QWidget(self)
ac584e86
JS
287 layout = QtGui.QGridLayout(self.preferencesPage)
288
289 layout.addWidget(QtGui.QLabel('<b>Graph</b>'), 0, 0)
290 layout.addWidget(QtGui.QLabel('Recording time (seconds):'), 1, 0)
291
292 spin = QtGui.QSpinBox(self)
293 spin.setMinimum(10)
294 spin.setMaximum(3600)
295 spin.setSingleStep(10)
296 spin.setValue(settings.graph.backlog.value())
297 spin.valueChanged[int].connect(settings.graph.backlog.setValue)
298 layout.addWidget(spin, 1, 1)
299
a6fe45e1
JS
300 layout.addWidget(QtGui.QLabel('<b>Logging</b>'), 2, 0)
301 layout.addWidget(QtGui.QLabel('Log level:'), 3, 0)
302
303 cbox = QtGui.QComboBox()
304 descriptions = [
305 'no messages at all',
306 'error messages',
307 'warnings',
308 'informational messages',
309 'debug messages',
310 'very noisy debug messages'
311 ]
312 for i, desc in enumerate(descriptions):
313 level = sr.LogLevel.get(i)
314 text = '{} ({})'.format(level.name, desc)
315 # The numeric log level corresponds to the index of the text in the
316 # combo box. Should this ever change, we could use the 'userData'
317 # that can also be stored in the item.
318 cbox.addItem(text)
319
320 cbox.setCurrentIndex(settings.logging.level.value().id)
321 cbox.currentIndexChanged[int].connect(
322 (lambda i: settings.logging.level.setValue(sr.LogLevel.get(i))))
323 layout.addWidget(cbox, 3, 1)
324
325 layout.addWidget(QtGui.QLabel('Number of lines to log:'), 4, 0)
326
327 spin = QtGui.QSpinBox(self)
328 spin.setMinimum(100)
329 spin.setMaximum(10 * 1000 * 1000)
330 spin.setSingleStep(100)
331 spin.setValue(settings.logging.lines.value())
332 spin.valueChanged[int].connect(settings.logging.lines.setValue)
333 layout.addWidget(spin, 4, 1)
334
ac584e86 335 layout.setRowStretch(layout.rowCount(), 100)
68348e5a
JS
336
337 def showPage(self, page):
338 self.stackedWidget.setCurrentIndex(self._pages.index(page))
339
340 @QtCore.Slot(bool)
341 def showGraphPage(self):
342 self.showPage(self.graphPage)
343
344 @QtCore.Slot(bool)
345 def showAddDevicePage(self):
346 self.showPage(self.addDevicePage)
347
348 @QtCore.Slot(bool)
349 def showLogPage(self):
350 self.showPage(self.logPage)
351
352 @QtCore.Slot(bool)
353 def showPreferencesPage(self):
354 self.showPage(self.preferencesPage)
2e8c2e6e 355
ac584e86
JS
356 @QtCore.Slot(int)
357 def on_setting_graph_backlog_changed(self, bl):
358 for unit in self._plots:
359 plot = self._plots[unit]
360
361 # Remove the limits first, otherwise the range update would
362 # be ignored.
363 plot.view.setLimits(xMin=None, xMax=None)
364
365 # Now change the range, and then use the calculated limits
366 # (also see the comment in '_getPlot()').
367 plot.view.setXRange(-bl, 0, update=True)
368 r = plot.view.viewRange()
369 plot.view.setLimits(xMin=r[0][0], xMax=r[0][1])
370
f76b9df8
JS
371 def _getPlot(self, unit):
372 '''Looks up or creates a new plot for 'unit'.'''
373
374 if unit in self._plots:
375 return self._plots[unit]
376
377 # create a new plot for the unit
378 plot = self.plotwidget.addPlot()
379 plot.yaxis.setLabel(util.quantity_from_unit(unit), units=util.format_unit(unit))
ac584e86 380 plot.view.setXRange(-settings.graph.backlog.value(), 0, update=False)
f76b9df8
JS
381 plot.view.setYRange(-1, 1)
382 plot.view.enableAutoRange(axis=pyqtgraph.ViewBox.YAxis)
383 # lock to the range calculated by the view using additional padding,
384 # looks nicer this way
385 r = plot.view.viewRange()
386 plot.view.setLimits(xMin=r[0][0], xMax=r[0][1])
387
388 self._plots[unit] = plot
389 return plot
390
391 def _getCurve(self, plot, deviceID):
392 '''Looks up or creates a new curve for '(plot, deviceID)'.'''
393
68348e5a 394 key = (plot, deviceID)
f76b9df8
JS
395 if key in self._curves:
396 return self._curves[key]
397
398 # create a new curve
3010b5a0
JS
399 curve = pyqtgraph.PlotDataItem(
400 antialias=True,
401 symbolPen=pyqtgraph.mkPen(QtGui.QColor(QtCore.Qt.black)),
402 symbolBrush=pyqtgraph.mkBrush(QtGui.QColor(QtCore.Qt.black)),
403 symbolSize=1
404 )
f76b9df8
JS
405 plot.view.addItem(curve)
406
407 self._curves[key] = curve
408 return curve
409
d0aa45b4
JS
410 def _updatePlots(self):
411 '''Updates all plots.'''
f76b9df8 412
d0aa45b4
JS
413 # loop over all devices and channels
414 for row in range(self.model.rowCount()):
415 idx = self.model.index(row, 0)
416 deviceID = self.model.data(idx,
417 datamodel.MeasurementDataModel.idRole)
1879265a 418 deviceID = tuple(deviceID) # PySide returns a list.
d0aa45b4
JS
419 traces = self.model.data(idx,
420 datamodel.MeasurementDataModel.tracesRole)
421
422 for unit, trace in traces.items():
423 now = time.time()
424
425 # remove old samples
ac584e86 426 l = now - settings.graph.backlog.value()
d0aa45b4
JS
427 while trace.samples and trace.samples[0][0] < l:
428 trace.samples.pop(0)
429
430 plot = self._getPlot(unit)
431 if not plot.visible:
432 if trace.new:
433 self.plotwidget.showPlot(plot)
434
435 if plot.visible:
436 xdata = [s[0] - now for s in trace.samples]
437 ydata = [s[1] for s in trace.samples]
438
439 color = self.model.data(idx,
440 datamodel.MeasurementDataModel.colorRole)
441
442 curve = self._getCurve(plot, deviceID)
443 curve.setPen(pyqtgraph.mkPen(color=color))
444 curve.setData(xdata, ydata)
445
446 @QtCore.Slot(multiplotwidget.Plot)
447 def _on_plotHidden(self, plot):
448 plotunit = [u for u, p in self._plots.items() if p == plot][0]
449
450 # Mark all traces of all devices/channels with the same unit as the
451 # plot as "old" ('trace.new = False'). As soon as a new sample arrives
452 # on one trace, the plot will be shown again
453 for row in range(self.model.rowCount()):
454 idx = self.model.index(row, 0)
455 traces = self.model.data(idx, datamodel.MeasurementDataModel.tracesRole)
f76b9df8 456
d0aa45b4
JS
457 for traceunit, trace in traces.items():
458 if traceunit == plotunit:
459 trace.new = False
48723bbb 460
2e8c2e6e
JS
461 @QtCore.Slot()
462 def _stopped(self):
463 if self._closing:
68348e5a
JS
464 # The acquisition was stopped by the 'closeEvent()', close the
465 # window again now that the acquisition has stopped.
2e8c2e6e
JS
466 self.close()
467
48723bbb 468 def closeEvent(self, event):
2e8c2e6e 469 if self.acquisition.is_running():
68348e5a 470 # Stop the acquisition before closing the window.
2e8c2e6e 471 self._closing = True
68348e5a 472 self.start_stop_acquisition()
2e8c2e6e
JS
473 event.ignore()
474 else:
2abf5a93
JS
475 settings.mainwindow.size.setValue(self.size())
476 settings.mainwindow.pos.setValue(self.pos())
2e8c2e6e 477 event.accept()
48723bbb 478
68348e5a
JS
479 @QtCore.Slot()
480 def start_stop_acquisition(self):
481 if self.acquisition.is_running():
482 self.acquisition.stop()
483 self._plot_update_timer.stop()
484 self.actionStartStop.setText('Start Acquisition')
485 self.actionStartStop.setIcon(icons.start)
486 else:
487 # before starting (again), remove all old samples and old curves
488 self.model.clear_samples()
489
490 for key in self._curves:
491 plot, _ = key
492 curve = self._curves[key]
493 plot.view.removeItem(curve)
494 self._curves = {}
495
496 self.acquisition.start()
497 self._plot_update_timer.start()
498 self.actionStartStop.setText('Stop Acquisition')
499 self.actionStartStop.setIcon(icons.stop)
500
48723bbb
JS
501 @QtCore.Slot()
502 def show_about(self):
503 text = textwrap.dedent('''\
504 <div align="center">
480cdb7b
UH
505 <b>sigrok-meter 0.1.0</b><br/><br/>
506 Using libsigrok {} (lib version {}).<br/><br/>
48723bbb
JS
507 <a href='http://www.sigrok.org'>
508 http://www.sigrok.org</a><br/>
509 <br/>
480cdb7b
UH
510 License: GNU GPL, version 3 or later<br/>
511 <br/>
48723bbb
JS
512 This program comes with ABSOLUTELY NO WARRANTY;<br/>
513 for details visit
514 <a href='http://www.gnu.org/licenses/gpl.html'>
68348e5a
JS
515 http://www.gnu.org/licenses/gpl.html</a><br/>
516 <br/>
517 Some icons by <a href='https://www.gnome.org'>
518 the GNOME project</a>
48723bbb
JS
519 </div>
520 '''.format(self.context.package_version, self.context.lib_version))
521
522 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)