]>
Commit | Line | Data |
---|---|---|
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 | 22 | import acquisition |
48723bbb | 23 | import datamodel |
68348e5a | 24 | import icons |
f76b9df8 | 25 | import multiplotwidget |
48723bbb JS |
26 | import os.path |
27 | import qtcompat | |
2abf5a93 | 28 | import settings |
48723bbb | 29 | import textwrap |
f76b9df8 JS |
30 | import time |
31 | import util | |
48723bbb JS |
32 | |
33 | QtCore = qtcompat.QtCore | |
34 | QtGui = qtcompat.QtGui | |
f76b9df8 | 35 | pyqtgraph = qtcompat.pyqtgraph |
48723bbb JS |
36 | |
37 | class EmptyMessageListView(QtGui.QListView): | |
38 | '''List view that shows a message if the model im empty.''' | |
39 | ||
40 | def __init__(self, message, parent=None): | |
41 | super(self.__class__, self).__init__(parent) | |
42 | ||
43 | self._message = message | |
44 | ||
45 | def paintEvent(self, event): | |
46 | m = self.model() | |
47 | if m and m.rowCount(): | |
48 | super(self.__class__, self).paintEvent(event) | |
49 | return | |
50 | ||
51 | painter = QtGui.QPainter(self.viewport()) | |
52 | painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message) | |
53 | ||
54 | class MainWindow(QtGui.QMainWindow): | |
55 | '''The main window of the application.''' | |
56 | ||
f76b9df8 JS |
57 | # Update interval of the plots in milliseconds. |
58 | UPDATEINTERVAL = 100 | |
59 | ||
48723bbb JS |
60 | def __init__(self, context, drivers): |
61 | super(self.__class__, self).__init__() | |
62 | ||
2e8c2e6e JS |
63 | # Used to coordinate the stopping of the acquisition and |
64 | # the closing of the window. | |
65 | self._closing = False | |
66 | ||
48723bbb | 67 | self.context = context |
2e8c2e6e | 68 | self.drivers = drivers |
48723bbb JS |
69 | |
70 | self.delegate = datamodel.MultimeterDelegate(self, self.font()) | |
71 | self.model = datamodel.MeasurementDataModel(self) | |
48723bbb | 72 | |
68348e5a JS |
73 | # Maps from 'unit' to the corresponding plot. |
74 | self._plots = {} | |
75 | # Maps from '(plot, device)' to the corresponding curve. | |
76 | self._curves = {} | |
77 | ||
78 | self._setup_ui() | |
79 | ||
80 | self._plot_update_timer = QtCore.QTimer() | |
81 | self._plot_update_timer.setInterval(MainWindow.UPDATEINTERVAL) | |
82 | self._plot_update_timer.timeout.connect(self._updatePlots) | |
48723bbb | 83 | |
ac584e86 JS |
84 | settings.graph.backlog.changed.connect(self.on_setting_graph_backlog_changed) |
85 | ||
2e8c2e6e JS |
86 | QtCore.QTimer.singleShot(0, self._start_acquisition) |
87 | ||
88 | def _start_acquisition(self): | |
89 | self.acquisition = acquisition.Acquisition(self.context) | |
90 | self.acquisition.measured.connect(self.model.update) | |
91 | self.acquisition.stopped.connect(self._stopped) | |
92 | ||
93 | try: | |
94 | for (ds, cs) in self.drivers: | |
95 | self.acquisition.add_device(ds, cs) | |
96 | except Exception as e: | |
97 | QtGui.QMessageBox.critical(self, 'Error', str(e)) | |
98 | self.close() | |
99 | return | |
100 | ||
68348e5a | 101 | self.start_stop_acquisition() |
48723bbb | 102 | |
68348e5a | 103 | def _setup_ui(self): |
48723bbb | 104 | self.setWindowTitle('sigrok-meter') |
480cdb7b | 105 | # Resizing the listView below will increase this again. |
0e810ddf | 106 | self.resize(350, 10) |
48723bbb | 107 | |
68348e5a | 108 | self.setWindowIcon(QtGui.QIcon(':/logo.png')) |
48723bbb | 109 | |
68348e5a JS |
110 | self._setup_graphPage() |
111 | self._setup_addDevicePage() | |
112 | self._setup_logPage() | |
113 | self._setup_preferencesPage() | |
48723bbb | 114 | |
68348e5a JS |
115 | self._pages = [ |
116 | self.graphPage, | |
117 | self.addDevicePage, | |
118 | self.logPage, | |
119 | self.preferencesPage | |
120 | ] | |
48723bbb | 121 | |
68348e5a JS |
122 | self.stackedWidget = QtGui.QStackedWidget(self) |
123 | for page in self._pages: | |
124 | self.stackedWidget.addWidget(page) | |
48723bbb | 125 | |
68348e5a | 126 | self._setup_sidebar() |
f76b9df8 | 127 | |
68348e5a JS |
128 | self.setCentralWidget(QtGui.QWidget()) |
129 | self.centralWidget().setContentsMargins(0, 0, 0, 0) | |
f76b9df8 | 130 | |
68348e5a JS |
131 | layout = QtGui.QHBoxLayout(self.centralWidget()) |
132 | layout.addWidget(self.sideBar) | |
133 | layout.addWidget(self.stackedWidget) | |
134 | layout.setSpacing(0) | |
135 | layout.setContentsMargins(0, 0, 0, 0) | |
136 | ||
2abf5a93 JS |
137 | self.resize(settings.mainwindow.size.value()) |
138 | if settings.mainwindow.pos.value(): | |
139 | self.move(settings.mainwindow.pos.value()) | |
68348e5a JS |
140 | |
141 | def _setup_sidebar(self): | |
142 | self.sideBar = QtGui.QToolBar(self) | |
143 | self.sideBar.setOrientation(QtCore.Qt.Vertical) | |
144 | ||
145 | actionGraph = self.sideBar.addAction('Instantaneous Values and Graphs') | |
146 | actionGraph.setCheckable(True) | |
147 | actionGraph.setIcon(icons.graph) | |
148 | actionGraph.triggered.connect(self.showGraphPage) | |
149 | ||
150 | #actionAdd = self.sideBar.addAction('Add Device') | |
151 | #actionAdd.setCheckable(True) | |
152 | #actionAdd.setIcon(icons.add) | |
153 | #actionAdd.triggered.connect(self.showAddDevicePage) | |
154 | ||
155 | #actionLog = self.sideBar.addAction('Logs') | |
156 | #actionLog.setCheckable(True) | |
157 | #actionLog.setIcon(icons.log) | |
158 | #actionLog.triggered.connect(self.showLogPage) | |
159 | ||
ac584e86 JS |
160 | actionPreferences = self.sideBar.addAction('Preferences') |
161 | actionPreferences.setCheckable(True) | |
162 | actionPreferences.setIcon(icons.preferences) | |
163 | actionPreferences.triggered.connect(self.showPreferencesPage) | |
68348e5a JS |
164 | |
165 | # make the buttons at the top exclusive | |
166 | self.actionGroup = QtGui.QActionGroup(self) | |
167 | self.actionGroup.addAction(actionGraph) | |
168 | #self.actionGroup.addAction(actionAdd) | |
169 | #self.actionGroup.addAction(actionLog) | |
ac584e86 | 170 | self.actionGroup.addAction(actionPreferences) |
68348e5a JS |
171 | |
172 | # show graph at startup | |
173 | actionGraph.setChecked(True) | |
174 | ||
175 | # fill space between buttons on the top and on the bottom | |
176 | fill = QtGui.QWidget(self) | |
177 | fill.setSizePolicy(QtGui.QSizePolicy.Preferred, QtGui.QSizePolicy.Expanding) | |
178 | self.sideBar.addWidget(fill) | |
179 | ||
180 | self.actionStartStop = self.sideBar.addAction('Start Acquisition') | |
181 | self.actionStartStop.setIcon(icons.start) | |
182 | self.actionStartStop.triggered.connect(self.start_stop_acquisition) | |
183 | ||
184 | actionAbout = self.sideBar.addAction('About') | |
185 | actionAbout.setIcon(icons.about) | |
186 | actionAbout.triggered.connect(self.show_about) | |
f76b9df8 | 187 | |
68348e5a JS |
188 | actionQuit = self.sideBar.addAction('Quit') |
189 | actionQuit.setIcon(icons.exit) | |
190 | actionQuit.triggered.connect(self.close) | |
191 | ||
192 | s = self.style().pixelMetric(QtGui.QStyle.PM_LargeIconSize) | |
193 | self.sideBar.setIconSize(QtCore.QSize(s, s)) | |
194 | ||
195 | self.sideBar.setStyleSheet(''' | |
196 | QToolBar { | |
197 | background-color: white; | |
198 | margin: 0px; | |
199 | border: 0px; | |
200 | border-right: 1px solid black; | |
201 | } | |
202 | ||
203 | QToolButton { | |
204 | padding: 10px; | |
205 | border: 0px; | |
206 | border-right: 1px solid black; | |
207 | } | |
208 | ||
209 | QToolButton:checked, | |
210 | QToolButton[checkable="false"]:hover { | |
211 | background-color: #c0d0e8; | |
212 | } | |
213 | ''') | |
214 | ||
215 | def _setup_graphPage(self): | |
216 | listView = EmptyMessageListView('waiting for data...') | |
217 | listView.setFrameShape(QtGui.QFrame.NoFrame) | |
218 | listView.viewport().setBackgroundRole(QtGui.QPalette.Window) | |
219 | listView.viewport().setAutoFillBackground(True) | |
220 | listView.setMinimumWidth(260) | |
221 | listView.setSelectionMode(QtGui.QAbstractItemView.NoSelection) | |
222 | listView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers) | |
223 | listView.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel) | |
224 | listView.setItemDelegate(self.delegate) | |
225 | listView.setModel(self.model) | |
226 | listView.setUniformItemSizes(True) | |
227 | listView.setMinimumSize(self.delegate.sizeHint()) | |
f76b9df8 | 228 | |
68348e5a JS |
229 | self.plotwidget = multiplotwidget.MultiPlotWidget(self) |
230 | self.plotwidget.plotHidden.connect(self._on_plotHidden) | |
f76b9df8 | 231 | |
68348e5a JS |
232 | self.graphPage = QtGui.QSplitter(QtCore.Qt.Horizontal, self) |
233 | self.graphPage.addWidget(listView) | |
234 | self.graphPage.addWidget(self.plotwidget) | |
235 | self.graphPage.setStretchFactor(0, 0) | |
236 | self.graphPage.setStretchFactor(1, 1) | |
237 | ||
238 | def _setup_addDevicePage(self): | |
239 | self.addDevicePage = QtGui.QWidget(self) | |
240 | layout = QtGui.QVBoxLayout(self.addDevicePage) | |
241 | label = QtGui.QLabel('add device page') | |
242 | layout.addWidget(label) | |
243 | ||
244 | def _setup_logPage(self): | |
245 | self.logPage = QtGui.QWidget(self) | |
246 | layout = QtGui.QVBoxLayout(self.logPage) | |
247 | label = QtGui.QLabel('log page') | |
248 | layout.addWidget(label) | |
249 | ||
250 | def _setup_preferencesPage(self): | |
251 | self.preferencesPage = QtGui.QWidget(self) | |
ac584e86 JS |
252 | layout = QtGui.QGridLayout(self.preferencesPage) |
253 | ||
254 | layout.addWidget(QtGui.QLabel('<b>Graph</b>'), 0, 0) | |
255 | layout.addWidget(QtGui.QLabel('Recording time (seconds):'), 1, 0) | |
256 | ||
257 | spin = QtGui.QSpinBox(self) | |
258 | spin.setMinimum(10) | |
259 | spin.setMaximum(3600) | |
260 | spin.setSingleStep(10) | |
261 | spin.setValue(settings.graph.backlog.value()) | |
262 | spin.valueChanged[int].connect(settings.graph.backlog.setValue) | |
263 | layout.addWidget(spin, 1, 1) | |
264 | ||
265 | layout.setRowStretch(layout.rowCount(), 100) | |
68348e5a JS |
266 | |
267 | def showPage(self, page): | |
268 | self.stackedWidget.setCurrentIndex(self._pages.index(page)) | |
269 | ||
270 | @QtCore.Slot(bool) | |
271 | def showGraphPage(self): | |
272 | self.showPage(self.graphPage) | |
273 | ||
274 | @QtCore.Slot(bool) | |
275 | def showAddDevicePage(self): | |
276 | self.showPage(self.addDevicePage) | |
277 | ||
278 | @QtCore.Slot(bool) | |
279 | def showLogPage(self): | |
280 | self.showPage(self.logPage) | |
281 | ||
282 | @QtCore.Slot(bool) | |
283 | def showPreferencesPage(self): | |
284 | self.showPage(self.preferencesPage) | |
2e8c2e6e | 285 | |
ac584e86 JS |
286 | @QtCore.Slot(int) |
287 | def on_setting_graph_backlog_changed(self, bl): | |
288 | for unit in self._plots: | |
289 | plot = self._plots[unit] | |
290 | ||
291 | # Remove the limits first, otherwise the range update would | |
292 | # be ignored. | |
293 | plot.view.setLimits(xMin=None, xMax=None) | |
294 | ||
295 | # Now change the range, and then use the calculated limits | |
296 | # (also see the comment in '_getPlot()'). | |
297 | plot.view.setXRange(-bl, 0, update=True) | |
298 | r = plot.view.viewRange() | |
299 | plot.view.setLimits(xMin=r[0][0], xMax=r[0][1]) | |
300 | ||
f76b9df8 JS |
301 | def _getPlot(self, unit): |
302 | '''Looks up or creates a new plot for 'unit'.''' | |
303 | ||
304 | if unit in self._plots: | |
305 | return self._plots[unit] | |
306 | ||
307 | # create a new plot for the unit | |
308 | plot = self.plotwidget.addPlot() | |
309 | plot.yaxis.setLabel(util.quantity_from_unit(unit), units=util.format_unit(unit)) | |
ac584e86 | 310 | plot.view.setXRange(-settings.graph.backlog.value(), 0, update=False) |
f76b9df8 JS |
311 | plot.view.setYRange(-1, 1) |
312 | plot.view.enableAutoRange(axis=pyqtgraph.ViewBox.YAxis) | |
313 | # lock to the range calculated by the view using additional padding, | |
314 | # looks nicer this way | |
315 | r = plot.view.viewRange() | |
316 | plot.view.setLimits(xMin=r[0][0], xMax=r[0][1]) | |
317 | ||
318 | self._plots[unit] = plot | |
319 | return plot | |
320 | ||
321 | def _getCurve(self, plot, deviceID): | |
322 | '''Looks up or creates a new curve for '(plot, deviceID)'.''' | |
323 | ||
68348e5a | 324 | key = (plot, deviceID) |
f76b9df8 JS |
325 | if key in self._curves: |
326 | return self._curves[key] | |
327 | ||
328 | # create a new curve | |
3010b5a0 JS |
329 | curve = pyqtgraph.PlotDataItem( |
330 | antialias=True, | |
331 | symbolPen=pyqtgraph.mkPen(QtGui.QColor(QtCore.Qt.black)), | |
332 | symbolBrush=pyqtgraph.mkBrush(QtGui.QColor(QtCore.Qt.black)), | |
333 | symbolSize=1 | |
334 | ) | |
f76b9df8 JS |
335 | plot.view.addItem(curve) |
336 | ||
337 | self._curves[key] = curve | |
338 | return curve | |
339 | ||
d0aa45b4 JS |
340 | def _updatePlots(self): |
341 | '''Updates all plots.''' | |
f76b9df8 | 342 | |
d0aa45b4 JS |
343 | # loop over all devices and channels |
344 | for row in range(self.model.rowCount()): | |
345 | idx = self.model.index(row, 0) | |
346 | deviceID = self.model.data(idx, | |
347 | datamodel.MeasurementDataModel.idRole) | |
1879265a | 348 | deviceID = tuple(deviceID) # PySide returns a list. |
d0aa45b4 JS |
349 | traces = self.model.data(idx, |
350 | datamodel.MeasurementDataModel.tracesRole) | |
351 | ||
352 | for unit, trace in traces.items(): | |
353 | now = time.time() | |
354 | ||
355 | # remove old samples | |
ac584e86 | 356 | l = now - settings.graph.backlog.value() |
d0aa45b4 JS |
357 | while trace.samples and trace.samples[0][0] < l: |
358 | trace.samples.pop(0) | |
359 | ||
360 | plot = self._getPlot(unit) | |
361 | if not plot.visible: | |
362 | if trace.new: | |
363 | self.plotwidget.showPlot(plot) | |
364 | ||
365 | if plot.visible: | |
366 | xdata = [s[0] - now for s in trace.samples] | |
367 | ydata = [s[1] for s in trace.samples] | |
368 | ||
369 | color = self.model.data(idx, | |
370 | datamodel.MeasurementDataModel.colorRole) | |
371 | ||
372 | curve = self._getCurve(plot, deviceID) | |
373 | curve.setPen(pyqtgraph.mkPen(color=color)) | |
374 | curve.setData(xdata, ydata) | |
375 | ||
376 | @QtCore.Slot(multiplotwidget.Plot) | |
377 | def _on_plotHidden(self, plot): | |
378 | plotunit = [u for u, p in self._plots.items() if p == plot][0] | |
379 | ||
380 | # Mark all traces of all devices/channels with the same unit as the | |
381 | # plot as "old" ('trace.new = False'). As soon as a new sample arrives | |
382 | # on one trace, the plot will be shown again | |
383 | for row in range(self.model.rowCount()): | |
384 | idx = self.model.index(row, 0) | |
385 | traces = self.model.data(idx, datamodel.MeasurementDataModel.tracesRole) | |
f76b9df8 | 386 | |
d0aa45b4 JS |
387 | for traceunit, trace in traces.items(): |
388 | if traceunit == plotunit: | |
389 | trace.new = False | |
48723bbb | 390 | |
2e8c2e6e JS |
391 | @QtCore.Slot() |
392 | def _stopped(self): | |
393 | if self._closing: | |
68348e5a JS |
394 | # The acquisition was stopped by the 'closeEvent()', close the |
395 | # window again now that the acquisition has stopped. | |
2e8c2e6e JS |
396 | self.close() |
397 | ||
48723bbb | 398 | def closeEvent(self, event): |
2e8c2e6e | 399 | if self.acquisition.is_running(): |
68348e5a | 400 | # Stop the acquisition before closing the window. |
2e8c2e6e | 401 | self._closing = True |
68348e5a | 402 | self.start_stop_acquisition() |
2e8c2e6e JS |
403 | event.ignore() |
404 | else: | |
2abf5a93 JS |
405 | settings.mainwindow.size.setValue(self.size()) |
406 | settings.mainwindow.pos.setValue(self.pos()) | |
2e8c2e6e | 407 | event.accept() |
48723bbb | 408 | |
68348e5a JS |
409 | @QtCore.Slot() |
410 | def start_stop_acquisition(self): | |
411 | if self.acquisition.is_running(): | |
412 | self.acquisition.stop() | |
413 | self._plot_update_timer.stop() | |
414 | self.actionStartStop.setText('Start Acquisition') | |
415 | self.actionStartStop.setIcon(icons.start) | |
416 | else: | |
417 | # before starting (again), remove all old samples and old curves | |
418 | self.model.clear_samples() | |
419 | ||
420 | for key in self._curves: | |
421 | plot, _ = key | |
422 | curve = self._curves[key] | |
423 | plot.view.removeItem(curve) | |
424 | self._curves = {} | |
425 | ||
426 | self.acquisition.start() | |
427 | self._plot_update_timer.start() | |
428 | self.actionStartStop.setText('Stop Acquisition') | |
429 | self.actionStartStop.setIcon(icons.stop) | |
430 | ||
48723bbb JS |
431 | @QtCore.Slot() |
432 | def show_about(self): | |
433 | text = textwrap.dedent('''\ | |
434 | <div align="center"> | |
480cdb7b UH |
435 | <b>sigrok-meter 0.1.0</b><br/><br/> |
436 | Using libsigrok {} (lib version {}).<br/><br/> | |
48723bbb JS |
437 | <a href='http://www.sigrok.org'> |
438 | http://www.sigrok.org</a><br/> | |
439 | <br/> | |
480cdb7b UH |
440 | License: GNU GPL, version 3 or later<br/> |
441 | <br/> | |
48723bbb JS |
442 | This program comes with ABSOLUTELY NO WARRANTY;<br/> |
443 | for details visit | |
444 | <a href='http://www.gnu.org/licenses/gpl.html'> | |
68348e5a JS |
445 | http://www.gnu.org/licenses/gpl.html</a><br/> |
446 | <br/> | |
447 | Some icons by <a href='https://www.gnome.org'> | |
448 | the GNOME project</a> | |
48723bbb JS |
449 | </div> |
450 | '''.format(self.context.package_version, self.context.lib_version)) | |
451 | ||
452 | QtGui.QMessageBox.about(self, 'About sigrok-meter', text) |