4 ## This file is part of the sigrok-meter project.
6 ## Copyright (C) 2013 Uwe Hermann <uwe@hermann-uwe.de>
7 ## Copyright (C) 2014 Jens Steinhauser <jens.steinhauser@gmail.com>
9 ## This program is free software; you can redistribute it and/or modify
10 ## it under the terms of the GNU General Public License as published by
11 ## the Free Software Foundation; either version 2 of the License, or
12 ## (at your option) any later version.
14 ## This program is distributed in the hope that it will be useful,
15 ## but WITHOUT ANY WARRANTY; without even the implied warranty of
16 ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 ## GNU General Public License for more details.
19 ## You should have received a copy of the GNU General Public License
20 ## along with this program; if not, write to the Free Software
21 ## Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
28 import sigrok.core as sr
32 default_drivers = [('demo', {'analog_channels': 4})]
33 default_loglevel = sr.LogLevel.WARN
36 parser = argparse.ArgumentParser(
37 description='Simple sigrok GUI for multimeters and dataloggers.',
38 epilog=textwrap.dedent('''\
39 The DRIVER string is the same as for sigrok-cli(1).
43 %(prog)s --driver tecpel-dmm-8061-ser:conn=/dev/ttyUSB0
45 %(prog)s --driver uni-t-ut61e:conn=1a86.e008
47 formatter_class=argparse.RawDescriptionHelpFormatter)
49 parser.add_argument('-d', '--driver',
51 help='The driver to use')
52 parser.add_argument('-l', '--loglevel',
54 help='Set loglevel (5 is most verbose)')
55 parser.add_argument('--pyside',
58 help='Force use of PySide (default is to use PyQt4)')
59 args = parser.parse_args()
62 'drivers': default_drivers,
63 'loglevel': default_loglevel,
68 result['drivers'] = []
70 m = re.match('(?P<name>[^:]+)(?P<opts>(:[^:=]+=[^:=]+)*)', d)
72 sys.exit('error parsing option "{}"'.format(d))
74 opts = m.group('opts').split(':')[1:]
75 opts = [tuple(kv.split('=')) for kv in opts]
78 result['drivers'].append((m.group('name'), opts))
80 if args.loglevel != None:
82 result['loglevel'] = sr.LogLevel.get(args.loglevel)
84 sys.exit('error: invalid log level')
88 if __name__ == '__main__':
89 # The command line parsing and import of the Qt modules is done here,
90 # so that the modules are imported before the classes below derive
91 # from classes therein. The rest of the main function is at the bottom.
96 from PySide import QtCore, QtGui
99 # Use version 2 API in all cases, because that's what PySide uses.
101 sip.setapi('QVariant', 2)
102 sip.setapi('QDate', 2)
103 sip.setapi('QDateTime', 2)
104 sip.setapi('QString', 2)
105 sip.setapi('QTextStream', 2)
106 sip.setapi('QTime', 2)
107 sip.setapi('QUrl', 2)
108 sip.setapi('QVariant', 2)
110 from PyQt4 import QtCore, QtGui
112 # Add PySide compatible names.
113 QtCore.Signal = QtCore.pyqtSignal
114 QtCore.Slot = QtCore.pyqtSlot
116 sys.stderr.write('import of PyQt4 failed, using PySide\n')
117 from PySide import QtCore, QtGui
119 class SamplingThread(QtCore.QObject):
120 '''A class that handles the reception of sigrok packets in the background.'''
122 class Worker(QtCore.QObject):
123 '''Helper class that does the actual work in another thread.'''
125 '''Signal emitted when new data arrived.'''
126 measured = QtCore.Signal(object, object)
128 '''Signal emmited in case of an error.'''
129 error = QtCore.Signal(str)
131 def __init__(self, drivers, loglevel):
132 super(self.__class__, self).__init__()
134 self.sampling = False
135 self.drivers = drivers
137 self.context = sr.Context_create()
138 self.context.log_level = loglevel
140 self.sr_pkg_version = self.context.package_version
141 self.sr_lib_version = self.context.lib_version
144 def start_sampling(self):
146 for name, options in self.drivers:
148 dr = self.context.drivers[name]
149 devices.append(dr.scan(**options)[0])
152 'Unable to get device for driver "{}".'.format(name))
155 self.session = self.context.create_session()
157 self.session.add_device(dev)
159 self.session.add_datafeed_callback(self.callback)
164 # If sampling is 'True' here, it means that 'stop_sampling()' was
165 # not called, therefore 'session.run()' ended too early, indicating
168 self.error.emit('An error occured during the acquisition.')
170 def stop_sampling(self):
172 self.sampling = False
175 def callback(self, device, packet):
176 if packet.type == sr.PacketType.ANALOG:
177 self.measured.emit(device, packet.payload)
179 # wait a short time so that in any case we don't flood the GUI
180 # with new data (for example if the demo device is used)
181 self.thread().msleep(100)
183 # signal used to start the worker across threads
184 _start_signal = QtCore.Signal()
186 def __init__(self, drivers, loglevel):
187 super(self.__class__, self).__init__()
189 self.worker = self.Worker(drivers, loglevel)
190 self.thread = QtCore.QThread()
191 self.worker.moveToThread(self.thread)
193 self._start_signal.connect(self.worker.start_sampling)
195 # expose the signals of the worker
196 self.measured = self.worker.measured
197 self.error = self.worker.error
202 '''Starts sampling'''
203 self._start_signal.emit()
206 '''Stops sampling and the background thread.'''
207 self.worker.stop_sampling()
209 # the timeout is needed when the demo device is used, because it
210 # produces so much outstanding data that quitting takes a long time
211 self.thread.wait(500)
213 def sr_pkg_version(self):
214 '''Returns the version number of the libsigrok package.'''
215 return self.worker.sr_pkg_version
217 def sr_lib_version(self):
218 '''Returns the version number fo the libsigrok library.'''
219 return self.worker.sr_lib_version
221 class MeasurementDataModel(QtGui.QStandardItemModel):
222 '''Model to hold the measured values.'''
224 '''Role used to identify and find the item.'''
225 _idRole = QtCore.Qt.UserRole + 1
227 '''Role used to store the device vendor and model.'''
228 descRole = QtCore.Qt.UserRole + 2
230 def __init__(self, parent):
231 super(self.__class__, self).__init__(parent)
233 # used in 'format_mag()' to check against
234 self.inf = float('inf')
236 def format_unit(self, u):
240 sr.Unit.OHM: u'\u03A9',
243 sr.Unit.CELSIUS: u'\u00B0C',
244 sr.Unit.FAHRENHEIT: u'\u00B0F',
246 sr.Unit.PERCENTAGE: '%',
249 sr.Unit.SIEMENS: 'S',
250 sr.Unit.DECIBEL_MW: 'dBu',
251 sr.Unit.DECIBEL_VOLT: 'dBV',
253 sr.Unit.DECIBEL_SPL: 'dB',
254 # sr.Unit.CONCENTRATION
255 sr.Unit.REVOLUTIONS_PER_MINUTE: 'rpm',
256 sr.Unit.VOLT_AMPERE: 'VA',
258 sr.Unit.WATT_HOUR: 'Wh',
259 sr.Unit.METER_SECOND: 'm/s',
260 sr.Unit.HECTOPASCAL: 'hPa',
261 sr.Unit.HUMIDITY_293K: '%rF',
262 sr.Unit.DEGREE: u'\u00B0',
266 return units.get(u, '')
268 def format_mqflags(self, mqflags):
269 if sr.QuantityFlag.AC in mqflags:
271 elif sr.QuantityFlag.DC in mqflags:
276 def format_mag(self, mag):
279 return '{:f}'.format(mag)
281 def getItem(self, device, channel):
282 '''Returns the item for the device + channel combination from the model,
283 or creates a new item if no existing one matches.'''
285 # unique identifier for the device + channel
286 # TODO: isn't there something better?
290 device.serial_number(),
291 device.connection_id(),
295 # find the correct item in the model
296 for row in range(self.rowCount()):
297 item = self.item(row)
298 rid = item.data(MeasurementDataModel._idRole)
299 rid = tuple(rid) # PySide returns a list
303 # nothing found, create a new item
304 desc = '{} {}, channel "{}"'.format(
305 device.vendor, device.model, channel.name)
307 item = QtGui.QStandardItem()
308 item.setData(uid, MeasurementDataModel._idRole)
309 item.setData(desc, MeasurementDataModel.descRole)
313 @QtCore.Slot(object, object)
314 def update(self, device, payload):
315 '''Updates the data for the device (+channel) with the most recent
316 measurement from the given payload.'''
318 if not len(payload.channels):
321 # TODO: find a device with multiple channels in one packet
322 channel = payload.channels[0]
324 item = self.getItem(device, channel)
326 # the most recent value
327 mag = payload.data[0][-1]
329 unit_str = self.format_unit(payload.unit)
330 mqflags_str = self.format_mqflags(payload.mq_flags)
331 mag_str = self.format_mag(mag)
332 disp = ' '.join([mag_str, unit_str, mqflags_str])
333 item.setData(disp, QtCore.Qt.DisplayRole)
335 class MultimeterDelegate(QtGui.QStyledItemDelegate):
336 '''Delegate to show the data items from a MeasurementDataModel.'''
338 def __init__(self, parent, font):
339 '''Initializes the delegate.
341 :param font: Font used for the description text, the value is drawn
342 with a slightly bigger and bold variant of the font.
345 super(self.__class__, self).__init__(parent)
348 self._bfont = QtGui.QFont(self._nfont)
350 self._bfont.setBold(True)
351 if self._bfont.pixelSize() != -1:
352 self._bfont.setPixelSize(self._bfont.pixelSize() * 1.8)
354 self._bfont.setPointSizeF(self._bfont.pointSizeF() * 1.8)
356 fi = QtGui.QFontInfo(self._nfont)
357 self._nfontheight = fi.pixelSize()
359 fm = QtGui.QFontMetrics(self._bfont)
360 r = fm.boundingRect('-XX.XXXXXX X XX')
361 self._size = QtCore.QSize(r.width() * 1.2, r.height() * 3.5)
363 def sizeHint(self, option=None, index=None):
366 def paint(self, painter, options, index):
367 value = index.data(QtCore.Qt.DisplayRole)
368 desc = index.data(MeasurementDataModel.descRole)
370 # description in the top left corner
371 painter.setFont(self._nfont)
372 p = options.rect.topLeft()
373 p += QtCore.QPoint(self._nfontheight, 2 * self._nfontheight)
374 painter.drawText(p, desc)
376 # value in the center
377 painter.setFont(self._bfont)
378 r = options.rect.adjusted(self._nfontheight, 2.5 * self._nfontheight,
380 painter.drawText(r, QtCore.Qt.AlignCenter, value)
382 class EmptyMessageListView(QtGui.QListView):
383 '''List view that shows a message if the model im empty.'''
385 def __init__(self, message, parent=None):
386 super(self.__class__, self).__init__(parent)
388 self._message = message
390 def paintEvent(self, event):
392 if m and m.rowCount():
393 super(self.__class__, self).paintEvent(event)
396 painter = QtGui.QPainter(self.viewport())
397 painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
399 class SigrokMeter(QtGui.QMainWindow):
400 '''The main window of the application.'''
402 def __init__(self, thread):
403 super(SigrokMeter, self).__init__()
405 self.delegate = MultimeterDelegate(self, self.font())
406 self.model = MeasurementDataModel(self)
407 self.model.rowsInserted.connect(self.modelRowsInserted)
412 self.thread.measured.connect(self.model.update)
413 self.thread.error.connect(self.error)
417 self.setWindowTitle('sigrok-meter')
418 # resizing the listView below will increase this again
421 p = os.path.abspath(os.path.dirname(__file__))
422 p = os.path.join(p, 'sigrok-logo-notext.png')
423 self.setWindowIcon(QtGui.QIcon(p))
425 actionQuit = QtGui.QAction(self)
426 actionQuit.setText('&Quit')
427 actionQuit.setIcon(QtGui.QIcon.fromTheme('application-exit'))
428 actionQuit.setShortcut('Ctrl+Q')
429 actionQuit.triggered.connect(self.close)
431 actionAbout = QtGui.QAction(self)
432 actionAbout.setText('&About')
433 actionAbout.setIcon(QtGui.QIcon.fromTheme('help-about'))
434 actionAbout.triggered.connect(self.show_about)
436 menubar = self.menuBar()
437 menuFile = menubar.addMenu('&File')
438 menuFile.addAction(actionQuit)
439 menuHelp = menubar.addMenu('&Help')
440 menuHelp.addAction(actionAbout)
442 self.listView = EmptyMessageListView('waiting for data...')
443 self.listView.setFrameShape(QtGui.QFrame.NoFrame)
444 self.listView.viewport().setBackgroundRole(QtGui.QPalette.Window)
445 self.listView.viewport().setAutoFillBackground(True)
446 self.listView.setMinimumWidth(260)
447 self.listView.setSelectionMode(QtGui.QAbstractItemView.NoSelection)
448 self.listView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
449 self.listView.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel)
450 self.listView.setItemDelegate(self.delegate)
451 self.listView.setModel(self.model)
452 self.listView.setUniformItemSizes(True)
453 self.listView.setMinimumSize(self.delegate.sizeHint())
455 self.setCentralWidget(self.listView)
456 self.centralWidget().setContentsMargins(0, 0, 0, 0)
459 def show_about(self):
460 text = textwrap.dedent('''\
462 <b>sigrok-meter</b><br/>
464 Using libsigrok {} (lib version {}).<br/>
465 <a href='http://www.sigrok.org'>
466 http://www.sigrok.org</a><br/>
468 This program comes with ABSOLUTELY NO WARRANTY;<br/>
470 <a href='http://www.gnu.org/licenses/gpl.html'>
471 http://www.gnu.org/licenses/gpl.html</a>
473 '''.format(self.thread.sr_pkg_version(), self.thread.sr_lib_version()))
475 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)
478 def error(self, msg):
479 '''Error handler for the sampling thread.'''
480 QtGui.QMessageBox.critical(self, 'Error', msg)
483 @QtCore.Slot(object, int, int)
484 def modelRowsInserted(self, parent, start, end):
485 '''Resizes the list view to the size of the content.'''
487 rows = self.model.rowCount()
488 dh = self.delegate.sizeHint().height()
489 self.listView.setMinimumHeight(dh * rows)
491 if __name__ == '__main__':
492 thread = SamplingThread(args['drivers'], args['loglevel'])
494 app = QtGui.QApplication([])
495 s = SigrokMeter(thread)