]> sigrok.org Git - sigrok-meter.git/blame - sigrok-meter
Don't pass the packet.payload between threads.
[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.'''
b7b93278 126 measured = QtCore.Signal(object, object, object)
73f2129a 127
284a2e34 128 '''Signal emmited in case of an error.'''
5fda58ad 129 error = QtCore.Signal(str)
284a2e34 130
e65cc368 131 def __init__(self, context, drivers):
73f2129a
JS
132 super(self.__class__, self).__init__()
133
e65cc368 134 self.context = context
284a2e34
JS
135 self.drivers = drivers
136
e65cc368 137 self.sampling = False
73f2129a 138
f517686f 139 @QtCore.Slot()
284a2e34
JS
140 def start_sampling(self):
141 devices = []
142 for name, options in self.drivers:
73f2129a
JS
143 try:
144 dr = self.context.drivers[name]
284a2e34 145 devices.append(dr.scan(**options)[0])
73f2129a 146 except:
284a2e34
JS
147 self.error.emit(
148 'Unable to get device for driver "{}".'.format(name))
149 return
73f2129a 150
73f2129a 151 self.session = self.context.create_session()
284a2e34 152 for dev in devices:
73f2129a
JS
153 self.session.add_device(dev)
154 dev.open()
155 self.session.add_datafeed_callback(self.callback)
156 self.session.start()
284a2e34 157 self.sampling = True
73f2129a
JS
158 self.session.run()
159
284a2e34
JS
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
73f2129a 166 def stop_sampling(self):
284a2e34
JS
167 if self.sampling:
168 self.sampling = False
169 self.session.stop()
73f2129a
JS
170
171 def callback(self, device, packet):
2f050135
JS
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
b7b93278
JS
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))
73f2129a 192
73f2129a 193 # signal used to start the worker across threads
5fda58ad 194 _start_signal = QtCore.Signal()
73f2129a 195
e65cc368 196 def __init__(self, context, drivers):
73f2129a
JS
197 super(self.__class__, self).__init__()
198
e65cc368 199 self.worker = self.Worker(context, drivers)
73f2129a
JS
200 self.thread = QtCore.QThread()
201 self.worker.moveToThread(self.thread)
202
203 self._start_signal.connect(self.worker.start_sampling)
204
284a2e34 205 # expose the signals of the worker
73f2129a 206 self.measured = self.worker.measured
284a2e34 207 self.error = self.worker.error
73f2129a
JS
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()
082e7d04 219 self.thread.wait()
73f2129a 220
58d308d1
JS
221class 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
b9a9a7a1
JS
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
b7b93278 237 # Used in 'format_value()' to check against.
58d308d1
JS
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
b7b93278 280 def format_value(self, mag):
58d308d1
JS
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)
b9a9a7a1 315 self.sort(0)
58d308d1
JS
316 return item
317
b7b93278
JS
318 @QtCore.Slot(object, object, object)
319 def update(self, device, channel, data):
58d308d1
JS
320 '''Updates the data for the device (+channel) with the most recent
321 measurement from the given payload.'''
322
58d308d1
JS
323 item = self.getItem(device, channel)
324
b7b93278
JS
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)
58d308d1 329
b7b93278 330 disp = ' '.join([value_str, unit_str, mqflags_str])
58d308d1
JS
331 item.setData(disp, QtCore.Qt.DisplayRole)
332
333class 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
380class 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
73f2129a
JS
397class SigrokMeter(QtGui.QMainWindow):
398 '''The main window of the application.'''
399
e65cc368 400 def __init__(self, context, drivers):
73f2129a 401 super(SigrokMeter, self).__init__()
73f2129a 402
e65cc368
JS
403 self.context = context
404
58d308d1
JS
405 self.delegate = MultimeterDelegate(self, self.font())
406 self.model = MeasurementDataModel(self)
407 self.model.rowsInserted.connect(self.modelRowsInserted)
408
409 self.setup_ui()
50523e84 410
e65cc368 411 self.thread = SamplingThread(self.context, drivers)
58d308d1 412 self.thread.measured.connect(self.model.update)
284a2e34 413 self.thread.error.connect(self.error)
73f2129a
JS
414 self.thread.start()
415
416 def setup_ui(self):
417 self.setWindowTitle('sigrok-meter')
58d308d1
JS
418 # resizing the listView below will increase this again
419 self.resize(10, 10)
73f2129a
JS
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
58d308d1
JS
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)
73f2129a
JS
456 self.centralWidget().setContentsMargins(0, 0, 0, 0)
457
e65cc368
JS
458 def closeEvent(self, event):
459 self.thread.stop()
460 event.accept()
461
f517686f 462 @QtCore.Slot()
73f2129a
JS
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>
e65cc368 477 '''.format(self.context.package_version, self.context.lib_version))
73f2129a
JS
478
479 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)
480
f517686f 481 @QtCore.Slot(str)
284a2e34
JS
482 def error(self, msg):
483 '''Error handler for the sampling thread.'''
484 QtGui.QMessageBox.critical(self, 'Error', msg)
485 self.close()
486
58d308d1
JS
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
f94bb73f 495if __name__ == '__main__':
e65cc368
JS
496 context = sr.Context_create()
497 context.log_level = args['loglevel']
73f2129a
JS
498
499 app = QtGui.QApplication([])
e65cc368 500 s = SigrokMeter(context, args['drivers'])
73f2129a
JS
501 s.show()
502
e65cc368 503 sys.exit(app.exec_())