]> sigrok.org Git - sigrok-meter.git/blame - sigrok-meter
Return from sample callback if sigrok module is not available.
[sigrok-meter.git] / sigrok-meter
CommitLineData
5add80f6
JS
1#!/usr/bin/env python
2
c09ca11b
UH
3##
4## This file is part of the sigrok-meter project.
5##
6## Copyright (C) 2013 Uwe Hermann <uwe@hermann-uwe.de>
73f2129a 7## Copyright (C) 2014 Jens Steinhauser <jens.steinhauser@gmail.com>
c09ca11b
UH
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
782f5926 24import argparse
73f2129a
JS
25import datetime
26import os.path
f94bb73f 27import re
efdef4fa 28import sigrok.core as sr
782f5926 29import sys
f94bb73f 30import textwrap
13e332b7 31
58d308d1 32default_drivers = [('demo', {'analog_channels': 4})]
782f5926
JS
33default_loglevel = sr.LogLevel.WARN
34
1f199679
JS
35def 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
88if __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
1f199679
JS
95 if args['pyside']:
96 from PySide import QtCore, QtGui
1f199679
JS
97 else:
98 try:
5fda58ad
JS
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
1f199679 110 from PyQt4 import QtCore, QtGui
5fda58ad
JS
111
112 # Add PySide compatible names.
113 QtCore.Signal = QtCore.pyqtSignal
114 QtCore.Slot = QtCore.pyqtSlot
1f199679
JS
115 except:
116 sys.stderr.write('import of PyQt4 failed, using PySide\n')
117 from PySide import QtCore, QtGui
1f199679 118
73f2129a
JS
119class 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.'''
5fda58ad 126 measured = QtCore.Signal(object, object)
73f2129a 127
284a2e34 128 '''Signal emmited in case of an error.'''
5fda58ad 129 error = QtCore.Signal(str)
284a2e34 130
73f2129a
JS
131 def __init__(self, drivers, loglevel):
132 super(self.__class__, self).__init__()
133
284a2e34
JS
134 self.sampling = False
135 self.drivers = drivers
136
73f2129a
JS
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
f517686f 143 @QtCore.Slot()
284a2e34
JS
144 def start_sampling(self):
145 devices = []
146 for name, options in self.drivers:
73f2129a
JS
147 try:
148 dr = self.context.drivers[name]
284a2e34 149 devices.append(dr.scan(**options)[0])
73f2129a 150 except:
284a2e34
JS
151 self.error.emit(
152 'Unable to get device for driver "{}".'.format(name))
153 return
73f2129a 154
73f2129a 155 self.session = self.context.create_session()
284a2e34 156 for dev in devices:
73f2129a
JS
157 self.session.add_device(dev)
158 dev.open()
159 self.session.add_datafeed_callback(self.callback)
160 self.session.start()
284a2e34 161 self.sampling = True
73f2129a
JS
162 self.session.run()
163
284a2e34
JS
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
73f2129a 170 def stop_sampling(self):
284a2e34
JS
171 if self.sampling:
172 self.sampling = False
173 self.session.stop()
73f2129a
JS
174
175 def callback(self, device, packet):
2f050135
JS
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
73f2129a 182 if packet.type == sr.PacketType.ANALOG:
58d308d1 183 self.measured.emit(device, packet.payload)
73f2129a
JS
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
5fda58ad 190 _start_signal = QtCore.Signal()
73f2129a
JS
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
284a2e34 201 # expose the signals of the worker
73f2129a 202 self.measured = self.worker.measured
284a2e34 203 self.error = self.worker.error
73f2129a
JS
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
58d308d1
JS
227class 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
b9a9a7a1
JS
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.
58d308d1
JS
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)
b9a9a7a1 321 self.sort(0)
58d308d1
JS
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
346class 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
393class 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
73f2129a
JS
410class SigrokMeter(QtGui.QMainWindow):
411 '''The main window of the application.'''
412
413 def __init__(self, thread):
414 super(SigrokMeter, self).__init__()
73f2129a 415
58d308d1
JS
416 self.delegate = MultimeterDelegate(self, self.font())
417 self.model = MeasurementDataModel(self)
418 self.model.rowsInserted.connect(self.modelRowsInserted)
419
420 self.setup_ui()
50523e84 421
73f2129a 422 self.thread = thread
58d308d1 423 self.thread.measured.connect(self.model.update)
284a2e34 424 self.thread.error.connect(self.error)
73f2129a
JS
425 self.thread.start()
426
427 def setup_ui(self):
428 self.setWindowTitle('sigrok-meter')
58d308d1
JS
429 # resizing the listView below will increase this again
430 self.resize(10, 10)
73f2129a
JS
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
58d308d1
JS
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)
73f2129a
JS
467 self.centralWidget().setContentsMargins(0, 0, 0, 0)
468
f517686f 469 @QtCore.Slot()
73f2129a
JS
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
f517686f 488 @QtCore.Slot(str)
284a2e34
JS
489 def error(self, msg):
490 '''Error handler for the sampling thread.'''
491 QtGui.QMessageBox.critical(self, 'Error', msg)
492 self.close()
493
58d308d1
JS
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
f94bb73f 502if __name__ == '__main__':
73f2129a
JS
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)