MqttMonitor/MqttConnectionProfilesDialog.py

Sat, 08 Sep 2018 15:29:39 +0200

author
Detlev Offenbach <detlev@die-offenbachs.de>
date
Sat, 08 Sep 2018 15:29:39 +0200
branch
connection_profiles
changeset 26
ad232a5129cc
parent 23
0b23bd856e43
child 30
17ef10819773
permissions
-rw-r--r--

MqttConnectionProfilesDialog: added support for TLS and added a button to copy the current profile.

# -*- coding: utf-8 -*-

# Copyright (c) 2018 Detlev Offenbach <detlev@die-offenbachs.de>
#

"""
Module implementing a dialog to edit the MQTT connection profiles.
"""

from __future__ import unicode_literals

import collections

from PyQt5.QtCore import pyqtSlot, Qt, QUuid
from PyQt5.QtWidgets import QDialog, QDialogButtonBox, QAbstractButton, \
    QListWidgetItem, QInputDialog, QLineEdit

from E5Gui import E5MessageBox
from E5Gui.E5PathPicker import E5PathPickerModes

from .Ui_MqttConnectionProfilesDialog import Ui_MqttConnectionProfilesDialog

import UI.PixmapCache
from Utilities.crypto import pwConvert


class MqttConnectionProfilesDialog(QDialog, Ui_MqttConnectionProfilesDialog):
    """
    Class implementing a dialog to edit the MQTT connection profiles.
    """
    def __init__(self, client, profiles, parent=None):
        """
        Constructor
        
        @param client reference to the MQTT client object
        @type MqttClient
        @param profiles dictionary containing dictionaries containing the
            connection parameters. Each entry must have the keys
            "BrokerAddress", "BrokerPort", "ClientId",
            "Keepalive", "CleanSession", "Username", "Password", "WillTopic",
            "WillMessage", "WillQos", "WillRetain", "TlsEnable", "TlsCaCert",
            "TlsClientCert", "TlsClientKey".
        @type dict
        @param parent reference to the parent widget
        @type QWidget
        """
        super(MqttConnectionProfilesDialog, self).__init__(parent)
        self.setupUi(self)
        
        self.__client = client
        
        self.__profiles = collections.defaultdict(self.__defaultProfile)
        self.__profiles.update(profiles)
        self.__profilesChanged = False
        
        self.plusButton.setIcon(UI.PixmapCache.getIcon("plus.png"))
        self.copyButton.setIcon(UI.PixmapCache.getIcon("editCopy.png"))
        self.minusButton.setIcon(UI.PixmapCache.getIcon("minus.png"))
        
        self.tlsCertsFilePicker.setMode(E5PathPickerModes.OpenFileMode)
        self.tlsCertsFilePicker.setFilters(
            self.tr("Certificate Files (*.crt *.pem);;All Files (*)"))
        self.tlsSelfSignedCertsFilePicker.setMode(
            E5PathPickerModes.OpenFileMode)
        self.tlsSelfSignedCertsFilePicker.setFilters(
            self.tr("Certificate Files (*.crt *.pem);;All Files (*)"))
        self.tlsSelfSignedClientCertFilePicker.setMode(
            E5PathPickerModes.OpenFileMode)
        self.tlsSelfSignedClientCertFilePicker.setFilters(
            self.tr("Certificate Files (*.crt *.pem);;All Files (*)"))
        self.tlsSelfSignedClientKeyFilePicker.setMode(
            E5PathPickerModes.OpenFileMode)
        self.tlsSelfSignedClientKeyFilePicker.setFilters(
            self.tr("Key Files (*.key *.pem);;All Files (*)"))
        
        self.profileTabWidget.setCurrentIndex(0)
        
        if len(self.__profiles) == 0:
            self.minusButton.setEnabled(False)
            self.copyButton.setEnabled(False)
        
        self.profileFrame.setEnabled(False)
        self.__populatingProfile = False
        self.__deletingProfile = False
        
        self.__populateProfilesList()
    
    @pyqtSlot(str)
    def on_profileEdit_textChanged(self, name):
        """
        Private slot to handle changes of the profile name.
        
        @param name name of the profile
        @type str
        """
        self.__updateApplyButton()
    
    @pyqtSlot(QAbstractButton)
    def on_profileButtonBox_clicked(self, button):
        """
        Private slot handling presses of the profile buttons.
        
        @param button reference to the pressed button
        @type QAbstractButton
        """
        if button == self.profileButtonBox.button(QDialogButtonBox.Apply):
            currentProfile = self.__applyProfile()
            self.__populateProfilesList(currentProfile)
        
        elif button == self.profileButtonBox.button(QDialogButtonBox.Reset):
            self.__resetProfile()
        
        elif button == self.profileButtonBox.button(
                QDialogButtonBox.RestoreDefaults):
            self.__populateProfileDefault()
    
    @pyqtSlot(QListWidgetItem, QListWidgetItem)
    def on_profilesList_currentItemChanged(self, current, previous):
        """
        Private slot to handle a change of the current profile.
        
        @param current new current item
        @type QListWidgetItem
        @param previous previous current item
        @type QListWidgetItem
        """
        self.minusButton.setEnabled(current is not None)
        self.copyButton.setEnabled(current is not None)
        
        if current is not previous:
            if not self.__deletingProfile and self.__isChangedProfile():
                # modified profile belongs to previous
                yes = E5MessageBox.yesNo(
                    self,
                    self.tr("Changed Connection Profile"),
                    self.tr("""The current profile has unsaved changes."""
                            """ Shall these be saved?"""),
                    icon=E5MessageBox.Warning,
                    yesDefault=True)
                if yes:
                    self.__applyProfile()
        
        if current:
            profileName = current.text()
            self.__populateProfile(profileName)
        else:
            self.__clearProfile()
    
    @pyqtSlot()
    def on_plusButton_clicked(self):
        """
        Private slot to add a new empty profile entry.
        """
        profileName, ok = QInputDialog.getText(
            self,
            self.tr("New Connection Profile"),
            self.tr("Enter name for the new Connection Profile:"),
            QLineEdit.Normal)
        if ok and bool(profileName):
            if profileName in self.__profiles:
                E5MessageBox.warning(
                    self,
                    self.tr("New Connection Profile"),
                    self.tr("""<p>A connection named <b>{0}</b> exists"""
                            """ already. Aborting...</p>""").format(
                        profileName))
            else:
                itm = QListWidgetItem(profileName, self.profilesList)
                self.profilesList.setCurrentItem(itm)
                self.brokerAddressEdit.setFocus(Qt.OtherFocusReason)
    
    @pyqtSlot()
    def on_copyButton_clicked(self):
        """
        Private slot to copy the selected profile entry.
        """
        itm = self.profilesList.currentItem()
        if itm:
            profileName = itm.text()
            newProfileName, ok = QInputDialog.getText(
                self,
                self.tr("Copy Connection Profile"),
                self.tr("Enter name for the copied Connection Profile:"),
                QLineEdit.Normal)
            if ok and bool(newProfileName):
                if newProfileName in self.__profiles:
                    E5MessageBox.warning(
                        self,
                        self.tr("Copy Connection Profile"),
                        self.tr("""<p>A connection named <b>{0}</b> exists"""
                                """ already. Aborting...</p>""").format(
                            newProfileName))
                else:
                    profile = self.__defaultProfile()
                    profile.update(self.__profiles[profileName])
                    self.__profiles[newProfileName] = profile
                    
                    itm = QListWidgetItem(newProfileName, self.profilesList)
                    self.profilesList.setCurrentItem(itm)
                    self.brokerAddressEdit.setFocus(Qt.OtherFocusReason)
    
    @pyqtSlot()
    def on_minusButton_clicked(self):
        """
        Private slot to delete the selected entry.
        """
        itm = self.profilesList.currentItem()
        if itm:
            profileName = itm.text()
            yes = E5MessageBox.yesNo(
                self,
                self.tr("Delete Connection Profile"),
                self.tr("""<p>Shall the Connection Profile <b>{0}</b>"""
                        """ really be deleted?</p>""").format(profileName)
            )
            if yes:
                self.__deletingProfile = True
                del self.__profiles[profileName]
                self.__profilesChanged = True
                self.__populateProfilesList()
                self.__deletingProfile = False
        
        self.profilesList.setFocus(Qt.OtherFocusReason)
    
    def getProfiles(self):
        """
        Public method to return a dictionary of profiles.
        
        @return dictionary containing dictionaries containing the defined
            connection profiles. Each entry have the keys "BrokerAddress",
            "BrokerPort", "ClientId", "Keepalive", "CleanSession", "Username",
            "Password", "WillTopic", "WillMessage", "WillQos", "WillRetain",
            "TlsEnable", "TlsCaCert", "TlsClientCert", "TlsClientKey".
        @rtype dict
        """
        profilesDict = {}
        profilesDict.update(self.__profiles)
        return profilesDict
    
    def __applyProfile(self):
        """
        Private method to apply the entered data to the list of profiles.
        
        @return name of the applied profile
        @rtype str
        """
        profileName = self.profileEdit.text()
        profile = {
            "BrokerAddress": self.brokerAddressEdit.text(),
            "BrokerPort": self.brokerPortSpinBox.value(),
            "ClientId": self.clientIdEdit.text(),
            "Keepalive": self.keepaliveSpinBox.value(),
            "CleanSession": self.cleanSessionCheckBox.isChecked(),
            "Username": self.usernameEdit.text(),
            "Password": pwConvert(self.passwordEdit.text(), encode=True),
            "WillTopic": self.willTopicEdit.text(),
            "WillMessage": self.willMessageEdit.toPlainText(),
            "WillQos": self.willQosSpinBox.value(),
            "WillRetain": self.willRetainCheckBox.isChecked(),
            "TlsEnable": self.tlsGroupBox.isChecked(),
            "TlsCaCert": "",
            "TlsClientCert": "",
            "TlsClientKey": "",
        }
        if profile["TlsEnable"]:
            if self.tlsCertsFileButton.isChecked():
                profile["TlsCaCert"] = self.tlsCertsFilePicker.text()
            elif self.tlsSelfSignedCertsButton.isChecked():
                profile["TlsCaCert"] = \
                    self.tlsSelfSignedCertsFilePicker.text()
                profile["TlsClientCert"] = \
                    self.tlsSelfSignedClientCertFilePicker.text()
                profile["TlsClientKey"] = \
                    self.tlsSelfSignedClientKeyFilePicker.text()
        
        self.__profiles[profileName] = profile
        self.__profilesChanged = True
        
        return profileName
    
    def __defaultProfile(self):
        """
        Private method to populate non-existing profile items.
        
        @return default dictionary entry
        @rtype dict
        """
        defaultProfile = self.__client.defaultConnectionOptions()
        defaultProfile["BrokerAddress"] = ""
        if defaultProfile["TlsEnable"]:
            defaultProfile["BrokerPort"] = 8883
        else:
            defaultProfile["BrokerPort"] = 1883
        
        return defaultProfile
    
    def __populateProfilesList(self, currentProfile=""):
        """
        Private method to populate the list of defined profiles.
        
        @param currentProfile name of the current profile
        @type str
        """
        if not currentProfile:
            currentItem = self.profilesList.currentItem()
            if currentItem:
                currentProfile = currentItem.text()
        
        self.profilesList.clear()
        self.profilesList.addItems(sorted(self.__profiles.keys()))
        
        if currentProfile:
            items = self.profilesList.findItems(
                currentProfile, Qt.MatchExactly)
            if items:
                self.profilesList.setCurrentItem(items[0])
        
        if len(self.__profiles) == 0:
            self.profileFrame.setEnabled(False)
    
    def __populateProfile(self, profileName):
        """
        Private method to populate the profile data entry fields.
        
        @param profileName name of the profile to get data from
        @type str
        """
        profile = self.__defaultProfile()
        if profileName:
            profile.update(self.__profiles[profileName])
        
        self.__populatingProfile = True
        if profileName is not None:
            self.profileEdit.setText(profileName)
        self.brokerAddressEdit.setText(profile["BrokerAddress"])
        self.brokerPortSpinBox.setValue(profile["BrokerPort"])
        self.clientIdEdit.setText(profile["ClientId"])
        self.keepaliveSpinBox.setValue(profile["Keepalive"])
        self.cleanSessionCheckBox.setChecked(profile["CleanSession"])
        self.usernameEdit.setText(profile["Username"])
        self.passwordEdit.setText(pwConvert(profile["Password"], encode=False))
        self.willTopicEdit.setText(profile["WillTopic"])
        self.willMessageEdit.setPlainText(profile["WillMessage"])
        self.willQosSpinBox.setValue(profile["WillQos"])
        self.willRetainCheckBox.setChecked(profile["WillRetain"])
        self.tlsGroupBox.setChecked(profile["TlsEnable"])
        if profile["TlsCaCert"] and profile["TlsClientCert"]:
            self.tlsSelfSignedCertsButton.setChecked(True)
            self.tlsSelfSignedCertsFilePicker.setText(profile["TlsCaCert"])
            self.tlsSelfSignedClientCertFilePicker.setText(
                profile["TlsClientCert"])
            self.tlsSelfSignedClientKeyFilePicker.setText(
                profile["TlsClientKey"])
        elif profile["TlsCaCert"]:
            self.tlsCertsFileButton.setChecked(True)
            self.tlsCertsFilePicker.setText(profile["TlsCaCert"])
        else:
            self.tlsDefaultCertsButton.setChecked(True)
        self.__populatingProfile = False
        
        self.profileFrame.setEnabled(True)
        self.__updateApplyButton()
    
    def __clearProfile(self):
        """
        Private method to clear the profile data entry fields.
        """
        self.__populatingProfile = True
        self.profileEdit.setText("")
        self.brokerAddressEdit.setText("")
        self.brokerPortSpinBox.setValue(1883)
        self.clientIdEdit.setText("")
        self.keepaliveSpinBox.setValue(60)
        self.cleanSessionCheckBox.setChecked(True)
        self.usernameEdit.setText("")
        self.passwordEdit.setText("")
        self.willTopicEdit.setText("")
        self.willMessageEdit.setPlainText("")
        self.willQosSpinBox.setValue(0)
        self.willRetainCheckBox.setChecked(False)
        self.tlsGroupBox.setChecked(False)
        self.tlsDefaultCertsButton.setChecked(True)
        self.tlsCertsFileButton.setChecked(True)
        self.tlsCertsFilePicker.setText("")
        self.tlsSelfSignedCertsButton.setChecked(False)
        self.tlsSelfSignedCertsFilePicker.setText("")
        self.tlsSelfSignedClientCertFilePicker.setText("")
        self.tlsSelfSignedClientKeyFilePicker.setText("")
        self.__populatingProfile = False
        
        self.profileFrame.setEnabled(False)
        self.__updateApplyButton()
    
    def __resetProfile(self):
        """
        Private method to reset the profile data entry fields to their stored
        values.
        """
        profileName = self.profileEdit.text()
        if profileName in self.__profiles:
            self.__populateProfile(profileName)
    
    def __populateProfileDefault(self):
        """
        Private method to populate the profile data entry fields with default
        profile values.
        """
        self.__populateProfile(None)
    
    def __isChangedProfile(self):
        """
        Private method to check, if the currently shown profile contains some
        changed data.
        
        @return flag indicating changed data
        @type bool
        """
        profileName = self.profileEdit.text()
        if profileName == "":
            return False
        
        elif profileName in self.__profiles:
            profile = self.__defaultProfile()
            profile.update(self.__profiles[profileName])
            changed = (
                self.brokerAddressEdit.text() != profile["BrokerAddress"] or
                self.brokerPortSpinBox.value() != profile["BrokerPort"] or
                self.clientIdEdit.text() != profile["ClientId"] or
                self.keepaliveSpinBox.value() != profile["Keepalive"] or
                self.cleanSessionCheckBox.isChecked() !=
                profile["CleanSession"] or
                self.usernameEdit.text() != profile["Username"] or
                self.passwordEdit.text() !=
                pwConvert(profile["Password"], encode=False) or
                self.willTopicEdit.text() != profile["WillTopic"] or
                self.willMessageEdit.toPlainText() != profile["WillMessage"] or
                self.willQosSpinBox.value() != profile["WillQos"] or
                self.willRetainCheckBox.isChecked() != profile["WillRetain"] or
                self.tlsGroupBox.isChecked() != profile["TlsEnable"]
            )
            # check TLS stuff only, if not yet changed
            if not changed:
                if self.tlsCertsFileButton.isChecked():
                    changed |= (
                        self.tlsCertsFilePicker.text() != profile["TlsCaCert"]
                    )
                elif self.tlsSelfSignedCertsButton.isChecked():
                    changed |= (
                        self.tlsSelfSignedCertsFilePicker.text() !=
                        profile["TlsCaCert"] or
                        self.tlsSelfSignedClientCertFilePicker.text() !=
                        profile["TlsClientCert"] or
                        self.tlsSelfSignedClientKeyFilePicker.text() !=
                        profile["TlsClientKey"]
                    )
            return changed
        
        else:
            return True
    
    def __updateApplyButton(self):
        """
        Private method to set the state of the Apply button.
        """
        # condition 1: profile name and broker address need to be given
        enable = (bool(self.profileEdit.text()) and
                  bool(self.brokerAddressEdit.text()))
        
        # condition 2: if client ID is empty, clean session must be selected
        if not self.__populatingProfile:
            if self.clientIdEdit.text() == "" and \
                    not self.cleanSessionCheckBox.isChecked():
                enable = False
                E5MessageBox.critical(
                    self,
                    self.tr("Invalid Connection Parameters"),
                    self.tr("An empty Client ID requires a clean session."))
        
        if self.tlsGroupBox.isChecked():
            if self.tlsCertsFileButton.isChecked():
                # condition 3a: if CA certificates file shall be used, it must
                # be given
                enable &= bool(self.tlsCertsFilePicker.text())
            elif self.tlsSelfSignedCertsButton.isChecked():
                # condition 3b: if client certificates shall be used, all files
                # must be given
                enable &= (
                    bool(self.tlsSelfSignedCertsFilePicker.text()) and
                    bool(self.tlsSelfSignedClientCertFilePicker.text()) and
                    bool(self.tlsSelfSignedClientKeyFilePicker.text())
                )
        
        self.profileButtonBox.button(QDialogButtonBox.Apply).setEnabled(enable)
    
    @pyqtSlot(str)
    def on_brokerAddressEdit_textChanged(self, address):
        """
        Private slot handling a change of the broker address.
        
        @param address broker address
        @type str
        """
        self.__updateApplyButton()
    
    @pyqtSlot()
    def on_generateIdButton_clicked(self):
        """
        Private slot to generate a client ID.
        """
        uuid = QUuid.createUuid()
        self.clientIdEdit.setText(uuid.toString(QUuid.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.__updateApplyButton()
    
    @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.__updateApplyButton()
    
    @pyqtSlot(str)
    def on_tlsCertsFilePicker_textChanged(self, path):
        """
        Private slot handling a change of the TLS CA certificates file.
        
        @param path file path
        @type str
        """
        self.__updateApplyButton()
    
    @pyqtSlot(str)
    def on_tlsSelfSignedCertsFilePicker_textChanged(self, path):
        """
        Private slot handling a change of the TLS CA certificates file.
        
        @param path file path
        @type str
        """
        self.__updateApplyButton()
    
    @pyqtSlot(str)
    def on_tlsSelfSignedClientCertFilePicker_textChanged(self, path):
        """
        Private slot handling a change of the TLS client certificate file.
        
        @param path file path
        @type str
        """
        self.__updateApplyButton()
    
    @pyqtSlot(str)
    def on_tlsSelfSignedClientKeyFilePicker_textChanged(self, path):
        """
        Private slot handling a change of the TLS client key file.
        
        @param path file path
        @type str
        """
        self.__updateApplyButton()
    
    @pyqtSlot(bool)
    def on_tlsGroupBox_toggled(self, checked):
        """
        Private slot handling the selection of TLS mode.
        
        @param checked state of the selection
        @type bool
        """
        if checked and self.brokerPortSpinBox.value() == 1883:
            # port is still standard non-TLS port
            yes = E5MessageBox.yesNo(
                self,
                self.tr("SSL/TLS Enabled"),
                self.tr(
                    """Encrypted connection using SSL/TLS has been enabled."""
                    """ However, the broker port is still the default"""
                    """ unencrypted port (port 1883). Shall this be"""
                    """ changed?"""),
                icon=E5MessageBox.Warning,
                yesDefault=True)
            if yes:
                self.brokerPortSpinBox.setValue(8883)
        elif not checked and self.brokerPortSpinBox.value() == 8883:
            # port is still standard TLS port
            yes = E5MessageBox.yesNo(
                self,
                self.tr("SSL/TLS Disabled"),
                self.tr(
                    """Encrypted connection using SSL/TLS has been disabled."""
                    """ However, the broker port is still the default"""
                    """ encrypted port (port 8883). Shall this be"""
                    """ changed?"""),
                icon=E5MessageBox.Warning,
                yesDefault=True)
            if yes:
                self.brokerPortSpinBox.setValue(1883)
        
        self.__updateApplyButton()
    
    @pyqtSlot(bool)
    def on_tlsDefaultCertsButton_toggled(self, checked):
        """
        Private slot handling the selection of using the default
        certificates file.
        
        @param checked state of the selection
        @type bool
        """
        self.__updateApplyButton()
    
    @pyqtSlot(bool)
    def on_tlsCertsFileButton_toggled(self, checked):
        """
        Private slot handling the selection of using a non-default
        certificates file.
        
        @param checked state of the selection
        @type bool
        """
        self.__updateApplyButton()
    
    @pyqtSlot(bool)
    def on_tlsSelfSignedCertsButton_toggled(self, checked):
        """
        Private slot handling the selection of using self signed
        client certificate and key files.
        
        @param checked state of the selection
        @type bool
        """
        self.__updateApplyButton()
    
    @pyqtSlot()
    def reject(self):
        """
        Public slot to reject the dialog changes.
        """
        if self.__isChangedProfile():
            button = E5MessageBox.warning(
                self,
                self.tr("Changed Connection Profile"),
                self.tr("""The current profile has unsaved changes. Shall"""
                        """ these be saved?"""),
                E5MessageBox.StandardButtons(
                    E5MessageBox.Discard |
                    E5MessageBox.Save),
                E5MessageBox.Save)
            if button == E5MessageBox.Save:
                self.__applyProfile()
                return
        
        if self.__profilesChanged:
            button = E5MessageBox.warning(
                self,
                self.tr("Changed Connection Profiles"),
                self.tr("""The list of connection profiles has unsaved"""
                        """ changes."""),
                E5MessageBox.StandardButtons(
                    E5MessageBox.Abort |
                    E5MessageBox.Discard |
                    E5MessageBox.Save),
                E5MessageBox.Save)
            if button == E5MessageBox.Save:
                super(MqttConnectionProfilesDialog, self).accept()
                return
            elif button == E5MessageBox.Abort:
                return
        
        super(MqttConnectionProfilesDialog, self).reject()
    
    @pyqtSlot()
    def accept(self):
        """
        Public slot to accept the dialog.
        """
        if self.__isChangedProfile():
            yes = E5MessageBox.yesNo(
                self,
                self.tr("Changed Connection Profile"),
                self.tr("""The current profile has unsaved changes. Shall"""
                        """ these be saved?"""),
                icon=E5MessageBox.Warning,
                yesDefault=True)
            if yes:
                self.__applyProfile()
        
        super(MqttConnectionProfilesDialog, self).accept()

eric ide

mercurial