]> sigrok.org Git - sigrok-meter.git/blame_incremental - mainwindow.py
Allow saving the log messages to a file.
[sigrok-meter.git] / mainwindow.py
... / ...
CommitLineData
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
22import acquisition
23import datamodel
24import datetime
25import icons
26import multiplotwidget
27import os.path
28import qtcompat
29import settings
30import sigrok.core as sr
31import sys
32import textwrap
33import time
34import util
35
36QtCore = qtcompat.QtCore
37QtGui = qtcompat.QtGui
38pyqtgraph = qtcompat.pyqtgraph
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
60 # Update interval of the plots in milliseconds.
61 UPDATEINTERVAL = 100
62
63 def __init__(self, context, drivers):
64 super(self.__class__, self).__init__()
65
66 # Used to coordinate the stopping of the acquisition and
67 # the closing of the window.
68 self._closing = False
69
70 self.context = context
71 self.drivers = drivers
72
73 self.logModel = QtGui.QStringListModel(self)
74 self.context.set_log_callback(self._log_callback)
75
76 self.delegate = datamodel.MultimeterDelegate(self, self.font())
77 self.model = datamodel.MeasurementDataModel(self)
78
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)
89
90 settings.graph.backlog.changed.connect(self.on_setting_graph_backlog_changed)
91
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
107 self.start_stop_acquisition()
108
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
134 def _setup_ui(self):
135 self.setWindowTitle('sigrok-meter')
136 # Resizing the listView below will increase this again.
137 self.resize(350, 10)
138
139 self.setWindowIcon(QtGui.QIcon(':/logo.png'))
140
141 self._setup_graphPage()
142 self._setup_addDevicePage()
143 self._setup_logPage()
144 self._setup_preferencesPage()
145
146 self._pages = [
147 self.graphPage,
148 self.addDevicePage,
149 self.logPage,
150 self.preferencesPage
151 ]
152
153 self.stackedWidget = QtGui.QStackedWidget(self)
154 for page in self._pages:
155 self.stackedWidget.addWidget(page)
156
157 self._setup_sidebar()
158
159 self.setCentralWidget(QtGui.QWidget())
160 self.centralWidget().setContentsMargins(0, 0, 0, 0)
161
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
168 self.resize(settings.mainwindow.size.value())
169 if settings.mainwindow.pos.value():
170 self.move(settings.mainwindow.pos.value())
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
186 actionLog = self.sideBar.addAction('Logs')
187 actionLog.setCheckable(True)
188 actionLog.setIcon(icons.log)
189 actionLog.triggered.connect(self.showLogPage)
190
191 actionPreferences = self.sideBar.addAction('Preferences')
192 actionPreferences.setCheckable(True)
193 actionPreferences.setIcon(icons.preferences)
194 actionPreferences.triggered.connect(self.showPreferencesPage)
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)
200 self.actionGroup.addAction(actionLog)
201 self.actionGroup.addAction(actionPreferences)
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)
218
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())
259
260 self.plotwidget = multiplotwidget.MultiPlotWidget(self)
261 self.plotwidget.plotHidden.connect(self._on_plotHidden)
262
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):
276 self.logPage = QtGui.QWidget(self)
277 layout = QtGui.QVBoxLayout(self.logPage)
278
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)
284
285 btn = QtGui.QPushButton('Save to file...', self)
286 btn.clicked.connect(self.on_save_log_clicked)
287 layout.addWidget(btn)
288
289 def _setup_preferencesPage(self):
290 self.preferencesPage = QtGui.QWidget(self)
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
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
339 layout.setRowStretch(layout.rowCount(), 100)
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)
359
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
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))
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])
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
398 key = (plot, deviceID)
399 if key in self._curves:
400 return self._curves[key]
401
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
408 )
409 plot.view.addItem(curve)
410
411 self._curves[key] = curve
412 return curve
413
414 def _updatePlots(self):
415 '''Updates all plots.'''
416
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)
425
426 for unit, trace in traces.items():
427 now = time.time()
428
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)
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)
460
461 for traceunit, trace in traces.items():
462 if traceunit == plotunit:
463 trace.new = False
464
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()
471
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()
482
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
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
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))
545
546 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)