Minor cosmetics and typo fixes.
[sigrok-meter/gsi.git] / mainwindow.py
blob560dfd3c02327a665f45c005bbe28203c9655bae
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, write to the Free Software
19 ## Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
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
36 QtCore = qtcompat.QtCore
37 QtGui = qtcompat.QtGui
38 pyqtgraph = qtcompat.pyqtgraph
40 class EmptyMessageListView(QtGui.QListView):
41 '''List view that shows a message if the model is empty.'''
43 def __init__(self, message, parent=None):
44 super(self.__class__, self).__init__(parent)
46 self._message = message
48 def paintEvent(self, event):
49 m = self.model()
50 if m and m.rowCount():
51 super(self.__class__, self).paintEvent(event)
52 return
54 painter = QtGui.QPainter(self.viewport())
55 painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
57 class MainWindow(QtGui.QMainWindow):
58 '''The main window of the application.'''
60 # Update interval of the plots in milliseconds.
61 UPDATEINTERVAL = 100
63 def __init__(self, context, drivers):
64 super(self.__class__, self).__init__()
66 # Used to coordinate the stopping of the acquisition and
67 # the closing of the window.
68 self._closing = False
70 self.context = context
71 self.drivers = drivers
73 self.logModel = QtGui.QStringListModel(self)
74 self.context.set_log_callback(self._log_callback)
76 self.delegate = datamodel.MultimeterDelegate(self, self.font())
77 self.model = datamodel.MeasurementDataModel(self)
79 # Maps from 'unit' to the corresponding plot.
80 self._plots = {}
81 # Maps from '(plot, device)' to the corresponding curve.
82 self._curves = {}
84 self._setup_ui()
86 self._plot_update_timer = QtCore.QTimer()
87 self._plot_update_timer.setInterval(MainWindow.UPDATEINTERVAL)
88 self._plot_update_timer.timeout.connect(self._updatePlots)
90 settings.graph.backlog.changed.connect(self.on_setting_graph_backlog_changed)
92 QtCore.QTimer.singleShot(0, self._start_acquisition)
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)
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
107 self.start_stop_acquisition()
109 def _log_callback(self, level, message):
110 if level.id > settings.logging.level.value().id:
111 return
113 t = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')
114 message = '[{}] sr: {}'.format(t, message)
116 sys.stderr.write(message + '\n')
118 scrollBar = self.logView.verticalScrollBar()
119 bottom = scrollBar.value() == scrollBar.maximum()
121 rows = self.logModel.rowCount()
122 maxrows = settings.logging.lines.value()
123 while rows > maxrows:
124 self.logModel.removeRows(0, 1)
125 rows -= 1
127 if self.logModel.insertRow(rows):
128 index = self.logModel.index(rows)
129 self.logModel.setData(index, message, QtCore.Qt.DisplayRole)
131 if bottom:
132 self.logView.scrollToBottom()
134 def _setup_ui(self):
135 self.setWindowTitle('sigrok-meter')
136 # Resizing the listView below will increase this again.
137 self.resize(350, 10)
139 self.setWindowIcon(QtGui.QIcon(':/logo.png'))
141 self._setup_graphPage()
142 self._setup_addDevicePage()
143 self._setup_logPage()
144 self._setup_preferencesPage()
146 self._pages = [
147 self.graphPage,
148 self.addDevicePage,
149 self.logPage,
150 self.preferencesPage
153 self.stackedWidget = QtGui.QStackedWidget(self)
154 for page in self._pages:
155 self.stackedWidget.addWidget(page)
157 self._setup_sidebar()
159 self.setCentralWidget(QtGui.QWidget())
160 self.centralWidget().setContentsMargins(0, 0, 0, 0)
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)
168 self.resize(settings.mainwindow.size.value())
169 if settings.mainwindow.pos.value():
170 self.move(settings.mainwindow.pos.value())
172 def _setup_sidebar(self):
173 self.sideBar = QtGui.QToolBar(self)
174 self.sideBar.setOrientation(QtCore.Qt.Vertical)
176 actionGraph = self.sideBar.addAction('Instantaneous Values and Graphs')
177 actionGraph.setCheckable(True)
178 actionGraph.setIcon(icons.graph)
179 actionGraph.triggered.connect(self.showGraphPage)
181 #actionAdd = self.sideBar.addAction('Add Device')
182 #actionAdd.setCheckable(True)
183 #actionAdd.setIcon(icons.add)
184 #actionAdd.triggered.connect(self.showAddDevicePage)
186 actionLog = self.sideBar.addAction('Logs')
187 actionLog.setCheckable(True)
188 actionLog.setIcon(icons.log)
189 actionLog.triggered.connect(self.showLogPage)
191 actionPreferences = self.sideBar.addAction('Preferences')
192 actionPreferences.setCheckable(True)
193 actionPreferences.setIcon(icons.preferences)
194 actionPreferences.triggered.connect(self.showPreferencesPage)
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)
203 # Show graph at startup.
204 actionGraph.setChecked(True)
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)
211 self.actionStartStop = self.sideBar.addAction('Start Acquisition')
212 self.actionStartStop.setIcon(icons.start)
213 self.actionStartStop.triggered.connect(self.start_stop_acquisition)
215 actionAbout = self.sideBar.addAction('About')
216 actionAbout.setIcon(icons.about)
217 actionAbout.triggered.connect(self.show_about)
219 actionQuit = self.sideBar.addAction('Quit')
220 actionQuit.setIcon(icons.exit)
221 actionQuit.triggered.connect(self.close)
223 s = self.style().pixelMetric(QtGui.QStyle.PM_LargeIconSize)
224 self.sideBar.setIconSize(QtCore.QSize(s, s))
226 self.sideBar.setStyleSheet('''
227 QToolBar {
228 background-color: white;
229 margin: 0px;
230 border: 0px;
231 border-right: 1px solid black;
234 QToolButton {
235 padding: 10px;
236 border: 0px;
237 border-right: 1px solid black;
240 QToolButton:checked,
241 QToolButton[checkable="false"]:hover {
242 background-color: #c0d0e8;
244 ''')
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())
260 self.plotwidget = multiplotwidget.MultiPlotWidget(self)
261 self.plotwidget.plotHidden.connect(self._on_plotHidden)
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)
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)
275 def _setup_logPage(self):
276 self.logPage = QtGui.QWidget(self)
277 layout = QtGui.QVBoxLayout(self.logPage)
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)
285 btn = QtGui.QPushButton('Save to file...', self)
286 btn.clicked.connect(self.on_save_log_clicked)
287 layout.addWidget(btn)
289 def _setup_preferencesPage(self):
290 self.preferencesPage = QtGui.QWidget(self)
291 layout = QtGui.QGridLayout(self.preferencesPage)
293 layout.addWidget(QtGui.QLabel('<b>Graph</b>'), 0, 0)
294 layout.addWidget(QtGui.QLabel('Recording time (seconds):'), 1, 0)
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)
304 layout.addWidget(QtGui.QLabel('<b>Logging</b>'), 2, 0)
305 layout.addWidget(QtGui.QLabel('Log level:'), 3, 0)
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'
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)
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)
329 layout.addWidget(QtGui.QLabel('Number of lines to log:'), 4, 0)
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)
339 layout.setRowStretch(layout.rowCount(), 100)
341 def showPage(self, page):
342 self.stackedWidget.setCurrentIndex(self._pages.index(page))
344 @QtCore.Slot(bool)
345 def showGraphPage(self):
346 self.showPage(self.graphPage)
348 @QtCore.Slot(bool)
349 def showAddDevicePage(self):
350 self.showPage(self.addDevicePage)
352 @QtCore.Slot(bool)
353 def showLogPage(self):
354 self.showPage(self.logPage)
356 @QtCore.Slot(bool)
357 def showPreferencesPage(self):
358 self.showPage(self.preferencesPage)
360 @QtCore.Slot(int)
361 def on_setting_graph_backlog_changed(self, bl):
362 for unit in self._plots:
363 plot = self._plots[unit]
365 # Remove the limits first, otherwise the range update would
366 # be ignored.
367 plot.view.setLimits(xMin=None, xMax=None)
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])
375 def _getPlot(self, unit):
376 '''Looks up or creates a new plot for 'unit'.'''
378 if unit in self._plots:
379 return self._plots[unit]
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])
392 self._plots[unit] = plot
393 return plot
395 def _getCurve(self, plot, deviceID):
396 '''Looks up or creates a new curve for '(plot, deviceID)'.'''
398 key = (plot, deviceID)
399 if key in self._curves:
400 return self._curves[key]
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
409 plot.view.addItem(curve)
411 self._curves[key] = curve
412 return curve
414 def _updatePlots(self):
415 '''Updates all plots.'''
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)
426 for unit, trace in traces.items():
427 now = time.time()
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)
434 plot = self._getPlot(unit)
435 if not plot.visible:
436 if trace.new:
437 self.plotwidget.showPlot(plot)
439 if plot.visible:
440 xdata = [s[0] - now for s in trace.samples]
441 ydata = [s[1] for s in trace.samples]
443 color = self.model.data(idx,
444 datamodel.MeasurementDataModel.colorRole)
446 curve = self._getCurve(plot, deviceID)
447 curve.setPen(pyqtgraph.mkPen(color=color))
448 curve.setData(xdata, ydata)
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]
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)
461 for traceunit, trace in traces.items():
462 if traceunit == plotunit:
463 trace.new = False
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()
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()
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()
494 for key in self._curves:
495 plot, _ = key
496 curve = self._curves[key]
497 plot.view.removeItem(curve)
498 self._curves = {}
500 self.acquisition.start()
501 self._plot_update_timer.start()
502 self.actionStartStop.setText('Stop Acquisition')
503 self.actionStartStop.setIcon(icons.stop)
505 @QtCore.Slot()
506 def on_save_log_clicked(self):
507 filename = QtGui.QFileDialog.getSaveFileName(self,
508 'Save Log File', settings.logging.filename.value())
510 if not filename:
511 # User pressed 'cancel'.
512 return
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))
523 settings.logging.filename.setValue(filename)
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))
546 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)