]> sigrok.org Git - sigrok-meter.git/blame - mainwindow.py
Allow saving the log messages to a file.
[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):
c46081ed
JS
276 self.logPage = QtGui.QWidget(self)
277 layout = QtGui.QVBoxLayout(self.logPage)
278
a6fe45e1
JS
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)
a6fe45e1 283 layout.addWidget(self.logView)
68348e5a 284
c46081ed
JS
285 btn = QtGui.QPushButton('Save to file...', self)
286 btn.clicked.connect(self.on_save_log_clicked)
287 layout.addWidget(btn)
288
68348e5a
JS
289 def _setup_preferencesPage(self):
290 self.preferencesPage = QtGui.QWidget(self)
ac584e86
JS
291 layout = QtGui.QGridLayout(self.preferencesPage)
292
293 layout.addWidget(QtGui.QLabel('<b>Graph</b>'), 0, 0)
294 layout.addWidget(QtGui.QLabel('Recording time (seconds):'), 1, 0)
295
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)
303
a6fe45e1
JS
304 layout.addWidget(QtGui.QLabel('<b>Logging</b>'), 2, 0)
305 layout.addWidget(QtGui.QLabel('Log level:'), 3, 0)
306
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'
315 ]
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)
323
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)
328
329 layout.addWidget(QtGui.QLabel('Number of lines to log:'), 4, 0)
330
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)
338
ac584e86 339 layout.setRowStretch(layout.rowCount(), 100)
68348e5a
JS
340
341 def showPage(self, page):
342 self.stackedWidget.setCurrentIndex(self._pages.index(page))
343
344 @QtCore.Slot(bool)
345 def showGraphPage(self):
346 self.showPage(self.graphPage)
347
348 @QtCore.Slot(bool)
349 def showAddDevicePage(self):
350 self.showPage(self.addDevicePage)
351
352 @QtCore.Slot(bool)
353 def showLogPage(self):
354 self.showPage(self.logPage)
355
356 @QtCore.Slot(bool)
357 def showPreferencesPage(self):
358 self.showPage(self.preferencesPage)
2e8c2e6e 359
ac584e86
JS
360 @QtCore.Slot(int)
361 def on_setting_graph_backlog_changed(self, bl):
362 for unit in self._plots:
363 plot = self._plots[unit]
364
365 # Remove the limits first, otherwise the range update would
366 # be ignored.
367 plot.view.setLimits(xMin=None, xMax=None)
368
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])
374
f76b9df8
JS
375 def _getPlot(self, unit):
376 '''Looks up or creates a new plot for 'unit'.'''
377
378 if unit in self._plots:
379 return self._plots[unit]
380
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))
ac584e86 384 plot.view.setXRange(-settings.graph.backlog.value(), 0, update=False)
f76b9df8
JS
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])
391
392 self._plots[unit] = plot
393 return plot
394
395 def _getCurve(self, plot, deviceID):
396 '''Looks up or creates a new curve for '(plot, deviceID)'.'''
397
68348e5a 398 key = (plot, deviceID)
f76b9df8
JS
399 if key in self._curves:
400 return self._curves[key]
401
402 # create a new curve
3010b5a0
JS
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
408 )
f76b9df8
JS
409 plot.view.addItem(curve)
410
411 self._curves[key] = curve
412 return curve
413
d0aa45b4
JS
414 def _updatePlots(self):
415 '''Updates all plots.'''
f76b9df8 416
d0aa45b4
JS
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)
1879265a 422 deviceID = tuple(deviceID) # PySide returns a list.
d0aa45b4
JS
423 traces = self.model.data(idx,
424 datamodel.MeasurementDataModel.tracesRole)
425
426 for unit, trace in traces.items():
427 now = time.time()
428
429 # remove old samples
ac584e86 430 l = now - settings.graph.backlog.value()
d0aa45b4
JS
431 while trace.samples and trace.samples[0][0] < l:
432 trace.samples.pop(0)
433
434 plot = self._getPlot(unit)
435 if not plot.visible:
436 if trace.new:
437 self.plotwidget.showPlot(plot)
438
439 if plot.visible:
440 xdata = [s[0] - now for s in trace.samples]
441 ydata = [s[1] for s in trace.samples]
442
443 color = self.model.data(idx,
444 datamodel.MeasurementDataModel.colorRole)
445
446 curve = self._getCurve(plot, deviceID)
447 curve.setPen(pyqtgraph.mkPen(color=color))
448 curve.setData(xdata, ydata)
449
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]
453
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)
f76b9df8 460
d0aa45b4
JS
461 for traceunit, trace in traces.items():
462 if traceunit == plotunit:
463 trace.new = False
48723bbb 464
2e8c2e6e
JS
465 @QtCore.Slot()
466 def _stopped(self):
467 if self._closing:
68348e5a
JS
468 # The acquisition was stopped by the 'closeEvent()', close the
469 # window again now that the acquisition has stopped.
2e8c2e6e
JS
470 self.close()
471
48723bbb 472 def closeEvent(self, event):
2e8c2e6e 473 if self.acquisition.is_running():
68348e5a 474 # Stop the acquisition before closing the window.
2e8c2e6e 475 self._closing = True
68348e5a 476 self.start_stop_acquisition()
2e8c2e6e
JS
477 event.ignore()
478 else:
2abf5a93
JS
479 settings.mainwindow.size.setValue(self.size())
480 settings.mainwindow.pos.setValue(self.pos())
2e8c2e6e 481 event.accept()
48723bbb 482
68348e5a
JS
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()
493
494 for key in self._curves:
495 plot, _ = key
496 curve = self._curves[key]
497 plot.view.removeItem(curve)
498 self._curves = {}
499
500 self.acquisition.start()
501 self._plot_update_timer.start()
502 self.actionStartStop.setText('Stop Acquisition')
503 self.actionStartStop.setIcon(icons.stop)
504
c46081ed
JS
505 @QtCore.Slot()
506 def on_save_log_clicked(self):
507 filename = QtGui.QFileDialog.getSaveFileName(self,
508 'Save Log File', settings.logging.filename.value())
509
510 if not filename:
511 # User pressed 'cancel'.
512 return
513
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))
522
523 settings.logging.filename.setValue(filename)
524
48723bbb
JS
525 @QtCore.Slot()
526 def show_about(self):
527 text = textwrap.dedent('''\
528 <div align="center">
480cdb7b
UH
529 <b>sigrok-meter 0.1.0</b><br/><br/>
530 Using libsigrok {} (lib version {}).<br/><br/>
48723bbb
JS
531 <a href='http://www.sigrok.org'>
532 http://www.sigrok.org</a><br/>
533 <br/>
480cdb7b
UH
534 License: GNU GPL, version 3 or later<br/>
535 <br/>
48723bbb
JS
536 This program comes with ABSOLUTELY NO WARRANTY;<br/>
537 for details visit
538 <a href='http://www.gnu.org/licenses/gpl.html'>
68348e5a
JS
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>
48723bbb
JS
543 </div>
544 '''.format(self.context.package_version, self.context.lib_version))
545
546 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)