]>
Commit | Line | Data |
---|---|---|
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 | 24 | import argparse |
73f2129a JS |
25 | import datetime |
26 | import os.path | |
f94bb73f | 27 | import re |
efdef4fa | 28 | import sigrok.core as sr |
782f5926 | 29 | import sys |
f94bb73f | 30 | import textwrap |
13e332b7 | 31 | |
58d308d1 | 32 | default_drivers = [('demo', {'analog_channels': 4})] |
782f5926 JS |
33 | default_loglevel = sr.LogLevel.WARN |
34 | ||
1f199679 JS |
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 | ||
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 |
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.''' | |
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 |
221 | class 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 | ||
340 | class 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 | ||
387 | class 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 |
404 | class 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 | 496 | if __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) |