]> sigrok.org Git - sigrok-meter.git/blame_incremental - mainwindow.py
doc: update IRC reference to Libera.Chat
[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, see <http://www.gnu.org/licenses/>.
19##
20
21import acquisition
22import datamodel
23import datetime
24import icons
25import multiplotwidget
26import os.path
27import qtcompat
28import settings
29import sigrok.core as sr
30import sys
31import textwrap
32import time
33import util
34
35QtCore = qtcompat.QtCore
36QtGui = qtcompat.QtGui
37pyqtgraph = qtcompat.pyqtgraph
38
39class EmptyMessageListView(QtGui.QListView):
40 '''List view that shows a message if the model is empty.'''
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
59 # Update interval of the plots in milliseconds.
60 UPDATEINTERVAL = 100
61
62 def __init__(self, context, drivers):
63 super(self.__class__, self).__init__()
64
65 # Used to coordinate the stopping of the acquisition and
66 # the closing of the window.
67 self._closing = False
68
69 self.context = context
70 self.drivers = drivers
71
72 self.logModel = QtGui.QStringListModel(self)
73 self.context.set_log_callback(self._log_callback)
74
75 self.delegate = datamodel.MultimeterDelegate(self, self.font())
76 self.model = datamodel.MeasurementDataModel(self)
77
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)
88
89 settings.graph.backlog.changed.connect(self.on_setting_graph_backlog_changed)
90
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
106 self.start_stop_acquisition()
107
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
133 def _setup_ui(self):
134 self.setWindowTitle('sigrok-meter')
135 # Resizing the listView below will increase this again.
136 self.resize(350, 10)
137
138 self.setWindowIcon(QtGui.QIcon(':/logo.png'))
139
140 self._setup_graphPage()
141 self._setup_addDevicePage()
142 self._setup_logPage()
143 self._setup_preferencesPage()
144
145 self._pages = [
146 self.graphPage,
147 self.addDevicePage,
148 self.logPage,
149 self.preferencesPage
150 ]
151
152 self.stackedWidget = QtGui.QStackedWidget(self)
153 for page in self._pages:
154 self.stackedWidget.addWidget(page)
155
156 self._setup_sidebar()
157
158 self.setCentralWidget(QtGui.QWidget())
159 self.centralWidget().setContentsMargins(0, 0, 0, 0)
160
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
167 self.resize(settings.mainwindow.size.value())
168 if settings.mainwindow.pos.value():
169 self.move(settings.mainwindow.pos.value())
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
185 actionLog = self.sideBar.addAction('Logs')
186 actionLog.setCheckable(True)
187 actionLog.setIcon(icons.log)
188 actionLog.triggered.connect(self.showLogPage)
189
190 actionPreferences = self.sideBar.addAction('Preferences')
191 actionPreferences.setCheckable(True)
192 actionPreferences.setIcon(icons.preferences)
193 actionPreferences.triggered.connect(self.showPreferencesPage)
194
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)
201
202 # Show graph at startup.
203 actionGraph.setChecked(True)
204
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)
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)
217
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())
258
259 self.plotwidget = multiplotwidget.MultiPlotWidget(self)
260 self.plotwidget.plotHidden.connect(self._on_plotHidden)
261
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):
275 self.logPage = QtGui.QWidget(self)
276 layout = QtGui.QVBoxLayout(self.logPage)
277
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)
283
284 btn = QtGui.QPushButton('Save to file...', self)
285 btn.clicked.connect(self.on_save_log_clicked)
286 layout.addWidget(btn)
287
288 def _setup_preferencesPage(self):
289 self.preferencesPage = QtGui.QWidget(self)
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
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
338 layout.setRowStretch(layout.rowCount(), 100)
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)
358
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
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
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])
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
397 key = (plot, deviceID)
398 if key in self._curves:
399 return self._curves[key]
400
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
407 )
408 plot.view.addItem(curve)
409
410 self._curves[key] = curve
411 return curve
412
413 def _updatePlots(self):
414 '''Updates all plots.'''
415
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)
424
425 for unit, trace in traces.items():
426 now = time.time()
427
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)
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
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)
459
460 for traceunit, trace in traces.items():
461 if traceunit == plotunit:
462 trace.new = False
463
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()
470
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()
481
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()
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
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
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))
544
545 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)