]> sigrok.org Git - sigrok-meter.git/blame - mainwindow.py
license: remove FSF postal address from boiler plate license text
[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
0b63748b 18## along with this program; if not, see <http://www.gnu.org/licenses/>.
48723bbb
JS
19##
20
2e8c2e6e 21import acquisition
48723bbb 22import datamodel
a6fe45e1 23import datetime
68348e5a 24import icons
f76b9df8 25import multiplotwidget
48723bbb
JS
26import os.path
27import qtcompat
2abf5a93 28import settings
a6fe45e1
JS
29import sigrok.core as sr
30import sys
48723bbb 31import textwrap
f76b9df8
JS
32import time
33import util
48723bbb
JS
34
35QtCore = qtcompat.QtCore
36QtGui = qtcompat.QtGui
f76b9df8 37pyqtgraph = qtcompat.pyqtgraph
48723bbb
JS
38
39class EmptyMessageListView(QtGui.QListView):
911ab26e 40 '''List view that shows a message if the model is empty.'''
48723bbb
JS
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
56class MainWindow(QtGui.QMainWindow):
57 '''The main window of the application.'''
58
f76b9df8
JS
59 # Update interval of the plots in milliseconds.
60 UPDATEINTERVAL = 100
61
48723bbb
JS
62 def __init__(self, context, drivers):
63 super(self.__class__, self).__init__()
64
2e8c2e6e
JS
65 # Used to coordinate the stopping of the acquisition and
66 # the closing of the window.
67 self._closing = False
68
48723bbb 69 self.context = context
2e8c2e6e 70 self.drivers = drivers
48723bbb 71
a6fe45e1
JS
72 self.logModel = QtGui.QStringListModel(self)
73 self.context.set_log_callback(self._log_callback)
74
48723bbb
JS
75 self.delegate = datamodel.MultimeterDelegate(self, self.font())
76 self.model = datamodel.MeasurementDataModel(self)
48723bbb 77
68348e5a
JS
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)
48723bbb 88
ac584e86
JS
89 settings.graph.backlog.changed.connect(self.on_setting_graph_backlog_changed)
90
2e8c2e6e
JS
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
68348e5a 106 self.start_stop_acquisition()
48723bbb 107
a6fe45e1
JS
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
68348e5a 133 def _setup_ui(self):
48723bbb 134 self.setWindowTitle('sigrok-meter')
480cdb7b 135 # Resizing the listView below will increase this again.
0e810ddf 136 self.resize(350, 10)
48723bbb 137
68348e5a 138 self.setWindowIcon(QtGui.QIcon(':/logo.png'))
48723bbb 139
68348e5a
JS
140 self._setup_graphPage()
141 self._setup_addDevicePage()
142 self._setup_logPage()
143 self._setup_preferencesPage()
48723bbb 144
68348e5a
JS
145 self._pages = [
146 self.graphPage,
147 self.addDevicePage,
148 self.logPage,
149 self.preferencesPage
150 ]
48723bbb 151
68348e5a
JS
152 self.stackedWidget = QtGui.QStackedWidget(self)
153 for page in self._pages:
154 self.stackedWidget.addWidget(page)
48723bbb 155
68348e5a 156 self._setup_sidebar()
f76b9df8 157
68348e5a
JS
158 self.setCentralWidget(QtGui.QWidget())
159 self.centralWidget().setContentsMargins(0, 0, 0, 0)
f76b9df8 160
68348e5a
JS
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
2abf5a93
JS
167 self.resize(settings.mainwindow.size.value())
168 if settings.mainwindow.pos.value():
169 self.move(settings.mainwindow.pos.value())
68348e5a
JS
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
a6fe45e1
JS
185 actionLog = self.sideBar.addAction('Logs')
186 actionLog.setCheckable(True)
187 actionLog.setIcon(icons.log)
188 actionLog.triggered.connect(self.showLogPage)
68348e5a 189
ac584e86
JS
190 actionPreferences = self.sideBar.addAction('Preferences')
191 actionPreferences.setCheckable(True)
192 actionPreferences.setIcon(icons.preferences)
193 actionPreferences.triggered.connect(self.showPreferencesPage)
68348e5a 194
911ab26e 195 # Make the buttons at the top exclusive.
68348e5a
JS
196 self.actionGroup = QtGui.QActionGroup(self)
197 self.actionGroup.addAction(actionGraph)
198 #self.actionGroup.addAction(actionAdd)
a6fe45e1 199 self.actionGroup.addAction(actionLog)
ac584e86 200 self.actionGroup.addAction(actionPreferences)
68348e5a 201
911ab26e 202 # Show graph at startup.
68348e5a
JS
203 actionGraph.setChecked(True)
204
911ab26e 205 # Fill space between buttons on the top and on the bottom.
68348e5a
JS
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)
f76b9df8 217
68348e5a
JS
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())
f76b9df8 258
68348e5a
JS
259 self.plotwidget = multiplotwidget.MultiPlotWidget(self)
260 self.plotwidget.plotHidden.connect(self._on_plotHidden)
f76b9df8 261
68348e5a
JS
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):
c46081ed
JS
275 self.logPage = QtGui.QWidget(self)
276 layout = QtGui.QVBoxLayout(self.logPage)
277
a6fe45e1
JS
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)
a6fe45e1 282 layout.addWidget(self.logView)
68348e5a 283
c46081ed
JS
284 btn = QtGui.QPushButton('Save to file...', self)
285 btn.clicked.connect(self.on_save_log_clicked)
286 layout.addWidget(btn)
287
68348e5a
JS
288 def _setup_preferencesPage(self):
289 self.preferencesPage = QtGui.QWidget(self)
ac584e86
JS
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
a6fe45e1
JS
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
ac584e86 338 layout.setRowStretch(layout.rowCount(), 100)
68348e5a
JS
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)
2e8c2e6e 358
ac584e86
JS
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
f76b9df8
JS
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
911ab26e 380 # Create a new plot for the unit.
f76b9df8
JS
381 plot = self.plotwidget.addPlot()
382 plot.yaxis.setLabel(util.quantity_from_unit(unit), units=util.format_unit(unit))
ac584e86 383 plot.view.setXRange(-settings.graph.backlog.value(), 0, update=False)
f76b9df8
JS
384 plot.view.setYRange(-1, 1)
385 plot.view.enableAutoRange(axis=pyqtgraph.ViewBox.YAxis)
911ab26e
UH
386 # Lock to the range calculated by the view using additional padding,
387 # looks nicer this way.
f76b9df8
JS
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
68348e5a 397 key = (plot, deviceID)
f76b9df8
JS
398 if key in self._curves:
399 return self._curves[key]
400
911ab26e 401 # Create a new curve.
3010b5a0
JS
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 )
f76b9df8
JS
408 plot.view.addItem(curve)
409
410 self._curves[key] = curve
411 return curve
412
d0aa45b4
JS
413 def _updatePlots(self):
414 '''Updates all plots.'''
f76b9df8 415
911ab26e 416 # Loop over all devices and channels.
d0aa45b4
JS
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)
1879265a 421 deviceID = tuple(deviceID) # PySide returns a list.
d0aa45b4
JS
422 traces = self.model.data(idx,
423 datamodel.MeasurementDataModel.tracesRole)
424
425 for unit, trace in traces.items():
426 now = time.time()
427
911ab26e 428 # Remove old samples.
ac584e86 429 l = now - settings.graph.backlog.value()
d0aa45b4
JS
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
911ab26e 455 # on one trace, the plot will be shown again.
d0aa45b4
JS
456 for row in range(self.model.rowCount()):
457 idx = self.model.index(row, 0)
458 traces = self.model.data(idx, datamodel.MeasurementDataModel.tracesRole)
f76b9df8 459
d0aa45b4
JS
460 for traceunit, trace in traces.items():
461 if traceunit == plotunit:
462 trace.new = False
48723bbb 463
2e8c2e6e
JS
464 @QtCore.Slot()
465 def _stopped(self):
466 if self._closing:
68348e5a
JS
467 # The acquisition was stopped by the 'closeEvent()', close the
468 # window again now that the acquisition has stopped.
2e8c2e6e
JS
469 self.close()
470
48723bbb 471 def closeEvent(self, event):
2e8c2e6e 472 if self.acquisition.is_running():
68348e5a 473 # Stop the acquisition before closing the window.
2e8c2e6e 474 self._closing = True
68348e5a 475 self.start_stop_acquisition()
2e8c2e6e
JS
476 event.ignore()
477 else:
2abf5a93
JS
478 settings.mainwindow.size.setValue(self.size())
479 settings.mainwindow.pos.setValue(self.pos())
2e8c2e6e 480 event.accept()
48723bbb 481
68348e5a
JS
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:
911ab26e 490 # Before starting (again), remove all old samples and old curves.
68348e5a
JS
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
c46081ed
JS
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
48723bbb
JS
524 @QtCore.Slot()
525 def show_about(self):
526 text = textwrap.dedent('''\
527 <div align="center">
480cdb7b
UH
528 <b>sigrok-meter 0.1.0</b><br/><br/>
529 Using libsigrok {} (lib version {}).<br/><br/>
48723bbb
JS
530 <a href='http://www.sigrok.org'>
531 http://www.sigrok.org</a><br/>
532 <br/>
480cdb7b
UH
533 License: GNU GPL, version 3 or later<br/>
534 <br/>
48723bbb
JS
535 This program comes with ABSOLUTELY NO WARRANTY;<br/>
536 for details visit
537 <a href='http://www.gnu.org/licenses/gpl.html'>
68348e5a
JS
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>
48723bbb
JS
542 </div>
543 '''.format(self.context.package_version, self.context.lib_version))
544
545 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)