]> sigrok.org Git - sigrok-meter.git/blob - sigrok-meter
6febbc393941d2738f42e7e6ce4460e220d65727
[sigrok-meter.git] / sigrok-meter
1 #!/usr/bin/env python
2
3 ##
4 ## This file is part of the sigrok-meter project.
5 ##
6 ## Copyright (C) 2013 Uwe Hermann <uwe@hermann-uwe.de>
7 ## Copyright (C) 2014 Jens Steinhauser <jens.steinhauser@gmail.com>
8 ##
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.
13 ##
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.
18 ##
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
22 ##
23
24 import argparse
25 import datetime
26 import os.path
27 import re
28 import sigrok.core as sr
29 import sys
30 import textwrap
31
32 default_drivers = [('demo', {'analog_channels': 4})]
33 default_loglevel = sr.LogLevel.WARN
34
35 def parse_cli():
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).
40
41             examples:
42
43               %(prog)s --driver tecpel-dmm-8061-ser:conn=/dev/ttyUSB0
44
45               %(prog)s --driver uni-t-ut61e:conn=1a86.e008
46         '''),
47         formatter_class=argparse.RawDescriptionHelpFormatter)
48
49     parser.add_argument('-d', '--driver',
50         action='append',
51         help='The driver to use')
52     parser.add_argument('-l', '--loglevel',
53         type=int,
54         help='Set loglevel (5 is most verbose)')
55     parser.add_argument('--pyside',
56         action='store_true',
57         default=False,
58         help='Force use of PySide (default is to use PyQt4)')
59     args = parser.parse_args()
60
61     result = {
62         'drivers': default_drivers,
63         'loglevel': default_loglevel,
64         'pyside': args.pyside
65     }
66
67     if args.driver:
68         result['drivers'] = []
69         for d in args.driver:
70             m = re.match('(?P<name>[^:]+)(?P<opts>(:[^:=]+=[^:=]+)*)', d)
71             if not m:
72                 sys.exit('error parsing option "{}"'.format(d))
73
74             opts = m.group('opts').split(':')[1:]
75             opts = [tuple(kv.split('=')) for kv in opts]
76             opts = dict(opts)
77
78             result['drivers'].append((m.group('name'), opts))
79
80     if args.loglevel != None:
81         try:
82             result['loglevel'] = sr.LogLevel.get(args.loglevel)
83         except:
84             sys.exit('error: invalid log level')
85
86     return result
87
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.
92
93     args = parse_cli()
94
95     if args['pyside']:
96         from PySide import QtCore, QtGui
97     else:
98         try:
99             # Use version 2 API in all cases, because that's what PySide uses.
100             import sip
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)
109
110             from PyQt4 import QtCore, QtGui
111
112             # Add PySide compatible names.
113             QtCore.Signal = QtCore.pyqtSignal
114             QtCore.Slot = QtCore.pyqtSlot
115         except:
116             sys.stderr.write('import of PyQt4 failed, using PySide\n')
117             from PySide import QtCore, QtGui
118
119 class SamplingThread(QtCore.QObject):
120     '''A class that handles the reception of sigrok packets in the background.'''
121
122     class Worker(QtCore.QObject):
123         '''Helper class that does the actual work in another thread.'''
124
125         '''Signal emitted when new data arrived.'''
126         measured = QtCore.Signal(object, object)
127
128         '''Signal emmited in case of an error.'''
129         error = QtCore.Signal(str)
130
131         def __init__(self, drivers, loglevel):
132             super(self.__class__, self).__init__()
133
134             self.sampling = False
135             self.drivers = drivers
136
137             self.context = sr.Context_create()
138             self.context.log_level = loglevel
139
140             self.sr_pkg_version = self.context.package_version
141             self.sr_lib_version = self.context.lib_version
142
143         @QtCore.Slot()
144         def start_sampling(self):
145             devices = []
146             for name, options in self.drivers:
147                 try:
148                     dr = self.context.drivers[name]
149                     devices.append(dr.scan(**options)[0])
150                 except:
151                     self.error.emit(
152                         'Unable to get device for driver "{}".'.format(name))
153                     return
154
155             self.session = self.context.create_session()
156             for dev in devices:
157                 self.session.add_device(dev)
158                 dev.open()
159             self.session.add_datafeed_callback(self.callback)
160             self.session.start()
161             self.sampling = True
162             self.session.run()
163
164             # If sampling is 'True' here, it means that 'stop_sampling()' was
165             # not called, therefore 'session.run()' ended too early, indicating
166             # an error.
167             if self.sampling:
168                 self.error.emit('An error occured during the acquisition.')
169
170         def stop_sampling(self):
171             if self.sampling:
172                 self.sampling = False
173                 self.session.stop()
174
175         def callback(self, device, packet):
176             if packet.type == sr.PacketType.ANALOG:
177                 self.measured.emit(device, packet.payload)
178
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)
182
183     # signal used to start the worker across threads
184     _start_signal = QtCore.Signal()
185
186     def __init__(self, drivers, loglevel):
187         super(self.__class__, self).__init__()
188
189         self.worker = self.Worker(drivers, loglevel)
190         self.thread = QtCore.QThread()
191         self.worker.moveToThread(self.thread)
192
193         self._start_signal.connect(self.worker.start_sampling)
194
195         # expose the signals of the worker
196         self.measured = self.worker.measured
197         self.error = self.worker.error
198
199         self.thread.start()
200
201     def start(self):
202         '''Starts sampling'''
203         self._start_signal.emit()
204
205     def stop(self):
206         '''Stops sampling and the background thread.'''
207         self.worker.stop_sampling()
208         self.thread.quit()
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)
212
213     def sr_pkg_version(self):
214         '''Returns the version number of the libsigrok package.'''
215         return self.worker.sr_pkg_version
216
217     def sr_lib_version(self):
218         '''Returns the version number fo the libsigrok library.'''
219         return self.worker.sr_lib_version
220
221 class MeasurementDataModel(QtGui.QStandardItemModel):
222     '''Model to hold the measured values.'''
223
224     '''Role used to identify and find the item.'''
225     _idRole = QtCore.Qt.UserRole + 1
226
227     '''Role used to store the device vendor and model.'''
228     descRole = QtCore.Qt.UserRole + 2
229
230     def __init__(self, parent):
231         super(self.__class__, self).__init__(parent)
232
233         # used in 'format_mag()' to check against
234         self.inf = float('inf')
235
236     def format_unit(self, u):
237         units = {
238             sr.Unit.VOLT:                   'V',
239             sr.Unit.AMPERE:                 'A',
240             sr.Unit.OHM:                   u'\u03A9',
241             sr.Unit.FARAD:                  'F',
242             sr.Unit.KELVIN:                 'K',
243             sr.Unit.CELSIUS:               u'\u00B0C',
244             sr.Unit.FAHRENHEIT:            u'\u00B0F',
245             sr.Unit.HERTZ:                  'Hz',
246             sr.Unit.PERCENTAGE:             '%',
247           # sr.Unit.BOOLEAN
248             sr.Unit.SECOND:                 's',
249             sr.Unit.SIEMENS:                'S',
250             sr.Unit.DECIBEL_MW:             'dBu',
251             sr.Unit.DECIBEL_VOLT:           'dBV',
252           # sr.Unit.UNITLESS
253             sr.Unit.DECIBEL_SPL:            'dB',
254           # sr.Unit.CONCENTRATION
255             sr.Unit.REVOLUTIONS_PER_MINUTE: 'rpm',
256             sr.Unit.VOLT_AMPERE:            'VA',
257             sr.Unit.WATT:                   'W',
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',
263             sr.Unit.HENRY:                  'H'
264         }
265
266         return units.get(u, '')
267
268     def format_mqflags(self, mqflags):
269         if sr.QuantityFlag.AC in mqflags:
270             return 'AC'
271         elif sr.QuantityFlag.DC in mqflags:
272             return 'DC'
273         else:
274             return ''
275
276     def format_mag(self, mag):
277         if mag == self.inf:
278             return u'\u221E'
279         return '{:f}'.format(mag)
280
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.'''
284
285         # unique identifier for the device + channel
286         # TODO: isn't there something better?
287         uid = (
288             device.vendor,
289             device.model,
290             device.serial_number(),
291             device.connection_id(),
292             channel.index
293         )
294
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
300             if uid == rid:
301                 return item
302
303         # nothing found, create a new item
304         desc = '{} {}, channel "{}"'.format(
305                 device.vendor, device.model, channel.name)
306
307         item = QtGui.QStandardItem()
308         item.setData(uid, MeasurementDataModel._idRole)
309         item.setData(desc, MeasurementDataModel.descRole)
310         self.appendRow(item)
311         return item
312
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.'''
317
318         if not len(payload.channels):
319             return
320
321         # TODO: find a device with multiple channels in one packet
322         channel = payload.channels[0]
323
324         item = self.getItem(device, channel)
325
326         # the most recent value
327         mag = payload.data[0][-1]
328
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)
334
335 class MultimeterDelegate(QtGui.QStyledItemDelegate):
336     '''Delegate to show the data items from a MeasurementDataModel.'''
337
338     def __init__(self, parent, font):
339         '''Initializes the delegate.
340
341         :param font: Font used for the description text, the value is drawn
342                      with a slightly bigger and bold variant of the font.
343         '''
344
345         super(self.__class__, self).__init__(parent)
346
347         self._nfont = font
348         self._bfont = QtGui.QFont(self._nfont)
349
350         self._bfont.setBold(True)
351         if self._bfont.pixelSize() != -1:
352             self._bfont.setPixelSize(self._bfont.pixelSize() * 1.8)
353         else:
354             self._bfont.setPointSizeF(self._bfont.pointSizeF() * 1.8)
355
356         fi = QtGui.QFontInfo(self._nfont)
357         self._nfontheight = fi.pixelSize()
358
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)
362
363     def sizeHint(self, option=None, index=None):
364         return self._size
365
366     def paint(self, painter, options, index):
367         value = index.data(QtCore.Qt.DisplayRole)
368         desc = index.data(MeasurementDataModel.descRole)
369
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)
375
376         # value in the center
377         painter.setFont(self._bfont)
378         r = options.rect.adjusted(self._nfontheight, 2.5 * self._nfontheight,
379                 0, 0)
380         painter.drawText(r, QtCore.Qt.AlignCenter, value)
381
382 class EmptyMessageListView(QtGui.QListView):
383     '''List view that shows a message if the model im empty.'''
384
385     def __init__(self, message, parent=None):
386         super(self.__class__, self).__init__(parent)
387
388         self._message = message
389
390     def paintEvent(self, event):
391         m = self.model()
392         if m and m.rowCount():
393             super(self.__class__, self).paintEvent(event)
394             return
395
396         painter = QtGui.QPainter(self.viewport())
397         painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
398
399 class SigrokMeter(QtGui.QMainWindow):
400     '''The main window of the application.'''
401
402     def __init__(self, thread):
403         super(SigrokMeter, self).__init__()
404
405         self.delegate = MultimeterDelegate(self, self.font())
406         self.model = MeasurementDataModel(self)
407         self.model.rowsInserted.connect(self.modelRowsInserted)
408
409         self.setup_ui()
410
411         self.thread = thread
412         self.thread.measured.connect(self.model.update)
413         self.thread.error.connect(self.error)
414         self.thread.start()
415
416     def setup_ui(self):
417         self.setWindowTitle('sigrok-meter')
418         # resizing the listView below will increase this again
419         self.resize(10, 10)
420
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))
424
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)
430
431         actionAbout = QtGui.QAction(self)
432         actionAbout.setText('&About')
433         actionAbout.setIcon(QtGui.QIcon.fromTheme('help-about'))
434         actionAbout.triggered.connect(self.show_about)
435
436         menubar = self.menuBar()
437         menuFile = menubar.addMenu('&File')
438         menuFile.addAction(actionQuit)
439         menuHelp = menubar.addMenu('&Help')
440         menuHelp.addAction(actionAbout)
441
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())
454
455         self.setCentralWidget(self.listView)
456         self.centralWidget().setContentsMargins(0, 0, 0, 0)
457
458     @QtCore.Slot()
459     def show_about(self):
460         text = textwrap.dedent('''\
461             <div align="center">
462                 <b>sigrok-meter</b><br/>
463                 0.1.0<br/>
464                 Using libsigrok {} (lib version {}).<br/>
465                 <a href='http://www.sigrok.org'>
466                          http://www.sigrok.org</a><br/>
467                 <br/>
468                 This program comes with ABSOLUTELY NO WARRANTY;<br/>
469                 for details visit
470                 <a href='http://www.gnu.org/licenses/gpl.html'>
471                          http://www.gnu.org/licenses/gpl.html</a>
472             </div>
473         '''.format(self.thread.sr_pkg_version(), self.thread.sr_lib_version()))
474
475         QtGui.QMessageBox.about(self, 'About sigrok-meter', text)
476
477     @QtCore.Slot(str)
478     def error(self, msg):
479         '''Error handler for the sampling thread.'''
480         QtGui.QMessageBox.critical(self, 'Error', msg)
481         self.close()
482
483     @QtCore.Slot(object, int, int)
484     def modelRowsInserted(self, parent, start, end):
485         '''Resizes the list view to the size of the content.'''
486
487         rows = self.model.rowCount()
488         dh = self.delegate.sizeHint().height()
489         self.listView.setMinimumHeight(dh * rows)
490
491 if __name__ == '__main__':
492     thread = SamplingThread(args['drivers'], args['loglevel'])
493
494     app = QtGui.QApplication([])
495     s = SigrokMeter(thread)
496     s.show()
497
498     r = app.exec_()
499     thread.stop()
500     sys.exit(r)