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.
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:
Install the RemoteStation Arduino Sketch on your Arduino using the Arduino IDE. This sketch may be a convenient starting point for your customized solution.
Exit the Arduino IDE to release the Arduino serial port.
Launch the
qt_arduino_mqtt_bridge.py
program using Python 3.Select your Arduino serial port from the dropdown, then click Connect.
Select the port corresponding to your course number.
Enter the username and password provided by your instructor.
Verify that the server address is
mqtt.ideate.cmu.edu
, then click Connect.Enter your Andrew ID in the sending field; this will identify your transmitted messages.
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¶
QtArduinoMQTT¶
- class mqtt.qt_arduino_mqtt_bridge.QtArduinoMQTT(*args: Any, **kwargs: Any)[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.
MainGUI¶
The verbose user interface code is separated into a separate class from the serial port and network logic.
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()