class Autocomplete(QComboBox): def __init__(self, parent = None): super(Autocomplete, self).__init__(parent) self.setFocusPolicy(Qt.StrongFocus) self.setEditable(True) # add a filter model to filter matching items self.pFilterModel = QSortFilterProxyModel(self) self.pFilterModel.setFilterCaseSensitivity(Qt.CaseInsensitive) self.pFilterModel.setSourceModel(self.model()) # add a completer, which uses the filter model self.completer = QCompleter(self.pFilterModel, self) # always show all (filtered) completions self.completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) self.setCompleter(self.completer) self.resize(500, 30) # connect signals def filter(text): self.pFilterModel.setFilterFixedString(str(text)) self.lineEdit().textEdited.connect(filter) self.completer.activated.connect(self.on_completer_activated) # on selection of an item from the completer, select the corresponding item from combobox def on_completer_activated(self, text): if text: index = self.findText(str(text)) self.setCurrentIndex(index)
def __init__(self, parent=None): QWidget.__init__(self, parent) self.setupUi(self) self.toolBox.setStyleSheet(style.toolBoxSS()) self.mw = mainWindow() self.txtGeneralSplitScenes.setStyleSheet(style.lineEditSS()) # TreeView to select parent # We use a proxy to display only folders proxy = QSortFilterProxyModel() proxy.setFilterKeyColumn(Outline.type) proxy.setFilterFixedString("folder") proxy.setSourceModel(self.mw.mdlOutline) self.treeGeneralParent.setModel(proxy) for i in range(1, self.mw.mdlOutline.columnCount()): self.treeGeneralParent.hideColumn(i) self.treeGeneralParent.setCurrentIndex(self.getParentIndex()) self.chkGeneralParent.toggled.connect(self.treeGeneralParent.setVisible) self.treeGeneralParent.hide()
def __init__(self, parent=None): QWidget.__init__(self, parent) self.setupUi(self) self.toolBox.setStyleSheet(style.toolBoxSS()) self.mw = mainWindow() self.txtGeneralSplitScenes.setStyleSheet(style.lineEditSS()) # TreeView to select parent # We use a proxy to display only folders proxy = QSortFilterProxyModel() proxy.setFilterKeyColumn(Outline.type) proxy.setFilterFixedString("folder") proxy.setSourceModel(self.mw.mdlOutline) self.treeGeneralParent.setModel(proxy) for i in range(1, self.mw.mdlOutline.columnCount()): self.treeGeneralParent.hideColumn(i) self.treeGeneralParent.setCurrentIndex(self.getParentIndex()) self.chkGeneralParent.toggled.connect( self.treeGeneralParent.setVisible) self.treeGeneralParent.hide()
class SelectionDialog(CustomStandardDialog, Ui_SelectionDialog): def __init__(self, source_table, selection_mode=QAbstractItemView.ExtendedSelection, selection_behavior=QAbstractItemView.SelectItems): CustomStandardDialog.__init__(self) self.setupUi(self) self.source_table = source_table self.resize(self.dataView.horizontalHeader().width(), self.height()) self.proxyModel = QSortFilterProxyModel(self) self.proxyModel.setSourceModel(source_table) self.proxyModel.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) self.proxyModel.setFilterKeyColumn(-1) self.dataView.setModel(self.proxyModel) self.dataView.setSelectionBehavior(selection_behavior) self.dataView.setSelectionMode(selection_mode) self.proxyModel.sort(0) self.dataView.selectionModel().selectionChanged.connect( self.activate_button) self.dataView.doubleClicked.connect(self.accept) self.restore_dialog_geometry() def selected_items(self): return [ self.source_table.item_from_row(x) for x in self.dataView.get_selected_rows() ] @QtCore.pyqtSlot() def activate_button(self): self.buttonBox.button(QDialogButtonBox.Ok).setEnabled( len(self.dataView.selectedIndexes())) @QtCore.pyqtSlot(str) def update_filter(self, new): self.proxyModel.setFilterFixedString(new)
class Explorer(QWidget): """ This class implements the diagram predicate node explorer. """ def __init__(self, mainwindow): """ Initialize the Explorer. :type mainwindow: MainWindow """ super().__init__(mainwindow) self.expanded = {} self.searched = {} self.scrolled = {} self.mainview = None self.iconA = QIcon(':/icons/treeview-icon-attribute') self.iconC = QIcon(':/icons/treeview-icon-concept') self.iconD = QIcon(':/icons/treeview-icon-datarange') self.iconI = QIcon(':/icons/treeview-icon-instance') self.iconR = QIcon(':/icons/treeview-icon-role') self.iconV = QIcon(':/icons/treeview-icon-value') self.search = StringField(self) self.search.setAcceptDrops(False) self.search.setClearButtonEnabled(True) self.search.setPlaceholderText('Search...') self.search.setFixedHeight(30) self.model = QStandardItemModel(self) self.proxy = QSortFilterProxyModel(self) self.proxy.setDynamicSortFilter(False) self.proxy.setFilterCaseSensitivity(Qt.CaseInsensitive) self.proxy.setSortCaseSensitivity(Qt.CaseSensitive) self.proxy.setSourceModel(self.model) self.view = ExplorerView(mainwindow, self) self.view.setModel(self.proxy) self.mainLayout = QVBoxLayout(self) self.mainLayout.setContentsMargins(0, 0, 0, 0) self.mainLayout.addWidget(self.search) self.mainLayout.addWidget(self.view) self.setContentsMargins(0, 0, 0, 0) self.setMinimumWidth(216) self.setMinimumHeight(160) connect(self.view.doubleClicked, self.itemDoubleClicked) connect(self.view.pressed, self.itemPressed) connect(self.view.collapsed, self.itemCollapsed) connect(self.view.expanded, self.itemExpanded) connect(self.search.textChanged, self.filterItem) #################################################################################################################### # # # EVENTS # # # #################################################################################################################### def paintEvent(self, paintEvent): """ This is needed for the widget to pick the stylesheet. :type paintEvent: QPaintEvent """ option = QStyleOption() option.initFrom(self) painter = QPainter(self) style = self.style() style.drawPrimitive(QStyle.PE_Widget, option, painter, self) #################################################################################################################### # # # SLOTS # # # #################################################################################################################### @pyqtSlot('QGraphicsItem') def add(self, item): """ Add a node in the tree view. :type item: AbstractItem """ if item.node and item.predicate: parent = self.parentFor(item) if not parent: parent = ParentItem(item) parent.setIcon(self.iconFor(item)) self.model.appendRow(parent) self.proxy.sort(0, Qt.AscendingOrder) child = ChildItem(item) child.setData(item) parent.appendRow(child) self.proxy.sort(0, Qt.AscendingOrder) @pyqtSlot(str) def filterItem(self, key): """ Executed when the search box is filled with data. :type key: str """ if self.mainview: self.proxy.setFilterFixedString(key) self.proxy.sort(Qt.AscendingOrder) self.searched[self.mainview] = key @pyqtSlot('QModelIndex') def itemCollapsed(self, index): """ Executed when an item in the tree view is collapsed. :type index: QModelIndex """ if self.mainview: if self.mainview in self.expanded: item = self.model.itemFromIndex(self.proxy.mapToSource(index)) expanded = self.expanded[self.mainview] expanded.remove(item.text()) @pyqtSlot('QModelIndex') def itemDoubleClicked(self, index): """ Executed when an item in the tree view is double clicked. :type index: QModelIndex """ item = self.model.itemFromIndex(self.proxy.mapToSource(index)) node = item.data() if node: self.selectNode(node) self.focusNode(node) @pyqtSlot('QModelIndex') def itemExpanded(self, index): """ Executed when an item in the tree view is expanded. :type index: QModelIndex """ if self.mainview: item = self.model.itemFromIndex(self.proxy.mapToSource(index)) if self.mainview not in self.expanded: self.expanded[self.mainview] = set() expanded = self.expanded[self.mainview] expanded.add(item.text()) @pyqtSlot('QModelIndex') def itemPressed(self, index): """ Executed when an item in the tree view is clicked. :type index: QModelIndex """ item = self.model.itemFromIndex(self.proxy.mapToSource(index)) node = item.data() if node: self.selectNode(node) @pyqtSlot('QGraphicsItem') def remove(self, item): """ Remove a node from the tree view. :type item: AbstractItem """ if item.node and item.predicate: parent = self.parentFor(item) if parent: child = self.childFor(parent, item) if child: parent.removeRow(child.index().row()) if not parent.rowCount(): self.model.removeRow(parent.index().row()) #################################################################################################################### # # # AUXILIARY METHODS # # # #################################################################################################################### @staticmethod def childFor(parent, node): """ Search the item representing this node among parent children. :type parent: QStandardItem :type node: AbstractNode """ key = ChildItem.key(node) for i in range(parent.rowCount()): child = parent.child(i) if child.text() == key: return child return None def parentFor(self, node): """ Search the parent element of the given node. :type node: AbstractNode :rtype: QStandardItem """ key = ParentItem.key(node) for i in self.model.findItems(key, Qt.MatchExactly): n = i.child(0).data() if node.item is n.item: return i return None #################################################################################################################### # # # INTERFACE # # # #################################################################################################################### def browse(self, view): """ Set the widget to inspect the given view. :type view: MainView """ self.reset() self.mainview = view if self.mainview: scene = self.mainview.scene() connect(scene.index.sgnItemAdded, self.add) connect(scene.index.sgnItemRemoved, self.remove) for item in scene.index.nodes(): self.add(item) if self.mainview in self.expanded: expanded = self.expanded[self.mainview] for i in range(self.model.rowCount()): item = self.model.item(i) index = self.proxy.mapFromSource( self.model.indexFromItem(item)) self.view.setExpanded(index, item.text() in expanded) key = '' if self.mainview in self.searched: key = self.searched[self.mainview] self.search.setText(key) if self.mainview in self.scrolled: rect = self.rect() item = first(self.model.findItems( self.scrolled[self.mainview])) for i in range(self.model.rowCount()): self.view.scrollTo( self.proxy.mapFromSource( self.model.indexFromItem(self.model.item(i)))) index = self.proxy.mapToSource( self.view.indexAt(rect.topLeft())) if self.model.itemFromIndex(index) is item: break def reset(self): """ Clear the widget from inspecting the current view. """ if self.mainview: rect = self.rect() item = self.model.itemFromIndex( self.proxy.mapToSource(self.view.indexAt(rect.topLeft()))) if item: node = item.data() key = ParentItem.key(node) if node else item.text() self.scrolled[self.mainview] = key else: self.scrolled.pop(self.mainview, None) try: scene = self.mainview.scene() disconnect(scene.index.sgnItemAdded, self.add) disconnect(scene.index.sgnItemRemoved, self.remove) except RuntimeError: pass finally: self.mainview = None self.model.clear() def flush(self, view): """ Flush the cache of the given mainview. :type view: MainView """ self.expanded.pop(view, None) self.searched.pop(view, None) self.scrolled.pop(view, None) def iconFor(self, node): """ Returns the icon for the given node. :type node: """ if node.item is Item.AttributeNode: return self.iconA if node.item is Item.ConceptNode: return self.iconC if node.item is Item.ValueDomainNode: return self.iconD if node.item is Item.ValueRestrictionNode: return self.iconD if node.item is Item.IndividualNode: if node.identity is Identity.Instance: return self.iconI if node.identity is Identity.Value: return self.iconV if node.item is Item.RoleNode: return self.iconR def focusNode(self, node): """ Focus the given node in the main view. :type node: AbstractNode """ if self.mainview: self.mainview.centerOn(node) def selectNode(self, node): """ Select the given node in the main view. :type node: AbstractNode """ if self.mainview: scene = self.mainview.scene() scene.clearSelection() node.setSelected(True)
class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self._version = "0.1.20" self.setWindowIcon(QIcon("GUI/icons/logo.png")) self.setWindowTitle("Tasmota Device Manager {}".format(self._version)) self.main_splitter = QSplitter() self.devices_splitter = QSplitter(Qt.Vertical) self.mqtt_queue = [] self.devices = {} self.fulltopic_queue = [] old_settings = QSettings() self.settings = QSettings("{}/TDM/tdm.cfg".format(QDir.homePath()), QSettings.IniFormat) self.setMinimumSize(QSize(1280, 800)) for k in old_settings.allKeys(): self.settings.setValue(k, old_settings.value(k)) old_settings.remove(k) self.device_model = TasmotaDevicesModel() self.telemetry_model = TasmotaDevicesTree() self.console_model = ConsoleModel() self.sorted_console_model = QSortFilterProxyModel() self.sorted_console_model.setSourceModel(self.console_model) self.sorted_console_model.setFilterKeyColumn(CnsMdl.FRIENDLY_NAME) self.setup_mqtt() self.setup_telemetry_view() self.setup_main_layout() self.add_devices_tab() self.build_toolbars() self.setStatusBar(QStatusBar()) self.queue_timer = QTimer() self.queue_timer.timeout.connect(self.mqtt_publish_queue) self.queue_timer.start(500) self.auto_timer = QTimer() self.auto_timer.timeout.connect(self.autoupdate) self.load_window_state() if self.settings.value("connect_on_startup", False, bool): self.actToggleConnect.trigger() def setup_main_layout(self): self.mdi = QMdiArea() self.mdi.setActivationOrder(QMdiArea.ActivationHistoryOrder) self.mdi.setViewMode(QMdiArea.TabbedView) self.mdi.setDocumentMode(True) mdi_widget = QWidget() mdi_widget.setLayout(VLayout()) mdi_widget.layout().addWidget(self.mdi) self.devices_splitter.addWidget(mdi_widget) vl_console = VLayout() hl_filter = HLayout() self.cbFilter = QCheckBox("Console filtering") self.cbxFilterDevice = QComboBox() self.cbxFilterDevice.setEnabled(False) self.cbxFilterDevice.setFixedWidth(200) self.cbxFilterDevice.setModel(self.device_model) self.cbxFilterDevice.setModelColumn(DevMdl.FRIENDLY_NAME) hl_filter.addWidgets([self.cbFilter, self.cbxFilterDevice]) hl_filter.addStretch(0) vl_console.addLayout(hl_filter) self.console_view = TableView() self.console_view.setModel(self.console_model) self.console_view.setupColumns(columns_console) self.console_view.setAlternatingRowColors(True) self.console_view.verticalHeader().setDefaultSectionSize(20) self.console_view.setMinimumHeight(200) vl_console.addWidget(self.console_view) console_widget = QWidget() console_widget.setLayout(vl_console) self.devices_splitter.addWidget(console_widget) self.main_splitter.insertWidget(0, self.devices_splitter) self.setCentralWidget(self.main_splitter) self.console_view.clicked.connect(self.select_cons_entry) self.console_view.doubleClicked.connect(self.view_payload) self.cbFilter.toggled.connect(self.toggle_console_filter) self.cbxFilterDevice.currentTextChanged.connect( self.select_console_filter) def setup_telemetry_view(self): tele_widget = QWidget() vl_tele = VLayout() self.tview = QTreeView() self.tview.setMinimumWidth(300) self.tview.setModel(self.telemetry_model) self.tview.setAlternatingRowColors(True) self.tview.setUniformRowHeights(True) self.tview.setIndentation(15) self.tview.setSizePolicy( QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)) self.tview.expandAll() self.tview.resizeColumnToContents(0) vl_tele.addWidget(self.tview) tele_widget.setLayout(vl_tele) self.main_splitter.addWidget(tele_widget) def setup_mqtt(self): self.mqtt = MqttClient() self.mqtt.connecting.connect(self.mqtt_connecting) self.mqtt.connected.connect(self.mqtt_connected) self.mqtt.disconnected.connect(self.mqtt_disconnected) self.mqtt.connectError.connect(self.mqtt_connectError) self.mqtt.messageSignal.connect(self.mqtt_message) def add_devices_tab(self): tabDevicesList = DevicesListWidget(self) self.mdi.addSubWindow(tabDevicesList) tabDevicesList.setWindowState(Qt.WindowMaximized) def load_window_state(self): wndGeometry = self.settings.value('window_geometry') if wndGeometry: self.restoreGeometry(wndGeometry) spltState = self.settings.value('splitter_state') if spltState: self.main_splitter.restoreState(spltState) def build_toolbars(self): main_toolbar = Toolbar(orientation=Qt.Horizontal, iconsize=16, label_position=Qt.ToolButtonTextBesideIcon) main_toolbar.setObjectName("main_toolbar") self.addToolBar(main_toolbar) main_toolbar.addAction(QIcon("./GUI/icons/connections.png"), "Broker", self.setup_broker) self.actToggleConnect = QAction(QIcon("./GUI/icons/disconnect.png"), "MQTT") self.actToggleConnect.setCheckable(True) self.actToggleConnect.toggled.connect(self.toggle_connect) main_toolbar.addAction(self.actToggleConnect) self.actToggleAutoUpdate = QAction(QIcon("./GUI/icons/automatic.png"), "Auto telemetry") self.actToggleAutoUpdate.setCheckable(True) self.actToggleAutoUpdate.toggled.connect(self.toggle_autoupdate) main_toolbar.addAction(self.actToggleAutoUpdate) main_toolbar.addSeparator() main_toolbar.addAction(QIcon("./GUI/icons/bssid.png"), "BSSId", self.bssid) main_toolbar.addAction(QIcon("./GUI/icons/export.png"), "Export list", self.export) def initial_query(self, idx, queued=False): for q in initial_queries: topic = "{}status".format(self.device_model.commandTopic(idx)) if queued: self.mqtt_queue.append([topic, q]) else: self.mqtt.publish(topic, q, 1) self.console_log(topic, "Asked for STATUS {}".format(q), q) def setup_broker(self): brokers_dlg = BrokerDialog() if brokers_dlg.exec_( ) == QDialog.Accepted and self.mqtt.state == self.mqtt.Connected: self.mqtt.disconnect() def toggle_autoupdate(self, state): if state: self.auto_timer.setInterval(5000) self.auto_timer.start() def toggle_connect(self, state): if state and self.mqtt.state == self.mqtt.Disconnected: self.broker_hostname = self.settings.value('hostname', 'localhost') self.broker_port = self.settings.value('port', 1883, int) self.broker_username = self.settings.value('username') self.broker_password = self.settings.value('password') self.mqtt.hostname = self.broker_hostname self.mqtt.port = self.broker_port if self.broker_username: self.mqtt.setAuth(self.broker_username, self.broker_password) self.mqtt.connectToHost() elif not state and self.mqtt.state == self.mqtt.Connected: self.mqtt_disconnect() def autoupdate(self): if self.mqtt.state == self.mqtt.Connected: for d in range(self.device_model.rowCount()): idx = self.device_model.index(d, 0) cmnd = self.device_model.commandTopic(idx) self.mqtt.publish(cmnd + "STATUS", payload=8) def mqtt_connect(self): self.broker_hostname = self.settings.value('hostname', 'localhost') self.broker_port = self.settings.value('port', 1883, int) self.broker_username = self.settings.value('username') self.broker_password = self.settings.value('password') self.mqtt.hostname = self.broker_hostname self.mqtt.port = self.broker_port if self.broker_username: self.mqtt.setAuth(self.broker_username, self.broker_password) if self.mqtt.state == self.mqtt.Disconnected: self.mqtt.connectToHost() def mqtt_disconnect(self): self.mqtt.disconnectFromHost() def mqtt_connecting(self): self.statusBar().showMessage("Connecting to broker") def mqtt_connected(self): self.actToggleConnect.setIcon(QIcon("./GUI/icons/connect.png")) self.statusBar().showMessage("Connected to {}:{} as {}".format( self.broker_hostname, self.broker_port, self.broker_username if self.broker_username else '[anonymous]')) self.mqtt_subscribe() for d in range(self.device_model.rowCount()): idx = self.device_model.index(d, 0) self.initial_query(idx) def mqtt_subscribe(self): main_topics = ["+/stat/+", "+/tele/+", "stat/#", "tele/#"] for d in range(self.device_model.rowCount()): idx = self.device_model.index(d, 0) if not self.device_model.isDefaultTemplate(idx): main_topics.append(self.device_model.commandTopic(idx)) main_topics.append(self.device_model.statTopic(idx)) for t in main_topics: self.mqtt.subscribe(t) def mqtt_publish_queue(self): for q in self.mqtt_queue: t, p = q self.mqtt.publish(t, p) self.mqtt_queue.pop(self.mqtt_queue.index(q)) def mqtt_disconnected(self): self.actToggleConnect.setIcon(QIcon("./GUI/icons/disconnect.png")) self.statusBar().showMessage("Disconnected") def mqtt_connectError(self, rc): reason = { 1: "Incorrect protocol version", 2: "Invalid client identifier", 3: "Server unavailable", 4: "Bad username or password", 5: "Not authorized", } self.statusBar().showMessage("Connection error: {}".format(reason[rc])) self.actToggleConnect.setChecked(False) def mqtt_message(self, topic, msg): found = self.device_model.findDevice(topic) if found.reply == 'LWT': if not msg: msg = "offline" if found.index.isValid(): self.console_log(topic, "LWT update: {}".format(msg), msg) self.device_model.updateValue(found.index, DevMdl.LWT, msg) self.initial_query(found.index, queued=True) elif msg == "Online": self.console_log( topic, "LWT for unknown device '{}'. Asking for FullTopic.". format(found.topic), msg, False) self.mqtt_queue.append( ["cmnd/{}/fulltopic".format(found.topic), ""]) self.mqtt_queue.append( ["{}/cmnd/fulltopic".format(found.topic), ""]) elif found.reply == 'RESULT': try: full_topic = loads(msg).get('FullTopic') new_topic = loads(msg).get('Topic') template_name = loads(msg).get('NAME') ota_url = loads(msg).get('OtaUrl') teleperiod = loads(msg).get('TelePeriod') if full_topic: # TODO: update FullTopic for existing device AFTER the FullTopic changes externally (the message will arrive from new FullTopic) if not found.index.isValid(): self.console_log( topic, "FullTopic for {}".format(found.topic), msg, False) new_idx = self.device_model.addDevice(found.topic, full_topic, lwt='online') tele_idx = self.telemetry_model.addDevice( TasmotaDevice, found.topic) self.telemetry_model.devices[found.topic] = tele_idx #TODO: add QSortFilterProxyModel to telemetry treeview and sort devices after adding self.initial_query(new_idx) self.console_log( topic, "Added {} with fulltopic {}, querying for STATE". format(found.topic, full_topic), msg) self.tview.expand(tele_idx) self.tview.resizeColumnToContents(0) elif new_topic: if found.index.isValid() and found.topic != new_topic: self.console_log( topic, "New topic for {}".format(found.topic), msg) self.device_model.updateValue(found.index, DevMdl.TOPIC, new_topic) tele_idx = self.telemetry_model.devices.get( found.topic) if tele_idx: self.telemetry_model.setDeviceName( tele_idx, new_topic) self.telemetry_model.devices[ new_topic] = self.telemetry_model.devices.pop( found.topic) elif template_name: self.device_model.updateValue( found.index, DevMdl.MODULE, "{} (0)".format(template_name)) elif ota_url: self.device_model.updateValue(found.index, DevMdl.OTA_URL, ota_url) elif teleperiod: self.device_model.updateValue(found.index, DevMdl.TELEPERIOD, teleperiod) except JSONDecodeError as e: self.console_log( topic, "JSON payload decode error. Check error.log for additional info." ) with open("{}/TDM/error.log".format(QDir.homePath()), "a+") as l: l.write("{}\t{}\t{}\t{}\n".format( QDateTime.currentDateTime().toString( "yyyy-MM-dd hh:mm:ss"), topic, msg, e.msg)) elif found.index.isValid(): ok = False try: if msg.startswith("{"): payload = loads(msg) else: payload = msg ok = True except JSONDecodeError as e: self.console_log( topic, "JSON payload decode error. Check error.log for additional info." ) with open("{}/TDM/error.log".format(QDir.homePath()), "a+") as l: l.write("{}\t{}\t{}\t{}\n".format( QDateTime.currentDateTime().toString( "yyyy-MM-dd hh:mm:ss"), topic, msg, e.msg)) if ok: try: if found.reply == 'STATUS': self.console_log(topic, "Received device status", msg) payload = payload['Status'] self.device_model.updateValue( found.index, DevMdl.FRIENDLY_NAME, payload['FriendlyName'][0]) self.telemetry_model.setDeviceFriendlyName( self.telemetry_model.devices[found.topic], payload['FriendlyName'][0]) module = payload['Module'] if module == 0: self.mqtt.publish( self.device_model.commandTopic(found.index) + "template") else: self.device_model.updateValue( found.index, DevMdl.MODULE, modules.get(module, 'Unknown')) self.device_model.updateValue(found.index, DevMdl.MODULE_ID, module) elif found.reply == 'STATUS1': self.console_log(topic, "Received program information", msg) payload = payload['StatusPRM'] self.device_model.updateValue( found.index, DevMdl.RESTART_REASON, payload.get('RestartReason')) self.device_model.updateValue(found.index, DevMdl.OTA_URL, payload.get('OtaUrl')) elif found.reply == 'STATUS2': self.console_log(topic, "Received firmware information", msg) payload = payload['StatusFWR'] self.device_model.updateValue(found.index, DevMdl.FIRMWARE, payload['Version']) self.device_model.updateValue(found.index, DevMdl.CORE, payload['Core']) elif found.reply == 'STATUS3': self.console_log(topic, "Received syslog information", msg) payload = payload['StatusLOG'] self.device_model.updateValue(found.index, DevMdl.TELEPERIOD, payload['TelePeriod']) elif found.reply == 'STATUS5': self.console_log(topic, "Received network status", msg) payload = payload['StatusNET'] self.device_model.updateValue(found.index, DevMdl.MAC, payload['Mac']) self.device_model.updateValue(found.index, DevMdl.IP, payload['IPAddress']) elif found.reply in ('STATE', 'STATUS11'): self.console_log(topic, "Received device state", msg) if found.reply == 'STATUS11': payload = payload['StatusSTS'] self.parse_state(found.index, payload) elif found.reply in ('SENSOR', 'STATUS8'): self.console_log(topic, "Received telemetry", msg) if found.reply == 'STATUS8': payload = payload['StatusSNS'] self.parse_telemetry(found.index, payload) elif found.reply.startswith('POWER'): self.console_log( topic, "Received {} state".format(found.reply), msg) payload = {found.reply: msg} self.parse_power(found.index, payload) except KeyError as k: self.console_log( topic, "JSON key error. Check error.log for additional info.") with open("{}/TDM/error.log".format(QDir.homePath()), "a+") as l: l.write("{}\t{}\t{}\tKeyError: {}\n".format( QDateTime.currentDateTime().toString( "yyyy-MM-dd hh:mm:ss"), topic, payload, k.args[0])) def parse_power(self, index, payload, from_state=False): old = self.device_model.power(index) power = { k: payload[k] for k in payload.keys() if k.startswith("POWER") } # TODO: fix so that number of relays get updated properly after module/no. of relays change needs_update = False if old: # if from_state and len(old) != len(power): # needs_update = True # # else: for k in old.keys(): needs_update |= old[k] != power.get(k, old[k]) if needs_update: break else: needs_update = True if needs_update: self.device_model.updateValue(index, DevMdl.POWER, power) def parse_state(self, index, payload): bssid = payload['Wifi'].get('BSSId') if not bssid: bssid = payload['Wifi'].get('APMac') self.device_model.updateValue(index, DevMdl.BSSID, bssid) self.device_model.updateValue(index, DevMdl.SSID, payload['Wifi']['SSId']) self.device_model.updateValue(index, DevMdl.CHANNEL, payload['Wifi'].get('Channel', "n/a")) self.device_model.updateValue(index, DevMdl.RSSI, payload['Wifi']['RSSI']) self.device_model.updateValue(index, DevMdl.UPTIME, payload['Uptime']) self.device_model.updateValue(index, DevMdl.LOADAVG, payload.get('LoadAvg')) self.device_model.updateValue(index, DevMdl.LINKCOUNT, payload['Wifi'].get('LinkCount', "n/a")) self.device_model.updateValue(index, DevMdl.DOWNTIME, payload['Wifi'].get('Downtime', "n/a")) self.parse_power(index, payload, True) tele_idx = self.telemetry_model.devices.get( self.device_model.topic(index)) if tele_idx: tele_device = self.telemetry_model.getNode(tele_idx) self.telemetry_model.setDeviceFriendlyName( tele_idx, self.device_model.friendly_name(index)) pr = tele_device.provides() for k in pr.keys(): self.telemetry_model.setData(pr[k], payload.get(k)) def parse_telemetry(self, index, payload): device = self.telemetry_model.devices.get( self.device_model.topic(index)) if device: node = self.telemetry_model.getNode(device) time = node.provides()['Time'] if 'Time' in payload: self.telemetry_model.setData(time, payload.pop('Time')) temp_unit = "C" pres_unit = "hPa" if 'TempUnit' in payload: temp_unit = payload.pop('TempUnit') if 'PressureUnit' in payload: pres_unit = payload.pop('PressureUnit') for sensor in sorted(payload.keys()): if sensor == 'DS18x20': for sns_name in payload[sensor].keys(): d = node.devices().get(sensor) if not d: d = self.telemetry_model.addDevice( DS18x20, payload[sensor][sns_name]['Type'], device) self.telemetry_model.getNode(d).setTempUnit(temp_unit) payload[sensor][sns_name]['Id'] = payload[sensor][ sns_name].pop('Address') pr = self.telemetry_model.getNode(d).provides() for pk in pr.keys(): self.telemetry_model.setData( pr[pk], payload[sensor][sns_name].get(pk)) self.tview.expand(d) elif sensor.startswith('DS18B20'): d = node.devices().get(sensor) if not d: d = self.telemetry_model.addDevice( DS18x20, sensor, device) self.telemetry_model.getNode(d).setTempUnit(temp_unit) pr = self.telemetry_model.getNode(d).provides() for pk in pr.keys(): self.telemetry_model.setData(pr[pk], payload[sensor].get(pk)) self.tview.expand(d) if sensor == 'COUNTER': d = node.devices().get(sensor) if not d: d = self.telemetry_model.addDevice( CounterSns, "Counter", device) pr = self.telemetry_model.getNode(d).provides() for pk in pr.keys(): self.telemetry_model.setData(pr[pk], payload[sensor].get(pk)) self.tview.expand(d) else: d = node.devices().get(sensor) if not d: d = self.telemetry_model.addDevice( sensor_map.get(sensor, Node), sensor, device) pr = self.telemetry_model.getNode(d).provides() if 'Temperature' in pr: self.telemetry_model.getNode(d).setTempUnit(temp_unit) if 'Pressure' in pr or 'SeaPressure' in pr: self.telemetry_model.getNode(d).setPresUnit(pres_unit) for pk in pr.keys(): self.telemetry_model.setData(pr[pk], payload[sensor].get(pk)) self.tview.expand(d) # self.tview.resizeColumnToContents(0) def console_log(self, topic, description, payload="", known=True): longest_tp = 0 longest_fn = 0 short_topic = "/".join(topic.split("/")[0:-1]) fname = self.devices.get(short_topic, "") if not fname: device = self.device_model.findDevice(topic) fname = self.device_model.friendly_name(device.index) self.devices.update({short_topic: fname}) self.console_model.addEntry(topic, fname, description, payload, known) if len(topic) > longest_tp: longest_tp = len(topic) self.console_view.resizeColumnToContents(1) if len(fname) > longest_fn: longest_fn = len(fname) self.console_view.resizeColumnToContents(1) def view_payload(self, idx): if self.cbFilter.isChecked(): idx = self.sorted_console_model.mapToSource(idx) row = idx.row() timestamp = self.console_model.data( self.console_model.index(row, CnsMdl.TIMESTAMP)) topic = self.console_model.data( self.console_model.index(row, CnsMdl.TOPIC)) payload = self.console_model.data( self.console_model.index(row, CnsMdl.PAYLOAD)) dlg = PayloadViewDialog(timestamp, topic, payload) dlg.exec_() def select_cons_entry(self, idx): self.cons_idx = idx def export(self): fname, _ = QFileDialog.getSaveFileName(self, "Export device list as...", directory=QDir.homePath(), filter="CSV files (*.csv)") if fname: if not fname.endswith(".csv"): fname += ".csv" with open(fname, "w", encoding='utf8') as f: column_titles = [ 'mac', 'topic', 'friendly_name', 'full_topic', 'cmnd_topic', 'stat_topic', 'tele_topic', 'module', 'module_id', 'firmware', 'core' ] c = csv.writer(f) c.writerow(column_titles) for r in range(self.device_model.rowCount()): d = self.device_model.index(r, 0) c.writerow([ self.device_model.mac(d), self.device_model.topic(d), self.device_model.friendly_name(d), self.device_model.fullTopic(d), self.device_model.commandTopic(d), self.device_model.statTopic(d), self.device_model.teleTopic(d), modules.get(self.device_model.module(d)), self.device_model.module(d), self.device_model.firmware(d), self.device_model.core(d) ]) def bssid(self): BSSIdDialog().exec_() # if dlg.exec_() == QDialog.Accepted: def toggle_console_filter(self, state): self.cbxFilterDevice.setEnabled(state) if state: self.console_view.setModel(self.sorted_console_model) else: self.console_view.setModel(self.console_model) def select_console_filter(self, fname): self.sorted_console_model.setFilterFixedString(fname) def closeEvent(self, e): self.settings.setValue("window_geometry", self.saveGeometry()) self.settings.setValue("splitter_state", self.main_splitter.saveState()) self.settings.sync() e.accept()
class FilteringComboBox(Widget): """Combination of QCombobox and QLineEdit with autocompletionself. Line edit and completer model is taken from QSqlTable mod Parameters: table (str): db table name containing data for combobox column (str): column name containing data for combobox color (str): 'rgb(r, g, b)' used for primary color font_size (int): default text font size in pt _model (QSqlTableModel): data model _col (int): display data model source coulumn _proxy (QSortFilterProxyModel): completer data model. _proxy.sourceModel() == _model _le (QLineEdit): QCombobox LineEdit Methods: createEditor(): (Widget): returns user input widgets value(): (str): returns user input text value setValue(value(str)): sets editor widget display value style(): (str): Returns CSS stylesheet string for input widget updateModel(): updates input widget model Args: table (str): db table name containing data for combobox column (str): column name containing data for combobox """ def __init__(self, parent, placeholderText, table, column, color='rgb(0,145,234)', image=''): self.table = table self.column = column self.color = color super().__init__(parent, placeholderText, image) self.updateModel() def createEditor(self): # setup data model self._model = QSqlTableModel() self._model.setTable(self.table) self._col = self._model.fieldIndex(self.column) # setup filter model for sorting and filtering self._proxy = QSortFilterProxyModel() self._proxy.setFilterCaseSensitivity(Qt.CaseInsensitive) self._proxy.setSourceModel(self._model) self._proxy.setFilterKeyColumn(self._col) # setup completer self._completer = QCompleter() self._completer.setModel(self._proxy) self._completer.setCompletionColumn(self._col) self._completer.setCompletionMode(QCompleter.UnfilteredPopupCompletion) # setup combobox editor = QComboBox() editor.setModel(self._proxy) editor.setModelColumn(self._col) editor.setEditable(True) editor.setFocusPolicy(Qt.StrongFocus) editor.setInsertPolicy(QComboBox.NoInsert) editor.setCompleter(self._completer) # setup connections editor.currentTextChanged[str].connect(self.onActivated) # setup editor appearence style = self.style() editor.setStyleSheet(style) editor.lineEdit().setStyleSheet(style) font = editor.font() self._completer.popup().setFont(font) return editor @pyqtSlot(str) def onActivated(self, text): print('combo_box filter text', text) if not text: # placeholder text displayed and label is not visible self._editor.setCurrentIndex(-1) if self.toggle: self._label.showLabel(False) else: # selected text is displayed and label is visible # self._editor.showPopup() # self._editor.lineEdit().setFocus() print('current copmpletion string', self._completer.currentCompletion()) self._proxy.setFilterFixedString(text) if self.toggle: self._label.showLabel(True) def style(self): """Returns stylesheet for editors Returns: style (str) """ style = """ QLineEdit {{ border: none; padding-bottom: 2px; border-bottom: 1px solid rgba(0,0,0,0.42); background-color: white; color: rgba(0,0,0,0.42); font-size: {font_size}pt;}} QLineEdit:editable {{ padding-bottom: 2px; border-bottom: 1px rgba(0,0,0,0.42); color: rgba(0,0,0,0.42);}} QLineEdit:disabled {{ border: none; padding-bottom: 2px; border-bottom: 1px rgba(0,0,0,0.42); color: rgba(0,0,0,0.38);}} QLineEdit:hover {{ padding-bottom: 2px; border-bottom: 2px solid rgba(0,0,0,0.6); color: rgba(0,0,0,0.54); }} QLineEdit:focus {{ padding-bottom: 2px; border-bottom: 2px solid {color}; color: rgba(0,0,0,0.87);}} QLineEdit:pressed {{ border-bottom: 2px {color}; font: bold; color: rgba(0,0,0,0.87)}} QComboBox {{ border: none; padding-bottom: 2px; font-size: {font_size}pt; }} QComboBox::down-arrow {{ image: url('dropdown.png'); background-color: white; border: 0px white; padding: 0px; margin: 0px; height:14px; width:14px;}} QComboBox::drop-down{{ subcontrol-position: center right; border: 0px; margin: 0px; }} QComboBox QAbstractItemView {{ font: {font_size};}} """.format(color=self.color, font_size=self._editor_font_size, dropdown=DROPDOWN_PNG) return style def updateModel(self): model = self._editor.model().sourceModel() col = self._editor.modelColumn() model.select() model.sort(col, Qt.AscendingOrder) def value(self): return self._editor.currentText() def setValue(self, value): self._editor.setCurrentText(value)
class ListViewSearch(QListView): """ An QListView with an implicit and transparent row filtering. """ def __init__(self, *a, preferred_size=None, **ak): super().__init__(*a, **ak) self.__search = QLineEdit(self, placeholderText="筛选...") self.__search.textEdited.connect(self.__setFilterString) # Use an QSortFilterProxyModel for filtering. Note that this is # never set on the view, only its rows insertes/removed signals are # connected to observe an update row hidden state. self.__pmodel = QSortFilterProxyModel( self, filterCaseSensitivity=Qt.CaseInsensitive) self.__pmodel.rowsAboutToBeRemoved.connect( self.__filter_rowsAboutToBeRemoved) self.__pmodel.rowsInserted.connect(self.__filter_rowsInserted) self.__layout() self.preferred_size = preferred_size def setFilterPlaceholderText(self, text: str): self.__search.setPlaceholderText(text) def filterPlaceholderText(self) -> str: return self.__search.placeholderText() def setFilterProxyModel(self, proxy: QSortFilterProxyModel) -> None: """ Set an instance of QSortFilterProxyModel that will be used for filtering the model. The `proxy` must be a filtering proxy only; it MUST not sort the row of the model. The FilterListView takes ownership of the proxy. """ self.__pmodel.rowsAboutToBeRemoved.disconnect( self.__filter_rowsAboutToBeRemoved) self.__pmodel.rowsInserted.disconnect(self.__filter_rowsInserted) self.__pmodel = proxy proxy.setParent(self) self.__pmodel.rowsAboutToBeRemoved.connect( self.__filter_rowsAboutToBeRemoved) self.__pmodel.rowsInserted.connect(self.__filter_rowsInserted) self.__pmodel.setSourceModel(self.model()) self.__filter_reset() def filterProxyModel(self) -> QSortFilterProxyModel: return self.__pmodel def setModel(self, model: QAbstractItemModel) -> None: super().setModel(model) self.__pmodel.setSourceModel(model) self.__filter_reset() def setRootIndex(self, index: QModelIndex) -> None: super().setRootIndex(index) self.__filter_reset() def __filter_reset(self): root = self.rootIndex() pm = self.__pmodel for i in range(self.__pmodel.rowCount(root)): self.setRowHidden(i, not pm.filterAcceptsRow(i, root)) def __setFilterString(self, string: str): self.__pmodel.setFilterFixedString(string) def setFilterString(self, string: str): """Set the filter string.""" self.__search.setText(string) self.__pmodel.setFilterFixedString(string) def filterString(self): """Return the filter string.""" return self.__search.text() def __filter_set(self, rows: Iterable[int], state: bool): for r in rows: self.setRowHidden(r, state) def __filter_rowsAboutToBeRemoved(self, parent: QModelIndex, start: int, end: int) -> None: fmodel = self.__pmodel mrange = QItemSelection(fmodel.index(start, 0, parent), fmodel.index(end, 0, parent)) mranges = fmodel.mapSelectionToSource(mrange) for mrange in mranges: self.__filter_set(range(mrange.top(), mrange.bottom() + 1), True) def __filter_rowsInserted(self, parent: QModelIndex, start: int, end: int) -> None: fmodel = self.__pmodel mrange = QItemSelection(fmodel.index(start, 0, parent), fmodel.index(end, 0, parent)) mranges = fmodel.mapSelectionToSource(mrange) for mrange in mranges: self.__filter_set(range(mrange.top(), mrange.bottom() + 1), False) def resizeEvent(self, event: QResizeEvent) -> None: super().resizeEvent(event) def updateGeometries(self) -> None: super().updateGeometries() self.__layout() def __layout(self): margins = self.viewportMargins() search = self.__search sh = search.sizeHint() size = self.size() margins.setTop(sh.height()) vscroll = self.verticalScrollBar() style = self.style() transient = style.styleHint(QStyle.SH_ScrollBar_Transient, None, vscroll) w = size.width() if vscroll.isVisibleTo(self) and not transient: w = w - vscroll.width() - 1 search.setGeometry(0, 0, w, sh.height()) self.setViewportMargins(margins) def sizeHint(self): return (self.preferred_size if self.preferred_size is not None else super().sizeHint())
class MainWindow(QMainWindow, Ui_MainWindow): """ Main window for the application with groups and password lists """ KEY_IDX = 0 # column where key is shown in password table PASSWORD_IDX = 1 # column where password is shown in password table COMMENTS_IDX = 2 # column where comments is shown in password table NO_OF_PASSWDTABLE_COLUMNS = 3 # 3 columns: key + value/passwd/secret + comments CACHE_IDX = 0 # column of QWidgetItem in whose data we cache decrypted passwords+comments def __init__(self, pwMap, settings, dbFilename): """ @param pwMap: a PasswordMap instance with encrypted passwords @param dbFilename: file name for saving pwMap """ super(MainWindow, self).__init__() self.setupUi(self) self.logger = settings.logger self.settings = settings self.pwMap = pwMap self.selectedGroup = None self.modified = False # modified flag for "Save?" question on exit self.dbFilename = dbFilename self.groupsModel = QStandardItemModel(parent=self) self.groupsModel.setHorizontalHeaderLabels([u"Password group"]) self.groupsFilter = QSortFilterProxyModel(parent=self) self.groupsFilter.setSourceModel(self.groupsModel) self.groupsTree.setModel(self.groupsFilter) self.groupsTree.setContextMenuPolicy(Qt.CustomContextMenu) self.groupsTree.customContextMenuRequested.connect(self.showGroupsContextMenu) # Dont use e following line, it would cause loadPasswordsBySelection # to be called twice on mouse-click. # self.groupsTree.clicked.connect(self.loadPasswordsBySelection) self.groupsTree.selectionModel().selectionChanged.connect(self.loadPasswordsBySelection) self.groupsTree.setSortingEnabled(True) self.passwordTable.setContextMenuPolicy(Qt.CustomContextMenu) self.passwordTable.customContextMenuRequested.connect(self.showPasswdContextMenu) self.passwordTable.setSelectionBehavior(QAbstractItemView.SelectRows) self.passwordTable.setSelectionMode(QAbstractItemView.SingleSelection) shortcut = QShortcut(QKeySequence(u"Ctrl+C"), self.passwordTable, self.copyPasswordFromSelection) shortcut.setContext(Qt.WidgetShortcut) self.actionQuit.triggered.connect(self.close) self.actionQuit.setShortcut(QKeySequence(u"Ctrl+Q")) self.actionExport.triggered.connect(self.exportCsv) self.actionImport.triggered.connect(self.importCsv) self.actionBackup.triggered.connect(self.saveBackup) self.actionAbout.triggered.connect(self.printAbout) self.actionSave.triggered.connect(self.saveDatabase) self.actionSave.setShortcut(QKeySequence(u"Ctrl+S")) # headerKey = QTableWidgetItem(u"Key") # headerValue = QTableWidgetItem(u"Password/Value") # headerComments = QTableWidgetItem(u"Comments") # self.passwordTable.setColumnCount(self.NO_OF_PASSWDTABLE_COLUMNS) # self.passwordTable.setHorizontalHeaderItem(self.KEY_IDX, headerKey) # self.passwordTable.setHorizontalHeaderItem(self.PASSWORD_IDX, headerValue) # self.passwordTable.setHorizontalHeaderItem(self.COMMENTS_IDX, headerComments) # # self.passwordTable.resizeRowsToContents() # self.passwordTable.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) # self.passwordTable.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) # self.passwordTable.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.searchEdit.textChanged.connect(self.filterGroups) if pwMap is not None: self.setPwMap(pwMap) self.clipboard = QApplication.clipboard() self.timer = QTimer(parent=self) self.timer.timeout.connect(self.clearClipboard) def setPwMap(self, pwMap): """ if not done in __init__ pwMap can be supplied later """ self.pwMap = pwMap groupNames = self.pwMap.groups.keys() for groupName in groupNames: item = QStandardItem(groupName) self.groupsModel.appendRow(item) self.groupsTree.sortByColumn(0, Qt.AscendingOrder) self.settings.mlogger.log("pwMap was initialized.", logging.DEBUG, "GUI IO") def setModified(self, modified): """ Sets the modified flag so that user is notified when exiting with unsaved changes. """ self.modified = modified self.setWindowTitle("TrezorPass" + "*" * int(self.modified)) def showGroupsContextMenu(self, point): """ Show context menu for group management. @param point: point in self.groupsTree where click occured """ self.addGroupMenu = QMenu(self) newGroupAction = QAction('Add group', self) editGroupAction = QAction('Rename group', self) deleteGroupAction = QAction('Delete group', self) self.addGroupMenu.addAction(newGroupAction) self.addGroupMenu.addAction(editGroupAction) self.addGroupMenu.addAction(deleteGroupAction) # disable deleting if no point is clicked on proxyIdx = self.groupsTree.indexAt(point) itemIdx = self.groupsFilter.mapToSource(proxyIdx) item = self.groupsModel.itemFromIndex(itemIdx) if item is None: deleteGroupAction.setEnabled(False) action = self.addGroupMenu.exec_(self.groupsTree.mapToGlobal(point)) if action == newGroupAction: self.createGroupWithCheck() elif action == editGroupAction: self.editGroupWithCheck(item) elif action == deleteGroupAction: self.deleteGroupWithCheck(item) def showPasswdContextMenu(self, point): """ Show context menu for password management @param point: point in self.passwordTable where click occured """ self.passwdMenu = QMenu(self) showPasswordAction = QAction('Show password', self) copyPasswordAction = QAction('Copy password', self) copyPasswordAction.setShortcut(QKeySequence("Ctrl+C")) showCommentsAction = QAction('Show comments', self) copyCommentsAction = QAction('Copy comments', self) showAllAction = QAction('Show all of group', self) newItemAction = QAction('New item', self) deleteItemAction = QAction('Delete item', self) editItemAction = QAction('Edit item', self) self.passwdMenu.addAction(showPasswordAction) self.passwdMenu.addAction(copyPasswordAction) self.passwdMenu.addSeparator() self.passwdMenu.addAction(showCommentsAction) self.passwdMenu.addAction(copyCommentsAction) self.passwdMenu.addSeparator() self.passwdMenu.addAction(showAllAction) self.passwdMenu.addSeparator() self.passwdMenu.addAction(newItemAction) self.passwdMenu.addAction(deleteItemAction) self.passwdMenu.addAction(editItemAction) # disable creating if no group is selected if self.selectedGroup is None: newItemAction.setEnabled(False) showAllAction.setEnabled(False) # disable deleting if no point is clicked on item = self.passwordTable.itemAt(point.x(), point.y()) if item is None: deleteItemAction.setEnabled(False) showPasswordAction.setEnabled(False) copyPasswordAction.setEnabled(False) showCommentsAction.setEnabled(False) copyCommentsAction.setEnabled(False) editItemAction.setEnabled(False) action = self.passwdMenu.exec_(self.passwordTable.mapToGlobal(point)) if action == newItemAction: self.createPassword() elif action == deleteItemAction: self.deletePassword(item) elif action == editItemAction: self.editPassword(item) elif action == copyPasswordAction: self.copyPasswordFromItem(item) elif action == showPasswordAction: self.showPassword(item) elif action == copyCommentsAction: self.copyCommentsFromItem(item) elif action == showCommentsAction: self.showComments(item) elif action == showAllAction: self.showAll() def createGroup(self, groupName, group=None): """ Slot to create a password group. """ newItem = QStandardItem(groupName) self.groupsModel.appendRow(newItem) self.pwMap.addGroup(groupName) if group is not None: self.pwMap.replaceGroup(groupName, group) # make new item selected to save a few clicks itemIdx = self.groupsModel.indexFromItem(newItem) proxyIdx = self.groupsFilter.mapFromSource(itemIdx) self.groupsTree.selectionModel().select(proxyIdx, QItemSelectionModel.ClearAndSelect | QItemSelectionModel.Rows) self.groupsTree.sortByColumn(0, Qt.AscendingOrder) # Make item's passwords loaded so new key-value entries can be created # right away - better from UX perspective. self.loadPasswords(newItem) self.setModified(True) self.settings.mlogger.log("Group '%s' was created." % (groupName), logging.DEBUG, "GUI IO") def createGroupWithCheck(self): """ Slot to create a password group. """ dialog = AddGroupDialog(self.pwMap.groups, self.settings) if not dialog.exec_(): return groupName = dialog.newGroupName() self.createGroup(groupName) def createRenamedGroupWithCheck(self, groupNameOld, groupNameNew): """ Creates a copy of a group by name as utf-8 encoded string with a new group name. A more appropriate name for the method would be: createRenamedGroup(). Since the entries inside the group are encrypted with the groupName, we cannot simply make a copy. We must decrypt with old name and afterwards encrypt with new name. If the group has many entries, each entry would require a 'Confirm' press on Trezor. So, to mkae it faster and more userfriendly we use the backup key to decrypt. This requires a single Trezor 'Confirm' press independent of how many entries there are in the group. @param groupNameOld: name of group to copy and rename @type groupNameOld: string @param groupNameNew: name of group to be created @type groupNameNew: string """ if groupNameOld not in self.pwMap.groups: raise KeyError("Password group does not exist") # with less than 3 rows dont bother the user with a pop-up rowCount = len(self.pwMap.groups[groupNameOld].entries) if rowCount < 3: self.pwMap.createRenamedGroupSecure(groupNameOld, groupNameNew) return msgBox = QMessageBox(parent=self) msgBox.setText("Do you want to use the more secure way?") msgBox.setIcon(QMessageBox.Question) msgBox.setWindowTitle("How to decrypt?") msgBox.setDetailedText("The more secure way requires pressing 'Confirm' " "on Trezor once for each entry in the group. %d presses in this " "case. This is recommended. " "Select 'Yes'.\n\n" "The less secure way requires only a single 'Confirm' click on the " "Trezor. This is not recommended. " "Select 'No'." % (rowCount)) msgBox.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msgBox.setDefaultButton(QMessageBox.Yes) res = msgBox.exec_() if res == QMessageBox.Yes: moreSecure = True else: moreSecure = False groupNew = self.pwMap.createRenamedGroup(groupNameOld, groupNameNew, moreSecure) self.settings.mlogger.log("Copy of group '%s' with new name '%s' " "was created the %s way." % (groupNameOld, groupNameNew, 'secure' if moreSecure else 'fast'), logging.DEBUG, "GUI IO") return(groupNew) def editGroup(self, item, groupNameOld, groupNameNew): """ Slot to edit name a password group. """ groupNew = self.createRenamedGroupWithCheck(groupNameOld, groupNameNew) self.deleteGroup(item) self.createGroup(groupNameNew, groupNew) self.settings.mlogger.log("Group '%s' was renamed to '%s'." % (groupNameOld, groupNameNew), logging.DEBUG, "GUI IO") def editGroupWithCheck(self, item): """ Slot to edit name a password group. """ groupNameOld = encoding.normalize_nfc(item.text()) dialog = AddGroupDialog(self.pwMap.groups, self.settings) dialog.setWindowTitle("Edit group name") dialog.groupNameLabel.setText("New name for group") dialog.setNewGroupName(groupNameOld) if not dialog.exec_(): return groupNameNew = dialog.newGroupName() self.editGroup(item, groupNameOld, groupNameNew) def deleteGroup(self, item): # without checking user groupName = encoding.normalize_nfc(item.text()) self.selectedGroup = None del self.pwMap.groups[groupName] itemIdx = self.groupsModel.indexFromItem(item) self.groupsModel.takeRow(itemIdx.row()) self.passwordTable.setRowCount(0) self.groupsTree.clearSelection() self.setModified(True) self.settings.mlogger.log("Group '%s' was deleted." % (groupName), logging.DEBUG, "GUI IO") def deleteGroupWithCheck(self, item): msgBox = QMessageBox(text="Are you sure about delete?", parent=self) msgBox.setStandardButtons(QMessageBox.Yes | QMessageBox.No) res = msgBox.exec_() if res != QMessageBox.Yes: return self.deleteGroup(item) def deletePassword(self, item): msgBox = QMessageBox(text="Are you sure about delete?", parent=self) msgBox.setStandardButtons(QMessageBox.Yes | QMessageBox.No) res = msgBox.exec_() if res != QMessageBox.Yes: return row = self.passwordTable.row(item) self.passwordTable.removeRow(row) group = self.pwMap.groups[self.selectedGroup] group.removeEntry(row) self.passwordTable.resizeRowsToContents() self.passwordTable.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.passwordTable.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.passwordTable.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.setModified(True) self.settings.mlogger.log("Row '%d' was deleted." % (row), logging.DEBUG, "GUI IO") def logCache(self, row): item = self.passwordTable.item(row, self.CACHE_IDX) cachedTuple = item.data(Qt.UserRole) if cachedTuple is None: cachedPassword, cachedComments = (None, None) else: cachedPassword, cachedComments = cachedTuple if cachedPassword is not None: cachedPassword = u'***' if cachedComments is not None: cachedComments = cachedComments[0:3] + u'...' self.settings.mlogger.log("Cache holds '%s' and '%s'." % (cachedPassword, cachedComments), logging.DEBUG, "Cache") def cachePasswordComments(self, row, password, comments): item = self.passwordTable.item(row, self.CACHE_IDX) item.setData(Qt.UserRole, QVariant((password, comments))) def cachedPassword(self, row): """ Retrieve cached password for given row of currently selected group. Returns password as string or None if no password cached. """ item = self.passwordTable.item(row, self.CACHE_IDX) cachedTuple = item.data(Qt.UserRole) if cachedTuple is None: cachedPassword, cachedComments = (None, None) else: cachedPassword, cachedComments = cachedTuple return cachedPassword def cachedComments(self, row): """ Retrieve cached comments for given row of currently selected group. Returns comments as string or None if no coments cached. """ item = self.passwordTable.item(row, self.CACHE_IDX) cachedTuple = item.data(Qt.UserRole) if cachedTuple is None: cachedPassword, cachedComments = (None, None) else: cachedPassword, cachedComments = cachedTuple return cachedComments def cachedOrDecryptPassword(self, row): """ Try retrieving cached password for item in given row, otherwise decrypt with Trezor. """ cached = self.cachedPassword(row) if cached is not None: return cached else: # decrypt with Trezor group = self.pwMap.groups[self.selectedGroup] pwEntry = group.entry(row) encPwComments = pwEntry[1] decryptedPwComments = self.pwMap.decryptPassword(encPwComments, self.selectedGroup) lngth = int(decryptedPwComments[0:4]) decryptedPassword = decryptedPwComments[4:4+lngth] decryptedComments = decryptedPwComments[4+lngth:] # while we are at it, cache the comments too self.cachePasswordComments(row, decryptedPassword, decryptedComments) self.settings.mlogger.log("Decrypted password and comments " "for '%s', row '%d'." % (pwEntry[0], row), logging.DEBUG, "GUI IO") return decryptedPassword def cachedOrDecryptComments(self, row): """ Try retrieving cached comments for item in given row, otherwise decrypt with Trezor. """ cached = self.cachedComments(row) if cached is not None: return cached else: # decrypt with Trezor group = self.pwMap.groups[self.selectedGroup] pwEntry = group.entry(row) encPwComments = pwEntry[1] decryptedPwComments = self.pwMap.decryptPassword(encPwComments, self.selectedGroup) lngth = int(decryptedPwComments[0:4]) decryptedPassword = decryptedPwComments[4:4+lngth] decryptedComments = decryptedPwComments[4+lngth:] self.cachePasswordComments(row, decryptedPassword, decryptedComments) self.settings.mlogger.log("Decrypted password and comments " "for '%s', row '%d'." % (pwEntry[0], row), logging.DEBUG, "GUI IO") return decryptedComments def showPassword(self, item): # check if this password has been decrypted, use cached version row = self.passwordTable.row(item) self.logCache(row) try: decryptedPassword = self.cachedOrDecryptPassword(row) except CallException: return item = QTableWidgetItem(decryptedPassword) self.passwordTable.setItem(row, self.PASSWORD_IDX, item) def showComments(self, item): # check if these comments has been decrypted, use cached version row = self.passwordTable.row(item) try: decryptedComments = self.cachedOrDecryptComments(row) except CallException: return item = QTableWidgetItem(decryptedComments) self.passwordTable.setItem(row, self.COMMENTS_IDX, item) def showAllSecure(self): rowCount = self.passwordTable.rowCount() for row in range(rowCount): try: decryptedPassword = self.cachedOrDecryptPassword(row) except CallException: return item = QTableWidgetItem(decryptedPassword) self.passwordTable.setItem(row, self.PASSWORD_IDX, item) try: decryptedComments = self.cachedOrDecryptComments(row) except CallException: return item = QTableWidgetItem(decryptedComments) self.passwordTable.setItem(row, self.COMMENTS_IDX, item) self.settings.mlogger.log("Showed all entries for group '%s' the secure way." % (self.selectedGroup), logging.DEBUG, "GUI IO") def showAllFast(self): try: privateKey = self.pwMap.backupKey.unwrapPrivateKey() except CallException: return group = self.pwMap.groups[self.selectedGroup] row = 0 for key, _, bkupPw in group.entries: decryptedPwComments = self.pwMap.backupKey.decryptPassword(bkupPw, privateKey) lngth = int(decryptedPwComments[0:4]) password = decryptedPwComments[4:4+lngth] comments = decryptedPwComments[4+lngth:] item = QTableWidgetItem(key) pwItem = QTableWidgetItem(password) commentsItem = QTableWidgetItem(comments) self.passwordTable.setItem(row, self.KEY_IDX, item) self.passwordTable.setItem(row, self.PASSWORD_IDX, pwItem) self.passwordTable.setItem(row, self.COMMENTS_IDX, commentsItem) self.cachePasswordComments(row, password, comments) row = row+1 self.passwordTable.resizeRowsToContents() self.passwordTable.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.passwordTable.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.passwordTable.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.settings.mlogger.log("Showed all entries for group '%s' the fast way." % (self.selectedGroup), logging.DEBUG, "GUI IO") def showAll(self): """ show all passwords and comments in plaintext in GUI can be called without any password selectedGroup a group must be selected """ # with less than 3 rows dont bother the user with a pop-up if self.passwordTable.rowCount() < 3: self.showAllSecure() return msgBox = QMessageBox(parent=self) msgBox.setText("Do you want to use the more secure way?") msgBox.setIcon(QMessageBox.Question) msgBox.setWindowTitle("How to decrypt?") msgBox.setDetailedText("The more secure way requires pressing 'Confirm' " "on Trezor once for each entry in the group. %d presses in this " "case. This is recommended. " "Select 'Yes'.\n\n" "The less secure way requires only a single 'Confirm' click on the " "Trezor. This is not recommended. " "Select 'No'." % (self.passwordTable.rowCount())) msgBox.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msgBox.setDefaultButton(QMessageBox.Yes) res = msgBox.exec_() if res == QMessageBox.Yes: self.showAllSecure() else: self.showAllFast() def createPassword(self): """ Slot to create key-value password entry. """ if self.selectedGroup is None: return group = self.pwMap.groups[self.selectedGroup] dialog = AddPasswordDialog(self.pwMap.trezor, self.settings) if not dialog.exec_(): return plainPw = dialog.pw1() plainComments = dialog.comments() if len(plainPw) + len(plainComments) > basics.MAX_SIZE_OF_PASSWDANDCOMMENTS: self.settings.mlogger.log("Password and/or comments too long. " "Combined they must not be larger than %d." % basics.MAX_SIZE_OF_PASSWDANDCOMMENTS, logging.CRITICAL, "User IO") return row = self.passwordTable.rowCount() self.passwordTable.setRowCount(row+1) item = QTableWidgetItem(dialog.key()) pwItem = QTableWidgetItem("*****") commentsItem = QTableWidgetItem("*****") self.passwordTable.setItem(row, self.KEY_IDX, item) self.passwordTable.setItem(row, self.PASSWORD_IDX, pwItem) self.passwordTable.setItem(row, self.COMMENTS_IDX, commentsItem) plainPwComments = ("%4d" % len(plainPw)) + plainPw + plainComments encPw = self.pwMap.encryptPassword(plainPwComments, self.selectedGroup) bkupPw = self.pwMap.backupKey.encryptPassword(plainPwComments) group.addEntry(dialog.key(), encPw, bkupPw) self.cachePasswordComments(row, plainPw, plainComments) self.passwordTable.resizeRowsToContents() self.passwordTable.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.passwordTable.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.passwordTable.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.setModified(True) self.settings.mlogger.log("Password and comments entry " "for '%s', row '%d' was created." % (dialog.key(), row), logging.DEBUG, "GUI IO") def editPassword(self, item): row = self.passwordTable.row(item) group = self.pwMap.groups[self.selectedGroup] try: decrypted = self.cachedOrDecryptPassword(row) decryptedComments = self.cachedOrDecryptComments(row) except CallException: return dialog = AddPasswordDialog(self.pwMap.trezor, self.settings) entry = group.entry(row) dialog.keyEdit.setText(encoding.normalize_nfc(entry[0])) dialog.pwEdit1.setText(encoding.normalize_nfc(decrypted)) dialog.pwEdit2.setText(encoding.normalize_nfc(decrypted)) doc = QTextDocument(encoding.normalize_nfc(decryptedComments), parent=self) dialog.commentsEdit.setDocument(doc) if not dialog.exec_(): return item = QTableWidgetItem(dialog.key()) pwItem = QTableWidgetItem("*****") commentsItem = QTableWidgetItem("*****") self.passwordTable.setItem(row, self.KEY_IDX, item) self.passwordTable.setItem(row, self.PASSWORD_IDX, pwItem) self.passwordTable.setItem(row, self.COMMENTS_IDX, commentsItem) plainPw = dialog.pw1() plainComments = dialog.comments() if len(plainPw) + len(plainComments) > basics.MAX_SIZE_OF_PASSWDANDCOMMENTS: self.settings.mlogger.log("Password and/or comments too long. " "Combined they must not be larger than %d." % basics.MAX_SIZE_OF_PASSWDANDCOMMENTS, logging.CRITICAL, "User IO") return plainPwComments = ("%4d" % len(plainPw)) + plainPw + plainComments encPw = self.pwMap.encryptPassword(plainPwComments, self.selectedGroup) bkupPw = self.pwMap.backupKey.encryptPassword(plainPwComments) group.updateEntry(row, dialog.key(), encPw, bkupPw) self.cachePasswordComments(row, plainPw, plainComments) self.setModified(True) self.settings.mlogger.log("Password and comments entry " "for '%s', row '%d' was edited." % (dialog.key(), row), logging.DEBUG, "GUI IO") def copyPasswordFromSelection(self): """ Copy selected password to clipboard. Password is decrypted if necessary. """ indexes = self.passwordTable.selectedIndexes() if not indexes: return # there will be more indexes as the selection is on a row row = indexes[0].row() item = self.passwordTable.item(row, self.PASSWORD_IDX) self.copyPasswordFromItem(item) def copyPasswordFromItem(self, item): row = self.passwordTable.row(item) try: decryptedPassword = self.cachedOrDecryptPassword(row) except CallException: return self.clipboard.setText(decryptedPassword) # Do not log contents of clipboard, contains secrets! self.settings.mlogger.log("Copied text to clipboard.", logging.DEBUG, "Clipboard") if basics.CLIPBOARD_TIMEOUT_IN_SEC > 0: self.timer.start(basics.CLIPBOARD_TIMEOUT_IN_SEC*1000) # cancels previous timer def copyCommentsFromItem(self, item): row = self.passwordTable.row(item) try: decryptedComments = self.cachedOrDecryptComments(row) except CallException: return self.clipboard.setText(decryptedComments) # Do not log contents of clipboard, contains secrets! self.settings.mlogger.log("Copied text to clipboard.", logging.DEBUG, "Clipboard") if basics.CLIPBOARD_TIMEOUT_IN_SEC > 0: self.timer.start(basics.CLIPBOARD_TIMEOUT_IN_SEC*1000) # cancels previous timer def clearClipboard(self): self.clipboard.clear() self.timer.stop() # cancels previous timer self.settings.mlogger.log("Clipboard cleared.", logging.DEBUG, "Clipboard") def loadPasswords(self, item): """ Slot that should load items for group that has been clicked on. """ self.passwordTable.clear() # clears cahce, but also clears the header, the 3 titles headerKey = QTableWidgetItem(u"Key") headerValue = QTableWidgetItem(u"Password/Value") headerComments = QTableWidgetItem(u"Comments") self.passwordTable.setColumnCount(self.NO_OF_PASSWDTABLE_COLUMNS) self.passwordTable.setHorizontalHeaderItem(self.KEY_IDX, headerKey) self.passwordTable.setHorizontalHeaderItem(self.PASSWORD_IDX, headerValue) self.passwordTable.setHorizontalHeaderItem(self.COMMENTS_IDX, headerComments) groupName = encoding.normalize_nfc(item.text()) self.selectedGroup = groupName group = self.pwMap.groups[groupName] self.passwordTable.setRowCount(len(group.entries)) i = 0 for key, encValue, bkupValue in group.entries: item = QTableWidgetItem(key) pwItem = QTableWidgetItem("*****") commentsItem = QTableWidgetItem("*****") self.passwordTable.setItem(i, self.KEY_IDX, item) self.passwordTable.setItem(i, self.PASSWORD_IDX, pwItem) self.passwordTable.setItem(i, self.COMMENTS_IDX, commentsItem) i = i+1 self.passwordTable.resizeRowsToContents() self.passwordTable.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) self.passwordTable.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) self.passwordTable.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) self.settings.mlogger.log("Loaded password group '%s'." % (groupName), logging.DEBUG, "GUI IO") def loadPasswordsBySelection(self): proxyIdx = self.groupsTree.currentIndex() itemIdx = self.groupsFilter.mapToSource(proxyIdx) selectedItem = self.groupsModel.itemFromIndex(itemIdx) if not selectedItem: return self.loadPasswords(selectedItem) def filterGroups(self, substring): """ Filter groupsTree view to have items containing given substring. """ self.groupsFilter.setFilterFixedString(substring) self.groupsTree.sortByColumn(0, Qt.AscendingOrder) def printAbout(self): """ Show window with about and version information. """ msgBox = QMessageBox(QMessageBox.Information, "About", "About <b>TrezorPass</b>: <br><br>TrezorPass is a safe " + "Password Manager application for people owning a Trezor who prefer to " + "keep their passwords local and not on the cloud. All passwords are " + "stored locally in a single file.<br><br>" + "<b>" + basics.NAME + " Version: </b>" + basics.VERSION_STR + " from " + basics.VERSION_DATE_STR + "<br><br><b>Python Version: </b>" + sys.version.replace(" \n", "; ") + "<br><br><b>Qt Version: </b>" + QT_VERSION_STR + "<br><br><b>PyQt Version: </b>" + PYQT_VERSION_STR, parent=self) msgBox.setIconPixmap(QPixmap("icons/TrezorPass.svg")) msgBox.exec_() def saveBackup(self): """ First it saves any pending changes to the pwdb database file. Then it uses an operating system call to copy the file appending a timestamp at the end of the file name. """ if self.modified: self.saveDatabase() backupFilename = self.settings.dbFilename + u"." + time.strftime('%Y%m%d%H%M%S') copyfile(self.settings.dbFilename, backupFilename) self.settings.mlogger.log("Backup of the encrypted database file has been created " "and placed into file \"%s\" (%d bytes)." % (backupFilename, os.path.getsize(backupFilename)), logging.INFO, "User IO") def importCsv(self): """ Read a properly formated CSV file from disk and add its contents to the current entries. Import format in CSV should be : group, key, password, comments There is no error checking, so be extra careful. Make a backup first. Entries from CSV will be *added* to existing pwdb. If this is not desired create an empty pwdb file first. GroupNames are unique, so if a groupname exists then key-password-comments tuples are added to the already existing group. If a group name does not exist, a new group is created and the key-password-comments tuples are added to the newly created group. Keys are not unique. So key-password-comments are always added. If a key with a given name existed before and the CSV file contains a key with the same name, then the key-password-comments is added and after the import the given group has 2 keys with the same name. Both keys exist then, the old from before the import, and the new one from the import. Examples of valid CSV file format: Some example lines First Bank account,login,myloginname, # no comment [email protected],2-factor-authentication key,abcdef12345678,seed to regenerate 2FA codes # with comment [email protected],recovery phrase,"passwd with 2 commas , ,", # with comma [email protected],large multi-line comments,,"first line, some comma, second line" """ if self.modified: self.saveDatabase() copyfile(self.settings.dbFilename, self.settings.dbFilename + ".beforeCsvImport.backup") self.settings.mlogger.log("WARNING: You are about to import entries from a " "CSV file into your current password-database file. For safety " "reasons please make a backup copy now.\nFurthermore, this" "operation can be slow, so please be patient.", logging.NOTSET, "CSV import") dialog = QFileDialog(self, "Select CSV file to import", "", "CSV files (*.csv)") dialog.setAcceptMode(QFileDialog.AcceptOpen) res = dialog.exec_() if not res: return fname = encoding.normalize_nfc(dialog.selectedFiles()[0]) listOfAddedGroupNames = self.pwMap.importCsv(fname) for groupNameNew in listOfAddedGroupNames: item = QStandardItem(groupNameNew) self.groupsModel.appendRow(item) self.groupsTree.sortByColumn(0, Qt.AscendingOrder) self.setModified(True) def exportCsv(self): """ Uses backup key encrypted by Trezor to decrypt all passwords at once and export them into a single paintext CSV file. Export format is CSV: group, key, password, comments """ self.settings.mlogger.log("WARNING: During backup/export all passwords will be " "written in plaintext to disk. If possible you should consider performing this " "operation on an offline or air-gapped computer. Be aware of the risks.", logging.NOTSET, "CSV export") dialog = QFileDialog(self, "Select backup export file", "", "CSV files (*.csv)") dialog.setAcceptMode(QFileDialog.AcceptSave) res = dialog.exec_() if not res: return fname = encoding.normalize_nfc(dialog.selectedFiles()[0]) self.pwMap.exportCsv(fname) def saveDatabase(self): """ Save main database file. """ self.pwMap.save(self.dbFilename) self.setModified(False) self.settings.mlogger.log("TrezorPass password database file was " "saved to '%s'." % (self.dbFilename), logging.DEBUG, "GUI IO") def closeEvent(self, event): if self.modified: msgBox = QMessageBox(text="Password database is modified. Save on exit?", parent=self) msgBox.setStandardButtons(QMessageBox.Yes | QMessageBox.No | QMessageBox.Cancel) reply = msgBox.exec_() if not reply or reply == QMessageBox.Cancel: event.ignore() return elif reply == QMessageBox.Yes: self.saveDatabase() event.accept()
class DictionaryEditorWidget(QDialog): userDictPath = os.path.join('files', 'DICT', 'User.DICT') pyboDictPath = os.path.join('files', 'DICT', 'Tibetan.DICT') def __init__(self, parent=None): super().__init__(parent) self.parent = parent self.pyboDict = self.getPyboDict() self.userDict = self.getUserDict() self.tokens = [] self.resize(400, 600) self.setWindowTitle('Dictionary Editor') self.setupTable() self.initUI() @property def dict(self): mergedDict = self.pyboDict.copy() for key, value in self.userDict.items(): removed = False if key.startswith('--'): removed = True key = key[2:] if removed: mergedDict.pop(key) else: mergedDict[key] = value return mergedDict def setupTable(self): self.model = TableModel(parent=self, header=('Text', 'Tag'), data=[[k, v] for k, v in self.dict.items()]) self.proxyModel = QSortFilterProxyModel() self.proxyModel.setSourceModel(self.model) self.tableView = QTableView() self.tableView.horizontalHeader().setSectionResizeMode( QHeaderView.Stretch) self.tableView.setFixedHeight(500) self.tableView.setModel(self.proxyModel) def initUI(self): self.searchLabel = QLabel() self.searchLabel.setPixmap( QIcon('files/searching').pixmap(QSize(30, 30))) self.searchField = QLineEdit() self.searchField.textChanged.connect(self.search) hbox = QHBoxLayout() hbox.addWidget(self.searchLabel) hbox.addWidget(self.searchField) self.addButton = QPushButton() self.addButton.setFlat(True) self.addButton.setIcon(QIcon('files/add.png')) self.addButton.setIconSize(QSize(30, 30)) self.addButton.clicked.connect(self.addWord) self.removeButton = QPushButton() self.removeButton.setFlat(True) self.removeButton.setIcon(QIcon('files/delete.png')) self.removeButton.setIconSize(QSize(30, 30)) self.removeButton.clicked.connect(self.removeWord) hbox2 = QHBoxLayout() hbox2.addWidget(self.addButton) hbox2.addStretch() hbox2.addWidget(self.removeButton) self.fbox = QFormLayout() self.fbox.addRow(hbox) self.fbox.addRow(hbox2) self.fbox.addRow(self.tableView) self.setLayout(self.fbox) def search(self, text): self.proxyModel.setFilterKeyColumn(-1) # -1 means all cols self.proxyModel.setFilterFixedString(text) def getPyboDict(self): if not os.path.isfile(self.pyboDictPath): self.downloadPyboDict() return self.getDict(self.pyboDictPath) def downloadPyboDict(self): import pkg_resources resourcePkg = 'pybo' resourcePath = '/'.join(('resources', 'trie', 'Tibetan.DICT')) reader = pkg_resources.resource_stream(resourcePkg, resourcePath) file = reader.read() reader.close() with open(self.pyboDictPath, 'w', encoding='UTF-8') as f: f.write(file.decode()) def getUserDict(self): if not os.path.isfile(self.userDictPath): with open(self.userDictPath, 'w', encoding='UTF-8') as f: f.write('') return self.getDict(self.userDictPath) def getDict(self, filename): dictionary = OrderedDict() with open(filename, 'r', encoding='UTF-8') as f: rows = f.readlines() for row in rows: content, tag = row.split() dictionary[content] = tag return dictionary def removeWord(self): rows = sorted( set(index.row() for index in self.tableView.selectionModel().selectedIndexes())) for row in reversed(rows): self.model.data.pop(row) self.model.saveDict() def addWord(self): self.model.data.insert(0, ['...', '...']) self.model.layoutChanged.emit() def getAllTags(self): pyboTags = set(value for key, value in self.pyboDict.items()) userTags = set(value for key, value in self.getUserDict().items()) return pyboTags | userTags
class MainModel(): '''Data model for pyspector.''' def __init__(self): '''Initializes a MainModel instance.''' self._searchText = '' self._matchCase = False self._includePrivateMembers = False self._includeInheritedMembers = False self._sortByType = True # Initialize icons. iconDir = f'{dirname(dirname(__file__))}/icons' self._icons = { 'module': QIcon(f'{iconDir}/module.svg'), 'abstract base class': QIcon(f'{iconDir}/abstract.svg'), 'class': QIcon(f'{iconDir}/class.svg'), 'function': QIcon(f'{iconDir}/function.svg'), 'property': QIcon(f'{iconDir}/property.svg'), 'object': QIcon(f'{iconDir}/object.svg') } # Create the unfiltered tree model. self._treeModel = QStandardItemModel() # Create regular expressions that exclude or include private members. self._excludePrivateRegEx = QRegularExpression('^[^_]|^__') self._includePrivateRegEx = QRegularExpression('') # Create regular expressions that exclude or include inherited members. self._excludeInheritedRegEx = QRegularExpression('^$') self._includeInheritedRegEx = QRegularExpression('') # Create a filtered tree model used to exclude or include private members. self._intermediateTreeModel = QSortFilterProxyModel() self._intermediateTreeModel.setSourceModel(self._treeModel) privateRegEx = self._includePrivateRegEx if self._includePrivateMembers else self._excludePrivateRegEx self._intermediateTreeModel.setFilterRegularExpression(privateRegEx) # Create a filtered tree model used to exclude or include inherited members. self._secondIntermediateTreeModel = QSortFilterProxyModel() self._secondIntermediateTreeModel.setSourceModel(self._intermediateTreeModel) self._secondIntermediateTreeModel.setFilterKeyColumn(2) inheritedRegEx = self._includeInheritedRegEx if self._includeInheritedMembers else self._excludeInheritedRegEx self._secondIntermediateTreeModel.setFilterRegularExpression(inheritedRegEx) # Create a filtered tree model that matches the search text. self._filteredTreeModel = QSortFilterProxyModel() self._filteredTreeModel.setSourceModel(self._secondIntermediateTreeModel) self._filteredTreeModel.setRecursiveFilteringEnabled(True) self._filteredTreeModel.setFilterFixedString(self._searchText) self._filteredTreeModel.setFilterCaseSensitivity(1 if self._matchCase else 0) @property def filteredTreeModel(self) -> QSortFilterProxyModel: '''The filtered version of the tree model.''' return self._filteredTreeModel @property def searchText(self) -> str: '''The current search text.''' return self._searchText @searchText.setter def searchText(self, value: str) -> None: self._searchText = value self._filteredTreeModel.setFilterFixedString(value) @property def matchCase(self) -> bool: '''Whether or not case-sensitive matching is used.''' return self._matchCase @matchCase.setter def matchCase(self, value: bool) -> None: self._matchCase = value self._filteredTreeModel.setFilterCaseSensitivity(1 if value else 0) @property def includePrivateMembers(self) -> bool: '''Whether or not private members (beginning with "_") are included in the tree.''' return self._includePrivateMembers @includePrivateMembers.setter def includePrivateMembers(self, value: bool) -> None: self._includePrivateMembers = value regEx = self._includePrivateRegEx if value else self._excludePrivateRegEx self._intermediateTreeModel.setFilterRegularExpression(regEx) @property def includeInheritedMembers(self) -> bool: '''Whether or not inherited members are included in the tree.''' return self._includeInheritedMembers @includeInheritedMembers.setter def includeInheritedMembers(self, value: bool) -> None: self._includeInheritedMembers = value regEx = self._includeInheritedRegEx if value else self._excludeInheritedRegEx self._secondIntermediateTreeModel.setFilterRegularExpression(regEx) @property def sortByType(self) -> bool: '''Whether or not members are sorted by type.''' return self._sortByType @sortByType.setter def sortByType(self, value: bool) -> None: self._sortByType = value self._sort() def setModuleNames(self, moduleNames) -> None: '''Adds the specified modules to the tree, removing any that are no longer needed.''' # Remove any modules that aren't in the list. rootItem: QStandardItem = self._treeModel.invisibleRootItem() for i in range(rootItem.rowCount() - 1, -1, -1): if rootItem.child(i).text() not in moduleNames: rootItem.removeRow(i) # Add all the modules in the list. for moduleName in moduleNames: self._addModule(moduleName) # Sort. self._sort() def _sort(self) -> None: # Sort all items alphabetically by name. self._treeModel.sort(0) # Optionally, sort by type. if self.sortByType: self._treeModel.sort(1) def _dumpTree(self, parentIndex = QModelIndex(), depth = 0) -> None: rowCount = self._treeModel.rowCount(parentIndex) for row in range(rowCount): index = self._treeModel.index(row, 0, parentIndex) item = self._treeModel.itemFromIndex(index) indent = ' ' * depth id = item.data()['id'] print(f'{indent}{id}') self._dumpTree(index, depth + 1) def findItemByName(self, name: str) -> QModelIndex: '''Finds the item with the specified name.''' # Create search predicates for matching and containing name. if self.matchCase: itemHasName = lambda item: item.text() == name itemContainsName = lambda item: name in item.text() else: nameNoCase = name.casefold() itemHasName = lambda item: item.text().casefold() == nameNoCase itemContainsName = lambda item: nameNoCase in item.text().casefold() # Try for a full match, then a partial match. for predicate in [itemHasName, itemContainsName]: index = utilities.findIndexInModel(self._filteredTreeModel, predicate) if index.isValid(): break return index def findItemById(self, id: str) -> QModelIndex: '''Finds the item with the specified ID.''' print(f'looking for item with id {id}') predicate = lambda item: item.data()['id'] == id index = utilities.findIndexInModel(self._filteredTreeModel, predicate) return index def _addModule(self, moduleName, depth = 0): # Check to see if module has already been added. rootItem = self._treeModel.invisibleRootItem() if self._parentContainsItem(rootItem, moduleName): return try: module = importlib.import_module(moduleName) item = self._addItem(rootItem, moduleName, moduleName, 'module', module) self._inspectObject(item, module, depth) except: self._addItem(rootItem, moduleName, moduleName, 'module', None, error = 'Could not import module.') def _parentContainsItem(self, parentItem: QStandardItem, id: str) -> bool: for row in range(parentItem.rowCount()): childId = parentItem.child(row).data()['id'] if childId == id: return True return False def _inspectObject(self, parentItem: QStandardItem, obj: object, depth: int) -> None: '''Recursively adds object to the hierarchical model.''' for (memberName, memberValue) in inspect.getmembers(obj): memberType = self._getMemberType(memberValue) # Skip "magic" members that are classes -- they cause problems. if memberName.startswith('__') and memberType == 'class': continue # Skip modules within modules. if memberType == 'module': # TODO: Should we add nested modules? Seems useful, but leads to a segfault in the # case of matplotlib. #print(f'{" "*depth}adding nested module {memberName}: {memberValue.__name__}') #self._addModule(memberValue, depth + 1) continue # Don't add the same item twice. parentId = parentItem.data()['id'] id = f'{parentId}/{memberName}' if self._parentContainsItem(parentItem, id): continue # Check inheritance of class members. inheritance = 'inherited' if inspect.isclass(obj) and memberName not in obj.__dict__ else '' # For functions, try to include the signature in the name. name = memberName if memberType == 'function': try: name += str(inspect.signature(memberValue)) except: pass # Add an item for the current member. item = self._addItem(parentItem, id, name, memberType, memberValue, inheritance) # Recurse into classes (but not if it's the same class we're inspecting). if 'class' in memberType and memberValue != obj: print(f'{" "*depth}inspecting class {memberName} in module {memberValue.__module__}') self._inspectObject(item, memberValue, depth + 1) # Recurse into property getter, setter, deleter functions. # TODO: Generalize this to data descriptors other than just the 'property' class. if type(memberValue) == property: if memberValue.fget: self._addItem(item, f'{id}/get', '[get]', 'function', memberValue.fget) if memberValue.fset: self._addItem(item, f'{id}/set', '[set]', 'function', memberValue.fset) if memberValue.fdel: self._addItem(item, f'{id}/delete', '[delete]', 'function', memberValue.fdel) def _addItem(self, parentItem: QStandardItem, id: str, name: str, type: str, value: object, inheritance: str = '', error: str = '') -> QStandardItem: '''Adds one model item to a parent model item.''' key = type if type in self._icons else 'object' item1 = QStandardItem(self._icons[key], name) item1.setData({ 'id': id, 'type': type, 'value': value, 'error': error }) item1.setEditable(False) if len(error): item1.setBackground(QBrush(QColor(255, 0, 0, 64))) item2 = QStandardItem(type) item2.setEditable(False) item3 = QStandardItem(inheritance) item3.setEditable(False) parentItem.appendRow([item1, item2, item3]) return item1 def _getMemberType(self, memberValue: object) -> str: '''Attempts to determine the type of a member from its value.''' if inspect.ismodule(memberValue): return 'module' if inspect.isabstract(memberValue): return 'abstract base class' if inspect.isclass(memberValue): return 'class' if inspect.isfunction(memberValue) or inspect.isbuiltin(memberValue) or inspect.isroutine(memberValue): return 'function' if inspect.isdatadescriptor(memberValue): return 'property' return 'object'
class DictionaryEditorWidget(QDialog): def __init__(self, parent=None): super().__init__(parent) self.parent = parent self.pyboDict = dict() self.initPyboDict() self.tokens = [] self.resize(400, 600) self.setWindowTitle('Dictionary Editor') self.setupTable() self.initUI() def getDict(self): mergedDict = self.pyboDict.copy() # mergedDict = {'ཉམ་ཐག་པ': 'VERB'} for token in Token.objects.all(): if token.type == Token.TYPE_UPDATE: mergedDict[token.text] = token.pos else: # Dict.ACTION_DELETE del mergedDict[token.text] return mergedDict def setupTable(self): dict = self.getDict() self.model = TableModel(parent=self, header=('Text', 'Tag'), data=[[k, v] for k, v in dict.items()]) # print(self.model.bt.has_word('ནང་ཆོས་')) self.proxyModel = QSortFilterProxyModel() self.proxyModel.eventFilter = lambda: self.removeButton.setDisabled( True) self.proxyModel.setSourceModel(self.model) self.tableView = QTableView() self.tableView.horizontalHeader().setSectionResizeMode( QHeaderView.Stretch) self.tableView.setFixedHeight(500) self.tableView.setModel(self.proxyModel) def initUI(self): self.searchLabel = QLabel() self.searchLabel.setPixmap( QIcon(os.path.join(BASE_DIR, 'icons', 'searching.png')).pixmap(QSize(20, 20))) self.searchField = QLineEdit() self.searchField.textChanged.connect(self.search) hbox = QHBoxLayout() hbox.addWidget(self.searchLabel) hbox.addWidget(self.searchField) self.addButton = QPushButton() self.addButton.setFlat(True) self.addButton.setIcon( QIcon(os.path.join(BASE_DIR, 'icons', 'add.png'))) self.addButton.setIconSize(QSize(30, 30)) self.addButton.clicked.connect(self.addWord) self.removeButton = QPushButton() self.removeButton.setFlat(True) self.removeButton.setIcon( QIcon(os.path.join(BASE_DIR, "icons", "delete.png"))) self.removeButton.setIconSize(QSize(30, 30)) self.removeButton.clicked.connect(self.removeWord) hbox2 = QHBoxLayout() hbox2.addWidget(self.addButton) hbox2.addStretch() hbox2.addWidget(self.removeButton) self.fbox = QFormLayout() self.fbox.addRow(hbox) self.fbox.addRow(hbox2) self.fbox.addRow(self.tableView) self.setLayout(self.fbox) def search(self, text): self.proxyModel.setFilterKeyColumn(-1) # -1 means all cols self.proxyModel.setFilterFixedString(text) def initPyboDict(self): # import pkg_resources # resourcePkg = 'pybo' # resourcePath = '/'.join(('resources', 'lexica_bo', 'Tibetan.DICT')) # reader = pkg_resources.resource_stream(resourcePkg, resourcePath) resourcePath = '/'.join( ('resources', 'dictionaries', 'lexica_bo', 'Tibetan.DICT')) reader = open(resourcePath, mode="r", encoding="utf-8", newline="") file = reader.read() for line in file.splitlines(): key, val = line.split() self.pyboDict[key] = val # file.decode() reader.close() def removeWord(self): rows = sorted( set(index.row() for index in self.tableView.selectionModel().selectedIndexes())) for row in reversed(rows): self.model.data.pop(row) self.model.saveDict() def addWord(self, text=None): if text is not None: self.model.data.insert(0, [text, '']) else: self.model.data.insert(0, ['', '']) self.model.layoutChanged.emit() def getAllTags(self): return set(self.getDict().values())
class MainWindow(QMainWindow): def __init__(self, *args, **kwargs): super(MainWindow, self).__init__(*args, **kwargs) self._version = "0.1.11" self.setWindowIcon(QIcon("GUI/icons/logo.png")) self.setWindowTitle("Tasmota Device Manager {}".format(self._version)) self.main_splitter = QSplitter() self.devices_splitter = QSplitter(Qt.Vertical) self.fulltopic_queue = [] self.settings = QSettings() self.setMinimumSize(QSize(1280,800)) self.device_model = TasmotaDevicesModel() self.telemetry_model = TasmotaDevicesTree() self.console_model = ConsoleModel() self.sorted_console_model = QSortFilterProxyModel() self.sorted_console_model.setSourceModel(self.console_model) self.sorted_console_model.setFilterKeyColumn(CnsMdl.FRIENDLY_NAME) self.setup_mqtt() self.setup_telemetry_view() self.setup_main_layout() self.add_devices_tab() self.build_toolbars() self.setStatusBar(QStatusBar()) self.queue_timer = QTimer() self.queue_timer.setSingleShot(True) self.queue_timer.timeout.connect(self.mqtt_ask_for_fulltopic) self.build_cons_ctx_menu() self.load_window_state() def setup_main_layout(self): self.mdi = QMdiArea() self.mdi.setActivationOrder(QMdiArea.ActivationHistoryOrder) self.mdi.setViewMode(QMdiArea.TabbedView) self.mdi.setDocumentMode(True) mdi_widget = QWidget() mdi_widget.setLayout(VLayout()) mdi_widget.layout().addWidget(self.mdi) self.devices_splitter.addWidget(mdi_widget) vl_console = VLayout() self.console_view = TableView() self.console_view.setModel(self.sorted_console_model) self.console_view.setupColumns(columns_console) self.console_view.setAlternatingRowColors(True) self.console_view.setSortingEnabled(True) self.console_view.sortByColumn(CnsMdl.TIMESTAMP, Qt.DescendingOrder) self.console_view.verticalHeader().setDefaultSectionSize(20) self.console_view.setMinimumHeight(200) self.console_view.setContextMenuPolicy(Qt.CustomContextMenu) vl_console.addWidget(self.console_view) console_widget = QWidget() console_widget.setLayout(vl_console) self.devices_splitter.addWidget(console_widget) self.main_splitter.insertWidget(0, self.devices_splitter) self.setCentralWidget(self.main_splitter) self.console_view.clicked.connect(self.select_cons_entry) self.console_view.doubleClicked.connect(self.view_payload) self.console_view.customContextMenuRequested.connect(self.show_cons_ctx_menu) def setup_telemetry_view(self): tele_widget = QWidget() vl_tele = VLayout() self.tview = QTreeView() self.tview.setMinimumWidth(300) self.tview.setModel(self.telemetry_model) self.tview.setAlternatingRowColors(True) self.tview.setUniformRowHeights(True) self.tview.setIndentation(15) self.tview.setSizePolicy(QSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.Minimum)) self.tview.expandAll() self.tview.resizeColumnToContents(0) vl_tele.addWidget(self.tview) tele_widget.setLayout(vl_tele) self.main_splitter.addWidget(tele_widget) def setup_mqtt(self): self.mqtt = MqttClient() self.mqtt.connecting.connect(self.mqtt_connecting) self.mqtt.connected.connect(self.mqtt_connected) self.mqtt.disconnected.connect(self.mqtt_disconnected) self.mqtt.connectError.connect(self.mqtt_connectError) self.mqtt.messageSignal.connect(self.mqtt_message) def add_devices_tab(self): tabDevicesList = DevicesListWidget(self) self.mdi.addSubWindow(tabDevicesList) tabDevicesList.setWindowState(Qt.WindowMaximized) def load_window_state(self): wndGeometry = self.settings.value('window_geometry') if wndGeometry: self.restoreGeometry(wndGeometry) spltState = self.settings.value('splitter_state') if spltState: self.main_splitter.restoreState(spltState) def build_toolbars(self): main_toolbar = Toolbar(orientation=Qt.Horizontal, iconsize=32, label_position=Qt.ToolButtonIconOnly) main_toolbar.setObjectName("main_toolbar") self.addToolBar(main_toolbar) main_toolbar.addAction(QIcon("./GUI/icons/connections.png"), "Configure MQTT broker", self.setup_broker) agBroker = QActionGroup(self) agBroker.setExclusive(True) self.actConnect = CheckableAction(QIcon("./GUI/icons/connect.png"), "Connect to the broker", agBroker) self.actDisconnect = CheckableAction(QIcon("./GUI/icons/disconnect.png"), "Disconnect from broker", agBroker) self.actDisconnect.setChecked(True) self.actConnect.triggered.connect(self.mqtt_connect) self.actDisconnect.triggered.connect(self.mqtt_disconnect) main_toolbar.addActions(agBroker.actions()) main_toolbar.addSeparator() def initial_query(self, idx): for q in initial_queries: topic = "{}status".format(self.device_model.commandTopic(idx)) self.mqtt.publish(topic, q) q = q if q else '' self.console_log(topic, "Asked for STATUS {}".format(q), q) def setup_broker(self): brokers_dlg = BrokerDialog() if brokers_dlg.exec_() == QDialog.Accepted and self.mqtt.state == self.mqtt.Connected: self.mqtt.disconnect() def mqtt_connect(self): self.broker_hostname = self.settings.value('hostname', 'localhost') self.broker_port = self.settings.value('port', 1883, int) self.broker_username = self.settings.value('username') self.broker_password = self.settings.value('password') self.mqtt.hostname = self.broker_hostname self.mqtt.port = self.broker_port if self.broker_username: self.mqtt.setAuth(self.broker_username, self.broker_password) if self.mqtt.state == self.mqtt.Disconnected: self.mqtt.connectToHost() def mqtt_disconnect(self): self.mqtt.disconnectFromHost() def mqtt_connecting(self): self.statusBar().showMessage("Connecting to broker") def mqtt_connected(self): self.statusBar().showMessage("Connected to {}:{} as {}".format(self.broker_hostname, self.broker_port, self.broker_username if self.broker_username else '[anonymous]')) self.mqtt_subscribe() for d in range(self.device_model.rowCount()): idx = self.device_model.index(d, 0) self.initial_query(idx) def mqtt_subscribe(self): main_topics = ["+/stat/+", "+/tele/+", "stat/#", "tele/#"] for d in range(self.device_model.rowCount()): idx = self.device_model.index(d, 0) if not self.device_model.isDefaultTemplate(idx): main_topics.append(self.device_model.commandTopic(idx)) main_topics.append(self.device_model.statTopic(idx)) for t in main_topics: self.mqtt.subscribe(t) def mqtt_ask_for_fulltopic(self): for i in range(len(self.fulltopic_queue)): self.mqtt.publish(self.fulltopic_queue.pop(0)) def mqtt_disconnected(self): self.statusBar().showMessage("Disconnected") def mqtt_connectError(self, rc): reason = { 1: "Incorrect protocol version", 2: "Invalid client identifier", 3: "Server unavailable", 4: "Bad username or password", 5: "Not authorized", } self.statusBar().showMessage("Connection error: {}".format(reason[rc])) self.actDisconnect.setChecked(True) def mqtt_message(self, topic, msg): found = self.device_model.findDevice(topic) if found.reply == 'LWT': if not msg: msg = "offline" if found.index.isValid(): self.console_log(topic, "LWT update: {}".format(msg), msg) self.device_model.updateValue(found.index, DevMdl.LWT, msg) elif msg == "Online": self.console_log(topic, "LWT for unknown device '{}'. Asking for FullTopic.".format(found.topic), msg, False) self.fulltopic_queue.append("cmnd/{}/fulltopic".format(found.topic)) self.fulltopic_queue.append("{}/cmnd/fulltopic".format(found.topic)) self.queue_timer.start(1500) elif found.reply == 'RESULT': full_topic = loads(msg).get('FullTopic') new_topic = loads(msg).get('Topic') template_name = loads(msg).get('NAME') if full_topic: # TODO: update FullTopic for existing device AFTER the FullTopic changes externally (the message will arrive from new FullTopic) if not found.index.isValid(): self.console_log(topic, "FullTopic for {}".format(found.topic), msg, False) new_idx = self.device_model.addDevice(found.topic, full_topic, lwt='online') tele_idx = self.telemetry_model.addDevice(TasmotaDevice, found.topic) self.telemetry_model.devices[found.topic] = tele_idx #TODO: add QSortFilterProxyModel to telemetry treeview and sort devices after adding self.initial_query(new_idx) self.console_log(topic, "Added {} with fulltopic {}, querying for STATE".format(found.topic, full_topic), msg) self.tview.expand(tele_idx) self.tview.resizeColumnToContents(0) if new_topic: if found.index.isValid() and found.topic != new_topic: self.console_log(topic, "New topic for {}".format(found.topic), msg) self.device_model.updateValue(found.index, DevMdl.TOPIC, new_topic) tele_idx = self.telemetry_model.devices.get(found.topic) if tele_idx: self.telemetry_model.setDeviceName(tele_idx, new_topic) self.telemetry_model.devices[new_topic] = self.telemetry_model.devices.pop(found.topic) if template_name: self.device_model.updateValue(found.index, DevMdl.MODULE, template_name) elif found.index.isValid(): if found.reply == 'STATUS': self.console_log(topic, "Received device status", msg) payload = loads(msg)['Status'] self.device_model.updateValue(found.index, DevMdl.FRIENDLY_NAME, payload['FriendlyName'][0]) self.telemetry_model.setDeviceFriendlyName(self.telemetry_model.devices[found.topic], payload['FriendlyName'][0]) self.tview.resizeColumnToContents(0) module = payload['Module'] if module == '0': self.mqtt.publish(self.device_model.commandTopic(found.index)+"template") else: self.device_model.updateValue(found.index, DevMdl.MODULE, module) elif found.reply == 'STATUS1': self.console_log(topic, "Received program information", msg) payload = loads(msg)['StatusPRM'] self.device_model.updateValue(found.index, DevMdl.RESTART_REASON, payload['RestartReason']) elif found.reply == 'STATUS2': self.console_log(topic, "Received firmware information", msg) payload = loads(msg)['StatusFWR'] self.device_model.updateValue(found.index, DevMdl.FIRMWARE, payload['Version']) self.device_model.updateValue(found.index, DevMdl.CORE, payload['Core']) elif found.reply == 'STATUS3': self.console_log(topic, "Received syslog information", msg) payload = loads(msg)['StatusLOG'] self.device_model.updateValue(found.index, DevMdl.TELEPERIOD, payload['TelePeriod']) elif found.reply == 'STATUS5': self.console_log(topic, "Received network status", msg) payload = loads(msg)['StatusNET'] self.device_model.updateValue(found.index, DevMdl.MAC, payload['Mac']) self.device_model.updateValue(found.index, DevMdl.IP, payload['IPAddress']) elif found.reply == 'STATUS8': self.console_log(topic, "Received telemetry", msg) payload = loads(msg)['StatusSNS'] self.parse_telemetry(found.index, payload) elif found.reply == 'STATUS11': self.console_log(topic, "Received device state", msg) payload = loads(msg)['StatusSTS'] self.parse_state(found.index, payload) elif found.reply == 'SENSOR': self.console_log(topic, "Received telemetry", msg) payload = loads(msg) self.parse_telemetry(found.index, payload) elif found.reply == 'STATE': self.console_log(topic, "Received device state", msg) payload = loads(msg) self.parse_state(found.index, payload) elif found.reply.startswith('POWER'): self.console_log(topic, "Received {} state".format(found.reply), msg) payload = {found.reply: msg} self.parse_power(found.index, payload) def parse_power(self, index, payload): old = self.device_model.power(index) power = {k: payload[k] for k in payload.keys() if k.startswith("POWER")} needs_update = False if old: for k in old.keys(): needs_update |= old[k] != power.get(k, old[k]) if needs_update: break else: needs_update = True if needs_update: self.device_model.updateValue(index, DevMdl.POWER, power) def parse_state(self, index, payload): bssid = payload['Wifi'].get('BSSId') if not bssid: bssid = payload['Wifi'].get('APMac') self.device_model.updateValue(index, DevMdl.BSSID, bssid) self.device_model.updateValue(index, DevMdl.SSID, payload['Wifi']['SSId']) self.device_model.updateValue(index, DevMdl.CHANNEL, payload['Wifi'].get('Channel')) self.device_model.updateValue(index, DevMdl.RSSI, payload['Wifi']['RSSI']) self.device_model.updateValue(index, DevMdl.UPTIME, payload['Uptime']) self.device_model.updateValue(index, DevMdl.LOADAVG, payload.get('LoadAvg')) self.parse_power(index, payload) tele_idx = self.telemetry_model.devices.get(self.device_model.topic(index)) if tele_idx: tele_device = self.telemetry_model.getNode(tele_idx) self.telemetry_model.setDeviceFriendlyName(tele_idx, self.device_model.friendly_name(index)) pr = tele_device.provides() for k in pr.keys(): self.telemetry_model.setData(pr[k], payload.get(k)) def parse_telemetry(self, index, payload): device = self.telemetry_model.devices.get(self.device_model.topic(index)) if device: node = self.telemetry_model.getNode(device) time = node.provides()['Time'] if 'Time' in payload: self.telemetry_model.setData(time, payload.pop('Time')) temp_unit = "C" pres_unit = "hPa" if 'TempUnit' in payload: temp_unit = payload.pop('TempUnit') if 'PressureUnit' in payload: pres_unit = payload.pop('PressureUnit') for sensor in sorted(payload.keys()): if sensor == 'DS18x20': for sns_name in payload[sensor].keys(): d = node.devices().get(sensor) if not d: d = self.telemetry_model.addDevice(DS18x20, payload[sensor][sns_name]['Type'], device) self.telemetry_model.getNode(d).setTempUnit(temp_unit) payload[sensor][sns_name]['Id'] = payload[sensor][sns_name].pop('Address') pr = self.telemetry_model.getNode(d).provides() for pk in pr.keys(): self.telemetry_model.setData(pr[pk], payload[sensor][sns_name].get(pk)) self.tview.expand(d) elif sensor.startswith('DS18B20'): d = node.devices().get(sensor) if not d: d = self.telemetry_model.addDevice(DS18x20, sensor, device) self.telemetry_model.getNode(d).setTempUnit(temp_unit) pr = self.telemetry_model.getNode(d).provides() for pk in pr.keys(): self.telemetry_model.setData(pr[pk], payload[sensor].get(pk)) self.tview.expand(d) if sensor == 'COUNTER': d = node.devices().get(sensor) if not d: d = self.telemetry_model.addDevice(CounterSns, "Counter", device) pr = self.telemetry_model.getNode(d).provides() for pk in pr.keys(): self.telemetry_model.setData(pr[pk], payload[sensor].get(pk)) self.tview.expand(d) else: d = node.devices().get(sensor) if not d: d = self.telemetry_model.addDevice(sensor_map.get(sensor, Node), sensor, device) pr = self.telemetry_model.getNode(d).provides() if 'Temperature' in pr: self.telemetry_model.getNode(d).setTempUnit(temp_unit) if 'Pressure' in pr or 'SeaPressure' in pr: self.telemetry_model.getNode(d).setPresUnit(pres_unit) for pk in pr.keys(): self.telemetry_model.setData(pr[pk], payload[sensor].get(pk)) self.tview.expand(d) self.tview.resizeColumnToContents(0) def console_log(self, topic, description, payload, known=True): device = self.device_model.findDevice(topic) fname = self.device_model.friendly_name(device.index) self.console_model.addEntry(topic, fname, description, payload, known) self.console_view.resizeColumnToContents(1) def view_payload(self, idx): idx = self.sorted_console_model.mapToSource(idx) row = idx.row() timestamp = self.console_model.data(self.console_model.index(row, CnsMdl.TIMESTAMP)) topic = self.console_model.data(self.console_model.index(row, CnsMdl.TOPIC)) payload = self.console_model.data(self.console_model.index(row, CnsMdl.PAYLOAD)) dlg = PayloadViewDialog(timestamp, topic, payload) dlg.exec_() def select_cons_entry(self, idx): self.cons_idx = idx def build_cons_ctx_menu(self): self.cons_ctx_menu = QMenu() self.cons_ctx_menu.addAction("View payload", lambda: self.view_payload(self.cons_idx)) self.cons_ctx_menu.addSeparator() self.cons_ctx_menu.addAction("Show only this device", lambda: self.cons_set_filter(self.cons_idx)) self.cons_ctx_menu.addAction("Show all devices", self.cons_set_filter) def show_cons_ctx_menu(self, at): self.select_cons_entry(self.console_view.indexAt(at)) self.cons_ctx_menu.popup(self.console_view.viewport().mapToGlobal(at)) def cons_set_filter(self, idx=None): if idx: idx = self.sorted_console_model.mapToSource(idx) topic = self.console_model.data(self.console_model.index(idx.row(), CnsMdl.FRIENDLY_NAME)) self.sorted_console_model.setFilterFixedString(topic) else: self.sorted_console_model.setFilterFixedString("") def closeEvent(self, e): self.settings.setValue("window_geometry", self.saveGeometry()) self.settings.setValue("splitter_state", self.main_splitter.saveState()) self.settings.sync() e.accept()