Continued implementing support for MQTT v5 user properties. eric7

Thu, 22 Jul 2021 19:02:32 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Thu, 22 Jul 2021 19:02:32 +0200
branch
eric7
changeset 103
5fe4f179975f
parent 102
70b8858199f5
child 104
9a4c9b7f078c

Continued implementing support for MQTT v5 user properties.

MqttMonitor/MqttClient.py file | annotate | diff | comparison | revisions
MqttMonitor/MqttConnectionOptionsDialog.py file | annotate | diff | comparison | revisions
MqttMonitor/MqttConnectionOptionsDialog.ui file | annotate | diff | comparison | revisions
MqttMonitor/MqttConnectionProfilesDialog.py file | annotate | diff | comparison | revisions
MqttMonitor/MqttConnectionProfilesDialog.ui file | annotate | diff | comparison | revisions
MqttMonitor/MqttMonitorWidget.py file | annotate | diff | comparison | revisions
MqttMonitor/MqttMonitorWidget.ui file | annotate | diff | comparison | revisions
MqttMonitor/MqttUserPropertiesEditor.py file | annotate | diff | comparison | revisions
MqttMonitor/MqttUserPropertiesEditor.ui file | annotate | diff | comparison | revisions
PluginMqttMonitor.py file | annotate | diff | comparison | revisions
--- a/MqttMonitor/MqttClient.py	Wed Jul 21 20:10:36 2021 +0200
+++ b/MqttMonitor/MqttClient.py	Thu Jul 22 19:02:32 2021 +0200
@@ -122,6 +122,7 @@
         
         self.__cleanSession = cleanSession
         self.__protocol = protocol
+        self.__disconnectUserProperties = []
         
         if protocol == MqttProtocols.MQTTv5:
             cleanSession = None
@@ -278,7 +279,7 @@
         """
         self.__mqttClient.user_data_set(userdata)
     
-    # TODO: MQTTv5: add support for properties
+    # TODO: MQTTv5: add support for WILL properties
     def setLastWill(self, topic, payload=None, qos=0, retain=False):
         """
         Public method to set the last will of the client.
@@ -350,7 +351,7 @@
         self.__loopStarted = False
     
     def connectToServer(self, host, port=1883, keepalive=60, bindAddress="",
-                        reinit=True):
+                        properties=None):
         """
         Public method to connect to a remote MQTT broker.
         
@@ -365,14 +366,18 @@
         @param bindAddress IP address of a local network interface to bind
             this client to
         @type str
-        @param reinit flag indicating to reinitialize the MQTT client before
-            trying to connect with the given parameters
-        @type bool
+        @param properties list of user properties to be sent with the
+            subscription
+        @type list of tuple of (str, str)
         """
-        # TODO: MQTTv5: add support for MQTTv5 properties
+        props = (
+            self.__createPropertiesObject(PacketTypes.CONNECT, properties)
+            if properties else
+            None
+        )
         self.__mqttClient.connect_async(
             host, port=port, keepalive=keepalive, bind_address=bindAddress,
-            clean_start=self.__cleanSession)
+            clean_start=self.__cleanSession, properties=props)
         
         self.__connectTimeoutTimer.start()
         
@@ -393,10 +398,10 @@
             this client to
         @type str
         @param options dictionary containing the connection options. This
-            dictionary should contain the keys "ClientId", "Keepalive",
-            "CleanSession", "Username", "Password", "WillTopic", "WillMessage",
-            "WillQos", "WillRetain", "TlsEnable", "TlsCaCert", "TlsClientCert",
-            "TlsClientKey", "ConnectionTimeout"
+            dictionary should contain the keys "ClientId", "ConnectionTimeout",
+            "Keepalive", "CleanSession", "Username", "Password", "WillTopic",
+            "WillMessage", "WillQos", "WillRetain", "TlsEnable", "TlsCaCert",
+            "TlsClientCert", "TlsClientKey", "UserProperties".
         @type dict
         """
         if options:
@@ -443,10 +448,25 @@
                     # use default TLS configuration
                     self.setTLS()
             
+            # step 4: get the connect user properties
+            if self.__protocol == MqttProtocols.MQTTv5:
+                try:
+                    userProperties = parametersDict["UserProperties"]
+                    properties = userProperties["connect"][:]
+                    self.__disconnectUserProperties = (
+                        userProperties["connect"][:]
+                        if userProperties["use_connect"] else
+                        userProperties["disconnect"][:]
+                    )
+                except KeyError:
+                    properties = None
+            else:
+                properties = None
             # step 4: connect to server
             self.__cleanSession = parametersDict["CleanSession"]
             self.connectToServer(host, port=port,
-                                 keepalive=parametersDict["Keepalive"])
+                                 keepalive=parametersDict["Keepalive"],
+                                 properties=properties)
         else:
             keepalive = self.defaultConnectionOptions["Keepalive"]
             self.connectToServer(host, port=port, keepalive=keepalive,
@@ -462,7 +482,7 @@
             the keys "ClientId", "Protocol", "ConnectionTimeout", "Keepalive",
             "CleanSession", "Username", "Password", "WillTopic", "WillMessage",
             "WillQos", "WillRetain", "TlsEnable", "TlsCaCert", "TlsClientCert",
-            "TlsClientKey".
+            "TlsClientKey", "UserProperties".
         @rtype dict
         """
         return {
@@ -481,6 +501,11 @@
             "TlsCaCert": "",
             "TlsClientCert": "",
             "TlsClientKey": "",
+            "UserProperties": {
+                "connect": [],
+                "disconnect": [],
+                "use_connect": True,
+            },
         }
     
     def reconnectToServer(self):
@@ -500,9 +525,13 @@
         """
         self.__connectTimeoutTimer.stop()
         
-        # TODO: MQTTv5: add support for properties (?)
-        # TODO: MQTTv5: add support for reason code
-        self.__mqttClient.disconnect()
+        props = (
+            self.__createPropertiesObject(
+                PacketTypes.DISCONNECT, self.__disconnectUserProperties)
+            if self.__disconnectUserProperties else
+            None
+        )
+        self.__mqttClient.disconnect(properties=props)
     
     def subscribe(self, topic, qos=0, properties=None):
         """
@@ -539,14 +568,14 @@
         @rtype tuple of (int, int)
         """
         props = (
-            self.__createPropertiesObject(PacketTypes.SUBSCRIBE, properties)
+            self.__createPropertiesObject(PacketTypes.UNSUBSCRIBE, properties)
             if properties else
             None
         )
         return self.__mqttClient.unsubscribe(topic, properties=props)
     
-    # TODO: MQTTv5: add support for properties
-    def publish(self, topic, payload=None, qos=0, retain=False):
+    def publish(self, topic, payload=None, qos=0, retain=False,
+                properties=None):
         """
         Public method to publish to a topic.
         
@@ -559,11 +588,19 @@
         @param retain flag indicating to set as the "last known good"/retained
             message for the topic
         @type bool
+        @param properties list of user properties to be sent with the
+            subscription
+        @type list of tuple of (str, str)
         @return message info object
         @rtype mqtt.MQTTMessageInfo
         """
+        props = (
+            self.__createPropertiesObject(PacketTypes.PUBLISH, properties)
+            if properties else
+            None
+        )
         return self.__mqttClient.publish(topic, payload=payload, qos=qos,
-                                         retain=retain)
+                                         retain=retain, properties=props)
     
     def __createPropertiesObject(self, packetType, properties):
         """
@@ -577,8 +614,7 @@
         @rtype Properties
         """
         props = Properties(packetType)
-        for userProperty in properties:
-            props.UserProperty = tuple(userProperty)
+        props.UserProperty = properties
         return props
 
 
--- a/MqttMonitor/MqttConnectionOptionsDialog.py	Wed Jul 21 20:10:36 2021 +0200
+++ b/MqttMonitor/MqttConnectionOptionsDialog.py	Thu Jul 22 19:02:32 2021 +0200
@@ -7,6 +7,8 @@
 Module implementing a dialog to enter MQTT connection options.
 """
 
+import copy
+
 from PyQt6.QtCore import pyqtSlot, QUuid
 from PyQt6.QtWidgets import QDialog, QDialogButtonBox, QAbstractButton
 
@@ -24,6 +26,7 @@
     """
     Class implementing a dialog to enter MQTT connection options.
     """
+    # TODO: add WILL user properties
     def __init__(self, options=None, parent=None):
         """
         Constructor
@@ -32,7 +35,7 @@
             populate the dialog with. It must have the keys "ClientId",
             "Protocol", "ConnectionTimeout", "Keepalive", "CleanSession",
             "Username", "Password", "WillTopic", "WillMessage", "WillQos",
-            "WillRetain", "TlsEnable", "TlsCaCert".
+            "WillRetain", "TlsEnable", "TlsCaCert", "UserProperties".
         @type dict
         @param parent reference to the parent widget
         @type QWidget
@@ -44,8 +47,16 @@
         self.tlsCertsFilePicker.setFilters(
             self.tr("Certificate Files (*.crt *.pem);;All Files (*)"))
         
+        # initialize MQTTv5 related stuff
+        self.on_mqttv5Button_toggled(False)
+        
         self.__populateDefaults(options=options)
         
+        self.connectPropertiesButton.clicked[bool].connect(
+            self.__propertiesTypeSelected)
+        self.disconnectPropertiesButton.clicked[bool].connect(
+            self.__propertiesTypeSelected)
+        
         self.__updateOkButton()
     
     def __updateOkButton(self):
@@ -76,6 +87,40 @@
         self.clientIdEdit.setText(
             uuid.toString(QUuid.StringFormat.WithoutBraces))
     
+    @pyqtSlot(str)
+    def on_clientIdEdit_textChanged(self, clientId):
+        """
+        Private slot handling a change of the client ID string.
+        
+        @param clientId client ID
+        @type str
+        """
+        self.__updateOkButton()
+    
+    @pyqtSlot(bool)
+    def on_cleanSessionCheckBox_clicked(self, checked):
+        """
+        Private slot to handle a change of the clean session selection.
+        
+        @param checked current state of the clean session selection
+        @type bool
+        """
+        self.__updateOkButton()
+    
+    @pyqtSlot(bool)
+    def on_mqttv5Button_toggled(self, checked):
+        """
+        Private slot to handle the selection of the MQTT protocol.
+        
+        @param checked state of the button
+        @type bool
+        """
+        self.optionsWidget.setTabEnabled(
+            self.optionsWidget.indexOf(self.propertiesTab),
+            checked
+        )
+        # TODO: add code to enable the WILL properties button
+    
     @pyqtSlot(QAbstractButton)
     def on_buttonBox_clicked(self, button):
         """
@@ -89,6 +134,40 @@
         ):
             self.__populateDefaults(options=None)
     
+    @pyqtSlot(bool)
+    def on_samePropertiesCheckBox_toggled(self, checked):
+        """
+        Private slot to handle a change of the properties usage.
+        
+        @param checked flag indicating to use the same user properties for
+            CONNECT and DISCONNECT
+        @type bool
+        """
+        if checked and not self.connectPropertiesButton.isChecked():
+            self.connectPropertiesButton.click()
+        self.disconnectPropertiesButton.setEnabled(not checked)
+    
+    @pyqtSlot(bool)
+    def __propertiesTypeSelected(self, checked):
+        """
+        Private slot to handle the switching of the user properties type.
+        
+        @param checked state of the buttons
+        @type bool
+        """
+        if checked:
+            # handle the selection only
+            if self.connectPropertiesButton.isChecked():
+                self.__userProperties["disconnect"] = (
+                    self.propertiesWidget.getProperties())
+                self.propertiesWidget.setProperties(
+                    self.__userProperties["connect"])
+            else:
+                self.__userProperties["connect"] = (
+                    self.propertiesWidget.getProperties())
+                self.propertiesWidget.setProperties(
+                    self.__userProperties["disconnect"])
+    
     def __populateDefaults(self, options=None):
         """
         Private method to populate the dialog.
@@ -97,10 +176,10 @@
         default values.
         
         @param options dictionary containing the connection options to populate
-            the dialog with. It must have the keys "ClientId", "Keepalive",
-            "CleanSession", "Username", "Password", "WillTopic", "WillMessage",
-            "WillQos", "WillRetain", "TlsEnable", "TlsCaCert",
-            "ConnectionTimeout".
+            the dialog with. It must have the keys "ClientId", "Protocol",
+            "ConnectionTimeout", "Keepalive", "CleanSession", "Username",
+            "Password", "WillTopic", "WillMessage", "WillQos", "WillRetain",
+            "TlsEnable", "TlsCaCert", "UserProperties".
         @type dict
         """
         if options is None:
@@ -131,6 +210,27 @@
         # TLS parameters
         self.tlsEnableCheckBox.setChecked(options["TlsEnable"])
         self.tlsCertsFilePicker.setText(options["TlsCaCert"])
+        
+        # user properties
+        self.__userProperties = copy.deepcopy(
+            options.get("UserProperties", {}))
+        if not self.__userProperties:
+            self.__userProperties = {
+                "connect": [],
+                "disconnect": [],
+                "use_connect": True,
+            }
+        
+        if options["Protocol"] == MqttProtocols.MQTTv5:
+            self.connectPropertiesButton.setChecked(True)
+            self.propertiesWidget.setProperties(
+                self.__userProperties["connect"])
+            self.samePropertiesCheckBox.setChecked(
+                self.__userProperties["use_connect"])
+            self.disconnectPropertiesButton.setEnabled(
+                not self.__userProperties["use_connect"])
+        else:
+            self.propertiesWidget.clear()
     
     def getConnectionOptions(self):
         """
@@ -139,7 +239,8 @@
         @return dictionary containing the connection options. It has the keys
             "ClientId", "Protocol", "ConnectionTimeout", "Keepalive",
             "CleanSession", "Username", "Password", "WillTopic", "WillMessage",
-            "WillQos", "WillRetain", "TlsEnable", "TlsCaCert".
+            "WillQos", "WillRetain", "TlsEnable", "TlsCaCert",
+            "UserProperties".
         @rtype dict
         """
         if self.mqttv31Button.isChecked():
@@ -151,6 +252,18 @@
         else:
             protocol = MqttProtocols.MQTTv311
         
+        if protocol == MqttProtocols.MQTTv5:
+            if self.connectPropertiesButton.isChecked():
+                self.__userProperties["connect"] = (
+                    self.propertiesWidget.getProperties())
+            else:
+                self.__userProperties["disconnect"] = (
+                    self.propertiesWidget.getProperties())
+            self.__userProperties["use_connect"] = (
+                self.samePropertiesCheckBox.isChecked())
+        else:
+            self.__userProperties = {}
+        
         return {
             "ClientId": self.clientIdEdit.text(),
             "Protocol": protocol,
@@ -164,25 +277,6 @@
             "WillQos": self.willQosSpinBox.value(),
             "WillRetain": self.willRetainCheckBox.isChecked(),
             "TlsEnable": self.tlsEnableCheckBox.isChecked(),
-            "TlsCaCert": self.tlsCertsFilePicker.text()
+            "TlsCaCert": self.tlsCertsFilePicker.text(),
+            "UserProperties": copy.deepcopy(self.__userProperties),
         }
-    
-    @pyqtSlot(str)
-    def on_clientIdEdit_textChanged(self, clientId):
-        """
-        Private slot handling a change of the client ID string.
-        
-        @param clientId client ID
-        @type str
-        """
-        self.__updateOkButton()
-    
-    @pyqtSlot(bool)
-    def on_cleanSessionCheckBox_clicked(self, checked):
-        """
-        Private slot to handle a change of the clean session selection.
-        
-        @param checked current state of the clean session selection
-        @type bool
-        """
-        self.__updateOkButton()
--- a/MqttMonitor/MqttConnectionOptionsDialog.ui	Wed Jul 21 20:10:36 2021 +0200
+++ b/MqttMonitor/MqttConnectionOptionsDialog.ui	Thu Jul 22 19:02:32 2021 +0200
@@ -6,8 +6,8 @@
    <rect>
     <x>0</x>
     <y>0</y>
-    <width>550</width>
-    <height>675</height>
+    <width>450</width>
+    <height>350</height>
    </rect>
   </property>
   <property name="windowTitle">
@@ -16,67 +16,390 @@
   <property name="sizeGripEnabled">
    <bool>true</bool>
   </property>
-  <layout class="QVBoxLayout" name="verticalLayout_2">
+  <layout class="QVBoxLayout" name="verticalLayout">
    <item>
-    <widget class="QGroupBox" name="groupBox">
-     <property name="title">
-      <string>General</string>
+    <widget class="QTabWidget" name="optionsWidget">
+     <property name="currentIndex">
+      <number>0</number>
      </property>
-     <layout class="QVBoxLayout" name="verticalLayout">
-      <item>
-       <layout class="QHBoxLayout" name="horizontalLayout">
-        <item>
-         <widget class="QLabel" name="label">
-          <property name="text">
-           <string>Client ID:</string>
-          </property>
-         </widget>
-        </item>
-        <item>
-         <widget class="QLineEdit" name="clientIdEdit">
-          <property name="toolTip">
-           <string>Enter the ID string for this client</string>
-          </property>
-          <property name="clearButtonEnabled">
-           <bool>true</bool>
-          </property>
-         </widget>
-        </item>
-        <item>
-         <widget class="QPushButton" name="generateIdButton">
-          <property name="toolTip">
-           <string>Press to generate a client ID</string>
-          </property>
-          <property name="text">
-           <string>Generate</string>
-          </property>
-         </widget>
-        </item>
-       </layout>
-      </item>
-      <item>
-       <widget class="QGroupBox" name="groupBox_5">
-        <property name="title">
-         <string>MQTT Protocol</string>
-        </property>
-        <layout class="QHBoxLayout" name="horizontalLayout_3">
+     <widget class="QWidget" name="generalTab">
+      <attribute name="title">
+       <string>General</string>
+      </attribute>
+      <layout class="QVBoxLayout" name="verticalLayout_2">
+       <item>
+        <layout class="QHBoxLayout" name="horizontalLayout">
          <item>
-          <widget class="QRadioButton" name="mqttv31Button">
+          <widget class="QLabel" name="label">
+           <property name="text">
+            <string>Client ID:</string>
+           </property>
+          </widget>
+         </item>
+         <item>
+          <widget class="QLineEdit" name="clientIdEdit">
            <property name="toolTip">
-            <string>Select to use the MQTT 3.1 protocol</string>
+            <string>Enter the ID string for this client</string>
            </property>
-           <property name="text">
-            <string>v 3.1</string>
+           <property name="clearButtonEnabled">
+            <bool>true</bool>
            </property>
           </widget>
          </item>
          <item>
-          <widget class="QRadioButton" name="mqttv311Button">
+          <widget class="QPushButton" name="generateIdButton">
            <property name="toolTip">
-            <string>Select to use the MQTT 3.1.1 protocol</string>
+            <string>Press to generate a client ID</string>
            </property>
            <property name="text">
-            <string>v 3.1.1</string>
+            <string>Generate</string>
+           </property>
+          </widget>
+         </item>
+        </layout>
+       </item>
+       <item>
+        <widget class="QGroupBox" name="groupBox_5">
+         <property name="title">
+          <string>MQTT Protocol</string>
+         </property>
+         <layout class="QHBoxLayout" name="horizontalLayout_3">
+          <item>
+           <widget class="QRadioButton" name="mqttv31Button">
+            <property name="toolTip">
+             <string>Select to use the MQTT 3.1 protocol</string>
+            </property>
+            <property name="text">
+             <string>v 3.1</string>
+            </property>
+           </widget>
+          </item>
+          <item>
+           <widget class="QRadioButton" name="mqttv311Button">
+            <property name="toolTip">
+             <string>Select to use the MQTT 3.1.1 protocol</string>
+            </property>
+            <property name="text">
+             <string>v 3.1.1</string>
+            </property>
+            <property name="checked">
+             <bool>true</bool>
+            </property>
+           </widget>
+          </item>
+          <item>
+           <widget class="QRadioButton" name="mqttv5Button">
+            <property name="toolTip">
+             <string>Select to use the MQTT 5.0 protocol</string>
+            </property>
+            <property name="text">
+             <string>v 5.0</string>
+            </property>
+           </widget>
+          </item>
+         </layout>
+        </widget>
+       </item>
+       <item>
+        <layout class="QGridLayout" name="gridLayout">
+         <item row="0" column="0">
+          <widget class="QLabel" name="label_6">
+           <property name="text">
+            <string>Connection Timeout:</string>
+           </property>
+          </widget>
+         </item>
+         <item row="0" column="1">
+          <widget class="QSpinBox" name="connectionTimeoutSpinBox">
+           <property name="toolTip">
+            <string>Enter the connection timeout in seconds</string>
+           </property>
+           <property name="alignment">
+            <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+           </property>
+           <property name="suffix">
+            <string> s</string>
+           </property>
+           <property name="maximum">
+            <number>300</number>
+           </property>
+           <property name="singleStep">
+            <number>5</number>
+           </property>
+           <property name="value">
+            <number>15</number>
+           </property>
+          </widget>
+         </item>
+         <item row="0" column="2">
+          <spacer name="horizontalSpacer_2">
+           <property name="orientation">
+            <enum>Qt::Horizontal</enum>
+           </property>
+           <property name="sizeHint" stdset="0">
+            <size>
+             <width>148</width>
+             <height>20</height>
+            </size>
+           </property>
+          </spacer>
+         </item>
+         <item row="1" column="0">
+          <widget class="QLabel" name="label_2">
+           <property name="text">
+            <string>Keep Alive Interval:</string>
+           </property>
+          </widget>
+         </item>
+         <item row="1" column="1">
+          <widget class="QSpinBox" name="keepaliveSpinBox">
+           <property name="toolTip">
+            <string>Enter the keep alive interval in seconds</string>
+           </property>
+           <property name="alignment">
+            <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+           </property>
+           <property name="suffix">
+            <string> s</string>
+           </property>
+           <property name="maximum">
+            <number>300</number>
+           </property>
+           <property name="singleStep">
+            <number>5</number>
+           </property>
+           <property name="value">
+            <number>60</number>
+           </property>
+          </widget>
+         </item>
+         <item row="1" column="2">
+          <spacer name="horizontalSpacer">
+           <property name="orientation">
+            <enum>Qt::Horizontal</enum>
+           </property>
+           <property name="sizeHint" stdset="0">
+            <size>
+             <width>148</width>
+             <height>20</height>
+            </size>
+           </property>
+          </spacer>
+         </item>
+        </layout>
+       </item>
+       <item>
+        <widget class="QCheckBox" name="cleanSessionCheckBox">
+         <property name="toolTip">
+          <string>Select to start with a clean session</string>
+         </property>
+         <property name="text">
+          <string>Clean Session</string>
+         </property>
+        </widget>
+       </item>
+       <item>
+        <spacer name="verticalSpacer">
+         <property name="orientation">
+          <enum>Qt::Vertical</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>20</width>
+           <height>92</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QWidget" name="credentialsTab">
+      <attribute name="title">
+       <string>User Credentials</string>
+      </attribute>
+      <layout class="QGridLayout" name="gridLayout_5">
+       <item row="0" column="0">
+        <widget class="QLabel" name="label_3">
+         <property name="text">
+          <string>User Name:</string>
+         </property>
+        </widget>
+       </item>
+       <item row="0" column="1">
+        <widget class="QLineEdit" name="usernameEdit">
+         <property name="toolTip">
+          <string>Enter the user name</string>
+         </property>
+         <property name="clearButtonEnabled">
+          <bool>true</bool>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="0">
+        <widget class="QLabel" name="label_4">
+         <property name="text">
+          <string>Password:</string>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="1">
+        <widget class="QLineEdit" name="passwordEdit">
+         <property name="toolTip">
+          <string>Enter the password</string>
+         </property>
+         <property name="echoMode">
+          <enum>QLineEdit::Password</enum>
+         </property>
+         <property name="clearButtonEnabled">
+          <bool>true</bool>
+         </property>
+        </widget>
+       </item>
+       <item row="2" column="1">
+        <spacer name="verticalSpacer_2">
+         <property name="orientation">
+          <enum>Qt::Vertical</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>20</width>
+           <height>204</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QWidget" name="lastWillTab">
+      <attribute name="title">
+       <string>Last Will</string>
+      </attribute>
+      <layout class="QGridLayout" name="gridLayout_2">
+       <item row="0" column="1">
+        <widget class="QLabel" name="label_5">
+         <property name="text">
+          <string>QoS:</string>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="0" colspan="4">
+        <widget class="QPlainTextEdit" name="willMessageEdit">
+         <property name="maximumSize">
+          <size>
+           <width>16777215</width>
+           <height>300</height>
+          </size>
+         </property>
+         <property name="toolTip">
+          <string>Enter the last will message to be sent</string>
+         </property>
+        </widget>
+       </item>
+       <item row="0" column="2">
+        <widget class="QSpinBox" name="willQosSpinBox">
+         <property name="toolTip">
+          <string>Enter the desired QoS value</string>
+         </property>
+         <property name="alignment">
+          <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
+         </property>
+         <property name="maximum">
+          <number>2</number>
+         </property>
+        </widget>
+       </item>
+       <item row="0" column="3">
+        <widget class="QCheckBox" name="willRetainCheckBox">
+         <property name="toolTip">
+          <string>Select to retain the last will message</string>
+         </property>
+         <property name="text">
+          <string>Retain</string>
+         </property>
+        </widget>
+       </item>
+       <item row="0" column="0">
+        <widget class="QLineEdit" name="willTopicEdit">
+         <property name="toolTip">
+          <string>Enter the topic of the last will</string>
+         </property>
+         <property name="clearButtonEnabled">
+          <bool>true</bool>
+         </property>
+        </widget>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QWidget" name="tlsTab">
+      <attribute name="title">
+       <string>SSL/TLS</string>
+      </attribute>
+      <layout class="QGridLayout" name="gridLayout_3">
+       <item row="0" column="0" colspan="2">
+        <widget class="QCheckBox" name="tlsEnableCheckBox">
+         <property name="toolTip">
+          <string>Select to enable SSL/TLS connections</string>
+         </property>
+         <property name="text">
+          <string>SSL/TLS Enabled</string>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="0">
+        <widget class="QLabel" name="label_8">
+         <property name="text">
+          <string>CA File:</string>
+         </property>
+        </widget>
+       </item>
+       <item row="1" column="1">
+        <widget class="EricPathPicker" name="tlsCertsFilePicker" native="true">
+         <property name="enabled">
+          <bool>false</bool>
+         </property>
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+         <property name="focusPolicy">
+          <enum>Qt::StrongFocus</enum>
+         </property>
+         <property name="toolTip">
+          <string>Enter the full path to the CA certificate file; leave empty to use platform default</string>
+         </property>
+        </widget>
+       </item>
+       <item row="2" column="0" colspan="2">
+        <spacer name="verticalSpacer_4">
+         <property name="orientation">
+          <enum>Qt::Vertical</enum>
+         </property>
+         <property name="sizeHint" stdset="0">
+          <size>
+           <width>20</width>
+           <height>214</height>
+          </size>
+         </property>
+        </spacer>
+       </item>
+      </layout>
+     </widget>
+     <widget class="QWidget" name="propertiesTab">
+      <attribute name="title">
+       <string>User Properties</string>
+      </attribute>
+      <layout class="QVBoxLayout" name="verticalLayout_3">
+       <item>
+        <layout class="QHBoxLayout" name="horizontalLayout_2">
+         <item>
+          <widget class="QRadioButton" name="connectPropertiesButton">
+           <property name="toolTip">
+            <string>Select to edit the CONNECT user properties</string>
+           </property>
+           <property name="text">
+            <string>CONNECT</string>
            </property>
            <property name="checked">
             <bool>true</bool>
@@ -84,270 +407,42 @@
           </widget>
          </item>
          <item>
-          <widget class="QRadioButton" name="mqttv5Button">
+          <widget class="QRadioButton" name="disconnectPropertiesButton">
            <property name="toolTip">
-            <string>Select to use the MQTT 5.0 protocol</string>
+            <string>Select to edit the DISCONNECT user properties</string>
            </property>
            <property name="text">
-            <string>v 5.0</string>
+            <string>DISCONNECT</string>
            </property>
           </widget>
          </item>
         </layout>
-       </widget>
-      </item>
-      <item>
-       <layout class="QGridLayout" name="gridLayout">
-        <item row="0" column="0">
-         <widget class="QLabel" name="label_6">
-          <property name="text">
-           <string>Connection Timeout:</string>
-          </property>
-         </widget>
-        </item>
-        <item row="0" column="1">
-         <widget class="QSpinBox" name="connectionTimeoutSpinBox">
-          <property name="toolTip">
-           <string>Enter the connection timeout in seconds</string>
-          </property>
-          <property name="alignment">
-           <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
-          </property>
-          <property name="suffix">
-           <string> s</string>
-          </property>
-          <property name="maximum">
-           <number>300</number>
-          </property>
-          <property name="singleStep">
-           <number>5</number>
-          </property>
-          <property name="value">
-           <number>15</number>
-          </property>
-         </widget>
-        </item>
-        <item row="0" column="2">
-         <spacer name="horizontalSpacer_2">
-          <property name="orientation">
-           <enum>Qt::Horizontal</enum>
-          </property>
-          <property name="sizeHint" stdset="0">
-           <size>
-            <width>148</width>
-            <height>20</height>
-           </size>
-          </property>
-         </spacer>
-        </item>
-        <item row="1" column="0">
-         <widget class="QLabel" name="label_2">
-          <property name="text">
-           <string>Keep Alive Interval:</string>
-          </property>
-         </widget>
-        </item>
-        <item row="1" column="1">
-         <widget class="QSpinBox" name="keepaliveSpinBox">
-          <property name="toolTip">
-           <string>Enter the keep alive interval in seconds</string>
-          </property>
-          <property name="alignment">
-           <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
-          </property>
-          <property name="suffix">
-           <string> s</string>
-          </property>
-          <property name="maximum">
-           <number>300</number>
-          </property>
-          <property name="singleStep">
-           <number>5</number>
-          </property>
-          <property name="value">
-           <number>60</number>
-          </property>
-         </widget>
-        </item>
-        <item row="1" column="2">
-         <spacer name="horizontalSpacer">
-          <property name="orientation">
-           <enum>Qt::Horizontal</enum>
-          </property>
-          <property name="sizeHint" stdset="0">
-           <size>
-            <width>148</width>
-            <height>20</height>
-           </size>
-          </property>
-         </spacer>
-        </item>
-       </layout>
-      </item>
-      <item>
-       <widget class="QCheckBox" name="cleanSessionCheckBox">
-        <property name="toolTip">
-         <string>Select to start with a clean session</string>
-        </property>
-        <property name="text">
-         <string>Clean Session</string>
-        </property>
-       </widget>
-      </item>
-     </layout>
-    </widget>
-   </item>
-   <item>
-    <widget class="QGroupBox" name="groupBox_2">
-     <property name="title">
-      <string>User Credentials</string>
-     </property>
-     <layout class="QGridLayout" name="gridLayout_2">
-      <item row="0" column="0">
-       <widget class="QLabel" name="label_3">
-        <property name="text">
-         <string>User Name:</string>
-        </property>
-       </widget>
-      </item>
-      <item row="0" column="1">
-       <widget class="QLineEdit" name="usernameEdit">
-        <property name="toolTip">
-         <string>Enter the user name</string>
-        </property>
-        <property name="clearButtonEnabled">
-         <bool>true</bool>
-        </property>
-       </widget>
-      </item>
-      <item row="1" column="0">
-       <widget class="QLabel" name="label_4">
-        <property name="text">
-         <string>Password:</string>
-        </property>
-       </widget>
-      </item>
-      <item row="1" column="1">
-       <widget class="QLineEdit" name="passwordEdit">
-        <property name="toolTip">
-         <string>Enter the password</string>
-        </property>
-        <property name="echoMode">
-         <enum>QLineEdit::Password</enum>
-        </property>
-        <property name="clearButtonEnabled">
-         <bool>true</bool>
-        </property>
-       </widget>
-      </item>
-     </layout>
-    </widget>
-   </item>
-   <item>
-    <widget class="QGroupBox" name="groupBox_3">
-     <property name="title">
-      <string>Last Will and Testament</string>
-     </property>
-     <layout class="QGridLayout" name="gridLayout_3">
-      <item row="0" column="0">
-       <widget class="QLineEdit" name="willTopicEdit">
-        <property name="toolTip">
-         <string>Enter the topic of the last will</string>
-        </property>
-        <property name="clearButtonEnabled">
-         <bool>true</bool>
-        </property>
-       </widget>
-      </item>
-      <item row="0" column="1">
-       <widget class="QLabel" name="label_5">
-        <property name="text">
-         <string>QoS:</string>
-        </property>
-       </widget>
-      </item>
-      <item row="0" column="2">
-       <widget class="QSpinBox" name="willQosSpinBox">
-        <property name="toolTip">
-         <string>Enter the desired QoS value</string>
-        </property>
-        <property name="alignment">
-         <set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
-        </property>
-        <property name="maximum">
-         <number>2</number>
-        </property>
-       </widget>
-      </item>
-      <item row="0" column="3">
-       <widget class="QCheckBox" name="willRetainCheckBox">
-        <property name="toolTip">
-         <string>Select to retain the last will message</string>
-        </property>
-        <property name="text">
-         <string>Retain</string>
-        </property>
-       </widget>
-      </item>
-      <item row="1" column="0" colspan="4">
-       <widget class="QPlainTextEdit" name="willMessageEdit">
-        <property name="maximumSize">
-         <size>
-          <width>16777215</width>
-          <height>300</height>
-         </size>
-        </property>
-        <property name="toolTip">
-         <string>Enter the last will message to be sent</string>
-        </property>
-       </widget>
-      </item>
-     </layout>
-    </widget>
-   </item>
-   <item>
-    <widget class="QGroupBox" name="groupBox_4">
-     <property name="title">
-      <string>SSL/TLS</string>
-     </property>
-     <layout class="QGridLayout" name="gridLayout_4">
-      <item row="0" column="0" colspan="2">
-       <widget class="QCheckBox" name="tlsEnableCheckBox">
-        <property name="toolTip">
-         <string>Select to enable SSL/TLS connections</string>
-        </property>
-        <property name="text">
-         <string>SSL/TLS Enabled</string>
-        </property>
-       </widget>
-      </item>
-      <item row="1" column="0">
-       <widget class="QLabel" name="label_8">
-        <property name="text">
-         <string>CA File:</string>
-        </property>
-       </widget>
-      </item>
-      <item row="1" column="1">
-       <widget class="EricPathPicker" name="tlsCertsFilePicker" native="true">
-        <property name="enabled">
-         <bool>false</bool>
-        </property>
-        <property name="sizePolicy">
-         <sizepolicy hsizetype="Expanding" vsizetype="Preferred">
-          <horstretch>0</horstretch>
-          <verstretch>0</verstretch>
-         </sizepolicy>
-        </property>
-        <property name="focusPolicy">
-         <enum>Qt::StrongFocus</enum>
-        </property>
-        <property name="toolTip">
-         <string>Enter the full path to the CA certificate file; leave empty to use platform default</string>
-        </property>
-       </widget>
-      </item>
-     </layout>
+       </item>
+       <item>
+        <widget class="MqttUserPropertiesEditor" name="propertiesWidget" native="true">
+         <property name="sizePolicy">
+          <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+           <horstretch>0</horstretch>
+           <verstretch>0</verstretch>
+          </sizepolicy>
+         </property>
+         <property name="focusPolicy">
+          <enum>Qt::StrongFocus</enum>
+         </property>
+        </widget>
+       </item>
+       <item>
+        <widget class="QCheckBox" name="samePropertiesCheckBox">
+         <property name="toolTip">
+          <string>Select to use the CONNECT user properties when disconnecting</string>
+         </property>
+         <property name="text">
+          <string>Use CONNECT properties for DISCONNECT</string>
+         </property>
+        </widget>
+       </item>
+      </layout>
+     </widget>
     </widget>
    </item>
    <item>
@@ -369,8 +464,15 @@
    <header>EricWidgets/EricPathPicker.h</header>
    <container>1</container>
   </customwidget>
+  <customwidget>
+   <class>MqttUserPropertiesEditor</class>
+   <extends>QWidget</extends>
+   <header>MqttMonitor/MqttUserPropertiesEditor.h</header>
+   <container>1</container>
+  </customwidget>
  </customwidgets>
  <tabstops>
+  <tabstop>optionsWidget</tabstop>
   <tabstop>clientIdEdit</tabstop>
   <tabstop>generateIdButton</tabstop>
   <tabstop>mqttv31Button</tabstop>
@@ -382,11 +484,15 @@
   <tabstop>usernameEdit</tabstop>
   <tabstop>passwordEdit</tabstop>
   <tabstop>willTopicEdit</tabstop>
+  <tabstop>willMessageEdit</tabstop>
   <tabstop>willQosSpinBox</tabstop>
   <tabstop>willRetainCheckBox</tabstop>
-  <tabstop>willMessageEdit</tabstop>
   <tabstop>tlsEnableCheckBox</tabstop>
   <tabstop>tlsCertsFilePicker</tabstop>
+  <tabstop>connectPropertiesButton</tabstop>
+  <tabstop>disconnectPropertiesButton</tabstop>
+  <tabstop>propertiesWidget</tabstop>
+  <tabstop>samePropertiesCheckBox</tabstop>
  </tabstops>
  <resources/>
  <connections>
--- a/MqttMonitor/MqttConnectionProfilesDialog.py	Wed Jul 21 20:10:36 2021 +0200
+++ b/MqttMonitor/MqttConnectionProfilesDialog.py	Thu Jul 22 19:02:32 2021 +0200
@@ -8,6 +8,7 @@
 """
 
 import collections
+import copy
 
 from PyQt6.QtCore import pyqtSlot, Qt, QUuid
 from PyQt6.QtWidgets import (
@@ -39,7 +40,8 @@
             "BrokerAddress", "BrokerPort", "ClientId", "Protocol",
             "ConnectionTimeout", "Keepalive", "CleanSession", "Username",
             "Password", "WillTopic", "WillMessage", "WillQos", "WillRetain",
-            "TlsEnable", "TlsCaCert", "TlsClientCert", "TlsClientKey".
+            "TlsEnable", "TlsCaCert", "TlsClientCert", "TlsClientKey",
+            "UserProperties".
         @type dict
         @param parent reference to the parent widget
         @type QWidget
@@ -74,6 +76,11 @@
         
         self.profileTabWidget.setCurrentIndex(0)
         
+        self.connectPropertiesButton.clicked[bool].connect(
+            self.__propertiesTypeSelected)
+        self.disconnectPropertiesButton.clicked[bool].connect(
+            self.__propertiesTypeSelected)
+        
         if len(self.__profiles) == 0:
             self.minusButton.setEnabled(False)
             self.copyButton.setEnabled(False)
@@ -236,11 +243,11 @@
         Public method to return a dictionary of profiles.
         
         @return dictionary containing dictionaries containing the defined
-            connection profiles. Each entry have the keys "BrokerAddress",
+            connection profiles. Each entry has the keys "BrokerAddress",
             "BrokerPort", "ClientId", "Protocol", "ConnectionTimeout",
             "Keepalive", "CleanSession", "Username", "Password", "WillTopic",
             "WillMessage", "WillQos", "WillRetain", "TlsEnable", "TlsCaCert",
-            "TlsClientCert", "TlsClientKey".
+            "TlsClientCert", "TlsClientKey", "UserProperties".
         @rtype dict
         """
         profilesDict = {}
@@ -263,6 +270,18 @@
         else:
             protocol = MqttProtocols.MQTTv311
         
+        if protocol == MqttProtocols.MQTTv5:
+            if self.connectPropertiesButton.isChecked():
+                self.__userProperties["connect"] = (
+                    self.propertiesWidget.getProperties())
+            else:
+                self.__userProperties["disconnect"] = (
+                    self.propertiesWidget.getProperties())
+            self.__userProperties["use_connect"] = (
+                self.samePropertiesCheckBox.isChecked())
+        else:
+            self.__userProperties = {}
+        
         profileName = self.profileEdit.text()
         connectionProfile = {
             "BrokerAddress": self.brokerAddressEdit.text(),
@@ -282,6 +301,7 @@
             "TlsCaCert": "",
             "TlsClientCert": "",
             "TlsClientKey": "",
+            "UserProperties": copy.deepcopy(self.__userProperties),
         }
         if connectionProfile["TlsEnable"]:
             if self.tlsCertsFileButton.isChecked():
@@ -356,23 +376,32 @@
         self.brokerAddressEdit.setText(connectionProfile["BrokerAddress"])
         self.brokerPortSpinBox.setValue(connectionProfile["BrokerPort"])
         self.clientIdEdit.setText(connectionProfile["ClientId"])
+        
+        # general tab
         self.mqttv31Button.setChecked(
             connectionProfile["Protocol"] == MqttProtocols.MQTTv31)
         self.mqttv311Button.setChecked(
             connectionProfile["Protocol"] == MqttProtocols.MQTTv311)
         self.mqttv5Button.setChecked(
             connectionProfile["Protocol"] == MqttProtocols.MQTTv5)
+        self.on_mqttv5Button_toggled(self.mqttv5Button.isChecked())
         self.connectionTimeoutSpinBox.setValue(
             connectionProfile["ConnectionTimeout"])
         self.keepaliveSpinBox.setValue(connectionProfile["Keepalive"])
         self.cleanSessionCheckBox.setChecked(connectionProfile["CleanSession"])
+        
+        # user credentials tab
         self.usernameEdit.setText(connectionProfile["Username"])
         self.passwordEdit.setText(
             pwConvert(connectionProfile["Password"], encode=False))
+        
+        # will tab
         self.willTopicEdit.setText(connectionProfile["WillTopic"])
         self.willMessageEdit.setPlainText(connectionProfile["WillMessage"])
         self.willQosSpinBox.setValue(connectionProfile["WillQos"])
         self.willRetainCheckBox.setChecked(connectionProfile["WillRetain"])
+        
+        # SSL/TLS tab
         self.tlsGroupBox.setChecked(connectionProfile["TlsEnable"])
         if (
             connectionProfile["TlsCaCert"] and
@@ -390,11 +419,35 @@
             self.tlsCertsFilePicker.setText(connectionProfile["TlsCaCert"])
         else:
             self.tlsDefaultCertsButton.setChecked(True)
+        
+        # user properties tab
+        self.__userProperties = copy.deepcopy(
+            connectionProfile.get("UserProperties", {}))
+        if not self.__userProperties:
+            self.__userProperties = {
+                "connect": [],
+                "disconnect": [],
+                "use_connect": True,
+            }
+        
+        if connectionProfile["Protocol"] == MqttProtocols.MQTTv5:
+            self.connectPropertiesButton.setChecked(True)
+            self.propertiesWidget.setProperties(
+                self.__userProperties["connect"])
+            self.samePropertiesCheckBox.setChecked(
+                self.__userProperties["use_connect"])
+            self.disconnectPropertiesButton.setEnabled(
+                not self.__userProperties["use_connect"])
+        else:
+            self.propertiesWidget.clear()
+        
         self.__populatingProfile = False
         
         self.showPasswordButton.setChecked(False)
         self.profileFrame.setEnabled(True)
         self.__updateApplyButton()
+        
+        self.profileTabWidget.setCurrentIndex(0)
     
     def __clearProfile(self):
         """
@@ -422,6 +475,17 @@
         self.tlsSelfSignedCertsFilePicker.setText("")
         self.tlsSelfSignedClientCertFilePicker.setText("")
         self.tlsSelfSignedClientKeyFilePicker.setText("")
+        
+        self.__userProperties = {
+            "connect": [],
+            "disconnect": [],
+            "use_connect": True,
+        }
+        self.propertiesWidget.clear()
+        self.samePropertiesCheckBox.setChecked(True)
+        self.connectPropertiesButton.setChecked(True)
+        self.disconnectPropertiesButton.setEnabled(False)
+        
         self.__populatingProfile = False
         
         self.showPasswordButton.setChecked(False)
@@ -508,6 +572,24 @@
                         self.tlsSelfSignedClientKeyFilePicker.text() !=
                         connectionProfile["TlsClientKey"]
                     )
+            # check user properties only, if not yet changed
+            if not changed and protocol == MqttProtocols.MQTTv5:
+                properties = {
+                    "connect": self.propertiesWidget.getProperties(),
+                    "disconnect": self.__userProperties["disconnect"],
+                } if self.connectPropertiesButton.isChecked() else {
+                    "connect": self.__userProperties["connect"],
+                    "disconnect": self.propertiesWidget.getProperties(),
+                }
+                changed |= (
+                    self.samePropertiesCheckBox.isChecked() !=
+                    connectionProfile["UserProperties"]["use_connect"] or
+                    sorted(properties["connect"]) !=
+                    sorted(connectionProfile["UserProperties"]["connect"]) or
+                    sorted(properties["disconnect"]) !=
+                    sorted(connectionProfile["UserProperties"]["disconnect"])
+                )
+            
             return changed
         
         else:
@@ -590,6 +672,20 @@
         self.__updateApplyButton()
     
     @pyqtSlot(bool)
+    def on_mqttv5Button_toggled(self, checked):
+        """
+        Private slot to handle the selection of the MQTT protocol.
+        
+        @param checked state of the button
+        @type bool
+        """
+        self.profileTabWidget.setTabEnabled(
+            self.profileTabWidget.indexOf(self.propertiesTab),
+            checked
+        )
+        # TODO: add code to enable the WILL properties button
+    
+    @pyqtSlot(bool)
     def on_showPasswordButton_toggled(self, checked):
         """
         Private slot to show or hide the password.
@@ -715,6 +811,40 @@
         """
         self.__updateApplyButton()
     
+    @pyqtSlot(bool)
+    def on_samePropertiesCheckBox_toggled(self, checked):
+        """
+        Private slot to handle a change of the properties usage.
+        
+        @param checked flag indicating to use the same user properties for
+            CONNECT and DISCONNECT
+        @type bool
+        """
+        if checked and not self.connectPropertiesButton.isChecked():
+            self.connectPropertiesButton.click()
+        self.disconnectPropertiesButton.setEnabled(not checked)
+    
+    @pyqtSlot(bool)
+    def __propertiesTypeSelected(self, checked):
+        """
+        Private slot to handle the switching of the user properties type.
+        
+        @param checked state of the buttons
+        @type bool
+        """
+        if checked:
+            # handle the selection only
+            if self.connectPropertiesButton.isChecked():
+                self.__userProperties["disconnect"] = (
+                    self.propertiesWidget.getProperties())
+                self.propertiesWidget.setProperties(
+                    self.__userProperties["connect"])
+            else:
+                self.__userProperties["connect"] = (
+                    self.propertiesWidget.getProperties())
+                self.propertiesWidget.setProperties(
+                    self.__userProperties["disconnect"])
+    
     @pyqtSlot()
     def reject(self):
         """
--- a/MqttMonitor/MqttConnectionProfilesDialog.ui	Wed Jul 21 20:10:36 2021 +0200
+++ b/MqttMonitor/MqttConnectionProfilesDialog.ui	Thu Jul 22 19:02:32 2021 +0200
@@ -740,6 +740,63 @@
            </item>
           </layout>
          </widget>
+         <widget class="QWidget" name="propertiesTab">
+          <attribute name="title">
+           <string>User Properties</string>
+          </attribute>
+          <layout class="QVBoxLayout" name="verticalLayout_6">
+           <item>
+            <layout class="QHBoxLayout" name="horizontalLayout_8">
+             <item>
+              <widget class="QRadioButton" name="connectPropertiesButton">
+               <property name="toolTip">
+                <string>Select to edit the CONNECT user properties</string>
+               </property>
+               <property name="text">
+                <string>CONNECT</string>
+               </property>
+               <property name="checked">
+                <bool>true</bool>
+               </property>
+              </widget>
+             </item>
+             <item>
+              <widget class="QRadioButton" name="disconnectPropertiesButton">
+               <property name="toolTip">
+                <string>Select to edit the DISCONNECT user properties</string>
+               </property>
+               <property name="text">
+                <string>DISCONNECT</string>
+               </property>
+              </widget>
+             </item>
+            </layout>
+           </item>
+           <item>
+            <widget class="MqttUserPropertiesEditor" name="propertiesWidget" native="true">
+             <property name="sizePolicy">
+              <sizepolicy hsizetype="Expanding" vsizetype="Expanding">
+               <horstretch>0</horstretch>
+               <verstretch>0</verstretch>
+              </sizepolicy>
+             </property>
+             <property name="focusPolicy">
+              <enum>Qt::StrongFocus</enum>
+             </property>
+            </widget>
+           </item>
+           <item>
+            <widget class="QCheckBox" name="samePropertiesCheckBox">
+             <property name="toolTip">
+              <string>Select to use the CONNECT user properties when disconnecting</string>
+             </property>
+             <property name="text">
+              <string>Use CONNECT properties for DISCONNECT</string>
+             </property>
+            </widget>
+           </item>
+          </layout>
+         </widget>
         </widget>
        </item>
        <item>
@@ -772,6 +829,12 @@
    <header>EricWidgets/EricPathPicker.h</header>
    <container>1</container>
   </customwidget>
+  <customwidget>
+   <class>MqttUserPropertiesEditor</class>
+   <extends>QWidget</extends>
+   <header>MqttMonitor/MqttUserPropertiesEditor.h</header>
+   <container>1</container>
+  </customwidget>
  </customwidgets>
  <tabstops>
   <tabstop>profilesList</tabstop>
@@ -794,9 +857,9 @@
   <tabstop>passwordEdit</tabstop>
   <tabstop>showPasswordButton</tabstop>
   <tabstop>willTopicEdit</tabstop>
+  <tabstop>willMessageEdit</tabstop>
   <tabstop>willQosSpinBox</tabstop>
   <tabstop>willRetainCheckBox</tabstop>
-  <tabstop>willMessageEdit</tabstop>
   <tabstop>tlsGroupBox</tabstop>
   <tabstop>tlsDefaultCertsButton</tabstop>
   <tabstop>tlsCertsFileButton</tabstop>
@@ -805,6 +868,10 @@
   <tabstop>tlsSelfSignedCertsFilePicker</tabstop>
   <tabstop>tlsSelfSignedClientCertFilePicker</tabstop>
   <tabstop>tlsSelfSignedClientKeyFilePicker</tabstop>
+  <tabstop>connectPropertiesButton</tabstop>
+  <tabstop>disconnectPropertiesButton</tabstop>
+  <tabstop>propertiesWidget</tabstop>
+  <tabstop>samePropertiesCheckBox</tabstop>
  </tabstops>
  <resources/>
  <connections>
--- a/MqttMonitor/MqttMonitorWidget.py	Wed Jul 21 20:10:36 2021 +0200
+++ b/MqttMonitor/MqttMonitorWidget.py	Thu Jul 22 19:02:32 2021 +0200
@@ -74,6 +74,8 @@
         self.__messagesTopicFormat.setFontWeight(QFont.Weight.Bold)
         self.__messagesQosFormat = self.messagesEdit.currentCharFormat()
         self.__messagesQosFormat.setFontItalic(True)
+        self.__messagesSubheaderFormat = self.messagesEdit.currentCharFormat()
+        self.__messagesSubheaderFormat.setFontUnderline(True)
         
         self.__propertiesFormat = self.propertiesEdit.currentCharFormat()
         self.__propertiesTopicFormat = self.propertiesEdit.currentCharFormat()
@@ -159,6 +161,10 @@
         self.__publishedTopics = []
         self.__updatePublishTopicComboBox()
         self.publishButton.setEnabled(False)
+        self.publishPropertiesButton.setIcon(
+            UI.PixmapCache.getIcon("listSelection"))
+        self.publishPropertiesButton.setEnabled(False)
+        self.publishPropertiesButton.setVisible(False)
         
         self.__connectionOptions = None
         
@@ -319,6 +325,8 @@
                 self.__client.getProtocol() == MqttProtocols.MQTTv5)
             self.publishGroup.setEnabled(True)
             self.brokerStatusButton.setEnabled(True)
+            self.publishPropertiesButton.setVisible(
+                self.__client.getProtocol() == MqttProtocols.MQTTv5)
             
             self.__statusLoadValues.clear()
             self.__clearBrokerStatusLabels()
@@ -376,6 +384,7 @@
         self.unsubscribeGroup.setEnabled(False)
         self.unsubscribePropertiesButton.setVisible(False)
         self.publishGroup.setEnabled(False)
+        self.publishPropertiesButton.setVisible(False)
         self.brokerStatusButton.setEnabled(False)
         
         self.__statusLoadValues.clear()
@@ -466,6 +475,8 @@
         @param mid ID of the subscribe request
         @type int
         """
+        # TODO: remember the successfully subscribed topic
+        # TODO: max. number of recent topics as a config item
         if mid in self.__topicQueue:
             topic = self.__topicQueue.pop(mid)
             self.__subscribedTopics.append(topic)
@@ -723,6 +734,18 @@
                 topic, properties=properties)
             self.__topicQueue[mid] = topic
     
+    @pyqtSlot()
+    def on_publishPropertiesButton_clicked(self):
+        """
+        Private slot to edit the publish user properties.
+        """
+        topic = self.publishTopicComboBox.currentText()
+        self.__editProperties(
+            "publish",
+            self.tr("PUBLISH: User Properties for '{0}'").format(topic),
+            topic
+        )
+    
     @pyqtSlot(str)
     def on_publishTopicComboBox_editTextChanged(self, topic):
         """
@@ -732,6 +755,7 @@
         @type str
         """
         self.publishButton.setEnabled(bool(topic))
+        self.publishPropertiesButton.setEnabled(bool(topic))
     
     @pyqtSlot()
     def on_publishButton_clicked(self):
@@ -765,8 +789,15 @@
                 # use empty string together with the retain flag to clean
                 # a retained message by sending None instead
                 payloadStr = None
+        properties = (
+            self.__plugin.getPreferences("PublishProperties").get(topic, [])
+            if self.__client.getProtocol() == MqttProtocols.MQTTv5 else
+            None
+        )
         
-        msgInfo = self.__client.publish(topic, payloadStr, qos, retain)
+        msgInfo = self.__client.publish(
+            topic, payload=payloadStr, qos=qos, retain=retain,
+            properties=properties)
         if msgInfo.rc == 0:
             if topic not in self.__publishedTopics:
                 self.__publishedTopics.append(topic)
@@ -780,8 +811,14 @@
         Private slot to clear the retained messages for the topic.
         """
         topic = self.publishTopicComboBox.currentText()
+        properties = (
+            self.__plugin.getPreferences("PublishProperties").get(topic, [])
+            if self.__client.getProtocol() == MqttProtocols.MQTTv5 else
+            None
+        )
         
-        msgInfo = self.__client.publish(topic, payload=None, retain=True)
+        msgInfo = self.__client.publish(
+            topic, payload=None, retain=True, properties=properties)
         if msgInfo.rc == 0:
             if topic not in self.__publishedTopics:
                 self.__publishedTopics.append(topic)
@@ -988,6 +1025,7 @@
             brokerList.remove(hostAndPort)
         brokerList.insert(0, hostAndPort)
         # limit to most recently used 20 entries
+        # TODO: make the amount of recent brokers a config item
         brokerList = brokerList[:20]
         self.__plugin.setPreferences("RecentBrokersWithPort", brokerList)
         
@@ -1112,19 +1150,25 @@
             self.messagesEdit.insertPlainText(self.tr("Retained Message\n"))
         
         if properties:
-            self.messagesEdit.setCurrentCharFormat(self.__messagesTopicFormat)
+            self.messagesEdit.setCurrentCharFormat(
+                self.__messagesSubheaderFormat)
             self.messagesEdit.insertPlainText(self.tr("Properties:\n"))
             self.messagesEdit.setCurrentCharFormat(self.__messagesFormat)
-            for name, value in sorted(properties.items):
+            for name, value in sorted(properties.items()):
                 self.messagesEdit.insertPlainText(
                     self.tr("{0}: {1}\n", "property name, property value")
                     .format(name, value)
                 )
         
+        self.messagesEdit.setCurrentCharFormat(self.__messagesSubheaderFormat)
+        self.messagesEdit.insertPlainText(self.tr("Message:\n"))
         payloadStr = str(payload, encoding="utf-8", errors="replace")
+        payloadStr = Utilities.filterAnsiSequences(payloadStr)
         self.messagesEdit.setCurrentCharFormat(self.__messagesFormat)
-        self.messagesEdit.insertPlainText(
-            Utilities.filterAnsiSequences(payloadStr))
+        if payloadStr:
+            self.messagesEdit.insertPlainText(payloadStr)
+        else:
+            self.messagesEdit.insertPlainText(self.tr("<empty>"))
         
         if self.followMessagesCheckBox.isChecked():
             self.messagesEdit.ensureCursorVisible()
@@ -1273,7 +1317,7 @@
             self.__plugin.setPreferences("MostRecentProfile", profileName)
             
             profilesDict = self.__plugin.getPreferences("BrokerProfiles")
-            connectionProfile = copy.copy(profilesDict[profileName])
+            connectionProfile = copy.deepcopy(profilesDict[profileName])
             host = connectionProfile["BrokerAddress"]
             port = connectionProfile["BrokerPort"]
             try:
@@ -1348,11 +1392,12 @@
         @param key key to retrieve the right properties
         @type str
         """
-        from .MqttUserPropertiesEditor import MqttUserPropertiesEditor
+        from .MqttUserPropertiesEditor import MqttUserPropertiesEditorDialog
         
         preferencesKey = "{0}Properties".format(propertiesType.capitalize())
         properties = self.__plugin.getPreferences(preferencesKey)
-        dlg = MqttUserPropertiesEditor(header, properties.get(key, []), self)
+        dlg = MqttUserPropertiesEditorDialog(
+            header, properties.get(key, []), self)
         if dlg.exec() == QDialog.DialogCode.Accepted:
             properties[key] = dlg.getProperties()
             self.__plugin.setPreferences(preferencesKey, properties)
--- a/MqttMonitor/MqttMonitorWidget.ui	Wed Jul 21 20:10:36 2021 +0200
+++ b/MqttMonitor/MqttMonitorWidget.ui	Thu Jul 22 19:02:32 2021 +0200
@@ -351,6 +351,16 @@
               </property>
              </widget>
             </item>
+            <item>
+             <widget class="QToolButton" name="publishPropertiesButton">
+              <property name="toolTip">
+               <string>Press to edit the user properties</string>
+              </property>
+              <property name="text">
+               <string/>
+              </property>
+             </widget>
+            </item>
            </layout>
           </item>
           <item>
@@ -614,8 +624,8 @@
            <rect>
             <x>0</x>
             <y>0</y>
-            <width>344</width>
-            <height>840</height>
+            <width>339</width>
+            <height>670</height>
            </rect>
           </property>
           <layout class="QFormLayout" name="formLayout">
@@ -1471,13 +1481,16 @@
   <tabstop>subscribeTopicEdit</tabstop>
   <tabstop>subscribeQosSpinBox</tabstop>
   <tabstop>subscribeButton</tabstop>
+  <tabstop>subscribePropertiesButton</tabstop>
   <tabstop>unsubscribeTopicComboBox</tabstop>
   <tabstop>unsubscribeButton</tabstop>
+  <tabstop>unsubscribePropertiesButton</tabstop>
   <tabstop>publishTopicComboBox</tabstop>
   <tabstop>publishPayloadEdit</tabstop>
   <tabstop>publishPayloadFilePicker</tabstop>
   <tabstop>publishQosSpinBox</tabstop>
   <tabstop>publishRetainCheckBox</tabstop>
+  <tabstop>publishPropertiesButton</tabstop>
   <tabstop>publishButton</tabstop>
   <tabstop>publishClearButton</tabstop>
   <tabstop>publishClearRetainedButton</tabstop>
--- a/MqttMonitor/MqttUserPropertiesEditor.py	Wed Jul 21 20:10:36 2021 +0200
+++ b/MqttMonitor/MqttUserPropertiesEditor.py	Thu Jul 22 19:02:32 2021 +0200
@@ -7,26 +7,24 @@
 Module implementing an editor for MQTT v5 user properties.
 """
 
-from PyQt6.QtCore import pyqtSlot
-from PyQt6.QtWidgets import QDialog, QTableWidgetItem
+from PyQt6.QtCore import pyqtSlot, Qt
+from PyQt6.QtWidgets import (
+    QDialog, QDialogButtonBox, QTableWidgetItem, QVBoxLayout, QLabel, QWidget
+)
 
 from .Ui_MqttUserPropertiesEditor import Ui_MqttUserPropertiesEditor
 
 import UI.PixmapCache
 
 
-class MqttUserPropertiesEditor(QDialog, Ui_MqttUserPropertiesEditor):
+class MqttUserPropertiesEditor(QWidget, Ui_MqttUserPropertiesEditor):
     """
     Class implementing an editor for MQTT v5 user properties.
     """
-    def __init__(self, header, properties, parent=None):
+    def __init__(self, parent=None):
         """
         Constructor
         
-        @param header text to be shown in the dialog header label
-        @type  str
-        @param properties list of defined user properties
-        @type list of tuple of (str, str)
         @param parent reference to the parent widget (defaults to None)
         @type QWidget (optional)
         """
@@ -37,13 +35,7 @@
         self.deleteButton.setIcon(UI.PixmapCache.getIcon("minus"))
         self.clearButton.setIcon(UI.PixmapCache.getIcon("editDelete"))
         
-        self.headerLabel.setText(header)
-        
-        if properties:
-            self.propertiesTable.setRowCount(len(properties))
-            for row, (key, value) in enumerate(properties):
-                self.propertiesTable.setItem(row, 0, QTableWidgetItem(key))
-                self.propertiesTable.setItem(row, 1, QTableWidgetItem(value))
+        self.clearButton.clicked.connect(self.clear)
         
         self.deleteButton.setEnabled(False)
     
@@ -76,14 +68,29 @@
                 self.propertiesTable.removeRow(row)
     
     @pyqtSlot()
-    def on_clearButton_clicked(self):
+    def clear(self):
         """
-        Private slot to delete all properties.
+        Public slot to delete all properties.
         """
         self.propertiesTable.clearContents()
         self.propertiesTable.setRowCount(10)
         self.propertiesTable.setCurrentCell(0, 0)
     
+    def setProperties(self, properties):
+        """
+        Public method to populate the editor with a list of user properties.
+        
+        @param properties list of defined user properties
+        @type list of tuple of (str, str)
+        """
+        if properties:
+            self.propertiesTable.setRowCount(len(properties))
+            for row, (key, value) in enumerate(properties):
+                self.propertiesTable.setItem(row, 0, QTableWidgetItem(key))
+                self.propertiesTable.setItem(row, 1, QTableWidgetItem(value))
+        else:
+            self.clear()
+    
     def getProperties(self):
         """
         Public method to get the list of defined user properties.
@@ -99,6 +106,59 @@
             if key:
                 valueItem = self.propertiesTable.item(row, 1)
                 value = valueItem.text() if valueItem else ""
-                properties.append((key, value))
+                properties.append([key, value])
         
         return properties
+
+
+class MqttUserPropertiesEditorDialog(QDialog):
+    """
+    Class implementing an editor dialog for MQTT v5 user properties.
+    """
+    def __init__(self, header, properties, parent=None):
+        """
+        Constructor
+        
+        @param header text to be shown in the dialog header label
+        @type  str
+        @param properties list of defined user properties
+        @type list of tuple of (str, str)
+        @param parent reference to the parent widget (defaults to None)
+        @type QWidget (optional)
+        """
+        super().__init__(parent)
+        
+        self.setObjectName("MqttUserPropertiesEditor")
+        self.resize(400, 300)
+        self.setSizeGripEnabled(True)
+        self.setWindowTitle(self.tr("User Properties"))
+        
+        self.__layout = QVBoxLayout(self)
+        
+        self.__headerLabel = QLabel(header, self)
+        self.__layout.addWidget(self.__headerLabel)
+        
+        self.__propertiesEditor = MqttUserPropertiesEditor(self)
+        self.__layout.addWidget(self.__propertiesEditor)
+        
+        self.__buttonBox = QDialogButtonBox(self)
+        self.__buttonBox.setOrientation(Qt.Orientation.Horizontal)
+        self.__buttonBox.setStandardButtons(
+            QDialogButtonBox.StandardButton.Cancel |
+            QDialogButtonBox.StandardButton.Ok)
+        self.__buttonBox.setObjectName("buttonBox")
+        self.__layout.addWidget(self.__buttonBox)
+        
+        self.__buttonBox.accepted.connect(self.accept)
+        self.__buttonBox.rejected.connect(self.reject)
+        
+        self.__propertiesEditor.setProperties(properties)
+    
+    def getProperties(self):
+        """
+        Public method to get the list of defined user properties.
+        
+        @return list of defined user properties
+        @rtype list of tuple of (str, str)
+        """
+        return self.__propertiesEditor.getProperties()
--- a/MqttMonitor/MqttUserPropertiesEditor.ui	Wed Jul 21 20:10:36 2021 +0200
+++ b/MqttMonitor/MqttUserPropertiesEditor.ui	Thu Jul 22 19:02:32 2021 +0200
@@ -1,33 +1,35 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <ui version="4.0">
  <class>MqttUserPropertiesEditor</class>
- <widget class="QDialog" name="MqttUserPropertiesEditor">
+ <widget class="QWidget" name="MqttUserPropertiesEditor">
   <property name="geometry">
    <rect>
     <x>0</x>
     <y>0</y>
     <width>400</width>
-    <height>350</height>
+    <height>250</height>
    </rect>
   </property>
   <property name="windowTitle">
    <string>User Properties</string>
   </property>
-  <property name="sizeGripEnabled">
+  <property name="sizeGripEnabled" stdset="0">
    <bool>true</bool>
   </property>
-  <layout class="QGridLayout" name="gridLayout">
-   <item row="0" column="0" colspan="2">
-    <widget class="QLabel" name="headerLabel">
-     <property name="text">
-      <string/>
-     </property>
-     <property name="wordWrap">
-      <bool>true</bool>
-     </property>
-    </widget>
-   </item>
-   <item row="1" column="0">
+  <layout class="QHBoxLayout" name="horizontalLayout">
+   <property name="leftMargin">
+    <number>0</number>
+   </property>
+   <property name="topMargin">
+    <number>0</number>
+   </property>
+   <property name="rightMargin">
+    <number>0</number>
+   </property>
+   <property name="bottomMargin">
+    <number>0</number>
+   </property>
+   <item>
     <widget class="QTableWidget" name="propertiesTable">
      <property name="alternatingRowColors">
       <bool>true</bool>
@@ -66,7 +68,7 @@
      </column>
     </widget>
    </item>
-   <item row="1" column="1">
+   <item>
     <layout class="QVBoxLayout" name="verticalLayout">
      <item>
       <widget class="QToolButton" name="addButton">
@@ -113,51 +115,8 @@
      </item>
     </layout>
    </item>
-   <item row="2" column="0" colspan="2">
-    <widget class="QDialogButtonBox" name="buttonBox">
-     <property name="orientation">
-      <enum>Qt::Horizontal</enum>
-     </property>
-     <property name="standardButtons">
-      <set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
-     </property>
-    </widget>
-   </item>
   </layout>
  </widget>
  <resources/>
- <connections>
-  <connection>
-   <sender>buttonBox</sender>
-   <signal>accepted()</signal>
-   <receiver>MqttUserPropertiesEditor</receiver>
-   <slot>accept()</slot>
-   <hints>
-    <hint type="sourcelabel">
-     <x>248</x>
-     <y>254</y>
-    </hint>
-    <hint type="destinationlabel">
-     <x>157</x>
-     <y>274</y>
-    </hint>
-   </hints>
-  </connection>
-  <connection>
-   <sender>buttonBox</sender>
-   <signal>rejected()</signal>
-   <receiver>MqttUserPropertiesEditor</receiver>
-   <slot>reject()</slot>
-   <hints>
-    <hint type="sourcelabel">
-     <x>316</x>
-     <y>260</y>
-    </hint>
-    <hint type="destinationlabel">
-     <x>286</x>
-     <y>274</y>
-    </hint>
-   </hints>
-  </connection>
- </connections>
+ <connections/>
 </ui>
--- a/PluginMqttMonitor.py	Wed Jul 21 20:10:36 2021 +0200
+++ b/PluginMqttMonitor.py	Thu Jul 22 19:02:32 2021 +0200
@@ -24,7 +24,7 @@
 author = "Detlev Offenbach <detlev@die-offenbachs.de>"
 autoactivate = True
 deactivateable = True
-version = "1.0.1"
+version = "1.1.0"
 className = "MqttMonitorPlugin"
 packageName = "MqttMonitor"
 shortDescription = "Plug-in implementing a tool to connect to a MQTT broker"
@@ -95,9 +95,11 @@
             "BrokerProfiles": "{}",             # JSON formatted empty dict
             # __IGNORE_WARNING_M613__
             "MostRecentProfile": "",            # most recently used profile
+            "PublishProperties": "{}",          # JSON formatted empty dict
+            # __IGNORE_WARNING_M613__
             "SubscribeProperties": "{}",        # JSON formatted empty dict
             # __IGNORE_WARNING_M613__
-            "UnsubscribeProperties": "{}",        # JSON formatted empty dict
+            "UnsubscribeProperties": "{}",      # JSON formatted empty dict
             # __IGNORE_WARNING_M613__
         }
         
@@ -217,7 +219,8 @@
         @rtype Any
         """
         if key in ["RecentBrokersWithPort", "BrokerProfiles",
-                   "SubscribeProperties", "UnsubscribeProperties"]:
+                   "SubscribeProperties", "UnsubscribeProperties",
+                   "PublishProperties"]:
             return json.loads(Preferences.Prefs.settings.value(
                 self.PreferencesKey + "/" + key, self.__defaults[key]))
         else:
@@ -234,7 +237,8 @@
         @type Any
         """
         if key in ["RecentBrokersWithPort", "BrokerProfiles",
-                   "SubscribeProperties", "UnsubscribeProperties"]:
+                   "SubscribeProperties", "UnsubscribeProperties",
+                   "PublishProperties"]:
             Preferences.Prefs.settings.setValue(
                 self.PreferencesKey + "/" + key, json.dumps(value))
         else:

eric ide

mercurial