]> sigrok.org Git - sigrok-meter.git/blob - sigrok-meter
Don't pass the packet.payload between threads.
[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, object)
127
128         '''Signal emmited in case of an error.'''
129         error = QtCore.Signal(str)
130
131         def __init__(self, context, drivers):
132             super(self.__class__, self).__init__()
133
134             self.context = context
135             self.drivers = drivers
136
137             self.sampling = False
138
139         @QtCore.Slot()
140         def start_sampling(self):
141             devices = []
142             for name, options in self.drivers:
143                 try:
144                     dr = self.context.drivers[name]
145                     devices.append(dr.scan(**options)[0])
146                 except:
147                     self.error.emit(
148                         'Unable to get device for driver "{}".'.format(name))
149                     return
150
151             self.session = self.context.create_session()
152             for dev in devices:
153                 self.session.add_device(dev)
154                 dev.open()
155             self.session.add_datafeed_callback(self.callback)
156             self.session.start()
157             self.sampling = True
158             self.session.run()
159
160             # If sampling is 'True' here, it means that 'stop_sampling()' was
161             # not called, therefore 'session.run()' ended too early, indicating
162             # an error.
163             if self.sampling:
164                 self.error.emit('An error occured during the acquisition.')
165
166         def stop_sampling(self):
167             if self.sampling:
168                 self.sampling = False
169                 self.session.stop()
170
171         def callback(self, device, packet):
172             if not sr:
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'.
176                 return
177
178             if packet.type != sr.PacketType.ANALOG:
179                 return
180
181             if not len(packet.payload.channels):
182                 return
183
184             # TODO: find a device with multiple channels in one packet
185             channel = packet.payload.channels[0]
186
187             # the most recent value
188             value = packet.payload.data[0][-1]
189
190             self.measured.emit(device, channel,
191                     (value, packet.payload.unit, packet.payload.mq_flags))
192
193     # signal used to start the worker across threads
194     _start_signal = QtCore.Signal()
195
196     def __init__(self, context, drivers):
197         super(self.__class__, self).__init__()
198
199         self.worker = self.Worker(context, drivers)
200         self.thread = QtCore.QThread()
201         self.worker.moveToThread(self.thread)
202
203         self._start_signal.connect(self.worker.start_sampling)
204
205         # expose the signals of the worker
206         self.measured = self.worker.measured
207         self.error = self.worker.error
208
209         self.thread.start()
210
211     def start(self):
212         '''Starts sampling'''
213         self._start_signal.emit()
214
215     def stop(self):
216         '''Stops sampling and the background thread.'''
217         self.worker.stop_sampling()
218         self.thread.quit()
219         self.thread.wait()
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         # 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)
236
237         # Used in 'format_value()' to check against.
238         self.inf = float('inf')
239
240     def format_unit(self, u):
241         units = {
242             sr.Unit.VOLT:                   'V',
243             sr.Unit.AMPERE:                 'A',
244             sr.Unit.OHM:                   u'\u03A9',
245             sr.Unit.FARAD:                  'F',
246             sr.Unit.KELVIN:                 'K',
247             sr.Unit.CELSIUS:               u'\u00B0C',
248             sr.Unit.FAHRENHEIT:            u'\u00B0F',
249             sr.Unit.HERTZ:                  'Hz',
250             sr.Unit.PERCENTAGE:             '%',
251           # sr.Unit.BOOLEAN
252             sr.Unit.SECOND:                 's',
253             sr.Unit.SIEMENS:                'S',
254             sr.Unit.DECIBEL_MW:             'dBu',
255             sr.Unit.DECIBEL_VOLT:           'dBV',
256           # sr.Unit.UNITLESS
257             sr.Unit.DECIBEL_SPL:            'dB',
258           # sr.Unit.CONCENTRATION
259             sr.Unit.REVOLUTIONS_PER_MINUTE: 'rpm',
260             sr.Unit.VOLT_AMPERE:            'VA',
261             sr.Unit.WATT:                   'W',
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',
267             sr.Unit.HENRY:                  'H'
268         }
269
270         return units.get(u, '')
271
272     def format_mqflags(self, mqflags):
273         if sr.QuantityFlag.AC in mqflags:
274             return 'AC'
275         elif sr.QuantityFlag.DC in mqflags:
276             return 'DC'
277         else:
278             return ''
279
280     def format_value(self, mag):
281         if mag == self.inf:
282             return u'\u221E'
283         return '{:f}'.format(mag)
284
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.'''
288
289         # unique identifier for the device + channel
290         # TODO: isn't there something better?
291         uid = (
292             device.vendor,
293             device.model,
294             device.serial_number(),
295             device.connection_id(),
296             channel.index
297         )
298
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
304             if uid == rid:
305                 return item
306
307         # nothing found, create a new item
308         desc = '{} {}, channel "{}"'.format(
309                 device.vendor, device.model, channel.name)
310
311         item = QtGui.QStandardItem()
312         item.setData(uid, MeasurementDataModel._idRole)
313         item.setData(desc, MeasurementDataModel.descRole)
314         self.appendRow(item)
315         self.sort(0)
316         return item
317
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.'''
322
323         item = self.getItem(device, channel)
324
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)
329
330         disp = ' '.join([value_str, unit_str, mqflags_str])
331         item.setData(disp, QtCore.Qt.DisplayRole)
332
333 class MultimeterDelegate(QtGui.QStyledItemDelegate):
334     '''Delegate to show the data items from a MeasurementDataModel.'''
335
336     def __init__(self, parent, font):
337         '''Initializes the delegate.
338
339         :param font: Font used for the description text, the value is drawn
340                      with a slightly bigger and bold variant of the font.
341         '''
342
343         super(self.__class__, self).__init__(parent)
344
345         self._nfont = font
346         self._bfont = QtGui.QFont(self._nfont)
347
348         self._bfont.setBold(True)
349         if self._bfont.pixelSize() != -1:
350             self._bfont.setPixelSize(self._bfont.pixelSize() * 1.8)
351         else:
352             self._bfont.setPointSizeF(self._bfont.pointSizeF() * 1.8)
353
354         fi = QtGui.QFontInfo(self._nfont)
355         self._nfontheight = fi.pixelSize()
356
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)
360
361     def sizeHint(self, option=None, index=None):
362         return self._size
363
364     def paint(self, painter, options, index):
365         value = index.data(QtCore.Qt.DisplayRole)
366         desc = index.data(MeasurementDataModel.descRole)
367
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)
373
374         # value in the center
375         painter.setFont(self._bfont)
376         r = options.rect.adjusted(self._nfontheight, 2.5 * self._nfontheight,
377                 0, 0)
378         painter.drawText(r, QtCore.Qt.AlignCenter, value)
379
380 class EmptyMessageListView(QtGui.QListView):
381     '''List view that shows a message if the model im empty.'''
382
383     def __init__(self, message, parent=None):
384         super(self.__class__, self).__init__(parent)
385
386         self._message = message
387
388     def paintEvent(self, event):
389         m = self.model()
390         if m and m.rowCount():
391             super(self.__class__, self).paintEvent(event)
392             return
393
394         painter = QtGui.QPainter(self.viewport())
395         painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
396
397 class SigrokMeter(QtGui.QMainWindow):
398     '''The main window of the application.'''
399
400     def __init__(self, context, drivers):
401         super(SigrokMeter, self).__init__()
402
403         self.context = context
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 = SamplingThread(self.context, drivers)
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     def closeEvent(self, event):
459         self.thread.stop()
460         event.accept()
461
462     @QtCore.Slot()
463     def show_about(self):
464         text = textwrap.dedent('''\
465             <div align="center">
466                 <b>sigrok-meter</b><br/>
467                 0.1.0<br/>
468                 Using libsigrok {} (lib version {}).<br/>
469                 <a href='http://www.sigrok.org'>
470                          http://www.sigrok.org</a><br/>
471                 <br/>
472                 This program comes with ABSOLUTELY NO WARRANTY;<br/>
473                 for details visit
474                 <a href='http://www.gnu.org/licenses/gpl.html'>
475                          http://www.gnu.org/licenses/gpl.html</a>
476             </div>
477         '''.format(self.context.package_version, self.context.lib_version))
478
479         QtGui.QMessageBox.about(self, 'About sigrok-meter', text)
480
481     @QtCore.Slot(str)
482     def error(self, msg):
483         '''Error handler for the sampling thread.'''
484         QtGui.QMessageBox.critical(self, 'Error', msg)
485         self.close()
486
487     @QtCore.Slot(object, int, int)
488     def modelRowsInserted(self, parent, start, end):
489         '''Resizes the list view to the size of the content.'''
490
491         rows = self.model.rowCount()
492         dh = self.delegate.sizeHint().height()
493         self.listView.setMinimumHeight(dh * rows)
494
495 if __name__ == '__main__':
496     context = sr.Context_create()
497     context.log_level = args['loglevel']
498
499     app = QtGui.QApplication([])
500     s = SigrokMeter(context, args['drivers'])
501     s.show()
502
503     sys.exit(app.exec_())