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):
177 # In rare cases it can happen that the callback fires while
178 # the interpreter is shutting down. Then the sigrok module
179 # is already set to 'None'.
182 if packet.type == sr.PacketType.ANALOG:
183 self.measured.emit(device, packet.payload)
185 # wait a short time so that in any case we don't flood the GUI
186 # with new data (for example if the demo device is used)
187 self.thread().msleep(100)
189 # signal used to start the worker across threads
190 _start_signal = QtCore.Signal()
192 def __init__(self, drivers, loglevel):
193 super(self.__class__, self).__init__()
195 self.worker = self.Worker(drivers, loglevel)
196 self.thread = QtCore.QThread()
197 self.worker.moveToThread(self.thread)
199 self._start_signal.connect(self.worker.start_sampling)
201 # expose the signals of the worker
202 self.measured = self.worker.measured
203 self.error = self.worker.error
208 '''Starts sampling'''
209 self._start_signal.emit()
212 '''Stops sampling and the background thread.'''
213 self.worker.stop_sampling()
215 # the timeout is needed when the demo device is used, because it
216 # produces so much outstanding data that quitting takes a long time
217 self.thread.wait(500)
219 def sr_pkg_version(self):
220 '''Returns the version number of the libsigrok package.'''
221 return self.worker.sr_pkg_version
223 def sr_lib_version(self):
224 '''Returns the version number fo the libsigrok library.'''
225 return self.worker.sr_lib_version
227 class MeasurementDataModel(QtGui.QStandardItemModel):
228 '''Model to hold the measured values.'''
230 '''Role used to identify and find the item.'''
231 _idRole = QtCore.Qt.UserRole + 1
233 '''Role used to store the device vendor and model.'''
234 descRole = QtCore.Qt.UserRole + 2
236 def __init__(self, parent):
237 super(self.__class__, self).__init__(parent)
239 # Use the description text to sort the items for now, because the
240 # _idRole holds tuples, and using them to sort doesn't work.
241 self.setSortRole(MeasurementDataModel.descRole)
243 # Used in 'format_mag()' to check against.
244 self.inf = float('inf')
246 def format_unit(self, u):
250 sr.Unit.OHM: u'\u03A9',
253 sr.Unit.CELSIUS: u'\u00B0C',
254 sr.Unit.FAHRENHEIT: u'\u00B0F',
256 sr.Unit.PERCENTAGE: '%',
259 sr.Unit.SIEMENS: 'S',
260 sr.Unit.DECIBEL_MW: 'dBu',
261 sr.Unit.DECIBEL_VOLT: 'dBV',
263 sr.Unit.DECIBEL_SPL: 'dB',
264 # sr.Unit.CONCENTRATION
265 sr.Unit.REVOLUTIONS_PER_MINUTE: 'rpm',
266 sr.Unit.VOLT_AMPERE: 'VA',
268 sr.Unit.WATT_HOUR: 'Wh',
269 sr.Unit.METER_SECOND: 'm/s',
270 sr.Unit.HECTOPASCAL: 'hPa',
271 sr.Unit.HUMIDITY_293K: '%rF',
272 sr.Unit.DEGREE: u'\u00B0',
276 return units.get(u, '')
278 def format_mqflags(self, mqflags):
279 if sr.QuantityFlag.AC in mqflags:
281 elif sr.QuantityFlag.DC in mqflags:
286 def format_mag(self, mag):
289 return '{:f}'.format(mag)
291 def getItem(self, device, channel):
292 '''Returns the item for the device + channel combination from the model,
293 or creates a new item if no existing one matches.'''
295 # unique identifier for the device + channel
296 # TODO: isn't there something better?
300 device.serial_number(),
301 device.connection_id(),
305 # find the correct item in the model
306 for row in range(self.rowCount()):
307 item = self.item(row)
308 rid = item.data(MeasurementDataModel._idRole)
309 rid = tuple(rid) # PySide returns a list
313 # nothing found, create a new item
314 desc = '{} {}, channel "{}"'.format(
315 device.vendor, device.model, channel.name)
317 item = QtGui.QStandardItem()
318 item.setData(uid, MeasurementDataModel._idRole)
319 item.setData(desc, MeasurementDataModel.descRole)
324 @QtCore.Slot(object, object)
325 def update(self, device, payload):
326 '''Updates the data for the device (+channel) with the most recent
327 measurement from the given payload.'''
329 if not len(payload.channels):
332 # TODO: find a device with multiple channels in one packet
333 channel = payload.channels[0]
335 item = self.getItem(device, channel)
337 # the most recent value
338 mag = payload.data[0][-1]
340 unit_str = self.format_unit(payload.unit)
341 mqflags_str = self.format_mqflags(payload.mq_flags)
342 mag_str = self.format_mag(mag)
343 disp = ' '.join([mag_str, unit_str, mqflags_str])
344 item.setData(disp, QtCore.Qt.DisplayRole)
346 class MultimeterDelegate(QtGui.QStyledItemDelegate):
347 '''Delegate to show the data items from a MeasurementDataModel.'''
349 def __init__(self, parent, font):
350 '''Initializes the delegate.
352 :param font: Font used for the description text, the value is drawn
353 with a slightly bigger and bold variant of the font.
356 super(self.__class__, self).__init__(parent)
359 self._bfont = QtGui.QFont(self._nfont)
361 self._bfont.setBold(True)
362 if self._bfont.pixelSize() != -1:
363 self._bfont.setPixelSize(self._bfont.pixelSize() * 1.8)
365 self._bfont.setPointSizeF(self._bfont.pointSizeF() * 1.8)
367 fi = QtGui.QFontInfo(self._nfont)
368 self._nfontheight = fi.pixelSize()
370 fm = QtGui.QFontMetrics(self._bfont)
371 r = fm.boundingRect('-XX.XXXXXX X XX')
372 self._size = QtCore.QSize(r.width() * 1.2, r.height() * 3.5)
374 def sizeHint(self, option=None, index=None):
377 def paint(self, painter, options, index):
378 value = index.data(QtCore.Qt.DisplayRole)
379 desc = index.data(MeasurementDataModel.descRole)
381 # description in the top left corner
382 painter.setFont(self._nfont)
383 p = options.rect.topLeft()
384 p += QtCore.QPoint(self._nfontheight, 2 * self._nfontheight)
385 painter.drawText(p, desc)
387 # value in the center
388 painter.setFont(self._bfont)
389 r = options.rect.adjusted(self._nfontheight, 2.5 * self._nfontheight,
391 painter.drawText(r, QtCore.Qt.AlignCenter, value)
393 class EmptyMessageListView(QtGui.QListView):
394 '''List view that shows a message if the model im empty.'''
396 def __init__(self, message, parent=None):
397 super(self.__class__, self).__init__(parent)
399 self._message = message
401 def paintEvent(self, event):
403 if m and m.rowCount():
404 super(self.__class__, self).paintEvent(event)
407 painter = QtGui.QPainter(self.viewport())
408 painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
410 class SigrokMeter(QtGui.QMainWindow):
411 '''The main window of the application.'''
413 def __init__(self, thread):
414 super(SigrokMeter, self).__init__()
416 self.delegate = MultimeterDelegate(self, self.font())
417 self.model = MeasurementDataModel(self)
418 self.model.rowsInserted.connect(self.modelRowsInserted)
423 self.thread.measured.connect(self.model.update)
424 self.thread.error.connect(self.error)
428 self.setWindowTitle('sigrok-meter')
429 # resizing the listView below will increase this again
432 p = os.path.abspath(os.path.dirname(__file__))
433 p = os.path.join(p, 'sigrok-logo-notext.png')
434 self.setWindowIcon(QtGui.QIcon(p))
436 actionQuit = QtGui.QAction(self)
437 actionQuit.setText('&Quit')
438 actionQuit.setIcon(QtGui.QIcon.fromTheme('application-exit'))
439 actionQuit.setShortcut('Ctrl+Q')
440 actionQuit.triggered.connect(self.close)
442 actionAbout = QtGui.QAction(self)
443 actionAbout.setText('&About')
444 actionAbout.setIcon(QtGui.QIcon.fromTheme('help-about'))
445 actionAbout.triggered.connect(self.show_about)
447 menubar = self.menuBar()
448 menuFile = menubar.addMenu('&File')
449 menuFile.addAction(actionQuit)
450 menuHelp = menubar.addMenu('&Help')
451 menuHelp.addAction(actionAbout)
453 self.listView = EmptyMessageListView('waiting for data...')
454 self.listView.setFrameShape(QtGui.QFrame.NoFrame)
455 self.listView.viewport().setBackgroundRole(QtGui.QPalette.Window)
456 self.listView.viewport().setAutoFillBackground(True)
457 self.listView.setMinimumWidth(260)
458 self.listView.setSelectionMode(QtGui.QAbstractItemView.NoSelection)
459 self.listView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
460 self.listView.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel)
461 self.listView.setItemDelegate(self.delegate)
462 self.listView.setModel(self.model)
463 self.listView.setUniformItemSizes(True)
464 self.listView.setMinimumSize(self.delegate.sizeHint())
466 self.setCentralWidget(self.listView)
467 self.centralWidget().setContentsMargins(0, 0, 0, 0)
470 def show_about(self):
471 text = textwrap.dedent('''\
473 <b>sigrok-meter</b><br/>
475 Using libsigrok {} (lib version {}).<br/>
476 <a href='http://www.sigrok.org'>
477 http://www.sigrok.org</a><br/>
479 This program comes with ABSOLUTELY NO WARRANTY;<br/>
481 <a href='http://www.gnu.org/licenses/gpl.html'>
482 http://www.gnu.org/licenses/gpl.html</a>
484 '''.format(self.thread.sr_pkg_version(), self.thread.sr_lib_version()))
486 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)
489 def error(self, msg):
490 '''Error handler for the sampling thread.'''
491 QtGui.QMessageBox.critical(self, 'Error', msg)
494 @QtCore.Slot(object, int, int)
495 def modelRowsInserted(self, parent, start, end):
496 '''Resizes the list view to the size of the content.'''
498 rows = self.model.rowCount()
499 dh = self.delegate.sizeHint().height()
500 self.listView.setMinimumHeight(dh * rows)
502 if __name__ == '__main__':
503 thread = SamplingThread(args['drivers'], args['loglevel'])
505 app = QtGui.QApplication([])
506 s = SigrokMeter(thread)