]> sigrok.org Git - sigrok-meter.git/blame - sigrok-meter
Create one central libsigrok context.
[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
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
73f2129a 178 if packet.type == sr.PacketType.ANALOG:
58d308d1 179 self.measured.emit(device, packet.payload)
73f2129a 180
73f2129a 181 # signal used to start the worker across threads
5fda58ad 182 _start_signal = QtCore.Signal()
73f2129a 183
e65cc368 184 def __init__(self, context, drivers):
73f2129a
JS
185 super(self.__class__, self).__init__()
186
e65cc368 187 self.worker = self.Worker(context, drivers)
73f2129a
JS
188 self.thread = QtCore.QThread()
189 self.worker.moveToThread(self.thread)
190
191 self._start_signal.connect(self.worker.start_sampling)
192
284a2e34 193 # expose the signals of the worker
73f2129a 194 self.measured = self.worker.measured
284a2e34 195 self.error = self.worker.error
73f2129a
JS
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()
082e7d04 207 self.thread.wait()
73f2129a 208
58d308d1
JS
209class 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
b9a9a7a1
JS
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.
58d308d1
JS
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)
b9a9a7a1 303 self.sort(0)
58d308d1
JS
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
328class 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
375class 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
73f2129a
JS
392class SigrokMeter(QtGui.QMainWindow):
393 '''The main window of the application.'''
394
e65cc368 395 def __init__(self, context, drivers):
73f2129a 396 super(SigrokMeter, self).__init__()
73f2129a 397
e65cc368
JS
398 self.context = context
399
58d308d1
JS
400 self.delegate = MultimeterDelegate(self, self.font())
401 self.model = MeasurementDataModel(self)
402 self.model.rowsInserted.connect(self.modelRowsInserted)
403
404 self.setup_ui()
50523e84 405
e65cc368 406 self.thread = SamplingThread(self.context, drivers)
58d308d1 407 self.thread.measured.connect(self.model.update)
284a2e34 408 self.thread.error.connect(self.error)
73f2129a
JS
409 self.thread.start()
410
411 def setup_ui(self):
412 self.setWindowTitle('sigrok-meter')
58d308d1
JS
413 # resizing the listView below will increase this again
414 self.resize(10, 10)
73f2129a
JS
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
58d308d1
JS
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)
73f2129a
JS
451 self.centralWidget().setContentsMargins(0, 0, 0, 0)
452
e65cc368
JS
453 def closeEvent(self, event):
454 self.thread.stop()
455 event.accept()
456
f517686f 457 @QtCore.Slot()
73f2129a
JS
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>
e65cc368 472 '''.format(self.context.package_version, self.context.lib_version))
73f2129a
JS
473
474 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)
475
f517686f 476 @QtCore.Slot(str)
284a2e34
JS
477 def error(self, msg):
478 '''Error handler for the sampling thread.'''
479 QtGui.QMessageBox.critical(self, 'Error', msg)
480 self.close()
481
58d308d1
JS
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
f94bb73f 490if __name__ == '__main__':
e65cc368
JS
491 context = sr.Context_create()
492 context.log_level = args['loglevel']
73f2129a
JS
493
494 app = QtGui.QApplication([])
e65cc368 495 s = SigrokMeter(context, args['drivers'])
73f2129a
JS
496 s.show()
497
e65cc368 498 sys.exit(app.exec_())