]> sigrok.org Git - sigrok-meter.git/blame - sigrok-meter
README: Fix capitalization.
[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 184
73f2129a 185 # signal used to start the worker across threads
5fda58ad 186 _start_signal = QtCore.Signal()
73f2129a
JS
187
188 def __init__(self, drivers, loglevel):
189 super(self.__class__, self).__init__()
190
191 self.worker = self.Worker(drivers, loglevel)
192 self.thread = QtCore.QThread()
193 self.worker.moveToThread(self.thread)
194
195 self._start_signal.connect(self.worker.start_sampling)
196
284a2e34 197 # expose the signals of the worker
73f2129a 198 self.measured = self.worker.measured
284a2e34 199 self.error = self.worker.error
73f2129a
JS
200
201 self.thread.start()
202
203 def start(self):
204 '''Starts sampling'''
205 self._start_signal.emit()
206
207 def stop(self):
208 '''Stops sampling and the background thread.'''
209 self.worker.stop_sampling()
210 self.thread.quit()
082e7d04 211 self.thread.wait()
73f2129a
JS
212
213 def sr_pkg_version(self):
214 '''Returns the version number of the libsigrok package.'''
215 return self.worker.sr_pkg_version
216
217 def sr_lib_version(self):
218 '''Returns the version number fo the libsigrok library.'''
219 return self.worker.sr_lib_version
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
237 # Used in 'format_mag()' 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
280 def format_mag(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)
b9a9a7a1 315 self.sort(0)
58d308d1
JS
316 return item
317
318 @QtCore.Slot(object, object)
319 def update(self, device, payload):
320 '''Updates the data for the device (+channel) with the most recent
321 measurement from the given payload.'''
322
323 if not len(payload.channels):
324 return
325
326 # TODO: find a device with multiple channels in one packet
327 channel = payload.channels[0]
328
329 item = self.getItem(device, channel)
330
331 # the most recent value
332 mag = payload.data[0][-1]
333
334 unit_str = self.format_unit(payload.unit)
335 mqflags_str = self.format_mqflags(payload.mq_flags)
336 mag_str = self.format_mag(mag)
337 disp = ' '.join([mag_str, unit_str, mqflags_str])
338 item.setData(disp, QtCore.Qt.DisplayRole)
339
340class MultimeterDelegate(QtGui.QStyledItemDelegate):
341 '''Delegate to show the data items from a MeasurementDataModel.'''
342
343 def __init__(self, parent, font):
344 '''Initializes the delegate.
345
346 :param font: Font used for the description text, the value is drawn
347 with a slightly bigger and bold variant of the font.
348 '''
349
350 super(self.__class__, self).__init__(parent)
351
352 self._nfont = font
353 self._bfont = QtGui.QFont(self._nfont)
354
355 self._bfont.setBold(True)
356 if self._bfont.pixelSize() != -1:
357 self._bfont.setPixelSize(self._bfont.pixelSize() * 1.8)
358 else:
359 self._bfont.setPointSizeF(self._bfont.pointSizeF() * 1.8)
360
361 fi = QtGui.QFontInfo(self._nfont)
362 self._nfontheight = fi.pixelSize()
363
364 fm = QtGui.QFontMetrics(self._bfont)
365 r = fm.boundingRect('-XX.XXXXXX X XX')
366 self._size = QtCore.QSize(r.width() * 1.2, r.height() * 3.5)
367
368 def sizeHint(self, option=None, index=None):
369 return self._size
370
371 def paint(self, painter, options, index):
372 value = index.data(QtCore.Qt.DisplayRole)
373 desc = index.data(MeasurementDataModel.descRole)
374
375 # description in the top left corner
376 painter.setFont(self._nfont)
377 p = options.rect.topLeft()
378 p += QtCore.QPoint(self._nfontheight, 2 * self._nfontheight)
379 painter.drawText(p, desc)
380
381 # value in the center
382 painter.setFont(self._bfont)
383 r = options.rect.adjusted(self._nfontheight, 2.5 * self._nfontheight,
384 0, 0)
385 painter.drawText(r, QtCore.Qt.AlignCenter, value)
386
387class EmptyMessageListView(QtGui.QListView):
388 '''List view that shows a message if the model im empty.'''
389
390 def __init__(self, message, parent=None):
391 super(self.__class__, self).__init__(parent)
392
393 self._message = message
394
395 def paintEvent(self, event):
396 m = self.model()
397 if m and m.rowCount():
398 super(self.__class__, self).paintEvent(event)
399 return
400
401 painter = QtGui.QPainter(self.viewport())
402 painter.drawText(self.rect(), QtCore.Qt.AlignCenter, self._message)
403
73f2129a
JS
404class SigrokMeter(QtGui.QMainWindow):
405 '''The main window of the application.'''
406
407 def __init__(self, thread):
408 super(SigrokMeter, self).__init__()
73f2129a 409
58d308d1
JS
410 self.delegate = MultimeterDelegate(self, self.font())
411 self.model = MeasurementDataModel(self)
412 self.model.rowsInserted.connect(self.modelRowsInserted)
413
414 self.setup_ui()
50523e84 415
73f2129a 416 self.thread = thread
58d308d1 417 self.thread.measured.connect(self.model.update)
284a2e34 418 self.thread.error.connect(self.error)
73f2129a
JS
419 self.thread.start()
420
421 def setup_ui(self):
422 self.setWindowTitle('sigrok-meter')
58d308d1
JS
423 # resizing the listView below will increase this again
424 self.resize(10, 10)
73f2129a
JS
425
426 p = os.path.abspath(os.path.dirname(__file__))
427 p = os.path.join(p, 'sigrok-logo-notext.png')
428 self.setWindowIcon(QtGui.QIcon(p))
429
430 actionQuit = QtGui.QAction(self)
431 actionQuit.setText('&Quit')
432 actionQuit.setIcon(QtGui.QIcon.fromTheme('application-exit'))
433 actionQuit.setShortcut('Ctrl+Q')
434 actionQuit.triggered.connect(self.close)
435
436 actionAbout = QtGui.QAction(self)
437 actionAbout.setText('&About')
438 actionAbout.setIcon(QtGui.QIcon.fromTheme('help-about'))
439 actionAbout.triggered.connect(self.show_about)
440
441 menubar = self.menuBar()
442 menuFile = menubar.addMenu('&File')
443 menuFile.addAction(actionQuit)
444 menuHelp = menubar.addMenu('&Help')
445 menuHelp.addAction(actionAbout)
446
58d308d1
JS
447 self.listView = EmptyMessageListView('waiting for data...')
448 self.listView.setFrameShape(QtGui.QFrame.NoFrame)
449 self.listView.viewport().setBackgroundRole(QtGui.QPalette.Window)
450 self.listView.viewport().setAutoFillBackground(True)
451 self.listView.setMinimumWidth(260)
452 self.listView.setSelectionMode(QtGui.QAbstractItemView.NoSelection)
453 self.listView.setEditTriggers(QtGui.QAbstractItemView.NoEditTriggers)
454 self.listView.setVerticalScrollMode(QtGui.QAbstractItemView.ScrollPerPixel)
455 self.listView.setItemDelegate(self.delegate)
456 self.listView.setModel(self.model)
457 self.listView.setUniformItemSizes(True)
458 self.listView.setMinimumSize(self.delegate.sizeHint())
459
460 self.setCentralWidget(self.listView)
73f2129a
JS
461 self.centralWidget().setContentsMargins(0, 0, 0, 0)
462
f517686f 463 @QtCore.Slot()
73f2129a
JS
464 def show_about(self):
465 text = textwrap.dedent('''\
466 <div align="center">
467 <b>sigrok-meter</b><br/>
468 0.1.0<br/>
469 Using libsigrok {} (lib version {}).<br/>
470 <a href='http://www.sigrok.org'>
471 http://www.sigrok.org</a><br/>
472 <br/>
473 This program comes with ABSOLUTELY NO WARRANTY;<br/>
474 for details visit
475 <a href='http://www.gnu.org/licenses/gpl.html'>
476 http://www.gnu.org/licenses/gpl.html</a>
477 </div>
478 '''.format(self.thread.sr_pkg_version(), self.thread.sr_lib_version()))
479
480 QtGui.QMessageBox.about(self, 'About sigrok-meter', text)
481
f517686f 482 @QtCore.Slot(str)
284a2e34
JS
483 def error(self, msg):
484 '''Error handler for the sampling thread.'''
485 QtGui.QMessageBox.critical(self, 'Error', msg)
486 self.close()
487
58d308d1
JS
488 @QtCore.Slot(object, int, int)
489 def modelRowsInserted(self, parent, start, end):
490 '''Resizes the list view to the size of the content.'''
491
492 rows = self.model.rowCount()
493 dh = self.delegate.sizeHint().height()
494 self.listView.setMinimumHeight(dh * rows)
495
f94bb73f 496if __name__ == '__main__':
73f2129a
JS
497 thread = SamplingThread(args['drivers'], args['loglevel'])
498
499 app = QtGui.QApplication([])
500 s = SigrokMeter(thread)
501 s.show()
502
503 r = app.exec_()
504 thread.stop()
505 sys.exit(r)