diff -r aeb276d76ec7 -r 0f02baed8308 MqttMonitor/MqttConnectionProfilesDialog.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/MqttMonitor/MqttConnectionProfilesDialog.py Sat Sep 08 16:55:42 2018 +0200 @@ -0,0 +1,699 @@ +# -*- 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()