]> sigrok.org Git - sigrok-meter.git/blob - sigrok-meter
Return from sample callback if sigrok module is not available.
[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 not sr:
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'.
180                 return
181
182             if packet.type == sr.PacketType.ANALOG:
183                 self.measured.emit(device, packet.payload)
184
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)
188
189     # signal used to start the worker across threads
190     _start_signal = QtCore.Signal()
191
192     def __init__(self, drivers, loglevel):
193         super(self.__class__, self).__init__()
194
195         self.worker = self.Worker(drivers, loglevel)
196         self.thread = QtCore.QThread()
197         self.worker.moveToThread(self.thread)
198
199         self._start_signal.connect(self.worker.start_sampling)
200
201         # expose the signals of the worker
202         self.measured = self.worker.measured
203         self.error = self.worker.error
204
205         self.thread.start()
206
207     def start(self):
208         '''Starts sampling'''
209         self._start_signal.emit()
210
211     def stop(self):
212         '''Stops sampling and the background thread.'''
213         self.worker.stop_sampling()
214         self.thread.quit()
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)
218
219     def sr_pkg_version(self):
220         '''Returns the version number of the libsigrok package.'''
221         return self.worker.sr_pkg_version
222
223     def sr_lib_version(self):
224         '''Returns the version number fo the libsigrok library.'''
225         return self.worker.sr_lib_version
226
227 class MeasurementDataModel(QtGui.QStandardItemModel):
228     '''Model to hold the measured values.'''
229
230     '''Role used to identify and find the item.'''
231     _idRole = QtCore.Qt.UserRole + 1
232
233     '''Role used to store the device vendor and model.'''
234     descRole = QtCore.Qt.UserRole + 2
235
236     def __init__(self, parent):
237         super(self.__class__, self).__init__(parent)
238
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)
242
243         # Used in 'format_mag()' to check against.
244         self.inf = float('inf')
245
246     def format_unit(self, u):
247         units = {
248             sr.Unit.VOLT:                   'V',
249             sr.Unit.AMPERE:                 'A',
250             sr.Unit.OHM:                   u'\u03A9',
251             sr.Unit.FARAD:                  'F',
252             sr.Unit.KELVIN:                 'K',
253             sr.Unit.CELSIUS:               u'\u00B0C',
254             sr.Unit.FAHRENHEIT:            u'\u00B0F',
255             sr.Unit.HERTZ:                  'Hz',
256             sr.Unit.PERCENTAGE:             '%',
257           # sr.Unit.BOOLEAN
258             sr.Unit.SECOND:                 's',
259             sr.Unit.SIEMENS:                'S',
260             sr.Unit.DECIBEL_MW:             'dBu',
261             sr.Unit.DECIBEL_VOLT:           'dBV',
262           # sr.Unit.UNITLESS
263             sr.Unit.DECIBEL_SPL:            'dB',
264           # sr.Unit.CONCENTRATION
265             sr.Unit.REVOLUTIONS_PER_MINUTE: 'rpm',
266             sr.Unit.VOLT_AMPERE:            'VA',
267             sr.Unit.WATT:                   'W',
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',
273             sr.Unit.HENRY:                  'H'
274         }
275
276         return units.get(u, '')
277
278     def format_mqflags(self, mqflags):
279         if sr.QuantityFlag.AC in mqflags:
280             return 'AC'
281         elif sr.QuantityFlag.DC in mqflags:
282             return 'DC'
283         else:
284             return ''
285
286     def format_mag(self, mag):
287         if mag == self.inf:
288             return u'\u221E'
289         return '{:f}'.format(mag)
290
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.'''
294
295         # unique identifier for the device + channel
296         # TODO: isn't there something better?
297         uid = (
298             device.vendor,
299             device.model,
300             device.serial_number(),
301             device.connection_id(),
302             channel.index
303         )
304
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
310             if uid == rid:
311                 return item
312
313         # nothing found, create a new item
314         desc = '{} {}, channel "{}"'.format(
315                 device.vendor, device.model, channel.name)
316
317         item = QtGui.QStandardItem()
318         item.setData(uid, MeasurementDataModel._idRole)
319         item.setData(desc, MeasurementDataModel.descRole)
320         self.appendRow(item)
321         self.sort(0)
322         return item
323
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.'''
328
329         if not len(payload.channels):
330             return
331
332         # TODO: find a device with multiple channels in one packet
333         channel = payload.channels[0]
334
335         item = self.getItem(device, channel)
336
337         # the most recent value
338         mag = payload.data[0][-1]
339
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)
345
346 class MultimeterDelegate(QtGui.QStyledItemDelegate):
347     '''Delegate to show the data items from a MeasurementDataModel.'''
348
349     def __init__(self, parent, font):
350         '''Initializes the delegate.
351
352         :param font: Font used for the description text, the value is drawn
353                      with a slightly bigger and bold variant of the font.
354         '''
355
356         super(self.__class__, self).__init__(parent)
357
358         self._nfont = font
359         self._bfont = QtGui.QFont(self._nfont)
360
361         self._bfont.setBold(True)
362         if self._bfont.pixelSize() != -1:
363             self._bfont.setPixelSize(self._bfont.pixelSize() * 1.8)
364         else:
365             self._bfont.setPointSizeF(self._bfont.pointSizeF() * 1.8)
366
367         fi = QtGui.QFontInfo(self._nfont)
368         self._nfontheight = fi.pixelSize()
369
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)
373
374     def sizeHint(self, option=None, index=None):
375         return self._size
376
377     def paint(self, painter, options, index):
378         value = index.data(QtCore.Qt.DisplayRole)
379         desc = index.data(MeasurementDataModel.descRole)
380
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)
386
387         # value in the center
388         painter.setFont(self._bfont)
389         r = options.rect.adjusted(self._nfontheight, 2.5 * self._nfontheight,
390                 0, 0)
391         painter.drawText(r, QtCore.Qt.AlignCenter, value)
392
393 class EmptyMessageListView(QtGui.QListView):
394     '''List view that shows a message if the model im empty.'''
395
396     def __init__(self, message, parent=None):
397         super(self.__class__, self).__init__(parent)
398
399         self._message = message
400
401     def paintEvent(self, event):
402         m = self.model()
403         if m and m.rowCount():
404             super(self.__class__, self).paintEvent(event)
405             return
406
407         painter = QtGui.QPainter(self.viewport())
408         painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
409
410 class SigrokMeter(QtGui.QMainWindow):
411     '''The main window of the application.'''
412
413     def __init__(self, thread):
414         super(SigrokMeter, self).__init__()
415
416         self.delegate = MultimeterDelegate(self, self.font())
417         self.model = MeasurementDataModel(self)
418         self.model.rowsInserted.connect(self.modelRowsInserted)
419
420         self.setup_ui()
421
422         self.thread = thread
423         self.thread.measured.connect(self.model.update)
424         self.thread.error.connect(self.error)
425         self.thread.start()
426
427     def setup_ui(self):
428         self.setWindowTitle('sigrok-meter')
429         # resizing the listView below will increase this again
430         self.resize(10, 10)
431
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))
435
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)
441
442         actionAbout = QtGui.QAction(self)
443         actionAbout.setText('&About')
444         actionAbout.setIcon(QtGui.QIcon.fromTheme('help-about'))
445         actionAbout.triggered.connect(self.show_about)
446
447         menubar = self.menuBar()
448         menuFile = menubar.addMenu('&File')
449         menuFile.addAction(actionQuit)
450         menuHelp = menubar.addMenu('&Help')
451         menuHelp.addAction(actionAbout)
452
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())
465
466         self.setCentralWidget(self.listView)
467         self.centralWidget().setContentsMargins(0, 0, 0, 0)
468
469     @QtCore.Slot()
470     def show_about(self):
471         text = textwrap.dedent('''\
472             <div align="center">
473                 <b>sigrok-meter</b><br/>
474                 0.1.0<br/>
475                 Using libsigrok {} (lib version {}).<br/>
476                 <a href='http://www.sigrok.org'>
477                          http://www.sigrok.org</a><br/>
478                 <br/>
479                 This program comes with ABSOLUTELY NO WARRANTY;<br/>
480                 for details visit
481                 <a href='http://www.gnu.org/licenses/gpl.html'>
482                          http://www.gnu.org/licenses/gpl.html</a>
483             </div>
484         '''.format(self.thread.sr_pkg_version(), self.thread.sr_lib_version()))
485
486         QtGui.QMessageBox.about(self, 'About sigrok-meter', text)
487
488     @QtCore.Slot(str)
489     def error(self, msg):
490         '''Error handler for the sampling thread.'''
491         QtGui.QMessageBox.critical(self, 'Error', msg)
492         self.close()
493
494     @QtCore.Slot(object, int, int)
495     def modelRowsInserted(self, parent, start, end):
496         '''Resizes the list view to the size of the content.'''
497
498         rows = self.model.rowCount()
499         dh = self.delegate.sizeHint().height()
500         self.listView.setMinimumHeight(dh * rows)
501
502 if __name__ == '__main__':
503     thread = SamplingThread(args['drivers'], args['loglevel'])
504
505     app = QtGui.QApplication([])
506     s = SigrokMeter(thread)
507     s.show()
508
509     r = app.exec_()
510     thread.stop()
511     sys.exit(r)