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, 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:
181 if not len(packet.payload.channels):
184 # TODO: find a device with multiple channels in one packet
185 channel = packet.payload.channels[0]
187 # the most recent value
188 value = packet.payload.data[0][-1]
190 self.measured.emit(device, channel,
191 (value, packet.payload.unit, packet.payload.mq_flags))
193 # signal used to start the worker across threads
194 _start_signal = QtCore.Signal()
196 def __init__(self, context, drivers):
197 super(self.__class__, self).__init__()
199 self.worker = self.Worker(context, drivers)
200 self.thread = QtCore.QThread()
201 self.worker.moveToThread(self.thread)
203 self._start_signal.connect(self.worker.start_sampling)
205 # expose the signals of the worker
206 self.measured = self.worker.measured
207 self.error = self.worker.error
212 '''Starts sampling'''
213 self._start_signal.emit()
216 '''Stops sampling and the background thread.'''
217 self.worker.stop_sampling()
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 # Use the description text to sort the items for now, because the
234 # _idRole holds tuples, and using them to sort doesn't work.
235 self.setSortRole(MeasurementDataModel.descRole)
237 # Used in 'format_value()' to check against.
238 self.inf = float('inf')
240 def format_unit(self, u):
244 sr.Unit.OHM: u'\u03A9',
247 sr.Unit.CELSIUS: u'\u00B0C',
248 sr.Unit.FAHRENHEIT: u'\u00B0F',
250 sr.Unit.PERCENTAGE: '%',
253 sr.Unit.SIEMENS: 'S',
254 sr.Unit.DECIBEL_MW: 'dBu',
255 sr.Unit.DECIBEL_VOLT: 'dBV',
257 sr.Unit.DECIBEL_SPL: 'dB',
258 # sr.Unit.CONCENTRATION
259 sr.Unit.REVOLUTIONS_PER_MINUTE: 'rpm',
260 sr.Unit.VOLT_AMPERE: 'VA',
262 sr.Unit.WATT_HOUR: 'Wh',
263 sr.Unit.METER_SECOND: 'm/s',
264 sr.Unit.HECTOPASCAL: 'hPa',
265 sr.Unit.HUMIDITY_293K: '%rF',
266 sr.Unit.DEGREE: u'\u00B0',
270 return units.get(u, '')
272 def format_mqflags(self, mqflags):
273 if sr.QuantityFlag.AC in mqflags:
275 elif sr.QuantityFlag.DC in mqflags:
280 def format_value(self, mag):
283 return '{:f}'.format(mag)
285 def getItem(self, device, channel):
286 '''Returns the item for the device + channel combination from the model,
287 or creates a new item if no existing one matches.'''
289 # unique identifier for the device + channel
290 # TODO: isn't there something better?
294 device.serial_number(),
295 device.connection_id(),
299 # find the correct item in the model
300 for row in range(self.rowCount()):
301 item = self.item(row)
302 rid = item.data(MeasurementDataModel._idRole)
303 rid = tuple(rid) # PySide returns a list
307 # nothing found, create a new item
308 desc = '{} {}, channel "{}"'.format(
309 device.vendor, device.model, channel.name)
311 item = QtGui.QStandardItem()
312 item.setData(uid, MeasurementDataModel._idRole)
313 item.setData(desc, MeasurementDataModel.descRole)
318 @QtCore.Slot(object, object, object)
319 def update(self, device, channel, data):
320 '''Updates the data for the device (+channel) with the most recent
321 measurement from the given payload.'''
323 item = self.getItem(device, channel)
325 value, unit, mqflags = data
326 value_str = self.format_value(value)
327 unit_str = self.format_unit(unit)
328 mqflags_str = self.format_mqflags(mqflags)
330 disp = ' '.join([value_str, unit_str, mqflags_str])
331 item.setData(disp, QtCore.Qt.DisplayRole)
333 class MultimeterDelegate(QtGui.QStyledItemDelegate):
334 '''Delegate to show the data items from a MeasurementDataModel.'''
336 def __init__(self, parent, font):
337 '''Initializes the delegate.
339 :param font: Font used for the description text, the value is drawn
340 with a slightly bigger and bold variant of the font.
343 super(self.__class__, self).__init__(parent)
346 self._bfont = QtGui.QFont(self._nfont)
348 self._bfont.setBold(True)
349 if self._bfont.pixelSize() != -1:
350 self._bfont.setPixelSize(self._bfont.pixelSize() * 1.8)
352 self._bfont.setPointSizeF(self._bfont.pointSizeF() * 1.8)
354 fi = QtGui.QFontInfo(self._nfont)
355 self._nfontheight = fi.pixelSize()
357 fm = QtGui.QFontMetrics(self._bfont)
358 r = fm.boundingRect('-XX.XXXXXX X XX')
359 self._size = QtCore.QSize(r.width() * 1.2, r.height() * 3.5)
361 def sizeHint(self, option=None, index=None):
364 def paint(self, painter, options, index):
365 value = index.data(QtCore.Qt.DisplayRole)
366 desc = index.data(MeasurementDataModel.descRole)
368 # description in the top left corner
369 painter.setFont(self._nfont)
370 p = options.rect.topLeft()
371 p += QtCore.QPoint(self._nfontheight, 2 * self._nfontheight)
372 painter.drawText(p, desc)
374 # value in the center
375 painter.setFont(self._bfont)
376 r = options.rect.adjusted(self._nfontheight, 2.5 * self._nfontheight,
378 painter.drawText(r, QtCore.Qt.AlignCenter, value)
380 class EmptyMessageListView(QtGui.QListView):
381 '''List view that shows a message if the model im empty.'''
383 def __init__(self, message, parent=None):
384 super(self.__class__, self).__init__(parent)
386 self._message = message
388 def paintEvent(self, event):
390 if m and m.rowCount():
391 super(self.__class__, self).paintEvent(event)
394 painter = QtGui.QPainter(self.viewport())
395 painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
397 class SigrokMeter(QtGui.QMainWindow):
398 '''The main window of the application.'''
400 def __init__(self, context, drivers):
401 super(SigrokMeter, self).__init__()
403 self.context = context
405 self.delegate = MultimeterDelegate(self, self.font())
406 self.model = MeasurementDataModel(self)
407 self.model.rowsInserted.connect(self.modelRowsInserted)
411 self.thread = SamplingThread(self.context, drivers)
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)
458 def closeEvent(self, event):
463 def show_about(self):
464 text = textwrap.dedent('''\
466 <b>sigrok-meter</b><br/>
468 Using libsigrok {} (lib version {}).<br/>
469 <a href='http://www.sigrok.org'>
470 http://www.sigrok.org</a><br/>
472 This program comes with ABSOLUTELY NO WARRANTY;<br/>
474 <a href='http://www.gnu.org/licenses/gpl.html'>
475 http://www.gnu.org/licenses/gpl.html</a>
477 '''.format(self.context.package_version, self.context.lib_version))
479 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)
482 def error(self, msg):
483 '''Error handler for the sampling thread.'''
484 QtGui.QMessageBox.critical(self, 'Error', msg)
487 @QtCore.Slot(object, int, int)
488 def modelRowsInserted(self, parent, start, end):
489 '''Resizes the list view to the size of the content.'''
491 rows = self.model.rowCount()
492 dh = self.delegate.sizeHint().height()
493 self.listView.setMinimumHeight(dh * rows)
495 if __name__ == '__main__':
496 context = sr.Context_create()
497 context.log_level = args['loglevel']
499 app = QtGui.QApplication([])
500 s = SigrokMeter(context, args['drivers'])
503 sys.exit(app.exec_())