Arduino-MQTT Bridge (PyQt5)

This GUI application connects a microcontroller program to a remote MQTT server over the network. It communicates with the microcontroller using the serial port, relaying lines of text to and from the MQTT server. This can be used as a platform for remote collaboration between embedded devices.

../_images/qt_arduino_mqtt_bridge.png

The Arduino-MQTT bridge application in operation. The large button at the top brings up detailed help.

The application is provided all in one file and can be be directly downloaded from qt_arduino_mqtt_bridge.py. The following sections have both documentation and the full code.

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 procedure for use generally follows this sequence:

  1. Install one of the remote connection examples on your CircuitPython board. These can serve as starting points for your own sketches.

  2. Launch the qt_arduino_mqtt_bridge.py program using Python 3.

  3. Select your Arduino serial port from the dropdown, then click Connect.

  4. Select the port corresponding to your course number.

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

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

  7. Enter your Andrew ID in the sending field; this will identify your transmitted messages.

  8. Enter your partner Andrew ID in the receiving field; this will select incoming messages.

If testing locally, it is convenient also to run the MQTT Monitor (PyQt5) in order to simulate the collaborating system.

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_arduino_mqtt_bridge.MainApp[source]

Main application object holding any non-GUI related state.

send_arduino_message(payload)[source]

Transfer a message from the Arduino to the current topic.

send_message(payload)[source]

Publish a message entered by the user.

QtArduinoMQTT

class mqtt.qt_arduino_mqtt_bridge.QtArduinoMQTT(main)[source]

Class to manage a serial connection to an Arduino MQTT sketch using Qt QSerialPort object for data transport. The data protocol is based on lines of text.

available_ports()[source]

Return a list of names of available serial ports.

close()[source]

Shut down the serial connection to the Arduino.

open()[source]

Open the serial port and initialize communications. If the port is already open, this will close it first. If the current name is None, this will not open anything. Returns True if the port is open, else False.

thread_safe_write(data)[source]

Function to receive data to transmit from a background thread, then send it as a signal to a slot on the main thread.

MainGUI

The verbose user interface code is separated into a separate class from the serial port and network logic.

class mqtt.qt_arduino_mqtt_bridge.MainGUI(main, *args, **kwargs)[source]

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

closeEvent(self, a0: QCloseEvent)[source]
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.

Full Code

  1#!/usr/bin/env python3
  2"""A PyQt5 GUI utility to connect an Arduino with a remote MQTT server."""
  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
 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, QtSerialPort
 22
 23# documentation: https://www.eclipse.org/paho/clients/python/docs/
 24import paho.mqtt.client as mqtt
 25
 26# configure logging output
 27log = logging.getLogger('main')
 28log.setLevel(logging.INFO)
 29
 30mqtt_log = logging.getLogger('mqtt')
 31mqtt_log.setLevel(logging.INFO)
 32
 33paho_log = logging.getLogger('paho.mqtt')
 34paho_log.setLevel(logging.INFO)
 35
 36# IDeATE server instances, as per https://mqtt.ideate.cmu.edu/#ports
 37
 38ideate_ports = { 8884 : '16-223',
 39                 8885 : '16-375',
 40                 8886 : '60-223',
 41                 8887 : '62-362',
 42                 8889 : '16-376',
 43}
 44mqtt_rc_codes = ['Success', 'Incorrect protocol version', 'Invalid client identifier', 'Server unavailable', 'Bad username or password', 'Not authorized']
 45
 46# Set the version identifier.  This should be updated prior to setting a new git tag.
 47version_id = 'v1.1'
 48
 49################################################################
 50class QtArduinoMQTT(QtCore.QObject):
 51    """Class to manage a serial connection to an Arduino MQTT sketch using Qt
 52    QSerialPort object for data transport.  The data protocol is based on lines
 53    of text.
 54    """
 55
 56    # class variable with Qt signal used to communicate between background thread and serial port thread
 57    _threadedWrite = QtCore.pyqtSignal(bytes, name='threadedWrite')
 58
 59    def __init__(self, main):
 60        super(QtArduinoMQTT,self).__init__()
 61        self._portname = None
 62        self._buffer = b''
 63        self._port = None
 64        self.log = logging.getLogger('arduino')
 65        self.log.setLevel(logging.INFO)
 66        self.main = main
 67        return
 68
 69    def is_open(self):
 70        return self._port is not None
 71
 72    def available_ports(self):
 73        """Return a list of names of available serial ports."""
 74        return [port.portName() for port in QtSerialPort.QSerialPortInfo.availablePorts()]
 75
 76    def set_port(self, name):
 77        self._portname = name
 78
 79    def open(self):
 80        """Open the serial port and initialize communications.  If the port is already
 81        open, this will close it first.  If the current name is None, this will not open
 82        anything.  Returns True if the port is open, else False."""
 83        if self._port is not None:
 84            self.close()
 85
 86        if self._portname is None:
 87            self.log.debug("No port name provided so not opening port.")
 88            return False
 89
 90        self._port = QtSerialPort.QSerialPort()
 91        self._port.setBaudRate(115200)
 92        self._port.setPortName(self._portname)
 93
 94        # open the serial port, which should also reset the Arduino
 95        if self._port.open(QtCore.QIODevice.ReadWrite):
 96            self.log.info("Opened serial port %s", self._port.portName())
 97            # always process data as it becomes available
 98            self._port.readyRead.connect(self.read_input)
 99
100            # initialize the slot used to receive data from background threads
101            self._threadedWrite.connect(self._data_send)
102
103            return True
104
105        else:
106            # Error codes: https://doc.qt.io/qt-5/qserialport.html#SerialPortError-enum
107            errcode = self._port.error()
108            if errcode == QtSerialPort.QSerialPort.PermissionError:
109                self.log.warning("Failed to open serial port %s with a QSerialPort PermissionError, which could involve an already running control process, a stale lock file, or dialout group permissions.", self._port.portName())
110            else:
111                self.log.warning("Failed to open serial port %s with a QSerialPort error code %d.", self._port.portName(), errcode)
112            self._port = None
113            return False
114
115    def set_and_open_port(self, name):
116        self.set_port(name)
117        self.open()
118
119    def close(self):
120        """Shut down the serial connection to the Arduino."""
121        if self._port is not None:
122            self.log.info("Closing serial port %s", self._port.portName())
123            self._port.close()
124            self._port = None
125        return
126
127    def write(self, data):
128        if self._port is not None:
129            self._port.write(data)
130        else:
131            self.log.debug("Serial port not open during write.")
132
133    @QtCore.pyqtSlot(bytes)
134    def _data_send(self, data):
135        """Slot to receive serial data on the main thread."""
136        self.write(data)
137
138    def thread_safe_write(self, data):
139        """Function to receive data to transmit from a background thread, then send it as a signal to a slot on the main thread."""
140        self._threadedWrite.emit(data)
141
142    def read_input(self):
143        # Read as much input as available; callback from Qt event loop.
144        data = self._port.readAll()
145        if len(data) > 0:
146            self.data_received(data)
147        return
148
149    def _parse_serial_input(self, data):
150        # parse a single line of status input provided as a bytestring
151        tokens = data.split()
152        self.log.debug("Received serial data: %s", tokens)
153        self.main.send_arduino_message(data)
154
155    def data_received(self, data):
156        # Manage the possibility of partial reads by appending new data to any previously received partial line.
157        # The data arrives as a PyQT5.QtCore.QByteArray.
158        self._buffer += bytes(data)
159
160        # Process all complete newline-terminated lines.
161        while b'\n' in self._buffer:
162            first, self._buffer = self._buffer.split(b'\n', 1)
163            first = first.rstrip()
164            self._parse_serial_input(first)
165
166    def send(self, string):
167        self.log.debug("Sending to serial port: %s", string)
168        self.write(string.encode()+b'\n')
169        return
170
171
172################################################################
173class MainGUI(QtWidgets.QMainWindow):
174    """A custom main window which provides all GUI controls.  Requires a delegate main application object to handle user requests."""
175
176    def __init__(self, main, *args, **kwargs):
177        super(MainGUI,self).__init__()
178
179        # save the main object for delegating GUI events
180        self.main = main
181
182        # create the GUI elements
183        self.console_queue = queue.Queue()
184        self.setupUi()
185
186        self._handler = None
187        self.enable_console_logging()
188
189        # finish initialization
190        self.show()
191
192        # manage the console output across threads
193        self.console_timer = QtCore.QTimer()
194        self.console_timer.timeout.connect(self._poll_console_queue)
195        self.console_timer.start(50)  # units are milliseconds
196
197        return
198
199    # ------------------------------------------------------------------------------------------------
200    def setupUi(self):
201        self.setWindowTitle("IDeATe Arduino-MQTT Bridge " + version_id)
202        self.resize(600, 800)
203
204        self.centralwidget = QtWidgets.QWidget(self)
205        self.setCentralWidget(self.centralwidget)
206        self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget)
207        self.verticalLayout.setContentsMargins(-1, -1, -1, 9) # left, top, right, bottom
208
209        # help panel button
210        help = QtWidgets.QPushButton('Open the Help Panel')
211        help.pressed.connect(self.help_requested)
212        self.verticalLayout.addWidget(help)
213
214        hrule = QtWidgets.QFrame()
215        hrule.setFrameShape(QtWidgets.QFrame.HLine)
216        self.verticalLayout.addWidget(hrule)
217
218        # generate fields for configuring the Arduino serial port
219        hbox = QtWidgets.QHBoxLayout()
220        self.verticalLayout.addLayout(hbox)
221        hbox.addWidget(QtWidgets.QLabel("Arduino serial port:"))
222        self.portSelector = QtWidgets.QComboBox()
223        hbox.addWidget(self.portSelector)
224        self.update_port_selector()
225        self.portSelector.activated['QString'].connect(self.arduino_port_selected)
226
227        rescan = QtWidgets.QPushButton('Rescan Serial Ports')
228        rescan.pressed.connect(self.update_port_selector)
229        hbox.addWidget(rescan)
230
231        # Arduino connection indicator and connect/disconnect buttons
232        hbox = QtWidgets.QHBoxLayout()
233        self.verticalLayout.addLayout(hbox)
234        self.arduino_connected = QtWidgets.QLabel()
235        self.arduino_connected.setLineWidth(3)
236        self.arduino_connected.setFrameStyle(QtWidgets.QFrame.Box)
237        self.arduino_connected.setAlignment(QtCore.Qt.AlignCenter)
238        sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
239        self.arduino_connected.setSizePolicy(sizePolicy)
240        self.set_arduino_connected_state(False)
241        hbox.addWidget(self.arduino_connected)
242        connect = QtWidgets.QPushButton('Connect')
243        connect.pressed.connect(self.main.connect_to_arduino)
244        hbox.addWidget(connect)
245        disconnect = QtWidgets.QPushButton('Disconnect')
246        disconnect.pressed.connect(self.main.disconnect_from_arduino)
247        hbox.addWidget(disconnect)
248
249        hrule = QtWidgets.QFrame()
250        hrule.setFrameShape(QtWidgets.QFrame.HLine)
251        self.verticalLayout.addWidget(hrule)
252
253        # generate GUI for configuring the MQTT connection
254
255        # server name entry and port selection
256        hbox = QtWidgets.QHBoxLayout()
257        self.verticalLayout.addLayout(hbox)
258        hbox.addWidget(QtWidgets.QLabel("MQTT server address:"))
259        self.mqtt_server_name = QtWidgets.QLineEdit()
260        self.mqtt_server_name.setText(str(self.main.hostname))
261        self.mqtt_server_name.editingFinished.connect(self.mqtt_server_name_entered)
262        hbox.addWidget(self.mqtt_server_name)
263
264        hbox.addWidget(QtWidgets.QLabel("port:"))
265        self.port_selector = QtWidgets.QComboBox()
266        hbox.addWidget(self.port_selector)
267
268        self.port_selector.addItem("")
269        for pairs in ideate_ports.items():
270            self.port_selector.addItem("%d (%s)" % pairs)
271        self.port_selector.activated['QString'].connect(self.mqtt_port_selected)
272
273        # attempt to pre-select the stored port number
274        try:
275            idx = list(ideate_ports.keys()).index(self.main.portnum)
276            self.port_selector.setCurrentIndex(idx+1)
277        except ValueError:
278            pass
279
280        # instructions
281        explanation = QtWidgets.QLabel("""Username and password provided by instructor.  Please see help panel for details.""")
282        explanation.setWordWrap(True)
283        self.verticalLayout.addWidget(explanation)
284
285        # user and password entry
286        hbox = QtWidgets.QHBoxLayout()
287        self.verticalLayout.addLayout(hbox)
288        hbox.addWidget(QtWidgets.QLabel("MQTT username:"))
289        self.mqtt_username = QtWidgets.QLineEdit()
290        self.mqtt_username.setText(str(self.main.username))
291        self.mqtt_username.editingFinished.connect(self.mqtt_username_entered)
292        hbox.addWidget(self.mqtt_username)
293
294        hbox.addWidget(QtWidgets.QLabel("password:"))
295        self.mqtt_password = QtWidgets.QLineEdit()
296        self.mqtt_password.setText(str(self.main.password))
297        self.mqtt_password.editingFinished.connect(self.mqtt_password_entered)
298        hbox.addWidget(self.mqtt_password)
299
300        # connection indicator
301        self.mqtt_connected = QtWidgets.QLabel()
302        self.mqtt_connected.setLineWidth(3)
303        self.mqtt_connected.setFrameStyle(QtWidgets.QFrame.Box)
304        self.mqtt_connected.setAlignment(QtCore.Qt.AlignCenter)
305        sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Fixed, QtWidgets.QSizePolicy.Fixed)
306        self.mqtt_connected.setSizePolicy(sizePolicy)
307        self.set_mqtt_connected_state(False)
308
309        # connection control buttons
310        connect = QtWidgets.QPushButton('Connect')
311        connect.pressed.connect(self.connection_requested)
312        disconnect = QtWidgets.QPushButton('Disconnect')
313        disconnect.pressed.connect(self.main.disconnect_from_mqtt_server)
314        hbox = QtWidgets.QHBoxLayout()
315        hbox.addWidget(self.mqtt_connected)
316        hbox.addWidget(connect)
317        hbox.addWidget(disconnect)
318        self.verticalLayout.addLayout(hbox)
319
320        hrule = QtWidgets.QFrame()
321        hrule.setFrameShape(QtWidgets.QFrame.HLine)
322        self.verticalLayout.addWidget(hrule)
323
324        # user and partner ID instructions
325        explanation = QtWidgets.QLabel("""Andrew IDs identify where to send and receive messages.  Please see help panel for details.""")
326        explanation.setWordWrap(True)
327        self.verticalLayout.addWidget(explanation)
328
329        # user ID entry
330        hbox = QtWidgets.QHBoxLayout()
331        hbox.addWidget(QtWidgets.QLabel("Your Andrew ID (for sending):"))
332        self.user_id = QtWidgets.QLineEdit()
333        self.user_id.setText(self.main.user_id)
334        self.user_id.editingFinished.connect(self.user_id_entered)
335        hbox.addWidget(self.user_id)
336        self.verticalLayout.addLayout(hbox)
337
338        # partner ID entry
339        hbox = QtWidgets.QHBoxLayout()
340        hbox.addWidget(QtWidgets.QLabel("Partner's Andrew ID (for receiving):"))
341        self.partner_id = QtWidgets.QLineEdit()
342        self.partner_id.setText(self.main.partner_id)
343        self.partner_id.editingFinished.connect(self.partner_id_entered)
344        hbox.addWidget(self.partner_id)
345        self.verticalLayout.addLayout(hbox)
346
347        # text area for displaying both internal and received messages
348        self.consoleOutput = QtWidgets.QPlainTextEdit()
349        self.consoleOutput.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded)
350        self.verticalLayout.addWidget(self.consoleOutput)
351
352        # instructions
353        explanation = QtWidgets.QLabel("""Entering text below will manually send a message.""")
354        explanation.setWordWrap(True)
355        self.verticalLayout.addWidget(explanation)
356
357        # message payload entry
358        hbox = QtWidgets.QHBoxLayout()
359        label = QtWidgets.QLabel("User message:")
360        self.mqtt_payload = QtWidgets.QLineEdit()
361        self.mqtt_payload.setText(self.main.payload)
362        self.mqtt_payload.returnPressed.connect(self.mqtt_payload_entered)
363        hbox.addWidget(label)
364        hbox.addWidget(self.mqtt_payload)
365        self.verticalLayout.addLayout(hbox)
366
367        # set up the status bar which appears at the bottom of the window
368        self.statusbar = QtWidgets.QStatusBar(self)
369        self.setStatusBar(self.statusbar)
370
371        # set up the main menu
372        self.menubar = QtWidgets.QMenuBar(self)
373        self.menubar.setGeometry(QtCore.QRect(0, 0, 500, 22))
374        self.menubar.setNativeMenuBar(False)
375        self.menubar.setObjectName("menubar")
376        self.menuTitle = QtWidgets.QMenu(self.menubar)
377        self.setMenuBar(self.menubar)
378        self.actionQuit = QtWidgets.QAction(self)
379        self.menuTitle.addAction(self.actionQuit)
380        self.menubar.addAction(self.menuTitle.menuAction())
381        self.menuTitle.setTitle("File")
382        self.actionQuit.setText("Quit")
383        self.actionQuit.setShortcut("Ctrl+Q")
384        self.actionQuit.triggered.connect(self.quitSelected)
385
386        return
387
388    # --- logging to screen -------------------------------------------------------------
389    def enable_console_logging(self):
390        # get the root logger to receive all logging traffic
391        logger = logging.getLogger()
392        # create a logging handler which writes to the console window via self.write
393        handler = logging.StreamHandler(self)
394        handler.setFormatter(logging.Formatter('%(levelname)s:%(name)s: %(message)s'))
395        logger.addHandler(handler)
396        # logger.setLevel(logging.NOTSET)
397        logger.setLevel(logging.DEBUG)
398        handler.setLevel(logging.DEBUG)
399        self._handler = handler
400        log.info("Enabled logging in console window.")
401        return
402
403    def disable_console_logging(self):
404        if self._handler is not None:
405            logging.getLogger().removeHandler(self._handler)
406            self._handler = None
407
408    # --- window and qt event processing -------------------------------------------------------------
409    def show_status(self, string):
410        self.statusbar.showMessage(string)
411
412    def _poll_console_queue(self):
413        """Write any queued console text to the console text area from the main thread."""
414        while not self.console_queue.empty():
415            string = str(self.console_queue.get())
416            stripped = string.rstrip()
417            if stripped != "":
418                self.consoleOutput.appendPlainText(stripped)
419        return
420
421    def write(self, string):
422        """Write output to the console text area in a thread-safe way.  Qt only allows
423        calls from the main thread, but the service routines run on separate threads."""
424        self.console_queue.put(string)
425        return
426
427    def quitSelected(self):
428        self.write("User selected quit.")
429        self.close()
430
431    def closeEvent(self, event):
432        self.write("Received window close event.")
433        self.main.app_is_exiting()
434        self.disable_console_logging()
435        super(MainGUI,self).closeEvent(event)
436
437    def set_mqtt_connected_state(self, flag):
438        if flag is True:
439            self.mqtt_connected.setText("  Connected   ")
440            self.mqtt_connected.setStyleSheet("color: white; background-color: green;")
441        else:
442            self.mqtt_connected.setText(" Not Connected ")
443            self.mqtt_connected.setStyleSheet("color: white; background-color: blue;")
444
445    def set_arduino_connected_state(self, flag):
446        if flag is True:
447            self.arduino_connected.setText("  Connected   ")
448            self.arduino_connected.setStyleSheet("color: white; background-color: green;")
449        else:
450            self.arduino_connected.setText(" Not Connected ")
451            self.arduino_connected.setStyleSheet("color: white; background-color: blue;")
452
453    def update_port_selector(self):
454        self.portSelector.clear()
455        self.portSelector.addItem("<no port selected>")
456        for port in QtSerialPort.QSerialPortInfo.availablePorts():
457            self.portSelector.insertItem(0, port.portName())
458        self.portSelector.setCurrentText(self.main.portname)
459
460    # --- GUI widget event processing ----------------------------------------------------------------------
461    def help_requested(self):
462        panel = QtWidgets.QDialog(self)
463        panel.resize(600,400)
464        panel.setWindowTitle("IDeATe MQTT Monitor: Help Info")
465        vbox = QtWidgets.QVBoxLayout(panel)
466        hbox = QtWidgets.QHBoxLayout()
467        vbox.addLayout(hbox)
468        text = QtWidgets.QTextEdit(panel)
469        hbox.addWidget(text)
470        text.insertHtml("""
471<style type="text/css">
472table { margin-left: 20px; }
473td { padding-left: 20px; }
474</style>
475<a href="#top"></a>
476<h1>IDeATe Arduino-MQTT Bridge</h1>
477
478<p>This Python application is a tool intended for connecting an Arduino sketch
479to a remote Arduino by passing short data messages back and forth across the
480network via a MQTT server.  It supports connecting to an Arduino serial port,
481opening an authenticated connection to a remote server, subscribing to a class of
482messages in order to receive them, viewing message traffic, and publishing new
483messages from the Arduino on a specified message topic.</p>
484
485<p>In typical use, the messages are readable text such as lines of numbers
486separated by spaces.  The Arduino serial data is treated as line-delimited text,
487with each text line input transmitted as a message.  I.e., messages can be sent
488to the remote Arduino using <code>Serial.println()</code>.  Received messages
489are treated as text and forwarded to the Arduino with an appended linefeed
490character.  Sketches processing the received network data may use Serial.read()
491and related functions to parse the input.</p>
492
493<h2>Arduino</h2>
494
495<p>The first set of controls configures the Arduino connection.  The port names
496are only scanned during launch so the application will need to be restarted if
497the Arduino is plugged in or moved after starting.  Please note this is the raw
498list of serial ports which will likely include other non-Arduino devices; when
499in doubt, please use the same port name used by the Arduino IDE.</p>
500
501<p>The Arduino USB serial port is shared with the Arduino IDE, so you may need
502to disconnect the Arduino IDE Console or stop the Arduino IDE entirely for the
503connection to succeed.  You'll also need to disconnect this tool before
504reloading code with the IDE.</p>
505
506<h2>Connecting to MQTT</h2>
507
508<p>The next set of controls configures server parameters before attempting a
509connection.  Changes will not take effect until the next connection attempt.</p
510
511<dl>
512  <dt>server address</dt><dd>The network name of the MQTT server. (Defaults to mqtt.ideate.cmu.edu.)</dd>
513  <dt>server port</dt><dd>The numeric port number for the MQTT server.  IDeATe is
514      using a separate server for each course, so the drop-down menu also identifies the associated course number.</dd>
515  <dt>username</dt><dd>Server-specific identity, chosen by your instructor.</dd>
516  <dt>password</dt><dd>Server-specific password, chosen by your instructor.</dd>
517</dl>
518
519<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>
520
521<h2>User Identification and Messages</h2>
522
523<p>MQTT works on a publish/subscribe model in which messages are published on
524<i>topics</i> identified by a topic name.   For simplicity, this tool publishes your
525Arduino output on a single topic based on your Andrew ID; anyone on the server may read this topic to receive your data.
526Similarly, this tool <i>subscribes</i> (i.e. listens) to a single topic based on your partner's Andrew ID.
527<p>
528
529<p>Changing either the user or partner ID immediately changes what is sent or
530received.</p>
531
532<p>The large text field below the ID fieldss is the console area which shows
533message traffic as well as status and debugging messages.</p>
534
535<p>At the bottom is a field for publishing a message as if it were received from
536your Arduino.<p>
537
538<h2>More Information</h2>
539
540<p>The IDeATE server has more detailed information on the server help page at <b>https://mqtt.ideate.cmu.edu</b></p>
541
542""")
543        text.scrollToAnchor("top")
544        text.setReadOnly(True)
545        panel.show()
546
547    def arduino_port_selected(self, name):
548        self.write("Arduino port selected: %s" % name)
549        self.main.set_arduino_port(name)
550
551    def mqtt_server_name_entered(self):
552        name = self.mqtt_server_name.text()
553        self.write("Server name changed: %s" % name)
554        self.main.set_server_name(name)
555
556    def decode_port_selection(self):
557        title = self.port_selector.currentText()
558        if title == "":
559            return None
560        else:
561            return int(title.split()[0])  # convert the first token to a number
562
563    def mqtt_port_selected(self, title):
564        portnum  = self.decode_port_selection()
565        self.write("Port selection changed: %s" % title)
566        self.main.set_server_port(portnum)
567
568    def mqtt_username_entered(self):
569        name = self.mqtt_username.text()
570        self.write("User name changed: %s" % name)
571        self.main.set_username(name)
572
573    def mqtt_password_entered(self):
574        name = self.mqtt_password.text()
575        self.write("Password changed: %s" % name)
576        self.main.set_password(name)
577
578    def connection_requested(self):
579        # When the connect button is pressed, make sure all fields are up to
580        # date.  It is otherwise possible to leave a text field selected with
581        # unreceived changes while pressing Connect.
582        hostname = self.mqtt_server_name.text()
583        portnum  = self.decode_port_selection()
584        username = self.mqtt_username.text()
585        password = self.mqtt_password.text()
586
587        self.main.set_server_name(hostname)
588        self.main.set_server_port(portnum)
589        self.main.set_username(username)
590        self.main.set_password(password)
591
592        self.main.connect_to_mqtt_server()
593
594    def user_id_entered(self):
595        id = self.user_id.text()
596        if id == '':
597            log.warning("User ID cannot be empty.")
598        else:
599            self.main.set_user_id(id)
600            self.write("Set user ID to %s" % id)
601
602    def partner_id_entered(self):
603        id = self.partner_id.text()
604        if id == '':
605            log.warning("Partner ID cannot be empty.")
606        else:
607            self.main.set_partner_id(id)
608            self.write("Set partner ID to %s" % id)
609
610    def mqtt_payload_entered(self):
611        payload = self.mqtt_payload.text()
612        self.main.send_message(payload)
613        self.mqtt_payload.clear()
614
615################################################################
616class MainApp(object):
617    """Main application object holding any non-GUI related state."""
618
619    def __init__(self):
620
621        # Attach a handler to the keyboard interrupt (control-C).
622        signal.signal(signal.SIGINT, self._sigint_handler)
623
624        # load any available persistent application settings
625        QtCore.QCoreApplication.setOrganizationName("IDeATe")
626        QtCore.QCoreApplication.setOrganizationDomain("ideate.cmu.edu")
627        QtCore.QCoreApplication.setApplicationName('arduino_mqtt_bridge')
628        self.settings = QtCore.QSettings()
629
630        # uncomment to restore 'factory defaults'
631        # self.settings.clear()
632
633        # Arduino serial port name
634        self.portname = self.settings.value('arduino_port', '')
635
636        # MQTT server settings
637        self.hostname = self.settings.value('mqtt_host', 'mqtt.ideate.cmu.edu')
638        self.portnum  = self.settings.value('mqtt_port', None)
639        self.username = self.settings.value('mqtt_user', 'students')
640        self.password = self.settings.value('mqtt_password', '(not yet entered)')
641
642        # Student and partner Andrew IDs, used for generating send and receive message topics.
643        username = getpass.getuser()
644        self.user_id = self.settings.value('user_id', username)
645        self.partner_id = self.settings.value('partner_id', 'unspecified')
646
647        # Create a default subscription and topic based on the username.
648        self.subscription = self.partner_id
649        self.topic = self.user_id
650        self.payload = ''
651
652        # create the interface window
653        self.window = MainGUI(self)
654
655        # Initialize the MQTT client system
656        self.client = mqtt.Client()
657        self.client.enable_logger(paho_log)
658        self.client.on_log = self.on_log
659        self.client.on_connect = self.on_connect
660        self.client.on_disconnect = self.on_disconnect
661        self.client.on_message = self.on_message
662        self.client.tls_set()
663
664        # Initialize the Arduino interface system.
665        self.arduino = QtArduinoMQTT(self)
666        self.arduino.set_port(self.portname)
667
668        self.window.show_status("Disconnected.")
669        return
670
671    ################################################################
672    def app_is_exiting(self):
673        if self.client.is_connected():
674            self.client.disconnect()
675            self.client.loop_stop()
676        self.arduino.close()
677
678    def _sigint_handler(self, signal, frame):
679        print("Keyboard interrupt caught, running close handlers...")
680        self.app_is_exiting()
681        sys.exit(0)
682
683    ################################################################
684    def set_arduino_port(self, name):
685        self.settings.setValue('arduino_port', name)
686        self.portname = name
687        self.arduino.set_port(name)
688        return
689
690    def connect_to_arduino(self):
691        connected_flag = self.arduino.open()
692        self.window.set_arduino_connected_state(connected_flag)
693        return
694
695    def disconnect_from_arduino(self):
696        self.arduino.close()
697        self.window.set_arduino_connected_state(False)
698        return
699
700    ################################################################
701    def set_server_name(self, name):
702        self.hostname = name
703        self.settings.setValue('mqtt_host', name)
704
705    def set_server_port(self, value):
706        self.portnum = value
707        self.settings.setValue('mqtt_port', self.portnum)
708
709    def set_username(self, name):
710        self.username = name
711        self.settings.setValue('mqtt_user', name)
712
713    def set_password(self, name):
714        self.password = name
715        self.settings.setValue('mqtt_password', name)
716
717    def connect_to_mqtt_server(self):
718        if self.client.is_connected():
719            self.window.write("Already connected.")
720        else:
721            if self.portnum is None:
722                log.warning("Please specify the server port before attempting connection.")
723            else:
724                log.debug("Initiating MQTT connection to %s:%d" % (self.hostname, self.portnum))
725                self.window.write("Attempting connection.")
726                self.client.username_pw_set(self.username, self.password)
727                self.client.connect_async(self.hostname, self.portnum)
728                self.client.loop_start()
729
730    def disconnect_from_mqtt_server(self):
731        if self.client.is_connected():
732            self.client.disconnect()
733        else:
734            self.window.write("Not connected.")
735        self.client.loop_stop()
736
737    ################################################################
738    # The callback for when the broker responds to our connection request.
739    def on_connect(self, client, userdata, flags, rc):
740        mqtt_log.debug("Connected to server with with flags: %s, result code: %s", flags, rc)
741
742        if rc == 0:
743            mqtt_log.info("Connection succeeded.")
744
745        elif rc > 0:
746            if rc < len(mqtt_rc_codes):
747                mqtt_log.warning("Connection failed with error: %s", mqtt_rc_codes[rc])
748            else:
749                mqtt_log.warning("Connection failed with unknown error %d", rc)
750
751        # Subscribing in on_connect() means that if we lose the connection and reconnect then subscriptions will be renewed.
752        client.subscribe(self.subscription)
753        self.window.show_status("Connected.")
754        self.window.set_mqtt_connected_state(True)
755        return
756
757    # The callback for when the broker responds with error messages.
758    def on_log(client, userdata, level, buf):
759        mqtt_log.debug("level %s: %s", level, userdata)
760        return
761
762    def on_disconnect(self, client, userdata, rc):
763        mqtt_log.info("Disconnected from server.")
764        self.window.show_status("Disconnected.")
765        self.window.set_mqtt_connected_state(False)
766
767    # The callback for when a message has been received on a topic to which this
768    # client is subscribed.  The message variable is a MQTTMessage that describes
769    # all of the message parameters.
770
771    # Some useful MQTTMessage fields: topic, payload, qos, retain, mid, properties.
772    #   The payload is a binary string (bytes).
773    #   qos is an integer quality of service indicator (0,1, or 2)
774    #   mid is an integer message ID.
775
776    def on_message(self, client, userdata, msg):
777        printable = self._printable_message_text(msg.payload)
778        self.window.write("Received from %s: %s" % (msg.topic, printable))
779
780        if self.arduino.is_open():
781            line = msg.payload + b'\n'
782            log.debug("Sending received message to Arduino: %s", line)
783            self.arduino.thread_safe_write(line)
784            log.debug("Sent received message to Arduino: %s", line)
785        else:
786            log.debug("Arduino not open to receive message: %s", msg.payload)
787        return
788
789    def _printable_message_text(self, msg):
790        # Test whether the bytes array is 7-bit clean text, else binary, and provide a printable form.
791        if all(c > 31 and c < 128 for c in msg):
792            return msg.decode(encoding='ascii')
793        else:
794            return "binary message:" + str([c for c in msg])
795
796    ################################################################
797    def set_subscription(self, sub):
798        if self.client.is_connected():
799            self.client.unsubscribe(self.subscription)
800            try:
801                self.client.subscribe(sub)
802                self.subscription = sub
803            except ValueError:
804                self.window.write("Invalid subscription string, not changed.")
805                self.client.subscribe(self.subscription)
806        else:
807            self.subscription = sub
808
809    def set_user_id(self, id):
810        self.user_id = id
811        self.topic = id
812
813    def set_partner_id(self, id):
814        self.partner_id = id
815        self.set_subscription(id)
816
817    def send_message(self, payload):
818        """Publish a message entered by the user."""
819
820        if self.client.is_connected():
821            self.client.publish(self.topic, payload)
822            self.window.write("Transmitting on %s: %s" % (self.topic, payload))
823        else:
824            self.window.write("Not connected.")
825        self.payload = payload
826
827    def send_arduino_message(self, payload):
828        """Transfer a message from the Arduino to the current topic."""
829        if self.client.is_connected():
830            self.client.publish(self.topic, payload)
831            log.debug("Published Arduino message: %s", payload)
832            printable = self._printable_message_text(payload)
833            self.window.write("Transmitting on %s: %s" % (self.topic, printable))
834
835    ################################################################
836
837def main():
838    # Optionally add an additional root log handler to stream messages to the terminal console.
839    if False:
840        console_handler = logging.StreamHandler()
841        console_handler.setLevel(logging.DEBUG)
842        console_handler.setFormatter(logging.Formatter('%(levelname)s:%(name)s: %(message)s'))
843        logging.getLogger().addHandler(console_handler)
844
845    # initialize the Qt system itself
846    app = QtWidgets.QApplication(sys.argv)
847
848    # create the main application controller
849    main = MainApp()
850
851    # run the event loop until the user is done
852    log.info("Starting event loop.")
853    sys.exit(app.exec_())
854
855################################################################
856# Main script follows.  This sequence is executed when the script is initiated from the command line.
857
858if __name__ == "__main__":
859    main()