2 ## This file is part of the sigrok-meter project.
4 ## Copyright (C) 2013 Uwe Hermann <uwe@hermann-uwe.de>
5 ## Copyright (C) 2014 Jens Steinhauser <jens.steinhauser@gmail.com>
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.
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.
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
26 import multiplotwidget
30 import sigrok
.core
as sr
36 QtCore
= qtcompat
.QtCore
37 QtGui
= qtcompat
.QtGui
38 pyqtgraph
= qtcompat
.pyqtgraph
40 class EmptyMessageListView(QtGui
.QListView
):
41 '''List view that shows a message if the model is empty.'''
43 def __init__(self
, message
, parent
=None):
44 super(self
.__class
__, self
).__init
__(parent
)
46 self
._message
= message
48 def paintEvent(self
, event
):
50 if m
and m
.rowCount():
51 super(self
.__class
__, self
).paintEvent(event
)
54 painter
= QtGui
.QPainter(self
.viewport())
55 painter
.drawText(self
.rect(), QtCore
.Qt
.AlignCenter
, self
._message
)
57 class MainWindow(QtGui
.QMainWindow
):
58 '''The main window of the application.'''
60 # Update interval of the plots in milliseconds.
63 def __init__(self
, context
, drivers
):
64 super(self
.__class
__, self
).__init
__()
66 # Used to coordinate the stopping of the acquisition and
67 # the closing of the window.
70 self
.context
= context
71 self
.drivers
= drivers
73 self
.logModel
= QtGui
.QStringListModel(self
)
74 self
.context
.set_log_callback(self
._log
_callback
)
76 self
.delegate
= datamodel
.MultimeterDelegate(self
, self
.font())
77 self
.model
= datamodel
.MeasurementDataModel(self
)
79 # Maps from 'unit' to the corresponding plot.
81 # Maps from '(plot, device)' to the corresponding curve.
86 self
._plot
_update
_timer
= QtCore
.QTimer()
87 self
._plot
_update
_timer
.setInterval(MainWindow
.UPDATEINTERVAL
)
88 self
._plot
_update
_timer
.timeout
.connect(self
._updatePlots
)
90 settings
.graph
.backlog
.changed
.connect(self
.on_setting_graph_backlog_changed
)
92 QtCore
.QTimer
.singleShot(0, self
._start
_acquisition
)
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
)
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
))
107 self
.start_stop_acquisition()
109 def _log_callback(self
, level
, message
):
110 if level
.id > settings
.logging
.level
.value().id:
113 t
= datetime
.datetime
.now().strftime('%Y-%m-%d %H:%M:%S.%f')
114 message
= '[{}] sr: {}'.format(t
, message
)
116 sys
.stderr
.write(message
+ '\n')
118 scrollBar
= self
.logView
.verticalScrollBar()
119 bottom
= scrollBar
.value() == scrollBar
.maximum()
121 rows
= self
.logModel
.rowCount()
122 maxrows
= settings
.logging
.lines
.value()
123 while rows
> maxrows
:
124 self
.logModel
.removeRows(0, 1)
127 if self
.logModel
.insertRow(rows
):
128 index
= self
.logModel
.index(rows
)
129 self
.logModel
.setData(index
, message
, QtCore
.Qt
.DisplayRole
)
132 self
.logView
.scrollToBottom()
135 self
.setWindowTitle('sigrok-meter')
136 # Resizing the listView below will increase this again.
139 self
.setWindowIcon(QtGui
.QIcon(':/logo.png'))
141 self
._setup
_graphPage
()
142 self
._setup
_addDevicePage
()
143 self
._setup
_logPage
()
144 self
._setup
_preferencesPage
()
153 self
.stackedWidget
= QtGui
.QStackedWidget(self
)
154 for page
in self
._pages
:
155 self
.stackedWidget
.addWidget(page
)
157 self
._setup
_sidebar
()
159 self
.setCentralWidget(QtGui
.QWidget())
160 self
.centralWidget().setContentsMargins(0, 0, 0, 0)
162 layout
= QtGui
.QHBoxLayout(self
.centralWidget())
163 layout
.addWidget(self
.sideBar
)
164 layout
.addWidget(self
.stackedWidget
)
166 layout
.setContentsMargins(0, 0, 0, 0)
168 self
.resize(settings
.mainwindow
.size
.value())
169 if settings
.mainwindow
.pos
.value():
170 self
.move(settings
.mainwindow
.pos
.value())
172 def _setup_sidebar(self
):
173 self
.sideBar
= QtGui
.QToolBar(self
)
174 self
.sideBar
.setOrientation(QtCore
.Qt
.Vertical
)
176 actionGraph
= self
.sideBar
.addAction('Instantaneous Values and Graphs')
177 actionGraph
.setCheckable(True)
178 actionGraph
.setIcon(icons
.graph
)
179 actionGraph
.triggered
.connect(self
.showGraphPage
)
181 #actionAdd = self.sideBar.addAction('Add Device')
182 #actionAdd.setCheckable(True)
183 #actionAdd.setIcon(icons.add)
184 #actionAdd.triggered.connect(self.showAddDevicePage)
186 actionLog
= self
.sideBar
.addAction('Logs')
187 actionLog
.setCheckable(True)
188 actionLog
.setIcon(icons
.log
)
189 actionLog
.triggered
.connect(self
.showLogPage
)
191 actionPreferences
= self
.sideBar
.addAction('Preferences')
192 actionPreferences
.setCheckable(True)
193 actionPreferences
.setIcon(icons
.preferences
)
194 actionPreferences
.triggered
.connect(self
.showPreferencesPage
)
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
)
203 # Show graph at startup.
204 actionGraph
.setChecked(True)
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
)
211 self
.actionStartStop
= self
.sideBar
.addAction('Start Acquisition')
212 self
.actionStartStop
.setIcon(icons
.start
)
213 self
.actionStartStop
.triggered
.connect(self
.start_stop_acquisition
)
215 actionAbout
= self
.sideBar
.addAction('About')
216 actionAbout
.setIcon(icons
.about
)
217 actionAbout
.triggered
.connect(self
.show_about
)
219 actionQuit
= self
.sideBar
.addAction('Quit')
220 actionQuit
.setIcon(icons
.exit
)
221 actionQuit
.triggered
.connect(self
.close
)
223 s
= self
.style().pixelMetric(QtGui
.QStyle
.PM_LargeIconSize
)
224 self
.sideBar
.setIconSize(QtCore
.QSize(s
, s
))
226 self
.sideBar
.setStyleSheet('''
228 background-color: white;
231 border-right: 1px solid black;
237 border-right: 1px solid black;
241 QToolButton[checkable="false"]:hover {
242 background-color: #c0d0e8;
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())
260 self
.plotwidget
= multiplotwidget
.MultiPlotWidget(self
)
261 self
.plotwidget
.plotHidden
.connect(self
._on
_plotHidden
)
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)
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
)
275 def _setup_logPage(self
):
276 self
.logPage
= QtGui
.QWidget(self
)
277 layout
= QtGui
.QVBoxLayout(self
.logPage
)
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
)
285 btn
= QtGui
.QPushButton('Save to file...', self
)
286 btn
.clicked
.connect(self
.on_save_log_clicked
)
287 layout
.addWidget(btn
)
289 def _setup_preferencesPage(self
):
290 self
.preferencesPage
= QtGui
.QWidget(self
)
291 layout
= QtGui
.QGridLayout(self
.preferencesPage
)
293 layout
.addWidget(QtGui
.QLabel('<b>Graph</b>'), 0, 0)
294 layout
.addWidget(QtGui
.QLabel('Recording time (seconds):'), 1, 0)
296 spin
= QtGui
.QSpinBox(self
)
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)
304 layout
.addWidget(QtGui
.QLabel('<b>Logging</b>'), 2, 0)
305 layout
.addWidget(QtGui
.QLabel('Log level:'), 3, 0)
307 cbox
= QtGui
.QComboBox()
309 'no messages at all',
312 'informational messages',
314 'very noisy debug messages'
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.
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)
329 layout
.addWidget(QtGui
.QLabel('Number of lines to log:'), 4, 0)
331 spin
= QtGui
.QSpinBox(self
)
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)
339 layout
.setRowStretch(layout
.rowCount(), 100)
341 def showPage(self
, page
):
342 self
.stackedWidget
.setCurrentIndex(self
._pages
.index(page
))
345 def showGraphPage(self
):
346 self
.showPage(self
.graphPage
)
349 def showAddDevicePage(self
):
350 self
.showPage(self
.addDevicePage
)
353 def showLogPage(self
):
354 self
.showPage(self
.logPage
)
357 def showPreferencesPage(self
):
358 self
.showPage(self
.preferencesPage
)
361 def on_setting_graph_backlog_changed(self
, bl
):
362 for unit
in self
._plots
:
363 plot
= self
._plots
[unit
]
365 # Remove the limits first, otherwise the range update would
367 plot
.view
.setLimits(xMin
=None, xMax
=None)
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])
375 def _getPlot(self
, unit
):
376 '''Looks up or creates a new plot for 'unit'.'''
378 if unit
in self
._plots
:
379 return self
._plots
[unit
]
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])
392 self
._plots
[unit
] = plot
395 def _getCurve(self
, plot
, deviceID
):
396 '''Looks up or creates a new curve for '(plot, deviceID)'.'''
398 key
= (plot
, deviceID
)
399 if key
in self
._curves
:
400 return self
._curves
[key
]
402 # Create a new curve.
403 curve
= pyqtgraph
.PlotDataItem(
405 symbolPen
=pyqtgraph
.mkPen(QtGui
.QColor(QtCore
.Qt
.black
)),
406 symbolBrush
=pyqtgraph
.mkBrush(QtGui
.QColor(QtCore
.Qt
.black
)),
409 plot
.view
.addItem(curve
)
411 self
._curves
[key
] = curve
414 def _updatePlots(self
):
415 '''Updates all plots.'''
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
)
426 for unit
, trace
in traces
.items():
429 # Remove old samples.
430 l
= now
- settings
.graph
.backlog
.value()
431 while trace
.samples
and trace
.samples
[0][0] < l
:
434 plot
= self
._getPlot
(unit
)
437 self
.plotwidget
.showPlot(plot
)
440 xdata
= [s
[0] - now
for s
in trace
.samples
]
441 ydata
= [s
[1] for s
in trace
.samples
]
443 color
= self
.model
.data(idx
,
444 datamodel
.MeasurementDataModel
.colorRole
)
446 curve
= self
._getCurve
(plot
, deviceID
)
447 curve
.setPen(pyqtgraph
.mkPen(color
=color
))
448 curve
.setData(xdata
, ydata
)
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]
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
)
461 for traceunit
, trace
in traces
.items():
462 if traceunit
== plotunit
:
468 # The acquisition was stopped by the 'closeEvent()', close the
469 # window again now that the acquisition has stopped.
472 def closeEvent(self
, event
):
473 if self
.acquisition
.is_running():
474 # Stop the acquisition before closing the window.
476 self
.start_stop_acquisition()
479 settings
.mainwindow
.size
.setValue(self
.size())
480 settings
.mainwindow
.pos
.setValue(self
.pos())
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
)
491 # Before starting (again), remove all old samples and old curves.
492 self
.model
.clear_samples()
494 for key
in self
._curves
:
496 curve
= self
._curves
[key
]
497 plot
.view
.removeItem(curve
)
500 self
.acquisition
.start()
501 self
._plot
_update
_timer
.start()
502 self
.actionStartStop
.setText('Stop Acquisition')
503 self
.actionStartStop
.setIcon(icons
.stop
)
506 def on_save_log_clicked(self
):
507 filename
= QtGui
.QFileDialog
.getSaveFileName(self
,
508 'Save Log File', settings
.logging
.filename
.value())
511 # User pressed 'cancel'.
515 with
open(filename
, 'w') as f
:
516 for line
in self
.logModel
.stringList():
519 except Exception as e
:
520 QtGui
.QMessageBox
.critical(self
, 'Error saving log file',
521 'Unable to save the log messages:\n{}'.format(e
))
523 settings
.logging
.filename
.setValue(filename
)
526 def show_about(self
):
527 text
= textwrap
.dedent('''\
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/>
534 License: GNU GPL, version 3 or later<br/>
536 This program comes with ABSOLUTELY NO WARRANTY;<br/>
538 <a href='http://www.gnu.org/licenses/gpl.html'>
539 http://www.gnu.org/licenses/gpl.html</a><br/>
541 Some icons by <a href='https://www.gnome.org'>
542 the GNOME project</a>
544 '''.format(self
.context
.package_version
, self
.context
.lib_version
))
546 QtGui
.QMessageBox
.about(self
, 'About sigrok-meter', text
)