]>
Commit | Line | Data |
---|---|---|
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 | ||
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 | |
34 | ||
35 | QtCore = qtcompat.QtCore | |
36 | QtGui = qtcompat.QtGui | |
37 | pyqtgraph = qtcompat.pyqtgraph | |
38 | ||
39 | class 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 | ||
56 | class 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) |