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, context, drivers):
132 super(self.__class__, self).__init__()
134 self.context = context
135 self.drivers = drivers
137 self.sampling = False
140 def start_sampling(self):
142 for name, options in self.drivers:
144 dr = self.context.drivers[name]
145 devices.append(dr.scan(**options)[0])
148 'Unable to get device for driver "{}".'.format(name))
151 self.session = self.context.create_session()
153 self.session.add_device(dev)
155 self.session.add_datafeed_callback(self.callback)
160 # If sampling is 'True' here, it means that 'stop_sampling()' was
161 # not called, therefore 'session.run()' ended too early, indicating
164 self.error.emit('An error occured during the acquisition.')
166 def stop_sampling(self):
168 self.sampling = False
171 def callback(self, device, packet):
173 # In rare cases it can happen that the callback fires while
174 # the interpreter is shutting down. Then the sigrok module
175 # is already set to 'None'.
178 if packet.type == sr.PacketType.ANALOG:
179 self.measured.emit(device, packet.payload)
181 # signal used to start the worker across threads
182 _start_signal = QtCore.Signal()
184 def __init__(self, context, drivers):
185 super(self.__class__, self).__init__()
187 self.worker = self.Worker(context, drivers)
188 self.thread = QtCore.QThread()
189 self.worker.moveToThread(self.thread)
191 self._start_signal.connect(self.worker.start_sampling)
193 # expose the signals of the worker
194 self.measured = self.worker.measured
195 self.error = self.worker.error
200 '''Starts sampling'''
201 self._start_signal.emit()
204 '''Stops sampling and the background thread.'''
205 self.worker.stop_sampling()
209 class MeasurementDataModel(QtGui.QStandardItemModel):
210 '''Model to hold the measured values.'''
212 '''Role used to identify and find the item.'''
213 _idRole = QtCore.Qt.UserRole + 1
215 '''Role used to store the device vendor and model.'''
216 descRole = QtCore.Qt.UserRole + 2
218 def __init__(self, parent):
219 super(self.__class__, self).__init__(parent)
221 # Use the description text to sort the items for now, because the
222 # _idRole holds tuples, and using them to sort doesn't work.
223 self.setSortRole(MeasurementDataModel.descRole)
225 # Used in 'format_mag()' to check against.
226 self.inf = float('inf')
228 def format_unit(self, u):
232 sr.Unit.OHM: u'\u03A9',
235 sr.Unit.CELSIUS: u'\u00B0C',
236 sr.Unit.FAHRENHEIT: u'\u00B0F',
238 sr.Unit.PERCENTAGE: '%',
241 sr.Unit.SIEMENS: 'S',
242 sr.Unit.DECIBEL_MW: 'dBu',
243 sr.Unit.DECIBEL_VOLT: 'dBV',
245 sr.Unit.DECIBEL_SPL: 'dB',
246 # sr.Unit.CONCENTRATION
247 sr.Unit.REVOLUTIONS_PER_MINUTE: 'rpm',
248 sr.Unit.VOLT_AMPERE: 'VA',
250 sr.Unit.WATT_HOUR: 'Wh',
251 sr.Unit.METER_SECOND: 'm/s',
252 sr.Unit.HECTOPASCAL: 'hPa',
253 sr.Unit.HUMIDITY_293K: '%rF',
254 sr.Unit.DEGREE: u'\u00B0',
258 return units.get(u, '')
260 def format_mqflags(self, mqflags):
261 if sr.QuantityFlag.AC in mqflags:
263 elif sr.QuantityFlag.DC in mqflags:
268 def format_mag(self, mag):
271 return '{:f}'.format(mag)
273 def getItem(self, device, channel):
274 '''Returns the item for the device + channel combination from the model,
275 or creates a new item if no existing one matches.'''
277 # unique identifier for the device + channel
278 # TODO: isn't there something better?
282 device.serial_number(),
283 device.connection_id(),
287 # find the correct item in the model
288 for row in range(self.rowCount()):
289 item = self.item(row)
290 rid = item.data(MeasurementDataModel._idRole)
291 rid = tuple(rid) # PySide returns a list
295 # nothing found, create a new item
296 desc = '{} {}, channel "{}"'.format(
297 device.vendor, device.model, channel.name)
299 item = QtGui.QStandardItem()
300 item.setData(uid, MeasurementDataModel._idRole)
301 item.setData(desc, MeasurementDataModel.descRole)
306 @QtCore.Slot(object, object)
307 def update(self, device, payload):
308 '''Updates the data for the device (+channel) with the most recent
309 measurement from the given payload.'''
311 if not len(payload.channels):
314 # TODO: find a device with multiple channels in one packet
315 channel = payload.channels[0]
317 item = self.getItem(device, channel)
319 # the most recent value
320 mag = payload.data[0][-1]
322 unit_str = self.format_unit(payload.unit)
323 mqflags_str = self.format_mqflags(payload.mq_flags)
324 mag_str = self.format_mag(mag)
325 disp = ' '.join([mag_str, unit_str, mqflags_str])
326 item.setData(disp, QtCore.Qt.DisplayRole)
328 class MultimeterDelegate(QtGui.QStyledItemDelegate):
329 '''Delegate to show the data items from a MeasurementDataModel.'''
331 def __init__(self, parent, font):
332 '''Initializes the delegate.
334 :param font: Font used for the description text, the value is drawn
335 with a slightly bigger and bold variant of the font.
338 super(self.__class__, self).__init__(parent)
341 self._bfont = QtGui.QFont(self._nfont)
343 self._bfont.setBold(True)
344 if self._bfont.pixelSize() != -1:
345 self._bfont.setPixelSize(self._bfont.pixelSize() * 1.8)
347 self._bfont.setPointSizeF(self._bfont.pointSizeF() * 1.8)
349 fi = QtGui.QFontInfo(self._nfont)
350 self._nfontheight = fi.pixelSize()
352 fm = QtGui.QFontMetrics(self._bfont)
353 r = fm.boundingRect('-XX.XXXXXX X XX')
354 self._size = QtCore.QSize(r.width() * 1.2, r.height() * 3.5)
356 def sizeHint(self, option=None, index=None):
359 def paint(self, painter, options, index):
360 value = index.data(QtCore.Qt.DisplayRole)
361 desc = index.data(MeasurementDataModel.descRole)
363 # description in the top left corner
364 painter.setFont(self._nfont)
365 p = options.rect.topLeft()
366 p += QtCore.QPoint(self._nfontheight, 2 * self._nfontheight)
367 painter.drawText(p, desc)
369 # value in the center
370 painter.setFont(self._bfont)
371 r = options.rect.adjusted(self._nfontheight, 2.5 * self._nfontheight,
373 painter.drawText(r, QtCore.Qt.AlignCenter, value)
375 class EmptyMessageListView(QtGui.QListView):
376 '''List view that shows a message if the model im empty.'''
378 def __init__(self, message, parent=None):
379 super(self.__class__, self).__init__(parent)
381 self._message = message
383 def paintEvent(self, event):
385 if m and m.rowCount():
386 super(self.__class__, self).paintEvent(event)
389 painter = QtGui.QPainter(self.viewport())
390 painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
392 class SigrokMeter(QtGui.QMainWindow):
393 '''The main window of the application.'''
395 def __init__(self, context, drivers):
396 super(SigrokMeter, self).__init__()
398 self.context = context
400 self.delegate = MultimeterDelegate(self, self.font())
401 self.model = MeasurementDataModel(self)
402 self.model.rowsInserted.connect(self.modelRowsInserted)
406 self.thread = SamplingThread(self.context, drivers)
407 self.thread.measured.connect(self.model.update)
408 self.thread.error.connect(self.error)
412 self.setWindowTitle('sigrok-meter')
413 # resizing the listView below will increase this again
416 p = os.path.abspath(os.path.dirname(__file__))
417 p = os.path.join(p, 'sigrok-logo-notext.png')
418 self.setWindowIcon(QtGui.QIcon(p))
420 actionQuit = QtGui.QAction(self)
421 actionQuit.setText('&Quit')
422 actionQuit.setIcon(QtGui.QIcon.fromTheme('application-exit'))
423 actionQuit.setShortcut('Ctrl+Q')
424 actionQuit.triggered.connect(self.close)
426 actionAbout = QtGui.QAction(self)
427 actionAbout.setText('&About')
428 actionAbout.setIcon(QtGui.QIcon.fromTheme('help-about'))
429 actionAbout.triggered.connect(self.show_about)
431 menubar = self.menuBar()
432 menuFile = menubar.addMenu('&File')
433 menuFile.addAction(actionQuit)
434 menuHelp = menubar.addMenu('&Help')
435 menuHelp.addAction(actionAbout)
437 self.listView = EmptyMessageListView('waiting for data...')
438 self.listView.setFrameShape(QtGui.QFrame.NoFrame)
439 self.listView.viewport().setBackgroundRole(QtGui.QPalette.Window)
440 self.listView.viewport().setAutoFillBackground(True)
441 self.listView.setMinimumWidth(260)
442 self.listView.setSelectionMode(QtGui.QAbstractItemView.NoSelection)
443 self.listView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
444 self.listView.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel)
445 self.listView.setItemDelegate(self.delegate)
446 self.listView.setModel(self.model)
447 self.listView.setUniformItemSizes(True)
448 self.listView.setMinimumSize(self.delegate.sizeHint())
450 self.setCentralWidget(self.listView)
451 self.centralWidget().setContentsMargins(0, 0, 0, 0)
453 def closeEvent(self, event):
458 def show_about(self):
459 text = textwrap.dedent('''\
461 <b>sigrok-meter</b><br/>
463 Using libsigrok {} (lib version {}).<br/>
464 <a href='http://www.sigrok.org'>
465 http://www.sigrok.org</a><br/>
467 This program comes with ABSOLUTELY NO WARRANTY;<br/>
469 <a href='http://www.gnu.org/licenses/gpl.html'>
470 http://www.gnu.org/licenses/gpl.html</a>
472 '''.format(self.context.package_version, self.context.lib_version))
474 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)
477 def error(self, msg):
478 '''Error handler for the sampling thread.'''
479 QtGui.QMessageBox.critical(self, 'Error', msg)
482 @QtCore.Slot(object, int, int)
483 def modelRowsInserted(self, parent, start, end):
484 '''Resizes the list view to the size of the content.'''
486 rows = self.model.rowCount()
487 dh = self.delegate.sizeHint().height()
488 self.listView.setMinimumHeight(dh * rows)
490 if __name__ == '__main__':
491 context = sr.Context_create()
492 context.log_level = args['loglevel']
494 app = QtGui.QApplication([])
495 s = SigrokMeter(context, args['drivers'])
498 sys.exit(app.exec_())