license: remove FSF postal address from boiler plate license text
[sigrok-meter/gsi.git] / mainwindow.py
blob26f1005bdbe5558c68edbd212d8d2768b1afcccf
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.
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.
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/>.
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
35 QtCore = qtcompat.QtCore
36 QtGui = qtcompat.QtGui
37 pyqtgraph = qtcompat.pyqtgraph
39 class EmptyMessageListView(QtGui.QListView):
40 '''List view that shows a message if the model is empty.'''
42 def __init__(self, message, parent=None):
43 super(self.__class__, self).__init__(parent)
45 self._message = message
47 def paintEvent(self, event):
48 m = self.model()
49 if m and m.rowCount():
50 super(self.__class__, self).paintEvent(event)
51 return
53 painter = QtGui.QPainter(self.viewport())
54 painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
56 class MainWindow(QtGui.QMainWindow):
57 '''The main window of the application.'''
59 # Update interval of the plots in milliseconds.
60 UPDATEINTERVAL = 100
62 def __init__(self, context, drivers):
63 super(self.__class__, self).__init__()
65 # Used to coordinate the stopping of the acquisition and
66 # the closing of the window.
67 self._closing = False
69 self.context = context
70 self.drivers = drivers
72 self.logModel = QtGui.QStringListModel(self)
73 self.context.set_log_callback(self._log_callback)
75 self.delegate = datamodel.MultimeterDelegate(self, self.font())
76 self.model = datamodel.MeasurementDataModel(self)
78 # Maps from 'unit' to the corresponding plot.
79 self._plots = {}
80 # Maps from '(plot, device)' to the corresponding curve.
81 self._curves = {}
83 self._setup_ui()
85 self._plot_update_timer = QtCore.QTimer()
86 self._plot_update_timer.setInterval(MainWindow.UPDATEINTERVAL)
87 self._plot_update_timer.timeout.connect(self._updatePlots)
89 settings.graph.backlog.changed.connect(self.on_setting_graph_backlog_changed)
91 QtCore.QTimer.singleShot(0, self._start_acquisition)
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)
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
106 self.start_stop_acquisition()
108 def _log_callback(self, level, message):
109 if level.id > settings.logging.level.value().id:
110 return
112 t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')
113 message = '[{}] sr: {}'.format(t, message)
115 sys.stderr.write(message + '\n')
117 scrollBar = self.logView.verticalScrollBar()
118 bottom = scrollBar.value() == scrollBar.maximum()
120 rows = self.logModel.rowCount()
121 maxrows = settings.logging.lines.value()
122 while rows > maxrows:
123 self.logModel.removeRows(0, 1)
124 rows -= 1
126 if self.logModel.insertRow(rows):
127 index = self.logModel.index(rows)
128 self.logModel.setData(index, message, QtCore.Qt.DisplayRole)
130 if bottom:
131 self.logView.scrollToBottom()
133 def _setup_ui(self):
134 self.setWindowTitle('sigrok-meter')
135 # Resizing the listView below will increase this again.
136 self.resize(350, 10)
138 self.setWindowIcon(QtGui.QIcon(':/logo.png'))
140 self._setup_graphPage()
141 self._setup_addDevicePage()
142 self._setup_logPage()
143 self._setup_preferencesPage()
145 self._pages = [
146 self.graphPage,
147 self.addDevicePage,
148 self.logPage,
149 self.preferencesPage
152 self.stackedWidget = QtGui.QStackedWidget(self)
153 for page in self._pages:
154 self.stackedWidget.addWidget(page)
156 self._setup_sidebar()
158 self.setCentralWidget(QtGui.QWidget())
159 self.centralWidget().setContentsMargins(0, 0, 0, 0)
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)
167 self.resize(settings.mainwindow.size.value())
168 if settings.mainwindow.pos.value():
169 self.move(settings.mainwindow.pos.value())
171 def _setup_sidebar(self):
172 self.sideBar = QtGui.QToolBar(self)
173 self.sideBar.setOrientation(QtCore.Qt.Vertical)
175 actionGraph = self.sideBar.addAction('Instantaneous Values and Graphs')
176 actionGraph.setCheckable(True)
177 actionGraph.setIcon(icons.graph)
178 actionGraph.triggered.connect(self.showGraphPage)
180 #actionAdd = self.sideBar.addAction('Add Device')
181 #actionAdd.setCheckable(True)
182 #actionAdd.setIcon(icons.add)
183 #actionAdd.triggered.connect(self.showAddDevicePage)
185 actionLog = self.sideBar.addAction('Logs')
186 actionLog.setCheckable(True)
187 actionLog.setIcon(icons.log)
188 actionLog.triggered.connect(self.showLogPage)
190 actionPreferences = self.sideBar.addAction('Preferences')
191 actionPreferences.setCheckable(True)
192 actionPreferences.setIcon(icons.preferences)
193 actionPreferences.triggered.connect(self.showPreferencesPage)
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)
202 # Show graph at startup.
203 actionGraph.setChecked(True)
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)
210 self.actionStartStop = self.sideBar.addAction('Start Acquisition')
211 self.actionStartStop.setIcon(icons.start)
212 self.actionStartStop.triggered.connect(self.start_stop_acquisition)
214 actionAbout = self.sideBar.addAction('About')
215 actionAbout.setIcon(icons.about)
216 actionAbout.triggered.connect(self.show_about)
218 actionQuit = self.sideBar.addAction('Quit')
219 actionQuit.setIcon(icons.exit)
220 actionQuit.triggered.connect(self.close)
222 s = self.style().pixelMetric(QtGui.QStyle.PM_LargeIconSize)
223 self.sideBar.setIconSize(QtCore.QSize(s, s))
225 self.sideBar.setStyleSheet('''
226 QToolBar {
227 background-color: white;
228 margin: 0px;
229 border: 0px;
230 border-right: 1px solid black;
233 QToolButton {
234 padding: 10px;
235 border: 0px;
236 border-right: 1px solid black;
239 QToolButton:checked,
240 QToolButton[checkable="false"]:hover {
241 background-color: #c0d0e8;
243 ''')
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())
259 self.plotwidget = multiplotwidget.MultiPlotWidget(self)
260 self.plotwidget.plotHidden.connect(self._on_plotHidden)
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)
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)
274 def _setup_logPage(self):
275 self.logPage = QtGui.QWidget(self)
276 layout = QtGui.QVBoxLayout(self.logPage)
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)
284 btn = QtGui.QPushButton('Save to file...', self)
285 btn.clicked.connect(self.on_save_log_clicked)
286 layout.addWidget(btn)
288 def _setup_preferencesPage(self):
289 self.preferencesPage = QtGui.QWidget(self)
290 layout = QtGui.QGridLayout(self.preferencesPage)
292 layout.addWidget(QtGui.QLabel('<b>Graph</b>'), 0, 0)
293 layout.addWidget(QtGui.QLabel('Recording time (seconds):'), 1, 0)
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)
303 layout.addWidget(QtGui.QLabel('<b>Logging</b>'), 2, 0)
304 layout.addWidget(QtGui.QLabel('Log level:'), 3, 0)
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'
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)
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)
328 layout.addWidget(QtGui.QLabel('Number of lines to log:'), 4, 0)
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)
338 layout.setRowStretch(layout.rowCount(), 100)
340 def showPage(self, page):
341 self.stackedWidget.setCurrentIndex(self._pages.index(page))
343 @QtCore.Slot(bool)
344 def showGraphPage(self):
345 self.showPage(self.graphPage)
347 @QtCore.Slot(bool)
348 def showAddDevicePage(self):
349 self.showPage(self.addDevicePage)
351 @QtCore.Slot(bool)
352 def showLogPage(self):
353 self.showPage(self.logPage)
355 @QtCore.Slot(bool)
356 def showPreferencesPage(self):
357 self.showPage(self.preferencesPage)
359 @QtCore.Slot(int)
360 def on_setting_graph_backlog_changed(self, bl):
361 for unit in self._plots:
362 plot = self._plots[unit]
364 # Remove the limits first, otherwise the range update would
365 # be ignored.
366 plot.view.setLimits(xMin=None, xMax=None)
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])
374 def _getPlot(self, unit):
375 '''Looks up or creates a new plot for 'unit'.'''
377 if unit in self._plots:
378 return self._plots[unit]
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])
391 self._plots[unit] = plot
392 return plot
394 def _getCurve(self, plot, deviceID):
395 '''Looks up or creates a new curve for '(plot, deviceID)'.'''
397 key = (plot, deviceID)
398 if key in self._curves:
399 return self._curves[key]
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
408 plot.view.addItem(curve)
410 self._curves[key] = curve
411 return curve
413 def _updatePlots(self):
414 '''Updates all plots.'''
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)
425 for unit, trace in traces.items():
426 now = time.time()
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)
433 plot = self._getPlot(unit)
434 if not plot.visible:
435 if trace.new:
436 self.plotwidget.showPlot(plot)
438 if plot.visible:
439 xdata = [s[0] - now for s in trace.samples]
440 ydata = [s[1] for s in trace.samples]
442 color = self.model.data(idx,
443 datamodel.MeasurementDataModel.colorRole)
445 curve = self._getCurve(plot, deviceID)
446 curve.setPen(pyqtgraph.mkPen(color=color))
447 curve.setData(xdata, ydata)
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]
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)
460 for traceunit, trace in traces.items():
461 if traceunit == plotunit:
462 trace.new = False
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()
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()
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()
493 for key in self._curves:
494 plot, _ = key
495 curve = self._curves[key]
496 plot.view.removeItem(curve)
497 self._curves = {}
499 self.acquisition.start()
500 self._plot_update_timer.start()
501 self.actionStartStop.setText('Stop Acquisition')
502 self.actionStartStop.setIcon(icons.stop)
504 @QtCore.Slot()
505 def on_save_log_clicked(self):
506 filename = QtGui.QFileDialog.getSaveFileName(self,
507 'Save Log File', settings.logging.filename.value())
509 if not filename:
510 # User pressed 'cancel'.
511 return
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))
522 settings.logging.filename.setValue(filename)
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))
545 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)