MQTT Plotter (PyQt5)

This utility GUI application is a tool for visualizing the content of multiple data streams passed as short numeric messages back and forth across the network via a MQTT server. It supports opening an authenticated connection to the server, subscribing to a class of messages in order to receive them, viewing message traffic, and plotting the received messages as points in a dynamically updated graphic.

The expected use is that each message will be generated by an Arduino sketch and forwarded to the MQTT network using the Arduino-MQTT Bridge (PyQt5) application. The following section documents the message formatting options.

Data Format

Each message received is processed as plain text integer numbers separated by spaces. Message lengths of either two or five value: with two values, they are interpreted as the X and Y location (“X Y”), with five values as a position and an RGB color (“X Y R G B”). All values may range from 0 to 100 inclusive. The topic name is used to identify the point, so multiple messages on the same topic will dynamically move a plot point. Some sample messages follow.

message text

graphical result on a plotted point

0 0

move the point to the lower left corner

50 50

move the point to the center

100 50 100 0 0

move to right edge and paint it red

75 75 0 0 100

move near upper right and paint it blue

Installation Requirements

The code requires a working installation of Python 3 with PyQt5 and paho-mqtt. For suggestions on setting up your system please see Python 3 Installation.

User Guide

The connection GUI is derived from MQTT Monitor (PyQt5). The procedure for use generally follows this sequence:

  1. Launch the qt_mqtt_plotter.py program using Python 3.

  2. Select the port corresponding to your course number.

  3. Enter the username and password provided by your instructor.

  4. Verify that the server address is mqtt.ideate.cmu.edu, then click Connect.

  5. Enter a subscription pattern according to your needs. This tool is designed to display all messages, so the default wildcard # is typical for capturing all messages. However, a pattern such as username/# will limit displayed traffic to a particular sender.

  6. Verify that data messages are appearing in the text area.

  7. The plot tab will then show an animated graphic in which each topic stream is represented by a single moving point with 2D location and 3D color.

Source Code Documentation

If you’re curious about the inner workings of the app, the rest of this page provides detailed documentation. This section is not necessary for using the system.

MainApp

class mqtt.qt_mqtt_plotter.MainApp(*args: Any, **kwargs: Any)[source]

Main application object holding any non-GUI related state.

MainGUI

class mqtt.qt_mqtt_plotter.MainGUI(*args: Any, **kwargs: Any)[source]

A custom main window which provides all GUI controls. Requires a delegate main application object to handle user requests.

write(string)[source]

Write output to the console text area in a thread-safe way. Qt only allows calls from the main thread, but the service routines run on separate threads.

QtParticles

class mqtt.qt_mqtt_plotter.QtParticles(*args: Any, **kwargs: Any)[source]

Custom widget to draw a 2D plot of a set of particles. Each particle has 5-D state: [x, y, r, g, b]. Each axis is defined to have unit scaling and is valid on [0,1].

paintEvent(e)[source]

Subclass implementation of parent QWidget class callback to repaint the graphics.

Full Code

  1#!/usr/bin/env python3
  2"""A PyQt5 GUI utility to monitor and plot MQTT server messages."""
  3
  4################################################################
  5# Written in 2018-2020 by Garth Zeglin <garthz@cmu.edu>
  6
  7# To the extent possible under law, the author has dedicated all copyright
  8# and related and neighboring rights to this software to the public domain
  9# worldwide. This software is distributed without any warranty.
 10
 11# You should have received a copy of the CC0 Public Domain Dedication along with this software.
 12# If not, see <http://creativecommons.org/publicdomain/zero/1.0/>.
 13
 14################################################################
 15# standard Python libraries
 16from __future__ import print_function
 17import os, sys, struct, time, logging, functools, queue, signal, getpass, math
 18
 19# documentation: https://doc.qt.io/qt-5/index.html
 20# documentation: https://www.riverbankcomputing.com/static/Docs/PyQt5/index.html
 21from PyQt5 import QtCore, QtGui, QtWidgets, QtNetwork
 22
 23# documentation: https://www.eclipse.org/paho/clients/python/docs/
 24import paho.mqtt.client as mqtt
 25
 26# default logging output
 27log = logging.getLogger('main')
 28
 29# logger to pass to the MQTT library
 30mqtt_log = logging.getLogger('mqtt')
 31mqtt_log.setLevel(logging.WARNING)
 32
 33# IDeATE server instances, as per https://mqtt.ideate.cmu.edu/#ports
 34
 35ideate_ports = { 8884 : '16-223',
 36                 8885 : '16-375',
 37                 8886 : '60-223',
 38                 8887 : '62-362',
 39}
 40
 41mqtt_rc_codes = ['Success', 'Incorrect protocol version', 'Invalid client identifier', 'Server unavailable', 'Bad username or password', 'Not authorized']
 42
 43################################################################
 44class QtParticles(QtWidgets.QWidget):
 45    """Custom widget to draw a 2D plot of a set of particles.  Each particle has 5-D
 46       state: [x, y, r, g, b].  Each axis is defined to have unit scaling and is
 47       valid on [0,1].
 48    """
 49
 50    def __init__(self):
 51        super().__init__()
 52        self.setMinimumSize(QtCore.QSize(100, 100))
 53        self.setAutoFillBackground(True)
 54
 55        # Graphical state variables. Define a placeholder point while testing.
 56        # self.particles  = {'placeholder' : [0.5, 0.5, 0.5, 0.5, 0.5] }
 57        self.particles  = {}
 58
 59        # finish initialization
 60        self.show()
 61        return
 62
 63    # === particle update methods ============================================================
 64    def update_particle_position(self, name, location):
 65        # limit the coordinate range to slightly larger than the plotting bounds
 66        x = min(max(location[0], -0.1), 1.1)
 67        y = min(max(location[1], -0.1), 1.1)
 68
 69        particle = self.particles.get(name)
 70        if particle is None:
 71            self.particles[name] = [x, y, 0.5, 0.5, 0.5]
 72        else:
 73            particle[0:2] = x, y
 74        self.repaint()
 75
 76    def update_particle_color(self, name, rgb):
 77        particle = self.particles.get(name)
 78        if particle is None:
 79            self.particles[name] = [0.0, 0.0, rgb[0], rgb[1], rgb[2]]
 80        else:
 81            particle[2:5] = rgb
 82        self.repaint()
 83
 84    # === Qt API methods ============================================================
 85    def paintEvent(self, e):
 86        """Subclass implementation of parent QWidget class callback to repaint the graphics."""
 87        geometry = self.geometry()
 88        view_width = geometry.width()
 89        view_height = geometry.height()
 90
 91        # Clear the background.
 92        qp = QtGui.QPainter()
 93        qp.begin(self)
 94        qp.fillRect(QtCore.QRectF(0, 0, view_width, view_height), QtCore.Qt.white)
 95        # qp.setRenderHint(QtGui.QPainter.Antialiasing)
 96
 97        # Set up a coordinate system scaled to unit dimension that keeps the
 98        # minimum visible area in view.
 99        scene_width   = 1.3            # minimum visible width
100        scene_height  = scene_width    # minimum visible height
101        scene_aspect  = scene_width / scene_height
102        view_aspect = view_width / view_height
103        if scene_aspect > view_aspect:
104            scaling = view_width / scene_width
105        else:
106            scaling = view_height/scene_height
107
108        # Capture the default graphics transformation.
109        qp.save()
110
111        # Move the origin to the center (in pixel coordinates).
112        qp.translate(QtCore.QPointF(view_width/2, view_height/2))
113
114        # Apply scaling to draw in unit coordinates.
115        qp.scale(scaling, scaling)
116
117        # Translate in the new scaled coordinates to place the origin near the
118        # upper left corner; the default coordinates using +Y pointing down.
119        qp.translate(QtCore.QPointF(-0.5, -0.5))
120
121        # Draw the bounds of the unit square.
122        pen = QtGui.QPen(QtCore.Qt.black)
123        pen.setWidthF(0.005)
124        qp.setPen(pen)
125        qp.drawRect(QtCore.QRectF(0.0, 0.0, 1.0, 1.0))
126
127        # Draw the particles.  The Y dimension is inverted to a normal
128        # mathematical plot with +Y up.
129        color = QtGui.QColor()
130        for particle in self.particles.values():
131            color.setRgbF(min(1.0, abs(particle[2])), min(1.0, abs(particle[3])), min(1.0, abs(particle[4])), 1.0)
132            brush = QtGui.QBrush(color)
133            qp.setBrush(brush)
134            qp.drawEllipse(QtCore.QPointF(particle[0], 1.0 - particle[1]), 0.02, 0.02)
135
136        # Restore the initial unscaled coordinates.
137        qp.restore()
138        qp.end()
139
140
141################################################################
142class MainGUI(QtWidgets.QMainWindow):
143    """A custom main window which provides all GUI controls.  Requires a delegate main application object to handle user requests."""
144
145    def __init__(self, main, *args, **kwargs):
146        super(MainGUI,self).__init__()
147
148        # save the main object for delegating GUI events
149        self.main = main
150
151        # create the GUI elements
152        self.console_queue = queue.Queue()
153        self.setupUi()
154
155        self._handler = None
156        self.enable_console_logging()
157
158        # finish initialization
159        self.show()
160
161        # manage the console output across threads
162        self.console_timer = QtCore.QTimer()
163        self.console_timer.timeout.connect(self._poll_console_queue)
164        self.console_timer.start(50)  # units are milliseconds
165
166        return
167
168    # ------------------------------------------------------------------------------------------------
169    def setupUi(self):
170        self.setWindowTitle("IDeATe MQTT Plotter")
171        self.resize(600, 600)
172
173        # set up tabbed page structure
174        self.tabs = QtWidgets.QTabWidget()
175        self.setCentralWidget(self.tabs)
176
177        # set up a main tab with the connection controls
178        self.mainTab = QtWidgets.QWidget(self)
179        self.tabs.addTab(self.mainTab, 'Main')
180        self.verticalLayout = QtWidgets.QVBoxLayout(self.mainTab)
181        self.verticalLayout.setContentsMargins(-1, -1, -1, 9) # left, top, right, bottom
182
183        # generate GUI for configuring the MQTT connection
184
185        # server name entry and port selection
186        hbox = QtWidgets.QHBoxLayout()
187        self.verticalLayout.addLayout(hbox)
188        hbox.addWidget(QtWidgets.QLabel("MQTT server address:"))
189        self.mqtt_server_name = QtWidgets.QLineEdit()
190        self.mqtt_server_name.setText(str(self.main.hostname))
191        self.mqtt_server_name.editingFinished.connect(self.mqtt_server_name_entered)
192        hbox.addWidget(self.mqtt_server_name)
193
194        hbox.addWidget(QtWidgets.QLabel("port:"))
195        self.port_selector = QtWidgets.QComboBox()
196        hbox.addWidget(self.port_selector)
197
198        self.port_selector.addItem("")
199        for pairs in ideate_ports.items():
200            self.port_selector.addItem("%d (%s)" % pairs)
201        self.port_selector.activated['QString'].connect(self.mqtt_port_selected)
202
203        # attempt to pre-select the stored port number
204        try:
205            idx = list(ideate_ports.keys()).index(self.main.portnum)
206            self.port_selector.setCurrentIndex(idx+1)
207        except ValueError:
208            pass
209
210        # instructions
211        explanation = QtWidgets.QLabel("""Username and password provided by instructor.  Please see help tab for details.""")
212        explanation.setWordWrap(True)
213        self.verticalLayout.addWidget(explanation)
214
215        # user and password entry
216        hbox = QtWidgets.QHBoxLayout()
217        self.verticalLayout.addLayout(hbox)
218        hbox.addWidget(QtWidgets.QLabel("MQTT username:"))
219        self.mqtt_username = QtWidgets.QLineEdit()
220        self.mqtt_username.setText(str(self.main.username))
221        self.mqtt_username.editingFinished.connect(self.mqtt_username_entered)
222        hbox.addWidget(self.mqtt_username)
223
224        hbox.addWidget(QtWidgets.QLabel("password:"))
225        self.mqtt_password = QtWidgets.QLineEdit()
226        self.mqtt_password.setText(str(self.main.password))
227        self.mqtt_password.editingFinished.connect(self.mqtt_password_entered)
228        hbox.addWidget(self.mqtt_password)
229
230        # instructions
231        explanation = QtWidgets.QLabel("""A subscription specifies topics to receive.  Please see help tab for details.""")
232        explanation.setWordWrap(True)
233        self.verticalLayout.addWidget(explanation)
234
235        # subscription topic entry
236        hbox = QtWidgets.QHBoxLayout()
237        label = QtWidgets.QLabel("MQTT message subscription:")
238        self.mqtt_sub = QtWidgets.QLineEdit()
239        self.mqtt_sub.setText(self.main.subscription)
240        self.mqtt_sub.editingFinished.connect(self.mqtt_sub_entered)
241        hbox.addWidget(label)
242        hbox.addWidget(self.mqtt_sub)
243        self.verticalLayout.addLayout(hbox)
244
245        # connection indicator
246        self.connected = QtWidgets.QLabel()
247        self.connected.setLineWidth(3)
248        self.connected.setFrameStyle(QtWidgets.QFrame.Box)
249        self.connected.setAlignment(QtCore.Qt.AlignCenter)
250        sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
251        self.connected.setSizePolicy(sizePolicy)
252        self.set_connected_state(False)
253
254        # connection control buttons
255        connect = QtWidgets.QPushButton('Connect')
256        connect.pressed.connect(self.connection_requested)
257        disconnect = QtWidgets.QPushButton('Disconnect')
258        disconnect.pressed.connect(self.main.disconnect_from_mqtt_server)
259        hbox = QtWidgets.QHBoxLayout()
260        hbox.addWidget(self.connected)
261        hbox.addWidget(connect)
262        hbox.addWidget(disconnect)
263        self.verticalLayout.addLayout(hbox)
264
265        # text area for displaying both internal and received messages
266        self.consoleOutput = QtWidgets.QPlainTextEdit()
267        self.consoleOutput.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
268        self.verticalLayout.addWidget(self.consoleOutput)
269
270        # set up the graphics tab
271        self.plot = QtParticles()
272        self.tabs.addTab(self.plot, 'Plot')
273
274        # set up the help tab
275        self.helpTab = QtWidgets.QWidget(self)
276        self.tabs.addTab(self.helpTab, 'Help')
277        self._make_help(self.helpTab)
278
279        # set up the status bar which appears at the bottom of the window
280        self.statusbar = QtWidgets.QStatusBar(self)
281        self.setStatusBar(self.statusbar)
282
283        # set up the main menu
284        self.menubar = QtWidgets.QMenuBar(self)
285        self.menubar.setGeometry(QtCore.QRect(0, 0, 500, 22))
286        self.menubar.setNativeMenuBar(False)
287        self.menubar.setObjectName("menubar")
288        self.menuTitle = QtWidgets.QMenu(self.menubar)
289        self.setMenuBar(self.menubar)
290        self.actionQuit = QtWidgets.QAction(self)
291        self.menuTitle.addAction(self.actionQuit)
292        self.menubar.addAction(self.menuTitle.menuAction())
293        self.menuTitle.setTitle("File")
294        self.actionQuit.setText("Quit")
295        self.actionQuit.setShortcut("Ctrl+Q")
296        self.actionQuit.triggered.connect(self.quitSelected)
297
298        return
299
300    # --- verbose function to create the help tab -----------------------------------------
301    def _make_help(self, parent):
302        vbox = QtWidgets.QVBoxLayout(parent)
303        hbox = QtWidgets.QHBoxLayout()
304        vbox.addLayout(hbox)
305        text = QtWidgets.QTextEdit()
306        hbox.addWidget(text)
307        text.insertHtml("""
308<style type="text/css">
309table { margin-left: 20px; }
310td { padding-left: 20px; }
311</style>
312<a href="#top"></a>
313<h1>IDeATe MQTT Plotter</h1>
314<p>This Python application is a tool intended for visualizing the content of multiple data streams passed as short numeric messages back and forth across the network via a MQTT server.  It supports opening an authenticated connection to the server, subscribing to a class of messages in order to receive them, viewing message traffic, and plotting the received messages as points in a dynamically updated graphic.</p>
315<h2>Connecting</h2>
316<p>The first set of controls configures server parameters before attempting a connection.  Changes will not take effect until the next connection attempt.</p
317
318<dl>
319  <dt>server address</dt><dd>The network name of the MQTT server. (Defaults to mqtt.ideate.cmu.edu.)</dd>
320  <dt>server port</dt><dd>The numeric port number for the MQTT server.  IDeATe is using a separate server for each course, so the drop-down menu also identifies the associated course number.</dd>
321  <dt>username</dt><dd>Server-specific identity, chosen by your instructor.</dd>
322  <dt>password</dt><dd>Server-specific password, chosen by your instructor.</dd>
323</dl>
324
325<p>Your username and password is specific to the MQTT server and will be provided by your instructor.  This may be individual or may be a shared login for all students in the course.  Please note, the password will not be your Andrew password.</p>
326
327<h2>Listening</h2>
328
329<p>MQTT works on a publish/subscribe model in which messages are published on <i>topics</i> identified by a topic name.  The name is structured like a path string separated by <tt>/</tt> characters to organize messages into a hierarchy of topics and subtopics.
330Our course policy will be to prefix all topics with a student andrew ID, e.g. if your user name is xyzzy, we ask that you publish on the 'xyzzy' topic and sub-topics, as per the following examples.</p>
331
332
333<p>
334<table>
335<tr><td><b>xyzzy</b></td><td>top-level topic on which user 'xyzzy' should publish</td></tr>
336<tr><td><b>xyzzy/status</b></td><td>a sub-topic on which user 'xyzzy' could publish</td></tr>
337<tr><td><b>xyzzy/sensor</b></td><td>another sub-topic on which user 'xyzzy' could publish</td></tr>
338<tr><td><b>xyzzy/sensor/1</b></td><td>a possible sub-sub-topic</td></tr>
339</table>
340</p>
341
342<p>The message subscription field specifies topics to receive.  The subscription may include a # character as a wildcard, as per the following examples.</p>
343<p><table>
344<tr><td><b>#</b></td><td>subscribe to all messages (typical for this application)</td></tr>
345<tr><td><b>xyzzy</b></td><td>subscribe to the top-level published messages for user xyzzy</td></tr>
346<tr><td><b>xyzzy/#</b></td><td>subscribe to all published messages for user xyzzy, including subtopics</td></tr>
347</table>
348</p>
349<p>Changing the subscription field immediately changes what is received; the monitor unsubscribes from the previous pattern and subscribes to the new one.  Entering an empty field defaults to the global pattern '#'.</p>
350
351<p>The large text field is the console area which shows both debugging and status log messages as well as received messages.</p>
352
353<h2>Data Format</h2>
354
355<p>Each message received is processed as plain text integer numbers separated by spaces.  Either two or five value messages are supported: with two values, they are interpreted as the X and Y location ("X Y"), with five values as a position and an RGB color ("X Y R G B").  All values may range from 0 to 100 inclusive.  The topic name is used to identify the point, so multiple messages on the same topic will dynamically move a plot point.  Some sample messages follow.</p>
356
357<p><table>
358<tr><td><b>0 0</b></td><td>move the point to the lower left corner</td></tr>
359<tr><td><b>50 50</b></td><td>move the point to the center</td></tr>
360<tr><td><b>100 50 100 0 0</b></td><td>move to right edge and paint it red</td></tr>
361<tr><td><b>75 75 0 0 100</b></td><td>move near upper right and paint it blue</td></tr>
362</table>
363</p>
364
365<h2>More Information</h2>
366
367<p>The IDeATE server has more detailed information on the server help page at <b>https://mqtt.ideate.cmu.edu</b></p>
368
369""")
370        text.scrollToAnchor("top")
371        text.setReadOnly(True)
372        return
373
374    # --- logging to screen -------------------------------------------------------------
375    def enable_console_logging(self):
376        # get the root logger to receive all logging traffic
377        logger = logging.getLogger()
378        # create a logging handler which writes to the console window via self.write
379        handler = logging.StreamHandler(self)
380        handler.setFormatter(logging.Formatter('%(levelname)s:%(name)s: %(message)s'))
381        logger.addHandler(handler)
382        # logger.setLevel(logging.NOTSET)
383        logger.setLevel(logging.DEBUG)
384        # logger.setLevel(logging.WARNING)
385        handler.setLevel(logging.NOTSET)
386        self._handler = handler
387        log.info("Enabled logging in console window.")
388        return
389
390    def disable_console_logging(self):
391        if self._handler is not None:
392            logging.getLogger().removeHandler(self._handler)
393            self._handler = None
394
395    # --- window and qt event processing -------------------------------------------------------------
396    def show_status(self, string):
397        self.statusbar.showMessage(string)
398
399    def _poll_console_queue(self):
400        """Write any queued console text to the console text area from the main thread."""
401        while not self.console_queue.empty():
402            string = str(self.console_queue.get())
403            stripped = string.rstrip()
404            if stripped != "":
405                self.consoleOutput.appendPlainText(stripped)
406        return
407
408    def write(self, string):
409        """Write output to the console text area in a thread-safe way.  Qt only allows
410        calls from the main thread, but the service routines run on separate threads."""
411        self.console_queue.put(string)
412        return
413
414    def quitSelected(self):
415        self.write("User selected quit.")
416        self.close()
417
418    def closeEvent(self, event):
419        self.write("Received window close event.")
420        self.main.app_is_exiting()
421        self.disable_console_logging()
422        super(MainGUI,self).closeEvent(event)
423
424    def set_connected_state(self, flag):
425        if flag is True:
426            self.connected.setText("  Connected   ")
427            self.connected.setStyleSheet("color: white; background-color: green;")
428        else:
429            self.connected.setText(" Not Connected ")
430            self.connected.setStyleSheet("color: white; background-color: blue;")
431
432
433    # --- GUI widget event processing ----------------------------------------------------------------------
434
435    def mqtt_server_name_entered(self):
436        name = self.mqtt_server_name.text()
437        self.write("Server name changed: %s" % name)
438        self.main.set_server_name(name)
439
440    def decode_port_selection(self):
441        title = self.port_selector.currentText()
442        if title == "":
443            return None
444        else:
445            return int(title.split()[0])  # convert the first token to a number
446
447    def mqtt_port_selected(self, title):
448        portnum  = self.decode_port_selection()
449        self.write("Port selection changed: %s" % title)
450        self.main.set_server_port(portnum)
451
452    def mqtt_username_entered(self):
453        name = self.mqtt_username.text()
454        self.write("User name changed: %s" % name)
455        self.main.set_username(name)
456
457    def mqtt_password_entered(self):
458        name = self.mqtt_password.text()
459        self.write("Password changed: %s" % name)
460        self.main.set_password(name)
461
462    def connection_requested(self):
463        # When the connect button is pressed, make sure all fields are up to
464        # date.  It is otherwise possible to leave a text field selected with
465        # unreceived changes while pressing Connect.
466        hostname = self.mqtt_server_name.text()
467        portnum  = self.decode_port_selection()
468        username = self.mqtt_username.text()
469        password = self.mqtt_password.text()
470
471        self.main.set_server_name(hostname)
472        self.main.set_server_port(portnum)
473        self.main.set_username(username)
474        self.main.set_password(password)
475
476        self.main.connect_to_mqtt_server()
477
478    def mqtt_sub_entered(self):
479        sub = self.mqtt_sub.text()
480        if sub == '':
481            self.mqtt_sub.setText("#")
482            sub = "#"
483
484        self.write("Subscription changed to: %s" % sub)
485        self.main.set_subscription(sub)
486
487################################################################
488class MainApp(QtCore.QObject):
489    """Main application object holding any non-GUI related state."""
490
491    # class variable with Qt signal used to communicate between network thread and main thread
492    _messageReceived = QtCore.pyqtSignal(str, bytes, name='_messageReceived')
493
494    def __init__(self):
495
496        super(MainApp,self).__init__()
497        # Attach a handler to the keyboard interrupt (control-C).
498        signal.signal(signal.SIGINT, self._sigint_handler)
499
500        # load any available persistent application settings
501        QtCore.QCoreApplication.setOrganizationName("IDeATe")
502        QtCore.QCoreApplication.setOrganizationDomain("ideate.cmu.edu")
503        QtCore.QCoreApplication.setApplicationName('mqtt_plotter')
504        self.settings = QtCore.QSettings()
505
506        # uncomment to restore 'factory defaults'
507        # self.settings.clear()
508
509        # MQTT server settings
510        self.hostname = self.settings.value('mqtt_host', 'mqtt.ideate.cmu.edu')
511        self.portnum  = self.settings.value('mqtt_port', None)
512        self.username = self.settings.value('mqtt_user', 'students')
513        self.password = self.settings.value('mqtt_password', '(not yet entered)')
514
515        # Create a default subscription based on the username.  The hash mark is a wildcard.
516        username = getpass.getuser()
517
518        # self.subscription = self.settings.value('mqtt_subscription', username + '/#')
519        self.subscription = self.settings.value('mqtt_subscription', '#')
520
521        # create the interface window
522        self.window = MainGUI(self)
523
524        # Initialize the MQTT client system
525        self.client = mqtt.Client()
526        self.client.enable_logger(mqtt_log)
527        self.client.on_log = self.on_log
528        self.client.on_connect = self.on_connect
529        self.client.on_disconnect = self.on_disconnect
530        self.client.on_message = self.on_message
531        self.client.tls_set()
532
533        # Connect the signal used to transfer received messages from the network server thread to the main thread.
534        self._messageReceived.connect(self.process_message)
535
536        self.window.show_status("Please set the MQTT server address and select Connect.")
537        return
538
539    ################################################################
540    def app_is_exiting(self):
541        if self.client.is_connected():
542            self.client.disconnect()
543            self.client.loop_stop()
544
545    def _sigint_handler(self, signal, frame):
546        print("Keyboard interrupt caught, running close handlers...")
547        self.app_is_exiting()
548        sys.exit(0)
549
550    ################################################################
551    def set_server_name(self, name):
552        self.hostname = name
553        self.settings.setValue('mqtt_host', name)
554
555    def set_server_port(self, value):
556        self.portnum = value
557        self.settings.setValue('mqtt_port', self.portnum)
558
559    def set_username(self, name):
560        self.username = name
561        self.settings.setValue('mqtt_user', name)
562
563    def set_password(self, name):
564        self.password = name
565        self.settings.setValue('mqtt_password', name)
566
567    def connect_to_mqtt_server(self):
568        if self.client.is_connected():
569            self.window.write("Already connected.")
570        else:
571            if self.portnum is None:
572                log.warning("Please specify the server port before attempting connection.")
573            else:
574                log.debug("Initiating MQTT connection to %s:%d" % (self.hostname, self.portnum))
575                self.window.write("Attempting connection.")
576                self.client.username_pw_set(self.username, self.password)
577                self.client.connect_async(self.hostname, self.portnum)
578                self.client.loop_start()
579
580    def disconnect_from_mqtt_server(self):
581        if self.client.is_connected():
582            self.client.disconnect()
583        else:
584            self.window.write("Not connected.")
585        self.client.loop_stop()
586
587    ################################################################
588    # The callback for when the broker responds to our connection request.
589    def on_connect(self, client, userdata, flags, rc):
590        self.window.write("Connected to server with with flags: %s, result code: %s" % (flags, rc))
591
592        if rc == 0:
593            log.info("Connection succeeded.")
594
595        elif rc > 0:
596            if rc < len(mqtt_rc_codes):
597                log.warning("Connection failed with error: %s", mqtt_rc_codes[rc])
598            else:
599                log.warning("Connection failed with unknown error %d", rc)
600
601        # Subscribing in on_connect() means that if we lose the connection and reconnect then subscriptions will be renewed.
602        client.subscribe(self.subscription)
603        self.window.show_status("Connected.")
604        self.window.set_connected_state(True)
605        return
606
607    # The callback for when the broker responds with error messages.
608    def on_log(client, userdata, level, buf):
609        log.debug("on_log level %s: %s", level, userdata)
610        return
611
612    def on_disconnect(self, client, userdata, rc):
613        log.debug("disconnected")
614        self.window.write("Disconnected from server.")
615        self.window.show_status("Disconnected.")
616        self.window.set_connected_state(False)
617
618    # The callback for when a message has been received on a topic to which this
619    # client is subscribed.  The message variable is a MQTTMessage that describes
620    # all of the message parameters.
621
622    # Some useful MQTTMessage fields: topic, payload, qos, retain, mid, properties.
623    #   The payload is a binary string (bytes).
624    #   qos is an integer quality of service indicator (0,1, or 2)
625    #   mid is an integer message ID.
626
627    # N.B. this function is called from the network server thread, but the actual
628    # processing needs to happen on the main thread for graphic output.
629    def on_message(self, client, userdata, msg):
630        self.window.write("{%s} %s" % (msg.topic, msg.payload))
631        self._messageReceived.emit(msg.topic, msg.payload)
632
633    @QtCore.pyqtSlot(str, bytes)
634    def process_message(self, topic, payload):
635        # Update the particle plotter.  The name of the particle is the topic
636        # name, and the data updated depends on the message format.
637        name = topic
638
639        # Parse the message text by attempting to convert all tokens to integers.  The values
640        # are scaled so that the default range is 0 to 100.
641        tokens = payload.split()
642        try:
643            values = [(0.01 * int(x)) for x in tokens]
644            if len(values) > 1:
645                self.window.plot.update_particle_position(name, values[0:2])
646            if len(values) > 4:
647                self.window.plot.update_particle_color(name, values[2:5])
648
649        except ValueError:
650            log.warning("Error parsing message into numbers: %s %s", topic, payload)
651
652        return
653
654    ################################################################
655    def set_subscription(self, sub):
656        if self.client.is_connected():
657            self.client.unsubscribe(self.subscription)
658            try:
659                self.client.subscribe(sub)
660                self.subscription = sub
661                self.settings.setValue('mqtt_subscription', sub)
662            except ValueError:
663                self.window.write("Invalid subscription string, not changed.")
664                self.client.subscribe(self.subscription)
665        else:
666            self.subscription = sub
667            self.settings.setValue('mqtt_subscription', sub)
668
669    ################################################################
670
671def main():
672    # Optionally add an additional root log handler to stream messages to the terminal console.
673    if False:
674        console_handler = logging.StreamHandler()
675        console_handler.setLevel(logging.DEBUG)
676        console_handler.setFormatter(logging.Formatter('%(levelname)s:%(name)s: %(message)s'))
677        logging.getLogger().addHandler(console_handler)
678
679    # initialize the Qt system itself
680    app = QtWidgets.QApplication(sys.argv)
681
682    # create the main application controller
683    main = MainApp()
684
685    # run the event loop until the user is done
686    log.info("Starting event loop.")
687    sys.exit(app.exec_())
688
689################################################################
690# Main script follows.  This sequence is executed when the script is initiated from the command line.
691
692if __name__ == "__main__":
693    main()