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