Arduino-MQTT Bridge (PyQt5)

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

../_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 the RemoteStation Arduino Sketch on your Arduino using the Arduino IDE. This sketch may be a convenient starting point for your customized solution.

  2. Exit the Arduino IDE to release the Arduino serial port.

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

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

  5. Select the port corresponding to your course number.

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

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

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

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