Started implementing support for MQTT v5 user properties. eric7

Wed, 21 Jul 2021 20:10:36 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Wed, 21 Jul 2021 20:10:36 +0200
branch
eric7
changeset 102
70b8858199f5
parent 101
0eae5f616154
child 103
5fe4f179975f

Started implementing support for MQTT v5 user properties.

MqttMonitor/MqttClient.py file | annotate | diff | comparison | revisions
MqttMonitor/MqttMonitorWidget.py file | annotate | diff | comparison | revisions
MqttMonitor/MqttMonitorWidget.ui file | annotate | diff | comparison | revisions
MqttMonitor/MqttReasonCodes.py file | annotate | diff | comparison | revisions
MqttMonitor/MqttUserPropertiesEditor.py file | annotate | diff | comparison | revisions
MqttMonitor/MqttUserPropertiesEditor.ui file | annotate | diff | comparison | revisions
PluginMqttMonitor.epj file | annotate | diff | comparison | revisions
PluginMqttMonitor.py file | annotate | diff | comparison | revisions
diff -r 0eae5f616154 -r 70b8858199f5 MqttMonitor/MqttClient.py
--- a/MqttMonitor/MqttClient.py	Tue Jul 20 18:10:55 2021 +0200
+++ b/MqttMonitor/MqttClient.py	Wed Jul 21 20:10:36 2021 +0200
@@ -15,6 +15,7 @@
 
 import paho.mqtt.client as mqtt
 from paho.mqtt.packettypes import PacketTypes
+from paho.mqtt.properties import Properties
 
 from Utilities.crypto import pwConvert
 
@@ -33,22 +34,32 @@
     Class implementing a PyQt wrapper around the paho MQTT client.
     
     @signal onConnectV3(flags, rc) emitted after the client has connected to
-        the broker
+        the broker (MQTT v3)
+    @signal onConnectV5(flags, rc, packetType, properties emitted after the
+        client has connected to the broker (MQTT v5)
     @signal onDisconnectedV3(rc) emitted after the client has disconnected from
-        the broker
+        the broker (MQTT v3)
+    @signal onDisconnectedV5(rc, packetType) emitted after the client has
+        disconnected from the broker (MQTT v5)
     @signal onLog(level, message) emitted to send client log data
     @signal onMessageV3(topic, payload, qos, retain) emitted after a message
-        has been received by the client
+        has been received by the client (MQTT v3)
+    @signal onMessageV5(topic, payload, qos, retain, properties) emitted after
+        a message has been received by the client (MQTT v5)
     @signal onPublish(mid) emitted after a message has been published
     @signal onSubscribeV3(mid, grantedQos) emitted after the client has
-        subscribed to some topics
+        subscribed to some topics (MQTT v3)
+    @signal onSubscribeV5(mid, reasonCodes, properties) emitted after the
+        client has subscribed to some topics (MQTT v5)
     @signal onUnsubscribeV3(mid) emitted after the client has unsubscribed from
-        some topics
+        some topics (MQTT v3)
+    @signal onUnsubscribeV5(mid, rc, packetType, properties) emitted after the
+        client has unsubscribed from some topics (MQTT v5)
     @signal connectTimeout() emitted to indicate, that a connection attempt
         timed out
     """
     onConnectV3 = pyqtSignal(dict, int)
-    onConnectV5 = pyqtSignal(dict, int, int)
+    onConnectV5 = pyqtSignal(dict, int, int, dict)
     onDisconnectedV3 = pyqtSignal(int)
     onDisconnectedV5 = pyqtSignal(int, int)
     onLog = pyqtSignal(int, str)
@@ -56,9 +67,9 @@
     onMessageV5 = pyqtSignal(str, bytes, int, bool, dict)
     onPublish = pyqtSignal(int)
     onSubscribeV3 = pyqtSignal(int, tuple)
-    onSubscribeV5 = pyqtSignal(int, list)
+    onSubscribeV5 = pyqtSignal(int, list, dict)
     onUnsubscribeV3 = pyqtSignal(int)
-    onUnsubscribeV5 = pyqtSignal(int, int, int)
+    onUnsubscribeV5 = pyqtSignal(int, int, int, dict)
     
     connectTimeout = pyqtSignal()
     
@@ -151,25 +162,34 @@
                                           message.qos, message.retain)
             )
         else:
-            # TODO: add properties to signals
             self.__mqttClient.on_connect = (
                 lambda client, userdata, flags, rc, properties=None:
-                    self.onConnectV5.emit(flags, rc.value, rc.packetType)
+                    self.onConnectV5.emit(
+                        flags, rc.value, rc.packetType,
+                        properties.json() if properties is not None else {}
+                    )
             )
             self.__mqttClient.on_disconnect = self.__onDisconnectedV5
             self.__mqttClient.on_subscribe = (
                 lambda client, userdata, mid, reasonCodes, properties=None:
-                    self.onSubscribeV5.emit(mid, reasonCodes)
+                    self.onSubscribeV5.emit(
+                        mid, reasonCodes,
+                        properties.json() if properties is not None else {}
+                    )
             )
             self.__mqttClient.on_unsubscribe = (
                 lambda client, userdata, mid, properties, rc:
-                    self.onUnsubscribeV5.emit(mid, rc.value, rc.packetType)
+                    self.onUnsubscribeV5.emit(
+                        mid, rc.value, rc.packetType,
+                        properties.json() if properties is not None else {}
+                    )
             )
             self.__mqttClient.on_message = (
                 lambda client, userdata, message:
-                    self.onMessageV5.emit(message.topic, message.payload,
-                                          message.qos, message.retain,
-                                          message.properties.json())
+                    self.onMessageV5.emit(
+                        message.topic, message.payload, message.qos,
+                        message.retain, message.properties.json()
+                    )
             )
         self.__mqttClient.on_log = (
             lambda client, userdata, level, buf:
@@ -209,22 +229,6 @@
         self.stopLoop()
         self.connectTimeout.emit()
     
-##    def reinitialise(self, clientId="", cleanSession=True, userdata=None):
-##        """
-##        Public method to reinitialize the client with given data.
-##        
-##        @param clientId ID to be used for the client
-##        @type str
-##        @param cleanSession flag indicating to start a clean session
-##        @type bool
-##        @param userdata user data
-##        @type any
-##        """
-##        self.__mqttClient.reinitialise(
-##            client_id=clientId, clean_session=cleanSession, userdata=userdata)
-##        
-##        self.__initCallbacks()
-##    
     def setConnectionTimeout(self, timeout):
         """
         Public method to set the connection timeout value.
@@ -365,8 +369,6 @@
             trying to connect with the given parameters
         @type bool
         """
-##        if reinit:
-##            self.reinitialise()
         # TODO: MQTTv5: add support for MQTTv5 properties
         self.__mqttClient.connect_async(
             host, port=port, keepalive=keepalive, bind_address=bindAddress,
@@ -445,7 +447,6 @@
             self.__cleanSession = parametersDict["CleanSession"]
             self.connectToServer(host, port=port,
                                  keepalive=parametersDict["Keepalive"])
-##                                 reinit=False)
         else:
             keepalive = self.defaultConnectionOptions["Keepalive"]
             self.connectToServer(host, port=port, keepalive=keepalive,
@@ -503,9 +504,7 @@
         # TODO: MQTTv5: add support for reason code
         self.__mqttClient.disconnect()
     
-    # TODO: MQTTv5: add support for properties
-    # TODO: MQTTv5: add support for subscribe options
-    def subscribe(self, topic, qos=0):
+    def subscribe(self, topic, qos=0, properties=None):
         """
         Public method to subscribe to topics with quality of service.
         
@@ -514,22 +513,37 @@
         @type str or tuple of (str, int) or list of tuple of (str, int)
         @param qos quality of service
         @type int, one of 0, 1 or 2
+        @param properties list of user properties to be sent with the
+            subscription
+        @type list of tuple of (str, str)
         @return tuple containing the result code and the message ID
         @rtype tuple of (int, int)
         """
-        return self.__mqttClient.subscribe(topic, qos=qos)
+        props = (
+            self.__createPropertiesObject(PacketTypes.SUBSCRIBE, properties)
+            if properties else
+            None
+        )
+        return self.__mqttClient.subscribe(topic, qos=qos, properties=props)
     
-    # TODO: MQTTv5: add support for properties (?)
-    def unsubscribe(self, topic):
+    def unsubscribe(self, topic, properties=None):
         """
         Public method to unsubscribe topics.
         
         @param topic topic or list of topics to unsubscribe
         @type str or list of str
+        @param properties list of user properties to be sent with the
+            subscription
+        @type list of tuple of (str, str)
         @return tuple containing the result code and the message ID
         @rtype tuple of (int, int)
         """
-        return self.__mqttClient.unsubscribe(topic)
+        props = (
+            self.__createPropertiesObject(PacketTypes.SUBSCRIBE, 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):
@@ -550,6 +564,22 @@
         """
         return self.__mqttClient.publish(topic, payload=payload, qos=qos,
                                          retain=retain)
+    
+    def __createPropertiesObject(self, packetType, properties):
+        """
+        Private method to assemble the MQTT v5 properties object.
+        
+        @param packetType type of the MQTT packet
+        @type PacketTypes (= int)
+        @param properties list of user properties
+        @type list of tuple of (str, str)
+        @return MQTT v5 properties object
+        @rtype Properties
+        """
+        props = Properties(packetType)
+        for userProperty in properties:
+            props.UserProperty = tuple(userProperty)
+        return props
 
 
 def mqttConnackMessage(connackCode):
diff -r 0eae5f616154 -r 70b8858199f5 MqttMonitor/MqttMonitorWidget.py
--- a/MqttMonitor/MqttMonitorWidget.py	Tue Jul 20 18:10:55 2021 +0200
+++ b/MqttMonitor/MqttMonitorWidget.py	Wed Jul 21 20:10:36 2021 +0200
@@ -12,9 +12,9 @@
 import copy
 import contextlib
 
-from PyQt6.QtCore import pyqtSlot, Qt, QTimer, QFileInfo
+from PyQt6.QtCore import pyqtSlot, Qt, QTimer, QFileInfo, QPoint
 from PyQt6.QtGui import QFont, QTextCursor, QBrush, QColor
-from PyQt6.QtWidgets import QWidget, QDialog
+from PyQt6.QtWidgets import QWidget, QDialog, QMenu
 
 from EricWidgets import EricMessageBox, EricFileDialog
 from EricWidgets.EricPathPicker import EricPathPickerModes
@@ -75,10 +75,17 @@
         self.__messagesQosFormat = self.messagesEdit.currentCharFormat()
         self.__messagesQosFormat.setFontItalic(True)
         
+        self.__propertiesFormat = self.propertiesEdit.currentCharFormat()
+        self.__propertiesTopicFormat = self.propertiesEdit.currentCharFormat()
+        self.__propertiesTopicFormat.setFontWeight(QFont.Weight.Bold)
+        self.__propertiesNameFormat = self.propertiesEdit.currentCharFormat()
+        self.__propertiesNameFormat.setFontItalic(True)
+        
         self.messagesSearchWidget.attachTextEdit(self.messagesEdit)
         self.messagesSearchWidget.setWidthForHeight(False)
         
-        self.__isAlternate = False
+        self.__isMessageAlternate = False
+        self.__isPropertiesAlternate = False
         
         for logLevel in (MqttClient.LogDisabled,
                          MqttClient.LogDebug,
@@ -131,7 +138,19 @@
         
         self.subscribeButton.setIcon(UI.PixmapCache.getIcon("plus"))
         self.subscribeButton.setEnabled(False)
+        self.subscribePropertiesButton.setIcon(
+            UI.PixmapCache.getIcon("listSelection"))
+        self.subscribePropertiesButton.setEnabled(False)
+        self.subscribePropertiesButton.setVisible(False)
+        
         self.unsubscribeButton.setIcon(UI.PixmapCache.getIcon("minus"))
+        self.unsubscribeButton.setEnabled(False)
+        self.unsubscribePropertiesButton.setIcon(
+            UI.PixmapCache.getIcon("listSelection"))
+        self.unsubscribePropertiesButton.setEnabled(False)
+        self.unsubscribePropertiesButton.setVisible(False)
+        
+        self.__initPropertiesEditMenu()
         
         self.__subscribedTopics = []
         self.__topicQueue = {}
@@ -187,10 +206,6 @@
         self.__statusLoadValues = collections.defaultdict(
             self.__loadDefaultDictFactory)
     
-    #######################################################################
-    ## Slots handling MQTT related signals
-    #######################################################################
-    
     # TODO: make MQTT default protocol version a configuration option
     # (config page)
     def __createClient(self, clientId="", cleanSession=None,
@@ -229,13 +244,35 @@
         
         return client
     
+    def __initPropertiesEditMenu(self):
+        """
+        Private method to create the properties output context menu.
+        """
+        self.__propertiesEditMenu = QMenu(self)
+        self.__copyPropertiesAct = self.__propertiesEditMenu.addAction(
+            UI.PixmapCache.getIcon("editCopy"),
+            self.tr("Copy"), self.propertiesEdit.copy)
+        self.__propertiesEditMenu.addSeparator()
+        self.__selectAllPropertiesAct = self.__propertiesEditMenu.addAction(
+            UI.PixmapCache.getIcon("editSelectAll"),
+            self.tr("Select All"), self.propertiesEdit.selectAll)
+        self.__propertiesEditMenu.addSeparator()
+        self.__clearPropertiesAct = self.__propertiesEditMenu.addAction(
+            UI.PixmapCache.getIcon("editDelete"),
+            self.tr("Clear"), self.propertiesEdit.clear)
+        
+        self.propertiesEdit.copyAvailable.connect(
+            self.__copyPropertiesAct.setEnabled)
+        
+        self.__copyPropertiesAct.setEnabled(False)
+    
     #######################################################################
     ## Slots handling MQTT related signals
     #######################################################################
     
     @pyqtSlot(dict, int)
-    @pyqtSlot(dict, int, int)
-    def __brokerConnected(self, flags, rc, packetType=None):
+    @pyqtSlot(dict, int, int, dict)
+    def __brokerConnected(self, flags, rc, packetType=None, properties=None):
         """
         Private slot to handle being connected to a broker.
         
@@ -245,6 +282,9 @@
         @type int
         @param packetType packet type as reported by the client
         @type int
+        @param properties dictionary containing the received connection
+            properties
+        @type dict
         """
         self.brokerStatusLabel.hide()
         
@@ -253,13 +293,16 @@
             self.__connectedToBroker = True
             self.__connectionOptions = None
         
-            msg = (
-                mqttReasonCode(rc, packetType)
-                if packetType is not None else
-                mqttConnackMessage(rc)
-            )
+        msg = (
+            mqttReasonCode(rc, packetType)
+            if packetType is not None else
+            mqttConnackMessage(rc)
+        )
         self.__flashBrokerStatusLabel(msg)
         
+        if properties:
+            self.__showProperties("Connect", properties)
+        
         self.connectButton.setEnabled(True)
         if rc == 0:
             self.__connectedToBroker = True
@@ -269,7 +312,11 @@
                 UI.PixmapCache.getIcon("ircDisconnect"))
             
             self.subscribeGroup.setEnabled(True)
+            self.subscribePropertiesButton.setVisible(
+                self.__client.getProtocol() == MqttProtocols.MQTTv5)
             self.unsubscribeGroup.setEnabled(True)
+            self.unsubscribePropertiesButton.setVisible(
+                self.__client.getProtocol() == MqttProtocols.MQTTv5)
             self.publishGroup.setEnabled(True)
             self.brokerStatusButton.setEnabled(True)
             
@@ -325,7 +372,9 @@
         self.__updatePublishTopicComboBox()
         
         self.subscribeGroup.setEnabled(False)
+        self.subscribePropertiesButton.setVisible(False)
         self.unsubscribeGroup.setEnabled(False)
+        self.unsubscribePropertiesButton.setVisible(False)
         self.publishGroup.setEnabled(False)
         self.brokerStatusButton.setEnabled(False)
         
@@ -373,7 +422,6 @@
         else:
             self.logEdit.verticalScrollBar().setValue(scrollbarValue)
     
-    # TODO: add support for MQTT v5 properties
     @pyqtSlot(str, bytes, int, bool)
     @pyqtSlot(str, bytes, int, bool, dict)
     def __messageReceived(self, topic, payload, qos, retain, properties=None):
@@ -406,7 +454,7 @@
         @param mid ID of the published message
         @type int
         """
-        # TODO: check this 'pass' statement
+        # nothing to show for this
         pass
     
     @pyqtSlot(int)
@@ -426,8 +474,8 @@
             self.__updateUnsubscribeTopicComboBox()
             self.__updatePublishTopicComboBox()
     
-    @pyqtSlot(int, list)
-    def __topicSubscribedV5(self, mid, reasonCodes):
+    @pyqtSlot(int, list, dict)
+    def __topicSubscribedV5(self, mid, reasonCodes, properties):
         """
         Private slot to handle being subscribed to topics (MQTT v5).
         
@@ -435,9 +483,16 @@
         @type int
         @param reasonCodes list of reason codes, one for each topic
         @type list of ReasonCodes
+        @param properties dictionary containing the received subscribe
+            properties
+        @type dict
         """
         msg = mqttReasonCode(reasonCodes[0].value, reasonCodes[0].packetType)
         self.__flashBrokerStatusLabel(msg)
+        
+        if properties:
+            self.__showProperties("Subscribe", properties)
+        
         self.__topicSubscribed(mid)
     
     @pyqtSlot(int)
@@ -456,8 +511,8 @@
                 self.__updateUnsubscribeTopicComboBox()
                 self.__updatePublishTopicComboBox()
     
-    @pyqtSlot(int, int, int)
-    def __topicUnsubscribedV5(self, mid, rc, packetType):
+    @pyqtSlot(int, int, int, dict)
+    def __topicUnsubscribedV5(self, mid, rc, packetType, properties):
         """
         Private slot to handle being unsubscribed to topics (MQTT v5).
         
@@ -467,9 +522,16 @@
         @type int
         @param packetType packet type as reported by the client
         @type int
+        @param properties dictionary containing the received subscribe
+            properties
+        @type dict
         """
         msg = mqttReasonCode(rc, packetType)
         self.__flashBrokerStatusLabel(msg)
+        
+        if properties:
+            self.__showProperties("Subscribe", properties)
+        
         self.__topicUnsubscribed(mid)
     
     #######################################################################
@@ -565,6 +627,18 @@
             else:
                 self.__directConnectToBroker()
     
+    @pyqtSlot()
+    def on_subscribePropertiesButton_clicked(self):
+        """
+        Private slot to edit the subscribe user properties.
+        """
+        topic = self.subscribeTopicEdit.text()
+        self.__editProperties(
+            "subscribe",
+            self.tr("SUBSCRIBE: User Properties for '{0}'").format(topic),
+            topic
+        )
+    
     @pyqtSlot(str)
     def on_subscribeTopicEdit_textChanged(self, topic):
         """
@@ -574,6 +648,7 @@
         @type str
         """
         self.subscribeButton.setEnabled(bool(topic))
+        self.subscribePropertiesButton.setEnabled(bool(topic))
     
     @pyqtSlot()
     def on_subscribeTopicEdit_returnPressed(self):
@@ -598,8 +673,27 @@
                     self.tr("Subscriptions to the Status topic '$SYS' shall"
                             " be done on the 'Status' tab."))
             else:
-                self.__topicQueue[
-                    self.__client.subscribe(topic, qos)[1]] = topic
+                properties = (
+                    self.__plugin.getPreferences("SubscribeProperties")
+                    .get(topic, [])
+                    if self.__client.getProtocol() == MqttProtocols.MQTTv5 else
+                    None
+                )
+                result, mid = self.__client.subscribe(
+                    topic, qos=qos, properties=properties)
+                self.__topicQueue[mid] = topic
+    
+    @pyqtSlot()
+    def on_unsubscribePropertiesButton_clicked(self):
+        """
+        Private slot to edit the unsubscribe user properties.
+        """
+        topic = self.unsubscribeTopicComboBox.currentText()
+        self.__editProperties(
+            "unsubscribe",
+            self.tr("UNSUBSCRIBE: User Properties for '{0}'").format(topic),
+            topic
+        )
     
     @pyqtSlot(str)
     def on_unsubscribeTopicComboBox_currentIndexChanged(self, topic):
@@ -610,6 +704,7 @@
         @type str
         """
         self.unsubscribeButton.setEnabled(bool(topic))
+        self.unsubscribePropertiesButton.setEnabled(bool(topic))
     
     @pyqtSlot()
     def on_unsubscribeButton_clicked(self):
@@ -618,8 +713,15 @@
         """
         topic = self.unsubscribeTopicComboBox.currentText()
         if topic:
-            self.__topicQueue[
-                self.__client.unsubscribe(topic)[1]] = topic
+            properties = (
+                self.__plugin.getPreferences("SubscribeProperties")
+                .get(topic, [])
+                if self.__client.getProtocol() == MqttProtocols.MQTTv5 else
+                None
+            )
+            result, mid = self.__client.unsubscribe(
+                topic, properties=properties)
+            self.__topicQueue[mid] = topic
     
     @pyqtSlot(str)
     def on_publishTopicComboBox_editTextChanged(self, topic):
@@ -673,6 +775,19 @@
                 self.on_publishClearButton_clicked()
     
     @pyqtSlot()
+    def on_publishClearRetainedButton_clicked(self):
+        """
+        Private slot to clear the retained messages for the topic.
+        """
+        topic = self.publishTopicComboBox.currentText()
+        
+        msgInfo = self.__client.publish(topic, payload=None, retain=True)
+        if msgInfo.rc == 0:
+            if topic not in self.__publishedTopics:
+                self.__publishedTopics.append(topic)
+            self.__updatePublishTopicComboBox(resetTopic=False)
+    
+    @pyqtSlot()
     def on_publishClearButton_clicked(self):
         """
         Private slot to clear the publish data fields.
@@ -693,6 +808,16 @@
         """
         self.publishPayloadEdit.setEnabled(not bool(path))
     
+    @pyqtSlot(QPoint)
+    def on_propertiesEdit_customContextMenuRequested(self, pos):
+        """
+        Private slot to show the context menu for the properties output.
+        
+        @param pos the position of the mouse pointer
+        @type QPoint
+        """
+        self.__propertiesEditMenu.popup(self.propertiesEdit.mapToGlobal(pos))
+    
     @pyqtSlot()
     def on_brokerStatusButton_clicked(self):
         """
@@ -920,7 +1045,10 @@
         """
         self.unsubscribeTopicComboBox.clear()
         self.unsubscribeTopicComboBox.addItems(sorted(self.__subscribedTopics))
-        self.unsubscribeButton.setEnabled(len(self.__subscribedTopics) > 0)
+        self.unsubscribeButton.setEnabled(
+            bool(self.__subscribedTopics))
+        self.unsubscribePropertiesButton.setEnabled(
+            bool(self.__subscribedTopics))
     
     def __updatePublishTopicComboBox(self, resetTopic=True):
         """
@@ -954,7 +1082,6 @@
         @param properties properties sent with the message (MQTT v5)
         @type dict
         """
-        # TODO: add Output for properties
         scrollbarValue = self.messagesEdit.verticalScrollBar().value()
         
         textCursor = self.messagesEdit.textCursor()
@@ -964,7 +1091,7 @@
             self.messagesEdit.insertPlainText("\n")
         
         textBlockFormat = textCursor.blockFormat()
-        if self.__isAlternate:
+        if self.__isMessageAlternate:
             textBlockFormat.setBackground(
                 self.messagesEdit.palette().alternateBase())
         else:
@@ -984,6 +1111,16 @@
             self.messagesEdit.setCurrentCharFormat(self.__messagesQosFormat)
             self.messagesEdit.insertPlainText(self.tr("Retained Message\n"))
         
+        if properties:
+            self.messagesEdit.setCurrentCharFormat(self.__messagesTopicFormat)
+            self.messagesEdit.insertPlainText(self.tr("Properties:\n"))
+            self.messagesEdit.setCurrentCharFormat(self.__messagesFormat)
+            for name, value in sorted(properties.items):
+                self.messagesEdit.insertPlainText(
+                    self.tr("{0}: {1}\n", "property name, property value")
+                    .format(name, value)
+                )
+        
         payloadStr = str(payload, encoding="utf-8", errors="replace")
         self.messagesEdit.setCurrentCharFormat(self.__messagesFormat)
         self.messagesEdit.insertPlainText(
@@ -994,11 +1131,11 @@
         else:
             self.messagesEdit.verticalScrollBar().setValue(scrollbarValue)
         
-        self.__isAlternate = not self.__isAlternate
+        self.__isMessageAlternate = not self.__isMessageAlternate
     
     def __handleBrokerStatusMessage(self, topic, payload):
         """
-        Private method to append a received message to the output.
+        Private method to handle a status message of the broker.
         
         @param topic topic of the received message
         @type str
@@ -1158,3 +1295,64 @@
             )
             self.__client.connectToServerWithOptions(
                 host, port=port, options=connectionProfile)
+    
+    def __showProperties(self, typeStr, properties):
+        """
+        Private method to display the received properties in the properties
+        pane.
+        
+        @param typeStr message type
+        @type str
+        @param properties dictionary containing the relevant properties
+        @type dict
+        """
+        textCursor = self.propertiesEdit.textCursor()
+        if not self.propertiesEdit.document().isEmpty():
+            textCursor.movePosition(QTextCursor.MoveOperation.End)
+            self.propertiesEdit.setTextCursor(textCursor)
+        
+        textBlockFormat = textCursor.blockFormat()
+        if self.__isPropertiesAlternate:
+            textBlockFormat.setBackground(
+                self.propertiesEdit.palette().alternateBase())
+        else:
+            textBlockFormat.setBackground(
+                self.propertiesEdit.palette().base())
+        textCursor.setBlockFormat(textBlockFormat)
+        textCursor.movePosition(QTextCursor.MoveOperation.End)
+        self.propertiesEdit.setTextCursor(textCursor)
+        
+        self.propertiesEdit.setCurrentCharFormat(self.__propertiesTopicFormat)
+        self.propertiesEdit.insertPlainText(typeStr + "\n")
+        
+        for name, value in sorted(properties.items()):
+            self.propertiesEdit.setCurrentCharFormat(
+                self.__propertiesNameFormat)
+            self.propertiesEdit.insertPlainText("{0}: ".format(name))
+            self.propertiesEdit.setCurrentCharFormat(self.__propertiesFormat)
+            self.propertiesEdit.insertPlainText("{0}\n".format(str(value)))
+        
+        self.propertiesEdit.ensureCursorVisible()
+        
+        self.__isPropertiesAlternate = not self.__isPropertiesAlternate
+    
+    def __editProperties(self, propertiesType, header, key):
+        """
+        Private method to edit user properties of a given type.
+        
+        @param propertiesType properties type (one of 'subscribe',
+            'unsubscribe', 'publish')
+        @type str
+        @param header header to be shown in the edit dialog
+        @type str
+        @param key key to retrieve the right properties
+        @type str
+        """
+        from .MqttUserPropertiesEditor import MqttUserPropertiesEditor
+        
+        preferencesKey = "{0}Properties".format(propertiesType.capitalize())
+        properties = self.__plugin.getPreferences(preferencesKey)
+        dlg = MqttUserPropertiesEditor(header, properties.get(key, []), self)
+        if dlg.exec() == QDialog.DialogCode.Accepted:
+            properties[key] = dlg.getProperties()
+            self.__plugin.setPreferences(preferencesKey, properties)
diff -r 0eae5f616154 -r 70b8858199f5 MqttMonitor/MqttMonitorWidget.ui
--- a/MqttMonitor/MqttMonitorWidget.ui	Tue Jul 20 18:10:55 2021 +0200
+++ b/MqttMonitor/MqttMonitorWidget.ui	Wed Jul 21 20:10:36 2021 +0200
@@ -7,7 +7,7 @@
     <x>0</x>
     <y>0</y>
     <width>400</width>
-    <height>600</height>
+    <height>715</height>
    </rect>
   </property>
   <layout class="QVBoxLayout" name="verticalLayout">
@@ -115,23 +115,30 @@
        </layout>
       </item>
       <item row="0" column="2">
-       <widget class="QToolButton" name="brokerConnectionOptionsButton">
-        <property name="toolTip">
-         <string>Press to open a dialog to enter connection options</string>
+       <layout class="QHBoxLayout" name="horizontalLayout_19">
+        <property name="spacing">
+         <number>0</number>
         </property>
-       </widget>
+        <item>
+         <widget class="QToolButton" name="brokerConnectionOptionsButton">
+          <property name="toolTip">
+           <string>Press to open a dialog to enter connection options</string>
+          </property>
+         </widget>
+        </item>
+        <item>
+         <widget class="QToolButton" name="connectButton">
+          <property name="toolTip">
+           <string>Press to connect to/disconnect from the broker</string>
+          </property>
+          <property name="text">
+           <string/>
+          </property>
+         </widget>
+        </item>
+       </layout>
       </item>
-      <item row="0" column="3">
-       <widget class="QToolButton" name="connectButton">
-        <property name="toolTip">
-         <string>Press to connect to/disconnect from the broker</string>
-        </property>
-        <property name="text">
-         <string/>
-        </property>
-       </widget>
-      </item>
-      <item row="1" column="0" colspan="4">
+      <item row="1" column="0" colspan="3">
        <widget class="QLabel" name="brokerStatusLabel">
         <property name="wordWrap">
          <bool>true</bool>
@@ -150,7 +157,7 @@
       <attribute name="title">
        <string>Pub/Sub</string>
       </attribute>
-      <layout class="QVBoxLayout" name="verticalLayout_5">
+      <layout class="QVBoxLayout" name="verticalLayout_7">
        <item>
         <widget class="QGroupBox" name="subscribeGroup">
          <property name="enabled">
@@ -159,7 +166,7 @@
          <property name="title">
           <string>Subscribe</string>
          </property>
-         <layout class="QHBoxLayout" name="horizontalLayout_2">
+         <layout class="QHBoxLayout" name="horizontalLayout_20">
           <item>
            <widget class="QLabel" name="label">
             <property name="text">
@@ -198,11 +205,28 @@
            </widget>
           </item>
           <item>
-           <widget class="QToolButton" name="subscribeButton">
-            <property name="toolTip">
-             <string>Press to subscribe to the given topic</string>
+           <layout class="QHBoxLayout" name="horizontalLayout_2">
+            <property name="spacing">
+             <number>0</number>
             </property>
-           </widget>
+            <item>
+             <widget class="QToolButton" name="subscribeButton">
+              <property name="toolTip">
+               <string>Press to subscribe to the given topic</string>
+              </property>
+             </widget>
+            </item>
+            <item>
+             <widget class="QToolButton" name="subscribePropertiesButton">
+              <property name="toolTip">
+               <string>Press to edit the user properties</string>
+              </property>
+              <property name="text">
+               <string/>
+              </property>
+             </widget>
+            </item>
+           </layout>
           </item>
          </layout>
         </widget>
@@ -215,7 +239,7 @@
          <property name="title">
           <string>Unsubscribe</string>
          </property>
-         <layout class="QHBoxLayout" name="horizontalLayout_3">
+         <layout class="QHBoxLayout" name="horizontalLayout_21">
           <item>
            <widget class="QLabel" name="label_4">
             <property name="text">
@@ -237,11 +261,28 @@
            </widget>
           </item>
           <item>
-           <widget class="QToolButton" name="unsubscribeButton">
-            <property name="toolTip">
-             <string>Press to unsubscribe the selected topic</string>
+           <layout class="QHBoxLayout" name="horizontalLayout_3">
+            <property name="spacing">
+             <number>0</number>
             </property>
-           </widget>
+            <item>
+             <widget class="QToolButton" name="unsubscribeButton">
+              <property name="toolTip">
+               <string>Press to unsubscribe the selected topic</string>
+              </property>
+             </widget>
+            </item>
+            <item>
+             <widget class="QToolButton" name="unsubscribePropertiesButton">
+              <property name="toolTip">
+               <string>Press to edit the user properties</string>
+              </property>
+              <property name="text">
+               <string/>
+              </property>
+             </widget>
+            </item>
+           </layout>
           </item>
          </layout>
         </widget>
@@ -368,6 +409,16 @@
              </widget>
             </item>
             <item>
+             <widget class="QPushButton" name="publishClearRetainedButton">
+              <property name="toolTip">
+               <string>Press to clear the retained messages of the selected topic</string>
+              </property>
+              <property name="text">
+               <string>Clear Retained</string>
+              </property>
+             </widget>
+            </item>
+            <item>
              <spacer name="horizontalSpacer_3">
               <property name="orientation">
                <enum>Qt::Horizontal</enum>
@@ -403,17 +454,32 @@
         </widget>
        </item>
        <item>
-        <spacer name="verticalSpacer">
-         <property name="orientation">
-          <enum>Qt::Vertical</enum>
+        <widget class="QGroupBox" name="groupBox">
+         <property name="title">
+          <string>MQTT V5 Properties</string>
          </property>
-         <property name="sizeHint" stdset="0">
-          <size>
-           <width>20</width>
-           <height>40</height>
-          </size>
-         </property>
-        </spacer>
+         <layout class="QVBoxLayout" name="verticalLayout_5">
+          <item>
+           <widget class="QPlainTextEdit" name="propertiesEdit">
+            <property name="contextMenuPolicy">
+             <enum>Qt::CustomContextMenu</enum>
+            </property>
+            <property name="tabChangesFocus">
+             <bool>true</bool>
+            </property>
+            <property name="lineWrapMode">
+             <enum>QPlainTextEdit::NoWrap</enum>
+            </property>
+            <property name="readOnly">
+             <bool>true</bool>
+            </property>
+            <property name="textInteractionFlags">
+             <set>Qt::TextSelectableByKeyboard|Qt::TextSelectableByMouse</set>
+            </property>
+           </widget>
+          </item>
+         </layout>
+        </widget>
        </item>
       </layout>
      </widget>
@@ -548,7 +614,7 @@
            <rect>
             <x>0</x>
             <y>0</y>
-            <width>178</width>
+            <width>344</width>
             <height>840</height>
            </rect>
           </property>
@@ -1414,7 +1480,9 @@
   <tabstop>publishRetainCheckBox</tabstop>
   <tabstop>publishButton</tabstop>
   <tabstop>publishClearButton</tabstop>
+  <tabstop>publishClearRetainedButton</tabstop>
   <tabstop>clearPublishCheckBox</tabstop>
+  <tabstop>propertiesEdit</tabstop>
   <tabstop>messagesSearchWidget</tabstop>
   <tabstop>messagesEdit</tabstop>
   <tabstop>saveMessagesButton</tabstop>
diff -r 0eae5f616154 -r 70b8858199f5 MqttMonitor/MqttReasonCodes.py
--- a/MqttMonitor/MqttReasonCodes.py	Tue Jul 20 18:10:55 2021 +0200
+++ b/MqttMonitor/MqttReasonCodes.py	Wed Jul 21 20:10:36 2021 +0200
@@ -205,6 +205,7 @@
         ): [PacketTypes.SUBACK, PacketTypes.DISCONNECT]},
 }
 
+
 def mqttReasonCode(rc, packetType):
     """
     Function to get the readable reason code string given the result code and
@@ -214,6 +215,8 @@
     @type int
     @param packetType packet type
     @type PacketTypes (= int)
+    @return message associated with the reason code and packet type
+    @rtype str
     """
     if rc not in MqttReasonCodeNames:
         return QCoreApplication.translate(
diff -r 0eae5f616154 -r 70b8858199f5 MqttMonitor/MqttUserPropertiesEditor.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MqttMonitor/MqttUserPropertiesEditor.py	Wed Jul 21 20:10:36 2021 +0200
@@ -0,0 +1,104 @@
+# -*- coding: utf-8 -*-
+
+# Copyright (c) 2021 Detlev Offenbach <detlev@die-offenbachs.de>
+#
+
+"""
+Module implementing an editor for MQTT v5 user properties.
+"""
+
+from PyQt6.QtCore import pyqtSlot
+from PyQt6.QtWidgets import QDialog, QTableWidgetItem
+
+from .Ui_MqttUserPropertiesEditor import Ui_MqttUserPropertiesEditor
+
+import UI.PixmapCache
+
+
+class MqttUserPropertiesEditor(QDialog, Ui_MqttUserPropertiesEditor):
+    """
+    Class implementing an editor 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.setupUi(self)
+        
+        self.addButton.setIcon(UI.PixmapCache.getIcon("plus"))
+        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.deleteButton.setEnabled(False)
+    
+    @pyqtSlot()
+    def on_propertiesTable_itemSelectionChanged(self):
+        """
+        Private slot to handle the selection of rows.
+        """
+        self.deleteButton.setEnabled(
+            bool(self.propertiesTable.selectedItems()))
+    
+    @pyqtSlot()
+    def on_addButton_clicked(self):
+        """
+        Private slot to add a row to the table.
+        """
+        self.propertiesTable.setRowCount(self.propertiesTable.rowCount() + 1)
+        self.propertiesTable.setCurrentCell(
+            self.propertiesTable.rowCount() - 1, 0)
+    
+    @pyqtSlot()
+    def on_deleteButton_clicked(self):
+        """
+        Private slot to delete the selected rows.
+        """
+        selectedRanges = self.propertiesTable.selectedRanges()
+        selectedRows = [(r.bottomRow(), r.topRow()) for r in selectedRanges]
+        for bottomRow, topRow in sorted(selectedRows, reverse=True):
+            for row in range(bottomRow, topRow - 1, -1):
+                self.propertiesTable.removeRow(row)
+    
+    @pyqtSlot()
+    def on_clearButton_clicked(self):
+        """
+        Private slot to delete all properties.
+        """
+        self.propertiesTable.clearContents()
+        self.propertiesTable.setRowCount(10)
+        self.propertiesTable.setCurrentCell(0, 0)
+    
+    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)
+        """
+        properties = []
+        
+        for row in range(self.propertiesTable.rowCount()):
+            keyItem = self.propertiesTable.item(row, 0)
+            key = keyItem.text() if keyItem else ""
+            if key:
+                valueItem = self.propertiesTable.item(row, 1)
+                value = valueItem.text() if valueItem else ""
+                properties.append((key, value))
+        
+        return properties
diff -r 0eae5f616154 -r 70b8858199f5 MqttMonitor/MqttUserPropertiesEditor.ui
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/MqttMonitor/MqttUserPropertiesEditor.ui	Wed Jul 21 20:10:36 2021 +0200
@@ -0,0 +1,163 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<ui version="4.0">
+ <class>MqttUserPropertiesEditor</class>
+ <widget class="QDialog" name="MqttUserPropertiesEditor">
+  <property name="geometry">
+   <rect>
+    <x>0</x>
+    <y>0</y>
+    <width>400</width>
+    <height>350</height>
+   </rect>
+  </property>
+  <property name="windowTitle">
+   <string>User Properties</string>
+  </property>
+  <property name="sizeGripEnabled">
+   <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">
+    <widget class="QTableWidget" name="propertiesTable">
+     <property name="alternatingRowColors">
+      <bool>true</bool>
+     </property>
+     <property name="selectionBehavior">
+      <enum>QAbstractItemView::SelectRows</enum>
+     </property>
+     <property name="rowCount">
+      <number>10</number>
+     </property>
+     <property name="columnCount">
+      <number>2</number>
+     </property>
+     <attribute name="horizontalHeaderStretchLastSection">
+      <bool>true</bool>
+     </attribute>
+     <row/>
+     <row/>
+     <row/>
+     <row/>
+     <row/>
+     <row/>
+     <row/>
+     <row/>
+     <row/>
+     <row/>
+     <column>
+      <property name="text">
+       <string>Key</string>
+      </property>
+     </column>
+     <column>
+      <property name="text">
+       <string>Value</string>
+      </property>
+     </column>
+    </widget>
+   </item>
+   <item row="1" column="1">
+    <layout class="QVBoxLayout" name="verticalLayout">
+     <item>
+      <widget class="QToolButton" name="addButton">
+       <property name="toolTip">
+        <string>Press to add a new empty row</string>
+       </property>
+       <property name="text">
+        <string/>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QToolButton" name="deleteButton">
+       <property name="toolTip">
+        <string>Press to delete the selected properties</string>
+       </property>
+       <property name="text">
+        <string/>
+       </property>
+      </widget>
+     </item>
+     <item>
+      <widget class="QToolButton" name="clearButton">
+       <property name="toolTip">
+        <string>Press to delete all properties</string>
+       </property>
+       <property name="text">
+        <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>40</height>
+        </size>
+       </property>
+      </spacer>
+     </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>
+</ui>
diff -r 0eae5f616154 -r 70b8858199f5 PluginMqttMonitor.epj
--- a/PluginMqttMonitor.epj	Tue Jul 20 18:10:55 2021 +0200
+++ b/PluginMqttMonitor.epj	Wed Jul 21 20:10:36 2021 +0200
@@ -154,7 +154,8 @@
     "FORMS": [
       "MqttMonitor/MqttConnectionOptionsDialog.ui",
       "MqttMonitor/MqttConnectionProfilesDialog.ui",
-      "MqttMonitor/MqttMonitorWidget.ui"
+      "MqttMonitor/MqttMonitorWidget.ui",
+      "MqttMonitor/MqttUserPropertiesEditor.ui"
     ],
     "HASH": "8b864e3e4a3495e242eae3cb3ef4dc8522bf6ce7",
     "IDLPARAMS": {
@@ -214,7 +215,8 @@
       "MqttMonitor/__init__.py",
       "PluginMqttMonitor.py",
       "__init__.py",
-      "MqttMonitor/MqttReasonCodes.py"
+      "MqttMonitor/MqttReasonCodes.py",
+      "MqttMonitor/MqttUserPropertiesEditor.py"
     ],
     "SPELLEXCLUDES": "",
     "SPELLLANGUAGE": "en",
@@ -280,4 +282,4 @@
     "VCSOTHERDATA": {},
     "VERSION": ""
   }
-}
\ No newline at end of file
+}
diff -r 0eae5f616154 -r 70b8858199f5 PluginMqttMonitor.py
--- a/PluginMqttMonitor.py	Tue Jul 20 18:10:55 2021 +0200
+++ b/PluginMqttMonitor.py	Wed Jul 21 20:10:36 2021 +0200
@@ -95,6 +95,10 @@
             "BrokerProfiles": "{}",             # JSON formatted empty dict
             # __IGNORE_WARNING_M613__
             "MostRecentProfile": "",            # most recently used profile
+            "SubscribeProperties": "{}",        # JSON formatted empty dict
+            # __IGNORE_WARNING_M613__
+            "UnsubscribeProperties": "{}",        # JSON formatted empty dict
+            # __IGNORE_WARNING_M613__
         }
         
         self.__translator = None
@@ -212,7 +216,8 @@
         @return value of the requested setting
         @rtype Any
         """
-        if key in ["RecentBrokersWithPort", "BrokerProfiles"]:
+        if key in ["RecentBrokersWithPort", "BrokerProfiles",
+                   "SubscribeProperties", "UnsubscribeProperties"]:
             return json.loads(Preferences.Prefs.settings.value(
                 self.PreferencesKey + "/" + key, self.__defaults[key]))
         else:
@@ -228,7 +233,8 @@
         @param value value to be set
         @type Any
         """
-        if key in ["RecentBrokersWithPort", "BrokerProfiles"]:
+        if key in ["RecentBrokersWithPort", "BrokerProfiles",
+                   "SubscribeProperties", "UnsubscribeProperties"]:
             Preferences.Prefs.settings.setValue(
                 self.PreferencesKey + "/" + key, json.dumps(value))
         else:

eric ide

mercurial