From 10e38b925132ff7c042c59113d5acff95c020a4f Mon Sep 17 00:00:00 2001 From: artem30801 Date: Thu, 21 Nov 2019 21:54:39 +0300 Subject: [PATCH 001/210] Added window icon --- Server/icons/image.ico | Bin 0 -> 4739 bytes Server/server_qt.py | 15 +++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 Server/icons/image.ico diff --git a/Server/icons/image.ico b/Server/icons/image.ico new file mode 100644 index 0000000000000000000000000000000000000000..76bea99a017d3c03d1973aaf29ae5fa1e8cea25c GIT binary patch literal 4739 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 Date: Fri, 22 Nov 2019 16:39:18 +0300 Subject: [PATCH 002/210] WIP --- Server/config_editor.py | 60 ++++++++++++ Server/config_editor.ui | 97 ++++++++++++++++++ Server/config_editor_models.py | 113 +++++++++++++++++++++ Server/copter_table_models.py | 5 +- Server/default.txt | 41 ++++++++ Server/test.py | 174 +++++++++++++++++++++++++++++++++ 6 files changed, 487 insertions(+), 3 deletions(-) create mode 100644 Server/config_editor.py create mode 100644 Server/config_editor.ui create mode 100644 Server/config_editor_models.py create mode 100644 Server/default.txt create mode 100644 Server/test.py diff --git a/Server/config_editor.py b/Server/config_editor.py new file mode 100644 index 0000000..ecad213 --- /dev/null +++ b/Server/config_editor.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- + +# Form implementation generated from reading ui file 'config_editor.ui' +# +# Created by: PyQt5 UI code generator 5.13.0 +# +# WARNING! All changes made in this file will be lost! + + +from PyQt5 import QtCore, QtGui, QtWidgets + + +class Ui_Dialog(object): + def setupUi(self, Dialog): + Dialog.setObjectName("Dialog") + Dialog.resize(268, 247) + Dialog.setModal(False) + self.gridLayout = QtWidgets.QGridLayout(Dialog) + self.gridLayout.setObjectName("gridLayout") + self.treeView = QtWidgets.QTreeView(Dialog) + self.treeView.setObjectName("treeView") + self.gridLayout.addWidget(self.treeView, 0, 0, 1, 1) + self.gridLayout_2 = QtWidgets.QGridLayout() + self.gridLayout_2.setObjectName("gridLayout_2") + self.pushButton = QtWidgets.QPushButton(Dialog) + self.pushButton.setObjectName("pushButton") + self.gridLayout_2.addWidget(self.pushButton, 0, 0, 1, 1) + self.checkBox = QtWidgets.QCheckBox(Dialog) + self.checkBox.setObjectName("checkBox") + self.gridLayout_2.addWidget(self.checkBox, 1, 0, 1, 1) + self.buttonBox = QtWidgets.QDialogButtonBox(Dialog) + self.buttonBox.setOrientation(QtCore.Qt.Horizontal) + self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Save) + self.buttonBox.setCenterButtons(False) + self.buttonBox.setObjectName("buttonBox") + self.gridLayout_2.addWidget(self.buttonBox, 1, 1, 1, 1) + self.gridLayout.addLayout(self.gridLayout_2, 1, 0, 1, 1) + + self.retranslateUi(Dialog) + self.buttonBox.accepted.connect(Dialog.accept) + self.buttonBox.rejected.connect(Dialog.reject) + QtCore.QMetaObject.connectSlotsByName(Dialog) + + def retranslateUi(self, Dialog): + _translate = QtCore.QCoreApplication.translate + Dialog.setWindowTitle(_translate("Dialog", "Config Editor")) + self.pushButton.setText(_translate("Dialog", "Delete")) + self.pushButton.setShortcut(_translate("Dialog", "Del")) + self.checkBox.setText(_translate("Dialog", "Restart")) + self.checkBox.setShortcut(_translate("Dialog", "R")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + Dialog = QtWidgets.QDialog() + ui = Ui_Dialog() + ui.setupUi(Dialog) + Dialog.show() + sys.exit(app.exec_()) diff --git a/Server/config_editor.ui b/Server/config_editor.ui new file mode 100644 index 0000000..dc3b4f5 --- /dev/null +++ b/Server/config_editor.ui @@ -0,0 +1,97 @@ + + + Dialog + + + + 0 + 0 + 268 + 247 + + + + Config Editor + + + false + + + + + + + + + + + Delete + + + Del + + + + + + + Restart + + + R + + + + + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Save + + + false + + + + + + + + + + + buttonBox + accepted() + Dialog + accept() + + + 260 + 237 + + + 157 + 246 + + + + + buttonBox + rejected() + Dialog + reject() + + + 260 + 239 + + + 267 + 246 + + + + + diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py new file mode 100644 index 0000000..1a82ea3 --- /dev/null +++ b/Server/config_editor_models.py @@ -0,0 +1,113 @@ +import config_editor +from PyQt5 import QtCore, QtGui, QtWidgets +from PyQt5.QtCore import Qt as Qt + + +class ConfigModelItem: + def __init__(self, data: list, parent=None): + self.parentItem = parent + self.itemData = data + self.childItems = [] + + def appendChild(self, item): + self.childItems.append(item) + + def child(self, row): + return self.childItems[row] + + def childCount(self): + return len(self.childItems) + + def columnCount(self): + return len(self.itemData) + + def data(self, column): + try: + return self.itemData[column] + except IndexError: + return None + + def parent(self): + return self.parentItem + + def row(self): + if self.parentItem: + return self.parentItem.childItems.index(self) + + return 0 + +class ConfigModel(QtCore.QAbstractItemModel): + def __init__(self, data, parent=None): + super(ConfigModel, self).__init__(parent) + + self.rootItem = ConfigModelItem(("Option", "Value")) + #self.setupModelData(data.split('\n'), self.rootItem) + self.rootItem.appendChild(ConfigModelItem(("1314", "345"))) + + def headerData(self, section, orientation, role): + if role == Qt.DisplayRole and orientation == Qt.Horizontal: + return self.rootItem.data(section) + + def columnCount(self, parent): + if parent.isValid(): + return parent.internalPointer().columnCount() + else: + return self.rootItem.columnCount() + + def rowCount(self, parent): + if parent.column() > 0: + return 0 + + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + return parentItem.childCount() + + def index(self, row, column, parent): + if not self.hasIndex(row, column, parent): + return QtCore.QModelIndex() + + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + childItem = parentItem.child(row) + + if childItem: + return self.createIndex(row, column, childItem) + else: + return QtCore.QModelIndex() + + def parent(self, index): + if not index.isValid(): + return QtCore.QModelIndex() + + childItem = index.internalPointer() + parentItem = childItem.parent() + + if parentItem == self.rootItem or parentItem is None: + return QtCore.QModelIndex() + + return self.createIndex(parentItem.row(), 0, parentItem) + +if __name__ == '__main__': + import sys + + + def except_hook(cls, exception, traceback): + sys.__excepthook__(cls, exception, traceback) + + sys.excepthook = except_hook + + m = ConfigModel([12313, 123]) + + app = QtWidgets.QApplication(sys.argv) + Dialog = QtWidgets.QDialog() + ui = config_editor.Ui_Dialog() + ui.setupUi(Dialog) + ui.treeView.setModel(m) + Dialog.show() + sys.exit(app.exec_()) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 977cdfc..184ed78 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -177,9 +177,8 @@ class CopterDataModel(QtCore.QAbstractTableModel): return len(self.headers) def headerData(self, section, orientation, role=Qt.DisplayRole): - if role == Qt.DisplayRole: - if orientation == Qt.Horizontal: - return self.headers[section] + if role == Qt.DisplayRole and orientation == Qt.Horizontal: + return self.headers[section] def data(self, index, role=Qt.DisplayRole): row = index.row() diff --git a/Server/default.txt b/Server/default.txt new file mode 100644 index 0000000..9492303 --- /dev/null +++ b/Server/default.txt @@ -0,0 +1,41 @@ + +Getting Started How to familiarize yourself with Qt Designer + Launching Designer Running the Qt Designer application + The User Interface How to interact with Qt Designer + +Designing a Component Creating a GUI for your application + Creating a Dialog How to create a dialog + Composing the Dialog Putting widgets into the dialog example + Creating a Layout Arranging widgets on a form + Signal and Slot Connections Making widget communicate with each other + +Using a Component in Your Application Generating code from forms + The Direct Approach Using a form without any adjustments + The Single Inheritance Approach Subclassing a form's base class + The Multiple Inheritance Approach Subclassing the form itself + Automatic Connections Connecting widgets using a naming scheme + A Dialog Without Auto-Connect How to connect widgets without a naming scheme + A Dialog With Auto-Connect Using automatic connections + +Form Editing Mode How to edit a form in Qt Designer + Managing Forms Loading and saving forms + Editing a Form Basic editing techniques + The Property Editor Changing widget properties + The Object Inspector Examining the hierarchy of objects on a form + Layouts Objects that arrange widgets on a form + Applying and Breaking Layouts Managing widgets in layouts + Horizontal and Vertical Layouts Standard row and column layouts + The Grid Layout Arranging widgets in a matrix + Previewing Forms Checking that the design works + +Using Containers How to group widgets together + General Features Common container features + Frames QFrame + Group Boxes QGroupBox + Stacked Widgets QStackedWidget + Tab Widgets QTabWidget + Toolbox Widgets QToolBox + +Connection Editing Mode Connecting widgets together with signals and slots + Connecting Objects Making connections in Qt Designer + Editing Connections Changing existing connections \ No newline at end of file diff --git a/Server/test.py b/Server/test.py new file mode 100644 index 0000000..1735972 --- /dev/null +++ b/Server/test.py @@ -0,0 +1,174 @@ +from PyQt5.QtCore import QAbstractItemModel, QFile, QIODevice, QModelIndex, Qt +from PyQt5.QtWidgets import QApplication, QTreeView + + +class TreeItem: + def __init__(self, data: list, parent=None): + self.parentItem = parent + self.itemData = data + self.childItems = [] + + def appendChild(self, item): + self.childItems.append(item) + + def child(self, row): + return self.childItems[row] + + def childCount(self): + return len(self.childItems) + + def columnCount(self): + return len(self.itemData) + + def data(self, column): + try: + return self.itemData[column] + except IndexError: + return None + + def parent(self): + return self.parentItem + + def row(self): + if self.parentItem: + return self.parentItem.childItems.index(self) + + return 0 + + +class TreeModel(QAbstractItemModel): + def __init__(self, data, parent=None): + super(TreeModel, self).__init__(parent) + + self.rootItem = TreeItem(("Title", "Summary")) + self.setupModelData(data.split('\n'), self.rootItem) + self.rootItem.child(1).appendChild(TreeItem(("1314", "345"))) + + def columnCount(self, parent): + if parent.isValid(): + return parent.internalPointer().columnCount() + else: + return self.rootItem.columnCount() + + def data(self, index, role): + if not index.isValid(): + return None + + if role != Qt.DisplayRole: + return None + + item = index.internalPointer() + + return item.data(index.column()) + + def flags(self, index): + if not index.isValid(): + return Qt.NoItemFlags + + return Qt.ItemIsEnabled | Qt.ItemIsSelectable + + def headerData(self, section, orientation, role): + if orientation == Qt.Horizontal and role == Qt.DisplayRole: + return self.rootItem.data(section) + + return None + + def index(self, row, column, parent): + if not self.hasIndex(row, column, parent): + return QModelIndex() + + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + childItem = parentItem.child(row) + if childItem: + return self.createIndex(row, column, childItem) + else: + return QModelIndex() + + def parent(self, index): + if not index.isValid(): + return QModelIndex() + + childItem = index.internalPointer() + parentItem = childItem.parent() + + if parentItem == self.rootItem or parentItem is None: + return QModelIndex() + + return self.createIndex(parentItem.row(), 0, parentItem) + + def rowCount(self, parent): + if parent.column() > 0: + return 0 + + if not parent.isValid(): + parentItem = self.rootItem + else: + parentItem = parent.internalPointer() + + return parentItem.childCount() + + def setupModelData(self, lines, parent): + parents = [parent] + indentations = [0] + + number = 0 + + while number < len(lines): + position = 0 + while position < len(lines[number]): + if lines[number][position] != ' ': + break + position += 1 + + lineData = lines[number][position:].trimmed() + + if lineData: + # Read the column data from the rest of the line. + columnData = [s for s in lineData.split('\t') if s] + + if position > indentations[-1]: + # The last child of the current parent is now the new + # parent unless the current parent has no children. + + if parents[-1].childCount() > 0: + parents.append(parents[-1].child(parents[-1].childCount() - 1)) + indentations.append(position) + + else: + while position < indentations[-1] and len(parents) > 0: + parents.pop() + indentations.pop() + + # Append a new item to the current parent's list of children. + parents[-1].appendChild(TreeItem(columnData, parents[-1])) + + number += 1 + + + +def except_hook(cls, exception, traceback): + sys.__excepthook__(cls, exception, traceback) + + +if __name__ == '__main__': + + import sys + + sys.excepthook = except_hook + + app = QApplication(sys.argv) + + f = QFile('default.txt') + f.open(QIODevice.ReadOnly) + model = TreeModel(f.readAll()) + f.close() + + view = QTreeView() + view.setModel(model) + view.setWindowTitle("Simple Tree Model") + view.show() + sys.exit(app.exec_()) \ No newline at end of file From 3dc665bd006df1348103b964a14185d9e7b86d2c Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 25 Nov 2019 23:19:07 +0300 Subject: [PATCH 003/210] WIP --- Drone/client.py | 1 + Drone/configs/configspec_client.ini | 16 +++ Drone/configs/default_clinet_config.ini | 19 +++ Server/config_editor.py | 56 ++++----- Server/config_editor.ui | 14 +-- Server/config_editor_models.py | 152 +++++++++++++++++++++--- config.py | 64 ++++++++++ 7 files changed, 271 insertions(+), 51 deletions(-) create mode 100644 Drone/configs/configspec_client.ini create mode 100644 Drone/configs/default_clinet_config.ini create mode 100644 config.py diff --git a/Drone/client.py b/Drone/client.py index a52e704..1de5035 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -20,6 +20,7 @@ sys.path.insert(0, parent_dir) logger = logging.getLogger(__name__) import messaging_lib as messaging +import config ConfigOption = collections.namedtuple("ConfigOption", ["section", "option", "value"]) diff --git a/Drone/configs/configspec_client.ini b/Drone/configs/configspec_client.ini new file mode 100644 index 0000000..a6b75b6 --- /dev/null +++ b/Drone/configs/configspec_client.ini @@ -0,0 +1,16 @@ +[SERVER] +port = integer(default=25000) +host = ip_addr(default=192.168.1.101) #string? +buffer_size = integer(default=1024) + +[BROADCAST] +use = boolean(default=True) +port = integer(default=8181) + +[NTP] +use = boolean(default=False) +host = string(default=ntp1.stratum2.ru) +port = integer(default=123) + +[PRIVATE] +id = string(default=/hostname, max=63) \ No newline at end of file diff --git a/Drone/configs/default_clinet_config.ini b/Drone/configs/default_clinet_config.ini new file mode 100644 index 0000000..fbf49e2 --- /dev/null +++ b/Drone/configs/default_clinet_config.ini @@ -0,0 +1,19 @@ +# DO NOT EDIT this file in production +# Use non-default config files for configuration purposes + +[SERVER] +port = 25000 +host = 192.168.1.101 +buffer_size = 1024 + +[BROADCAST] +use = True +port = 8181 + +[NTP] +use = False +host = ntp1.stratum2.ru +port = 123 + +[PRIVATE] +id = /hostname \ No newline at end of file diff --git a/Server/config_editor.py b/Server/config_editor.py index ecad213..2ace49f 100644 --- a/Server/config_editor.py +++ b/Server/config_editor.py @@ -10,25 +10,25 @@ from PyQt5 import QtCore, QtGui, QtWidgets -class Ui_Dialog(object): - def setupUi(self, Dialog): - Dialog.setObjectName("Dialog") - Dialog.resize(268, 247) - Dialog.setModal(False) - self.gridLayout = QtWidgets.QGridLayout(Dialog) +class Ui_config_dialog(object): + def setupUi(self, config_dialog): + config_dialog.setObjectName("config_dialog") + config_dialog.resize(317, 247) + config_dialog.setModal(False) + self.gridLayout = QtWidgets.QGridLayout(config_dialog) self.gridLayout.setObjectName("gridLayout") - self.treeView = QtWidgets.QTreeView(Dialog) - self.treeView.setObjectName("treeView") - self.gridLayout.addWidget(self.treeView, 0, 0, 1, 1) + self.config_view = QtWidgets.QTreeView(config_dialog) + self.config_view.setObjectName("config_view") + self.gridLayout.addWidget(self.config_view, 0, 0, 1, 1) self.gridLayout_2 = QtWidgets.QGridLayout() self.gridLayout_2.setObjectName("gridLayout_2") - self.pushButton = QtWidgets.QPushButton(Dialog) - self.pushButton.setObjectName("pushButton") - self.gridLayout_2.addWidget(self.pushButton, 0, 0, 1, 1) - self.checkBox = QtWidgets.QCheckBox(Dialog) + self.delete_button = QtWidgets.QPushButton(config_dialog) + self.delete_button.setObjectName("delete_button") + self.gridLayout_2.addWidget(self.delete_button, 0, 0, 1, 1) + self.checkBox = QtWidgets.QCheckBox(config_dialog) self.checkBox.setObjectName("checkBox") self.gridLayout_2.addWidget(self.checkBox, 1, 0, 1, 1) - self.buttonBox = QtWidgets.QDialogButtonBox(Dialog) + self.buttonBox = QtWidgets.QDialogButtonBox(config_dialog) self.buttonBox.setOrientation(QtCore.Qt.Horizontal) self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Save) self.buttonBox.setCenterButtons(False) @@ -36,25 +36,25 @@ class Ui_Dialog(object): self.gridLayout_2.addWidget(self.buttonBox, 1, 1, 1, 1) self.gridLayout.addLayout(self.gridLayout_2, 1, 0, 1, 1) - self.retranslateUi(Dialog) - self.buttonBox.accepted.connect(Dialog.accept) - self.buttonBox.rejected.connect(Dialog.reject) - QtCore.QMetaObject.connectSlotsByName(Dialog) + self.retranslateUi(config_dialog) + self.buttonBox.accepted.connect(config_dialog.accept) + self.buttonBox.rejected.connect(config_dialog.reject) + QtCore.QMetaObject.connectSlotsByName(config_dialog) - def retranslateUi(self, Dialog): + def retranslateUi(self, config_dialog): _translate = QtCore.QCoreApplication.translate - Dialog.setWindowTitle(_translate("Dialog", "Config Editor")) - self.pushButton.setText(_translate("Dialog", "Delete")) - self.pushButton.setShortcut(_translate("Dialog", "Del")) - self.checkBox.setText(_translate("Dialog", "Restart")) - self.checkBox.setShortcut(_translate("Dialog", "R")) + config_dialog.setWindowTitle(_translate("config_dialog", "Config Editor")) + self.delete_button.setText(_translate("config_dialog", "Delete")) + self.delete_button.setShortcut(_translate("config_dialog", "Del")) + self.checkBox.setText(_translate("config_dialog", "Restart")) + self.checkBox.setShortcut(_translate("config_dialog", "R")) if __name__ == "__main__": import sys app = QtWidgets.QApplication(sys.argv) - Dialog = QtWidgets.QDialog() - ui = Ui_Dialog() - ui.setupUi(Dialog) - Dialog.show() + config_dialog = QtWidgets.QDialog() + ui = Ui_config_dialog() + ui.setupUi(config_dialog) + config_dialog.show() sys.exit(app.exec_()) diff --git a/Server/config_editor.ui b/Server/config_editor.ui index dc3b4f5..a65b2b1 100644 --- a/Server/config_editor.ui +++ b/Server/config_editor.ui @@ -1,12 +1,12 @@ - Dialog - + config_dialog + 0 0 - 268 + 317 247 @@ -18,12 +18,12 @@ - + - + Delete @@ -64,7 +64,7 @@ buttonBox accepted() - Dialog + config_dialog accept() @@ -80,7 +80,7 @@ buttonBox rejected() - Dialog + config_dialog reject() diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 1a82ea3..102c57c 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -4,13 +4,17 @@ from PyQt5.QtCore import Qt as Qt class ConfigModelItem: - def __init__(self, data: list, parent=None): + def __init__(self, label, value="", parent=None): self.parentItem = parent - self.itemData = data + self.itemData = [label, value] self.childItems = [] + if self.parentItem is not None: + self.parentItem.appendChild(self) + def appendChild(self, item): self.childItems.append(item) + item.parentItem = self def child(self, row): return self.childItems[row] @@ -19,7 +23,7 @@ class ConfigModelItem: return len(self.childItems) def columnCount(self): - return len(self.itemData) + return 2 def data(self, column): try: @@ -27,32 +31,51 @@ class ConfigModelItem: except IndexError: return None + def set_data(self, data, column): + try: + self.itemData[column] = data + except IndexError: + return False + + return True + def parent(self): return self.parentItem def row(self): - if self.parentItem: + if self.parentItem is not None: return self.parentItem.childItems.index(self) return 0 + def removeChild(self, position): + if position < 0 or position > len(self.childItems): + return False + + child = self.childItems.pop(position) + child.parentItem = None + return True + + class ConfigModel(QtCore.QAbstractItemModel): def __init__(self, data, parent=None): super(ConfigModel, self).__init__(parent) - self.rootItem = ConfigModelItem(("Option", "Value")) - #self.setupModelData(data.split('\n'), self.rootItem) - self.rootItem.appendChild(ConfigModelItem(("1314", "345"))) + self.rootItem = ConfigModelItem("Option", "Value") + self.setup(data) + + #i = ConfigModelItem("1314", "") + #self.rootItem.appendChild(i) + #i.appendChild(ConfigModelItem("36hhj", "34566")) + #i.appendChild(ConfigModelItem("36hhj", "34566")) + def headerData(self, section, orientation, role): if role == Qt.DisplayRole and orientation == Qt.Horizontal: return self.rootItem.data(section) def columnCount(self, parent): - if parent.isValid(): - return parent.internalPointer().columnCount() - else: - return self.rootItem.columnCount() + return 2 def rowCount(self, parent): if parent.column() > 0: @@ -93,6 +116,99 @@ class ConfigModel(QtCore.QAbstractItemModel): return self.createIndex(parentItem.row(), 0, parentItem) + def data(self, index, role): + if not index.isValid(): + return None + + item = index.internalPointer() + + if role == Qt.DisplayRole or role == Qt.EditRole: + return item.data(index.column()) + + return None + + @QtCore.pyqtSlot() + def setData(self, index, value, role=Qt.EditRole): + if not index.isValid(): + return False + + item = index.internalPointer() + if role == Qt.EditRole: + item.set_data(value, index.column()) + + self.dataChanged.emit(index, index, (role,)) + + return True + + def flags(self, index): + if not index.isValid(): + return Qt.NoItemFlags + childItem = index.internalPointer() + parentItem = childItem.parent() + + flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + + if index.column() == 1 and parentItem != self.rootItem: + flags |= Qt.ItemIsEditable + + return flags + + @QtCore.pyqtSlot() + def removeRow(self, index): + parent = index.parent() + self.beginRemoveRows(parent, index.row(), index.row()) + + if not parent.isValid(): + parentNode = self.rootItem + else: + parentNode = parent.internalPointer() + + parentNode.removeChild(index.row()) + + self.endRemoveRows() + return True + + def setup(self, d: dict): + for section, options in d.items(): + section_item = ConfigModelItem(section, parent=self.rootItem) + for option, value in options.items(): + section_item.appendChild(ConfigModelItem(option, value)) + + def to_dict(self): + d = {} + for section in self.rootItem.childItems: + section_d = {} + section_name, _ = section.itemData + + for item in section.childItems: + option, value = item.itemData + section_d[option] = value + + d[section_name] = section_d + + return d + + +class ConfigDialog(config_editor.Ui_config_dialog): + def __init__(self, data): + super(ConfigDialog, self).__init__() + self.model = ConfigModel(data) + + def setupUi(self, config_dialog): + super(ConfigDialog, self).setupUi(config_dialog) + + self.config_view.setModel(self.model) + self.config_view.expandAll() + + self.delete_button.pressed.connect(self.remove_selected) + + def remove_selected(self): + index = self.config_view.selectedIndexes()[0] + self.model.removeRow(index) + + #print(self.model.to_dict()) + + if __name__ == '__main__': import sys @@ -102,12 +218,16 @@ if __name__ == '__main__': sys.excepthook = except_hook - m = ConfigModel([12313, 123]) - app = QtWidgets.QApplication(sys.argv) Dialog = QtWidgets.QDialog() - ui = config_editor.Ui_Dialog() + + data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": ""}, + "section 2": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": ""}} + + ui = ConfigDialog(data) ui.setupUi(Dialog) - ui.treeView.setModel(m) + + Dialog.show() - sys.exit(app.exec_()) + print(app.exec_()) + sys.exit() diff --git a/config.py b/config.py new file mode 100644 index 0000000..910d156 --- /dev/null +++ b/config.py @@ -0,0 +1,64 @@ +import collections +import os +from configobj import ConfigObj +from validate import Validator + +ConfigOption = collections.namedtuple("ConfigOption", ["section", "option", "value"]) + + +class ConfigManager: + def __init__(self): + self.configs = {} + + @staticmethod + def _get_default_path(path): + old_path, filename = os.path.split(path) + filename = os.path.splitext(filename)[0] + newfilename = "default_{}.ini".format(filename) + print(os.path.join(old_path, newfilename)) + return os.path.join(old_path, newfilename) + + def load_config(self, path): # todo maybe automatic config path + vdt = Validator() + + default_config = ConfigObj( + infile=self._get_default_path(path), configspec="Drone/configs/configspec_client.ini") + default_config.validate(vdt) + print(default_config) + default_config.walk(self.transform) + print(default_config.dict()) + #default_config = configparser.ConfigParser(co) + #default_config.read(default_path) + + + + def create_empty_config(self, path): + with open(path, 'w') as f: + f.write("# Write here any configurations to replace default values \n\n") + + @staticmethod + def getvalue(section, key): + try: + return section.as_int(key) + except ValueError: + pass + try: + return section.as_float(key) + except ValueError: + pass + try: + return section.as_bool(key) + except ValueError: + pass + return section.get(key) + + @classmethod + def transform(cls, section, key): + value = cls.getvalue(section, key) + print(value) + section[key] = value + +if __name__ == '__main__': + cfg = ConfigManager() + #open('Drone/default_clinet_config.ini') + cfg.load_config('Drone/configs/clinet_config.ini') From fe69c99fb5a77c1c4e45eb136be0b6618860f455 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 28 Nov 2019 08:45:33 +0300 Subject: [PATCH 004/210] WIP 3 --- Drone/client.py | 2 +- Drone/config/client.ini | 19 +++ .../spec}/configspec_client.ini | 3 +- Server/config_editor_models.py | 42 +++--- config.py | 137 ++++++++++++------ 5 files changed, 136 insertions(+), 67 deletions(-) create mode 100644 Drone/config/client.ini rename Drone/{configs => config/spec}/configspec_client.ini (71%) diff --git a/Drone/client.py b/Drone/client.py index 1de5035..3675660 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -224,7 +224,7 @@ class Client(object): @messaging.message_callback("config_write") def _command_config_write(*args, **kwargs): options = [ConfigOption(**raw_option) for raw_option in kwargs["options"]] - logger.info("Writing config options: {}".format(options)) + logger.info("Writing config_attrs options: {}".format(options)) active_client.write_config(kwargs["reload"], *options) diff --git a/Drone/config/client.ini b/Drone/config/client.ini new file mode 100644 index 0000000..29c6d69 --- /dev/null +++ b/Drone/config/client.ini @@ -0,0 +1,19 @@ +# This is generated config_attrs with defaults +# Modify to configure +[SERVER] +port = 25000 +host = 192.168.1.101 +buffer_size = 1024 + +[BROADCAST] +use = True +port = 8181 + +[NTP] +use = False +host = ntp1.stratum2.ru +port = 123n + +[PRIVATE] +# avialiable options: /hostname ; /default ; /ip ; any string 63 characters lengh +#id = /hostname diff --git a/Drone/configs/configspec_client.ini b/Drone/config/spec/configspec_client.ini similarity index 71% rename from Drone/configs/configspec_client.ini rename to Drone/config/spec/configspec_client.ini index a6b75b6..870f205 100644 --- a/Drone/configs/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -13,4 +13,5 @@ host = string(default=ntp1.stratum2.ru) port = integer(default=123) [PRIVATE] -id = string(default=/hostname, max=63) \ No newline at end of file +# avialiable options: /hostname ; /default ; /ip ; any string 63 characters lengh +id = string(default=/hostname, max=63) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 102c57c..47f3a65 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -4,10 +4,12 @@ from PyQt5.QtCore import Qt as Qt class ConfigModelItem: - def __init__(self, label, value="", parent=None): - self.parentItem = parent + def __init__(self, label, value="", is_section=False, parent=None): self.itemData = [label, value] + + self.is_section=is_section self.childItems = [] + self.parentItem = parent if self.parentItem is not None: self.parentItem.appendChild(self) @@ -64,12 +66,6 @@ class ConfigModel(QtCore.QAbstractItemModel): self.rootItem = ConfigModelItem("Option", "Value") self.setup(data) - #i = ConfigModelItem("1314", "") - #self.rootItem.appendChild(i) - #i.appendChild(ConfigModelItem("36hhj", "34566")) - #i.appendChild(ConfigModelItem("36hhj", "34566")) - - def headerData(self, section, orientation, role): if role == Qt.DisplayRole and orientation == Qt.Horizontal: return self.rootItem.data(section) @@ -143,12 +139,11 @@ class ConfigModel(QtCore.QAbstractItemModel): def flags(self, index): if not index.isValid(): return Qt.NoItemFlags - childItem = index.internalPointer() - parentItem = childItem.parent() + item = index.internalPointer() flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable - if index.column() == 1 and parentItem != self.rootItem: + if index.column() == 1 and not item.is_section: flags |= Qt.ItemIsEditable return flags @@ -168,13 +163,18 @@ class ConfigModel(QtCore.QAbstractItemModel): self.endRemoveRows() return True - def setup(self, d: dict): - for section, options in d.items(): - section_item = ConfigModelItem(section, parent=self.rootItem) - for option, value in options.items(): - section_item.appendChild(ConfigModelItem(option, value)) + def setup(self, data: dict, parent=None): + if parent is None: + parent = self.rootItem - def to_dict(self): + for key, value in data.items(): + if isinstance(value, dict): + item = ConfigModelItem(key, parent=parent, is_section=True) + self.setup(value, parent=item) + else: + parent.appendChild(ConfigModelItem(key, value)) + + def to_dict(self): # TODO recursive d = {} for section in self.rootItem.childItems: section_d = {} @@ -221,8 +221,8 @@ if __name__ == '__main__': app = QtWidgets.QApplication(sys.argv) Dialog = QtWidgets.QDialog() - data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": ""}, - "section 2": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": ""}} + data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": {'subopt': 'bal'}}, + "section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}} ui = ConfigDialog(data) ui.setupUi(Dialog) @@ -230,4 +230,8 @@ if __name__ == '__main__': Dialog.show() print(app.exec_()) + + print(Dialog.result()) + print(ui.model.to_dict()) + sys.exit() diff --git a/config.py b/config.py index 910d156..f265807 100644 --- a/config.py +++ b/config.py @@ -1,64 +1,109 @@ -import collections import os -from configobj import ConfigObj + +from functools import partial +from configobj import ConfigObj, Section from validate import Validator -ConfigOption = collections.namedtuple("ConfigOption", ["section", "option", "value"]) + +def modify_filename(path, pattern): + old_path, filename = os.path.split(path) + filename = os.path.splitext(filename)[0] + newfilename = pattern.format(filename) + return os.path.join(old_path, newfilename) class ConfigManager: def __init__(self): - self.configs = {} + self.config = ConfigObj() - @staticmethod - def _get_default_path(path): - old_path, filename = os.path.split(path) - filename = os.path.splitext(filename)[0] - newfilename = "default_{}.ini".format(filename) - print(os.path.join(old_path, newfilename)) - return os.path.join(old_path, newfilename) + def load_config(self, path): + self.generate_default_config(path) - def load_config(self, path): # todo maybe automatic config path vdt = Validator() + config = ConfigObj(infile=path, raise_errors=True, + configspec=modify_filename(path, 'spec/configspec_{}.ini')) + test = config.validate(vdt) + print(test) + print(config) + self.config = config - default_config = ConfigObj( - infile=self._get_default_path(path), configspec="Drone/configs/configspec_client.ini") - default_config.validate(vdt) - print(default_config) - default_config.walk(self.transform) - print(default_config.dict()) - #default_config = configparser.ConfigParser(co) - #default_config.read(default_path) + def get(self, section, option): + return self.config[section][option] + def set(self, section, option, value, write=False): + self.config[section][option] = value + if write: + self.write() - - def create_empty_config(self, path): - with open(path, 'w') as f: - f.write("# Write here any configurations to replace default values \n\n") - - @staticmethod - def getvalue(section, key): - try: - return section.as_int(key) - except ValueError: - pass - try: - return section.as_float(key) - except ValueError: - pass - try: - return section.as_bool(key) - except ValueError: - pass - return section.get(key) + def write(self): + self.config.write() @classmethod - def transform(cls, section, key): - value = cls.getvalue(section, key) - print(value) - section[key] = value + def _get_defaults(cls, item, unchanged_only=False): + if isinstance(item, Section): + default_values = item.default_values.copy() + + if unchanged_only: + default_list = item.defaults.copy() + defaults = {key: default_values[key] for key in default_list if key in default_values} + else: + defaults = default_values + + for key, value in item.items(): + result = cls._get_defaults(value, unchanged_only=unchanged_only) + if result is not None: + defaults[key] = result + + return defaults if defaults else None + + @property + def default_values(self): + return self._get_defaults(self.config) or {} + + @property + def unchanged_defaults(self): + return self._get_defaults(self.config, unchanged_only=True) or {} + + @staticmethod + def generate_default_config(path): + if os.path.isfile(path): + return + + vdt = Validator() + config = ConfigObj(configspec=modify_filename(path, 'spec/configspec_{}.ini')) + config.filename = path + config.validate(vdt, copy=True) + config.initial_comment = ('This is generated config_attrs with defaults', + 'Modify to configure') + config.write() + + def __getattr__(self, item): + try: + section, option = item.split('_', 1) + return self.config[section.upper()][option.lower()] + except (ValueError, KeyError): + return self.__dict__[item] + + def __setattr__(self, key, value): + try: + section, option = key.split('_', 1) + self.config[section.upper()][option.lower()] = value + except (ValueError, KeyError): + self.__dict__[key] = value + if __name__ == '__main__': cfg = ConfigManager() - #open('Drone/default_clinet_config.ini') - cfg.load_config('Drone/configs/clinet_config.ini') + cfg.load_config('Drone/config/client.ini') + + print(cfg.server_host) + cfg.server_host = '192.168.1.103' + + print(cfg.get('SERVER', 'host')) + cfg.set('SERVER', 'host', '192.168.1.103') + + print(cfg.config) + print(cfg.default_values) + print(cfg.unchanged_defaults) + #print(cfg.con) + From 6f84f167b0b4cbce081509cf0cf03dfb002e5225 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 28 Nov 2019 08:46:45 +0300 Subject: [PATCH 005/210] WIP 3.1 --- Drone/{configs => }/default_clinet_config.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename Drone/{configs => }/default_clinet_config.ini (77%) diff --git a/Drone/configs/default_clinet_config.ini b/Drone/default_clinet_config.ini similarity index 77% rename from Drone/configs/default_clinet_config.ini rename to Drone/default_clinet_config.ini index fbf49e2..6a05e75 100644 --- a/Drone/configs/default_clinet_config.ini +++ b/Drone/default_clinet_config.ini @@ -1,5 +1,5 @@ # DO NOT EDIT this file in production -# Use non-default config files for configuration purposes +# Use non-default config_attrs files for configuration purposes [SERVER] port = 25000 From 5b2614d83297fab5c00010db0d33eb88f7df025e Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Fri, 29 Nov 2019 23:26:47 +0300 Subject: [PATCH 006/210] wip4 --- Server/config_editor.py | 2 +- Server/config_editor.ui | 69 +++++++++--- Server/config_editor_models.py | 192 +++++++++++++++++++++++++++------ requirements.txt | 7 +- 4 files changed, 219 insertions(+), 51 deletions(-) diff --git a/Server/config_editor.py b/Server/config_editor.py index 2ace49f..ca488a2 100644 --- a/Server/config_editor.py +++ b/Server/config_editor.py @@ -13,7 +13,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets class Ui_config_dialog(object): def setupUi(self, config_dialog): config_dialog.setObjectName("config_dialog") - config_dialog.resize(317, 247) + config_dialog.resize(600, 700) config_dialog.setModal(False) self.gridLayout = QtWidgets.QGridLayout(config_dialog) self.gridLayout.setObjectName("gridLayout") diff --git a/Server/config_editor.ui b/Server/config_editor.ui index a65b2b1..e76c48f 100644 --- a/Server/config_editor.ui +++ b/Server/config_editor.ui @@ -6,8 +6,8 @@ 0 0 - 317 - 247 + 310 + 399 @@ -17,22 +17,9 @@ false - - - - + - - - Delete - - - Del - - - - Restart @@ -42,7 +29,7 @@ - + Qt::Horizontal @@ -57,6 +44,54 @@ + + + + + + + + + Delete + + + Del + + + + + + + Add option + + + + + + + Add section + + + Ctrl+A + + + + + + + Mark for deletion + + + + + + + + + Qt::Horizontal + + + diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 47f3a65..179eb05 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -1,13 +1,20 @@ +from copy import deepcopy + +import pickle + import config_editor from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt as Qt +from PyQt5.QtGui import QCursor, QStandardItemModel +from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu class ConfigModelItem: - def __init__(self, label, value="", is_section=False, parent=None): + def __init__(self, label, value="", is_section=False, state='default', parent=None): self.itemData = [label, value] + self.is_section = is_section + self.state = state - self.is_section=is_section self.childItems = [] self.parentItem = parent @@ -18,6 +25,19 @@ class ConfigModelItem: self.childItems.append(item) item.parentItem = self + def addChildren(self, items, row): + if row == -1: + self.childItems.extend(items) + else: + #row -= 1 + print('row', row) + self.childItems[row:row] = items + + print(self.childItems) + + for item in items: + item.parentItem = self + def child(self, row): return self.childItems[row] @@ -53,11 +73,21 @@ class ConfigModelItem: def removeChild(self, position): if position < 0 or position > len(self.childItems): return False - + print('removing', position) child = self.childItems.pop(position) child.parentItem = None return True + def removeChildren(self, row, count): + print(range(row, row+count)) + for pos in range(row, row+count): + self.removeChild(pos) + + return True + + def __repr__(self): + return str(self.itemData) + class ConfigModel(QtCore.QAbstractItemModel): def __init__(self, data, parent=None): @@ -88,11 +118,7 @@ class ConfigModel(QtCore.QAbstractItemModel): if not self.hasIndex(row, column, parent): return QtCore.QModelIndex() - if not parent.isValid(): - parentItem = self.rootItem - else: - parentItem = parent.internalPointer() - + parentItem = self.nodeFromIndex(parent) childItem = parentItem.child(row) if childItem: @@ -112,6 +138,11 @@ class ConfigModel(QtCore.QAbstractItemModel): return self.createIndex(parentItem.row(), 0, parentItem) + def nodeFromIndex(self, index): + if index.isValid(): + return index.internalPointer() + return self.rootItem + def data(self, index, role): if not index.isValid(): return None @@ -138,31 +169,86 @@ class ConfigModel(QtCore.QAbstractItemModel): def flags(self, index): if not index.isValid(): - return Qt.NoItemFlags + return QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsDropEnabled # Qt.NoItemFlags item = index.internalPointer() flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable + if index.column() == 0: + flags |= int(QtCore.Qt.ItemIsDragEnabled) + if item.is_section: + flags |= int(QtCore.Qt.ItemIsDropEnabled) + if index.column() == 1 and not item.is_section: flags |= Qt.ItemIsEditable return flags + def supportedDropActions(self): + return QtCore.Qt.CopyAction | QtCore.Qt.MoveAction + + def mimeTypes(self): + return ['bstream', 'text/xml'] + + def mimeData(self, indexes): + mimedata = QtCore.QMimeData() + index = indexes[0] + mimedata.setData('bstream', pickle.dumps(self.nodeFromIndex(index))) + return mimedata + + def dropMimeData(self, mimedata, action, row, column, parentIndex): + print(action) + print('mim', row) + if action == Qt.IgnoreAction: + return True + + parentNode = self.nodeFromIndex(parentIndex) + droppedNode = deepcopy(pickle.loads(mimedata.data('bstream'))) + print(droppedNode.itemData, 'node') + #droppedNode = pickle.loads(mimedata.data('bstream')) # + #self.removeRow()#self.index(row, column, parentIndex))#parentNode.child(row)) + self.insertItems(row, [droppedNode], parentIndex) + self.dataChanged.emit(parentIndex, parentIndex) + + return True + + def removeRows1(self, row, count, parent): + print('rem', row, count) + self.beginRemoveRows(parent, row, row+count-1) + parentItem = self.nodeFromIndex(parent) + print(parentItem, parentItem.itemData) + + #parentItem.removeChild(row) + parentItem.removeChildren(row, count) + print(parentItem.childItems) + + self.endRemoveRows() + print('removed') + return True + @QtCore.pyqtSlot() def removeRow(self, index): parent = index.parent() self.beginRemoveRows(parent, index.row(), index.row()) - if not parent.isValid(): - parentNode = self.rootItem - else: - parentNode = parent.internalPointer() - - parentNode.removeChild(index.row()) + parentItem = self.nodeFromIndex(parent) + parentItem.removeChild(index.row()) self.endRemoveRows() return True + def insertItems(self, row, items, parentIndex): + print('ins', row) + parent = self.nodeFromIndex(parentIndex) + self.beginInsertRows(parentIndex, row, row+len(items)-1) + + parent.addChildren(items, row) + print(parent.childItems) + + self.endInsertRows() + self.dataChanged.emit(parentIndex, parentIndex) + return True + def setup(self, data: dict, parent=None): if parent is None: parent = self.rootItem @@ -174,19 +260,23 @@ class ConfigModel(QtCore.QAbstractItemModel): else: parent.appendChild(ConfigModelItem(key, value)) - def to_dict(self): # TODO recursive - d = {} - for section in self.rootItem.childItems: - section_d = {} - section_name, _ = section.itemData + def to_dict(self, parent=None) -> dict: + if parent is None: + parent = self.rootItem - for item in section.childItems: - option, value = item.itemData - section_d[option] = value + data = {} + for item in parent.childItems: + item_name, item_data = item.itemData + if item.childItems: + data[item_name] = self.to_dict(item) + else: + data[item_name] = item_data - d[section_name] = section_d + return data - return d + @property + def dict(self): + return self.to_dict() class ConfigDialog(config_editor.Ui_config_dialog): @@ -197,17 +287,59 @@ class ConfigDialog(config_editor.Ui_config_dialog): def setupUi(self, config_dialog): super(ConfigDialog, self).setupUi(config_dialog) + #self.config_view = Tree() + + self.config_view = Tree() + self.config_view.setObjectName("config_view") self.config_view.setModel(self.model) + self.gridLayout.addWidget(self.config_view, 0, 0, 1, 1) + self.config_view.expandAll() + #self.config_view.setDragDropMode(True) + #self.setDragDropMode(QAbstractItemView.InternalMove) + #self.setDragEnabled(True) + #self.setAcceptDrops(True) + #self.setDropIndicatorShown(True) self.delete_button.pressed.connect(self.remove_selected) def remove_selected(self): index = self.config_view.selectedIndexes()[0] - self.model.removeRow(index) + self.model.removeRow(index)\ - #print(self.model.to_dict()) +class Tree(QTreeView): + def __init__(self): + QTreeView.__init__(self) + data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": {'subopt': 'bal'}}, + "section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}} + #model = ConfigModel(data) + + #self.setModel(model) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.open_menu) + + self.setSelectionMode(self.SingleSelection) + self.setDragDropMode(QAbstractItemView.InternalMove) + self.setDragEnabled(True) + self.setAcceptDrops(True) + self.setDropIndicatorShown(True) + + # def dropEvent(self, e): + # print(e.dropAction()==QtCore.Qt.MoveAction) + # if e.keyboardModifiers() & QtCore.Qt.AltModifier: + # e.setDropAction() + # print('copy') + # else: + # e.setDropAction(QtCore.Qt.MoveAction) + # print("drop") + # print(e) + # e.accept() + + def open_menu(self): + menu = QMenu() + menu.addAction("Create new folder") + menu.exec_(QCursor.pos()) if __name__ == '__main__': import sys @@ -221,13 +353,13 @@ if __name__ == '__main__': app = QtWidgets.QApplication(sys.argv) Dialog = QtWidgets.QDialog() - data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": {'subopt': 'bal'}}, - "section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}} + data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, }}#"...": {'subopt': 'bal'}},} + #"section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}} ui = ConfigDialog(data) ui.setupUi(Dialog) - + print(Qt.DisplayRole) Dialog.show() print(app.exec_()) diff --git a/requirements.txt b/requirements.txt index 728753f..c991080 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ +configobj indexed.py==0.0.1 -numpy==1.16.4 -PyQt5==5.13.0 -PyQt5-sip==4.19.18 +numpy==1.17.4 +PyQt5==5.13.2 +PyQt5-sip==12.7.0 selectors2==2.0.1 From f7ab920a18e88d5cac3099387d035daeb1e5dbe6 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 1 Dec 2019 17:05:28 +0300 Subject: [PATCH 007/210] Exporting/loading to/from dict --- config.py | 123 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 108 insertions(+), 15 deletions(-) diff --git a/config.py b/config.py index f265807..bb2c102 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,5 @@ import os -from functools import partial from configobj import ConfigObj, Section from validate import Validator @@ -13,18 +12,68 @@ def modify_filename(path, pattern): class ConfigManager: - def __init__(self): - self.config = ConfigObj() + def __init__(self, config=None): + self.config = ConfigObj() if config is None else config - def load_config(self, path): + def set_config(self, config): + self.config = config + + @classmethod + def _extract_values(cls, d): + result = {} + for key, val in d.items(): + if isinstance(val, dict) and val.get('__option__', False): + if not val.get('unchanged'): + result[key] = val.get('value') + else: + result[key] = cls._extract_values(val) + return result + + @classmethod + def _load_comments(cls, d, section): + comments = {} + inline_comments = {} + + for key, val in d.items(): + if val.get('__option__', False): + comments[key] = val.get('comments', []) + inline_comments[key] = val.get('inline_comment', '') + else: + cls._load_comments(val, section[key]) + comments[key] = [''] + inline_comments[key] = None + + section.comments = comments + section.inline_comments = inline_comments + + def load_from_dict(self, d, path): + initial_comment = d.pop('initial_comment', ['']) + final_comment = d.pop('final_comment', ['']) + + config = ConfigObj(infile=self._extract_values(d), indent_type='', + configspec=modify_filename(path, 'spec/configspec_{}.ini')) + self._load_validate(config) + self.config.filename = path + self.config.initial_comment = initial_comment + self.config.final_comment = final_comment + + self._load_comments(d, self.config) + + def load_from_file(self, path): self.generate_default_config(path) - vdt = Validator() config = ConfigObj(infile=path, raise_errors=True, configspec=modify_filename(path, 'spec/configspec_{}.ini')) + + self._load_validate(config) + + def _load_validate(self, config): + vdt = Validator() + test = config.validate(vdt) - print(test) - print(config) + if test != True: # Important syntax, do no change + raise ValueError('Some values are wrong: {}'.format(test)) + self.config = config def get(self, section, option): @@ -56,6 +105,34 @@ class ConfigManager: return defaults if defaults else None + @classmethod + def _full_dict(cls, item): + if not isinstance(item, Section): + return item + + d = {} + default_values = item.default_values + defaults = item.defaults + comments = item.comments + inline_comments = item.inline_comments + + for key, value in item.items(): + result = cls._full_dict(value) + if not isinstance(result, dict): + item_d = {'__option__': True, + 'value': value, + 'default': default_values[key], + 'unchanged': key in defaults, + 'comments': comments[key], + 'inline_comment': inline_comments[key], + } + d[key] = item_d + + else: + d[key] = result + + return d + @property def default_values(self): return self._get_defaults(self.config) or {} @@ -64,6 +141,13 @@ class ConfigManager: def unchanged_defaults(self): return self._get_defaults(self.config, unchanged_only=True) or {} + @property + def full_dict(self): + d = self._full_dict(self.config) + d['initial_comment'] = self.config.initial_comment + d['final_comment'] = self.config.final_comment + return d + @staticmethod def generate_default_config(path): if os.path.isfile(path): @@ -94,16 +178,25 @@ class ConfigManager: if __name__ == '__main__': cfg = ConfigManager() - cfg.load_config('Drone/config/client.ini') - - print(cfg.server_host) + cfg.load_from_file('Drone/config/client.ini') + #print(cfg.config.comments) + #print(cfg.server_host) cfg.server_host = '192.168.1.103' - print(cfg.get('SERVER', 'host')) + #print(cfg.get('SERVER', 'host')) cfg.set('SERVER', 'host', '192.168.1.103') - print(cfg.config) - print(cfg.default_values) - print(cfg.unchanged_defaults) - #print(cfg.con) + print(cfg.config.initial_comment, cfg.config.final_comment) + + # print(cfg.config) + # print(cfg.default_values) + # print(cfg.unchanged_defaults) + + # print(11111) + print(cfg.full_dict) + + #cfg.load_from_dict(cfg.full_dict, 'Drone/config/client.ini') + #print(cfg.config.initial_comment, cfg.config.final_comment) + #cfg.write() + From 11cb93b24f4a09b137945b3ecd09dbe4c343c36f Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 1 Dec 2019 17:06:01 +0300 Subject: [PATCH 008/210] Updated config gui --- Server/config_editor.py | 48 ++++++++++++++++++++++++++++++----------- Server/config_editor.ui | 16 +++++++++++--- 2 files changed, 49 insertions(+), 15 deletions(-) diff --git a/Server/config_editor.py b/Server/config_editor.py index ca488a2..a498c63 100644 --- a/Server/config_editor.py +++ b/Server/config_editor.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'config_editor.ui' # -# Created by: PyQt5 UI code generator 5.13.0 +# Created by: PyQt5 UI code generator 5.13.2 # # WARNING! All changes made in this file will be lost! @@ -17,24 +17,44 @@ class Ui_config_dialog(object): config_dialog.setModal(False) self.gridLayout = QtWidgets.QGridLayout(config_dialog) self.gridLayout.setObjectName("gridLayout") - self.config_view = QtWidgets.QTreeView(config_dialog) - self.config_view.setObjectName("config_view") - self.gridLayout.addWidget(self.config_view, 0, 0, 1, 1) self.gridLayout_2 = QtWidgets.QGridLayout() self.gridLayout_2.setObjectName("gridLayout_2") - self.delete_button = QtWidgets.QPushButton(config_dialog) - self.delete_button.setObjectName("delete_button") - self.gridLayout_2.addWidget(self.delete_button, 0, 0, 1, 1) self.checkBox = QtWidgets.QCheckBox(config_dialog) self.checkBox.setObjectName("checkBox") - self.gridLayout_2.addWidget(self.checkBox, 1, 0, 1, 1) + self.gridLayout_2.addWidget(self.checkBox, 0, 0, 1, 1) self.buttonBox = QtWidgets.QDialogButtonBox(config_dialog) self.buttonBox.setOrientation(QtCore.Qt.Horizontal) self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Save) self.buttonBox.setCenterButtons(False) self.buttonBox.setObjectName("buttonBox") - self.gridLayout_2.addWidget(self.buttonBox, 1, 1, 1, 1) - self.gridLayout.addLayout(self.gridLayout_2, 1, 0, 1, 1) + self.gridLayout_2.addWidget(self.buttonBox, 0, 1, 1, 1) + self.gridLayout.addLayout(self.gridLayout_2, 3, 0, 1, 1) + self.config_view = QtWidgets.QTreeView(config_dialog) + self.config_view.setEditTriggers(QtWidgets.QAbstractItemView.DoubleClicked|QtWidgets.QAbstractItemView.EditKeyPressed|QtWidgets.QAbstractItemView.SelectedClicked) + self.config_view.setObjectName("config_view") + self.config_view.header().setCascadingSectionResizes(False) + self.config_view.header().setDefaultSectionSize(250) + self.gridLayout.addWidget(self.config_view, 0, 0, 1, 1) + self.gridLayout_3 = QtWidgets.QGridLayout() + self.gridLayout_3.setObjectName("gridLayout_3") + self.delete_button = QtWidgets.QPushButton(config_dialog) + self.delete_button.setObjectName("delete_button") + self.gridLayout_3.addWidget(self.delete_button, 0, 1, 1, 1) + self.add_option_button = QtWidgets.QPushButton(config_dialog) + self.add_option_button.setObjectName("add_option_button") + self.gridLayout_3.addWidget(self.add_option_button, 0, 3, 1, 1) + self.add_section_button = QtWidgets.QPushButton(config_dialog) + self.add_section_button.setObjectName("add_section_button") + self.gridLayout_3.addWidget(self.add_section_button, 0, 2, 1, 1) + self.hard_delete = QtWidgets.QPushButton(config_dialog) + self.hard_delete.setObjectName("hard_delete") + self.gridLayout_3.addWidget(self.hard_delete, 0, 0, 1, 1) + self.gridLayout.addLayout(self.gridLayout_3, 1, 0, 1, 1) + self.line = QtWidgets.QFrame(config_dialog) + self.line.setFrameShape(QtWidgets.QFrame.HLine) + self.line.setFrameShadow(QtWidgets.QFrame.Sunken) + self.line.setObjectName("line") + self.gridLayout.addWidget(self.line, 2, 0, 1, 1) self.retranslateUi(config_dialog) self.buttonBox.accepted.connect(config_dialog.accept) @@ -44,10 +64,14 @@ class Ui_config_dialog(object): def retranslateUi(self, config_dialog): _translate = QtCore.QCoreApplication.translate config_dialog.setWindowTitle(_translate("config_dialog", "Config Editor")) - self.delete_button.setText(_translate("config_dialog", "Delete")) - self.delete_button.setShortcut(_translate("config_dialog", "Del")) self.checkBox.setText(_translate("config_dialog", "Restart")) self.checkBox.setShortcut(_translate("config_dialog", "R")) + self.delete_button.setText(_translate("config_dialog", "Delete")) + self.delete_button.setShortcut(_translate("config_dialog", "Del")) + self.add_option_button.setText(_translate("config_dialog", "Add option")) + self.add_section_button.setText(_translate("config_dialog", "Add section")) + self.add_section_button.setShortcut(_translate("config_dialog", "Ctrl+A")) + self.hard_delete.setText(_translate("config_dialog", "Mark for deletion")) if __name__ == "__main__": diff --git a/Server/config_editor.ui b/Server/config_editor.ui index e76c48f..f33760b 100644 --- a/Server/config_editor.ui +++ b/Server/config_editor.ui @@ -6,8 +6,8 @@ 0 0 - 310 - 399 + 600 + 700 @@ -45,7 +45,17 @@ - + + + QAbstractItemView::DoubleClicked|QAbstractItemView::EditKeyPressed|QAbstractItemView::SelectedClicked + + + false + + + 250 + + From 018567be3532a8c4fcc292012e3e6542bb6337f5 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 1 Dec 2019 17:07:03 +0300 Subject: [PATCH 009/210] Minimal working version of config editor --- Server/config_editor_models.py | 357 +++++++++++++++++++++++---------- 1 file changed, 254 insertions(+), 103 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 179eb05..0c57a17 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -1,19 +1,36 @@ +import pickle +from ast import literal_eval +from functools import partial from copy import deepcopy -import pickle - -import config_editor from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt as Qt from PyQt5.QtGui import QCursor, QStandardItemModel -from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu +from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu, QAction, QMessageBox, QInputDialog + +import config_editor + + +def dict_walk(d: dict, keys): + current = d + for key in keys: + try: + current = current[key] + except KeyError: + return None + return current class ConfigModelItem: - def __init__(self, label, value="", is_section=False, state='default', parent=None): - self.itemData = [label, value] + def __init__(self, values=(), is_section=False, state='normal', default=None, parent=None): + values = list(values) + if is_section: + values[1:1] = ('
',) + + self.itemData = values self.is_section = is_section self.state = state + self.default = default self.childItems = [] self.parentItem = parent @@ -27,13 +44,8 @@ class ConfigModelItem: def addChildren(self, items, row): if row == -1: - self.childItems.extend(items) - else: - #row -= 1 - print('row', row) - self.childItems[row:row] = items - - print(self.childItems) + row = 0 + self.childItems[row:row] = items for item in items: item.parentItem = self @@ -45,7 +57,7 @@ class ConfigModelItem: return len(self.childItems) def columnCount(self): - return 2 + return len(self.itemData) def data(self, column): try: @@ -54,6 +66,9 @@ class ConfigModelItem: return None def set_data(self, data, column): + if self.data(column) is None: + data = literal_eval(data) if data else None + try: self.itemData[column] = data except IndexError: @@ -67,41 +82,53 @@ class ConfigModelItem: def row(self): if self.parentItem is not None: return self.parentItem.childItems.index(self) - return 0 def removeChild(self, position): if position < 0 or position > len(self.childItems): return False - print('removing', position) child = self.childItems.pop(position) child.parentItem = None return True - def removeChildren(self, row, count): - print(range(row, row+count)) - for pos in range(row, row+count): - self.removeChild(pos) - - return True - def __repr__(self): return str(self.itemData) -class ConfigModel(QtCore.QAbstractItemModel): - def __init__(self, data, parent=None): - super(ConfigModel, self).__init__(parent) +def ensure_unique_names(item, include_self=True): + name = item.data(0) + siblings_names = [child.data(0) for child in item.parent().childItems] + if not include_self: + siblings_names.remove(name) - self.rootItem = ConfigModelItem("Option", "Value") - self.setup(data) + print(siblings_names, name) + + while name in siblings_names: + if '_copy' in name: + spl = name.split('_copy') + num = int(spl[1]) if spl[1] else 0 + num += 1 + name = spl[0] + '_copy' + str(num) + else: + name = name + '_copy' + + item.set_data(name, 0) + + +class ConfigModel(QtCore.QAbstractItemModel): + def __init__(self, parent=None, widget=None, + headers=("Option", "Value", 'Comment', 'Inline Comment')): + super(ConfigModel, self).__init__(parent) + self.widget = widget + + self.rootItem = ConfigModelItem(headers) def headerData(self, section, orientation, role): if role == Qt.DisplayRole and orientation == Qt.Horizontal: return self.rootItem.data(section) def columnCount(self, parent): - return 2 + return self.rootItem.columnCount() def rowCount(self, parent): if parent.column() > 0: @@ -154,14 +181,19 @@ class ConfigModel(QtCore.QAbstractItemModel): return None - @QtCore.pyqtSlot() def setData(self, index, value, role=Qt.EditRole): if not index.isValid(): return False item = index.internalPointer() if role == Qt.EditRole: + if index.column() == 0 and (self.widget is not None) \ + and not self.widget.edit_caution(): + return False + item.set_data(value, index.column()) + if index.column() == 0: + ensure_unique_names(item) self.dataChanged.emit(index, index, (role,)) @@ -179,7 +211,7 @@ class ConfigModel(QtCore.QAbstractItemModel): if item.is_section: flags |= int(QtCore.Qt.ItemIsDropEnabled) - if index.column() == 1 and not item.is_section: + if not (index.column() == 1 and item.is_section): flags |= Qt.ItemIsEditable return flags @@ -188,45 +220,39 @@ class ConfigModel(QtCore.QAbstractItemModel): return QtCore.Qt.CopyAction | QtCore.Qt.MoveAction def mimeTypes(self): - return ['bstream', 'text/xml'] + return ['app/configitem', 'text/xml'] def mimeData(self, indexes): mimedata = QtCore.QMimeData() index = indexes[0] - mimedata.setData('bstream', pickle.dumps(self.nodeFromIndex(index))) + mimedata.setData('app/configitem', pickle.dumps(self.nodeFromIndex(index))) return mimedata def dropMimeData(self, mimedata, action, row, column, parentIndex): - print(action) - print('mim', row) if action == Qt.IgnoreAction: return True - parentNode = self.nodeFromIndex(parentIndex) - droppedNode = deepcopy(pickle.loads(mimedata.data('bstream'))) - print(droppedNode.itemData, 'node') - #droppedNode = pickle.loads(mimedata.data('bstream')) # - #self.removeRow()#self.index(row, column, parentIndex))#parentNode.child(row)) + droppedNode = deepcopy(pickle.loads(mimedata.data('app/configitem'))) + self.insertItems(row, [droppedNode], parentIndex) self.dataChanged.emit(parentIndex, parentIndex) + self.widget.ui.config_view.expandAll() + + if action & Qt.CopyAction: + return False # to not delete original item return True - def removeRows1(self, row, count, parent): - print('rem', row, count) - self.beginRemoveRows(parent, row, row+count-1) + def removeRows(self, row, count, parent): + self.beginRemoveRows(parent, row, row + count - 1) parentItem = self.nodeFromIndex(parent) - print(parentItem, parentItem.itemData) - #parentItem.removeChild(row) - parentItem.removeChildren(row, count) - print(parentItem.childItems) + for x in range(count): + parentItem.removeChild(row) self.endRemoveRows() - print('removed') return True - @QtCore.pyqtSlot() def removeRow(self, index): parent = index.parent() self.beginRemoveRows(parent, index.row(), index.row()) @@ -238,27 +264,61 @@ class ConfigModel(QtCore.QAbstractItemModel): return True def insertItems(self, row, items, parentIndex): - print('ins', row) parent = self.nodeFromIndex(parentIndex) - self.beginInsertRows(parentIndex, row, row+len(items)-1) + self.beginInsertRows(parentIndex, row, row + len(items) - 1) parent.addChildren(items, row) - print(parent.childItems) self.endInsertRows() self.dataChanged.emit(parentIndex, parentIndex) return True - def setup(self, data: dict, parent=None): + def get_key_sequence(self, index): + item = index.internalPointer() + keys = [] + while item is not None: + key = item.data(0) + keys.append(key) + item = item.parent() + return list(reversed(keys[:-1])) + + def dict_setup(self, data: dict, parent=None): if parent is None: parent = self.rootItem for key, value in data.items(): if isinstance(value, dict): - item = ConfigModelItem(key, parent=parent, is_section=True) - self.setup(value, parent=item) + item = ConfigModelItem((key,), parent=parent, is_section=True) + self.dict_setup(value, parent=item) else: - parent.appendChild(ConfigModelItem(key, value)) + parent.appendChild(ConfigModelItem((key, value))) + + def config_dict_setup(self, data: dict, parent=None): + if parent is None: + data.pop('initial_comment', ['']) + data.pop('final_comment', ['']) + parent = self.rootItem + + for key, item in data.items(): + if item.get('__option__', False): + # {'__option__': True, 'value': 'Copter config', 'default': 'Copter config', 'unchanged': False, 'comments': [], 'inline_comment': None} + value = item['value'] + default = item['default'] + comments = '\n'.join(item['comments']) + inline_comment = item['inline_comment'] + + if item['unchanged']: + state = 'unchanged' + elif value == default: + state = 'default' + else: + state = 'normal' + + parent.appendChild(ConfigModelItem((key, value, comments, inline_comment), state=state)) + + else: + section = ConfigModelItem((key,), parent=parent, is_section=True) + self.config_dict_setup(item, parent=section) def to_dict(self, parent=None) -> dict: if parent is None: @@ -266,81 +326,147 @@ class ConfigModel(QtCore.QAbstractItemModel): data = {} for item in parent.childItems: - item_name, item_data = item.itemData - if item.childItems: + item_name, item_data = item.data(0), item.data(1) + if item.is_section: data[item_name] = self.to_dict(item) else: data[item_name] = item_data return data + def to_config_dict(self, parent=None) -> dict: + if parent is None: + parent = self.rootItem + + for item in parent.childItems: + pass + + data = {} + + return data + @property def dict(self): return self.to_dict() -class ConfigDialog(config_editor.Ui_config_dialog): - def __init__(self, data): +class ConfigDialog(QtWidgets.QDialog): + def __init__(self): super(ConfigDialog, self).__init__() - self.model = ConfigModel(data) + self.ui = config_editor.Ui_config_dialog() + self.model = ConfigModel(widget=self) + self.setupUi() - def setupUi(self, config_dialog): - super(ConfigDialog, self).setupUi(config_dialog) + def setupModel(self, data, pure_dict=False): + if pure_dict: + self.model.dict_setup(data) + else: + self.model.config_dict_setup(data) - #self.config_view = Tree() + self.ui.config_view.expandAll() - self.config_view = Tree() - self.config_view.setObjectName("config_view") - self.config_view.setModel(self.model) - self.gridLayout.addWidget(self.config_view, 0, 0, 1, 1) + def setupUi(self): + self.ui.setupUi(self) - self.config_view.expandAll() - #self.config_view.setDragDropMode(True) - #self.setDragDropMode(QAbstractItemView.InternalMove) - #self.setDragEnabled(True) - #self.setAcceptDrops(True) - #self.setDropIndicatorShown(True) + self.ui.config_view = Tree() + self.ui.config_view.setObjectName("config_view") + self.ui.config_view.setModel(self.model) + self.ui.gridLayout.addWidget(self.ui.config_view, 0, 0, 1, 1) + self.ui.config_view.expandAll() - self.delete_button.pressed.connect(self.remove_selected) + # self.ui.delete_button.pressed.connect(self.remove_selected) - def remove_selected(self): - index = self.config_view.selectedIndexes()[0] - self.model.removeRow(index)\ + # index = self.config_view.selectedIndexes()[0] + + def edit_caution(self): + reply = QMessageBox().warning(self, "Editing caution", + "Are you sure you want to edit section/option name? " + "Proceed with caution!", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No + ) + return reply == QMessageBox.Yes class Tree(QTreeView): def __init__(self): QTreeView.__init__(self) - data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": {'subopt': 'bal'}}, - "section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}} - #model = ConfigModel(data) - #self.setModel(model) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.open_menu) self.setSelectionMode(self.SingleSelection) - self.setDragDropMode(QAbstractItemView.InternalMove) + # self.setSelectionBehavior(self.SelectItems) + + self.setDragDropMode(QAbstractItemView.DragDrop) + self.setDefaultDropAction(Qt.MoveAction) self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(True) - # def dropEvent(self, e): - # print(e.dropAction()==QtCore.Qt.MoveAction) - # if e.keyboardModifiers() & QtCore.Qt.AltModifier: - # e.setDropAction() - # print('copy') - # else: - # e.setDropAction(QtCore.Qt.MoveAction) - # print("drop") - # print(e) - # e.accept() + # self.header() + # header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) + # header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) + # self.resizeColumnToContents(1) + # header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents) + + self.setAnimated(True) + + def open_menu(self, point): + index = self.indexAt(point) + item = index.internalPointer() - def open_menu(self): menu = QMenu() - menu.addAction("Create new folder") + + duplicate = QAction("Duplicate") + duplicate.triggered.connect(partial(self.duplicate, index)) + menu.addAction(duplicate) + + remove = QAction("Remove from config") + remove.triggered.connect(partial(self.remove, index)) + menu.addAction(remove) + + menu.addSeparator() + + add_option = QAction("Add option") + add_option.triggered.connect(partial(self.add_item, index, False)) + menu.addAction(add_option) + + add_section = QAction("Add section") + add_section.triggered.connect(partial(self.add_item, index, True)) + + menu.addAction(add_section) + + if item is None: + duplicate.setDisabled(True) + remove.setDisabled(True) + menu.exec_(QCursor.pos()) + def duplicate(self, index): + item = deepcopy(index.internalPointer()) + ensure_unique_names(item) + self.model().insertItems(index.row() + 1, [item], index.parent()) + self.expandAll() # fix not expanded duplicated section + + def remove(self, index): + self.model().removeRow(index) + + def add_item(self, index, is_section): + prompt = 'Enter {} name'.format('section' if is_section else 'option') + text, ok = QInputDialog.getText(self, prompt, prompt) + if not ok: + return + + item = ConfigModelItem((text,), is_section=is_section) + row = index.row() + if row == -1: # to append at last position + parentItem = self.model().nodeFromIndex(index) + row = parentItem.childCount() - 1 + + self.model().insertItems(row + 1, [item], index.parent()) + ensure_unique_names(item, include_self=False) + + if __name__ == '__main__': import sys @@ -348,22 +474,47 @@ if __name__ == '__main__': def except_hook(cls, exception, traceback): sys.__excepthook__(cls, exception, traceback) + sys.excepthook = except_hook app = QtWidgets.QApplication(sys.argv) - Dialog = QtWidgets.QDialog() - data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, }}#"...": {'subopt': 'bal'}},} - #"section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}} + # data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": {'subopt': 'bal'}}, + # "section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}} + data = { + 'config_name': {'__option__': True, 'value': 'Copter config', 'default': 'Copter config', 'unchanged': False, + 'comments': [], 'inline_comment': None}, + 'config_version': {'__option__': True, 'value': 0.0, 'default': 0.0, 'unchanged': False, 'comments': [], + 'inline_comment': None}, 'SERVER': { + 'port': {'__option__': True, 'value': 25000, 'default': 25000, 'unchanged': False, 'comments': [], + 'inline_comment': None}, + 'host': {'__option__': True, 'value': '192.168.1.103', 'default': '192.168.1.101', 'unchanged': False, + 'comments': [], 'inline_comment': None}, + 'buffer_size': {'__option__': True, 'value': 1024, 'default': 1024, 'unchanged': False, 'comments': [], + 'inline_comment': None}}, 'BROADCAST': { + 'use': {'__option__': True, 'value': True, 'default': True, 'unchanged': False, 'comments': [], + 'inline_comment': None}, + 'port': {'__option__': True, 'value': 8181, 'default': 8181, 'unchanged': False, 'comments': [], + 'inline_comment': None}}, 'NTP': { + 'use': {'__option__': True, 'value': False, 'default': False, 'unchanged': False, 'comments': [], + 'inline_comment': None}, + 'port': {'__option__': True, 'value': 123, 'default': 123, 'unchanged': False, + 'comments': ['#host = ntp1.stratum2.ru'], 'inline_comment': None}, + 'host': {'__option__': True, 'value': 'ntp1.stratum2.ru', 'default': 'ntp1.stratum2.ru', 'unchanged': True, + 'comments': [], 'inline_comment': ''}}, 'PRIVATE': { + 'id': {'__option__': True, 'value': '/hostname', 'default': '/hostname', 'unchanged': False, + 'comments': ['# avialiable options: /hostname ; /default ; /ip ; any string 63 characters lengh'], + 'inline_comment': None}}, + 'initial_comment': ['# This is generated config_attrs with defaults', '# Modify to configure'], + 'final_comment': []} - ui = ConfigDialog(data) - ui.setupUi(Dialog) + ui = ConfigDialog() + ui.setupModel(data) + ui.show() - print(Qt.DisplayRole) - Dialog.show() print(app.exec_()) - print(Dialog.result()) + print(ui.result()) print(ui.model.to_dict()) sys.exit() From 1c522a67aa507dee281dbdcb56534a89689b9531 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 1 Dec 2019 17:07:47 +0300 Subject: [PATCH 010/210] Added name and version for configs --- Drone/config/spec/configspec_client.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index 870f205..1695d84 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -1,6 +1,9 @@ +config_name = string(default='Copter config') +config_version = float(default='0.0') + [SERVER] port = integer(default=25000) -host = ip_addr(default=192.168.1.101) #string? +host = ip_addr(default=192.168.1.101) # string? buffer_size = integer(default=1024) [BROADCAST] From dc6765d8e8bed2566210374820360c0d528749fb Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 1 Dec 2019 18:37:52 +0300 Subject: [PATCH 011/210] Added import/export to new dict format --- Server/config_editor_models.py | 38 +++++++++++++++++++++++++--------- config.py | 2 +- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 0c57a17..37f86ad 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -101,8 +101,6 @@ def ensure_unique_names(item, include_self=True): if not include_self: siblings_names.remove(name) - print(siblings_names, name) - while name in siblings_names: if '_copy' in name: spl = name.split('_copy') @@ -122,6 +120,8 @@ class ConfigModel(QtCore.QAbstractItemModel): self.widget = widget self.rootItem = ConfigModelItem(headers) + self.initial_comment = '' + self.final_comment = '' def headerData(self, section, orientation, role): if role == Qt.DisplayRole and orientation == Qt.Horizontal: @@ -295,17 +295,18 @@ class ConfigModel(QtCore.QAbstractItemModel): def config_dict_setup(self, data: dict, parent=None): if parent is None: - data.pop('initial_comment', ['']) - data.pop('final_comment', ['']) parent = self.rootItem + self.initial_comment = '\n'.join(data.pop('initial_comment', [''])) + self.final_comment = '\n'.join(data.pop('final_comment', [''])) + for key, item in data.items(): if item.get('__option__', False): # {'__option__': True, 'value': 'Copter config', 'default': 'Copter config', 'unchanged': False, 'comments': [], 'inline_comment': None} value = item['value'] default = item['default'] - comments = '\n'.join(item['comments']) - inline_comment = item['inline_comment'] + comments = '\n'.join(item['comments']) or '' + inline_comment = item['inline_comment'] or '' if item['unchanged']: state = 'unchanged' @@ -314,7 +315,8 @@ class ConfigModel(QtCore.QAbstractItemModel): else: state = 'normal' - parent.appendChild(ConfigModelItem((key, value, comments, inline_comment), state=state)) + parent.appendChild(ConfigModelItem((key, value, comments, inline_comment), + state=state, default=default)) else: section = ConfigModelItem((key,), parent=parent, is_section=True) @@ -335,13 +337,28 @@ class ConfigModel(QtCore.QAbstractItemModel): return data def to_config_dict(self, parent=None) -> dict: + data = {} + if parent is None: parent = self.rootItem + data['initial_comment'] = self.initial_comment.split('\n') + data['final_comment'] = self.final_comment.split('\n') for item in parent.childItems: - pass + key = item.data(1) - data = {} + if item.is_section: + data[key] = self.to_config_dict(item) + elif item.state != 'unchanged': + d = {'__option__': True, + 'value': item.data(0), + # 'default': item.default, + # 'unchanged': False, + 'comments': (item.data(2) or '').split('\n'), + 'inline_comment': item.data(3) or '' + } + + data[key] = d return data @@ -503,7 +520,7 @@ if __name__ == '__main__': 'host': {'__option__': True, 'value': 'ntp1.stratum2.ru', 'default': 'ntp1.stratum2.ru', 'unchanged': True, 'comments': [], 'inline_comment': ''}}, 'PRIVATE': { 'id': {'__option__': True, 'value': '/hostname', 'default': '/hostname', 'unchanged': False, - 'comments': ['# avialiable options: /hostname ; /default ; /ip ; any string 63 characters lengh'], + 'comments': ['# avialiable options: /hostname ; /default ; /ip ; any string 63 characters lengh', 'newlibe'], 'inline_comment': None}}, 'initial_comment': ['# This is generated config_attrs with defaults', '# Modify to configure'], 'final_comment': []} @@ -516,5 +533,6 @@ if __name__ == '__main__': print(ui.result()) print(ui.model.to_dict()) + print(ui.model.to_config_dict()) sys.exit() diff --git a/config.py b/config.py index bb2c102..289f266 100644 --- a/config.py +++ b/config.py @@ -23,7 +23,7 @@ class ConfigManager: result = {} for key, val in d.items(): if isinstance(val, dict) and val.get('__option__', False): - if not val.get('unchanged'): + if not val.get('unchanged', False): result[key] = val.get('value') else: result[key] = cls._extract_values(val) From a98d746389c94fd17d0249a69854c594653b8660 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 1 Dec 2019 19:40:10 +0300 Subject: [PATCH 012/210] Basic color indication --- Server/config_editor_models.py | 85 ++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 5 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 37f86ad..204bb7c 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -21,16 +21,31 @@ def dict_walk(d: dict, keys): return current +states_colors = { + 'normal': Qt.white, #Qt.black, + 'unchanged': Qt.darkGray, + 'default': Qt.lightGray, + 'edited': Qt.yellow, + 'added': Qt.green, + 'deleted': Qt.red, +} + + class ConfigModelItem: def __init__(self, values=(), is_section=False, state='normal', default=None, parent=None): + self.default = default + values = list(values) if is_section: values[1:1] = ('
',) + self.default = values[1] self.itemData = values self.is_section = is_section self.state = state - self.default = default + + self.defaults = deepcopy(self.itemData) + self.default_state = state self.childItems = [] self.parentItem = parent @@ -38,6 +53,24 @@ class ConfigModelItem: if self.parentItem is not None: self.parentItem.appendChild(self) + def reset(self): + self.set_data(self.default, 1) + + self.check_state() + if self.default_state == 'unchanged': + self.set_state('unchanged') + + for child in self.childItems: + child.reset() + + def reset_all(self): + self.itemData = self.defaults + print(self.itemData, self.defaults) + self.set_state(self.default_state) + + for child in self.childItems: + child.reset() + def appendChild(self, item): self.childItems.append(item) item.parentItem = self @@ -66,16 +99,39 @@ class ConfigModelItem: return None def set_data(self, data, column): - if self.data(column) is None: + old_data = self.data(column) + if old_data is None: data = literal_eval(data) if data else None + print(data, old_data) + try: self.itemData[column] = data except IndexError: return False + print(self.itemData[column]) + + if old_data != data: + self.set_state('edited') + self.check_state() + return True + def check_state(self): + if self.default is not None and self.data(1) == self.default: + self.set_state('default') + + def set_state(self, state): + # if self.state == 'unchanged' and state == 'default': + # return + if self.state == 'added' and state in ('edited', 'unchanged', 'default', 'normal'): + return + + self.state = state + for child in self.childItems: + child.set_state(state) + def parent(self): return self.parentItem @@ -178,6 +234,8 @@ class ConfigModel(QtCore.QAbstractItemModel): if role == Qt.DisplayRole or role == Qt.EditRole: return item.data(index.column()) + if role == Qt.BackgroundRole: #Qt.BackgroundRole: + return QtGui.QBrush(states_colors[item.state]) return None @@ -211,7 +269,7 @@ class ConfigModel(QtCore.QAbstractItemModel): if item.is_section: flags |= int(QtCore.Qt.ItemIsDropEnabled) - if not (index.column() == 1 and item.is_section): + if not (index.column() > 0 and item.is_section): flags |= Qt.ItemIsEditable return flags @@ -444,13 +502,22 @@ class Tree(QTreeView): menu.addSeparator() + reset = QAction("Reset value to default") + reset.triggered.connect(partial(self.reset_item, index, False)) + menu.addAction(reset) + + reset_all = QAction("Reset all data") + reset_all.triggered.connect(partial(self.reset_item, index, True)) + menu.addAction(reset_all) + + menu.addSeparator() + add_option = QAction("Add option") add_option.triggered.connect(partial(self.add_item, index, False)) menu.addAction(add_option) add_section = QAction("Add section") add_section.triggered.connect(partial(self.add_item, index, True)) - menu.addAction(add_section) if item is None: @@ -461,6 +528,7 @@ class Tree(QTreeView): def duplicate(self, index): item = deepcopy(index.internalPointer()) + item.set_state('added') ensure_unique_names(item) self.model().insertItems(index.row() + 1, [item], index.parent()) self.expandAll() # fix not expanded duplicated section @@ -474,7 +542,7 @@ class Tree(QTreeView): if not ok: return - item = ConfigModelItem((text,), is_section=is_section) + item = ConfigModelItem((text, None, '', ''), is_section=is_section, state='added') row = index.row() if row == -1: # to append at last position parentItem = self.model().nodeFromIndex(index) @@ -483,6 +551,13 @@ class Tree(QTreeView): self.model().insertItems(row + 1, [item], index.parent()) ensure_unique_names(item, include_self=False) + def reset_item(self, index, reset_all): + item = index.internalPointer() + if reset_all: + item.reset_all() + else: + item.reset() + if __name__ == '__main__': import sys From b73b65c09f2e5bb376a73203a8fec73b80f34d8d Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 1 Dec 2019 21:06:57 +0300 Subject: [PATCH 013/210] gui+functionality update --- Server/config_editor.py | 56 +++++++++---------------- Server/config_editor.ui | 76 +++++++++++----------------------- Server/config_editor_models.py | 59 ++++++++++++++++---------- 3 files changed, 81 insertions(+), 110 deletions(-) diff --git a/Server/config_editor.py b/Server/config_editor.py index a498c63..e6e5f67 100644 --- a/Server/config_editor.py +++ b/Server/config_editor.py @@ -17,44 +17,33 @@ class Ui_config_dialog(object): config_dialog.setModal(False) self.gridLayout = QtWidgets.QGridLayout(config_dialog) self.gridLayout.setObjectName("gridLayout") - self.gridLayout_2 = QtWidgets.QGridLayout() - self.gridLayout_2.setObjectName("gridLayout_2") - self.checkBox = QtWidgets.QCheckBox(config_dialog) - self.checkBox.setObjectName("checkBox") - self.gridLayout_2.addWidget(self.checkBox, 0, 0, 1, 1) - self.buttonBox = QtWidgets.QDialogButtonBox(config_dialog) - self.buttonBox.setOrientation(QtCore.Qt.Horizontal) - self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Save) - self.buttonBox.setCenterButtons(False) - self.buttonBox.setObjectName("buttonBox") - self.gridLayout_2.addWidget(self.buttonBox, 0, 1, 1, 1) - self.gridLayout.addLayout(self.gridLayout_2, 3, 0, 1, 1) self.config_view = QtWidgets.QTreeView(config_dialog) self.config_view.setEditTriggers(QtWidgets.QAbstractItemView.DoubleClicked|QtWidgets.QAbstractItemView.EditKeyPressed|QtWidgets.QAbstractItemView.SelectedClicked) self.config_view.setObjectName("config_view") self.config_view.header().setCascadingSectionResizes(False) self.config_view.header().setDefaultSectionSize(250) self.gridLayout.addWidget(self.config_view, 0, 0, 1, 1) - self.gridLayout_3 = QtWidgets.QGridLayout() - self.gridLayout_3.setObjectName("gridLayout_3") - self.delete_button = QtWidgets.QPushButton(config_dialog) - self.delete_button.setObjectName("delete_button") - self.gridLayout_3.addWidget(self.delete_button, 0, 1, 1, 1) - self.add_option_button = QtWidgets.QPushButton(config_dialog) - self.add_option_button.setObjectName("add_option_button") - self.gridLayout_3.addWidget(self.add_option_button, 0, 3, 1, 1) - self.add_section_button = QtWidgets.QPushButton(config_dialog) - self.add_section_button.setObjectName("add_section_button") - self.gridLayout_3.addWidget(self.add_section_button, 0, 2, 1, 1) - self.hard_delete = QtWidgets.QPushButton(config_dialog) - self.hard_delete.setObjectName("hard_delete") - self.gridLayout_3.addWidget(self.hard_delete, 0, 0, 1, 1) - self.gridLayout.addLayout(self.gridLayout_3, 1, 0, 1, 1) + self.gridLayout_2 = QtWidgets.QGridLayout() + self.gridLayout_2.setObjectName("gridLayout_2") + self.buttonBox = QtWidgets.QDialogButtonBox(config_dialog) + self.buttonBox.setOrientation(QtCore.Qt.Horizontal) + self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Save) + self.buttonBox.setCenterButtons(False) + self.buttonBox.setObjectName("buttonBox") + self.gridLayout_2.addWidget(self.buttonBox, 0, 2, 1, 1) + self.do_restart = QtWidgets.QCheckBox(config_dialog) + self.do_restart.setObjectName("do_restart") + self.gridLayout_2.addWidget(self.do_restart, 0, 1, 1, 1) + self.do_coloring = QtWidgets.QCheckBox(config_dialog) + self.do_coloring.setChecked(True) + self.do_coloring.setObjectName("do_coloring") + self.gridLayout_2.addWidget(self.do_coloring, 0, 0, 1, 1) + self.gridLayout.addLayout(self.gridLayout_2, 2, 0, 1, 1) self.line = QtWidgets.QFrame(config_dialog) self.line.setFrameShape(QtWidgets.QFrame.HLine) self.line.setFrameShadow(QtWidgets.QFrame.Sunken) self.line.setObjectName("line") - self.gridLayout.addWidget(self.line, 2, 0, 1, 1) + self.gridLayout.addWidget(self.line, 1, 0, 1, 1) self.retranslateUi(config_dialog) self.buttonBox.accepted.connect(config_dialog.accept) @@ -64,14 +53,9 @@ class Ui_config_dialog(object): def retranslateUi(self, config_dialog): _translate = QtCore.QCoreApplication.translate config_dialog.setWindowTitle(_translate("config_dialog", "Config Editor")) - self.checkBox.setText(_translate("config_dialog", "Restart")) - self.checkBox.setShortcut(_translate("config_dialog", "R")) - self.delete_button.setText(_translate("config_dialog", "Delete")) - self.delete_button.setShortcut(_translate("config_dialog", "Del")) - self.add_option_button.setText(_translate("config_dialog", "Add option")) - self.add_section_button.setText(_translate("config_dialog", "Add section")) - self.add_section_button.setShortcut(_translate("config_dialog", "Ctrl+A")) - self.hard_delete.setText(_translate("config_dialog", "Mark for deletion")) + self.do_restart.setText(_translate("config_dialog", "Restart")) + self.do_restart.setShortcut(_translate("config_dialog", "R")) + self.do_coloring.setText(_translate("config_dialog", "Color Indication")) if __name__ == "__main__": diff --git a/Server/config_editor.ui b/Server/config_editor.ui index f33760b..6527d93 100644 --- a/Server/config_editor.ui +++ b/Server/config_editor.ui @@ -17,33 +17,6 @@ false - - - - - - Restart - - - R - - - - - - - Qt::Horizontal - - - QDialogButtonBox::Cancel|QDialogButtonBox::Save - - - false - - - - - @@ -57,45 +30,44 @@ - - - - - - Delete - - - Del - - - - - - - Add option - - - + + - + + + Qt::Horizontal + + + QDialogButtonBox::Cancel|QDialogButtonBox::Save + + + false + + + + + - Add section + Restart - Ctrl+A + R - + - Mark for deletion + Color Indication + + + true - + Qt::Horizontal diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 204bb7c..a184342 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -22,7 +22,7 @@ def dict_walk(d: dict, keys): states_colors = { - 'normal': Qt.white, #Qt.black, + 'normal': Qt.white, 'unchanged': Qt.darkGray, 'default': Qt.lightGray, 'edited': Qt.yellow, @@ -33,18 +33,18 @@ states_colors = { class ConfigModelItem: def __init__(self, values=(), is_section=False, state='normal', default=None, parent=None): - self.default = default + self.spec_default = default values = list(values) if is_section: values[1:1] = ('
',) - self.default = values[1] + self.spec_default = values[1] self.itemData = values - self.is_section = is_section + self.type = 'section' if is_section else None self.state = state - self.defaults = deepcopy(self.itemData) + self.default_values = deepcopy(self.itemData) self.default_state = state self.childItems = [] @@ -53,8 +53,12 @@ class ConfigModelItem: if self.parentItem is not None: self.parentItem.appendChild(self) + @property + def is_section(self): + return self.type == 'section' + def reset(self): - self.set_data(self.default, 1) + self.set_data(self.spec_default, 1) self.check_state() if self.default_state == 'unchanged': @@ -64,8 +68,7 @@ class ConfigModelItem: child.reset() def reset_all(self): - self.itemData = self.defaults - print(self.itemData, self.defaults) + self.itemData = self.default_values self.set_state(self.default_state) for child in self.childItems: @@ -103,15 +106,11 @@ class ConfigModelItem: if old_data is None: data = literal_eval(data) if data else None - print(data, old_data) - try: self.itemData[column] = data except IndexError: return False - print(self.itemData[column]) - if old_data != data: self.set_state('edited') self.check_state() @@ -119,7 +118,8 @@ class ConfigModelItem: return True def check_state(self): - if self.default is not None and self.data(1) == self.default: + if self.spec_default is not None and self.data(1) == self.spec_default \ + and self.data(0) == self.default_values[0]: self.set_state('default') def set_state(self, state): @@ -176,9 +176,16 @@ class ConfigModel(QtCore.QAbstractItemModel): self.widget = widget self.rootItem = ConfigModelItem(headers) + self.do_color = True + self.initial_comment = '' self.final_comment = '' + @QtCore.pyqtSlot(int) + def enable_color(self, value): + self.do_color = value + self.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex(), (Qt.BackgroundRole, )) + def headerData(self, section, orientation, role): if role == Qt.DisplayRole and orientation == Qt.Horizontal: return self.rootItem.data(section) @@ -234,7 +241,7 @@ class ConfigModel(QtCore.QAbstractItemModel): if role == Qt.DisplayRole or role == Qt.EditRole: return item.data(index.column()) - if role == Qt.BackgroundRole: #Qt.BackgroundRole: + if role == Qt.BackgroundRole and self.do_color: return QtGui.QBrush(states_colors[item.state]) return None @@ -246,12 +253,13 @@ class ConfigModel(QtCore.QAbstractItemModel): item = index.internalPointer() if role == Qt.EditRole: if index.column() == 0 and (self.widget is not None) \ - and not self.widget.edit_caution(): - return False + and value != item.data(index.column()): + if not self.widget.edit_caution(): + return False item.set_data(value, index.column()) if index.column() == 0: - ensure_unique_names(item) + ensure_unique_names(item, include_self=False) self.dataChanged.emit(index, index, (role,)) @@ -403,13 +411,16 @@ class ConfigModel(QtCore.QAbstractItemModel): data['final_comment'] = self.final_comment.split('\n') for item in parent.childItems: - key = item.data(1) + key = item.data(0) if item.is_section: - data[key] = self.to_config_dict(item) + d = self.to_config_dict(item) + if d: # to prevent empty sections + data[key] = d + elif item.state != 'unchanged': d = {'__option__': True, - 'value': item.data(0), + 'value': item.data(1), # 'default': item.default, # 'unchanged': False, 'comments': (item.data(2) or '').split('\n'), @@ -449,6 +460,8 @@ class ConfigDialog(QtWidgets.QDialog): self.ui.gridLayout.addWidget(self.ui.config_view, 0, 0, 1, 1) self.ui.config_view.expandAll() + self.ui.do_coloring.stateChanged.connect(self.model.enable_color) + # self.ui.delete_button.pressed.connect(self.remove_selected) # index = self.config_view.selectedIndexes()[0] @@ -521,6 +534,8 @@ class Tree(QTreeView): menu.addAction(add_section) if item is None: + reset.setDisabled(True) + reset_all.setDisabled(True) duplicate.setDisabled(True) remove.setDisabled(True) @@ -594,10 +609,10 @@ if __name__ == '__main__': 'comments': ['#host = ntp1.stratum2.ru'], 'inline_comment': None}, 'host': {'__option__': True, 'value': 'ntp1.stratum2.ru', 'default': 'ntp1.stratum2.ru', 'unchanged': True, 'comments': [], 'inline_comment': ''}}, 'PRIVATE': { - 'id': {'__option__': True, 'value': '/hostname', 'default': '/hostname', 'unchanged': False, + 'id': {'__option__': True, 'value': '/hostname', 'default': '/hostname', 'unchanged': True, 'comments': ['# avialiable options: /hostname ; /default ; /ip ; any string 63 characters lengh', 'newlibe'], 'inline_comment': None}}, - 'initial_comment': ['# This is generated config_attrs with defaults', '# Modify to configure'], + 'initial_comment': ['# This is generated config_attrs with default_values', '# Modify to configure'], 'final_comment': []} ui = ConfigDialog() From 74186e5c5be2f1e8f890b566f9a70f495e15987e Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 1 Dec 2019 21:07:17 +0300 Subject: [PATCH 014/210] Added offest list to configspec --- Drone/config/spec/configspec_client.ini | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index 1695d84..d1de342 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -16,5 +16,8 @@ host = string(default=ntp1.stratum2.ru) port = integer(default=123) [PRIVATE] -# avialiable options: /hostname ; /default ; /ip ; any string 63 characters lengh +# avialiable options: /hostname ; /spec_default ; /ip ; any string 63 characters lengh id = string(default=/hostname, max=63) +# Drone's individual offset +# __list__ X Y Z +offset = float_list(default=list(0, 0, 0), min=3, max=3) From a39970deca6c964ebeb689eb1c516dd10688e9cf Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 4 Dec 2019 00:10:28 +0300 Subject: [PATCH 015/210] List options and many improvemnts --- Server/config_editor_models.py | 350 +++++++++++++++++++++++---------- 1 file changed, 243 insertions(+), 107 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index a184342..198b59a 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -30,19 +30,19 @@ states_colors = { 'deleted': Qt.red, } +StateRole = 999 +TypeRole = 998 class ConfigModelItem: - def __init__(self, values=(), is_section=False, state='normal', default=None, parent=None): + def __init__(self, values=(None, None, None, None), item_type='option', + state='normal', default=None, parent=None): self.spec_default = default - - values = list(values) - if is_section: - values[1:1] = ('
',) - self.spec_default = values[1] - - self.itemData = values - self.type = 'section' if is_section else None + self.itemData = list(values) self.state = state + self.type = item_type + + if isinstance(self.data(1), list): + self.type = 'list' self.default_values = deepcopy(self.itemData) self.default_state = state @@ -50,30 +50,55 @@ class ConfigModelItem: self.childItems = [] self.parentItem = parent + self.setup_type() + if self.parentItem is not None: self.parentItem.appendChild(self) + def setup_type(self): + if self.type == 'section': + self.itemData[1:1] = ('
',) + self.spec_default = self.data(1) + + elif self.type == 'list': + self._setup_list(self.get_list_items()) + + def _get_list_spec(self): + data = self.data(1) + comments = self.data(2) + if comments: + try: + raw_spec = comments.split('\n')[-1].split()[1:] + print(raw_spec) + if raw_spec[0] == '__list__': # and len(raw_spec[1:]) == len(data): + return raw_spec[1:] + except IndexError: + pass + return list(map(str, range(len(data)))) + + def get_list_items(self): + spec = self._get_list_spec() + values = self.data(1) + if isinstance(self.spec_default, list): + defaults = self.spec_default + else: + defaults = (None, )*len(spec) + + self.itemData[1] = ''.format(' '.join(spec)) + # self.spec_default = self.itemData[1] + + for key, value, default in zip(spec, values, defaults): + yield ConfigModelItem((key, value, None, None), item_type='list_item', + state=self.state, default=default) + + def _setup_list(self, items): # use only at initialization + for child in items: + self.appendChild(child) + @property - def is_section(self): + def is_section(self): # probably deprecated return self.type == 'section' - def reset(self): - self.set_data(self.spec_default, 1) - - self.check_state() - if self.default_state == 'unchanged': - self.set_state('unchanged') - - for child in self.childItems: - child.reset() - - def reset_all(self): - self.itemData = self.default_values - self.set_state(self.default_state) - - for child in self.childItems: - child.reset() - def appendChild(self, item): self.childItems.append(item) item.parentItem = self @@ -104,7 +129,13 @@ class ConfigModelItem: def set_data(self, data, column): old_data = self.data(column) if old_data is None: - data = literal_eval(data) if data else None + try: + data = literal_eval(data) if data else None + except (SyntaxError, ValueError): + data = str(data) + + if data == '': + data = [] try: self.itemData[column] = data @@ -123,15 +154,23 @@ class ConfigModelItem: self.set_state('default') def set_state(self, state): - # if self.state == 'unchanged' and state == 'default': - # return + if self.state == 'unchanged' and state == 'default': + return + if self.state == 'added' and state in ('edited', 'unchanged', 'default', 'normal'): return self.state = state + for child in self.childItems: child.set_state(state) + if state == 'edited': + self.parentItem.state = state + + def set_type(self, item_type): + self.type = item_type + def parent(self): return self.parentItem @@ -204,6 +243,12 @@ class ConfigModel(QtCore.QAbstractItemModel): return parentItem.childCount() + def childrenIndexes(self, parent): + column = parent.column() + parent = self.index(parent.row(), 0, parent.parent()) + for i in range(self.rowCount(parent)): + yield self.index(i, column, parent) + def index(self, row, column, parent): if not self.hasIndex(row, column, parent): return QtCore.QModelIndex() @@ -221,13 +266,19 @@ class ConfigModel(QtCore.QAbstractItemModel): return QtCore.QModelIndex() childItem = index.internalPointer() + if not isinstance(childItem, ConfigModelItem): + print(childItem, index.column()),# index.row(), index.parent().internalPointer()) + return QtCore.QModelIndex() parentItem = childItem.parent() - if parentItem == self.rootItem or parentItem is None: + if parentItem == self.rootItem: #or parentItem is None: return QtCore.QModelIndex() return self.createIndex(parentItem.row(), 0, parentItem) + def modifyCol(self, index, col): + return self.index(index.row(), col, index.parent()) + def nodeFromIndex(self, index): if index.isValid(): return index.internalPointer() @@ -241,9 +292,15 @@ class ConfigModel(QtCore.QAbstractItemModel): if role == Qt.DisplayRole or role == Qt.EditRole: return item.data(index.column()) + if role == Qt.BackgroundRole and self.do_color: return QtGui.QBrush(states_colors[item.state]) + if role == StateRole: + return item.state + if role == TypeRole: + return item.type + return None def setData(self, index, value, role=Qt.EditRole): @@ -251,16 +308,34 @@ class ConfigModel(QtCore.QAbstractItemModel): return False item = index.internalPointer() + if role == Qt.EditRole: - if index.column() == 0 and (self.widget is not None) \ - and value != item.data(index.column()): + column = index.column() + + if column == 0 and value != item.data(column): if not self.widget.edit_caution(): return False - item.set_data(value, index.column()) - if index.column() == 0: + item.set_data(value, column) + + if column == 0: ensure_unique_names(item, include_self=False) + elif column == 1 and isinstance(item.data(1), (list, tuple)) \ + and item.type not in ('list', 'list_item'): + + item.set_type('list') + self.insertItems(0, list(item.get_list_items()), index) + self.widget.ui.config_view.expandAll() + + elif role == StateRole: + item.set_state(value) + + elif role == TypeRole: + # if value != item.type and value == 'list': # when list is created: + # pass + item.set_type(value) + self.dataChanged.emit(index, index, (role,)) return True @@ -273,11 +348,17 @@ class ConfigModel(QtCore.QAbstractItemModel): flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable if index.column() == 0: - flags |= int(QtCore.Qt.ItemIsDragEnabled) - if item.is_section: + if item.type != 'list_item': + flags |= int(QtCore.Qt.ItemIsDragEnabled) + + if item.type == 'section': flags |= int(QtCore.Qt.ItemIsDropEnabled) - if not (index.column() > 0 and item.is_section): + not_section = not (index.column() > 0 and item.type == 'section') + not_list_item = not (index.column() > 1 and item.type == 'list_item') + not_list_val = not (index.column() == 1 and item.type == 'list') + + if not_section and not_list_item and not_list_val: flags |= Qt.ItemIsEditable return flags @@ -312,8 +393,7 @@ class ConfigModel(QtCore.QAbstractItemModel): def removeRows(self, row, count, parent): self.beginRemoveRows(parent, row, row + count - 1) parentItem = self.nodeFromIndex(parent) - - for x in range(count): + for _ in range(count): parentItem.removeChild(row) self.endRemoveRows() @@ -331,15 +411,20 @@ class ConfigModel(QtCore.QAbstractItemModel): def insertItems(self, row, items, parentIndex): parent = self.nodeFromIndex(parentIndex) - self.beginInsertRows(parentIndex, row, row + len(items) - 1) + self.beginInsertRows(parentIndex, row, row + len(items) - 1) # parentIndex or QtCore.QModelIndex() parent.addChildren(items, row) self.endInsertRows() - self.dataChanged.emit(parentIndex, parentIndex) + self.update_all() + return True - def get_key_sequence(self, index): + def update_all(self): + self.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex()) + + @staticmethod + def get_key_sequence(index): # yet unused item = index.internalPointer() keys = [] while item is not None: @@ -354,10 +439,10 @@ class ConfigModel(QtCore.QAbstractItemModel): for key, value in data.items(): if isinstance(value, dict): - item = ConfigModelItem((key,), parent=parent, is_section=True) + item = ConfigModelItem((key,), parent=parent, item_type='section') self.dict_setup(value, parent=item) else: - parent.appendChild(ConfigModelItem((key, value))) + parent.appendChild(ConfigModelItem((key, value, '', ''))) def config_dict_setup(self, data: dict, parent=None): if parent is None: @@ -368,7 +453,6 @@ class ConfigModel(QtCore.QAbstractItemModel): for key, item in data.items(): if item.get('__option__', False): - # {'__option__': True, 'value': 'Copter config', 'default': 'Copter config', 'unchanged': False, 'comments': [], 'inline_comment': None} value = item['value'] default = item['default'] comments = '\n'.join(item['comments']) or '' @@ -385,7 +469,7 @@ class ConfigModel(QtCore.QAbstractItemModel): state=state, default=default)) else: - section = ConfigModelItem((key,), parent=parent, is_section=True) + section = ConfigModelItem((key,), parent=parent, item_type='section') self.config_dict_setup(item, parent=section) def to_dict(self, parent=None) -> dict: @@ -418,9 +502,14 @@ class ConfigModel(QtCore.QAbstractItemModel): if d: # to prevent empty sections data[key] = d - elif item.state != 'unchanged': + elif item.state not in ('unchanged', 'deleted'): + if item.type == 'list': + value = [child.data(1) for child in item.childItems] + else: + value = item.data(1) + d = {'__option__': True, - 'value': item.data(1), + 'value': value, # 'default': item.default, # 'unchanged': False, 'comments': (item.data(2) or '').split('\n'), @@ -454,7 +543,7 @@ class ConfigDialog(QtWidgets.QDialog): def setupUi(self): self.ui.setupUi(self) - self.ui.config_view = Tree() + self.ui.config_view = ConfigTreeWidget() self.ui.config_view.setObjectName("config_view") self.ui.config_view.setModel(self.model) self.ui.gridLayout.addWidget(self.ui.config_view, 0, 0, 1, 1) @@ -475,7 +564,7 @@ class ConfigDialog(QtWidgets.QDialog): return reply == QMessageBox.Yes -class Tree(QTreeView): +class ConfigTreeWidget(QTreeView): def __init__(self): QTreeView.__init__(self) @@ -491,12 +580,6 @@ class Tree(QTreeView): self.setAcceptDrops(True) self.setDropIndicatorShown(True) - # self.header() - # header.setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch) - # header.setSectionResizeMode(1, QtWidgets.QHeaderView.ResizeToContents) - # self.resizeColumnToContents(1) - # header.setSectionResizeMode(QtWidgets.QHeaderView.ResizeToContents) - self.setAnimated(True) def open_menu(self, point): @@ -509,18 +592,26 @@ class Tree(QTreeView): duplicate.triggered.connect(partial(self.duplicate, index)) menu.addAction(duplicate) + exclude = QAction("Toggle exclude") + exclude.triggered.connect(partial(self.exclude, index)) + menu.addAction(exclude) + remove = QAction("Remove from config") remove.triggered.connect(partial(self.remove, index)) menu.addAction(remove) menu.addSeparator() - reset = QAction("Reset value to default") - reset.triggered.connect(partial(self.reset_item, index, False)) - menu.addAction(reset) + clear = QAction("Clear item value") + clear.triggered.connect(partial(self.reset_item, index, 'clear_value')) + menu.addAction(clear) - reset_all = QAction("Reset all data") - reset_all.triggered.connect(partial(self.reset_item, index, True)) + reset_default = QAction("Reset value to default") + reset_default.triggered.connect(partial(self.reset_item, index, 'default')) + menu.addAction(reset_default) + + reset_all = QAction("Reset all changes") + reset_all.triggered.connect(partial(self.reset_item, index, 'all')) menu.addAction(reset_all) menu.addSeparator() @@ -534,10 +625,22 @@ class Tree(QTreeView): menu.addAction(add_section) if item is None: - reset.setDisabled(True) + clear.setDisabled(True) reset_all.setDisabled(True) + reset_default.setDisabled(True) + duplicate.setDisabled(True) remove.setDisabled(True) + exclude.setDisabled(True) + else: + if item.type in ('list', 'list_item'): + add_section.setDisabled(True) + + if item.type == 'list': + clear.setDisabled(True) # Temporary, cuz buggg + + # if item.type == 'section': + # clear.setDisabled(True) menu.exec_(QCursor.pos()) @@ -551,69 +654,102 @@ class Tree(QTreeView): def remove(self, index): self.model().removeRow(index) + def exclude(self, index): + item = self.model().nodeFromIndex(index) + #i + if item.state == 'deleted': + self.model().setData(index, item.default_state, StateRole) + else: + self.model().setData(index, 'deleted', StateRole) + def add_item(self, index, is_section): - prompt = 'Enter {} name'.format('section' if is_section else 'option') + parentItem = self.model().nodeFromIndex(index) + + if parentItem.type in ('list', 'list_item'): + item_type = 'list_item' + else: + item_type = 'section' if is_section else 'option' + + prompt = 'Enter {} name'.format(item_type.replace('_', ' ')) text, ok = QInputDialog.getText(self, prompt, prompt) if not ok: return - item = ConfigModelItem((text, None, '', ''), is_section=is_section, state='added') - row = index.row() - if row == -1: # to append at last position - parentItem = self.model().nodeFromIndex(index) - row = parentItem.childCount() - 1 - - self.model().insertItems(row + 1, [item], index.parent()) - ensure_unique_names(item, include_self=False) - - def reset_item(self, index, reset_all): - item = index.internalPointer() - if reset_all: - item.reset_all() + if parentItem.type in ('list', 'section'): # to append at first index in section or list + row = 0 + parent = index else: - item.reset() + row = index.row() + parent = index.parent() + if row == -1: # to append at last position e.g. at root + row = parentItem.childCount() + else: + row += 1 # to append under current position + item = ConfigModelItem((text, None, '', ''), item_type=item_type, state='added') + self.model().insertItems(row, [item], parent) + + ensure_unique_names(item, include_self=False) + # parent.internalPointer().set_state('edited') + self.expandAll() + + def reset_item(self, index, reset_type): # todo try deepcopy + item = index.internalPointer() + model = self.model() + itemdataindex = model.modifyCol(index, 1) + + if reset_type == 'all': + for i, default in enumerate(item.default_values): + model.setData(model.modifyCol(index, i), default) + + model.setData(index, item.default_state, role=StateRole) + + elif reset_type == 'default': + # if item.type == 'list' and \ + # not isinstance(item.spec_default, (list, tuple)): + # self.reset_item(item, 'clear_value') + + model.setData(itemdataindex, item.spec_default) + + if item.default_state == 'unchanged': + model.setData(index, 'unchanged', role=StateRole) + + elif reset_type == 'clear_value': + model.setData(itemdataindex, None) + + # if model.data(itemdataindex, TypeRole) == 'list': + # model.removeRows(0, item.childCount(), index) + # model.setData(index, 'option', role=TypeRole) + # return + + for child in model.childrenIndexes(index): + self.reset_item(child, reset_type) + + +def call_standalone_dialog(): + pass if __name__ == '__main__': + import os, inspect # Add parent dir to PATH to import messaging_lib + + current_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) + parent_dir = os.path.dirname(current_dir) + + import config + import sys + sys.path.insert(0, parent_dir) def except_hook(cls, exception, traceback): + print(cls, exception, traceback) sys.__excepthook__(cls, exception, traceback) - sys.excepthook = except_hook app = QtWidgets.QApplication(sys.argv) - # data = {"section 1": {"opt1": "str", "opt2": 123, "opt3": 1.23, "opt4": False, "...": {'subopt': 'bal'}}, - # "section 2": {"opt1": "str", "opt2": [1.1, 2.3, 34], "opt3": 1.23, "opt4": False, "...": ""}} - data = { - 'config_name': {'__option__': True, 'value': 'Copter config', 'default': 'Copter config', 'unchanged': False, - 'comments': [], 'inline_comment': None}, - 'config_version': {'__option__': True, 'value': 0.0, 'default': 0.0, 'unchanged': False, 'comments': [], - 'inline_comment': None}, 'SERVER': { - 'port': {'__option__': True, 'value': 25000, 'default': 25000, 'unchanged': False, 'comments': [], - 'inline_comment': None}, - 'host': {'__option__': True, 'value': '192.168.1.103', 'default': '192.168.1.101', 'unchanged': False, - 'comments': [], 'inline_comment': None}, - 'buffer_size': {'__option__': True, 'value': 1024, 'default': 1024, 'unchanged': False, 'comments': [], - 'inline_comment': None}}, 'BROADCAST': { - 'use': {'__option__': True, 'value': True, 'default': True, 'unchanged': False, 'comments': [], - 'inline_comment': None}, - 'port': {'__option__': True, 'value': 8181, 'default': 8181, 'unchanged': False, 'comments': [], - 'inline_comment': None}}, 'NTP': { - 'use': {'__option__': True, 'value': False, 'default': False, 'unchanged': False, 'comments': [], - 'inline_comment': None}, - 'port': {'__option__': True, 'value': 123, 'default': 123, 'unchanged': False, - 'comments': ['#host = ntp1.stratum2.ru'], 'inline_comment': None}, - 'host': {'__option__': True, 'value': 'ntp1.stratum2.ru', 'default': 'ntp1.stratum2.ru', 'unchanged': True, - 'comments': [], 'inline_comment': ''}}, 'PRIVATE': { - 'id': {'__option__': True, 'value': '/hostname', 'default': '/hostname', 'unchanged': True, - 'comments': ['# avialiable options: /hostname ; /default ; /ip ; any string 63 characters lengh', 'newlibe'], - 'inline_comment': None}}, - 'initial_comment': ['# This is generated config_attrs with default_values', '# Modify to configure'], - 'final_comment': []} + data = {'config_name': {'__option__': True, 'value': 'Copter config', 'default': 'Copter config', 'unchanged': True, 'comments': [], 'inline_comment': None}, 'config_version': {'__option__': True, 'value': 0.0, 'default': 0.0, 'unchanged': False, 'comments': [], 'inline_comment': None}, 'SERVER': {'port': {'__option__': True, 'value': 25000, 'default': 25000, 'unchanged': False, 'comments': [], 'inline_comment': None}, 'host': {'__option__': True, 'value': '192.168.1.103', 'default': '192.168.1.101', 'unchanged': False, 'comments': [], 'inline_comment': None}, 'buffer_size': {'__option__': True, 'value': 1024, 'default': 1024, 'unchanged': False, 'comments': [], 'inline_comment': None}}, 'BROADCAST': {'use': {'__option__': True, 'value': True, 'default': True, 'unchanged': False, 'comments': [], 'inline_comment': None}, 'port': {'__option__': True, 'value': 8181, 'default': 8181, 'unchanged': False, 'comments': [], 'inline_comment': None}}, 'NTP': {'use': {'__option__': True, 'value': False, 'default': False, 'unchanged': False, 'comments': [], 'inline_comment': None}, 'host': {'__option__': True, 'value': 'ntp1.stratum2.ru', 'default': 'ntp1.stratum2.ru', 'unchanged': False, 'comments': [], 'inline_comment': None}, 'port': {'__option__': True, 'value': 123, 'default': 123, 'unchanged': False, 'comments': [], 'inline_comment': None}}, 'PRIVATE': {'id': {'__option__': True, 'value': '/hostname', 'default': '/hostname', 'unchanged': False, 'comments': ['# avialiable options: /hostname ; /spec_default ; /ip ; any string 63 characters lengh'], 'inline_comment': None}, 'offset': {'__option__': True, 'value': [0.0, 0.0, 0.0], 'default': [0.0, 0.0, 0.0], 'unchanged': False, 'comments': ["# Drone's individual offset", '# __list__ X Y Z'], 'inline_comment': None}}, 'initial_comment': ['# This is generated config_attrs with defaults', '# Modify to configure'], 'final_comment': []} ui = ConfigDialog() ui.setupModel(data) From fb8f81450a5be112394c509d03ba9a7b95af2163 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 4 Dec 2019 00:11:11 +0300 Subject: [PATCH 016/210] slight config improvemnts --- Drone/config/client.ini | 12 +++++++++--- Drone/config/spec/configspec_client.ini | 2 +- config.py | 4 +++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/Drone/config/client.ini b/Drone/config/client.ini index 29c6d69..801fde4 100644 --- a/Drone/config/client.ini +++ b/Drone/config/client.ini @@ -1,5 +1,8 @@ # This is generated config_attrs with defaults # Modify to configure +config_name = Copter config +config_version = 0.0 + [SERVER] port = 25000 host = 192.168.1.101 @@ -12,8 +15,11 @@ port = 8181 [NTP] use = False host = ntp1.stratum2.ru -port = 123n +port = 123 [PRIVATE] -# avialiable options: /hostname ; /default ; /ip ; any string 63 characters lengh -#id = /hostname +# avialiable options: /hostname ; /spec_default ; /ip ; any string 63 characters lengh +id = /hostname +# Drone's individual offset +# __list__ X Y Z +offset = 0.0, 0.0, 0.0 diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index d1de342..2ee30d3 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -18,6 +18,6 @@ port = integer(default=123) [PRIVATE] # avialiable options: /hostname ; /spec_default ; /ip ; any string 63 characters lengh id = string(default=/hostname, max=63) -# Drone's individual offset +# Drone's individual offset (X, Y, Z) # __list__ X Y Z offset = float_list(default=list(0, 0, 0), min=3, max=3) diff --git a/config.py b/config.py index 289f266..de83b5c 100644 --- a/config.py +++ b/config.py @@ -193,7 +193,9 @@ if __name__ == '__main__': # print(cfg.unchanged_defaults) # print(11111) - print(cfg.full_dict) + import pprint + pprint.pprint(cfg.full_dict) + #print(cfg.full_dict) #cfg.load_from_dict(cfg.full_dict, 'Drone/config/client.ini') #print(cfg.config.initial_comment, cfg.config.final_comment) From c365df69e84528d1ff2f91064bd6ca38056483f2 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 5 Dec 2019 08:49:44 +0300 Subject: [PATCH 017/210] WIP 5 (config broken) --- Drone/config/client.ini | 2 +- Server/config_editor_models.py | 48 +++++++++----- config.py | 118 +++++++++++++++++++++++---------- 3 files changed, 117 insertions(+), 51 deletions(-) diff --git a/Drone/config/client.ini b/Drone/config/client.ini index 801fde4..29978d6 100644 --- a/Drone/config/client.ini +++ b/Drone/config/client.ini @@ -20,6 +20,6 @@ port = 123 [PRIVATE] # avialiable options: /hostname ; /spec_default ; /ip ; any string 63 characters lengh id = /hostname -# Drone's individual offset +# Drone's individual offset (X, Y, Z) # __list__ X Y Z offset = 0.0, 0.0, 0.0 diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 198b59a..fe09581 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -5,11 +5,20 @@ from copy import deepcopy from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt as Qt -from PyQt5.QtGui import QCursor, QStandardItemModel -from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu, QAction, QMessageBox, QInputDialog +from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu, QAction, QMessageBox, QInputDialog, QFileDialog import config_editor +import sys +import os, inspect # Add parent dir to PATH to import messaging_lib + +current_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) +parent_dir = os.path.dirname(current_dir) +sys.path.insert(0, parent_dir) + +import config + def dict_walk(d: dict, keys): current = d @@ -563,6 +572,27 @@ class ConfigDialog(QtWidgets.QDialog): ) return reply == QMessageBox.Yes + def call_standalone_dialog(self): + path = QFileDialog.getOpenFileName(self, "Select configuration or specification file", + filter="Config and spec files (*.ini)")[0] + print(path) + if not path: + return + + cfg = config.ConfigManager() + cfg.load_from_file(path) + + self.setupModel(cfg.full_dict) + + self.show() + self.exec() + + print(ui.result()) + print(ui.model.to_dict()) + print(ui.model.to_config_dict()) + + + class ConfigTreeWidget(QTreeView): def __init__(self): @@ -726,21 +756,9 @@ class ConfigTreeWidget(QTreeView): self.reset_item(child, reset_type) -def call_standalone_dialog(): - pass + if __name__ == '__main__': - import os, inspect # Add parent dir to PATH to import messaging_lib - - current_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) - parent_dir = os.path.dirname(current_dir) - - import config - - import sys - sys.path.insert(0, parent_dir) - - def except_hook(cls, exception, traceback): print(cls, exception, traceback) sys.__excepthook__(cls, exception, traceback) diff --git a/config.py b/config.py index de83b5c..6c6e873 100644 --- a/config.py +++ b/config.py @@ -4,7 +4,11 @@ from configobj import ConfigObj, Section from validate import Validator +from pathlib import Path + + def modify_filename(path, pattern): + # name = pattern.format(Path(path).stem) old_path, filename = os.path.split(path) filename = os.path.splitext(filename)[0] newfilename = pattern.format(filename) @@ -52,29 +56,71 @@ class ConfigManager: config = ConfigObj(infile=self._extract_values(d), indent_type='', configspec=modify_filename(path, 'spec/configspec_{}.ini')) - self._load_validate(config) - self.config.filename = path - self.config.initial_comment = initial_comment - self.config.final_comment = final_comment + config.filename = path + config.initial_comment = initial_comment + config.final_comment = final_comment + self._load_validate(config) self._load_comments(d, self.config) - def load_from_file(self, path): - self.generate_default_config(path) + @staticmethod + def _config_exists(path): + return not((not path.is_file()) or path.suffix != '.ini') - config = ConfigObj(infile=path, raise_errors=True, - configspec=modify_filename(path, 'spec/configspec_{}.ini')) + @staticmethod + def _get_spec_path(path): + return modify_filename(path, 'spec/configspec_{}.ini') + + def load_from_file(self, path): + p = Path(path) + if not self._config_exists(p): + raise ValueError('Config file do not exist!') + + if p.name.startswith('configspec_'): + config_path = p.parents[1].joinpath(p.name.replace('configspec_', '')) + if self._config_exists(config_path): + return self.load_config_and_spec(config_path) + + if p.parent.name == 'spec': + self.generate_default_config(config_path) + + return self.load_only_spec(p) + + else: + spec_path = Path(self._get_spec_path(p)) + if self._config_exists(spec_path): + return self.load_config_and_spec(p) + + return self.load_only_config(p) + + def load_config_and_spec(self, path): + path = str(path) + self.generate_default_config(path) + config = ConfigObj(infile=path, + configspec=self._get_spec_path(path)) self._load_validate(config) - def _load_validate(self, config): + def load_only_config(self, path: Path): + path = str(path) + config = ConfigObj(infile=path) + self.set_config(config) + + def load_only_spec(self, path: Path): + path = str(path) + config = ConfigObj(configspec=path) + config.filename = path.parent.joinpath(path.name.replace('configspec_', '')) + + self._load_validate(config, True) + + def _load_validate(self, config, copy=False): vdt = Validator() - test = config.validate(vdt) + test = config.validate(vdt, copy=copy) if test != True: # Important syntax, do no change raise ValueError('Some values are wrong: {}'.format(test)) - self.config = config + self.set_config(config) def get(self, section, option): return self.config[section][option] @@ -121,7 +167,7 @@ class ConfigManager: if not isinstance(result, dict): item_d = {'__option__': True, 'value': value, - 'default': default_values[key], + 'default': default_values.get(key, None), 'unchanged': key in defaults, 'comments': comments[key], 'inline_comment': inline_comments[key], @@ -148,13 +194,12 @@ class ConfigManager: d['final_comment'] = self.config.final_comment return d - @staticmethod - def generate_default_config(path): - if os.path.isfile(path): + @classmethod + def generate_default_config(cls, path): + if cls._config_exists(path): return - vdt = Validator() - config = ConfigObj(configspec=modify_filename(path, 'spec/configspec_{}.ini')) + config = ConfigObj(configspec=cls._get_spec_path(path)) config.filename = path config.validate(vdt, copy=True) config.initial_comment = ('This is generated config_attrs with defaults', @@ -179,26 +224,29 @@ class ConfigManager: if __name__ == '__main__': cfg = ConfigManager() cfg.load_from_file('Drone/config/client.ini') - #print(cfg.config.comments) - #print(cfg.server_host) - cfg.server_host = '192.168.1.103' - #print(cfg.get('SERVER', 'host')) - cfg.set('SERVER', 'host', '192.168.1.103') - print(cfg.config.initial_comment, cfg.config.final_comment) - - # print(cfg.config) - # print(cfg.default_values) - # print(cfg.unchanged_defaults) - - # print(11111) + # cfg.load_config_and_spec('Drone/config/client.ini') + # #print(cfg.config.comments) + # #print(cfg.server_host) + # cfg.server_host = '192.168.1.103' + # + # #print(cfg.get('SERVER', 'host')) + # cfg.set('SERVER', 'host', '192.168.1.103') + # + # print(cfg.config.initial_comment, cfg.config.final_comment) + # + # # print(cfg.config) + # # print(cfg.default_values) + # # print(cfg.unchanged_defaults) + # + # # print(11111) import pprint pprint.pprint(cfg.full_dict) - #print(cfg.full_dict) - - #cfg.load_from_dict(cfg.full_dict, 'Drone/config/client.ini') - #print(cfg.config.initial_comment, cfg.config.final_comment) - #cfg.write() - + # #print(cfg.full_dict) + # + # #cfg.load_from_dict(cfg.full_dict, 'Drone/config/client.ini') + # #print(cfg.config.initial_comment, cfg.config.final_comment) + # #cfg.write() + # From b4c3efc2d8b3b7a048b0103ac1558e5e5c920959 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Thu, 5 Dec 2019 13:49:49 +0300 Subject: [PATCH 018/210] Added non-spec config files to gitignore --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 8761dcb..be51795 100644 --- a/.gitignore +++ b/.gitignore @@ -105,9 +105,13 @@ venv.bak/ .vscode/settings.json Server/tests.py Server/convert_ui.sh +Server/config/server.ini + Drone/test_animation/ Drone/animation.csv Drone/client_logs +Drone/config/client.ini + images/ .vscode/ \.idea/ From 4899c2268f3763c404630f23390469958b36f57d Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Thu, 5 Dec 2019 13:50:25 +0300 Subject: [PATCH 019/210] Removed unnecessary files --- Server/default.txt | 41 --------- Server/server_config.ini | 19 ----- Server/test.py | 174 --------------------------------------- 3 files changed, 234 deletions(-) delete mode 100644 Server/default.txt delete mode 100644 Server/server_config.ini delete mode 100644 Server/test.py diff --git a/Server/default.txt b/Server/default.txt deleted file mode 100644 index 9492303..0000000 --- a/Server/default.txt +++ /dev/null @@ -1,41 +0,0 @@ - -Getting Started How to familiarize yourself with Qt Designer - Launching Designer Running the Qt Designer application - The User Interface How to interact with Qt Designer - -Designing a Component Creating a GUI for your application - Creating a Dialog How to create a dialog - Composing the Dialog Putting widgets into the dialog example - Creating a Layout Arranging widgets on a form - Signal and Slot Connections Making widget communicate with each other - -Using a Component in Your Application Generating code from forms - The Direct Approach Using a form without any adjustments - The Single Inheritance Approach Subclassing a form's base class - The Multiple Inheritance Approach Subclassing the form itself - Automatic Connections Connecting widgets using a naming scheme - A Dialog Without Auto-Connect How to connect widgets without a naming scheme - A Dialog With Auto-Connect Using automatic connections - -Form Editing Mode How to edit a form in Qt Designer - Managing Forms Loading and saving forms - Editing a Form Basic editing techniques - The Property Editor Changing widget properties - The Object Inspector Examining the hierarchy of objects on a form - Layouts Objects that arrange widgets on a form - Applying and Breaking Layouts Managing widgets in layouts - Horizontal and Vertical Layouts Standard row and column layouts - The Grid Layout Arranging widgets in a matrix - Previewing Forms Checking that the design works - -Using Containers How to group widgets together - General Features Common container features - Frames QFrame - Group Boxes QGroupBox - Stacked Widgets QStackedWidget - Tab Widgets QTabWidget - Toolbox Widgets QToolBox - -Connection Editing Mode Connecting widgets together with signals and slots - Connecting Objects Making connections in Qt Designer - Editing Connections Changing existing connections \ No newline at end of file diff --git a/Server/server_config.ini b/Server/server_config.ini deleted file mode 100644 index 6dcbc87..0000000 --- a/Server/server_config.ini +++ /dev/null @@ -1,19 +0,0 @@ -[SERVER] -port = 25000 -buffer_size = 1024 -remove_disconnected = False - -[BROADCAST] -use_broadcast = True -broadcast_port = 8181 -broadcast_delay = 5 - -[NTP] -use_ntp = False -host = ntp1.stratum2.ru -port = 123 - -[CHECKS] -battery_percentage_min = 50 -start_pos_delta_max = 1 -time_delta_max = 1 diff --git a/Server/test.py b/Server/test.py deleted file mode 100644 index 1735972..0000000 --- a/Server/test.py +++ /dev/null @@ -1,174 +0,0 @@ -from PyQt5.QtCore import QAbstractItemModel, QFile, QIODevice, QModelIndex, Qt -from PyQt5.QtWidgets import QApplication, QTreeView - - -class TreeItem: - def __init__(self, data: list, parent=None): - self.parentItem = parent - self.itemData = data - self.childItems = [] - - def appendChild(self, item): - self.childItems.append(item) - - def child(self, row): - return self.childItems[row] - - def childCount(self): - return len(self.childItems) - - def columnCount(self): - return len(self.itemData) - - def data(self, column): - try: - return self.itemData[column] - except IndexError: - return None - - def parent(self): - return self.parentItem - - def row(self): - if self.parentItem: - return self.parentItem.childItems.index(self) - - return 0 - - -class TreeModel(QAbstractItemModel): - def __init__(self, data, parent=None): - super(TreeModel, self).__init__(parent) - - self.rootItem = TreeItem(("Title", "Summary")) - self.setupModelData(data.split('\n'), self.rootItem) - self.rootItem.child(1).appendChild(TreeItem(("1314", "345"))) - - def columnCount(self, parent): - if parent.isValid(): - return parent.internalPointer().columnCount() - else: - return self.rootItem.columnCount() - - def data(self, index, role): - if not index.isValid(): - return None - - if role != Qt.DisplayRole: - return None - - item = index.internalPointer() - - return item.data(index.column()) - - def flags(self, index): - if not index.isValid(): - return Qt.NoItemFlags - - return Qt.ItemIsEnabled | Qt.ItemIsSelectable - - def headerData(self, section, orientation, role): - if orientation == Qt.Horizontal and role == Qt.DisplayRole: - return self.rootItem.data(section) - - return None - - def index(self, row, column, parent): - if not self.hasIndex(row, column, parent): - return QModelIndex() - - if not parent.isValid(): - parentItem = self.rootItem - else: - parentItem = parent.internalPointer() - - childItem = parentItem.child(row) - if childItem: - return self.createIndex(row, column, childItem) - else: - return QModelIndex() - - def parent(self, index): - if not index.isValid(): - return QModelIndex() - - childItem = index.internalPointer() - parentItem = childItem.parent() - - if parentItem == self.rootItem or parentItem is None: - return QModelIndex() - - return self.createIndex(parentItem.row(), 0, parentItem) - - def rowCount(self, parent): - if parent.column() > 0: - return 0 - - if not parent.isValid(): - parentItem = self.rootItem - else: - parentItem = parent.internalPointer() - - return parentItem.childCount() - - def setupModelData(self, lines, parent): - parents = [parent] - indentations = [0] - - number = 0 - - while number < len(lines): - position = 0 - while position < len(lines[number]): - if lines[number][position] != ' ': - break - position += 1 - - lineData = lines[number][position:].trimmed() - - if lineData: - # Read the column data from the rest of the line. - columnData = [s for s in lineData.split('\t') if s] - - if position > indentations[-1]: - # The last child of the current parent is now the new - # parent unless the current parent has no children. - - if parents[-1].childCount() > 0: - parents.append(parents[-1].child(parents[-1].childCount() - 1)) - indentations.append(position) - - else: - while position < indentations[-1] and len(parents) > 0: - parents.pop() - indentations.pop() - - # Append a new item to the current parent's list of children. - parents[-1].appendChild(TreeItem(columnData, parents[-1])) - - number += 1 - - - -def except_hook(cls, exception, traceback): - sys.__excepthook__(cls, exception, traceback) - - -if __name__ == '__main__': - - import sys - - sys.excepthook = except_hook - - app = QApplication(sys.argv) - - f = QFile('default.txt') - f.open(QIODevice.ReadOnly) - model = TreeModel(f.readAll()) - f.close() - - view = QTreeView() - view.setModel(model) - view.setWindowTitle("Simple Tree Model") - view.show() - sys.exit(app.exec_()) \ No newline at end of file From b62881c0dffcdafa62f542be5cbf9fd30c20d224 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Thu, 5 Dec 2019 13:51:06 +0300 Subject: [PATCH 020/210] Create configspec_server.ini --- Server/config/spec/configspec_server.ini | 30 ++++++++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 Server/config/spec/configspec_server.ini diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini new file mode 100644 index 0000000..804f10d --- /dev/null +++ b/Server/config/spec/configspec_server.ini @@ -0,0 +1,30 @@ +config_name = string(default='Copter config') +config_version = float(default='0.0') + +[SERVER] +port = integer(default=25000) +buffer_size = integer(default=1024) + +[COPTER_TABLE] +# True -> clients are removed on disconnection +# False -> disconnected clients indicated +remove_disconnected = boolean(default=False) + +[TABLE_CHECKS] +battery_percentage_min = float(default=50.0, min=0, max=100) +# in meters +start_pos_delta_max = float(default=1.0, min=0) +# in meters +time_delta_max = float(default=1.0, min=0) + +[BROADCAST] +send = boolean(default=True) +listen = boolean(default=True) +port = integer(default=8181) +# delay for message sending in seconds +delay = float(default=5.0, min=0) + +[NTP] +use = boolean(default=False) +host = string(default=ntp1.stratum2.ru) +port = integer(default=123) From f6b1b00549c69296bc481c31a5654459d5460ed5 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Sat, 7 Dec 2019 22:33:16 +0300 Subject: [PATCH 021/210] Added quamash to requirements.txt --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index c991080..4704619 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ configobj +quamash indexed.py==0.0.1 numpy==1.17.4 PyQt5==5.13.2 From f5b54c7ec137d35edda0e1a8963e5432c2513423 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Sat, 7 Dec 2019 22:34:44 +0300 Subject: [PATCH 022/210] Fully working version of ConfigManager --- config.py | 298 +++++++++++++++++++++++++++++++----------------------- 1 file changed, 174 insertions(+), 124 deletions(-) diff --git a/config.py b/config.py index 6c6e873..a22efba 100644 --- a/config.py +++ b/config.py @@ -1,12 +1,9 @@ import os -from configobj import ConfigObj, Section +from configobj import ConfigObj, Section, flatten_errors from validate import Validator -from pathlib import Path - - def modify_filename(path, pattern): # name = pattern.format(Path(path).stem) old_path, filename = os.path.split(path) @@ -15,112 +12,39 @@ def modify_filename(path, pattern): return os.path.join(old_path, newfilename) +def parent_path(path, levels=1): + for i in range(levels): + path = os.path.abspath(os.path.join(path, os.pardir)) + return path + + +def parent_dir(path): + return os.path.basename(os.path.normpath(path)) + + +class ValidationError(ValueError): + def __init__(self, message, config, errors): + super(ValidationError, self).__init__(message) + self.config = config + self.errors = errors + + def flatten_errors(self): + for entry in flatten_errors(self.config, self.errors): + section_list, key, error = entry + if key is not None: + section_list.append(key) + else: + section_list.append('[missing section]') + section_string = ', '.join(section_list) + if error == False: # Important syntax + error = 'Missing value or section.' + yield "[{}]: {}".format(section_string, error) + + class ConfigManager: def __init__(self, config=None): self.config = ConfigObj() if config is None else config - - def set_config(self, config): - self.config = config - - @classmethod - def _extract_values(cls, d): - result = {} - for key, val in d.items(): - if isinstance(val, dict) and val.get('__option__', False): - if not val.get('unchanged', False): - result[key] = val.get('value') - else: - result[key] = cls._extract_values(val) - return result - - @classmethod - def _load_comments(cls, d, section): - comments = {} - inline_comments = {} - - for key, val in d.items(): - if val.get('__option__', False): - comments[key] = val.get('comments', []) - inline_comments[key] = val.get('inline_comment', '') - else: - cls._load_comments(val, section[key]) - comments[key] = [''] - inline_comments[key] = None - - section.comments = comments - section.inline_comments = inline_comments - - def load_from_dict(self, d, path): - initial_comment = d.pop('initial_comment', ['']) - final_comment = d.pop('final_comment', ['']) - - config = ConfigObj(infile=self._extract_values(d), indent_type='', - configspec=modify_filename(path, 'spec/configspec_{}.ini')) - config.filename = path - config.initial_comment = initial_comment - config.final_comment = final_comment - - self._load_validate(config) - self._load_comments(d, self.config) - - @staticmethod - def _config_exists(path): - return not((not path.is_file()) or path.suffix != '.ini') - - @staticmethod - def _get_spec_path(path): - return modify_filename(path, 'spec/configspec_{}.ini') - - def load_from_file(self, path): - p = Path(path) - if not self._config_exists(p): - raise ValueError('Config file do not exist!') - - if p.name.startswith('configspec_'): - config_path = p.parents[1].joinpath(p.name.replace('configspec_', '')) - if self._config_exists(config_path): - return self.load_config_and_spec(config_path) - - if p.parent.name == 'spec': - self.generate_default_config(config_path) - - return self.load_only_spec(p) - - else: - spec_path = Path(self._get_spec_path(p)) - if self._config_exists(spec_path): - return self.load_config_and_spec(p) - - return self.load_only_config(p) - - def load_config_and_spec(self, path): - path = str(path) - self.generate_default_config(path) - config = ConfigObj(infile=path, - configspec=self._get_spec_path(path)) - - self._load_validate(config) - - def load_only_config(self, path: Path): - path = str(path) - config = ConfigObj(infile=path) - self.set_config(config) - - def load_only_spec(self, path: Path): - path = str(path) - config = ConfigObj(configspec=path) - config.filename = path.parent.joinpath(path.name.replace('configspec_', '')) - - self._load_validate(config, True) - - def _load_validate(self, config, copy=False): - vdt = Validator() - - test = config.validate(vdt, copy=copy) - if test != True: # Important syntax, do no change - raise ValueError('Some values are wrong: {}'.format(test)) - - self.set_config(config) + self.validated = False def get(self, section, option): return self.config[section][option] @@ -133,6 +57,20 @@ class ConfigManager: def write(self): self.config.write() + def set_config(self, config): + self.config = config + self.validated = False + + def validate_config(self, config, copy_defaults=False): + vdt = Validator() + + test = config.validate(vdt, copy=copy_defaults, preserve_errors=True) + if test != True: # Important syntax, do no change + raise ValidationError('Some values are wrong: {}'.format(test), config, test) + + self.config = config + self.validated = True + @classmethod def _get_defaults(cls, item, unchanged_only=False): if isinstance(item, Section): @@ -156,7 +94,7 @@ class ConfigManager: if not isinstance(item, Section): return item - d = {} + data = {} default_values = item.default_values defaults = item.defaults comments = item.comments @@ -172,12 +110,12 @@ class ConfigManager: 'comments': comments[key], 'inline_comment': inline_comments[key], } - d[key] = item_d + data[key] = item_d else: - d[key] = result + data[key] = result - return d + return data @property def default_values(self): @@ -194,18 +132,6 @@ class ConfigManager: d['final_comment'] = self.config.final_comment return d - @classmethod - def generate_default_config(cls, path): - if cls._config_exists(path): - return - vdt = Validator() - config = ConfigObj(configspec=cls._get_spec_path(path)) - config.filename = path - config.validate(vdt, copy=True) - config.initial_comment = ('This is generated config_attrs with defaults', - 'Modify to configure') - config.write() - def __getattr__(self, item): try: section, option = item.split('_', 1) @@ -220,10 +146,134 @@ class ConfigManager: except (ValueError, KeyError): self.__dict__[key] = value + @staticmethod + def _config_exists(path): + return os.path.isfile(path) and os.path.splitext(path)[1] == '.ini' + + @staticmethod + def _get_spec_path(path): + return modify_filename(path, 'spec/configspec_{}.ini') + + @staticmethod + def _get_config_path(path): + filename = os.path.split(path)[1] + return os.path.join(parent_path(path, levels=2), + filename.replace('configspec_', '')) + + def load_from_file(self, path): + if not self._config_exists(path): + raise ValueError('Config file do not exist!') + + f_path, filename = os.path.split(path) + if filename.startswith('configspec_'): + config_path = self._get_config_path(path) + + if self._config_exists(config_path): + return self.load_config_and_spec(config_path) + + generate_file = parent_dir(f_path) == 'spec' + if generate_file: + self.generate_default_config(config_path) + + return self.load_only_spec(path, generate_file) + + else: + spec_path = self._get_spec_path(path) + if self._config_exists(spec_path): + return self.load_config_and_spec(path) + + return self.load_only_config(path) + + def load_config_and_spec(self, path): + self.generate_default_config(path) + config = ConfigObj(infile=path, + configspec=self._get_spec_path(path)) + + self.validate_config(config) + + def load_only_config(self, path): + config = ConfigObj(infile=path) + self.set_config(config) + + def load_only_spec(self, path, generate_filename=True): + config = ConfigObj(configspec=path) + if generate_filename: + config.filename = self._get_config_path(path) + + self.validate_config(config, copy_defaults=True) + + @classmethod + def generate_default_config(cls, cfg_path): + if cls._config_exists(cfg_path): + return False + + vdt = Validator() + config = ConfigObj(configspec=cls._get_spec_path(cfg_path)) + config.filename = cfg_path + config.validate(vdt, copy=True) + config.initial_comment = ('This is generated config with default values', + 'Modify to configure') + config.write() + return True + + @classmethod + def _extract_values(cls, d): + result = {} + for key, val in d.items(): + if isinstance(val, dict) and val.get('__option__', False): + if not val.get('unchanged', False): + result[key] = val.get('value') + else: + result[key] = cls._extract_values(val) + return result + + @classmethod + def _load_comments(cls, d, section): + comments = {} + inline_comments = {} + + for key, val in d.items(): + if val.get('__option__', False): + comment = val.get('comments', []) + comments[key] = [] if comment == [''] else comment + inline_comments[key] = val.get('inline_comment', None) + else: + cls._load_comments(val, section[key]) + comments[key] = [''] + inline_comments[key] = None + + section.comments = comments + section.inline_comments = inline_comments + + def load_from_dict(self, d, path=None): + initial_comment = d.pop('initial_comment', ['']) + final_comment = d.pop('final_comment', ['']) + + kwargs = {'infile': self._extract_values(d), 'indent_type': ''} + if path is not None: + spec_path = self._get_spec_path(path) + if not self._config_exists(spec_path): + spec_path = path + if self._config_exists(spec_path): + kwargs.update({'configspec': spec_path}) + + config = ConfigObj(**kwargs) + config.filename = path + config.initial_comment = initial_comment + config.final_comment = final_comment + + if path is not None: + self.validate_config(config) + else: + self.set_config(config) + + self._load_comments(d, self.config) + if __name__ == '__main__': cfg = ConfigManager() - cfg.load_from_file('Drone/config/client.ini') + #cfg.load_from_file('Drone/config/client.ini') + cfg.load_from_file('Drone/config/spec/configspec_client.ini') # cfg.load_config_and_spec('Drone/config/client.ini') From bf185063dc8c3abe381e75fc2203dd39bb8201d1 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Sat, 7 Dec 2019 22:36:24 +0300 Subject: [PATCH 023/210] Added automatic type conversions and stansalone dialog call Fully working for now but needs testing --- Server/config_editor_models.py | 206 ++++++++++++++++++++------------- 1 file changed, 123 insertions(+), 83 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index fe09581..b4aaf7d 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -42,6 +42,15 @@ states_colors = { StateRole = 999 TypeRole = 998 + +def convert_type(data): + try: + data = literal_eval(data) if data else None + except (SyntaxError, ValueError): + data = str(data) + return data + + class ConfigModelItem: def __init__(self, values=(None, None, None, None), item_type='option', state='normal', default=None, parent=None): @@ -78,7 +87,6 @@ class ConfigModelItem: if comments: try: raw_spec = comments.split('\n')[-1].split()[1:] - print(raw_spec) if raw_spec[0] == '__list__': # and len(raw_spec[1:]) == len(data): return raw_spec[1:] except IndexError: @@ -138,10 +146,7 @@ class ConfigModelItem: def set_data(self, data, column): old_data = self.data(column) if old_data is None: - try: - data = literal_eval(data) if data else None - except (SyntaxError, ValueError): - data = str(data) + data = convert_type(data) if data == '': data = [] @@ -276,7 +281,7 @@ class ConfigModel(QtCore.QAbstractItemModel): childItem = index.internalPointer() if not isinstance(childItem, ConfigModelItem): - print(childItem, index.column()),# index.row(), index.parent().internalPointer()) + print(childItem, index.column()), # index.row(), index.parent().internalPointer()) return QtCore.QModelIndex() parentItem = childItem.parent() @@ -442,7 +447,7 @@ class ConfigModel(QtCore.QAbstractItemModel): item = item.parent() return list(reversed(keys[:-1])) - def dict_setup(self, data: dict, parent=None): + def dict_setup(self, data: dict, parent=None, convert_types=False): if parent is None: parent = self.rootItem @@ -451,9 +456,11 @@ class ConfigModel(QtCore.QAbstractItemModel): item = ConfigModelItem((key,), parent=parent, item_type='section') self.dict_setup(value, parent=item) else: + if convert_types: + value = convert_type(value) parent.appendChild(ConfigModelItem((key, value, '', ''))) - def config_dict_setup(self, data: dict, parent=None): + def config_dict_setup(self, data: dict, convert_types=False, parent=None): if parent is None: parent = self.rootItem @@ -463,6 +470,9 @@ class ConfigModel(QtCore.QAbstractItemModel): for key, item in data.items(): if item.get('__option__', False): value = item['value'] + if convert_types: + value = convert_type(value) + default = item['default'] comments = '\n'.join(item['comments']) or '' inline_comment = item['inline_comment'] or '' @@ -479,7 +489,7 @@ class ConfigModel(QtCore.QAbstractItemModel): else: section = ConfigModelItem((key,), parent=parent, item_type='section') - self.config_dict_setup(item, parent=section) + self.config_dict_setup(item, convert_types=convert_types, parent=section) def to_dict(self, parent=None) -> dict: if parent is None: @@ -533,67 +543,6 @@ class ConfigModel(QtCore.QAbstractItemModel): def dict(self): return self.to_dict() - -class ConfigDialog(QtWidgets.QDialog): - def __init__(self): - super(ConfigDialog, self).__init__() - self.ui = config_editor.Ui_config_dialog() - self.model = ConfigModel(widget=self) - self.setupUi() - - def setupModel(self, data, pure_dict=False): - if pure_dict: - self.model.dict_setup(data) - else: - self.model.config_dict_setup(data) - - self.ui.config_view.expandAll() - - def setupUi(self): - self.ui.setupUi(self) - - self.ui.config_view = ConfigTreeWidget() - self.ui.config_view.setObjectName("config_view") - self.ui.config_view.setModel(self.model) - self.ui.gridLayout.addWidget(self.ui.config_view, 0, 0, 1, 1) - self.ui.config_view.expandAll() - - self.ui.do_coloring.stateChanged.connect(self.model.enable_color) - - # self.ui.delete_button.pressed.connect(self.remove_selected) - - # index = self.config_view.selectedIndexes()[0] - - def edit_caution(self): - reply = QMessageBox().warning(self, "Editing caution", - "Are you sure you want to edit section/option name? " - "Proceed with caution!", - QMessageBox.Yes | QMessageBox.No, QMessageBox.No - ) - return reply == QMessageBox.Yes - - def call_standalone_dialog(self): - path = QFileDialog.getOpenFileName(self, "Select configuration or specification file", - filter="Config and spec files (*.ini)")[0] - print(path) - if not path: - return - - cfg = config.ConfigManager() - cfg.load_from_file(path) - - self.setupModel(cfg.full_dict) - - self.show() - self.exec() - - print(ui.result()) - print(ui.model.to_dict()) - print(ui.model.to_config_dict()) - - - - class ConfigTreeWidget(QTreeView): def __init__(self): QTreeView.__init__(self) @@ -686,7 +635,6 @@ class ConfigTreeWidget(QTreeView): def exclude(self, index): item = self.model().nodeFromIndex(index) - #i if item.state == 'deleted': self.model().setData(index, item.default_state, StateRole) else: @@ -756,6 +704,104 @@ class ConfigTreeWidget(QTreeView): self.reset_item(child, reset_type) +class ConfigDialog(QtWidgets.QDialog): + def __init__(self): + super(ConfigDialog, self).__init__() + self.ui = config_editor.Ui_config_dialog() + self.model = ConfigModel(widget=self) + self.setupUi() + + def setupModel(self, data, pure_dict=False, convert_types=False): + if pure_dict: + self.model.dict_setup(data, convert_types=convert_types) + else: + self.model.config_dict_setup(data, convert_types=convert_types) + + self.ui.config_view.expandAll() + + def setupUi(self): + self.ui.setupUi(self) + + self.ui.config_view = ConfigTreeWidget() + self.ui.config_view.setObjectName("config_view") + self.ui.config_view.setModel(self.model) + self.ui.gridLayout.addWidget(self.ui.config_view, 0, 0, 1, 1) + self.ui.config_view.expandAll() + + self.ui.do_coloring.stateChanged.connect(self.model.enable_color) + + # self.ui.delete_button.pressed.connect(self.remove_selected) + + # index = self.config_view.selectedIndexes()[0] + + def edit_caution(self): + reply = QMessageBox().warning(self, "Editing caution", + "Are you sure you want to edit section/option name? " + "Proceed with caution!", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No + ) + return reply == QMessageBox.Yes + + # def validate_loop(self): + + def call_standalone_dialog(self): + path = QFileDialog.getOpenFileName(self, "Select configuration or specification file", + filter="Config and spec files (*.ini)")[0] + if not path: + return False + + cfg = config.ConfigManager() + try: + cfg.load_from_file(path) + except ValueError: # When file do not exist or not validated + return False + + self.setupModel(cfg.full_dict, convert_types=(not cfg.validated)) + + self.show() + self.exec() + + save = ui.result() + if not save: + return False + + filename = cfg.config.filename + valid_path = path if cfg.config.filename is None else cfg.config.filename + valid_path = valid_path if cfg.validated else None + + while True: + try: + cfg.load_from_dict(ui.model.to_config_dict(), path=valid_path) + except config.ValidationError as error: + dialog = QMessageBox() + dialog.setIcon(QMessageBox.Critical) + dialog.setStandardButtons(QMessageBox.Yes | QMessageBox.Cancel) + dialog.setDefaultButton(QMessageBox.Yes) + dialog.setEscapeButton(QMessageBox.Cancel) + dialog.setWindowTitle("Validation error!") + msg = "\n".join(error.flatten_errors()) + dialog.setText("Can not validate. Proceed with editing? Errors: \n" + msg) + dialog.setDetailedText(msg) + reply = dialog.exec() + + if reply == QMessageBox.Cancel: + return False + + self.show() + self.exec() + else: + break + + if filename is None: + save_path = QFileDialog.getSaveFileName(self, "Save configuration file", + filter="Config files (*.ini)")[0] + if not save_path: + return False + else: + save_path = filename + + cfg.config.filename = save_path + cfg.write() if __name__ == '__main__': @@ -767,16 +813,10 @@ if __name__ == '__main__': app = QtWidgets.QApplication(sys.argv) - data = {'config_name': {'__option__': True, 'value': 'Copter config', 'default': 'Copter config', 'unchanged': True, 'comments': [], 'inline_comment': None}, 'config_version': {'__option__': True, 'value': 0.0, 'default': 0.0, 'unchanged': False, 'comments': [], 'inline_comment': None}, 'SERVER': {'port': {'__option__': True, 'value': 25000, 'default': 25000, 'unchanged': False, 'comments': [], 'inline_comment': None}, 'host': {'__option__': True, 'value': '192.168.1.103', 'default': '192.168.1.101', 'unchanged': False, 'comments': [], 'inline_comment': None}, 'buffer_size': {'__option__': True, 'value': 1024, 'default': 1024, 'unchanged': False, 'comments': [], 'inline_comment': None}}, 'BROADCAST': {'use': {'__option__': True, 'value': True, 'default': True, 'unchanged': False, 'comments': [], 'inline_comment': None}, 'port': {'__option__': True, 'value': 8181, 'default': 8181, 'unchanged': False, 'comments': [], 'inline_comment': None}}, 'NTP': {'use': {'__option__': True, 'value': False, 'default': False, 'unchanged': False, 'comments': [], 'inline_comment': None}, 'host': {'__option__': True, 'value': 'ntp1.stratum2.ru', 'default': 'ntp1.stratum2.ru', 'unchanged': False, 'comments': [], 'inline_comment': None}, 'port': {'__option__': True, 'value': 123, 'default': 123, 'unchanged': False, 'comments': [], 'inline_comment': None}}, 'PRIVATE': {'id': {'__option__': True, 'value': '/hostname', 'default': '/hostname', 'unchanged': False, 'comments': ['# avialiable options: /hostname ; /spec_default ; /ip ; any string 63 characters lengh'], 'inline_comment': None}, 'offset': {'__option__': True, 'value': [0.0, 0.0, 0.0], 'default': [0.0, 0.0, 0.0], 'unchanged': False, 'comments': ["# Drone's individual offset", '# __list__ X Y Z'], 'inline_comment': None}}, 'initial_comment': ['# This is generated config_attrs with defaults', '# Modify to configure'], 'final_comment': []} - ui = ConfigDialog() - ui.setupModel(data) - ui.show() - - print(app.exec_()) - - print(ui.result()) - print(ui.model.to_dict()) - print(ui.model.to_config_dict()) - - sys.exit() + ui.call_standalone_dialog() + # d = {'section': {'opt': 1, "opt222": 'text'}} + # ui.setupModel(d, pure_dict=True) + # ui.show() + # app.exec() + # print(ui.model.to_config_dict()) From ad0fc9e4bc40f9e333f7677d532480c1ce78078c Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Sat, 7 Dec 2019 22:39:29 +0300 Subject: [PATCH 024/210] Shift server to new ConfigManager --- Drone/config/spec/configspec_client.ini | 2 +- Server/config/spec/configspec_server.ini | 4 +- Server/copter_table_models.py | 13 ++-- Server/server.py | 86 +++++++++++------------- Server/server_qt.py | 4 +- 5 files changed, 48 insertions(+), 61 deletions(-) diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index 2ee30d3..0aff37e 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -16,7 +16,7 @@ host = string(default=ntp1.stratum2.ru) port = integer(default=123) [PRIVATE] -# avialiable options: /hostname ; /spec_default ; /ip ; any string 63 characters lengh +# available options: /hostname ; /default ; /ip ; any string 63 characters length id = string(default=/hostname, max=63) # Drone's individual offset (X, Y, Z) # __list__ X Y Z diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index 804f10d..ae67ac5 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -5,12 +5,12 @@ config_version = float(default='0.0') port = integer(default=25000) buffer_size = integer(default=1024) -[COPTER_TABLE] +[TABLE] # True -> clients are removed on disconnection # False -> disconnected clients indicated remove_disconnected = boolean(default=False) -[TABLE_CHECKS] +[CHECKS] battery_percentage_min = float(default=50.0, min=0, max=100) # in meters start_pos_delta_max = float(default=1.0, min=0) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 184ed78..28584cf 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -1,10 +1,7 @@ import sys import re import math -import configparser -import collections import indexed -from server import ConfigOption from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt as Qt @@ -13,9 +10,6 @@ from PyQt5.QtCore import Qt as Qt ModelDataRole = 998 ModelStateRole = 999 -config = configparser.ConfigParser() -config.read("server_config.ini") - class CopterData: class_basic_attrs = indexed.IndexedOrderedDict([('copter_id', None), ('git_ver', None), ('anim_id', None), @@ -94,9 +88,10 @@ def get_position_delta(pos1, pos2): class Checks: all_checks = {} takeoff_checklist = (3, 4, 6, 7, 8) - battery_min = config.getfloat('CHECKS', 'battery_percentage_min') - start_pos_delta_max = config.getfloat('CHECKS', 'start_pos_delta_max') - time_delta_max = config.getfloat('CHECKS', 'time_delta_max') + battery_min = 50 # config.getfloat('CHECKS', 'battery_percentage_min') + start_pos_delta_max = 1 # config.getfloat('CHECKS', 'start_pos_delta_max') + time_delta_max = 1 # config.getfloat('CHECKS', 'time_delta_max') + class CopterDataModel(QtCore.QAbstractTableModel): selected_ready_signal = QtCore.pyqtSignal(bool) diff --git a/Server/server.py b/Server/server.py index 82157d9..e3aa312 100644 --- a/Server/server.py +++ b/Server/server.py @@ -7,14 +7,15 @@ import datetime import threading import selectors import collections -import configparser -import os, inspect # Add parent dir to PATH to import messaging_lib +import os, inspect # Add parent dir to PATH to import messaging_lib and config_lib current_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) parent_dir = os.path.dirname(current_dir) sys.path.insert(0, parent_dir) + import messaging_lib as messaging +from config import ConfigManager random.seed() @@ -41,7 +42,7 @@ ConfigOption = collections.namedtuple("ConfigOption", ["section", "option", "val class Server(messaging.Singleton): - def __init__(self, server_id=None, config_path="server_config.ini", on_stop=None): + def __init__(self, server_id=None, config_path="config/server.ini", on_stop=None): self.id = server_id if server_id else str(random.randint(0, 9999)).zfill(4) self.time_started = 0 @@ -58,9 +59,8 @@ class Server(messaging.Singleton): self.ip = messaging.get_ip_address() # Init configs + self.config = ConfigManager() self.config_path = config_path - self.config = configparser.ConfigParser() - self.load_config() # Init threads self.autoconnect_thread = threading.Thread(target=self._client_processor, daemon=True, @@ -75,49 +75,36 @@ class Server(messaging.Singleton): name='IP broadcast listener') self.listener_thread_running = threading.Event() - def load_config(self): - self.config.read(self.config_path) - self.port = int(self.config['SERVER']['port']) # TODO try, init def - self.BUFFER_SIZE = int(self.config['SERVER']['buffer_size']) # TODO connect to connection manager + def start(self): # do_auto_connect=True, , do_listen_broadcast=False + # load config on startup + self.config.load_config_and_spec(self.config_path) - self.remove_disconnected = self.config.getboolean('SERVER', 'remove_disconnected') - - self.use_broadcast = self.config.getboolean('BROADCAST', 'use_broadcast') - self.broadcast_port = int(self.config['BROADCAST']['broadcast_port']) - self.BROADCAST_DELAY = int(self.config['BROADCAST']['broadcast_delay']) - - self.USE_NTP = self.config.getboolean('NTP', 'use_ntp') - self.NTP_HOST = self.config['NTP']['host'] - self.NTP_PORT = int(self.config['NTP']['port']) - - def start(self, do_ip_broadcast=None): # do_auto_connect=True, , do_listen_broadcast=False self.time_started = time.time() - if do_ip_broadcast is None: - do_ip_broadcast = self.use_broadcast - - logging.info("Starting server with id: {} on {}:{} !".format(self.id, self.ip, self.port)) - logging.info("Starting server socket!") - self.server_socket.bind((self.ip, self.port)) + logging.info("Starting server with id: {} on {}:{} !".format(self.id, self.ip, self.config.server_port)) + logging.info("Binding server socket!") + self.server_socket.bind((self.ip, self.config.server_port)) logging.info("Starting client processor thread!") self.client_processor_thread_running.set() self.autoconnect_thread.start() - if do_ip_broadcast: + if self.config.broadcast_send: logging.info("Starting broadcast sender thread!") self.broadcast_thread_running.set() self.broadcast_thread.start() - logging.info("Starting broadcast listener thread!") - self.listener_thread_running.set() - self.listener_thread.start() + if self.config.broadcast_listen: + logging.info("Starting broadcast listener thread!") + self.listener_thread_running.set() + self.listener_thread.start() def stop(self): logging.info("Stopping server") self.client_processor_thread_running.clear() self.broadcast_thread_running.clear() self.listener_thread_running.clear() + self.server_socket.close() self.sel.close() logging.info("Server stopped") @@ -137,8 +124,8 @@ class Server(messaging.Singleton): return int.from_bytes(msg[-8:], 'big') / 2 ** 32 - NTP_DELTA def time_now(self): - if self.USE_NTP: - timenow = self.get_ntp_time(self.NTP_HOST, self.NTP_PORT) + if self.config.ntp_use: + timenow = self.get_ntp_time(self.config.ntp_host, self.config.ntp_port) else: timenow = time.time() return timenow @@ -150,7 +137,7 @@ class Server(messaging.Singleton): self.server_socket.setblocking(False) self.sel.register(self.server_socket, selectors.EVENT_READ, data=None) #| selectors.EVENT_WRITE - messaging.NotifierSock().bind((self.ip, self.port)) + messaging.NotifierSock().bind((self.ip, self.config.server_port)) while self.client_processor_thread_running.is_set(): events = self.sel.select() @@ -193,18 +180,19 @@ class Server(messaging.Singleton): def _ip_broadcast(self): logging.info("Broadcast sender thread started!") msg = messaging.MessageManager.create_simple_message( - "server_ip", {"host": self.ip, "port": str(self.port), "id": self.id, "start_time": str(self.time_started)}) + "server_ip", {"host": self.ip, "port": str(self.config.server_port), "id": self.id, + "start_time": str(self.time_started)}) + logging.debug("Formed broadcast message: {}".format(msg)) + broadcast_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) broadcast_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) broadcast_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - logging.info("Formed broadcast message: {}".format(msg)) - time.sleep(self.BROADCAST_DELAY) try: while self.broadcast_thread_running.is_set(): - broadcast_sock.sendto(msg, ('255.255.255.255', self.broadcast_port)) + time.sleep(self.config.broadcast_delay) # todo make interruptable (from time lib) + broadcast_sock.sendto(msg, ('255.255.255.255', self.config.broadcast_port)) logging.debug("Broadcast sent") - time.sleep(self.BROADCAST_DELAY) finally: broadcast_sock.close() @@ -215,7 +203,7 @@ class Server(messaging.Singleton): broadcast_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) broadcast_client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) try: - broadcast_client.bind(("", self.broadcast_port)) + broadcast_client.bind(("", self.config.broadcast_port)) except OSError: logging.critical("Another server is running on this computer, shutting down!") # TODO popup and as function @@ -227,15 +215,19 @@ class Server(messaging.Singleton): message = messaging.MessageManager() message.income_raw = data message.process_message() - if message.content: - if message.content["command"] == "server_ip": - if message.content["args"]["id"] != str(self.id) \ - and float(message.content["args"]["start_time"]) <= self.time_started: + content = message.content - # younger server should shut down - logging.critical("Another server detected over the network, shutting down!") - # TODO popup - self.stop() + right_command = (content and content["command"] == "server_ip") + + if right_command: + different_id = content["args"]["id"] != str(self.id) + self_younger = float(message.content["args"]["start_time"]) <= self.time_started + + if different_id and self_younger: + # younger server should shut down + logging.critical("Another server detected over the network, shutting down!") + # TODO popup + self.stop() else: logging.warning("Got wrong broadcast message from {}".format(addr)) diff --git a/Server/server_qt.py b/Server/server_qt.py index 9e715f1..b0a2242 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -118,7 +118,7 @@ class MainWindow(QtWidgets.QMainWindow): row_num = self.model.get_row_index(row_data) logging.debug("Removing {}".format(client.copter_id)) if row_num is not None: - if Server().remove_disconnected and (not client.connected): + if Server().config.table_remove_disconnected and (not client.connected): client.remove() self.signals.remove_client_signal.emit(row_num) else: @@ -240,7 +240,7 @@ class MainWindow(QtWidgets.QMainWindow): if row_num is not None: copter.client.remove() - if not Server().remove_disconnected: + if not Server().config.table_remove_disconnected: self.signals.remove_client_signal.emit(row_num) logging.info("Client removed from table!") From 34926fd3e988c5255c11db768547ccfad77baad8 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Thu, 26 Dec 2019 15:28:34 +0300 Subject: [PATCH 025/210] Changed modifiyng filename --- config.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/config.py b/config.py index a22efba..cd7db70 100644 --- a/config.py +++ b/config.py @@ -5,10 +5,9 @@ from validate import Validator def modify_filename(path, pattern): - # name = pattern.format(Path(path).stem) old_path, filename = os.path.split(path) - filename = os.path.splitext(filename)[0] - newfilename = pattern.format(filename) + filename, ext = os.path.splitext(filename) + newfilename = pattern.format(filename) + ext return os.path.join(old_path, newfilename) @@ -152,7 +151,7 @@ class ConfigManager: @staticmethod def _get_spec_path(path): - return modify_filename(path, 'spec/configspec_{}.ini') + return modify_filename(path, 'spec/configspec_{}') @staticmethod def _get_config_path(path): From 8b4bb7a064beb70b05ad6a2d440024d089adb83f Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Thu, 26 Dec 2019 15:30:24 +0300 Subject: [PATCH 026/210] Merged master+server improvements --- Drone/FlightLib/FlightLib.py | 17 +- Drone/animation_lib.py | 167 +++++++++------- Drone/client.py | 18 +- Drone/client_config.ini | 15 +- Drone/client_setup.sh | 4 +- Drone/copter_client.py | 368 ++++++++++++++++++++++------------- Drone/mavros_mavlink.py | 4 +- Server/server.py | 2 +- messaging_lib.py | 111 ++++++----- 9 files changed, 435 insertions(+), 271 deletions(-) diff --git a/Drone/FlightLib/FlightLib.py b/Drone/FlightLib/FlightLib.py index abf8e59..238d7c0 100644 --- a/Drone/FlightLib/FlightLib.py +++ b/Drone/FlightLib/FlightLib.py @@ -44,6 +44,7 @@ FLIP_MIN_Z = 2.0 checklist = [] get_telemetry_lock = threading.Lock() +delta = 0.0 def get_telemetry_locked(*args, **kwargs): with get_telemetry_lock: @@ -161,9 +162,6 @@ def check_angles(angle_limit=math.radians(5)): if abs(telemetry.roll) >= angle_limit: yield ("Roll estimation: {:.3f} rad;{:.3f} degrees".format(telemetry.roll, math.degrees(telemetry.roll))) - if abs(telemetry.yaw) >= angle_limit: - yield ("Yaw estimation: {:.3f} rad;{:.3f} degrees".format(telemetry.yaw, - math.degrees(telemetry.yaw))) def selfcheck(): @@ -174,12 +172,22 @@ def selfcheck(): return checks +def get_delta(): + global delta + return delta + +def reset_delta(): + global delta + delta = 0 def navto(x, y, z, yaw=float('nan'), frame_id=FRAME_ID, auto_arm=False, **kwargs): + global delta set_position(frame_id=frame_id, x=x, y=y, z=z, yaw=yaw, auto_arm=auto_arm) - #telemetry = get_telemetry_locked(frame_id=frame_id) + telemetry = get_telemetry_locked(frame_id=frame_id) + delta = get_distance3d(x, y, z, telemetry.x, telemetry.y, telemetry.z) logger.info('Going to: | x: {:.3f} y: {:.3f} z: {:.3f} yaw: {:.3f}'.format(x, y, z, yaw)) + #logger.info('Delta: {}'.format(delta)) #print('Going to: | x: {:.3f} y: {:.3f} z: {:.3f} yaw: {:.3f}'.format(x, y, z, yaw)) ##logger.info('Telemetry now: | z: {:.3f}'.format(telemetry.z)) #print('Telemetry now: | z: {:.3f}'.format(telemetry.z)) @@ -272,6 +280,7 @@ def stop(frame_id='body', hold_speed=SPEED): def land(descend=True, z=Z_DESCEND, frame_id_descend=FRAME_ID, frame_id_land=FRAME_ID, timeout_descend=TIMEOUT_DESCEND, timeout_land=TIMEOUT_LAND, freq=FREQUENCY, interrupter=INTERRUPTER): + reset_delta() if descend: logger.info("Descending to: | z: {:.3f}".format(z)) #print("Descending to: | z: {:.3f}".format(z)) diff --git a/Drone/animation_lib.py b/Drone/animation_lib.py index 6ee6d68..be51fd9 100644 --- a/Drone/animation_lib.py +++ b/Drone/animation_lib.py @@ -4,9 +4,16 @@ import copy import rospy import logging import threading +import ConfigParser -from FlightLib import FlightLib -from FlightLib import LedLib +try: + from FlightLib import FlightLib +except ImportError: + print("Can't import FlightLib") +try: + from FlightLib import LedLib +except ImportError: + print("Can't import LedLib") import tasking_lib as tasking @@ -14,6 +21,11 @@ logger = logging.getLogger(__name__) interrupt_event = threading.Event() +config = ConfigParser.ConfigParser() +config.read("client_config.ini") + +default_delay = config.getfloat('ANIMATION', 'frame_delay') + anim_id = "Empty id" # TODO refactor as class @@ -54,23 +66,20 @@ def get_start_xy(filepath="animation.csv", x_ratio=1, y_ratio=1): animation_file, delimiter=',', quotechar='|' ) try: - row_0 = csv_reader.next() + row_frame = csv_reader.next() + except: + return float('nan'), float('nan') + if len(row_frame) == 1: + anim_id = row_frame[0] + logger.debug("Got animation_id: {}".format(row_frame[0])) + row_frame = csv_reader.next() + if len(row_frame) == 2: + logger.debug("Got frame delay: {}".format(row_frame[1])) + row_frame - csv_reader.next() + try: + frame_number, x, y, z, yaw, red, green, blue = row_frame except: return float('nan'), float('nan') - if len(row_0) == 1: - anim_id = row_0[0] - logger.debug("Got animation_id: {}".format(anim_id)) - try: - frame_number, x, y, z, yaw, red, green, blue = csv_reader.next() - except: - return float('nan'), float('nan') - else: - anim_id = "Empty id" - logger.debug("No animation id in file") - try: - frame_number, x, y, z, yaw, red, green, blue = row_0 - except: - return float('nan'), float('nan') return float(x)*x_ratio, float(y)*y_ratio @@ -84,6 +93,7 @@ def load_animation(filepath="animation.csv", x0=0, y0=0, z0=0, x_ratio=1, y_rati anim_id = "No animation" else: with animation_file: + current_frame_delay = default_delay csv_reader = csv.reader( animation_file, delimiter=',', quotechar='|' ) @@ -91,6 +101,9 @@ def load_animation(filepath="animation.csv", x0=0, y0=0, z0=0, x_ratio=1, y_rati if len(row_0) == 1: anim_id = row_0[0] logger.debug("Got animation_id: {}".format(anim_id)) + elif len(row_0) == 2: + current_frame_delay = float(row_0[1]) + logger.debug("Got new frame delay: {}".format(current_frame_delay)) else: logger.debug("No animation id in file") frame_number, x, y, z, yaw, red, green, blue = row_0 @@ -103,25 +116,31 @@ def load_animation(filepath="animation.csv", x0=0, y0=0, z0=0, x_ratio=1, y_rati 'red': int(red), 'green': int(green), 'blue': int(blue), + 'delay': current_frame_delay }) for row in csv_reader: - frame_number, x, y, z, yaw, red, green, blue = row - imported_frames.append({ - 'number': int(frame_number), - 'x': x_ratio*float(x) + x0, - 'y': y_ratio*float(y) + y0, - 'z': z_ratio*float(z) + z0, - 'yaw': float(yaw), - 'red': int(red), - 'green': int(green), - 'blue': int(blue), - }) + if len(row) == 2: + current_frame_delay = float(row[1]) + else: + frame_number, x, y, z, yaw, red, green, blue = row + imported_frames.append({ + 'number': int(frame_number), + 'x': x_ratio*float(x) + x0, + 'y': y_ratio*float(y) + y0, + 'z': z_ratio*float(z) + z0, + 'yaw': float(yaw), + 'red': int(red), + 'green': int(green), + 'blue': int(blue), + 'delay': current_frame_delay + }) return imported_frames def correct_animation(frames, frame_delay=0.1, min_takeoff_height=0.5, move_delta=0.01, check_takeoff=True, check_land=True): corrected_frames = copy.deepcopy(frames) start_action = 'takeoff' frames_to_start = 0 + time_to_start = 0 if len(corrected_frames) == 0: raise Exception('Nothing to correct!') # Check takeoff @@ -133,9 +152,10 @@ def correct_animation(frames, frame_delay=0.1, min_takeoff_height=0.5, move_delt for i in range(len(corrected_frames)-1): if corrected_frames[i-frames_to_start+1]['z'] - corrected_frames[i-frames_to_start]['z'] > move_delta: break + time_to_start += corrected_frames[i-frames_to_start]['delay'] del corrected_frames[i-frames_to_start] frames_to_start += 1 - start_delay = frames_to_start*frame_delay + start_delay = time_to_start # Check Land # If copter lands in animation, landing points can be deleted if (corrected_frames[len(corrected_frames)-1]['z'] < min_takeoff_height) and check_land: @@ -156,60 +176,61 @@ def save_corrected_animation(frames, filename="corrected_animation.csv"): corrected_animation = open(filename, mode='w+') csv_writer = csv.writer(corrected_animation, delimiter=',', quotechar='"', quoting=csv.QUOTE_MINIMAL) for frame in frames: - csv_writer.writerow([frame['number'],frame['x'], frame['y'], frame['z']]) + csv_writer.writerow([frame['number'],frame['x'], frame['y'], frame['z'], frame['delay']]) # print frame corrected_animation.close() def convert_frame(frame): return ((frame['x'], frame['y'], frame['z']), (frame['red'], frame['green'], frame['blue']), frame['yaw']) +try: + def execute_frame(point=(), color=(), yaw=float('Nan'), frame_id='aruco_map', use_leds=True, + flight_func=FlightLib.navto, auto_arm=False, flight_kwargs=None, interrupter=interrupt_event): + if flight_kwargs is None: + flight_kwargs = {} -def execute_frame(point=(), color=(), yaw=float('Nan'), frame_id='aruco_map', use_leds=True, - flight_func=FlightLib.navto, auto_arm=False, flight_kwargs=None, interrupter=interrupt_event): - if flight_kwargs is None: - flight_kwargs = {} - - flight_func(*point, yaw=yaw, frame_id=frame_id, auto_arm=auto_arm, interrupter=interrupt_event, **flight_kwargs) - if use_leds: - if color: - LedLib.fill(*color) + flight_func(*point, yaw=yaw, frame_id=frame_id, auto_arm=auto_arm, interrupter=interrupt_event, **flight_kwargs) + if use_leds: + if color: + LedLib.fill(*color) -def execute_animation(frames, frame_delay, frame_id='aruco_map', use_leds=True, flight_func=FlightLib.navto, - interrupter=interrupt_event): - next_frame_time = 0 - for frame in frames: - if interrupter.is_set(): - logger.warning("Animation playing function interrupted!") - interrupter.clear() - return - execute_frame(*convert_frame(frame), frame_id=frame_id, use_leds=use_leds, flight_func=flight_func, - interrupter=interrupter) - - next_frame_time += frame_delay - tasking.wait(next_frame_time, interrupter) -def takeoff(z=1.5, safe_takeoff=True, frame_id='map', timeout=5.0, use_leds=True, + def execute_animation(frames, frame_delay, frame_id='aruco_map', use_leds=True, flight_func=FlightLib.navto, + interrupter=interrupt_event): + next_frame_time = 0 + for frame in frames: + if interrupter.is_set(): + logger.warning("Animation playing function interrupted!") + interrupter.clear() + return + execute_frame(*convert_frame(frame), frame_id=frame_id, use_leds=use_leds, flight_func=flight_func, + interrupter=interrupter) + + next_frame_time += frame_delay + tasking.wait(next_frame_time, interrupter) + + + def takeoff(z=1.5, safe_takeoff=True, frame_id='map', timeout=5.0, use_leds=True, + interrupter=interrupt_event): + if use_leds: + LedLib.wipe_to(255, 0, 0, interrupter=interrupter) + result = FlightLib.takeoff(height=z, timeout_takeoff=timeout, frame_id=frame_id, + emergency_land=safe_takeoff, interrupter=interrupter) + if result == 'not armed' or result == 'timeout': + raise Exception('STOP') # Raise exception to clear task_manager if copter can't arm + if use_leds: + LedLib.blink(0, 255, 0, wait=50, interrupter=interrupter) + + + def land(z=1.5, descend=False, timeout=5.0, frame_id='aruco_map', use_leds=True, interrupter=interrupt_event): - if use_leds: - LedLib.wipe_to(255, 0, 0, interrupter=interrupter) - if interrupter.is_set(): - return - result = FlightLib.takeoff(height=z, timeout_takeoff=timeout, frame_id=frame_id, - emergency_land=safe_takeoff, interrupter=interrupter) - if result == 'not armed' or result == 'timeout': - raise Exception('STOP') # Raise exception to clear task_manager if copter can't arm - if interrupter.is_set(): - return - if use_leds: - LedLib.blink(0, 255, 0, wait=50, interrupter=interrupter) + if use_leds: + LedLib.blink(255, 0, 0, interrupter=interrupter) + FlightLib.land(z=z, descend=descend, timeout_land=timeout, frame_id_land=frame_id, interrupter=interrupter) + if use_leds: + LedLib.off() - -def land(z=1.5, descend=False, timeout=5.0, frame_id='aruco_map', use_leds=True, - interrupter=interrupt_event): - if use_leds: - LedLib.blink(255, 0, 0, interrupter=interrupter) - FlightLib.land(z=z, descend=descend, timeout_land=timeout, frame_id_land=frame_id, interrupter=interrupter) - if use_leds: - LedLib.off() +except NameError: + print("Can't create flying functions") diff --git a/Drone/client.py b/Drone/client.py index 3675660..81b5df3 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -106,6 +106,8 @@ class Client(object): def start(self): logger.info("Starting client") + messaging.NotifierSock().init(self.selector) + try: while True: self._reconnect() @@ -196,6 +198,7 @@ class Client(object): # self.server_connection.send_message("ping") # self._last_ping_time = time.time() # logging.debug("tick") + for key, mask in events: # TODO add notifier to client! connection = key.data if connection is None: @@ -214,11 +217,15 @@ class Client(object): if isinstance(error, OSError): if error.errno == errno.EINTR: raise KeyboardInterrupt - - - if not self.selector.get_map(): - logger.warning("No active connections left!") - return + try: + mapping = self.selector.get_map().values() + notifier_key = self.selector.get_key(messaging.NotifierSock().get_sock()) + notify_only= len(mapping) == 1 and notifier_key in mapping + if notify_only or not mapping: + logger.warning("No active connections left!") + return + except (RuntimeError, KeyError) as e: + logger.error("Exception {} occured when getting net map!".format(e)) @messaging.message_callback("config_write") @@ -244,5 +251,6 @@ def _response_time(*args, **kwargs): if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) client = Client() client.start() diff --git a/Drone/client_config.ini b/Drone/client_config.ini index 568a8f6..ba2339a 100644 --- a/Drone/client_config.ini +++ b/Drone/client_config.ini @@ -1,7 +1,7 @@ [SERVER] port = 25000 broadcast_port = 8181 -host = 192.168.1.101 +host = 192.168.1.19 buffer_size = 1024 [FILETRANSFER] @@ -23,7 +23,7 @@ timeout_to_disarm_after_watchdog_action = 10.0 [TELEMETRY] frequency = 1 transmit = True -clear_tasks_when_emergency = True +land_if_pos_delta_bigger_than = 3.0 log_cpu_and_memory = True [ANIMATION] @@ -35,22 +35,23 @@ y_ratio = 1.0 z_ratio = 1.0 [COPTERS] -frame_id = floor +frame_id = map takeoff_height = 1.0 takeoff_time = 5.0 safe_takeoff = False reach_first_point_time = 5.0 -land_time = 3.0 +land_time = 1.0 x0_common = 0 y0_common = 0 z0_common = 0 -land_timeout = 6.0 +yaw = 180 +land_timeout = 10.0 [FLOOR FRAME] parent = aruco_map x = 2.4 y = 12.4 -z = 6.27 +z = 6.4 roll = 180 pitch = 0 yaw = -90 @@ -58,7 +59,7 @@ yaw = -90 [PRIVATE] id = /hostname restart_dhcpcd = True -use_leds = False +use_leds = True led_pin = 21 x0 = 0 y0 = 0 diff --git a/Drone/client_setup.sh b/Drone/client_setup.sh index bf5bd26..81f1496 100755 --- a/Drone/client_setup.sh +++ b/Drone/client_setup.sh @@ -38,6 +38,7 @@ country=GB network={ ssid="$1" psk="$2" + scan_ssid=1 } EOF @@ -71,8 +72,9 @@ EOF # change server ip in client_config sed -i "0,/^host/s/\(^h.*\)/host = $4/" client_config.ini -# enable clever show service +# enable clever show service and visual_pose_watchdog service systemctl enable clever-show.service +systemctl enable visual_pose_watchdog.service # restart clever reboot \ No newline at end of file diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 376cdd8..148215a 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -29,11 +29,8 @@ from tf.transformations import quaternion_from_euler, euler_from_quaternion, qua import tf2_ros static_bloadcaster = tf2_ros.StaticTransformBroadcaster() -Telemetry = namedtuple("Telemetry", "git_version animation_id battery_v battery_p system_status calibration_status mode selfcheck current_position start_position armed") -telemetry = Telemetry('nan', 'No animation', 'nan', 'nan', 'NO_FCU', 'NO_FCU', 'NO_FCU', 'NO_FCU', 'NO_POS', 'NO_POS', False) -emergency = False -# get_telemetry = rospy.ServiceProxy('get_telemetry', srv.GetTelemetry) +emergency = False logging.basicConfig( # TODO all prints as logs level=logging.DEBUG, # INFO @@ -78,8 +75,8 @@ class CopterClient(client.Client): super(CopterClient, self).load_config() self.TELEM_FREQ = self.config.getfloat('TELEMETRY', 'frequency') self.TELEM_TRANSMIT = self.config.getboolean('TELEMETRY', 'transmit') - self.CLEAR_TASKS_WHEN_EMERGENCY = self.config.getboolean('TELEMETRY', 'clear_tasks_when_emergency') self.LOG_CPU_AND_MEMORY = self.config.getboolean('TELEMETRY', 'log_cpu_and_memory') + self.LAND_POS_DELTA = self.config.getfloat('TELEMETRY', 'land_if_pos_delta_bigger_than') self.FRAME_ID = self.config.get('COPTERS', 'frame_id') self.FRAME_FLIPPED_HEIGHT = 0. self.TAKEOFF_HEIGHT = self.config.getfloat('COPTERS', 'takeoff_height') @@ -91,6 +88,7 @@ class CopterClient(client.Client): self.X0_COMMON = self.config.getfloat('COPTERS', 'x0_common') self.Y0_COMMON = self.config.getfloat('COPTERS', 'y0_common') self.Z0_COMMON = self.config.getfloat('COPTERS', 'z0_common') + self.YAW = self.config.get('COPTERS', 'yaw') self.TAKEOFF_CHECK = self.config.getboolean('ANIMATION', 'takeoff_animation_check') self.LAND_CHECK = self.config.getboolean('ANIMATION', 'land_animation_check') self.FRAME_DELAY = self.config.getfloat('ANIMATION', 'frame_delay') @@ -132,7 +130,8 @@ class CopterClient(client.Client): else: rospy.logerror("Can't make floor frame!") start_subscriber() - telemetry_thread.start() + + telemetry.start_loop() super(CopterClient, self).start() def start_floor_frame_broadcast(self): @@ -305,9 +304,10 @@ def _response_selfcheck(*args, **kwargs): stop_subscriber() return "NOT_CONNECTED_TO_FCU" + @messaging.request_callback("telemetry") def _response_telemetry(*args, **kwargs): - return create_telemetry_message(telemetry) + return telemetry.create_msg_contents() @messaging.request_callback("anim_id") @@ -581,7 +581,6 @@ def _play_animation(*args, **kwargs): check_takeoff=client.active_client.TAKEOFF_CHECK, check_land=client.active_client.LAND_CHECK, ) - # Choose start action if start_action == 'takeoff': # Takeoff first @@ -635,16 +634,21 @@ def _play_animation(*args, **kwargs): # Play animation file for frame in corrected_frames: point, color, yaw = animation.convert_frame(frame) + if client.active_client.YAW == "animation": + yaw = frame["yaw"] + else: + yaw = math.radians(float(client.active_client.YAW)) task_manager.add_task(frame_time, 0, animation.execute_frame, task_kwargs={ "point": point, "color": color, + "yaw": yaw, "frame_id": client.active_client.FRAME_ID, "use_leds": client.active_client.USE_LEDS, "flight_func": FlightLib.navto, } ) - frame_time += client.active_client.FRAME_DELAY + frame_time += frame["delay"] # Calculate land_time land_time = frame_time + client.active_client.LAND_TIME @@ -657,143 +661,239 @@ def _play_animation(*args, **kwargs): }, ) -def telemetry_loop(): - global telemetry, emergency - last_state = [] - equal_state_counter = 0 - max_count = 2 - tasks_cleared = False - rate = rospy.Rate(client.active_client.TELEM_FREQ) - while not rospy.is_shutdown(): - telemetry = telemetry._replace(animation_id = animation.get_id()) - telemetry = telemetry._replace(git_version = subprocess.check_output("git log --pretty=format:'%h' -n 1", shell=True)) +class Telemetry: + params_default_dict = { + "git_version": None, + "animation_id": None, + "battery": None, + "armed": False, + "system_status": None, + "calibration_status": None, + "mode": None, + "selfcheck": None, + "current_position": None, + "start_position": None, + "time": None, + } + + def __init__(self): + self._lock = threading.Lock() + self._last_state = [] + self._interruption_counter = 0 + self._max_interruptions = 2 + self._tasks_cleared = False + + for key, value in self.params_default_dict.items(): + setattr(self, key, value) + + def __setattr__(self, key, value): + if key in self.params_default_dict: + with self.__dict__['_lock']: + self.__dict__[key] = value + else: + self.__dict__[key] = value + + def __getattr__(self, item): + if item in self.params_default_dict: + with self.__dict__['_lock']: + return self.__dict__[item] + + return self.__dict__[item] + + @classmethod + def get_git_version(cls): + return subprocess.check_output("git log --pretty=format:'%h' -n 1", shell=True) + + @classmethod + def get_start_position(cls): x_start, y_start = animation.get_start_xy(os.path.abspath("animation.csv"), - x_ratio=client.active_client.X_RATIO, - y_ratio=client.active_client.Y_RATIO, - ) + x_ratio=client.active_client.X_RATIO, + y_ratio=client.active_client.Y_RATIO, + ) x_delta = client.active_client.X0 + client.active_client.X0_COMMON y_delta = client.active_client.Y0 + client.active_client.Y0_COMMON z_delta = client.active_client.Z0 + client.active_client.Z0_COMMON - if not math.isnan(x_start): - telemetry = telemetry._replace(start_position = '{:.2f} {:.2f} {:.2f}'.format(x_start+x_delta, y_start+y_delta, z_delta)) - else: - telemetry = telemetry._replace(start_position = 'NO_POS') - services_unavailable = FlightLib.check_ros_services_unavailable() - if not services_unavailable: - try: - ros_telemetry = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) - if ros_telemetry.connected: - telemetry = telemetry._replace(armed = ros_telemetry.armed) - telemetry = telemetry._replace(battery_v = '{:.2f}'.format(ros_telemetry.voltage)) - batt_empty_param = get_param('BAT_V_EMPTY') - batt_charged_param = get_param('BAT_V_CHARGED') - batt_cells_param = get_param('BAT_N_CELLS') - if batt_empty_param.success and batt_charged_param.success and batt_cells_param.success: - batt_empty = batt_empty_param.value.real - batt_charged = batt_charged_param.value.real - batt_cells = batt_cells_param.value.integer - try: - telemetry = telemetry._replace(battery_p = '{}'.format(int(min((ros_telemetry.voltage/batt_cells - batt_empty)/(batt_charged - batt_empty)*100., 100)))) - except ValueError: - telemetry = telemetry._replace(battery_p = 'nan') - else: - telemetry = telemetry._replace(battery_p = 'nan') - telemetry = telemetry._replace(calibration_status = get_calibration_status()) - telemetry = telemetry._replace(system_status = get_sys_status()) - telemetry = telemetry._replace(mode = ros_telemetry.mode) - check = FlightLib.selfcheck() - if not check: - check = "OK" - telemetry = telemetry._replace(selfcheck = str(check)) - if not math.isnan(ros_telemetry.x): - telemetry = telemetry._replace(current_position = '{:.2f} {:.2f} {:.2f} {:.1f} {}'.format(ros_telemetry.x, ros_telemetry.y, ros_telemetry.z, - math.degrees(ros_telemetry.yaw), client.active_client.FRAME_ID)) - else: - telemetry = telemetry._replace(current_position = 'NO_POS in {}'.format(client.active_client.FRAME_ID)) - else: - telemetry = telemetry._replace(battery_v = 'nan') - telemetry = telemetry._replace(battery_p = 'nan') - telemetry = telemetry._replace(calibration_status = 'NO_FCU') - telemetry = telemetry._replace(system_status = 'NO_FCU') - telemetry = telemetry._replace(mode = 'NO_FCU') - telemetry = telemetry._replace(selfcheck = 'NO_FCU') - telemetry = telemetry._replace(current_position = 'NO_POS') - except rospy.ServiceException: - logger.debug("Some service is unavailable") - except AttributeError as e: - logger.debug(e) - except rospy.TransportException as e: - logger.debug(e) - else: - telemetry = telemetry._replace(selfcheck = 'WAIT_ROS') - if client.active_client.TELEM_TRANSMIT: - try: - client.active_client.server_connection.send_message('telem', args={'message':create_telemetry_message(telemetry)}) - except AttributeError as e: - logger.debug(e) - if client.active_client.CLEAR_TASKS_WHEN_EMERGENCY: - mode = telemetry.mode - armed = telemetry.armed - last_task = task_manager.get_last_task_name() - state = [mode, armed, last_task] - if state == last_state: - equal_state_counter += 1 - else: - equal_state_counter = 0 - external_interruption = (mode != "OFFBOARD" and armed == True and last_task not in [None, 'land']) - log_msg = '' - if emergency and external_interruption: - log_msg = "emergency and external interruption" - elif emergency: - log_msg = "emergency" - elif external_interruption: - log_msg = "external interruption" - logger.info("Possible expernal interruption, state_counter = {}".format(equal_state_counter)) - if emergency or (external_interruption and equal_state_counter >= max_count): - if not tasks_cleared: - logger.info("Clear task manager because of {}".format(log_msg)) - logger.info("Mode: {} | armed: {} | last task: {} ".format(mode, armed, last_task)) - task_manager.reset() - tasks_cleared = True - equal_state_counter = 0 - else: - tasks_cleared = False - last_state = state - if client.active_client.LOG_CPU_AND_MEMORY: - cpu_usage = psutil.cpu_percent(interval=None, percpu=True) - mem_usage = psutil.virtual_memory().percent - cpu_temp_info = psutil.sensors_temperatures()['cpu-thermal'][0] - cpu_temp = cpu_temp_info.current - # https://github.com/raspberrypi/documentation/blob/JamesH65-patch-vcgencmd-vcdbg-docs/raspbian/applications/vcgencmd.md - throttled_hex = subprocess.check_output("vcgencmd get_throttled", shell=True).split('=')[1] - under_voltage = bool(int(bin(int(throttled_hex,16))[2:][-1])) - power_state = 'normal' if not under_voltage else 'under voltage!' - if cpu_temp_info.critical: - cpu_temp_state = 'critical' - elif cpu_temp_info.high: - cpu_temp_state = 'high' - else: - cpu_temp_state = 'normal' - logger.info("CPU usage: {} | Memory: {} % | T: {} ({}) | Power: {}".format(cpu_usage, mem_usage, cpu_temp, cpu_temp_state, power_state)) - rate.sleep() + x = x_start + x_delta + y = y_start + y_delta + if not FlightLib._check_nans(x, y, z_delta): + return x, y, z_delta + return 'NO_POS' -def create_telemetry_message(telemetry): - msg = client.active_client.client_id + '`' - for key in telemetry.__dict__: - if key != 'armed': - msg += telemetry.__dict__[key] + '`' - msg += repr(time.time()) - return msg + @classmethod + def get_battery(cls, ros_telemetry): + battery_v = ros_telemetry.voltage + + batt_empty_param = get_param('BAT_V_EMPTY') + batt_charged_param = get_param('BAT_V_CHARGED') + batt_cells_param = get_param('BAT_N_CELLS') + + if batt_empty_param.success and batt_charged_param.success and batt_cells_param.success: + batt_empty = batt_empty_param.value.real + batt_charged = batt_charged_param.value.real + batt_cells = batt_cells_param.value.integer + + battery_p = (ros_telemetry.voltage / batt_cells - batt_empty) / (batt_charged - batt_empty) * 1. + battery_p = max(min(battery_p, 1.), 0.) + else: + battery_p = float('nan') + + return battery_v, battery_p + + @classmethod + def get_selfcheck(cls): + check = FlightLib.selfcheck() + if not check: + check = "OK" + return check + + @classmethod + def get_position(cls, ros_telemetry): + x, y, z = ros_telemetry.x, ros_telemetry.y, ros_telemetry.z + if not math.isnan(x): + return x, y, z, math.degrees(ros_telemetry.yaw), client.active_client.FRAME_ID + return 'NO_POS' + + def update_telemetry(self): + self.animation_id = animation.get_id() + self.git_version = self.get_git_version() + self.start_position = self.get_start_position() + try: + ros_telemetry = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) + if ros_telemetry.connected: + self.battery = self.get_battery(ros_telemetry) + self.armed = ros_telemetry.armed + self.calibration_status = get_calibration_status() + self.system_status = get_sys_status() + self.mode = ros_telemetry.mode + self.selfcheck = self.get_selfcheck() + self.current_position = self.get_position(ros_telemetry) + else: + self.reset_telemetry_values() + except rospy.ServiceException: + rospy.logdebug("Some service is unavailable") + self.selfcheck = ["WAIT_ROS"] + except AttributeError as e: + rospy.logdebug(e) + except rospy.TransportException as e: + rospy.logdebug(e) + self.time = time.time() + + def round_telemetry(self): + round_list = ["battery", "start_position", "current_position"] + for key in round_list: + if self.__dict__[key] not in [None, 'NO_POS', 'NO_FCU']: + self.__dict__[key] = [round(v,2) if type(v) == float else v for v in self.__dict__[key]] + + def reset_telemetry_values(self): + self.battery = float('nan'), float('nan') + self.calibration_status = 'NO_FCU' + self.system_status = 'NO_FCU' + self.mode = 'NO_FCU' + self.selfcheck = ['NO_FCU'] + self.current_position = 'NO_POS' + + def check_failsafe(self): + global emergency + # check current state + state = [self.mode, self.armed, task_manager.get_last_task_name()] + mode, armed, last_task = state + # check external interruption + external_interruption = (mode != "OFFBOARD" and armed == True and last_task not in [None, 'land']) + log_msg = '' + if emergency: + log_msg += 'emergency and ' + if external_interruption: + log_msg += 'external interruption and ' + # count interruptions to avoid px4 mode glitches + if state == self._last_state: + self._interruption_counter += 1 + else: + self._interruption_counter = 0 + logger.info("Possible expernal interruption, state_counter = {}".format(self._interruption_counter)) + # delete last ' end ' from log message + if len(log_msg) > 5: + log_msg = log_msg[:-5] + # clear task manager if emergency or external interruption + if emergency or (external_interruption and self._interruption_counter >= self._max_interruptions): + if not self._tasks_cleared: + logger.info("Clear task manager because of {}".format(log_msg)) + logger.info("Mode: {} | armed: {} | last task: {} ".format(mode, armed, last_task)) + task_manager.reset() + FlightLib.reset_delta() + self._tasks_cleared = True + self._interruption_counter = 0 + else: + self._tasks_cleared = False + self._last_state = state + # check position delta + if not emergency: + delta = FlightLib.get_delta() + if delta > client.active_client.LAND_POS_DELTA: + logger.info("Delta: {}".format(delta)) + _command_land() + + def transmit_message(self): + try: + client.active_client.server_connection.send_message('telemetry', args={'value': self.create_msg_contents()}) + except AttributeError as e: + logger.debug(e) + + @classmethod + def log_cpu_and_memory(cls): + cpu_usage = psutil.cpu_percent(interval=None, percpu=True) + mem_usage = psutil.virtual_memory().percent + cpu_temp_info = psutil.sensors_temperatures()['cpu-thermal'][0] + cpu_temp = cpu_temp_info.current + # https://github.com/raspberrypi/documentation/blob/JamesH65-patch-vcgencmd-vcdbg-docs/raspbian/applications/vcgencmd.md + throttled_hex = subprocess.check_output("vcgencmd get_throttled", shell=True).split('=')[1] + under_voltage = bool(int(bin(int(throttled_hex,16))[2:][-1])) + power_state = 'normal' if not under_voltage else 'under voltage!' + if cpu_temp_info.critical: + cpu_temp_state = 'critical' + elif cpu_temp_info.high: + cpu_temp_state = 'high' + else: + cpu_temp_state = 'normal' + logger.info("CPU usage: {} | Memory: {} % | T: {} ({}) | Power: {}".format( + cpu_usage, mem_usage, cpu_temp, cpu_temp_state, power_state)) + + def _update_loop(self, freq): # TODO extract? + rate = rospy.Rate(freq) + while not rospy.is_shutdown(): + + self.update_telemetry() + self.round_telemetry() + self.check_failsafe() + + if client.active_client.TELEM_TRANSMIT and client.active_client.connected: + self.transmit_message() + + if client.active_client.LOG_CPU_AND_MEMORY: + self.log_cpu_and_memory() + + rate.sleep() + + def start_loop(self): + if client.active_client.TELEM_FREQ > 0: + telemetry_thread = threading.Thread(target=self._update_loop, name="Telemetry getting thread", + args=(client.active_client.TELEM_FREQ,)) # TODO MOVE? Daemon? + telemetry_thread.start() + else: + logger.info("Don't create telemetry loop because of zero or negative telemetry frequency") + + def create_msg_contents(self, keys=None): # keys: set or list + if keys is None: + keys = self.params_default_dict.keys() + # return only existing keys from 'keys' + return {k: self.__dict__[k] for k in keys if k in self.params_default_dict} def emergency_callback(data): global emergency emergency = data.data -telemetry_thread = threading.Thread(target=telemetry_loop, name="Telemetry getting thread") - if __name__ == "__main__": - + telemetry = Telemetry() copter_client = CopterClient() task_manager = tasking.TaskManager() rospy.Subscriber('/emergency', Bool, emergency_callback) diff --git a/Drone/mavros_mavlink.py b/Drone/mavros_mavlink.py index 3b093da..7a6005e 100644 --- a/Drone/mavros_mavlink.py +++ b/Drone/mavros_mavlink.py @@ -108,9 +108,9 @@ def get_sys_status(): mavlink.MAV_STATE_EMERGENCY: "EMERGENCY", mavlink.MAV_STATE_POWEROFF: "POWEROFF", mavlink.MAV_STATE_FLIGHT_TERMINATION: "TERMINATION" - }.get(system_status, "NOT_CONNECTED_TO_FCU") + }.get(system_status, "NO_FCU") return status_text - return "NOT_CONNECTED_TO_FCU" + return "NO_FCU" def start_subscriber(): global heartbeat_sub, heartbeat_sub_status diff --git a/Server/server.py b/Server/server.py index e3aa312..b698119 100644 --- a/Server/server.py +++ b/Server/server.py @@ -15,6 +15,7 @@ parent_dir = os.path.dirname(current_dir) sys.path.insert(0, parent_dir) import messaging_lib as messaging +import timing_lib as timing from config import ConfigManager random.seed() @@ -190,7 +191,6 @@ class Server(messaging.Singleton): try: while self.broadcast_thread_running.is_set(): - time.sleep(self.config.broadcast_delay) # todo make interruptable (from time lib) broadcast_sock.sendto(msg, ('255.255.255.255', self.config.broadcast_port)) logging.debug("Broadcast sent") diff --git a/messaging_lib.py b/messaging_lib.py index 9b1074b..e7dc084 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -17,12 +17,24 @@ try: except ImportError: import selectors2 as selectors + # import logging_lib -PendingRequest = collections.namedtuple("PendingRequest", ["value", "requested_value", # "expires_on", - "callback", "callback_args", "callback_kwargs", - "request_args", "resend", - ]) + +class Namespace: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + def __getitem__(self, key): + return self.__dict__[key] + + def __setitem__(self, key, value): + self.__dict__[key] = value + + +class PendingRequest(Namespace): pass + + logger = logging.getLogger(__name__) @@ -239,10 +251,19 @@ class ConnectionManager(object): self.socket = client_socket self.addr = client_addr + self._clear() + self._set_selector_events_mask('r') if self.resend_requests: self._resend_requests() + def _clear(self): + if not self.resume_queue: # maybe needs locks + self._recv_buffer = b'' + self._send_buffer = b'' + self._received_queue.clear() + self._send_queue.clear() + def close(self): with self._close_lock: self._should_close = True @@ -253,11 +274,6 @@ class ConnectionManager(object): def _close(self): logger.info("Closing connection to {}".format(self.addr)) - if not self.resume_queue: - self._recv_buffer = b'' - self._send_buffer = b'' - self._received_queue.clear() # - try: logger.info("Unregistering selector of {}".format(self.addr)) self.selector.unregister(self.socket) @@ -281,6 +297,7 @@ class ConnectionManager(object): with self._close_lock: self._should_close = False + self._clear() logger.info("CLOSED connection to {}".format(self.addr)) def process_events(self, mask): @@ -379,7 +396,7 @@ class ConnectionManager(object): ) f = request.callback - f(value, *request.callback_args, **request.callback_kwargs) + f(self, value, *request.callback_args, **request.callback_kwargs) else: logger.warning("Unexpected response!") @@ -417,9 +434,6 @@ class ConnectionManager(object): logger.warning( "Attempt to send message {} to {} failed due error: {}".format(self._send_buffer, self.addr, error)) - if not self.resume_queue: - self._send_buffer = b'' - raise error else: logger.debug("Sent {} to {}".format(self._send_buffer[:sent], self.addr)) @@ -456,14 +470,12 @@ class ConnectionManager(object): def _resend_requests(self): with self._request_lock: - for request_id, request in self._request_queue.items(): + for request_id, request in self._request_queue.items(): #TODO filter if request.resend: self._send(MessageManager.create_request( request.requested_value, request_id, request.request_args.update(resend=request.resend)) ) - #request.resend = False - - # self._request_queue.clear() + request.resend = False def send_message(self, command, args=None): self._send(MessageManager.create_simple_message(command, args)) @@ -484,41 +496,52 @@ class ConnectionManager(object): )) -class NotifierSock(Singleton): #TODO remake as connecting ONLY to self socket and selector +class NotifierSock(Singleton): def __init__(self): - self.receive_socket = None - self.addr = None + self._server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self._server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + self._server_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - self._notify_socket = None - self._notify_lock = threading.Lock() + self._sending_sock = socket.socket() + self._send_lock = threading.Lock() - def bind(self, server_addr): - self._notify_socket = socket.socket() - self._notify_socket.connect(server_addr) - logger.info("Notify socket: bind") + self._receiving_sock = None - def connect(self, _, client_socket, client_addr): - self.receive_socket = client_socket - self.addr = client_addr + def init(self, selector, port=26000): + port += random.randint(0, 100) # local testing fix - logger.info("Notify socket: connected") + self._server_socket.bind(('', port)) + self._server_socket.listen(1) + self._sending_sock.connect(('127.0.0.1', port)) + self._receiving_sock, _ = self._server_socket.accept() + logger.info("Notify socket connected") + + selector.register(self._receiving_sock, selectors.EVENT_READ, data=self) + logger.info("Notify socket registered in selector") + + def close(self): + if self._server_socket is not None: + self._server_socket.close() + if self._receiving_sock is not None: + self._receiving_sock.close() + logger.info("Notify socket closed") + + def get_sock(self): + return self._receiving_sock def notify(self): - with self._notify_lock: - if self.addr is not None: - self._notify_socket.sendall(bytes(1)) - logger.debug("Notify socket: notified") + with self._send_lock: + if self._receiving_sock is not None: + self._sending_sock.sendall(bytes(1)) + logger.debug("Notify socket notified") def process_events(self, mask): - if mask & selectors.EVENT_READ: + if mask & selectors.EVENT_READ and self._receiving_sock is not None: try: - data = self.receive_socket.recv(1024) - except Exception: # TODO remove + self._receiving_sock.recv(1024) + logger.debug("Notify socket received") + except io.BlockingIOError: pass - else: - if data: - logger.debug("Notifier received {} from {}".format(data, self.addr)) - else: - self.addr = None - logger.warning("Notifier: connection to {} lost!".format(self.addr)) - + except Exception as e: + print(e) \ No newline at end of file From f1ea376a798efabf797b60e180c68e809ac1c8c1 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Thu, 26 Dec 2019 15:37:55 +0300 Subject: [PATCH 027/210] QT server refactor and improvements --- Server/config/spec/configspec_server.ini | 2 +- Server/config_editor_models.py | 1 + Server/copter_table.py | 62 +++ Server/copter_table_models.py | 381 +++++++++------ Server/icons/coex_splash.jpg | Bin 0 -> 37994 bytes Server/server.py | 123 ++--- Server/server_qt.py | 574 ++++++++++++----------- 7 files changed, 677 insertions(+), 466 deletions(-) create mode 100644 Server/copter_table.py create mode 100644 Server/icons/coex_splash.jpg diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index ae67ac5..618aa20 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -11,7 +11,7 @@ buffer_size = integer(default=1024) remove_disconnected = boolean(default=False) [CHECKS] -battery_percentage_min = float(default=50.0, min=0, max=100) +battery_min = float(default=50.0, min=0, max=100) # in meters start_pos_delta_max = float(default=1.0, min=0) # in meters diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index b4aaf7d..27018d9 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -543,6 +543,7 @@ class ConfigModel(QtCore.QAbstractItemModel): def dict(self): return self.to_dict() + class ConfigTreeWidget(QTreeView): def __init__(self): QTreeView.__init__(self) diff --git a/Server/copter_table.py b/Server/copter_table.py new file mode 100644 index 0000000..75cf104 --- /dev/null +++ b/Server/copter_table.py @@ -0,0 +1,62 @@ +import logging + +from PyQt5 import QtWidgets, QtCore +from PyQt5.QtCore import pyqtSlot +from PyQt5.QtWidgets import QTableView, QMessageBox + +from server import Client +import copter_table_models as table + + +class CopterTableWidget(QTableView): + def __init__(self, model, data_model=table.CopterData): + QTableView.__init__(self) + + self.model = model + self._data_model = data_model + + self.proxy_model = table.CopterProxyModel() + self.signals = table.SignalManager(self.model) + + self.proxy_model.setSourceModel(self.model) + self.proxy_model.setDynamicSortFilter(True) + + # Initiate table and table self.model + self.setModel(self.proxy_model) + + # Adjust properties + self.resizeColumnsToContents() + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.doubleClicked.connect(self.on_double_click) + + # Some fancy wrappers to simplify syntax + + def add_client(self, **kwargs): + self.signals.add_client_signal.emit(self._data_model(**kwargs)) + + def remove_client_data(self, row_data): + self.signals.remove_client_signal.emit(row_data) + + def update_data(self, row, col, data, role=table.ModelDataRole): + self.signals.update_data_signal.emit(row, col, data, role) + + @pyqtSlot(QtCore.QModelIndex) + def on_double_click(self, index): + col = index.column() + if col == 7: + data = self.proxy_model.data(index, role=table.ModelDataRole) + if data and data != "OK": + dialog = QMessageBox() + dialog.setIcon(QMessageBox.NoIcon) + dialog.setStandardButtons(QMessageBox.Ok) + dialog.setWindowTitle("Selfcheck info") + dialog.setText("\n".join(data[:10])) + dialog.setDetailedText("\n".join(data)) + dialog.exec() + + # def _selfcheck_shortener(self, data): # TODO!!! + # shortened = [] + # for line in data: + # if len(line) > 89: + # pass + # return shortened \ No newline at end of file diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 28584cf..011d70b 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -1,5 +1,6 @@ -import sys import re +import sys +import time import math import indexed @@ -11,10 +12,100 @@ ModelDataRole = 998 ModelStateRole = 999 +class ModelChecks: + checks_dict = {} + takeoff_checklist = (3, 4, 6, 7, 8) + + battery_min = 50.0 # config.getfloat('CHECKS', 'battery_percentage_min') + start_pos_delta_max = 1.0 # config.getfloat('CHECKS', 'start_pos_delta_max') + time_delta_max = 1.0 + + @classmethod + def col_check(cls, col): + def inner(f): + def wrapper(item): + if item is not None: + return f(item) + return None + + cls.checks_dict[col] = wrapper + return wrapper + + return inner + + @classmethod + def all_checks(cls, copter_item): + for col, check in cls.checks_dict.items(): + if not check(copter_item[col]): + return False + return True + + @classmethod + def takeoff_checks(cls, copter_item): + for col in cls.takeoff_checklist: + if not cls.checks_dict[col](copter_item[col]): + return False + return True + + +@ModelChecks.col_check(1) +def check_ver(item): + return True # TODO git version! + + +@ModelChecks.col_check(2) +def check_anim(item): + return str(item) != 'No animation' + + +@ModelChecks.col_check(3) +def check_bat(item): + if item == "NO_INFO": + return False + return item[1]*100 > ModelChecks.battery_min + + +@ModelChecks.col_check(4) +def check_sys_status(item): + return item == "STANDBY" + + +@ModelChecks.col_check(5) +def check_cal_status(item): + return item == "OK" + + +@ModelChecks.col_check(6) +def check_mode(item): + return (item != "NO_FCU") and not ("CMODE" in item) + + +@ModelChecks.col_check(7) +def check_selfcheck(item): + return item == "OK" + + +@ModelChecks.col_check(8) +def check_pos_status(item): + if item == 'NO_POS': + return False + return not math.isnan(item[0]) + + +@ModelChecks.col_check(9) +def check_start_pos_status(item): + return item != 'NO_POS' + + +@ModelChecks.col_check(10) +def check_time_delta(item): + return abs(item) < ModelChecks.time_delta_max + + class CopterData: class_basic_attrs = indexed.IndexedOrderedDict([('copter_id', None), ('git_ver', None), ('anim_id', None), ('battery', None), ('sys_status', None), ('cal_status', None), - ('mode', None), ('selfcheck', None), ('position', None), + ('mode', None), ('selfcheck', None), ('position', None), ('start_pos', None), ('time_delta', None), ('client', None)]) def __init__(self, **kwargs): @@ -35,8 +126,9 @@ class StatedCopterData(CopterData): class_basic_states = indexed.IndexedOrderedDict([("checked", 0), ("selfchecked", None), ("takeoff_ready", None), ("copter_id", True), ]) - def __init__(self, **kwargs): + def __init__(self, checks_class=ModelChecks, **kwargs): self.states = CopterData(**self.class_basic_states) + self.checks = ModelChecks super(StatedCopterData, self).__init__(**kwargs) @@ -46,31 +138,30 @@ class StatedCopterData(CopterData): if key in self.class_basic_attrs.keys(): try: self.states.__dict__[key] = \ - Checks.all_checks[self.attrs_dict.keys().index(key)](value) + ModelChecks.checks_dict[self.attrs_dict.keys().index(key)](value) if key == 'start_pos': if (self.__dict__['position'] is not None) and (self.__dict__['start_pos'] is not None): current_pos = get_position(self.__dict__['position']) start_pos = get_position(self.__dict__['start_pos']) delta = get_position_delta(current_pos, start_pos) if delta != 'NO_POS': - self.states.__dict__[key] = (delta < Checks.start_pos_delta_max) + self.states.__dict__[key] = (delta < ModelChecks.start_pos_delta_max) except KeyError: # No check present for that col pass else: # update selfchecked and takeoff_ready self.states.__dict__["selfchecked"] = all( - [self.states[i] for i in Checks.all_checks.keys()] + [self.states[i] for i in ModelChecks.checks_dict.keys()] ) self.states.__dict__["takeoff_ready"] = all( - [self.states[i] for i in Checks.takeoff_checklist] + [self.states[i] for i in ModelChecks.takeoff_checklist] ) -def get_position(pos_string): - pos = [] - pos_str = pos_string.split(' ') - if pos_str[0] != 'nan' and pos_str[0] != 'NO_POS': +def get_position(pos_array): + if pos_array[0] != 'nan' and pos_array != 'NO_POS': + pos = [] for i in range(3): - pos.append(float(pos_str[i])) + pos.append(pos_array[i]) else: pos = 'NO_POS' return pos @@ -85,28 +176,122 @@ def get_position_delta(pos1, pos2): return 'NO_POS' -class Checks: - all_checks = {} - takeoff_checklist = (3, 4, 6, 7, 8) - battery_min = 50 # config.getfloat('CHECKS', 'battery_percentage_min') - start_pos_delta_max = 1 # config.getfloat('CHECKS', 'start_pos_delta_max') - time_delta_max = 1 # config.getfloat('CHECKS', 'time_delta_max') +class ModelFormatter: + view_formatters = {} + place_formatters = {} + VIEW_FORMATTER = False + PLACE_FORMATTER = True + @classmethod + def format_view(cls, col, value): + if col in cls.view_formatters: + return cls.view_formatters[col](value) + return value + + @classmethod + def format_place(cls, col, value): + if col in cls.place_formatters: + return cls.place_formatters[col](value) + return value + + @classmethod + def col_format(cls, col, format_type): + def inner(f): + if format_type: + cls.place_formatters[col] = f + else: + cls.view_formatters[col] = f + + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + + return wrapper + + return inner + + +@ModelFormatter.col_format(0, ModelFormatter.PLACE_FORMATTER) +def place_id(value): + value = value.stip() + # check user hostname spelling http://man7.org/linux/man-pages/man7/hostname.7.html + # '-' (hyphen) not first; latin letters/numbers/hyphens; length form 1 to 63 + # or matches command pattern + if re.match("^(?!-)[A-Za-z0-9-]{1,63}$", value) or re.match("^/[A-Za-z0-9]*$", value): + return value + else: + msgbox = QtWidgets.QMessageBox() + msgbox.setWindowTitle("Wrong input for the copter name!") + msgbox.setIcon(QtWidgets.QMessageBox.Critical) + msgbox.setText( + "Wrong input for the copter name!\n" + "Please use only A-Z, a-z, 0-9, and '-' chars.\n" + "Don't use '-' as first char.") + msgbox.exec_() + return None + + +@ModelFormatter.col_format(3, ModelFormatter.PLACE_FORMATTER) +def place_battery(value): + if isinstance(value, list): + battery_v, battery_p = value + if math.isnan(battery_v) or math.isnan(battery_p): + return "NO_INFO" + return value + + +@ModelFormatter.col_format(3, ModelFormatter.VIEW_FORMATTER) +def view_battery(value): + if isinstance(value, list): + battery_v, battery_p = value + return "{:.1f}V {:d}%".format(battery_v, int(battery_p*100)) + return value + +@ModelFormatter.col_format(7, ModelFormatter.VIEW_FORMATTER) +def view_selfcheck(value): + if isinstance(value, list): + if len(value)==1: + return value[0] + return "ERROR" + return value + +@ModelFormatter.col_format(8, ModelFormatter.VIEW_FORMATTER) +def view_selfcheck(value): + if isinstance(value, list): + x, y, z, yaw, frame = value + return "{:.2f} {:.2f} {:.2f} {:d} {}".format(x, y, z, int(yaw), frame) + return value + +@ModelFormatter.col_format(9, ModelFormatter.VIEW_FORMATTER) +def view_selfcheck(value): + if isinstance(value, list): + x, y, z = value + return "{:.2f} {:.2f} {:.2f}".format(x, y, z) + return value + +@ModelFormatter.col_format(10, ModelFormatter.PLACE_FORMATTER) +def place_time_delta(value): + return abs(value - time.time()) + + +@ModelFormatter.col_format(10, ModelFormatter.VIEW_FORMATTER) +def view_time_delta(value): + return "{:.3f}".format(value) class CopterDataModel(QtCore.QAbstractTableModel): selected_ready_signal = QtCore.pyqtSignal(bool) selected_takeoff_ready_signal = QtCore.pyqtSignal(bool) - selected_flip_ready_signal = QtCore.pyqtSignal(bool) + selected_flip_ready_signal = QtCore.pyqtSignal(bool) # TODO fix this signals selected_calibrating_signal = QtCore.pyqtSignal(bool) selected_calibration_ready_signal = QtCore.pyqtSignal(bool) - def __init__(self, parent=None): + def __init__(self, checks=ModelChecks, formatter=ModelFormatter, parent=None): super(CopterDataModel, self).__init__(parent) self.headers = ('copter ID', 'version', ' animation ID ', ' battery ', ' system ', 'sensors', - ' mode ', 'checks', 'current x y z yaw frame_id', ' start x y z ', 'dt') + ' mode ', ' checks ', 'current x y z yaw frame_id', ' start x y z ', 'dt') self.data_contents = [] - self.on_id_changed = None + self.checks = checks + self.formatter = formatter self.first_col_is_checked = False @@ -139,15 +324,15 @@ class CopterDataModel(QtCore.QAbstractTableModel): def flip_ready(self, contents=()): contents = contents or self.data_contents - return filter(lambda x: flip_checks(x), contents) # possibly change as takeoff checks + return filter(flip_checks, contents) # possibly change as takeoff checks def calibrating(self, contents=()): contents = contents or self.data_contents - return filter(lambda x: calibrating_check(x), contents) + return filter(calibrating_check, contents) def calibration_ready(self, contents=()): contents = contents or self.data_contents - return filter(lambda x: calibration_ready_check(x), contents) + return filter(calibration_ready_check, contents) def get_row_index(self, row_data): try: @@ -180,7 +365,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): col = index.column() if role == Qt.DisplayRole or role == Qt.EditRole: # Separate editRole in case of editing non-text item = self.data_contents[row][col] - return str(item) if item is not None else "" + return str(self.formatter.format_view(col, item)) if item is not None else "" elif role == ModelDataRole: return self.data_contents[row][col] @@ -202,7 +387,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): return self.data_contents[row].states.checked if role == QtCore.Qt.TextAlignmentRole and col != 0: - return QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter + return QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter def update_model(self, index=QtCore.QModelIndex(), role=QtCore.Qt.EditRole): selected = set(self.user_selected()) @@ -226,19 +411,14 @@ class CopterDataModel(QtCore.QAbstractTableModel): if role == Qt.CheckStateRole: self.data_contents[row].states.checked = value - elif role == Qt.EditRole: # For user actions with data - if col == 0: - # check user hostname spelling http://man7.org/linux/man-pages/man7/hostname.7.html - if value[0] != '-' and len(value) <= 63 and re.match("^[A-Za-z0-9-]*$", value): - self.data_contents[row].client.send_message("id", {"new_id": value}) + elif role == Qt.EditRole: # For user/outer actions with data, place modifiers applied + formatted_value = self.formatter.format_place(col, value) + if formatted_value is not None: # todo use new := syntax + self.data_contents[row][col] = formatted_value + + if col == 0: + self.data_contents[row].client.send_message("id", {"new_id": formatted_value}) self.data_contents[row].client.remove() - else: - msg = QtWidgets.QMessageBox() - msg.setIcon(QtWidgets.QMessageBox.Critical) - msg.setText("Wrong input for the copter name!\nPlease use only A-Z, a-z, 0-9, and '-' chars.\nDon't use '-' as first char.") - msg.exec_() - else: - self.data_contents[row][col] = value elif role == ModelDataRole: # For inner setting\editing of data self.data_contents[row][col] = value @@ -250,7 +430,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.update_model(index, role) return True - def select_all(self): + def select_all(self): # probably NOT thread-safe! self.first_col_is_checked = not self.first_col_is_checked for row_num, copter in enumerate(self.data_contents): copter.states.checked = int(self.first_col_is_checked)*2 @@ -270,109 +450,23 @@ class CopterDataModel(QtCore.QAbstractTableModel): def add_client(self, client): self.insertRows([client]) - @QtCore.pyqtSlot(int) - def remove_client(self, row): + @QtCore.pyqtSlot(int) # Probably deprecated now + def remove_row(self, row): self.removeRows(row) - -def col_check(col): - def inner(f): - Checks.all_checks[col] = f - - def wrapper(*args, **kwargs): - return f(*args, **kwargs) - - return wrapper - - return inner - - -@col_check(1) -def check_ver(item): - if not item: - return None - return True - -@col_check(2) -def check_anim(item): - if not item: - return None - return str(item) != 'No animation' - -@col_check(3) -def check_bat(item): - if not item: - return None - if item == "NO_INFO": - return False - else: - return float(item.split(' ')[1][:-1]) > Checks.battery_min - -@col_check(4) -def check_sys_status(item): - if not item: - return None - return item == "STANDBY" - -@col_check(5) -def check_cal_status(item): - if not item: - return None - return item == "OK" - -@col_check(6) -def check_mode(item): - if not item: - return None - return (item != "NO_FCU") and not ("CMODE" in item) - -@col_check(7) -def check_selfcheck(item): - if not item: - return None - return item == "OK" - -@col_check(8) -def check_pos_status(item): - if not item: - return None - str_pos = item.split(' ') - return str_pos[0] != 'nan' and str_pos[0] != 'NO_POS' - -@col_check(9) -def check_start_pos_status(item): - if not item: - return None - str_start_pos = item.split(' ') - return str_start_pos[0] != 'nan' and str_start_pos[0] != 'NO_POS' - -@col_check(10) -def check_time_delta(item): - if not item: - return None - return abs(float(item)) < Checks.time_delta_max - - -def all_checks(copter_item): - for col, check in Checks.all_checks.items(): - if not check(copter_item[col]): - return False - return True - -def takeoff_checks(copter_item): - for col in Checks.takeoff_checklist: - if not Checks.all_checks[col](copter_item[col]): - return False - return True + @QtCore.pyqtSlot(object) + def remove_row_data(self, data): + row = self.get_row_index(data) + if row is not None: + self.removeRows(row) def flip_checks(copter_item): - for col in Checks.takeoff_checklist: + for col in ModelChecks.takeoff_checklist: if col != 4 or col != 7: - if not Checks.all_checks[col](copter_item[col]): - return False - else: - if copter_item[4] != "ACTIVE": + if not ModelChecks.checks_dict[col](copter_item[col]): return False + elif copter_item[4] != "ACTIVE": + return False return True @@ -381,7 +475,7 @@ def calibrating_check(copter_item): def calibration_ready_check(copter_item): - if not Checks.all_checks[4](copter_item[4]): + if not ModelChecks.checks_dict[4](copter_item[4]): return False return not calibrating_check(copter_item) @@ -408,7 +502,16 @@ class CopterProxyModel(QtCore.QSortFilterProxyModel): class SignalManager(QtCore.QObject): update_data_signal = QtCore.pyqtSignal(int, int, QtCore.QVariant, QtCore.QVariant) add_client_signal = QtCore.pyqtSignal(object) - remove_client_signal = QtCore.pyqtSignal(int) + remove_row_signal = QtCore.pyqtSignal(int) + remove_client_signal = QtCore.pyqtSignal(object) + + def __init__(self, model): + super().__init__() + + self.update_data_signal.connect(model.update_item) + self.add_client_signal.connect(model.add_client) + self.remove_row_signal.connect(model.remove_row) + self.remove_client_signal.connect(model.remove_row_data) if __name__ == '__main__': diff --git a/Server/icons/coex_splash.jpg b/Server/icons/coex_splash.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5c77c63c8d73eac1b0aed7a7212a5f2c3c910c3b GIT binary patch literal 37994 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 89: - pass - return shortened + row_data = self.model.get_row_by_attr("client", client) + row_num = self.model.get_row_index(row_data) + if row_num is not None: + self.ui.copter_table.update_data(row_num, col, value, Qt.EditRole) @pyqtSlot() def remove_selected(self): for copter in self.model.user_selected(): - row_num = self.model.get_row_index(copter) - if row_num is not None: - copter.client.remove() - - if not Server().config.table_remove_disconnected: - self.signals.remove_client_signal.emit(row_num) - - logging.info("Client removed from table!") - else: - logging.error("Client is not in table!") + copter.client.remove() + if not Server().config.table_remove_disconnected: + self.ui.copter_table.remove_client_data(copter) + logging.info("Client removed from table!") @pyqtSlot() @confirmation_required("This operation will takeoff selected copters with delay and start animation. Proceed?") - def send_starttime_selected(self, **kwargs): + def send_start_time_selected(self): time_now = server.time_now() dt = self.ui.start_delay_spin.value() logging.info('Wait {} seconds to start animation'.format(dt)) if self.ui.music_checkbox.isChecked(): music_dt = self.ui.music_delay_spin.value() - asyncio.ensure_future(self.play_music_at_time(music_dt+time_now), loop=loop) + asyncio.ensure_future(self.play_music_at_time(music_dt + time_now), loop=loop) logging.info('Wait {} seconds to play music'.format(music_dt)) # self.selfcheck_selected() - for copter in self.model.user_selected(): - if all_checks(copter): - server.send_starttime(copter.client, dt+time_now) + for copter in filter(self.model.checks.all_checks, self.model.user_selected()): + server.send_starttime(copter.client, dt + time_now) @pyqtSlot() def pause_resume_selected(self): if self.ui.pause_button.text() == 'Pause': - for copter in self.model.user_selected(): - copter.client.send_message("pause") + self.send_to_selected("pause") self.ui.pause_button.setText('Resume') else: - self._resume_selected() - - def _resume_selected(self, **kwargs): - time_gap = 0.1 - for copter in self.model.user_selected(): - copter.client.send_message('resume', {"time": server.time_now() + time_gap}) - self.ui.pause_button.setText('Pause') + time_gap = 0.1 + self.send_to_selected("resume", {"time": server.time_now() + time_gap}) + self.ui.pause_button.setText('Pause') @pyqtSlot() def land_all(self): Client.broadcast_message("land") - @pyqtSlot() - def disarm_selected(self): - for copter in self.model.user_selected(): - copter.client.send_message("disarm") - - @pyqtSlot() - def test_leds_selected(self): - for copter in self.model.user_selected(): - copter.client.send_message("led_test") - @pyqtSlot() def disarm_all(self): Client.broadcast_message("disarm") @pyqtSlot() @confirmation_required("This operation will takeoff copters immediately. Proceed?") - def takeoff_selected(self, **kwargs): + def takeoff_selected(self): for copter in self.model.user_selected(): - if takeoff_checks(copter): + if self.model.checks.takeoff_checks(copter): if self.ui.z_checkbox.isChecked(): - copter.client.send_message("takeoff_z", {"z":str(self.ui.z_spin.value())}) + copter.client.send_message("takeoff_z", {"z": str(self.ui.z_spin.value())}) # todo int else: copter.client.send_message("takeoff") @pyqtSlot() @confirmation_required("This operation will flip(!!!) copters immediately. Proceed?") - def flip_selected(self, **kwargs): + def flip_selected(self): for copter in self.model.user_selected(): - if flip_checks(copter): + if table.flip_checks(copter): copter.client.send_message("flip") - @pyqtSlot() - def land_selected(self): - for copter in self.model.user_selected(): - copter.client.send_message("land") - - @pyqtSlot() - def reboot_selected(self): - for copter in self.model.user_selected(): - copter.client.send_message("reboot_fcu") - @pyqtSlot() def calibrate_gyro_selected(self): for copter_data_row in self.model.user_selected(): @@ -330,9 +307,9 @@ class MainWindow(QtWidgets.QMainWindow): row = self.model.get_row_index(copter_data_row) col = 5 data = 'CALIBRATING' - self.signals.update_data_signal.emit(row, col, data, ModelDataRole) + self.signals.update_data_signal.emit(row, col, data, table.ModelDataRole) # Send request - client.get_response("calibrate_gyro", self._get_calibration_info, callback_args=(copter_data_row, )) + client.get_response("calibrate_gyro", self._get_calibration_info) @pyqtSlot() def calibrate_level_selected(self): @@ -342,15 +319,76 @@ class MainWindow(QtWidgets.QMainWindow): row = self.model.get_row_index(copter_data_row) col = 5 data = 'CALIBRATING' - self.signals.update_data_signal.emit(row, col, data, ModelDataRole) + self.signals.update_data_signal.emit(row, col, data, table.ModelDataRole) # Send request - client.get_response("calibrate_level", self._get_calibration_info, callback_args=(copter_data_row, )) + client.get_response("calibrate_level", self._get_calibration_info) - def _get_calibration_info(self, value, copter_data_row): + def _get_calibration_info(self, client, value): col = 5 - row = self.model.get_row_index(copter_data_row) - data = str(value) - self.signals.update_data_signal.emit(row, col, data, ModelDataRole) + row_data = self.model.get_row_by_attr("client", client) + row = self.model.get_row_index(row_data) + if row is not None: + data = str(value) + self.signals.update_data_signal.emit(row, col, data, table.ModelDataRole) + + def _send_files(self, files, copters=None, client_path="/", client_filename="", match_id=False, callback=None): + if copters is None: + copters = self.model.user_selected() + + for num, file in enumerate(files): + filepath, filename = os.path.split(file) + logging.info("Preparing file for sending: {} {}", filepath, filename) + + if match_id: + name = os.path.splitext(filename)[0] + to_send = filter(lambda copter: bool(re.fullmatch(name, copter.copter_id)), copters) # copter.copter_id == name + else: + to_send = copters + + to_send = list(to_send) + if not to_send: + logging.warning("No copters to send file {} to", filename) + continue + + filename = client_filename.format(num, filename) or filename + + for copter in to_send: + copter.client.send_file(file, os.path.join(client_path, filename)) + if callback is not None: + callback() + + def send_files(self, prompt, ext_filter, copters=None, client_path="/", client_filename="", match_id=False, + callback=None): + files = QFileDialog.getOpenFileName(self, prompt, filter=ext_filter) + if not files: + return + + self._send_files(files, copters, client_path, client_filename, match_id, callback) + + def send_directory_files(self, prompt, extensions=(), copters=None, client_path="/", client_filename="", + match_id=False, callback=None): + path = QFileDialog.getExistingDirectory(self, prompt) + + if not path: + return + + if extensions: + extensions = ['/*'+ext for ext in extensions] + patterns = [os.path.join(path, ext) for ext in extensions] + else: + patterns = [path+'/*.*'] + + files = multi_glob(*patterns) + self._send_files(files, copters, client_path, client_filename, match_id, callback) + + @pyqtSlot() + def send_any_files(self): + copter_path, ok = QInputDialog.getText(self, "Enter path to send on client", "Destination:", QLineEdit.Normal, + "/home/pi/") + copter_path, ok = QInputDialog.getText(self, "Enter path to send on client", "Destination:", QLineEdit.Normal, + "/home/pi/") + if not ok: + return @pyqtSlot() def send_animations(self): @@ -379,7 +417,8 @@ class MainWindow(QtWidgets.QMainWindow): for file, name in zip(files, names): for copter in self.model.user_selected(): if name == copter.copter_id: - copter.client.send_file(file, "/home/pi/catkin_ws/src/clever/clever/camera_info/calibration.yaml") + copter.client.send_file(file, + "/home/pi/catkin_ws/src/clever/clever/camera_info/calibration.yaml") else: logging.info("Filename has no matches with any drone selected") @@ -388,7 +427,7 @@ class MainWindow(QtWidgets.QMainWindow): path = QFileDialog.getOpenFileName(self, "Select configuration file", filter="Configs (*.ini *.txt .cfg)")[0] if path: print("Selected file:", path) - sendable_config = configparser.ConfigParser() + sendable_config = configparser.ConfigParser() # TODO sendable_config.read(path) options = [] for section in sendable_config.sections(): @@ -402,7 +441,8 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_aruco(self): - path = QFileDialog.getOpenFileName(self, "Select aruco map configuration file", filter="Aruco map files (*.txt)")[0] + path = \ + QFileDialog.getOpenFileName(self, "Select aruco map configuration file", filter="Aruco map files (*.txt)")[0] if path: filename = os.path.basename(path) print("Selected file:", path, filename) @@ -420,7 +460,7 @@ class MainWindow(QtWidgets.QMainWindow): for file in files: filename = os.path.basename(file) copter.client.send_file(file, "/home/pi/catkin_ws/src/clever/clever/launch/{}".format(filename)) - + @pyqtSlot() def send_fcu_parameters(self): path = QFileDialog.getOpenFileName(self, "Select px4 param file", filter="px4 params (*.params)")[0] @@ -447,61 +487,17 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_any_command(self): - text, okPressed = QInputDialog.getText(self, "Enter command to send on copter","Command:", QLineEdit.Normal, "") + text, okPressed = QInputDialog.getText(self, "Enter command to send on copter", + "Command:", QLineEdit.Normal, "") if okPressed and text != '': - for copter in self.model.user_selected(): - copter.client.send_message("execute", {"command": text}) - @pyqtSlot() - def restart_clever(self): - for copter in self.model.user_selected(): - copter.client.send_message("service_restart", {"name": "clever"}) - - @pyqtSlot() - def restart_clever_show(self): - for copter in self.model.user_selected(): - copter.client.send_message("service_restart", {"name": "clever-show"}) - - @pyqtSlot() - def update_client_repo(self): - for copter in self.model.user_selected(): - copter.client.send_message("update_repo") - - @pyqtSlot() - def reboot_all_on_selected(self): - for copter in self.model.user_selected(): - copter.client.send_message("reboot_all") - - @pyqtSlot() - def update_start_to_current_position(self): - for copter in self.model.user_selected(): - copter.client.send_message("move_start") - - @pyqtSlot() - def reset_start(self): - for copter in self.model.user_selected(): - copter.client.send_message("reset_start") - - @pyqtSlot() - def set_z_offset_to_ground(self): - for copter in self.model.user_selected(): - copter.client.send_message("set_z_to_ground") - - @pyqtSlot() - def reset_z_offset(self): - for copter in self.model.user_selected(): - copter.client.send_message("reset_z_offset") - - @pyqtSlot() - def restart_chrony(self): - for copter in self.model.user_selected(): - copter.client.send_message("repair_chrony") + self.send_to_selected("execute", {"command": text}) @pyqtSlot() def select_music_file(self): path = QFileDialog.getOpenFileName(self, "Select music file", filter="Music files (*.mp3 *.wav)")[0] if path: media = QUrl.fromLocalFile(path) - content = QtMultimedia.QMediaContent(media) + content = QtMultimedia.QMediaContent(media) self.player.setMedia(content) self.ui.action_select_music_file.setText(self.ui.action_select_music_file.text() + " (selected)") @@ -513,9 +509,9 @@ class MainWindow(QtWidgets.QMainWindow): if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.NoMedia: logging.info("No media file") return - + if self.player.state() == QtMultimedia.QMediaPlayer.StoppedState or \ - self.player.state() == QtMultimedia.QMediaPlayer.PausedState: + self.player.state() == QtMultimedia.QMediaPlayer.PausedState: self.ui.action_play_music.setText("Pause music") self.player.play() else: @@ -546,22 +542,22 @@ class MainWindow(QtWidgets.QMainWindow): self.player.play() @pyqtSlot() - def emergency(self): + def emergency(self): # TODO refactor for the sake of god client_row_min = 0 client_row_max = self.model.rowCount() - 1 result = -1 while (result != 0) and (result != 3) and (result != 4): # light_green_red(min, max) - client_row_mid = int(math.ceil((client_row_max+client_row_min) / 2.0)) + client_row_mid = int(math.ceil((client_row_max + client_row_min) / 2.0)) print(client_row_min, client_row_mid, client_row_max) for row_num in range(client_row_min, client_row_mid): - self.model.data_contents[row_num].client\ + self.model.data_contents[row_num].client \ .send_message("led_fill", {"green": 255}) for row_num in range(client_row_mid, client_row_max + 1): self.model.data_contents[row_num].client \ .send_message("led_fill", {"red": 255}) - Dialog = QtWidgets.QDialog() + Dialog = QtWidgets.QDialog() ui = Ui_Dialog() ui.setupUi(Dialog) Dialog.show() @@ -574,7 +570,7 @@ class MainWindow(QtWidgets.QMainWindow): self.model.data_contents[row_num].client \ .send_message("led_fill") client_row_max = client_row_mid - 1 - + elif result == 2: for row_num in range(client_row_min, client_row_mid): self.model.data_contents[row_num].client \ @@ -592,10 +588,15 @@ class MainWindow(QtWidgets.QMainWindow): self.model.data_contents[row_num].client \ .send_message("disarm") -@messaging.message_callback("telem") -def get_telem_data(*args, **kwargs): - message = kwargs.get("message", None) - window.update_table_data(message) + +@messaging.message_callback("telemetry") +def get_telem_data(self, **kwargs): + message = kwargs.get("value") + window.update_table_data(self, message) + + +def except_hook(cls, exception, traceback): + sys.__excepthook__(cls, exception, traceback) def set_taskbar_icon(): @@ -606,7 +607,33 @@ def set_taskbar_icon(): if __name__ == "__main__": + msgbox_handler = ExitMsgbox() + msgbox_handler.setLevel(logging.CRITICAL) + + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(name)-7.7s] [%(threadName)-19.19s] [%(levelname)-7.7s] %(message)s", + handlers=[ + logging.FileHandler("server_logs/{}.log".format(now)), + logging.StreamHandler(), + msgbox_handler + ]) + + sys.excepthook = except_hook # for debugging (exceptions traceback) + app = QtWidgets.QApplication(sys.argv) + splash_pix = QPixmap('icons/coex_splash.jpg') + + splash = QSplashScreen(splash_pix) + splash.setEnabled(False) + + splash.setWindowFlags(Qt.WindowStaysOnTopHint | Qt.FramelessWindowHint) + progressBar = QProgressBar(splash) + progressBar.setGeometry(25, splash_pix.height() - 80, splash_pix.width(), 35) + splash.showMessage("Loading clever-show server"+"\n\n\n\n\n", int(Qt.AlignBottom | Qt.AlignCenter), Qt.white) + app.processEvents() + splash.show() + # time.sleep(3) app_icon = QtGui.QIcon() app_icon.addFile('icons/image.ico', QtCore.QSize(256, 256)) @@ -618,16 +645,21 @@ if __name__ == "__main__": loop = QEventLoop(app) asyncio.set_event_loop(loop) - #app.exec_() + # app.exec_() with loop: - window = MainWindow() + server = ServerQt() + window = MainWindow(server) Client.on_first_connect = window.new_client_connected Client.on_connect = window.client_connection_changed Client.on_disconnect = window.client_connection_changed - server = Server(on_stop=app.quit) server.start() + + window.show() + window.send_directory_files("lol") + splash.close() + loop.run_forever() server.stop() From 01a2990e0a7681ec8444bb09bd4387015d7f77c7 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Thu, 26 Dec 2019 17:53:10 +0300 Subject: [PATCH 028/210] Removed timing lib import --- Server/server.py | 1 - Server/server_qt.py | 1 - 2 files changed, 2 deletions(-) diff --git a/Server/server.py b/Server/server.py index 82f602b..9707a70 100644 --- a/Server/server.py +++ b/Server/server.py @@ -15,7 +15,6 @@ parent_dir = os.path.dirname(current_dir) sys.path.insert(0, parent_dir) import messaging_lib as messaging -import timing_lib as timing from config import ConfigManager random.seed() diff --git a/Server/server_qt.py b/Server/server_qt.py index d66ae44..5aa1c99 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -27,7 +27,6 @@ from server import Server, Client import messaging_lib as messaging import copter_table_models as table -from timing_lib import precise_sleep from copter_table import CopterTableWidget from emergency import * From 44bf471385c91d30f84d2129a98cd0ed591575e2 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Sun, 29 Dec 2019 18:09:24 +0300 Subject: [PATCH 029/210] Fixes and improvents in server --- Server/copter_table.py | 19 +++++++++++-------- Server/server_qt.py | 31 ++++++++++++++++++------------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index 75cf104..f4b7876 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -9,7 +9,7 @@ import copter_table_models as table class CopterTableWidget(QTableView): - def __init__(self, model, data_model=table.CopterData): + def __init__(self, model, data_model=table.StatedCopterData): QTableView.__init__(self) self.model = model @@ -46,13 +46,16 @@ class CopterTableWidget(QTableView): if col == 7: data = self.proxy_model.data(index, role=table.ModelDataRole) if data and data != "OK": - dialog = QMessageBox() - dialog.setIcon(QMessageBox.NoIcon) - dialog.setStandardButtons(QMessageBox.Ok) - dialog.setWindowTitle("Selfcheck info") - dialog.setText("\n".join(data[:10])) - dialog.setDetailedText("\n".join(data)) - dialog.exec() + self._show_info("Selfcheck info", data) + + def _show_info(self, title, data): + dialog = QMessageBox() + dialog.setIcon(QMessageBox.NoIcon) + dialog.setStandardButtons(QMessageBox.Ok) + dialog.setWindowTitle(title) + dialog.setText("\n".join(data[:10])) + dialog.setDetailedText("\n".join(data)) + dialog.exec() # def _selfcheck_shortener(self, data): # TODO!!! # shortened = [] diff --git a/Server/server_qt.py b/Server/server_qt.py index 5aa1c99..d83a491 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -121,7 +121,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_send_Aruco_map.triggered.connect(self.send_aruco) self.ui.action_send_launch_file.triggered.connect(self.send_launch) self.ui.action_send_fcu_parameters.triggered.connect(self.send_fcu_parameters) - self.ui.action_send_any_file.triggered.connect(self.send_any_file) + self.ui.action_send_any_file.triggered.connect(self.send_any_files) self.ui.action_send_any_command.triggered.connect(self.send_any_command) self.ui.action_restart_clever.triggered.connect( partial(self.send_to_selected, "service_restart", {"name": "clever"})) @@ -185,21 +185,21 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_to_selected(self, *args, **kwargs): - self.iterate_selected(lambda copter: copter.client.send_message(*args, **kwargs)) + return list(self.iterate_selected(lambda copter: copter.client.send_message(*args, **kwargs))) def new_client_connected(self, client: Client): logging.debug("Added client {}".format(client)) self.ui.copter_table.add_client(copter_id=client.copter_id, client=client) def client_connection_changed(self, client: Client): - logging.debug("Connection {} changed {}".format(client, client.connected), ) + logging.debug("Connection {} changed {}".format(client, client.connected)) row_data = self.model.get_row_by_attr("client", client) if row_data is None: logging.error("No row for client presented") return - if Server().config.table_remove_disconnected and (not client.connected): + if self.server.config.table_remove_disconnected and (not client.connected): client.remove() self.ui.copter_table.remove_client_data(row_data) logging.debug("Removing from table") @@ -245,7 +245,7 @@ class MainWindow(QtWidgets.QMainWindow): def remove_selected(self): for copter in self.model.user_selected(): copter.client.remove() - if not Server().config.table_remove_disconnected: + if not self.server.config.table_remove_disconnected: self.ui.copter_table.remove_client_data(copter) logging.info("Client removed from table!") @@ -336,7 +336,7 @@ class MainWindow(QtWidgets.QMainWindow): for num, file in enumerate(files): filepath, filename = os.path.split(file) - logging.info("Preparing file for sending: {} {}", filepath, filename) + logging.info("Preparing file for sending: {} {}".format(filepath, filename)) if match_id: name = os.path.splitext(filename)[0] @@ -346,7 +346,7 @@ class MainWindow(QtWidgets.QMainWindow): to_send = list(to_send) if not to_send: - logging.warning("No copters to send file {} to", filename) + logging.warning("No copters to send file {} to".format(filename)) continue filename = client_filename.format(num, filename) or filename @@ -354,7 +354,7 @@ class MainWindow(QtWidgets.QMainWindow): for copter in to_send: copter.client.send_file(file, os.path.join(client_path, filename)) if callback is not None: - callback() + callback(copter) def send_files(self, prompt, ext_filter, copters=None, client_path="/", client_filename="", match_id=False, callback=None): @@ -382,13 +382,18 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_any_files(self): - copter_path, ok = QInputDialog.getText(self, "Enter path to send on client", "Destination:", QLineEdit.Normal, - "/home/pi/") - copter_path, ok = QInputDialog.getText(self, "Enter path to send on client", "Destination:", QLineEdit.Normal, - "/home/pi/") + files = QFileDialog.getOpenFileName(self, "Select any files") + if not files: + return + + c_path, ok = QInputDialog.getText(self, "Enter path (and name) to send on client", "Destination:", + QLineEdit.Normal, "/home/pi/") # TODO config? if not ok: return + c_filename, c_filepath = os.path.split(c_path) + self._send_files(files, client_path=c_filepath, client_filename=c_filename) + @pyqtSlot() def send_animations(self): path = str(QFileDialog.getExistingDirectory(self, "Select Animation Directory")) @@ -656,7 +661,7 @@ if __name__ == "__main__": server.start() window.show() - window.send_directory_files("lol") + # window.send_directory_files("lol") splash.close() loop.run_forever() From 239f32a616d8befd6a9e1e4838df6352fafd7b84 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Sun, 29 Dec 2019 20:07:03 +0300 Subject: [PATCH 030/210] Filesending debugging --- Server/server.py | 3 ++- Server/server_qt.py | 39 +++++++++++++++------------------------ 2 files changed, 17 insertions(+), 25 deletions(-) diff --git a/Server/server.py b/Server/server.py index 9707a70..7d77c94 100644 --- a/Server/server.py +++ b/Server/server.py @@ -75,7 +75,8 @@ class Server(messaging.Singleton): self.time_started = time.time() - logging.info("Starting server with id: {} on {}:{} !".format(self.id, self.ip, self.config.server_port)) + logging.info("Starting server with id: {} on {}:{} ({})!".format(self.id, self.ip, self.config.server_port, + socket.gethostname())) logging.info("Binding server socket!") self.server_socket.bind((self.ip, self.config.server_port)) diff --git a/Server/server_qt.py b/Server/server_qt.py index d83a491..a08a21d 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -269,7 +269,7 @@ class MainWindow(QtWidgets.QMainWindow): self.send_to_selected("pause") self.ui.pause_button.setText('Resume') else: - time_gap = 0.1 + time_gap = 0.1 # TODO config? automatic delay detection? self.send_to_selected("resume", {"time": server.time_now() + time_gap}) self.ui.pause_button.setText('Pause') @@ -330,9 +330,10 @@ class MainWindow(QtWidgets.QMainWindow): data = str(value) self.signals.update_data_signal.emit(row, col, data, table.ModelDataRole) - def _send_files(self, files, copters=None, client_path="/", client_filename="", match_id=False, callback=None): + def _send_files(self, files, copters=None, client_path="", client_filename="", match_id=False, callback=None): if copters is None: copters = self.model.user_selected() + copters = list(copters) for num, file in enumerate(files): filepath, filename = os.path.split(file) @@ -340,15 +341,15 @@ class MainWindow(QtWidgets.QMainWindow): if match_id: name = os.path.splitext(filename)[0] - to_send = filter(lambda copter: bool(re.fullmatch(name, copter.copter_id)), copters) # copter.copter_id == name + to_send = [copter for copter in copters if re.fullmatch(name, copter.copter_id)] else: to_send = copters - to_send = list(to_send) if not to_send: - logging.warning("No copters to send file {} to".format(filename)) + logging.warning(f"No copters to send file {filename} to") continue + logging.info(f"Sending file {filename} to clients: {to_send}") filename = client_filename.format(num, filename) or filename for copter in to_send: @@ -356,15 +357,15 @@ class MainWindow(QtWidgets.QMainWindow): if callback is not None: callback(copter) - def send_files(self, prompt, ext_filter, copters=None, client_path="/", client_filename="", match_id=False, + def send_files(self, prompt, ext_filter, copters=None, client_path="", client_filename="", match_id=False, callback=None): - files = QFileDialog.getOpenFileName(self, prompt, filter=ext_filter) + files = QFileDialog.getOpenFileNames(self, prompt, filter=ext_filter)[0] if not files: return self._send_files(files, copters, client_path, client_filename, match_id, callback) - def send_directory_files(self, prompt, extensions=(), copters=None, client_path="/", client_filename="", + def send_directory_files(self, prompt, extensions=(), copters=None, client_path="", client_filename="", match_id=False, callback=None): path = QFileDialog.getExistingDirectory(self, prompt) @@ -373,17 +374,16 @@ class MainWindow(QtWidgets.QMainWindow): if extensions: extensions = ['/*'+ext for ext in extensions] - patterns = [os.path.join(path, ext) for ext in extensions] + patterns = [path + ext for ext in extensions] else: patterns = [path+'/*.*'] - files = multi_glob(*patterns) self._send_files(files, copters, client_path, client_filename, match_id, callback) @pyqtSlot() def send_any_files(self): - files = QFileDialog.getOpenFileName(self, "Select any files") - if not files: + file = QFileDialog.getOpenFileName(self, "Select any file")[0] + if not file: return c_path, ok = QInputDialog.getText(self, "Enter path (and name) to send on client", "Destination:", @@ -392,22 +392,13 @@ class MainWindow(QtWidgets.QMainWindow): return c_filename, c_filepath = os.path.split(c_path) + files = [file] self._send_files(files, client_path=c_filepath, client_filename=c_filename) @pyqtSlot() def send_animations(self): - path = str(QFileDialog.getExistingDirectory(self, "Select Animation Directory")) - - if path: - print("Selected directory:", path) - files = [file for file in glob.glob(path + '/*.csv')] - names = [os.path.basename(file).split(".")[0] for file in files] - for file, name in zip(files, names): - for copter in self.model.user_selected(): - if name == copter.copter_id: - copter.client.send_file(file, "animation.csv") # TODO config - else: - logging.info("Filename has no matches with any drone selected") + self.send_directory_files("Select Animation Directory", ('.csv', '.txt'), match_id=True, + client_path="", client_filename="animation.csv") @pyqtSlot() def send_calibrations(self): From 2ebdb6f6833f3ea329ba08a6a30dd0467e75d58f Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Sun, 29 Dec 2019 21:04:39 +0300 Subject: [PATCH 031/210] Reworked old filesending funcions --- Server/server_qt.py | 104 +++++++++++++++++--------------------------- 1 file changed, 40 insertions(+), 64 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index a08a21d..e1ca12a 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -121,7 +121,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_send_Aruco_map.triggered.connect(self.send_aruco) self.ui.action_send_launch_file.triggered.connect(self.send_launch) self.ui.action_send_fcu_parameters.triggered.connect(self.send_fcu_parameters) - self.ui.action_send_any_file.triggered.connect(self.send_any_files) + self.ui.action_send_any_file.triggered.connect(self.send_any_file) self.ui.action_send_any_command.triggered.connect(self.send_any_command) self.ui.action_restart_clever.triggered.connect( partial(self.send_to_selected, "service_restart", {"name": "clever"})) @@ -358,8 +358,13 @@ class MainWindow(QtWidgets.QMainWindow): callback(copter) def send_files(self, prompt, ext_filter, copters=None, client_path="", client_filename="", match_id=False, - callback=None): - files = QFileDialog.getOpenFileNames(self, prompt, filter=ext_filter)[0] + onefile=False, callback=None): + if onefile: + file = QFileDialog.getOpenFileName(self, prompt, filter=ext_filter)[0] + files = [file] if file else [] + else: + files = QFileDialog.getOpenFileNames(self, prompt, filter=ext_filter)[0] + if not files: return @@ -381,7 +386,7 @@ class MainWindow(QtWidgets.QMainWindow): self._send_files(files, copters, client_path, client_filename, match_id, callback) @pyqtSlot() - def send_any_files(self): + def send_any_file(self): file = QFileDialog.getOpenFileName(self, "Select any file")[0] if not file: return @@ -397,25 +402,42 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_animations(self): - self.send_directory_files("Select Animation Directory", ('.csv', '.txt'), match_id=True, + self.send_directory_files("Select directory with animations", ('.csv', '.txt'), match_id=True, client_path="", client_filename="animation.csv") @pyqtSlot() def send_calibrations(self): - path = str(QFileDialog.getExistingDirectory(self, "Select directory with calibration files")) + self.send_directory_files("Select directory with calibrations", ('.yaml', ), match_id=True, + client_path="/home/pi/catkin_ws/src/clever/clever/camera_info/", + client_filename="calibration.yaml") # TODO callback to reload clever? - if path: - print("Selected directory:", path) - files = [file for file in glob.glob(path + '/*.yaml')] - names = [os.path.basename(file).split(".")[0] for file in files] - # print(files) - for file, name in zip(files, names): - for copter in self.model.user_selected(): - if name == copter.copter_id: - copter.client.send_file(file, - "/home/pi/catkin_ws/src/clever/clever/camera_info/calibration.yaml") - else: - logging.info("Filename has no matches with any drone selected") + # from os.path import expanduser # TODO on client + # home = expanduser("~") -> "~catkin_ws/src/clever/clever/camera_info/" + + @pyqtSlot() + def send_aruco(self): + def callback(copter): + copter.client.send_message("service_restart", {"name": "clever"}) + + self.send_files("Select aruco map configuration file", "Aruco map files (*.txt)", onefile=True, + client_path="/home/pi/catkin_ws/src/clever/aruco_pose/map/", + client_filename="animation_map.txt", callback=callback) + + @pyqtSlot() + def send_launch(self): + self.send_directory_files("Select directory with calibrations", ('.yaml', ), match_id=False, + client_path='"/home/pi/catkin_ws/src/clever/clever/launch/') # TODO clever restart callback? + + @pyqtSlot() + def send_fcu_parameters(self): + def request_callback(copter, value): + logging.info("Send parameters to {} success: {}".format(copter.client.copter_id, value)) + + def callback(copter): + copter.client.get_response("load_params", request_callback) + + self.send_files("Select px4 param file", "px4 params (*.params)", onefile=True, + client_filename="temp.params", callback=callback) @pyqtSlot() def send_configurations(self): @@ -434,52 +456,6 @@ class MainWindow(QtWidgets.QMainWindow): for copter in self.model.user_selected(): copter.client.send_config_options(*options) - @pyqtSlot() - def send_aruco(self): - path = \ - QFileDialog.getOpenFileName(self, "Select aruco map configuration file", filter="Aruco map files (*.txt)")[0] - if path: - filename = os.path.basename(path) - print("Selected file:", path, filename) - for copter in self.model.user_selected(): - copter.client.send_file(path, "/home/pi/catkin_ws/src/clever/aruco_pose/map/animation_map.txt") - copter.client.send_message("service_restart", {"name": "clever"}) - - @pyqtSlot() - def send_launch(self): - path = str(QFileDialog.getExistingDirectory(self, "Select directory with launch files")) - if path: - print("Selected directory:", path) - files = [file for file in glob.glob(path + '/*.launch')] - for copter in self.model.user_selected(): - for file in files: - filename = os.path.basename(file) - copter.client.send_file(file, "/home/pi/catkin_ws/src/clever/clever/launch/{}".format(filename)) - - @pyqtSlot() - def send_fcu_parameters(self): - path = QFileDialog.getOpenFileName(self, "Select px4 param file", filter="px4 params (*.params)")[0] - if path: - filename = os.path.basename(path) - print("Selected file:", path, filename) - for copter in self.model.user_selected(): - copter.client.send_file(path, "temp.params") - copter.client.get_response("load_params", self._print_send_fcu_params_result, callback_args=(copter, )) - - def _print_send_fcu_params_result(self, value, copter): - logging.info("Send parameters to {} success: {}".format(copter.client.copter_id, value)) - - @pyqtSlot() - def send_any_file(self): - path = QFileDialog.getOpenFileName(self, "Select file")[0] - if path: - filename = os.path.basename(path) - print("Selected file:", path, filename) - text, okPressed = QInputDialog.getText(self, "Enter path to send on copter","Destination:", QLineEdit.Normal, "/home/pi/") - if okPressed and text != '': - for copter in self.model.user_selected(): - copter.client.send_file(path, text+'/'+filename) - @pyqtSlot() def send_any_command(self): text, okPressed = QInputDialog.getText(self, "Enter command to send on copter", From 9091d905544f95b02559182da5ffa5d43afc02b4 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 30 Dec 2019 10:35:48 +0300 Subject: [PATCH 032/210] Slight improvements --- Server/copter_table_models.py | 5 +++-- Server/server_qt.py | 15 +++++++++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 011d70b..281a659 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -212,7 +212,7 @@ class ModelFormatter: @ModelFormatter.col_format(0, ModelFormatter.PLACE_FORMATTER) def place_id(value): - value = value.stip() + value = value.strip() # check user hostname spelling http://man7.org/linux/man-pages/man7/hostname.7.html # '-' (hyphen) not first; latin letters/numbers/hyphens; length form 1 to 63 # or matches command pattern @@ -277,6 +277,7 @@ def place_time_delta(value): def view_time_delta(value): return "{:.3f}".format(value) + class CopterDataModel(QtCore.QAbstractTableModel): selected_ready_signal = QtCore.pyqtSignal(bool) selected_takeoff_ready_signal = QtCore.pyqtSignal(bool) @@ -460,6 +461,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): if row is not None: self.removeRows(row) + def flip_checks(copter_item): for col in ModelChecks.takeoff_checklist: if col != 4 or col != 7: @@ -498,7 +500,6 @@ class CopterProxyModel(QtCore.QSortFilterProxyModel): return self.human_sort_prepare(leftData) < self.human_sort_prepare(rightData) - class SignalManager(QtCore.QObject): update_data_signal = QtCore.pyqtSignal(int, int, QtCore.QVariant, QtCore.QVariant) add_client_signal = QtCore.pyqtSignal(object) diff --git a/Server/server_qt.py b/Server/server_qt.py index e1ca12a..074e197 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -74,6 +74,7 @@ class ServerQt(Server): table.ModelChecks.time_delta_max = self.config.checks_time_delta_max +# noinspection PyCallByClass,PyArgumentList class MainWindow(QtWidgets.QMainWindow): def __init__(self, server): super(MainWindow, self).__init__() @@ -460,17 +461,19 @@ class MainWindow(QtWidgets.QMainWindow): def send_any_command(self): text, okPressed = QInputDialog.getText(self, "Enter command to send on copter", "Command:", QLineEdit.Normal, "") - if okPressed and text != '': + if okPressed and text: self.send_to_selected("execute", {"command": text}) @pyqtSlot() def select_music_file(self): path = QFileDialog.getOpenFileName(self, "Select music file", filter="Music files (*.mp3 *.wav)")[0] - if path: - media = QUrl.fromLocalFile(path) - content = QtMultimedia.QMediaContent(media) - self.player.setMedia(content) - self.ui.action_select_music_file.setText(self.ui.action_select_music_file.text() + " (selected)") + if not path: + return + + media = QUrl.fromLocalFile(path) + content = QtMultimedia.QMediaContent(media) + self.player.setMedia(content) + self.ui.action_select_music_file.setText(self.ui.action_select_music_file.text() + " (selected)") @pyqtSlot() def play_music(self): From 17ea0eba180766d684b83b7e27a427d79b25d237 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 30 Dec 2019 10:36:08 +0300 Subject: [PATCH 033/210] WIP copter table context menus --- Server/copter_table.py | 73 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index f4b7876..db9b427 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -1,8 +1,11 @@ import logging -from PyQt5 import QtWidgets, QtCore +from PyQt5 import QtWidgets, QtCore, QtGui +from PyQt5.QtCore import Qt as Qt from PyQt5.QtCore import pyqtSlot -from PyQt5.QtWidgets import QTableView, QMessageBox +from PyQt5.QtGui import QCursor, QStandardItem +from PyQt5.QtWidgets import QTableView, QMessageBox, QMenu, QAction, QCheckBox, QWidgetAction, QListView, QListWidget, \ + QAbstractItemView, QListWidgetItem from server import Client import copter_table_models as table @@ -24,6 +27,14 @@ class CopterTableWidget(QTableView): # Initiate table and table self.model self.setModel(self.proxy_model) + self.horizontalHeader().setSectionsMovable(True) + header = self.horizontalHeader() + header.setContextMenuPolicy(Qt.CustomContextMenu) + #header.setContextMenuPolicy(CustomContextMenu) + header.customContextMenuRequested.connect(self.showHeaderMenu) + + # self.horizontalHeader().contextMenuEvent = self.headercontextMenuEvent + # Adjust properties self.resizeColumnsToContents() self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) @@ -57,6 +68,64 @@ class CopterTableWidget(QTableView): dialog.setDetailedText("\n".join(data)) dialog.exec() + # action = QAction(name, menu, checkable=True, checked=True) + def showHeaderMenu1(self, event): + menu = QMenu(self) + names = [] + for column in range(self.proxy_model.columnCount()): + name = self.proxy_model.headerData(column, Qt.Horizontal).strip() + names.append(name) + #l = max(map(len, names)) + for name in names: + # name = "{0:<{1}}|".format(name, l) + box = QCheckBox(menu) + action = QWidgetAction(menu) + box.setText(name) + action.setDefaultWidget(box) + menu.addAction(action) + #act = menu.addAction(name.strip()) + menu.exec_(QCursor.pos()) + + def showHeaderMenu(self, event): + menu = QMenu(self) + names = [] + for column in range(self.proxy_model.columnCount()): + name = self.proxy_model.headerData(column, Qt.Horizontal).strip() + names.append(name) + #l = max(map(len, names)) + + box = QListWidget(menu) + action = QWidgetAction(menu) + #box.setText(name) + # model = QtGui.QStandardItemModel() + # box.setModel(model) + box.setDragDropMode(QAbstractItemView.InternalMove) + box.setDefaultDropAction(Qt.MoveAction) + # #box.setMovement(QListView.Snap) + # box.setDragDropOverwriteMode(False) + + for name in names: + item = QListWidgetItem(name, box) + item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsDragEnabled | Qt.ItemIsSelectable) + item.setCheckState(Qt.Checked) + #item = box.addItem(name)#, checkable=True, checked=True) + print(item) + #item.setCheckable(True) + #box.model().appendRow(item) + + box.setFixedHeight((box.geometry().height()-6)*len(names)) + #box.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + action.setDefaultWidget(box) + menu.addAction(action) + #act = menu.addAction(name.strip()) + menu.exec_(QCursor.pos()) + + def contextMenuEvent(self, event): + menu = QMenu(self) + + menu.addAction("LOl") + menu.exec_(QCursor.pos()) + # def _selfcheck_shortener(self, data): # TODO!!! # shortened = [] # for line in data: From 5ffa90a8ee041cb9eb6cdeee62bba545633caca9 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 30 Dec 2019 14:03:24 +0300 Subject: [PATCH 034/210] Working headers context menu --- Server/copter_table.py | 98 ++++++++++++++++++------------------------ 1 file changed, 42 insertions(+), 56 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index db9b427..202f66a 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -1,13 +1,10 @@ -import logging - from PyQt5 import QtWidgets, QtCore, QtGui from PyQt5.QtCore import Qt as Qt from PyQt5.QtCore import pyqtSlot -from PyQt5.QtGui import QCursor, QStandardItem -from PyQt5.QtWidgets import QTableView, QMessageBox, QMenu, QAction, QCheckBox, QWidgetAction, QListView, QListWidget, \ +from PyQt5.QtGui import QCursor +from PyQt5.QtWidgets import QTableView, QMessageBox, QMenu, QAction, QWidgetAction, QListWidget, \ QAbstractItemView, QListWidgetItem -from server import Client import copter_table_models as table @@ -27,10 +24,9 @@ class CopterTableWidget(QTableView): # Initiate table and table self.model self.setModel(self.proxy_model) - self.horizontalHeader().setSectionsMovable(True) header = self.horizontalHeader() + header.setSectionsMovable(True) header.setContextMenuPolicy(Qt.CustomContextMenu) - #header.setContextMenuPolicy(CustomContextMenu) header.customContextMenuRequested.connect(self.showHeaderMenu) # self.horizontalHeader().contextMenuEvent = self.headercontextMenuEvent @@ -68,67 +64,57 @@ class CopterTableWidget(QTableView): dialog.setDetailedText("\n".join(data)) dialog.exec() - # action = QAction(name, menu, checkable=True, checked=True) - def showHeaderMenu1(self, event): - menu = QMenu(self) - names = [] - for column in range(self.proxy_model.columnCount()): - name = self.proxy_model.headerData(column, Qt.Horizontal).strip() - names.append(name) - #l = max(map(len, names)) - for name in names: - # name = "{0:<{1}}|".format(name, l) - box = QCheckBox(menu) - action = QWidgetAction(menu) - box.setText(name) - action.setDefaultWidget(box) - menu.addAction(action) - #act = menu.addAction(name.strip()) - menu.exec_(QCursor.pos()) - def showHeaderMenu(self, event): menu = QMenu(self) - names = [] - for column in range(self.proxy_model.columnCount()): - name = self.proxy_model.headerData(column, Qt.Horizontal).strip() - names.append(name) - #l = max(map(len, names)) - - box = QListWidget(menu) - action = QWidgetAction(menu) - #box.setText(name) - # model = QtGui.QStandardItemModel() - # box.setModel(model) - box.setDragDropMode(QAbstractItemView.InternalMove) - box.setDefaultDropAction(Qt.MoveAction) - # #box.setMovement(QListView.Snap) - # box.setDragDropOverwriteMode(False) - - for name in names: - item = QListWidgetItem(name, box) - item.setFlags(Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsDragEnabled | Qt.ItemIsSelectable) - item.setCheckState(Qt.Checked) - #item = box.addItem(name)#, checkable=True, checked=True) - print(item) - #item.setCheckable(True) - #box.model().appendRow(item) - - box.setFixedHeight((box.geometry().height()-6)*len(names)) + header_view = HeaderListWidget(menu, self) + header_view.setFixedHeight((header_view.geometry().height()-6)*len(header_view.names)) #box.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) - action.setDefaultWidget(box) + action = QWidgetAction(menu) + action.setDefaultWidget(header_view) menu.addAction(action) - #act = menu.addAction(name.strip()) menu.exec_(QCursor.pos()) def contextMenuEvent(self, event): menu = QMenu(self) - menu.addAction("LOl") - menu.exec_(QCursor.pos()) + menu.addAction("action") + # menu.exec_(QCursor.pos()) # def _selfcheck_shortener(self, data): # TODO!!! # shortened = [] # for line in data: # if len(line) > 89: # pass - # return shortened \ No newline at end of file + # return shortened + + +class HeaderListWidget(QListWidget): + def __init__(self, parent, source: CopterTableWidget): + super().__init__(parent) + self.source_widget = source + self.source_model = source.proxy_model + + self.setDragDropMode(QAbstractItemView.InternalMove) + self.setDefaultDropAction(Qt.MoveAction) + + self.names = list(self.get_names()) + self.populate_items() + self.itemChanged.connect(self.on_itemChanged) + + def get_names(self): + for column in range(self.source_model.columnCount()): + yield self.source_model.headerData(column, Qt.Horizontal).strip() + + def populate_items(self): + for column, name in enumerate(self.names): + hidden = self.source_widget.isColumnHidden(column) + flags = Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsEnabled + state = Qt.Unchecked if hidden else Qt.Checked + + item = QListWidgetItem(name, self) + item.setFlags(flags) + item.setCheckState(state) + + @pyqtSlot(QListWidgetItem) + def on_itemChanged(self, item): + self.source_widget.setColumnHidden(self.names.index(item.text()), not bool(item.checkState())) From 5bd372e25baa715c8099dbb4f7110e72d3d2de23 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 30 Dec 2019 19:41:31 +0300 Subject: [PATCH 035/210] Removed unnececary functions in config.py, added regular dict and merging support --- config.py | 48 ++++++++++++++++++------------------------------ 1 file changed, 18 insertions(+), 30 deletions(-) diff --git a/config.py b/config.py index cd7db70..ffec817 100644 --- a/config.py +++ b/config.py @@ -70,24 +70,6 @@ class ConfigManager: self.config = config self.validated = True - @classmethod - def _get_defaults(cls, item, unchanged_only=False): - if isinstance(item, Section): - default_values = item.default_values.copy() - - if unchanged_only: - default_list = item.defaults.copy() - defaults = {key: default_values[key] for key in default_list if key in default_values} - else: - defaults = default_values - - for key, value in item.items(): - result = cls._get_defaults(value, unchanged_only=unchanged_only) - if result is not None: - defaults[key] = result - - return defaults if defaults else None - @classmethod def _full_dict(cls, item): if not isinstance(item, Section): @@ -116,14 +98,6 @@ class ConfigManager: return data - @property - def default_values(self): - return self._get_defaults(self.config) or {} - - @property - def unchanged_defaults(self): - return self._get_defaults(self.config, unchanged_only=True) or {} - @property def full_dict(self): d = self._full_dict(self.config) @@ -219,10 +193,12 @@ class ConfigManager: def _extract_values(cls, d): result = {} for key, val in d.items(): - if isinstance(val, dict) and val.get('__option__', False): + if not isinstance(val, dict): # Pure dict option + result[key] = val + elif val.get('__option__', False): # Full-dict option with params if not val.get('unchanged', False): result[key] = val.get('value') - else: + else: # Section result[key] = cls._extract_values(val) return result @@ -232,11 +208,14 @@ class ConfigManager: inline_comments = {} for key, val in d.items(): - if val.get('__option__', False): + if not isinstance(val, dict): # Pure dict option + comments[key] = [] + inline_comments[key] = None + elif val.get('__option__', False): # Full-dict option with params comment = val.get('comments', []) comments[key] = [] if comment == [''] else comment inline_comments[key] = val.get('inline_comment', None) - else: + else: # Section cls._load_comments(val, section[key]) comments[key] = [''] inline_comments[key] = None @@ -268,6 +247,9 @@ class ConfigManager: self._load_comments(d, self.config) + def merge(self, config): + self.config.merge(config.config) + if __name__ == '__main__': cfg = ConfigManager() @@ -292,6 +274,12 @@ if __name__ == '__main__': # # print(11111) import pprint pprint.pprint(cfg.full_dict) + cfg2 = ConfigManager() + cfg2.load_from_dict({"PRIVATE": {"id": 123132}}) + pprint.pprint(cfg2.full_dict) + cfg.merge(cfg2) + pprint.pprint(cfg.full_dict) + # #print(cfg.full_dict) # # #cfg.load_from_dict(cfg.full_dict, 'Drone/config/client.ini') From 6ffef1a0bfac19d49080ac584c25d01e637e0277 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 30 Dec 2019 19:43:13 +0300 Subject: [PATCH 036/210] Optimized imports and naming, started rework of config sending --- Server/server_qt.py | 2 ++ messaging_lib.py | 3 --- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 074e197..2a59cf7 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -29,6 +29,8 @@ import messaging_lib as messaging import copter_table_models as table from copter_table import CopterTableWidget from emergency import * +#from emergency import * +# TODO uncomment def multi_glob(*patterns): diff --git a/messaging_lib.py b/messaging_lib.py index e7dc084..fedad21 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -18,9 +18,6 @@ except ImportError: import selectors2 as selectors -# import logging_lib - - class Namespace: def __init__(self, **kwargs): self.__dict__.update(kwargs) From 8efc1029b6465d34be6b65679841521bd29a47b9 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 30 Dec 2019 19:45:19 +0300 Subject: [PATCH 037/210] Optimized imports and naming, started rework of config sending --- Server/server_qt.py | 75 +++++++++++++++++++++++++-------------------- 1 file changed, 42 insertions(+), 33 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 2a59cf7..00f522e 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -1,34 +1,31 @@ import os import re +import sys import glob import math import time import logging import asyncio -import threading -import functools import itertools -from functools import partial +from functools import partial, wraps -from PyQt5 import QtWidgets, QtMultimedia -from PyQt5.QtGui import QStandardItemModel, QStandardItem, QPixmap -from PyQt5.QtCore import Qt, pyqtSlot, pyqtSignal, QObject, QUrl +from PyQt5 import QtWidgets, QtMultimedia, QtCore +from PyQt5.QtGui import QPixmap, QIcon +from PyQt5.QtCore import Qt, pyqtSlot, QUrl -from PyQt5.QtWidgets import QFileDialog, QMessageBox, QApplication, QWidget, QInputDialog, QLineEdit, QStatusBar, \ +from PyQt5.QtWidgets import QFileDialog, QMessageBox, QApplication, QInputDialog, QLineEdit, QStatusBar, \ QSplashScreen, QProgressBar -from quamash import QEventLoop, QThreadExecutor +from quamash import QEventLoop # Importing gui form from server_gui import Ui_MainWindow -import config - -from server import Server, Client +from server import Server, Client, now import messaging_lib as messaging +import config as cfg import copter_table_models as table from copter_table import CopterTableWidget -from emergency import * #from emergency import * # TODO uncomment @@ -39,7 +36,7 @@ def multi_glob(*patterns): def confirmation_required(text="Are you sure?", label="Confirm operation?"): def inner(f): - @functools.wraps(f) + @wraps(f) def wrapper(*args, **kwargs): reply = QMessageBox.question( args[0], label, @@ -64,7 +61,7 @@ class ExitMsgbox(logging.Handler): def _emit(self, record): # window.close() QMessageBox.warning(None, "Critical error in {}: {}". format(record.name, record.threadName), record.msg) - QtWidgets.QApplication.quit() + QApplication.quit() sys.exit(record.msg) @@ -120,7 +117,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_send_animations.triggered.connect(self.send_animations) self.ui.action_send_calibrations.triggered.connect(self.send_calibrations) - self.ui.action_send_configurations.triggered.connect(self.send_configurations) + self.ui.action_send_configurations.triggered.connect(self.send_config) self.ui.action_send_Aruco_map.triggered.connect(self.send_aruco) self.ui.action_send_launch_file.triggered.connect(self.send_launch) self.ui.action_send_fcu_parameters.triggered.connect(self.send_fcu_parameters) @@ -302,7 +299,7 @@ class MainWindow(QtWidgets.QMainWindow): copter.client.send_message("flip") @pyqtSlot() - def calibrate_gyro_selected(self): + def calibrate_gyro_selected(self): # TODO merge commands for copter_data_row in self.model.user_selected(): client = copter_data_row.client # Update calibration status @@ -381,10 +378,10 @@ class MainWindow(QtWidgets.QMainWindow): return if extensions: - extensions = ['/*'+ext for ext in extensions] - patterns = [path + ext for ext in extensions] + patterns = [path + '/*' + ext for ext in extensions] else: patterns = [path+'/*.*'] + files = multi_glob(*patterns) self._send_files(files, copters, client_path, client_filename, match_id, callback) @@ -443,21 +440,33 @@ class MainWindow(QtWidgets.QMainWindow): client_filename="temp.params", callback=callback) @pyqtSlot() - def send_configurations(self): + def send_config(self): path = QFileDialog.getOpenFileName(self, "Select configuration file", filter="Configs (*.ini *.txt .cfg)")[0] - if path: - print("Selected file:", path) - sendable_config = configparser.ConfigParser() # TODO - sendable_config.read(path) - options = [] - for section in sendable_config.sections(): - for option in dict(sendable_config.items(section)): - value = sendable_config[section][option] - logging.debug("Got item from config: {} {} {}".format(section, option, value)) - options.append(ConfigOption(section, option, value)) + if not path: + return - for copter in self.model.user_selected(): - copter.client.send_config_options(*options) + config = cfg.ConfigManager() + config.load_only_config(path) + data = config.full_dict + logging.info(f"Loaded config from {path}") + + copters = self.model.user_selected() + for copter in copters: + copter.client.send_message("config", {"config": data, }) + + # if path: + # print("Selected file:", path) + # sendable_config = configparser.ConfigParser() # TODO + # sendable_config.read(path) + # options = [] + # for section in sendable_config.sections(): + # for option in dict(sendable_config.items(section)): + # value = sendable_config[section][option] + # logging.debug("Got item from config: {} {} {}".format(section, option, value)) + # options.append(ConfigOption(section, option, value)) + # + # for copter in self.model.user_selected(): + # copter.client.send_config_options(*options) @pyqtSlot() def send_any_command(self): @@ -597,7 +606,7 @@ if __name__ == "__main__": sys.excepthook = except_hook # for debugging (exceptions traceback) - app = QtWidgets.QApplication(sys.argv) + app = QApplication(sys.argv) splash_pix = QPixmap('icons/coex_splash.jpg') splash = QSplashScreen(splash_pix) @@ -611,7 +620,7 @@ if __name__ == "__main__": splash.show() # time.sleep(3) - app_icon = QtGui.QIcon() + app_icon = QIcon() app_icon.addFile('icons/image.ico', QtCore.QSize(256, 256)) app.setWindowIcon(app_icon) From 0ed088a99d0a22dfcce94a2296f688ff70bf9bb5 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 2 Jan 2020 19:26:27 +0300 Subject: [PATCH 038/210] Improved notifier closing and error handling + fixes --- Server/copter_table_models.py | 7 ++--- Server/server.py | 50 +++++++++++++++++++---------------- messaging_lib.py | 26 ++++++++++++------ 3 files changed, 47 insertions(+), 36 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index df66afc..92fa729 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -101,9 +101,6 @@ def check_start_pos_status(item): def check_time_delta(item): return abs(item) < ModelChecks.time_delta_max -battery_min = config.getfloat('CHECKS', 'battery_percentage_min') -start_pos_delta_max = config.getfloat('CHECKS', 'start_pos_delta_max') -time_delta_max = config.getfloat('CHECKS', 'time_delta_max') class ModelChecks: checks_dict = {} @@ -151,7 +148,7 @@ def check_anim(item): def check_bat(item): if item == "NO_INFO": return False - return item[1]*100 > battery_min + return item[1]*100 > ModelChecks.battery_min @ModelChecks.col_check(4) @@ -188,7 +185,7 @@ def check_start_pos_status(item): @ModelChecks.col_check(10) def check_time_delta(item): - return abs(item) < time_delta_max + return abs(item) < ModelChecks.time_delta_max class CopterData: diff --git a/Server/server.py b/Server/server.py index 918a6dd..03a2b4b 100644 --- a/Server/server.py +++ b/Server/server.py @@ -1,3 +1,4 @@ +import os import sys import time import socket @@ -8,7 +9,7 @@ import threading import selectors import collections -import os, inspect # Add parent dir to PATH to import messaging_lib and config_lib +import inspect # Add parent dir to PATH to import messaging_lib and config_lib current_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) parent_dir = os.path.dirname(current_dir) @@ -140,14 +141,11 @@ class Server(messaging.Singleton): self.server_socket.listen() self.server_socket.setblocking(False) - self.sel.register(self.server_socket, selectors.EVENT_READ, data=None) #| selectors.EVENT_WRITE + self.sel.register(self.server_socket, selectors.EVENT_READ, data=None) while self.client_processor_thread_running.is_set(): events = self.sel.select(timeout=1) - #logging.error('tick') for key, mask in events: - # logging.error(mask) - # logging.error(str(key.data)) client = key.data if client is None: self._connect_client(key.fileobj) @@ -197,8 +195,15 @@ class Server(messaging.Singleton): try: while self.broadcast_thread_running.is_set(): self.broadcast_thread_interrupt.wait(timeout=self.config.broadcast_delay) - broadcast_sock.sendto(msg, ('255.255.255.255', self.config.broadcast_port)) - logging.debug("Broadcast sent") + try: + broadcast_sock.sendto(msg, ('255.255.255.255', self.config.broadcast_port)) + except OSError as e: + logging.error(f"Cannot send broadcast due error {e}") + else: + logging.debug("Broadcast sent") + except Exception as e: + logging.error(f"Unexpected error {e}!") + raise finally: broadcast_sock.close() @@ -208,6 +213,7 @@ class Server(messaging.Singleton): logging.info("Broadcast listener thread started!") broadcast_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) broadcast_client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + # broadcast_client.settimeout(1) try: broadcast_client.bind(("", self.config.broadcast_port)) except OSError: @@ -216,7 +222,12 @@ class Server(messaging.Singleton): try: while self.listener_thread_running.is_set(): - data, addr = broadcast_client.recvfrom(1024) # TODO nonblock + try: + data, addr = broadcast_client.recvfrom(1024) # TODO nonblock + except OSError: + logging.error(f"Cannot receive broadcast due error {e}") + continue + message = messaging.MessageManager() message.income_raw = data message.process_message() @@ -234,12 +245,16 @@ class Server(messaging.Singleton): else: logging.warning("Got wrong broadcast message from {}".format(addr)) + + except Exception as e: + logging.error(f"Unexpected error {e}!") + raise + finally: broadcast_client.close() logging.info("Broadcast listener thread stopped, socked closed!") def send_starttime(self, copter, start_time): - print('start_time: {}'.format(start_time)) copter.send_message("start", {"time": str(start_time)}) @@ -247,8 +262,7 @@ def requires_connect(f): def wrapper(*args, **kwargs): if args[0].connected: return f(*args, **kwargs) - else: - logging.warning("Function requires client to be connected!") + logging.warning("Function requires client to be connected!") return wrapper @@ -257,8 +271,7 @@ def requires_any_connected(f): def wrapper(*args, **kwargs): if Client.clients: return f(*args, **kwargs) - else: - logging.warning("No clients were connected!") + logging.warning("No clients were connected!") return wrapper @@ -335,15 +348,6 @@ class Client(messaging.ConnectionManager): super()._send(data) logging.debug("Queued data to send (first 256 bytes): {}".format(data[:256])) - def send_config_options(self, *options: ConfigOption, reload_config=True): - logging.info("Sending config options: {} to {}".format(options, self.addr)) - sending_options = [{'section': option.section, 'option': option.option, 'value': option.value} - for option in options] - print(sending_options) - self.send_message( - 'config_write', {"options": sending_options, "reload": reload_config} - ) - @staticmethod @requires_any_connected def broadcast(message, force_all=False): @@ -370,4 +374,4 @@ if __name__ == '__main__': server.start() while True: - pass \ No newline at end of file + pass diff --git a/messaging_lib.py b/messaging_lib.py index ebaefa4..fa95415 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -5,7 +5,6 @@ import json import socket import struct import random -import inspect import logging import threading import collections @@ -35,9 +34,6 @@ class PendingRequest(Namespace): pass logger = logging.getLogger(__name__) -# logger = logging_lib.Logger(_logger, True) - - def get_ip_address(): try: with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as ip_socket: @@ -506,6 +502,7 @@ class NotifierSock(Singleton): self._send_lock = threading.Lock() self._receiving_sock = None + self._selector = None def init(self, selector, port=26000): port += random.randint(0, 100) # local testing fix @@ -517,6 +514,7 @@ class NotifierSock(Singleton): logger.info("Notify socket: connected") selector.register(self._receiving_sock, selectors.EVENT_READ, data=self) + self._selector = selector logger.info("Notify socket: selector registered") def get_sock(self): @@ -524,9 +522,10 @@ class NotifierSock(Singleton): def notify(self): with self._send_lock: - if self._receiving_sock is not None: - self._sending_sock.sendall(bytes(1)) - logger.debug("Notify socket: notified") + if self._receiving_sock is None: + return + self._sending_sock.sendall(bytes(1)) + logger.debug("Notify socket: notified") def process_events(self, mask): if mask & selectors.EVENT_READ and self._receiving_sock is not None: @@ -536,4 +535,15 @@ class NotifierSock(Singleton): except io.BlockingIOError: pass except Exception as e: - print(e) \ No newline at end of file + logger.error(e) + + def close(self): + try: + self._selector.unregister(self._receiving_sock) + self._server_socket.close() + self._sending_sock.close() + self._receiving_sock.close() + + except OSError as e: + pass + From 98e4e0190cce6f8b10654eb057457e75a59ce253 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 3 Jan 2020 15:36:54 +0300 Subject: [PATCH 039/210] Merge fixes and improvements --- Server/copter_table_models.py | 12 +++-- Server/server.py | 2 +- Server/server_qt.py | 93 +++++++++++------------------------ messaging_lib.py | 2 +- 4 files changed, 38 insertions(+), 71 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index fd438dc..5183e47 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -501,12 +501,14 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.data_contents[row].states.checked = value elif role == Qt.EditRole: # For user/outer actions with data, place modifiers applied formatted_value = self.formatter.format_place(col, value) - if formatted_value is not None: # todo use new := syntax - self.data_contents[row][col] = formatted_value + if formatted_value is None: # todo use new := syntax + return False - if col == 0: - self.data_contents[row].client.send_message("id", {"new_id": formatted_value}) - self.data_contents[row].client.remove() + self.data_contents[row][col] = formatted_value + + if col == 0: + self.data_contents[row].client.send_message("id", {"new_id": formatted_value}) + self.data_contents[row].client.remove() # TODO change elif role == ModelDataRole: # For inner setting\editing of data self.data_contents[row][col] = value diff --git a/Server/server.py b/Server/server.py index 46d65be..986fddc 100644 --- a/Server/server.py +++ b/Server/server.py @@ -172,7 +172,7 @@ class Server(messaging.Singleton): if not any([client_addr == addr[0] for client_addr in Client.clients.keys()]): client = Client(addr[0]) - client.buffer_size = self.BUFFER_SIZE + client.buffer_size = self.config.server_buffer_size logging.info("New client") else: client = Client.clients[addr[0]] diff --git a/Server/server_qt.py b/Server/server_qt.py index 48b2b69..223e328 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -2,7 +2,6 @@ import os import re import sys import glob -import math import time import logging import asyncio @@ -30,7 +29,6 @@ from copter_table import CopterTableWidget from visual_land_dialog import VisualLandDialog - def multi_glob(*patterns): return itertools.chain.from_iterable(glob.iglob(pattern) for pattern in patterns) @@ -100,21 +98,19 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.start_button.clicked.connect(self.send_start_time_selected) self.ui.pause_button.clicked.connect(self.pause_resume_selected) - self.ui.emergency_button.clicked.connect(self.emergency) - self.ui.disarm_button.clicked.connect(partial(self.send_to_selected, "disarm")) - self.ui.disarm_all_button.clicked.connect(self.disarm_all) - + self.ui.land_all_button.clicked.connect(partial(Client.broadcast, "land")) + self.ui.land_selected_button.clicked.connect(partial(self.send_to_selected, "land")) + self.ui.disarm_all_button.clicked.connect(partial(Client.broadcast, "disarm")) + self.ui.disarm_selected_button.clicked.connect(partial(self.send_to_selected, "disarm")) + self.ui.visual_land_button.clicked.connect(self.visual_land) + self.ui.emergency_land_button.clicked.connect(partial(self.send_to_selected, "emergency_land")) self.ui.leds_button.clicked.connect(partial(self.send_to_selected, "led_test")) self.ui.takeoff_button.clicked.connect(self.takeoff_selected) self.ui.flip_button.clicked.connect(self.flip_selected) - self.ui.land_button.clicked.connect(partial(self.send_to_selected, "land")) - self.ui.reboot_fcu.clicked.connect(partial(self.send_to_selected, "reboot_fcu")) self.ui.calibrate_gyro.clicked.connect(self.calibrate_gyro_selected) self.ui.calibrate_level.clicked.connect(self.calibrate_level_selected) - self.ui.action_remove_row.triggered.connect(self.remove_selected) - self.ui.action_send_animations.triggered.connect(self.send_animations) self.ui.action_send_calibrations.triggered.connect(self.send_calibrations) self.ui.action_send_configurations.triggered.connect(self.send_config) @@ -138,6 +134,8 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_play_music.triggered.connect(self.play_music) self.ui.action_stop_music.triggered.connect(self.stop_music) + self.ui.action_select_all_rows.triggered.connect(self.model.select_all) + self.init_table() # Set most safety-important buttons disabled @@ -146,20 +144,16 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.flip_button.setEnabled(False) def init_table(self): - # remove standard table widget + # Remove standard table widget self.ui.horizontalLayout.removeWidget(self.ui.tableView) self.ui.tableView.close() - - # init our custom widget + # Init our custom widget self.ui.copter_table = CopterTableWidget(self.model) self.ui.copter_table.setObjectName("copter_table") - - # add to layout - self.ui.horizontalLayout.addWidget(self.ui.copter_table, 0) + # Insert to layout at right + self.ui.horizontalLayout.insertWidget(0, self.ui.copter_table, 0) def init_model(self): - # self.model.on_id_changed = self.set_copter_id - # Connect model signals to UI self.model.selected_ready_signal.connect(self.ui.start_button.setEnabled) self.model.selected_takeoff_ready_signal.connect(self.ui.takeoff_button.setEnabled) @@ -167,18 +161,18 @@ class MainWindow(QtWidgets.QMainWindow): # Connect calibrating signal (testing) self.model.selected_calibrating_signal.connect(self.ui.check_button.setDisabled) self.model.selected_calibrating_signal.connect(self.ui.pause_button.setDisabled) - self.model.selected_calibrating_signal.connect(self.ui.stop_button.setDisabled) - self.model.selected_calibrating_signal.connect(self.ui.emergency_button.setDisabled) - self.model.selected_calibrating_signal.connect(self.ui.disarm_button.setDisabled) + self.model.selected_calibrating_signal.connect(self.ui.land_all_button.setDisabled) + self.model.selected_calibrating_signal.connect(self.ui.land_selected_button.setDisabled) + self.model.selected_calibrating_signal.connect(self.ui.disarm_selected_button.setDisabled) self.model.selected_calibrating_signal.connect(self.ui.disarm_all_button.setDisabled) + self.model.selected_calibrating_signal.connect(self.ui.visual_land_button.setDisabled) + self.model.selected_calibrating_signal.connect(self.ui.emergency_land_button.setDisabled) self.model.selected_calibrating_signal.connect(self.ui.leds_button.setDisabled) - self.model.selected_calibrating_signal.connect(self.ui.land_button.setDisabled) self.model.selected_calibrating_signal.connect(self.ui.reboot_fcu.setDisabled) + self.model.selected_calibration_ready_signal.connect(self.ui.calibrate_gyro.setEnabled) self.model.selected_calibration_ready_signal.connect(self.ui.calibrate_level.setEnabled) - self.ui.action_select_all_rows.triggered.connect(self.model.select_all) - def iterate_selected(self, f, *args, **kwargs): for copter in self.model.user_selected(): yield f(copter, *args, **kwargs) @@ -211,9 +205,8 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def selfcheck_selected(self): - for copter_data_row in self.model.user_selected(): - client = copter_data_row.client - client.get_response("telemetry", self.update_table_data) + for copter in self.model.user_selected(): + copter.client.get_response("telemetry", self.update_table_data) @pyqtSlot(object, dict) def update_table_data(self, client, telems: dict): @@ -273,24 +266,6 @@ class MainWindow(QtWidgets.QMainWindow): self.send_to_selected("resume", {"time": server.time_now() + time_gap}) self.ui.pause_button.setText('Pause') - @pyqtSlot() - def land_selected(self): - for copter in self.model.user_selected(): - copter.client.send_message("land") - - @pyqtSlot() - def land_all(self): - Client.broadcast_message("land") - - @pyqtSlot() - def disarm_all(self): - Client.broadcast_message("disarm") - - @pyqtSlot() - def test_leds_selected(self): - for copter in self.model.user_selected(): - copter.client.send_message("led_test") - @pyqtSlot() @confirmation_required("This operation will takeoff copters immediately. Proceed?") def takeoff_selected(self): @@ -451,6 +426,11 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_config(self): + mode, ok = QInputDialog.getItem(self, "Select config sending mode", "Mode:", + ("Modify", "Rewrite"), 0, False) + if not ok or not mode: + return + path = QFileDialog.getOpenFileName(self, "Select configuration file", filter="Configs (*.ini *.txt .cfg)")[0] if not path: return @@ -462,27 +442,13 @@ class MainWindow(QtWidgets.QMainWindow): copters = self.model.user_selected() for copter in copters: - copter.client.send_message("config", {"config": data, }) - - # if path: - # print("Selected file:", path) - # sendable_config = configparser.ConfigParser() # TODO - # sendable_config.read(path) - # options = [] - # for section in sendable_config.sections(): - # for option in dict(sendable_config.items(section)): - # value = sendable_config[section][option] - # logging.debug("Got item from config: {} {} {}".format(section, option, value)) - # options.append(ConfigOption(section, option, value)) - # - # for copter in self.model.user_selected(): - # copter.client.send_config_options(*options) + copter.client.send_message("config", {"config": data, "mode": mode}) @pyqtSlot() def send_any_command(self): - text, okPressed = QInputDialog.getText(self, "Enter command to send on copter", - "Command:", QLineEdit.Normal, "") - if okPressed and text: + text, ok = QInputDialog.getText(self, "Enter command to send on copter", + "Command:", QLineEdit.Normal, "") + if ok and text: self.send_to_selected("execute", {"command": text}) @pyqtSlot() @@ -610,7 +576,6 @@ if __name__ == "__main__": server.start() window.show() - # window.send_directory_files("lol") splash.close() loop.run_forever() diff --git a/messaging_lib.py b/messaging_lib.py index fa95415..a9d741f 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -544,6 +544,6 @@ class NotifierSock(Singleton): self._sending_sock.close() self._receiving_sock.close() - except OSError as e: + except (OSError, KeyError) as e: pass From 0f2574554d2f630c115d62ee7c03d43f03668d86 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 5 Jan 2020 12:37:33 +0300 Subject: [PATCH 040/210] Slight improvemnts --- Server/server.py | 2 +- Server/server_qt.py | 2 +- messaging_lib.py | 6 ++---- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/Server/server.py b/Server/server.py index 986fddc..373aced 100644 --- a/Server/server.py +++ b/Server/server.py @@ -60,7 +60,7 @@ class Server(messaging.Singleton): self.broadcast_thread = threading.Thread(target=self._ip_broadcast, daemon=True, name='IP broadcast sender') - self.broadcast_thread_running = threading.Event() # TOOD replace by interrupt + self.broadcast_thread_running = threading.Event() # TODO replace by interrupt self.broadcast_thread_interrupt = threading.Event() self.listener_thread = threading.Thread(target=self._broadcast_listen, daemon=True, diff --git a/Server/server_qt.py b/Server/server_qt.py index 223e328..ab5b55d 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -442,7 +442,7 @@ class MainWindow(QtWidgets.QMainWindow): copters = self.model.user_selected() for copter in copters: - copter.client.send_message("config", {"config": data, "mode": mode}) + copter.client.send_message("config", {"config": data, "mode": mode.lower()}) @pyqtSlot() def send_any_command(self): diff --git a/messaging_lib.py b/messaging_lib.py index a9d741f..650dcff 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -543,7 +543,5 @@ class NotifierSock(Singleton): self._server_socket.close() self._sending_sock.close() self._receiving_sock.close() - - except (OSError, KeyError) as e: - pass - + except (OSError, KeyError) as error: + logger.error("Error during unregistring notifier socket: {}".format(error)) From ccab44af56df9c54adb7aa7f0bf0a59803f43bad Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 5 Jan 2020 17:16:39 +0300 Subject: [PATCH 041/210] Perserving dict order in messages and configs --- config.py | 12 +++++++----- messaging_lib.py | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/config.py b/config.py index ffec817..9f773c0 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,5 @@ import os +import collections from configobj import ConfigObj, Section, flatten_errors from validate import Validator @@ -60,12 +61,13 @@ class ConfigManager: self.config = config self.validated = False - def validate_config(self, config, copy_defaults=False): + def validate_config(self, config=None, copy_defaults=False): + config = config or self.config vdt = Validator() test = config.validate(vdt, copy=copy_defaults, preserve_errors=True) if test != True: # Important syntax, do no change - raise ValidationError('Some values are wrong: {}'.format(test), config, test) + raise ValidationError('Some config values are wrong: {}'.format(test), config, test) self.config = config self.validated = True @@ -75,7 +77,7 @@ class ConfigManager: if not isinstance(item, Section): return item - data = {} + data = collections.OrderedDict() default_values = item.default_values defaults = item.defaults comments = item.comments @@ -191,11 +193,11 @@ class ConfigManager: @classmethod def _extract_values(cls, d): - result = {} + result = collections.OrderedDict() for key, val in d.items(): if not isinstance(val, dict): # Pure dict option result[key] = val - elif val.get('__option__', False): # Full-dict option with params + elif val.get('__option__', False): # Full-dict option with params if not val.get('unchanged', False): result[key] = val.get('value') else: # Section diff --git a/messaging_lib.py b/messaging_lib.py index 650dcff..43bc0e2 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -71,7 +71,7 @@ class MessageManager: @staticmethod def _json_decode(json_bytes, encoding="utf-8"): with io.TextIOWrapper(io.BytesIO(json_bytes), encoding=encoding, newline="") as tiow: - obj = json.load(tiow) + obj = json.load(tiow, object_pairs_hook=collections.OrderedDict) return obj @classmethod From bfe983b82d265dc3a9aadacea582456a765ef405 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 5 Jan 2020 17:17:25 +0300 Subject: [PATCH 042/210] Validation upon merge --- config.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/config.py b/config.py index 9f773c0..e091717 100644 --- a/config.py +++ b/config.py @@ -1,4 +1,5 @@ import os +import copy import collections from configobj import ConfigObj, Section, flatten_errors @@ -249,8 +250,13 @@ class ConfigManager: self._load_comments(d, self.config) - def merge(self, config): - self.config.merge(config.config) + def merge(self, config, validate=True): + current = copy.deepcopy(self.config) + current.merge(config.config) + if validate: + self.validate_config(current) + else: + self.set_config(current) if __name__ == '__main__': From 81c6961841c43563a40036b888ca4a1a98e6388b Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 5 Jan 2020 17:57:29 +0300 Subject: [PATCH 043/210] Hadling of missing command\key erorr differently --- messaging_lib.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/messaging_lib.py b/messaging_lib.py index 43bc0e2..ca81e40 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -342,7 +342,8 @@ class ConnectionManager(object): def process_received(self, income_message): message_type = income_message.jsonheader["message-type"] logger.debug( - "Received message! Header: {}, content: {}".format(income_message.jsonheader, income_message.content)) + "Received message! Header: {}, content: {}".format( + income_message.jsonheader, income_message.content[:256])) if message_type == "message": self._process_message(income_message) @@ -356,10 +357,12 @@ class ConnectionManager(object): def _process_message(self, message): command = message.content["command"] args = message.content["args"] - try: - self.messages_callbacks[command](self, **args) - except KeyError: + callback = self.messages_callbacks.get(command, None) + if callback is None: logger.warning("Command {} does not exist!".format(command)) + return + try: + callback(self, **args) except Exception as error: logger.error("Error during command {} execution: {}".format(command, error)) @@ -367,10 +370,12 @@ class ConnectionManager(object): command = message.content["requested_value"] request_id = message.content["request_id"] args = message.content["args"] - try: - value = self.requests_callbacks[command](self, **args) - except KeyError: + callback = self.requests_callbacks.get(command, None) + if callback is None: logger.warning("Request {} does not exist!".format(command)) + return + try: + value = callback(self, **args) except Exception as error: # TODO send response error\cancel logger.error("Error during request {} processing: {}".format(command, error)) else: @@ -431,8 +436,7 @@ class ConnectionManager(object): else: self._send_buffer = self._send_buffer[sent:] left = len(self._send_buffer) - logger.debug("Sent message to {}: sent {} bytes, {} bytes left.".format(self.addr, sent, left))#, self._send_buffer[:sent],)) - + logger.debug("Sent message to {}: sent {} bytes, {} bytes left.".format(self.addr, sent, left)) def _send(self, data): with self._send_lock: From 3798a5fddcea462bf0011dad7d322aff4cccf592 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 5 Jan 2020 17:58:04 +0300 Subject: [PATCH 044/210] Client with new config system --- Drone/client.py | 124 ++++++++++++++++++-------------------------- Server/server_qt.py | 2 +- 2 files changed, 52 insertions(+), 74 deletions(-) diff --git a/Drone/client.py b/Drone/client.py index 391d7eb..236c098 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -1,18 +1,16 @@ import os +import sys import time import errno import random import socket import struct import logging -import collections -import ConfigParser import selectors2 as selectors -import threading from contextlib import closing -import os,sys,inspect # Add parent dir to PATH to import messaging_lib +import inspect # Add parent dir to PATH to import messaging_lib current_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) parent_dir = os.path.dirname(current_dir) sys.path.insert(0, parent_dir) @@ -20,69 +18,40 @@ sys.path.insert(0, parent_dir) logger = logging.getLogger(__name__) import messaging_lib as messaging -import config +from config import ConfigManager -ConfigOption = collections.namedtuple("ConfigOption", ["section", "option", "value"]) +active_client = None # needs to be refactored: Singleton \ factory callbacks -active_client = None # maybe needs to be refactored class Client(object): - def __init__(self, config_path="client_config.ini"): + def __init__(self, config_path="config/client.ini"): self.selector = selectors.DefaultSelector() self.client_socket = None self.server_connection = messaging.ConnectionManager("pi") - self.server_host = None - self.server_port = None - self.broadcast_port = None - self.connected = False self.client_id = None # Init configs + self.config = ConfigManager() self.config_path = config_path - self.config = ConfigParser.ConfigParser() - self.load_config() global active_client active_client = self - # self._last_ping_time = 0 - def load_config(self): - self.config.read(self.config_path) + self.config.load_config_and_spec(self.config_path) - self.broadcast_port = self.config.getint('SERVER', 'broadcast_port') - self.server_port = self.config.getint('SERVER', 'port') - self.server_host = self.config.get('SERVER', 'host') - self.BUFFER_SIZE = self.config.getint('SERVER', 'buffer_size') - self.USE_NTP = self.config.getboolean('NTP', 'use_ntp') - self.NTP_HOST = self.config.get('NTP', 'host') - self.NTP_PORT = self.config.getint('NTP', 'port') - - self.client_id = self.config.get('PRIVATE', 'id') - if self.client_id == '/default': + config_id = self.config.private_id.lower() + if config_id == '/default': self.client_id = 'copter' + str(random.randrange(9999)).zfill(4) - self.write_config(False, ConfigOption('PRIVATE', 'id', self.client_id)) - elif self.client_id == '/hostname': + self.config.set('PRIVATE', 'id', self.client_id, write=True) # set and write + elif config_id == '/hostname': self.client_id = socket.gethostname() - elif self.client_id == '/ip': + elif config_id == '/ip': self.client_id = messaging.get_ip_address() - def rewrite_config(self): - with open(self.config_path, 'w') as file: - self.config.write(file) - os.system("chown -R pi:pi /home/pi/clever-show") - - def write_config(self, reload_config=True, *config_options): - for config_option in config_options: - self.config.set(config_option.section, config_option.option, config_option.value) - self.rewrite_config() - - if reload_config: - self.load_config() - @staticmethod def get_ntp_time(ntp_host, ntp_port): NTP_PACKET_FORMAT = "!12I" @@ -96,13 +65,15 @@ class Client(object): return unpacked[10] + float(unpacked[11]) / 2 ** 32 - NTP_DELTA def time_now(self): - if self.USE_NTP: - timenow = self.get_ntp_time(self.NTP_HOST, self.NTP_PORT) + if self.config.ntp_use: + timenow = self.get_ntp_time(self.config.ntp_host, self.config.ntp_port) else: timenow = time.time() return timenow def start(self): + self.load_config() + logger.info("Starting client") messaging.NotifierSock().init(self.selector) @@ -115,8 +86,8 @@ class Client(object): logger.critical("Caught interrupt, exiting!") self.selector.close() - def _reconnect(self, timeout=2.0, attempt_limit=3): - logger.info("Trying to connect to {}:{} ...".format(self.server_host, self.server_port)) + def _reconnect(self, timeout=2.0, attempt_limit=3): # TODO reconnecting broadcast listener in another thread + logger.info("Trying to connect to {}:{} ...".format(self.config.server_host, self.config.server_port)) attempt_count = 0 while not self.connected: logger.info("Waiting for connection, attempt {}".format(attempt_count)) @@ -125,7 +96,7 @@ class Client(object): self.client_socket.settimeout(timeout) self.client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) self.client_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - self.client_socket.connect((self.server_host, self.server_port)) + self.client_socket.connect((self.config.server_host, self.config.server_port)) except socket.error as error: if isinstance(error, OSError): if error.errno == errno.EINTR: @@ -149,21 +120,25 @@ class Client(object): def _connect(self): self.connected = True self.client_socket.setblocking(False) - events = selectors.EVENT_READ # | selectors.EVENT_WRITE - self.selector.register(self.client_socket, events, data=self.server_connection) - self.server_connection.connect(self.selector, self.client_socket, (self.server_host, self.server_port)) + self.selector.register(self.client_socket, selectors.EVENT_READ, data=self.server_connection) + self.server_connection.connect(self.selector, self.client_socket, + (self.config.server_host, self.config.server_port)) def broadcast_bind(self, timeout=2.0, attempt_limit=3): broadcast_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) broadcast_client.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) - broadcast_client.bind(("", self.broadcast_port)) broadcast_client.settimeout(timeout) + try: + broadcast_client.bind(("", self.config.broadcast_port)) + except socket.error as error: + logger.error("Error during broadcast listening binding: {}".format(error)) + return attempt_count = 0 try: while attempt_count <= attempt_limit: try: - data, addr = broadcast_client.recvfrom(self.BUFFER_SIZE) + data, addr = broadcast_client.recvfrom(self.config.server_buffer_size) except socket.error as error: logger.warning("Could not receive broadcast due error: {}".format(error)) attempt_count += 1 @@ -175,33 +150,27 @@ class Client(object): logger.info("Received broadcast message {} from {}".format(message.content, addr)) if message.content["command"] == "server_ip": args = message.content["args"] - self.server_port = int(args["port"]) - self.server_host = args["host"] - self.write_config(False, - ConfigOption("SERVER", "port", self.server_port), - ConfigOption("SERVER", "host", self.server_host)) - logger.info("Binding to new IP: {}:{}".format(self.server_host, self.server_port)) + self.config.set("SERVER", "port", int(args["port"])) + self.config.set("SERVER", "host", args["host"]) + self.config.write() + + logger.info("Binding to new IP: {}:{}".format( + self.config.server_host, self.config.server_port)) self.on_broadcast_bind() break finally: broadcast_client.close() - def on_broadcast_bind(self): + def on_broadcast_bind(self): # TODO move ALL binding code here pass def _process_connections(self): while True: events = self.selector.select(timeout=1) - # if time.time() - self._last_ping_time > 5: - # self.server_connection.send_message("ping") - # self._last_ping_time = time.time() - # logging.debug("tick") for key, mask in events: connection = key.data - if connection is None: - pass - else: + if connection is not None: try: connection.process_events(mask) @@ -228,19 +197,28 @@ class Client(object): return -@messaging.message_callback("config_write") +@messaging.message_callback("config") def _command_config_write(*args, **kwargs): - options = [ConfigOption(**raw_option) for raw_option in kwargs["options"]] - logger.info("Writing config_attrs options: {}".format(options)) - active_client.write_config(kwargs["reload"], *options) + print(kwargs) + mode = kwargs.get("mode", "modify") + # exceptions would be risen in case of incorrect config + if mode == "rewrite": + active_client.config.load_from_dict(kwargs["config"], path=active_client.config_path) # with validation + elif mode == "modify": + new_config = ConfigManager() + new_config.load_from_dict(kwargs["config"]) + active_client.config.merge(new_config, validate=True) + + active_client.config.write() + active_client.load_config() @messaging.request_callback("id") def _response_id(*args, **kwargs): new_id = kwargs.get("new_id", None) if new_id is not None: - cfg = ConfigOption("PRIVATE", "id", new_id) - active_client.write_config(True, cfg) + active_client.config.set("PRIVATE", "id", new_id, True) + active_client.load_config() return active_client.client_id diff --git a/Server/server_qt.py b/Server/server_qt.py index ab5b55d..ef33c67 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -431,7 +431,7 @@ class MainWindow(QtWidgets.QMainWindow): if not ok or not mode: return - path = QFileDialog.getOpenFileName(self, "Select configuration file", filter="Configs (*.ini *.txt .cfg)")[0] + path = QFileDialog.getOpenFileName(self, "Select configuration file", filter="Configs (*.ini *.txt *.cfg)")[0] if not path: return From 543a6450979d2b75f9d9806a460902f1b6305dfd Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 5 Jan 2020 22:08:39 +0300 Subject: [PATCH 045/210] Fix logging optimization --- messaging_lib.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/messaging_lib.py b/messaging_lib.py index ca81e40..d5c02b6 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -341,9 +341,9 @@ class ConnectionManager(object): def process_received(self, income_message): message_type = income_message.jsonheader["message-type"] + content = income_message.content if message_type != "filetransfer" else income_message.content[:256] logger.debug( - "Received message! Header: {}, content: {}".format( - income_message.jsonheader, income_message.content[:256])) + "Received message! Header: {}, content: {}".format(income_message.jsonheader, content)) if message_type == "message": self._process_message(income_message) From 24e75f7ed0a8efeea4534a0e7c04047624ace876 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 5 Jan 2020 22:58:07 +0300 Subject: [PATCH 046/210] config error repr + improved comment loading --- Drone/client.py | 4 +++- config.py | 16 ++++++++++------ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/Drone/client.py b/Drone/client.py index 236c098..53d50fa 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -52,6 +52,8 @@ class Client(object): elif config_id == '/ip': self.client_id = messaging.get_ip_address() + logger.info("Config loaded") + @staticmethod def get_ntp_time(ntp_host, ntp_port): NTP_PACKET_FORMAT = "!12I" @@ -199,7 +201,6 @@ class Client(object): @messaging.message_callback("config") def _command_config_write(*args, **kwargs): - print(kwargs) mode = kwargs.get("mode", "modify") # exceptions would be risen in case of incorrect config if mode == "rewrite": @@ -211,6 +212,7 @@ def _command_config_write(*args, **kwargs): active_client.config.write() active_client.load_config() + logger.info("Config successfully updated from command") @messaging.request_callback("id") diff --git a/config.py b/config.py index e091717..c798678 100644 --- a/config.py +++ b/config.py @@ -29,6 +29,9 @@ class ValidationError(ValueError): self.config = config self.errors = errors + def __str__(self): + return "{} - {}".format(self.args[0], " ".join(self.flatten_errors())) + def flatten_errors(self): for entry in flatten_errors(self.config, self.errors): section_list, key, error = entry @@ -68,7 +71,7 @@ class ConfigManager: test = config.validate(vdt, copy=copy_defaults, preserve_errors=True) if test != True: # Important syntax, do no change - raise ValidationError('Some config values are wrong: {}'.format(test), config, test) + raise ValidationError('Some config values are wrong', config, test) self.config = config self.validated = True @@ -91,8 +94,8 @@ class ConfigManager: 'value': value, 'default': default_values.get(key, None), 'unchanged': key in defaults, - 'comments': comments[key], - 'inline_comment': inline_comments[key], + 'comments': comments.get(key, []), + 'inline_comment': inline_comments.get(key, None), } data[key] = item_d @@ -207,8 +210,8 @@ class ConfigManager: @classmethod def _load_comments(cls, d, section): - comments = {} - inline_comments = {} + comments = section.comments + inline_comments = section.inline_comments for key, val in d.items(): if not isinstance(val, dict): # Pure dict option @@ -283,7 +286,8 @@ if __name__ == '__main__': import pprint pprint.pprint(cfg.full_dict) cfg2 = ConfigManager() - cfg2.load_from_dict({"PRIVATE": {"id": 123132}}) + cfg2.load_from_dict({"PRIVATE": {"offset": [1, 2, 3]}}, path='Drone/config/spec/configspec_client.ini') + #cfg2.load_from_dict({"PRIVATE": {"id": 123132}}) pprint.pprint(cfg2.full_dict) cfg.merge(cfg2) pprint.pprint(cfg.full_dict) From 6e03972271cf23d80b2d6ca22c0ec63b633e0548 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 6 Jan 2020 13:42:25 +0300 Subject: [PATCH 047/210] Update configspec_client.ini --- Drone/config/spec/configspec_client.ini | 68 ++++++++++++++++++++++--- 1 file changed, 60 insertions(+), 8 deletions(-) diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index 0aff37e..93f10c0 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -1,5 +1,5 @@ config_name = string(default='Copter config') -config_version = float(default='0.0') +config_version = float(default=0.0) [SERVER] port = integer(default=25000) @@ -10,14 +10,66 @@ buffer_size = integer(default=1024) use = boolean(default=True) port = integer(default=8181) +[WATCHDOG] +timeout = float(default=1.0, min=0) +pos_delta_max = float(default=3.0, min=0) +action = string(default=emergency_land) + +[EMERGENCYLAND] # todo subsection +thrust = float(default=0.45, min=0, max=1) +decrease_thrust_after = float(default=5.0, min=0) #TODO change name PLS +disarm_timeout = float(default=10.0, min=0) + +[FRAMES] + [[__many__]] + parent = string(default=aruco_map) # todo scale? + # Frame offset (x, y, z) + # __list__ x y z + offset = float_list(default=list(0.0, 0.0, 0.0), min=3, max=3) + # Frame rotation (roll, pitch, yaw) + # __list__ roll pitch yaw + rotation = float_list(default=list(0.0, 0.0, 0.0), min=3, max=3) + +[FLIGHT] +frame_id = string(default=map) +takeoff_height = float(default=1.0) +takeoff_time = float(default=5.0, min=0) +safe_takeoff = boolean(default=False) +reach_first_point_time = float(default=5.0) +land_time = float(default=1.0, min=0) +land_timeout = float(default=10.0, min=0) + +[ANIMATION] +takeoff_detection = float(default=True) +land_detection = float(default=True) +default_fps = float(default=10, min=0) +# Animation scale (x, y, z) +# __list__ x y z +scale = float_list(default=list(1.0, 1.0, 1.0), min=3, max=3) + +[TELEMETRY] +send = boolean(default=True) +land_pos_delta = float(default=3.0, min=0) # TODO move to watchdog + rename? +log_resources = boolean(default=True) + +[LED] +use = boolean(default=True) +pin = integer(default=21, min=0, max=100) +count = integer(default=60, min=1) + +[PRIVATE] +# available options: /hostname ; /default ; /ip ; any string 63 characters length +id = string(default=/hostname, max=63) #TODO our re check +# Drone's individual offset (x, y, z) +# __list__ x y z +offset = float_list(default=list(0, 0, 0), min=3, max=3) +yaw = float(default=180.0) + +[SYSTEM] +change_hostname = boolean(default=True) +rename_restart = boolean(default=True) + [NTP] use = boolean(default=False) host = string(default=ntp1.stratum2.ru) port = integer(default=123) - -[PRIVATE] -# available options: /hostname ; /default ; /ip ; any string 63 characters length -id = string(default=/hostname, max=63) -# Drone's individual offset (X, Y, Z) -# __list__ X Y Z -offset = float_list(default=list(0, 0, 0), min=3, max=3) From 68d10e8a923543ac288440b62a3be89613b34544 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 6 Jan 2020 17:24:24 +0300 Subject: [PATCH 048/210] Added support for advanced attribute calling Includes support of: - spaces - multi-level and nested sections in attribute calls --- config.py | 49 +++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/config.py b/config.py index c798678..f93f1bb 100644 --- a/config.py +++ b/config.py @@ -50,6 +50,8 @@ class ConfigManager: self.config = ConfigObj() if config is None else config self.validated = False + self._name_dict = {} + def get(self, section, option): return self.config[section][option] @@ -58,11 +60,27 @@ class ConfigManager: if write: self.write() + def get_chain(self, *keys): + current = self.config + for key in keys: + current = current[key] + return current + + def set_chain(self, value, *keys, write=False): # will create new sections! + current = self.config + for key in keys[:-1]: + current = current.setdefault(key, {}) + current[keys[-1]] = value + + if write: + self.write() + def write(self): self.config.write() def set_config(self, config): self.config = config + self._name_dict = self.flatten_keys(config) self.validated = False def validate_config(self, config=None, copy_defaults=False): @@ -73,7 +91,7 @@ class ConfigManager: if test != True: # Important syntax, do no change raise ValidationError('Some config values are wrong', config, test) - self.config = config + self.set_config(config) self.validated = True @classmethod @@ -111,17 +129,30 @@ class ConfigManager: d['final_comment'] = self.config.final_comment return d + @classmethod + def flatten_keys(cls, d, parent_keys=(), sep='_'): + items = {} + for key, value in d.items(): + keys = parent_keys + (key,) + if isinstance(value, dict): + items.update(cls.flatten_keys(value, keys, sep=sep)) + else: + formatted_keys = [key.lower().strip().replace(' ', sep) for key in keys] + formatted_key = sep.join(formatted_keys) + items.update({formatted_key: keys}) + return dict(items) + def __getattr__(self, item): try: - section, option = item.split('_', 1) - return self.config[section.upper()][option.lower()] + keys = self.__dict__['_name_dict'][item] + return self.get_chain(*keys) except (ValueError, KeyError): return self.__dict__[item] def __setattr__(self, key, value): try: - section, option = key.split('_', 1) - self.config[section.upper()][option.lower()] = value + keys = self.__dict__['_name_dict'][key] + self.set_chain(value, *keys) except (ValueError, KeyError): self.__dict__[key] = value @@ -273,8 +304,9 @@ if __name__ == '__main__': # #print(cfg.server_host) # cfg.server_host = '192.168.1.103' # - # #print(cfg.get('SERVER', 'host')) + # print(cfg.get('SERVER', 'host')) # cfg.set('SERVER', 'host', '192.168.1.103') + # print(cfg.get('SERVER', 'host')) # # print(cfg.config.initial_comment, cfg.config.final_comment) # @@ -288,9 +320,10 @@ if __name__ == '__main__': cfg2 = ConfigManager() cfg2.load_from_dict({"PRIVATE": {"offset": [1, 2, 3]}}, path='Drone/config/spec/configspec_client.ini') #cfg2.load_from_dict({"PRIVATE": {"id": 123132}}) - pprint.pprint(cfg2.full_dict) + #pprint.pprint(cfg2.full_dict) cfg.merge(cfg2) - pprint.pprint(cfg.full_dict) + #pprint.pprint(cfg.full_dict) + print(cfg.config) # #print(cfg.full_dict) # From d51888205b6be21081600cf9eb350a9a023c74d0 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 6 Jan 2020 17:25:41 +0300 Subject: [PATCH 049/210] Removed unnecessary functions --- Server/config_editor_models.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 27018d9..0ff8b51 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -20,16 +20,6 @@ sys.path.insert(0, parent_dir) import config -def dict_walk(d: dict, keys): - current = d - for key in keys: - try: - current = current[key] - except KeyError: - return None - return current - - states_colors = { 'normal': Qt.white, 'unchanged': Qt.darkGray, @@ -437,16 +427,6 @@ class ConfigModel(QtCore.QAbstractItemModel): def update_all(self): self.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex()) - @staticmethod - def get_key_sequence(index): # yet unused - item = index.internalPointer() - keys = [] - while item is not None: - key = item.data(0) - keys.append(key) - item = item.parent() - return list(reversed(keys[:-1])) - def dict_setup(self, data: dict, parent=None, convert_types=False): if parent is None: parent = self.rootItem @@ -672,7 +652,7 @@ class ConfigTreeWidget(QTreeView): # parent.internalPointer().set_state('edited') self.expandAll() - def reset_item(self, index, reset_type): # todo try deepcopy + def reset_item(self, index, reset_type): item = index.internalPointer() model = self.model() itemdataindex = model.modifyCol(index, 1) From 7757c0e5aedc31140c427a93e777ce2cadc11d84 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 6 Jan 2020 17:26:39 +0300 Subject: [PATCH 050/210] Updated+fixed client configspec --- Drone/config/spec/configspec_client.ini | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index 93f10c0..04c6ecd 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -10,15 +10,14 @@ buffer_size = integer(default=1024) use = boolean(default=True) port = integer(default=8181) -[WATCHDOG] +[VISUAL POSE WATCHDOG] timeout = float(default=1.0, min=0) pos_delta_max = float(default=3.0, min=0) action = string(default=emergency_land) - -[EMERGENCYLAND] # todo subsection -thrust = float(default=0.45, min=0, max=1) -decrease_thrust_after = float(default=5.0, min=0) #TODO change name PLS -disarm_timeout = float(default=10.0, min=0) + [EMERGENCY LAND] + thrust = float(default=0.45, min=0, max=1) + decrease_thrust_after = float(default=5.0, min=0) #TODO change name PLS + disarm_timeout = float(default=10.0, min=0) [FRAMES] [[__many__]] @@ -40,8 +39,8 @@ land_time = float(default=1.0, min=0) land_timeout = float(default=10.0, min=0) [ANIMATION] -takeoff_detection = float(default=True) -land_detection = float(default=True) +takeoff_detection = boolean(default=True) +land_detection = boolean(default=True) default_fps = float(default=10, min=0) # Animation scale (x, y, z) # __list__ x y z From 1bedb143d9b97e15c6569df7d8361ba5359f9076 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 6 Jan 2020 17:29:54 +0300 Subject: [PATCH 051/210] Code auto-reformatted --- Drone/copter_client.py | 332 ++++++++++++++++++++++------------------- 1 file changed, 182 insertions(+), 150 deletions(-) diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 3f7e08c..689cca2 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -9,7 +9,6 @@ import logging import threading import psutil import subprocess -import ConfigParser from collections import namedtuple from FlightLib import FlightLib @@ -33,12 +32,12 @@ static_bloadcaster = tf2_ros.StaticTransformBroadcaster() emergency = False logging.basicConfig( # TODO all prints as logs - level=logging.DEBUG, # INFO - stream=sys.stdout, - format="%(asctime)s [%(name)-7.7s] [%(threadName)-12.12s] [%(levelname)-5.5s] %(message)s", - handlers=[ - logging.StreamHandler(sys.stdout), - ]) + level=logging.DEBUG, # INFO + stream=sys.stdout, + format="%(asctime)s [%(name)-7.7s] [%(threadName)-12.12s] [%(levelname)-5.5s] %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + ]) handler = logging.StreamHandler(sys.stdout) handler.setLevel(logging.DEBUG) @@ -69,53 +68,58 @@ flightlib_logger = logging.getLogger('FlightLib') flightlib_logger.setLevel(logging.INFO) flightlib_logger.addHandler(handler) + class CopterClient(client.Client): + def __init__(self, config_path="config/client.ini"): + super(CopterClient, self).__init__(config_path) + self.frames = {} + def load_config(self): - self.FLOOR_FRAME_EXISTS = False super(CopterClient, self).load_config() - self.TELEM_FREQ = self.config.getfloat('TELEMETRY', 'frequency') - self.TELEM_TRANSMIT = self.config.getboolean('TELEMETRY', 'transmit') - self.LOG_CPU_AND_MEMORY = self.config.getboolean('TELEMETRY', 'log_cpu_and_memory') - self.LAND_POS_DELTA = self.config.getfloat('TELEMETRY', 'land_if_pos_delta_bigger_than') - self.FRAME_ID = self.config.get('COPTERS', 'frame_id') - self.FRAME_FLIPPED_HEIGHT = 0. - self.TAKEOFF_HEIGHT = self.config.getfloat('COPTERS', 'takeoff_height') - self.TAKEOFF_TIME = self.config.getfloat('COPTERS', 'takeoff_time') - self.SAFE_TAKEOFF = self.config.getboolean('COPTERS', 'safe_takeoff') - self.RFP_TIME = self.config.getfloat('COPTERS', 'reach_first_point_time') - self.LAND_TIME = self.config.getfloat('COPTERS', 'land_time') - self.LAND_TIMEOUT = self.config.getfloat('COPTERS', 'land_timeout') - self.X0_COMMON = self.config.getfloat('COPTERS', 'x0_common') - self.Y0_COMMON = self.config.getfloat('COPTERS', 'y0_common') - self.Z0_COMMON = self.config.getfloat('COPTERS', 'z0_common') - self.YAW = self.config.get('COPTERS', 'yaw') - self.TAKEOFF_CHECK = self.config.getboolean('ANIMATION', 'takeoff_animation_check') - self.LAND_CHECK = self.config.getboolean('ANIMATION', 'land_animation_check') - self.FRAME_DELAY = self.config.getfloat('ANIMATION', 'frame_delay') - self.X_RATIO = self.config.getfloat('ANIMATION', 'x_ratio') - self.Y_RATIO = self.config.getfloat('ANIMATION', 'y_ratio') - self.Z_RATIO = self.config.getfloat('ANIMATION', 'z_ratio') - self.X0 = self.config.getfloat('PRIVATE', 'x0') - self.Y0 = self.config.getfloat('PRIVATE', 'y0') - self.Z0 = self.config.getfloat('PRIVATE', 'z0') - self.USE_LEDS = self.config.getboolean('PRIVATE', 'use_leds') - self.LED_PIN = self.config.getint('PRIVATE', 'led_pin') - try: - self.FLOOR_DX = self.config.getfloat('FLOOR FRAME', 'x') - self.FLOOR_DY = self.config.getfloat('FLOOR FRAME', 'y') - self.FLOOR_DZ = self.config.getfloat('FLOOR FRAME', 'z') - self.FLOOR_ROLL = self.config.getfloat('FLOOR FRAME', 'roll') - self.FLOOR_PITCH = self.config.getfloat('FLOOR FRAME', 'pitch') - self.FLOOR_YAW = self.config.getfloat('FLOOR FRAME', 'yaw') - self.FLOOR_PARENT = self.config.get('FLOOR FRAME', 'parent') - self.FLOOR_FRAME_EXISTS = True - except ConfigParser.Error: - rospy.logerror("No floor frame!") - self.FLOOR_FRAME_EXISTS = False - self.RESTART_AFTER_RENAME = self.config.getboolean('PRIVATE', 'restart_after_rename') + # self.FLOOR_FRAME_EXISTS = False + # self.TELEM_FREQ = self.config.getfloat('TELEMETRY', 'frequency') + # self.TELEM_TRANSMIT = self.config.getboolean('TELEMETRY', 'transmit') + # self.LOG_CPU_AND_MEMORY = self.config.getboolean('TELEMETRY', 'log_cpu_and_memory') + # self.LAND_POS_DELTA = self.config.getfloat('TELEMETRY', 'land_if_pos_delta_bigger_than') + # self.FRAME_ID = self.config.get('COPTERS', 'frame_id') + # self.FRAME_FLIPPED_HEIGHT = 0. + # self.TAKEOFF_HEIGHT = self.config.getfloat('COPTERS', 'takeoff_height') + # self.TAKEOFF_TIME = self.config.getfloat('COPTERS', 'takeoff_time') + # self.SAFE_TAKEOFF = self.config.getboolean('COPTERS', 'safe_takeoff') + # self.RFP_TIME = self.config.getfloat('COPTERS', 'reach_first_point_time') + # self.LAND_TIME = self.config.getfloat('COPTERS', 'land_time') + # self.LAND_TIMEOUT = self.config.getfloat('COPTERS', 'land_timeout') + # self.X0_COMMON = self.config.getfloat('COPTERS', 'x0_common') + # self.Y0_COMMON = self.config.getfloat('COPTERS', 'y0_common') + # self.Z0_COMMON = self.config.getfloat('COPTERS', 'z0_common') + # self.YAW = self.config.get('COPTERS', 'yaw') + # self.TAKEOFF_CHECK = self.config.getboolean('ANIMATION', 'takeoff_animation_check') + # self.LAND_CHECK = self.config.getboolean('ANIMATION', 'land_animation_check') + # self.FRAME_DELAY = self.config.getfloat('ANIMATION', 'frame_delay') + # self.X_RATIO = self.config.getfloat('ANIMATION', 'x_ratio') + # self.Y_RATIO = self.config.getfloat('ANIMATION', 'y_ratio') + # self.Z_RATIO = self.config.getfloat('ANIMATION', 'z_ratio') + # self.X0 = self.config.getfloat('PRIVATE', 'x0') + # self.Y0 = self.config.getfloat('PRIVATE', 'y0') + # self.Z0 = self.config.getfloat('PRIVATE', 'z0') + # self.USE_LEDS = self.config.getboolean('PRIVATE', 'use_leds') + # self.LED_PIN = self.config.getint('PRIVATE', 'led_pin') + # try: + # self.FLOOR_DX = self.config.getfloat('FLOOR FRAME', 'x') + # self.FLOOR_DY = self.config.getfloat('FLOOR FRAME', 'y') + # self.FLOOR_DZ = self.config.getfloat('FLOOR FRAME', 'z') + # self.FLOOR_ROLL = self.config.getfloat('FLOOR FRAME', 'roll') + # self.FLOOR_PITCH = self.config.getfloat('FLOOR FRAME', 'pitch') + # self.FLOOR_YAW = self.config.getfloat('FLOOR FRAME', 'yaw') + # self.FLOOR_PARENT = self.config.get('FLOOR FRAME', 'parent') + # self.FLOOR_FRAME_EXISTS = True + # except ConfigParser.Error: + # rospy.logerror("No floor frame!") + # self.FLOOR_FRAME_EXISTS = False + # self.RESTART_AFTER_RENAME = self.config.getboolean('PRIVATE', 'restart_after_rename') def on_broadcast_bind(self): - configure_chrony_ip(self.server_host) + configure_chrony_ip(self.config.server_host) restart_service("chrony") def start(self, task_manager_instance): @@ -123,9 +127,9 @@ class CopterClient(client.Client): rospy.init_node('clever_show_client') if self.USE_LEDS: LedLib.init_led(self.LED_PIN) - task_manager_instance.start() + task_manager_instance.start() # TODO move to self if self.FRAME_ID == "floor": - if self.FLOOR_FRAME_EXISTS: + if self.FLOOR_FRAME_EXISTS: self.start_floor_frame_broadcast() else: rospy.logerror("Can't make floor frame!") @@ -140,18 +144,21 @@ class CopterClient(client.Client): trans.transform.translation.y = self.FLOOR_DY trans.transform.translation.z = self.FLOOR_DZ trans.transform.rotation = Quaternion(*quaternion_from_euler(math.radians(self.FLOOR_ROLL), - math.radians(self.FLOOR_PITCH), - math.radians(self.FLOOR_YAW))) + math.radians(self.FLOOR_PITCH), + math.radians(self.FLOOR_YAW))) trans.header.frame_id = self.FLOOR_PARENT trans.child_frame_id = self.FRAME_ID static_bloadcaster.sendTransform(trans) + def restart_service(name): os.system("systemctl restart {}".format(name)) + def execute_command(command): os.system(command) + def configure_chrony_ip(ip, path="/etc/chrony/chrony.conf", ip_index=1): try: with open(path, 'r') as f: @@ -224,7 +231,8 @@ def configure_hosts(hostname): _ip = hosts_array[0] current_hostname = hosts_array[1] if current_hostname != hostname: - content = raw_content[:index_start] + "{} {} {}.local".format(_ip, hostname, hostname) + raw_content[index_stop:] + content = raw_content[:index_start] + "{} {} {}.local".format(_ip, hostname, hostname) + raw_content[ + index_stop:] try: with open(path, 'w') as f: f.write(content) @@ -234,10 +242,12 @@ def configure_hosts(hostname): return True + def configure_motd(hostname): with open("/etc/motd", "w") as f: f.write("\r\n{}\r\n\r\n".format(hostname)) + def configure_bashrc(hostname): path = "/home/pi/.bashrc" try: @@ -262,6 +272,7 @@ def configure_bashrc(hostname): return True + @messaging.message_callback("execute") def _execute(*args, **kwargs): command = kwargs.get("command", None) @@ -270,6 +281,7 @@ def _execute(*args, **kwargs): execute_command(command) logger.info("Executing done") + @messaging.message_callback("id") def _response_id(*args, **kwargs): new_id = kwargs.get("new_id", None) @@ -286,12 +298,12 @@ def _response_id(*args, **kwargs): configure_bashrc(hostname) configure_motd(hostname) execute_command("reboot") - #execute_command("hostname {}".format(hostname)) - #restart_service("dhcpcd") - #restart_service("avahi-daemon") - #restart_service("smbd") - #restart_service("roscore") - #restart_service("clever") + # execute_command("hostname {}".format(hostname)) + # restart_service("dhcpcd") + # restart_service("avahi-daemon") + # restart_service("smbd") + # restart_service("roscore") + # restart_service("clever") restart_service("clever-show") @@ -316,25 +328,26 @@ def _response_animation_id(*args, **kwargs): # Load animation result = animation.get_id() if result != 'No animation': - logger.debug ("Saving corrected animation") + logger.debug("Saving corrected animation") frames = animation.load_animation(os.path.abspath("animation.csv"), - x0=client.active_client.X0 + client.active_client.X0_COMMON, - y0=client.active_client.Y0 + client.active_client.Y0_COMMON, - z0=client.active_client.Z0 + client.active_client.Z0_COMMON, - x_ratio=client.active_client.X_RATIO, - y_ratio=client.active_client.Y_RATIO, - z_ratio=client.active_client.Z_RATIO, - ) + x0=client.active_client.X0 + client.active_client.X0_COMMON, + y0=client.active_client.Y0 + client.active_client.Y0_COMMON, + z0=client.active_client.Z0 + client.active_client.Z0_COMMON, + x_ratio=client.active_client.X_RATIO, + y_ratio=client.active_client.Y_RATIO, + z_ratio=client.active_client.Z_RATIO, + ) # Correct start and land frames in animation corrected_frames, start_action, start_delay = animation.correct_animation(frames, - check_takeoff=client.active_client.TAKEOFF_CHECK, - check_land=client.active_client.LAND_CHECK, - ) + check_takeoff=client.active_client.TAKEOFF_CHECK, + check_land=client.active_client.LAND_CHECK, + ) logger.debug("Start action: {}".format(start_action)) # Save corrected animation animation.save_corrected_animation(corrected_frames) return result + @messaging.request_callback("batt_voltage") def _response_batt(*args, **kwargs): if check_state_topic(wait_new_status=True): @@ -352,10 +365,12 @@ def _response_cell(*args, **kwargs): stop_subscriber() return float('nan') + @messaging.request_callback("sys_status") def _response_sys_status(*args, **kwargs): return get_sys_status() + @messaging.request_callback("cal_status") def _response_cal_status(*args, **kwargs): if check_state_topic(wait_new_status=True): @@ -364,40 +379,46 @@ def _response_cal_status(*args, **kwargs): stop_subscriber() return "NOT_CONNECTED_TO_FCU" + @messaging.request_callback("position") def _response_position(*args, **kwargs): telem = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) return "{:.2f} {:.2f} {:.2f} {:.1f} {}".format( telem.x, telem.y, telem.z, math.degrees(telem.yaw), client.active_client.FRAME_ID) + @messaging.request_callback("calibrate_gyro") def _calibrate_gyro(*args, **kwargs): calibrate('gyro') return get_calibration_status() + @messaging.request_callback("calibrate_level") def _calibrate_level(*args, **kwargs): calibrate('level') return get_calibration_status() + @messaging.request_callback("load_params") def _load_params(*args, **kwargs): result = load_param_file('temp.params') logger.info("Load parameters to FCU success: {}".format(result)) return result + @messaging.message_callback("test") def _command_test(*args, **kwargs): logger.info("logging info test") rospy.logdebug("ros logdebug test") print("stdout test") + @messaging.message_callback("move_start") def _command_move_start_to_current_position(*args, **kwargs): x_start, y_start = animation.get_start_xy(os.path.abspath("animation.csv"), - x_ratio=client.active_client.X_RATIO, - y_ratio=client.active_client.Y_RATIO, - ) + x_ratio=client.active_client.X_RATIO, + y_ratio=client.active_client.Y_RATIO, + ) logger.debug("x_start = {}, y_start = {}".format(x_start, y_start)) if not math.isnan(x_start): telem = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) @@ -407,19 +428,21 @@ def _command_move_start_to_current_position(*args, **kwargs): client.active_client.config.set('PRIVATE', 'y0', telem.y - y_start) client.active_client.rewrite_config() client.active_client.load_config() - logger.info ("Set start delta: {:.2f} {:.2f}".format(client.active_client.X0, client.active_client.Y0)) + logger.info("Set start delta: {:.2f} {:.2f}".format(client.active_client.X0, client.active_client.Y0)) else: - logger.debug ("Wrong telemetry") + logger.debug("Wrong telemetry") else: logger.debug("Wrong animation file") + @messaging.message_callback("reset_start") def _command_reset_start(*args, **kwargs): client.active_client.config.set('PRIVATE', 'x0', 0) client.active_client.config.set('PRIVATE', 'y0', 0) client.active_client.rewrite_config() client.active_client.load_config() - logger.info ("Reset start to {:.2f} {:.2f}".format(client.active_client.X0, client.active_client.Y0)) + logger.info("Reset start to {:.2f} {:.2f}".format(client.active_client.X0, client.active_client.Y0)) + @messaging.message_callback("set_z_to_ground") def _command_set_z(*args, **kwargs): @@ -427,14 +450,15 @@ def _command_set_z(*args, **kwargs): client.active_client.config.set('PRIVATE', 'z0', telem.z) client.active_client.rewrite_config() client.active_client.load_config() - logger.info ("Set z offset to {:.2f}".format(client.active_client.Z0)) + logger.info("Set z offset to {:.2f}".format(client.active_client.Z0)) + @messaging.message_callback("reset_z_offset") def _command_reset_z(*args, **kwargs): client.active_client.config.set('PRIVATE', 'z0', 0) client.active_client.rewrite_config() client.active_client.load_config() - logger.info ("Reset z offset to {:.2f}".format(client.active_client.Z0)) + logger.info("Reset z offset to {:.2f}".format(client.active_client.Z0)) @messaging.message_callback("update_repo") @@ -447,11 +471,13 @@ def _command_update_repo(*args, **kwargs): os.system("mv /home/pi/clever-show/Drone/client_config_tmp.ini /home/pi/clever-show/Drone/client_config.ini") os.system("chown -R pi:pi /home/pi/clever-show") + @messaging.message_callback("reboot_all") def _command_reboot_all(*args, **kwargs): reboot_fcu() execute_command("reboot") + @messaging.message_callback("reboot_fcu") def _command_reboot(*args, **kwargs): reboot_fcu() @@ -489,6 +515,7 @@ def _command_led_fill(*args, **kwargs): def _copter_flip(*args, **kwargs): FlightLib.flip(frame_id=client.active_client.FRAME_ID) + @messaging.message_callback("takeoff") def _command_takeoff(*args, **kwargs): logger.info("Takeoff at {}".format(datetime.datetime.now())) @@ -501,6 +528,7 @@ def _command_takeoff(*args, **kwargs): } ) + @messaging.message_callback("takeoff_z") def _command_takeoff_z(*args, **kwargs): z_str = kwargs.get("z", None) @@ -508,15 +536,15 @@ def _command_takeoff_z(*args, **kwargs): telem = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) logger.info("Takeoff to z = {} at {}".format(z_str, datetime.datetime.now())) task_manager.add_task(0, 0, FlightLib.reach_point, - task_kwargs={ - "x": telem.x, - "y": telem.y, - "z": float(z_str), - "frame_id": client.active_client.FRAME_ID, - "timeout": client.active_client.TAKEOFF_TIME, - "auto_arm": True, - } - ) + task_kwargs={ + "x": telem.x, + "y": telem.y, + "z": float(z_str), + "frame_id": client.active_client.FRAME_ID, + "timeout": client.active_client.TAKEOFF_TIME, + "auto_arm": True, + } + ) @messaging.message_callback("land") @@ -571,45 +599,45 @@ def _play_animation(*args, **kwargs): return task_manager.reset(interrupt_next_task=False) - - logger.info("Start time = {}, wait for {} seconds".format(start_time, start_time-time.time())) + + logger.info("Start time = {}, wait for {} seconds".format(start_time, start_time - time.time())) # Load animation frames = animation.load_animation(os.path.abspath("animation.csv"), - x0=client.active_client.X0 + client.active_client.X0_COMMON, - y0=client.active_client.Y0 + client.active_client.Y0_COMMON, - z0=client.active_client.Z0 + client.active_client.Z0_COMMON, - x_ratio=client.active_client.X_RATIO, - y_ratio=client.active_client.Y_RATIO, - z_ratio=client.active_client.Z_RATIO, - ) + x0=client.active_client.X0 + client.active_client.X0_COMMON, + y0=client.active_client.Y0 + client.active_client.Y0_COMMON, + z0=client.active_client.Z0 + client.active_client.Z0_COMMON, + x_ratio=client.active_client.X_RATIO, + y_ratio=client.active_client.Y_RATIO, + z_ratio=client.active_client.Z_RATIO, + ) # Correct start and land frames in animation corrected_frames, start_action, start_delay = animation.correct_animation(frames, - check_takeoff=client.active_client.TAKEOFF_CHECK, - check_land=client.active_client.LAND_CHECK, - ) + check_takeoff=client.active_client.TAKEOFF_CHECK, + check_land=client.active_client.LAND_CHECK, + ) # Choose start action if start_action == 'takeoff': # Takeoff first task_manager.add_task(start_time, 0, animation.takeoff, - task_kwargs={ - "z": client.active_client.TAKEOFF_HEIGHT, - "timeout": client.active_client.TAKEOFF_TIME, - "safe_takeoff": client.active_client.SAFE_TAKEOFF, - # "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, - } - ) + task_kwargs={ + "z": client.active_client.TAKEOFF_HEIGHT, + "timeout": client.active_client.TAKEOFF_TIME, + "safe_takeoff": client.active_client.SAFE_TAKEOFF, + # "frame_id": client.active_client.FRAME_ID, + "use_leds": client.active_client.USE_LEDS, + } + ) # Fly to first point rfp_time = start_time + client.active_client.TAKEOFF_TIME task_manager.add_task(rfp_time, 0, animation.execute_frame, - task_kwargs={ - "point": animation.convert_frame(corrected_frames[0])[0], - "color": animation.convert_frame(corrected_frames[0])[1], - "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, - "flight_func": FlightLib.reach_point, - } - ) + task_kwargs={ + "point": animation.convert_frame(corrected_frames[0])[0], + "color": animation.convert_frame(corrected_frames[0])[1], + "frame_id": client.active_client.FRAME_ID, + "use_leds": client.active_client.USE_LEDS, + "flight_func": FlightLib.reach_point, + } + ) # Calculate first frame start time frame_time = rfp_time + client.active_client.RFP_TIME @@ -617,25 +645,25 @@ def _play_animation(*args, **kwargs): # Calculate start time start_time += start_delay # Arm - #task_manager.add_task(start_time, 0, FlightLib.arming_wrapper, + # task_manager.add_task(start_time, 0, FlightLib.arming_wrapper, # task_kwargs={ # "state": True # } # ) - frame_time = start_time # + 1.0 + frame_time = start_time # + 1.0 point, color, yaw = animation.convert_frame(corrected_frames[0]) task_manager.add_task(frame_time, 0, animation.execute_frame, - task_kwargs={ - "point": point, - "color": color, - "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, - "flight_func": FlightLib.navto, - "auto_arm": True, - } - ) + task_kwargs={ + "point": point, + "color": color, + "frame_id": client.active_client.FRAME_ID, + "use_leds": client.active_client.USE_LEDS, + "flight_func": FlightLib.navto, + "auto_arm": True, + } + ) # Calculate first frame start time - frame_time += client.active_client.FRAME_DELAY # TODO Think about arming time + frame_time += client.active_client.FRAME_DELAY # TODO Think about arming time logger.debug(task_manager.task_queue) # Play animation file for frame in corrected_frames: @@ -645,27 +673,28 @@ def _play_animation(*args, **kwargs): else: yaw = math.radians(float(client.active_client.YAW)) task_manager.add_task(frame_time, 0, animation.execute_frame, - task_kwargs={ - "point": point, - "color": color, - "yaw": yaw, - "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, - "flight_func": FlightLib.navto, - } - ) + task_kwargs={ + "point": point, + "color": color, + "yaw": yaw, + "frame_id": client.active_client.FRAME_ID, + "use_leds": client.active_client.USE_LEDS, + "flight_func": FlightLib.navto, + } + ) frame_time += frame["delay"] # Calculate land_time land_time = frame_time + client.active_client.LAND_TIME # Land task_manager.add_task(land_time, 0, animation.land, - task_kwargs={ - "timeout": client.active_client.LAND_TIMEOUT, - "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, - }, - ) + task_kwargs={ + "timeout": client.active_client.LAND_TIMEOUT, + "frame_id": client.active_client.FRAME_ID, + "use_leds": client.active_client.USE_LEDS, + }, + ) + class Telemetry: params_default_dict = { @@ -765,7 +794,7 @@ class Telemetry: return 'NO_POS' def update_telemetry_fast(self): - self.start_position = self.get_start_position() + self.start_position = self.get_start_position() try: self.ros_telemetry = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) if self.ros_telemetry.connected: @@ -808,7 +837,7 @@ class Telemetry: round_list = ["battery", "start_position", "current_position"] for key in round_list: if self.__dict__[key] not in [None, 'NO_POS', 'NO_FCU']: - self.__dict__[key] = [round(v,2) if type(v) == float else v for v in self.__dict__[key]] + self.__dict__[key] = [round(v, 2) if type(v) == float else v for v in self.__dict__[key]] def reset_telemetry_values(self): self.battery = float('nan'), float('nan') @@ -866,7 +895,7 @@ class Telemetry: cpu_temp = cpu_temp_info.current # https://github.com/raspberrypi/documentation/blob/JamesH65-patch-vcgencmd-vcdbg-docs/raspbian/applications/vcgencmd.md throttled_hex = subprocess.check_output("vcgencmd get_throttled", shell=True).split('=')[1] - under_voltage = bool(int(bin(int(throttled_hex,16))[2:][-1])) + under_voltage = bool(int(bin(int(throttled_hex, 16))[2:][-1])) power_state = 'normal' if not under_voltage else 'under voltage!' if cpu_temp_info.critical: cpu_temp_state = 'critical' @@ -875,7 +904,7 @@ class Telemetry: else: cpu_temp_state = 'normal' logger.info("CPU usage: {} | Memory: {} % | T: {} ({}) | Power: {}".format( - cpu_usage, mem_usage, cpu_temp, cpu_temp_state, power_state)) + cpu_usage, mem_usage, cpu_temp, cpu_temp_state, power_state)) def _update_loop(self, freq): # TODO extract? rate = rospy.Rate(freq) @@ -886,7 +915,7 @@ class Telemetry: if client.active_client.TELEM_TRANSMIT and client.active_client.connected: self.transmit_message() - + if client.active_client.LOG_CPU_AND_MEMORY: self.log_cpu_and_memory() @@ -902,7 +931,8 @@ class Telemetry: if client.active_client.TELEM_FREQ > 0: telemetry_thread = threading.Thread(target=self._update_loop, name="Telemetry getting thread", args=(client.active_client.TELEM_FREQ,)) # TODO MOVE? Daemon? - slow_telemetry_thread = threading.Thread(target=self._slow_update_loop, name="Slow telemetry getting thread") + slow_telemetry_thread = threading.Thread(target=self._slow_update_loop, + name="Slow telemetry getting thread") slow_telemetry_thread.start() telemetry_thread.start() else: @@ -912,12 +942,14 @@ class Telemetry: if keys is None: keys = self.params_default_dict.keys() # return only existing keys from 'keys' - return {k: self.__dict__[k] for k in keys if k in self.params_default_dict} + return {k: self.__dict__[k] for k in keys if k in self.params_default_dict} + def emergency_callback(data): global emergency emergency = data.data + if __name__ == "__main__": telemetry = Telemetry() copter_client = CopterClient() From 5e37d05220f23f36344b9297347d50ffa44be2c8 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 6 Jan 2020 23:15:43 +0300 Subject: [PATCH 052/210] Fixed set_chain for python2 --- config.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/config.py b/config.py index f93f1bb..aa0b7c1 100644 --- a/config.py +++ b/config.py @@ -66,15 +66,12 @@ class ConfigManager: current = current[key] return current - def set_chain(self, value, *keys, write=False): # will create new sections! + def set_chain(self, value, *keys): # will create new sections! current = self.config for key in keys[:-1]: current = current.setdefault(key, {}) current[keys[-1]] = value - if write: - self.write() - def write(self): self.config.write() From 168935fd421709b74312b58a0d9f5f1390530637 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 6 Jan 2020 23:16:09 +0300 Subject: [PATCH 053/210] Changed loading from dict with configspec --- config.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/config.py b/config.py index aa0b7c1..95539d3 100644 --- a/config.py +++ b/config.py @@ -257,24 +257,25 @@ class ConfigManager: section.comments = comments section.inline_comments = inline_comments - def load_from_dict(self, d, path=None): + def load_from_dict(self, d, configspec=None): initial_comment = d.pop('initial_comment', ['']) final_comment = d.pop('final_comment', ['']) kwargs = {'infile': self._extract_values(d), 'indent_type': ''} - if path is not None: - spec_path = self._get_spec_path(path) - if not self._config_exists(spec_path): - spec_path = path - if self._config_exists(spec_path): - kwargs.update({'configspec': spec_path}) + if isinstance(configspec, dict): + kwargs.update({'configspec': configspec}) + elif isinstance(configspec, str): + if not self._config_exists(configspec): + configspec = self._get_spec_path(configspec) + if self._config_exists(configspec): + kwargs.update({'configspec': configspec}) config = ConfigObj(**kwargs) - config.filename = path + config.filename = configspec if isinstance(configspec, str) else None config.initial_comment = initial_comment config.final_comment = final_comment - if path is not None: + if configspec is not None: self.validate_config(config) else: self.set_config(config) @@ -313,14 +314,16 @@ if __name__ == '__main__': # # # print(11111) import pprint - pprint.pprint(cfg.full_dict) + #pprint.pprint(cfg.full_dict) cfg2 = ConfigManager() - cfg2.load_from_dict({"PRIVATE": {"offset": [1, 2, 3]}}, path='Drone/config/spec/configspec_client.ini') + cfg2.load_from_dict({"PRIVATE": {"offset": [1, 2, 3]}}, configspec='Drone/config/spec/configspec_client.ini') #cfg2.load_from_dict({"PRIVATE": {"id": 123132}}) #pprint.pprint(cfg2.full_dict) cfg.merge(cfg2) #pprint.pprint(cfg.full_dict) print(cfg.config) + print(dict(cfg.config.configspec)) + #print(dict(ConfigManager(cfg.config.configspec).config)) # #print(cfg.full_dict) # From 65447fa3c09d73157c1038eabe216033ac011bd8 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 6 Jan 2020 23:16:47 +0300 Subject: [PATCH 054/210] Added config response --- Drone/client.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Drone/client.py b/Drone/client.py index 53d50fa..e969c6a 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -204,16 +204,21 @@ def _command_config_write(*args, **kwargs): mode = kwargs.get("mode", "modify") # exceptions would be risen in case of incorrect config if mode == "rewrite": - active_client.config.load_from_dict(kwargs["config"], path=active_client.config_path) # with validation + active_client.config.load_from_dict(kwargs["config"], configspec=active_client.config_path) # with validation elif mode == "modify": new_config = ConfigManager() new_config.load_from_dict(kwargs["config"]) active_client.config.merge(new_config, validate=True) active_client.config.write() - active_client.load_config() logger.info("Config successfully updated from command") + active_client.load_config() +@messaging.request_callback("config") +def _response_config(*args, **kwargs): + response = {"config": active_client.config.fulldict, + "configspec": dict(active_client.config.configspec)} + return response @messaging.request_callback("id") def _response_id(*args, **kwargs): From b245bc2b0c2937484a113e6027a6d2a955f8fcf4 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 6 Jan 2020 23:18:01 +0300 Subject: [PATCH 055/210] Extracted validation loop --- Server/config_editor_models.py | 59 ++++++++++++++++------------------ 1 file changed, 27 insertions(+), 32 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 0ff8b51..4c2c006 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -539,7 +539,6 @@ class ConfigTreeWidget(QTreeView): self.setDragEnabled(True) self.setAcceptDrops(True) self.setDropIndicatorShown(True) - self.setAnimated(True) def open_menu(self, point): @@ -723,7 +722,24 @@ class ConfigDialog(QtWidgets.QDialog): ) return reply == QMessageBox.Yes - # def validate_loop(self): + def run(self): + self.show() + self.exec() + + def _validate_loop(self, cfg, configspec=None): # modifies cfg object + while True: + try: + cfg.load_from_dict(ui.model.to_config_dict(), configspec=configspec) + except config.ValidationError as error: + msg = "Can not validate. Proceed with editing? Errors: \n" + "\n".join(error.flatten_errors()) + reply = QMessageBox.warning(self, "Validation error!", msg, QMessageBox.Yes | QMessageBox.Cancel) + + if reply == QMessageBox.Cancel: + return False + + self.run() + else: + return True def call_standalone_dialog(self): path = QFileDialog.getOpenFileName(self, "Select configuration or specification file", @@ -734,44 +750,23 @@ class ConfigDialog(QtWidgets.QDialog): cfg = config.ConfigManager() try: cfg.load_from_file(path) - except ValueError: # When file do not exist or not validated + except ValueError as error: # When file do not exist or not validated properly + QMessageBox.warning(self, "Error while opening file!", + "Config cannot be opened or validated: {}".format(error)) return False self.setupModel(cfg.full_dict, convert_types=(not cfg.validated)) - self.show() - self.exec() - - save = ui.result() - if not save: + self.run() + if not self.result(): return False filename = cfg.config.filename - valid_path = path if cfg.config.filename is None else cfg.config.filename - valid_path = valid_path if cfg.validated else None + validation_path = path if cfg.config.filename is None else cfg.config.filename + validation_path = validation_path if cfg.validated else None - while True: - try: - cfg.load_from_dict(ui.model.to_config_dict(), path=valid_path) - except config.ValidationError as error: - dialog = QMessageBox() - dialog.setIcon(QMessageBox.Critical) - dialog.setStandardButtons(QMessageBox.Yes | QMessageBox.Cancel) - dialog.setDefaultButton(QMessageBox.Yes) - dialog.setEscapeButton(QMessageBox.Cancel) - dialog.setWindowTitle("Validation error!") - msg = "\n".join(error.flatten_errors()) - dialog.setText("Can not validate. Proceed with editing? Errors: \n" + msg) - dialog.setDetailedText(msg) - reply = dialog.exec() - - if reply == QMessageBox.Cancel: - return False - - self.show() - self.exec() - else: - break + if not self._validate_loop(cfg, validation_path) or not self.result(): + return False if filename is None: save_path = QFileDialog.getSaveFileName(self, "Save configuration file", From 04ee93fc8b8a33df711696756e12e33ce9db9c3f Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 7 Jan 2020 17:23:45 +0300 Subject: [PATCH 056/210] Fixed configspec loading for dicts --- config.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/config.py b/config.py index 95539d3..824d280 100644 --- a/config.py +++ b/config.py @@ -265,10 +265,11 @@ class ConfigManager: if isinstance(configspec, dict): kwargs.update({'configspec': configspec}) elif isinstance(configspec, str): - if not self._config_exists(configspec): - configspec = self._get_spec_path(configspec) - if self._config_exists(configspec): - kwargs.update({'configspec': configspec}) + spec_path = self._get_spec_path(configspec) # check for /spec, then for config + if not self._config_exists(spec_path): + spec_path = configspec + if self._config_exists(spec_path): + kwargs.update({'configspec': spec_path}) config = ConfigObj(**kwargs) config.filename = configspec if isinstance(configspec, str) else None @@ -321,7 +322,7 @@ if __name__ == '__main__': #pprint.pprint(cfg2.full_dict) cfg.merge(cfg2) #pprint.pprint(cfg.full_dict) - print(cfg.config) + print(cfg.full_dict) print(dict(cfg.config.configspec)) #print(dict(ConfigManager(cfg.config.configspec).config)) From 0ccd801fc20962bb7bf8f58c1eb871c335332a23 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 7 Jan 2020 17:24:15 +0300 Subject: [PATCH 057/210] Fixed config responce in client --- Drone/client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Drone/client.py b/Drone/client.py index e969c6a..315168e 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -216,8 +216,8 @@ def _command_config_write(*args, **kwargs): @messaging.request_callback("config") def _response_config(*args, **kwargs): - response = {"config": active_client.config.fulldict, - "configspec": dict(active_client.config.configspec)} + response = {"config": active_client.config.full_dict, + "configspec": dict(active_client.config.config.configspec)} return response @messaging.request_callback("id") From 7e702482d65285ea618fa63234b5c85d66db7117 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 7 Jan 2020 17:27:44 +0300 Subject: [PATCH 058/210] Simplifyed validation loop usage + other improvemnts --- Server/config_editor_models.py | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 4c2c006..3b3e62c 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -1,10 +1,11 @@ import pickle +import logging from ast import literal_eval from functools import partial from copy import deepcopy from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtCore import Qt as Qt +from PyQt5.QtCore import Qt as Qt, pyqtSlot from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu, QAction, QMessageBox, QInputDialog, QFileDialog @@ -685,8 +686,8 @@ class ConfigTreeWidget(QTreeView): class ConfigDialog(QtWidgets.QDialog): - def __init__(self): - super(ConfigDialog, self).__init__() + def __init__(self, parent=None): + super(ConfigDialog, self).__init__(parent) self.ui = config_editor.Ui_config_dialog() self.model = ConfigModel(widget=self) self.setupUi() @@ -722,14 +723,19 @@ class ConfigDialog(QtWidgets.QDialog): ) return reply == QMessageBox.Yes + @pyqtSlot() def run(self): self.show() self.exec() + return self.result() - def _validate_loop(self, cfg, configspec=None): # modifies cfg object + def validation_loop(self, cfg, configspec=None): # modifies cfg object while True: + if not self.run(): + return False + try: - cfg.load_from_dict(ui.model.to_config_dict(), configspec=configspec) + cfg.load_from_dict(self.model.to_config_dict(), configspec=configspec) except config.ValidationError as error: msg = "Can not validate. Proceed with editing? Errors: \n" + "\n".join(error.flatten_errors()) reply = QMessageBox.warning(self, "Validation error!", msg, QMessageBox.Yes | QMessageBox.Cancel) @@ -737,7 +743,6 @@ class ConfigDialog(QtWidgets.QDialog): if reply == QMessageBox.Cancel: return False - self.run() else: return True @@ -757,15 +762,11 @@ class ConfigDialog(QtWidgets.QDialog): self.setupModel(cfg.full_dict, convert_types=(not cfg.validated)) - self.run() - if not self.result(): - return False - filename = cfg.config.filename validation_path = path if cfg.config.filename is None else cfg.config.filename validation_path = validation_path if cfg.validated else None - if not self._validate_loop(cfg, validation_path) or not self.result(): + if not self.validation_loop(cfg, validation_path): return False if filename is None: From c03083e238430530d245f6c5e309b7cb213c16a0 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 7 Jan 2020 17:30:02 +0300 Subject: [PATCH 059/210] Added context menu with config dialog! Tested and working for now --- Server/config_editor_models.py | 13 ++++++++++++ Server/copter_table.py | 37 ++++++++++++++++++++++++++++++---- Server/copter_table_models.py | 11 ++++++++++ 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 3b3e62c..5cb3783 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -746,6 +746,19 @@ class ConfigDialog(QtWidgets.QDialog): else: return True + def call_copter_dialog(self, client, value): + config_dict, spec_dict = value["config"], value["configspec"] + cfg = config.ConfigManager() + cfg.load_from_dict(config_dict, spec_dict) + + self.setupModel(config_dict) + if not self.validation_loop(cfg, spec_dict): + return False + + edited_dict = self.model.to_config_dict() + client.send_message("config", {"config": edited_dict, "mode": "rewrite"}) + return True + def call_standalone_dialog(self): path = QFileDialog.getOpenFileName(self, "Select configuration or specification file", filter="Config and spec files (*.ini)")[0] diff --git a/Server/copter_table.py b/Server/copter_table.py index 202f66a..c44c39e 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -1,3 +1,5 @@ +from functools import partial + from PyQt5 import QtWidgets, QtCore, QtGui from PyQt5.QtCore import Qt as Qt from PyQt5.QtCore import pyqtSlot @@ -5,13 +7,17 @@ from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QTableView, QMessageBox, QMenu, QAction, QWidgetAction, QListWidget, \ QAbstractItemView, QListWidgetItem +from config_editor_models import ConfigDialog import copter_table_models as table class CopterTableWidget(QTableView): - def __init__(self, model, data_model=table.StatedCopterData): + config_dialog_signal = QtCore.pyqtSignal(object, object) + + def __init__(self, model, window, data_model=table.StatedCopterData): QTableView.__init__(self) + self._window = window self.model = model self._data_model = data_model @@ -29,6 +35,10 @@ class CopterTableWidget(QTableView): header.setContextMenuPolicy(Qt.CustomContextMenu) header.customContextMenuRequested.connect(self.showHeaderMenu) + self.setContextMenuPolicy(Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self.open_menu) + + self.signal_connection = None # self.horizontalHeader().contextMenuEvent = self.headercontextMenuEvent # Adjust properties @@ -74,11 +84,30 @@ class CopterTableWidget(QTableView): menu.addAction(action) menu.exec_(QCursor.pos()) - def contextMenuEvent(self, event): + @pyqtSlot(QtCore.QPoint) + def open_menu(self, point): menu = QMenu(self) + index = self.indexAt(point) + item = self.model.get_row_data(index) + # print(item, index.row(), index.column()) - menu.addAction("action") - # menu.exec_(QCursor.pos()) + edit_config = QAction("Edit config") + edit_config.triggered.connect(partial(self.edit_config, item)) + menu.addAction(edit_config) + + if item is None: + edit_config.setDisabled(True) + + menu.exec_(QCursor.pos()) + + @pyqtSlot() + def edit_config(self, copter): + if self.signal_connection is not None: + self.config_dialog_signal.disconnect(self.signal_connection) + + call = ConfigDialog(self._window).call_copter_dialog + self.signal_connection = self.config_dialog_signal.connect(call) + copter.client.get_response("config", self.config_dialog_signal.emit) # def _selfcheck_shortener(self, data): # TODO!!! # shortened = [] diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 5183e47..39d23d3 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -422,6 +422,17 @@ class CopterDataModel(QtCore.QAbstractTableModel): contents = contents or self.data_contents return filter(calibration_ready_check, contents) + def get_row_data(self, index): + row = index.row() + if row == -1: + return None + try: + data = self.data_contents[row] + except IndexError: + return None + else: + return data + def get_row_index(self, row_data): try: index = self.data_contents.index(row_data) From 5c0306f2127fa541c48c2421a985eead322e39a7 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 7 Jan 2020 17:30:44 +0300 Subject: [PATCH 060/210] Improved qt server callbacks registartion --- Server/server_qt.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index ef33c67..0e8d7f6 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -27,6 +27,7 @@ import config as cfg import copter_table_models as table from copter_table import CopterTableWidget from visual_land_dialog import VisualLandDialog +from config_editor_models import ConfigDialog def multi_glob(*patterns): @@ -90,6 +91,7 @@ class MainWindow(QtWidgets.QMainWindow): # self.setStatusBar(self.statusBar) # self.statusBar.showMessage("Hey", 2000) + self.register_callbacks() self.player = QtMultimedia.QMediaPlayer() def init_ui(self): @@ -148,7 +150,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.horizontalLayout.removeWidget(self.ui.tableView) self.ui.tableView.close() # Init our custom widget - self.ui.copter_table = CopterTableWidget(self.model) + self.ui.copter_table = CopterTableWidget(self.model, self) self.ui.copter_table.setObjectName("copter_table") # Insert to layout at right self.ui.horizontalLayout.insertWidget(0, self.ui.copter_table, 0) @@ -507,11 +509,10 @@ class MainWindow(QtWidgets.QMainWindow): dialog = VisualLandDialog(self.model) dialog.start() - -@messaging.message_callback("telemetry") -def get_telem_data(self, **kwargs): - message = kwargs.get("value") - window.update_table_data(self, message) + def register_callbacks(self): + @messaging.message_callback("telemetry") + def get_telem_data(client, value, **kwargs): + self.update_table_data(client, value) def except_hook(cls, exception, traceback): From 2173f4a1346c7c19bbb328e040ea7f285c3f8e18 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Sun, 12 Jan 2020 10:02:51 +0300 Subject: [PATCH 061/210] WIP column reordering --- Server/copter_table.py | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index c44c39e..7c54038 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -1,4 +1,5 @@ from functools import partial +from copy import deepcopy from PyQt5 import QtWidgets, QtCore, QtGui from PyQt5.QtCore import Qt as Qt @@ -30,7 +31,11 @@ class CopterTableWidget(QTableView): # Initiate table and table self.model self.setModel(self.proxy_model) + self.columns = [header.strip() for header in self.model.headers] + self.current_columns = deepcopy(self.columns) + header = self.horizontalHeader() + header.sectionMoved.connect(self.moved) header.setSectionsMovable(True) header.setContextMenuPolicy(Qt.CustomContextMenu) header.customContextMenuRequested.connect(self.showHeaderMenu) @@ -46,8 +51,12 @@ class CopterTableWidget(QTableView): self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.doubleClicked.connect(self.on_double_click) - # Some fancy wrappers to simplify syntax + def moved(self, logical_index, old_index, new_index): + # print(logical_index, old_index, new_index) + name = self.current_columns.pop(old_index) + self.current_columns.insert(new_index, name) + # Some fancy wrappers to simplify syntax def add_client(self, **kwargs): self.signals.add_client_signal.emit(self._data_model(**kwargs)) @@ -77,7 +86,7 @@ class CopterTableWidget(QTableView): def showHeaderMenu(self, event): menu = QMenu(self) header_view = HeaderListWidget(menu, self) - header_view.setFixedHeight((header_view.geometry().height()-6)*len(header_view.names)) + header_view.setFixedHeight((header_view.geometry().height()-2) * len(header_view.original_names)) #box.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) action = QWidgetAction(menu) action.setDefaultWidget(header_view) @@ -126,13 +135,14 @@ class HeaderListWidget(QListWidget): self.setDragDropMode(QAbstractItemView.InternalMove) self.setDefaultDropAction(Qt.MoveAction) - self.names = list(self.get_names()) + self.names = source.current_columns + self.original_names = source.columns # list(deepcopy(parent.names))#list(self.get_names()) self.populate_items() self.itemChanged.connect(self.on_itemChanged) - def get_names(self): - for column in range(self.source_model.columnCount()): - yield self.source_model.headerData(column, Qt.Horizontal).strip() + # def get_names(self): + # for column in range(self.source_model.columnCount()): + # yield self.source_model.headerData(column, Qt.Horizontal).strip() def populate_items(self): for column, name in enumerate(self.names): @@ -144,6 +154,20 @@ class HeaderListWidget(QListWidget): item.setFlags(flags) item.setCheckState(state) + def dropEvent(self, event: QtGui.QDropEvent): + super().dropEvent(event) + + old_names = self.names[:] + print(old_names) + names = [self.item(i).text() for i in range(self.count())] + #print(names) + self.names[:] = names # don't breaking the link + print(self.source_widget.current_columns) + + # print(self.indexAt(event.pos()).row()) + #event.accept() + + @pyqtSlot(QListWidgetItem) def on_itemChanged(self, item): - self.source_widget.setColumnHidden(self.names.index(item.text()), not bool(item.checkState())) + self.source_widget.setColumnHidden(self.original_names.index(item.text()), not bool(item.checkState())) From 277ed82a6d6812ba45752e7ba338b6616e32c590 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 12 Jan 2020 12:20:16 +0300 Subject: [PATCH 062/210] Added column reodering from rigt-click menu + order loading --- Server/copter_table.py | 50 +++++++++++++++++++----------------------- 1 file changed, 22 insertions(+), 28 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index 7c54038..fd341fd 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -32,19 +32,18 @@ class CopterTableWidget(QTableView): self.setModel(self.proxy_model) self.columns = [header.strip() for header in self.model.headers] - self.current_columns = deepcopy(self.columns) + self.current_columns = self.columns[:] header = self.horizontalHeader() - header.sectionMoved.connect(self.moved) header.setSectionsMovable(True) + header.sectionMoved.connect(self.moved) header.setContextMenuPolicy(Qt.CustomContextMenu) header.customContextMenuRequested.connect(self.showHeaderMenu) self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.open_menu) - self.signal_connection = None - # self.horizontalHeader().contextMenuEvent = self.headercontextMenuEvent + self._signal_connection = None # Adjust properties self.resizeColumnsToContents() @@ -52,10 +51,18 @@ class CopterTableWidget(QTableView): self.doubleClicked.connect(self.on_double_click) def moved(self, logical_index, old_index, new_index): - # print(logical_index, old_index, new_index) name = self.current_columns.pop(old_index) self.current_columns.insert(new_index, name) + def load_column_order(self, order): + if set(order) != set(self.current_columns): + raise ValueError + + for index_to, item in enumerate(order): + index_from = self.current_columns.index(item) + if index_to != index_from: + self.horizontalHeader().moveSection(index_from, index_to) + # Some fancy wrappers to simplify syntax def add_client(self, **kwargs): self.signals.add_client_signal.emit(self._data_model(**kwargs)) @@ -86,7 +93,7 @@ class CopterTableWidget(QTableView): def showHeaderMenu(self, event): menu = QMenu(self) header_view = HeaderListWidget(menu, self) - header_view.setFixedHeight((header_view.geometry().height()-2) * len(header_view.original_names)) + header_view.setFixedHeight((header_view.geometry().height()-2) * len(header_view.columns)) #box.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) action = QWidgetAction(menu) action.setDefaultWidget(header_view) @@ -111,11 +118,11 @@ class CopterTableWidget(QTableView): @pyqtSlot() def edit_config(self, copter): - if self.signal_connection is not None: - self.config_dialog_signal.disconnect(self.signal_connection) + if self._signal_connection is not None: + self.config_dialog_signal.disconnect(self._signal_connection) call = ConfigDialog(self._window).call_copter_dialog - self.signal_connection = self.config_dialog_signal.connect(call) + self._signal_connection = self.config_dialog_signal.connect(call) copter.client.get_response("config", self.config_dialog_signal.emit) # def _selfcheck_shortener(self, data): # TODO!!! @@ -135,17 +142,13 @@ class HeaderListWidget(QListWidget): self.setDragDropMode(QAbstractItemView.InternalMove) self.setDefaultDropAction(Qt.MoveAction) - self.names = source.current_columns - self.original_names = source.columns # list(deepcopy(parent.names))#list(self.get_names()) + self.current_columns = source.current_columns + self.columns = source.columns self.populate_items() self.itemChanged.connect(self.on_itemChanged) - # def get_names(self): - # for column in range(self.source_model.columnCount()): - # yield self.source_model.headerData(column, Qt.Horizontal).strip() - def populate_items(self): - for column, name in enumerate(self.names): + for column, name in enumerate(self.current_columns): hidden = self.source_widget.isColumnHidden(column) flags = Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsEnabled state = Qt.Unchecked if hidden else Qt.Checked @@ -156,18 +159,9 @@ class HeaderListWidget(QListWidget): def dropEvent(self, event: QtGui.QDropEvent): super().dropEvent(event) - - old_names = self.names[:] - print(old_names) - names = [self.item(i).text() for i in range(self.count())] - #print(names) - self.names[:] = names # don't breaking the link - print(self.source_widget.current_columns) - - # print(self.indexAt(event.pos()).row()) - #event.accept() - + column_order = [self.item(i).text() for i in range(self.count())] + self.source_widget.load_column_order(column_order) @pyqtSlot(QListWidgetItem) def on_itemChanged(self, item): - self.source_widget.setColumnHidden(self.original_names.index(item.text()), not bool(item.checkState())) + self.source_widget.setColumnHidden(self.columns.index(item.text()), not bool(item.checkState())) From d23c12e3fd0acd70df44ef07c90d422d637e53d6 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 12 Jan 2020 15:45:46 +0300 Subject: [PATCH 063/210] Simplify init, better config window managing --- Server/copter_table.py | 9 ++++----- Server/server_qt.py | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index fd341fd..ace191b 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -15,10 +15,9 @@ import copter_table_models as table class CopterTableWidget(QTableView): config_dialog_signal = QtCore.pyqtSignal(object, object) - def __init__(self, model, window, data_model=table.StatedCopterData): + def __init__(self, model, data_model=table.StatedCopterData): QTableView.__init__(self) - self._window = window self.model = model self._data_model = data_model @@ -54,7 +53,7 @@ class CopterTableWidget(QTableView): name = self.current_columns.pop(old_index) self.current_columns.insert(new_index, name) - def load_column_order(self, order): + def set_column_order(self, order): if set(order) != set(self.current_columns): raise ValueError @@ -121,7 +120,7 @@ class CopterTableWidget(QTableView): if self._signal_connection is not None: self.config_dialog_signal.disconnect(self._signal_connection) - call = ConfigDialog(self._window).call_copter_dialog + call = ConfigDialog().call_copter_dialog self._signal_connection = self.config_dialog_signal.connect(call) copter.client.get_response("config", self.config_dialog_signal.emit) @@ -160,7 +159,7 @@ class HeaderListWidget(QListWidget): def dropEvent(self, event: QtGui.QDropEvent): super().dropEvent(event) column_order = [self.item(i).text() for i in range(self.count())] - self.source_widget.load_column_order(column_order) + self.source_widget.set_column_order(column_order) @pyqtSlot(QListWidgetItem) def on_itemChanged(self, item): diff --git a/Server/server_qt.py b/Server/server_qt.py index 0e8d7f6..77f273d 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -150,7 +150,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.horizontalLayout.removeWidget(self.ui.tableView) self.ui.tableView.close() # Init our custom widget - self.ui.copter_table = CopterTableWidget(self.model, self) + self.ui.copter_table = CopterTableWidget(self.model) self.ui.copter_table.setObjectName("copter_table") # Insert to layout at right self.ui.horizontalLayout.insertWidget(0, self.ui.copter_table, 0) From 493e0ca13e6009dd7e67e8cb4205f8fe8e8986e9 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 13 Jan 2020 17:30:25 +0300 Subject: [PATCH 064/210] Update requirements.txt --- requirements.txt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/requirements.txt b/requirements.txt index 3521555..8a2d17b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -configobj -quamash +configobj==5.0.6 indexed.py==0.0.1 -numpy==1.17.4 -PyQt5==5.13.2 -PyQt5-sip==12.7.0 +numpy==1.18.1 +PyQt5==5.13.0 +PyQt5-sip==4.19.18 +Quamash==0.6.1 selectors2==2.0.1 -Quamash==0.6.1 \ No newline at end of file +six==1.13.0 From 4b1f63e66e8d6bb00fefb3bf8254eeef8a6a79bb Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 13 Jan 2020 18:02:59 +0300 Subject: [PATCH 065/210] Identation fix in generated files --- config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/config.py b/config.py index 824d280..a00f638 100644 --- a/config.py +++ b/config.py @@ -218,6 +218,7 @@ class ConfigManager: config = ConfigObj(configspec=cls._get_spec_path(cfg_path)) config.filename = cfg_path config.validate(vdt, copy=True) + config.indent_type = '' config.initial_comment = ('This is generated config with default values', 'Modify to configure') config.write() From 1f7dd62d5a2f7dbc67ca5f021eeff7b8792b3fa1 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 13 Jan 2020 18:32:06 +0300 Subject: [PATCH 066/210] Removed unnececary selector unregistering --- messaging_lib.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/messaging_lib.py b/messaging_lib.py index d5c02b6..ccb6852 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -506,7 +506,6 @@ class NotifierSock(Singleton): self._send_lock = threading.Lock() self._receiving_sock = None - self._selector = None def init(self, selector, port=26000): port += random.randint(0, 100) # local testing fix @@ -518,7 +517,6 @@ class NotifierSock(Singleton): logger.info("Notify socket: connected") selector.register(self._receiving_sock, selectors.EVENT_READ, data=self) - self._selector = selector logger.info("Notify socket: selector registered") def get_sock(self): @@ -543,7 +541,6 @@ class NotifierSock(Singleton): def close(self): try: - self._selector.unregister(self._receiving_sock) self._server_socket.close() self._sending_sock.close() self._receiving_sock.close() From 26467f1472c5a6e03f25fe4b96682af620b6fc44 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 13 Jan 2020 18:33:41 +0300 Subject: [PATCH 067/210] Fixed resizing of columns --- Server/copter_table.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index ace191b..2f9b1ac 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -30,10 +30,12 @@ class CopterTableWidget(QTableView): # Initiate table and table self.model self.setModel(self.proxy_model) - self.columns = [header.strip() for header in self.model.headers] + self.columns = [header.strip() for header in self.model.headers] # header keys self.current_columns = self.columns[:] header = self.horizontalHeader() + header.setCascadingSectionResizes(False) + header.setStretchLastSection(True) header.setSectionsMovable(True) header.sectionMoved.connect(self.moved) header.setContextMenuPolicy(Qt.CustomContextMenu) @@ -45,6 +47,7 @@ class CopterTableWidget(QTableView): self._signal_connection = None # Adjust properties + self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) self.resizeColumnsToContents() self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.doubleClicked.connect(self.on_double_click) From dba274797dd702b72f4f78210dbde452331fcd18 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 13 Jan 2020 18:57:49 +0300 Subject: [PATCH 068/210] Server: Increase columns names --- Server/copter_table_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 39d23d3..8688ea4 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -374,8 +374,8 @@ class CopterDataModel(QtCore.QAbstractTableModel): def __init__(self, checks=ModelChecks, formatter=ModelFormatter, parent=None): super(CopterDataModel, self).__init__(parent) - self.headers = ('copter ID', 'version', ' animation ID ', ' battery ', ' system ', 'sensors', - ' mode ', ' checks ', 'current x y z yaw frame_id', ' start x y z ', 'dt') + self.headers = (' copter ID ', ' version ', ' animation ID ', ' battery ', ' system ', ' sensors ', + ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' dt ') self.data_contents = [] self.checks = checks From dd7b3648af23b13168a87b8d8bd8c83fa19203e4 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 13 Jan 2020 19:13:52 +0000 Subject: [PATCH 069/210] Client: Update config specs --- Drone/config/client.ini | 71 ++++++++++++++++++++++--- Drone/config/spec/configspec_client.ini | 54 ++++++++++--------- 2 files changed, 92 insertions(+), 33 deletions(-) diff --git a/Drone/config/client.ini b/Drone/config/client.ini index 29978d6..5faa4c5 100644 --- a/Drone/config/client.ini +++ b/Drone/config/client.ini @@ -1,4 +1,4 @@ -# This is generated config_attrs with defaults +# This is generated config with default values # Modify to configure config_name = Copter config config_version = 0.0 @@ -12,14 +12,69 @@ buffer_size = 1024 use = True port = 8181 +[TELEMETRY] +transmit = True +frequency = 1.0 +log_resources = True + +[VISUAL POSE WATCHDOG] +timeout = 1.0 +pos_delta_max = 3.0 +# Available options: emergency_land, land, disarm +action = emergency_land + +[EMERGENCY LAND] +thrust = 0.45 +decrease_thrust_after = 5.0 +disarm_timeout = 10.0 + +[COPTER] +frame_id = map +takeoff_height = 1.0 +takeoff_time = 5.0 +safe_takeoff = False +reach_first_point_time = 5.0 +land_time = 1.0 +land_timeout = 10.0 +common_offset = 0.0, 0.0, 0.0 + +[FLOOR FRAME] +enabled = False +parent = map +# Frame translation (x, y, z) +# __list__ x y z +translation = 0.0, 0.0, 0.0 +# Frame rotation (roll, pitch, yaw) in degrees +# __list__ roll pitch yaw +rotation = 0.0, 0.0, 0.0 + +[ANIMATION] +takeoff_detection = True +land_detection = True +frame_delay = 0.1 +# Animation ratio (x, y, z) +# __list__ x y z +ratio = 1.0, 1.0, 1.0 +# Available options: 'animation', 'nan' or a number in degrees +yaw = 180.0 + +[LED] +use = True +pin = 21 +count = 60 + +[PRIVATE] +# Available options: /hostname ; /default ; /ip ; any string 63 characters length +id = /hostname +# Drone's individual offset (x, y, z) +# __list__ x y z +offset = 0.0, 0.0, 0.0 + +[SYSTEM] +change_hostname = True +restart_after_rename = True + [NTP] use = False host = ntp1.stratum2.ru port = 123 - -[PRIVATE] -# avialiable options: /hostname ; /spec_default ; /ip ; any string 63 characters lengh -id = /hostname -# Drone's individual offset (X, Y, Z) -# __list__ X Y Z -offset = 0.0, 0.0, 0.0 diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index 04c6ecd..37038bb 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -10,26 +10,23 @@ buffer_size = integer(default=1024) use = boolean(default=True) port = integer(default=8181) +[TELEMETRY] +transmit = boolean(default=True) +frequency = float(default=1.0) +log_resources = boolean(default=True) + [VISUAL POSE WATCHDOG] timeout = float(default=1.0, min=0) pos_delta_max = float(default=3.0, min=0) +# Available options: emergency_land, land, disarm action = string(default=emergency_land) - [EMERGENCY LAND] - thrust = float(default=0.45, min=0, max=1) - decrease_thrust_after = float(default=5.0, min=0) #TODO change name PLS - disarm_timeout = float(default=10.0, min=0) -[FRAMES] - [[__many__]] - parent = string(default=aruco_map) # todo scale? - # Frame offset (x, y, z) - # __list__ x y z - offset = float_list(default=list(0.0, 0.0, 0.0), min=3, max=3) - # Frame rotation (roll, pitch, yaw) - # __list__ roll pitch yaw - rotation = float_list(default=list(0.0, 0.0, 0.0), min=3, max=3) +[EMERGENCY LAND] +thrust = float(default=0.45, min=0, max=1) +decrease_thrust_after = float(default=5.0, min=0) +disarm_timeout = float(default=10.0, min=0) -[FLIGHT] +[COPTER] frame_id = string(default=map) takeoff_height = float(default=1.0) takeoff_time = float(default=5.0, min=0) @@ -37,19 +34,27 @@ safe_takeoff = boolean(default=False) reach_first_point_time = float(default=5.0) land_time = float(default=1.0, min=0) land_timeout = float(default=10.0, min=0) +common_offset = float_list(default=list(0, 0, 0), min=3, max=3) + +[FLOOR FRAME] +enabled = boolean(default=False) +parent = string(default=map) +# Frame translation (x, y, z) +# __list__ x y z +translation = float_list(default=list(0.0, 0.0, 0.0), min=3, max=3) +# Frame rotation (roll, pitch, yaw) in degrees +# __list__ roll pitch yaw +rotation = float_list(default=list(0.0, 0.0, 0.0), min=3, max=3) [ANIMATION] takeoff_detection = boolean(default=True) land_detection = boolean(default=True) -default_fps = float(default=10, min=0) -# Animation scale (x, y, z) +frame_delay = float(default=0.1, min=0.01) +# Animation ratio (x, y, z) # __list__ x y z -scale = float_list(default=list(1.0, 1.0, 1.0), min=3, max=3) - -[TELEMETRY] -send = boolean(default=True) -land_pos_delta = float(default=3.0, min=0) # TODO move to watchdog + rename? -log_resources = boolean(default=True) +ratio = float_list(default=list(1.0, 1.0, 1.0), min=3, max=3) +# Available options: 'animation', 'nan' or a number in degrees +yaw = string(default=180.0) [LED] use = boolean(default=True) @@ -57,16 +62,15 @@ pin = integer(default=21, min=0, max=100) count = integer(default=60, min=1) [PRIVATE] -# available options: /hostname ; /default ; /ip ; any string 63 characters length +# Available options: /hostname ; /default ; /ip ; any string 63 characters length id = string(default=/hostname, max=63) #TODO our re check # Drone's individual offset (x, y, z) # __list__ x y z offset = float_list(default=list(0, 0, 0), min=3, max=3) -yaw = float(default=180.0) [SYSTEM] change_hostname = boolean(default=True) -rename_restart = boolean(default=True) +restart_after_rename = boolean(default=True) [NTP] use = boolean(default=False) From fb80e0df3b0ea1cc61d03b7f336c7ccc2acea2d8 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 13 Jan 2020 19:14:31 +0000 Subject: [PATCH 070/210] Client: Refactor copter_client to new configuration --- Drone/animation_lib.py | 2 +- Drone/copter_client.py | 208 +++++++++++++++++++---------------------- 2 files changed, 97 insertions(+), 113 deletions(-) diff --git a/Drone/animation_lib.py b/Drone/animation_lib.py index be51fd9..3906ac2 100644 --- a/Drone/animation_lib.py +++ b/Drone/animation_lib.py @@ -53,7 +53,7 @@ def get_id(filepath="animation.csv"): logger.debug("No animation id in file") return anim_id -def get_start_xy(filepath="animation.csv", x_ratio=1, y_ratio=1): +def get_start_xy(filepath="animation.csv", x_ratio=1, y_ratio=1, z_ratio=1): try: animation_file = open(filepath) except IOError: diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 689cca2..18a0f3d 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -3,6 +3,7 @@ import sys import time import math import rospy +import numpy from clever import srv import datetime import logging @@ -72,10 +73,12 @@ flightlib_logger.addHandler(handler) class CopterClient(client.Client): def __init__(self, config_path="config/client.ini"): super(CopterClient, self).__init__(config_path) + self.load_config() self.frames = {} def load_config(self): super(CopterClient, self).load_config() + #print(self.config) # self.FLOOR_FRAME_EXISTS = False # self.TELEM_FREQ = self.config.getfloat('TELEMETRY', 'frequency') # self.TELEM_TRANSMIT = self.config.getboolean('TELEMETRY', 'transmit') @@ -125,11 +128,11 @@ class CopterClient(client.Client): def start(self, task_manager_instance): rospy.loginfo("Init ROS node") rospy.init_node('clever_show_client') - if self.USE_LEDS: - LedLib.init_led(self.LED_PIN) + if self.config.led_use: + LedLib.init_led(self.config.led_pin) task_manager_instance.start() # TODO move to self - if self.FRAME_ID == "floor": - if self.FLOOR_FRAME_EXISTS: + if self.config.copter_frame_id == "floor": + if self.config.floor_frame_enabled: self.start_floor_frame_broadcast() else: rospy.logerror("Can't make floor frame!") @@ -140,14 +143,14 @@ class CopterClient(client.Client): def start_floor_frame_broadcast(self): trans = TransformStamped() - trans.transform.translation.x = self.FLOOR_DX - trans.transform.translation.y = self.FLOOR_DY - trans.transform.translation.z = self.FLOOR_DZ - trans.transform.rotation = Quaternion(*quaternion_from_euler(math.radians(self.FLOOR_ROLL), - math.radians(self.FLOOR_PITCH), - math.radians(self.FLOOR_YAW))) - trans.header.frame_id = self.FLOOR_PARENT - trans.child_frame_id = self.FRAME_ID + trans.transform.translation.x = self.config.floor_frame_transtation[0] + trans.transform.translation.y = self.config.floor_frame_transtation[1] + trans.transform.translation.z = self.config.floor_frame_transtation[2] + trans.transform.rotation = Quaternion(*quaternion_from_euler(math.radians(self.config.floor_frame_rotation[0]), + math.radians(self.config.floor_frame_rotation[1]), + math.radians(self.config.floor_frame_rotation[2]))) + trans.header.frame_id = self.config.floor_frame_parent + trans.child_frame_id = self.config.copter_frame_id static_bloadcaster.sendTransform(trans) @@ -288,10 +291,9 @@ def _response_id(*args, **kwargs): if new_id is not None: old_id = client.active_client.client_id if new_id != old_id: - cfg = client.ConfigOption("PRIVATE", "id", new_id) - client.active_client.write_config(True, cfg) + client.active_client.config.set('PRIVATE', 'id', new_id, write=True) if new_id != '/hostname': - if client.active_client.RESTART_AFTER_RENAME: + if client.active_client.system_restart_after_rename: hostname = client.active_client.client_id configure_hostname(hostname) configure_hosts(hostname) @@ -329,19 +331,13 @@ def _response_animation_id(*args, **kwargs): result = animation.get_id() if result != 'No animation': logger.debug("Saving corrected animation") - frames = animation.load_animation(os.path.abspath("animation.csv"), - x0=client.active_client.X0 + client.active_client.X0_COMMON, - y0=client.active_client.Y0 + client.active_client.Y0_COMMON, - z0=client.active_client.Z0 + client.active_client.Z0_COMMON, - x_ratio=client.active_client.X_RATIO, - y_ratio=client.active_client.Y_RATIO, - z_ratio=client.active_client.Z_RATIO, - ) + offset = numpy.array(client.active_client.config.private_offset) + numpy.array(client.active_client.config.copter_common_offset) + frames = animation.load_animation(os.path.abspath("animation.csv"), offset[0], offset[1], offset[2], *client.active_client.config.animation_ratio) # Correct start and land frames in animation corrected_frames, start_action, start_delay = animation.correct_animation(frames, - check_takeoff=client.active_client.TAKEOFF_CHECK, - check_land=client.active_client.LAND_CHECK, - ) + check_takeoff=client.active_client.config.animation_takeoff_detection, + check_land=client.active_client.config.animation_land_detection, + ) logger.debug("Start action: {}".format(start_action)) # Save corrected animation animation.save_corrected_animation(corrected_frames) @@ -382,9 +378,9 @@ def _response_cal_status(*args, **kwargs): @messaging.request_callback("position") def _response_position(*args, **kwargs): - telem = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) + telem = FlightLib.get_telemetry_locked(client.active_client.config.copter_frame_id) return "{:.2f} {:.2f} {:.2f} {:.1f} {}".format( - telem.x, telem.y, telem.z, math.degrees(telem.yaw), client.active_client.FRAME_ID) + telem.x, telem.y, telem.z, math.degrees(telem.yaw), client.active_client.config.copter_frame_id) @messaging.request_callback("calibrate_gyro") @@ -416,19 +412,17 @@ def _command_test(*args, **kwargs): @messaging.message_callback("move_start") def _command_move_start_to_current_position(*args, **kwargs): x_start, y_start = animation.get_start_xy(os.path.abspath("animation.csv"), - x_ratio=client.active_client.X_RATIO, - y_ratio=client.active_client.Y_RATIO, - ) + *client.active_client.config.animation_ratio) logger.debug("x_start = {}, y_start = {}".format(x_start, y_start)) if not math.isnan(x_start): - telem = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) + telem = FlightLib.get_telemetry_locked(client.active_client.config.copter_frame_id) logger.debug("x_telem = {}, y_telem = {}".format(telem.x, telem.y)) if not math.isnan(telem.x): - client.active_client.config.set('PRIVATE', 'x0', telem.x - x_start) - client.active_client.config.set('PRIVATE', 'y0', telem.y - y_start) - client.active_client.rewrite_config() - client.active_client.load_config() - logger.info("Set start delta: {:.2f} {:.2f}".format(client.active_client.X0, client.active_client.Y0)) + client.active_client.config.set('PRIVATE', 'offset', + [telem.x - x_start, telem.y - y_start, client.active_client.config.private_offset[2]], + write=True) + logger.info("Set start delta: {:.2f} {:.2f}".format(client.active_client.config.private_offset[0], + client.active_client.config.private_offset[1])) else: logger.debug("Wrong telemetry") else: @@ -437,28 +431,28 @@ def _command_move_start_to_current_position(*args, **kwargs): @messaging.message_callback("reset_start") def _command_reset_start(*args, **kwargs): - client.active_client.config.set('PRIVATE', 'x0', 0) - client.active_client.config.set('PRIVATE', 'y0', 0) - client.active_client.rewrite_config() - client.active_client.load_config() - logger.info("Reset start to {:.2f} {:.2f}".format(client.active_client.X0, client.active_client.Y0)) + client.active_client.config.set('PRIVATE', 'offset', + [0, 0, client.active_client.config.private_offset[2]], + write=True) + logger.info("Reset start to {:.2f} {:.2f}".format(client.active_client.config.private_offset[0], + client.active_client.config.private_offset[1])) @messaging.message_callback("set_z_to_ground") def _command_set_z(*args, **kwargs): - telem = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) - client.active_client.config.set('PRIVATE', 'z0', telem.z) - client.active_client.rewrite_config() - client.active_client.load_config() - logger.info("Set z offset to {:.2f}".format(client.active_client.Z0)) + telem = FlightLib.get_telemetry_locked(client.active_client.config.copter_frame_id) + client.active_client.config.set('PRIVATE', 'offset', + [client.active_client.config.private_offset[0], client.active_client.config.private_offset[1], telem.z], + write=True) + logger.info("Set z offset to {:.2f}".format(client.active_client.config.private_offset[2])) @messaging.message_callback("reset_z_offset") def _command_reset_z(*args, **kwargs): - client.active_client.config.set('PRIVATE', 'z0', 0) - client.active_client.rewrite_config() - client.active_client.load_config() - logger.info("Reset z offset to {:.2f}".format(client.active_client.Z0)) + client.active_client.config.set('PRIVATE', 'offset', + [client.active_client.config.private_offset[0], client.active_client.config.private_offset[1], 0], + write=True) + logger.info("Reset z offset to {:.2f}".format(client.active_client.config.private_offset[2])) @messaging.message_callback("update_repo") @@ -491,7 +485,7 @@ def _command_service_restart(*args, **kwargs): @messaging.message_callback("repair_chrony") def _command_chrony_repair(*args, **kwargs): - configure_chrony_ip(client.active_client.server_host) + configure_chrony_ip(client.active_client.config.server_host) restart_service("chrony") @@ -513,7 +507,7 @@ def _command_led_fill(*args, **kwargs): @messaging.message_callback("flip") def _copter_flip(*args, **kwargs): - FlightLib.flip(frame_id=client.active_client.FRAME_ID) + FlightLib.flip(frame_id=client.active_client.config.copter_frame_id) @messaging.message_callback("takeoff") @@ -521,10 +515,10 @@ def _command_takeoff(*args, **kwargs): logger.info("Takeoff at {}".format(datetime.datetime.now())) task_manager.add_task(0, 0, animation.takeoff, task_kwargs={ - "z": client.active_client.TAKEOFF_HEIGHT, - "timeout": client.active_client.TAKEOFF_TIME, - "safe_takeoff": client.active_client.SAFE_TAKEOFF, - "use_leds": client.active_client.USE_LEDS, + "z": client.active_client.config.copter_takeoff_height, + "timeout": client.active_client.config.copter_takeoff_time, + "safe_takeoff": client.active_client.config.copter_safe_takeoff, + "use_leds": client.active_client.config.led_use, } ) @@ -533,15 +527,15 @@ def _command_takeoff(*args, **kwargs): def _command_takeoff_z(*args, **kwargs): z_str = kwargs.get("z", None) if z_str is not None: - telem = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) + telem = FlightLib.get_telemetry_locked(client.active_client.config.copter_frame_id) logger.info("Takeoff to z = {} at {}".format(z_str, datetime.datetime.now())) task_manager.add_task(0, 0, FlightLib.reach_point, task_kwargs={ "x": telem.x, "y": telem.y, "z": float(z_str), - "frame_id": client.active_client.FRAME_ID, - "timeout": client.active_client.TAKEOFF_TIME, + "frame_id": client.active_client.config.copter_frame_id, + "timeout": client.active_client.config.copter_takeoff_time, "auto_arm": True, } ) @@ -552,10 +546,10 @@ def _command_land(*args, **kwargs): task_manager.reset() task_manager.add_task(0, 0, animation.land, task_kwargs={ - "z": client.active_client.TAKEOFF_HEIGHT, - "timeout": client.active_client.TAKEOFF_TIME, - "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, + "z": client.active_client.config.copter_takeoff_height, + "timeout": client.active_client.config.copter_takeoff_time, + "frame_id": client.active_client.config.copter_frame_id, + "use_leds": client.active_client.config.led_use, } ) @@ -602,44 +596,38 @@ def _play_animation(*args, **kwargs): logger.info("Start time = {}, wait for {} seconds".format(start_time, start_time - time.time())) # Load animation - frames = animation.load_animation(os.path.abspath("animation.csv"), - x0=client.active_client.X0 + client.active_client.X0_COMMON, - y0=client.active_client.Y0 + client.active_client.Y0_COMMON, - z0=client.active_client.Z0 + client.active_client.Z0_COMMON, - x_ratio=client.active_client.X_RATIO, - y_ratio=client.active_client.Y_RATIO, - z_ratio=client.active_client.Z_RATIO, - ) + offset = numpy.array(client.active_client.config.private_offset) + numpy.array(client.active_client.config.copter_common_offset) + frames = animation.load_animation(os.path.abspath("animation.csv"), offset[0], offset[1], offset[2], *client.active_client.config.animation_ratio) # Correct start and land frames in animation corrected_frames, start_action, start_delay = animation.correct_animation(frames, - check_takeoff=client.active_client.TAKEOFF_CHECK, - check_land=client.active_client.LAND_CHECK, - ) + check_takeoff=client.active_client.config.animation_takeoff_detection, + check_land=client.active_client.config.animation_land_detection, + ) # Choose start action if start_action == 'takeoff': # Takeoff first task_manager.add_task(start_time, 0, animation.takeoff, task_kwargs={ - "z": client.active_client.TAKEOFF_HEIGHT, - "timeout": client.active_client.TAKEOFF_TIME, - "safe_takeoff": client.active_client.SAFE_TAKEOFF, - # "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, + "z": client.active_client.config.copter_takeoff_height, + "timeout": client.active_client.config.copter_takeoff_time, + "safe_takeoff": client.active_client.config.copter_safe_takeoff, + # "frame_id": client.active_client.config.copter_frame_id, + "use_leds": client.active_client.config.led_use, } ) # Fly to first point - rfp_time = start_time + client.active_client.TAKEOFF_TIME + rfp_time = start_time + client.active_client.config.copter_takeoff_time task_manager.add_task(rfp_time, 0, animation.execute_frame, task_kwargs={ "point": animation.convert_frame(corrected_frames[0])[0], "color": animation.convert_frame(corrected_frames[0])[1], - "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, + "frame_id": client.active_client.config.copter_frame_id, + "use_leds": client.active_client.config.led_use, "flight_func": FlightLib.reach_point, } ) # Calculate first frame start time - frame_time = rfp_time + client.active_client.RFP_TIME + frame_time = rfp_time + client.active_client.config.copter_reach_first_point_time elif start_action == 'arm': # Calculate start time @@ -656,42 +644,42 @@ def _play_animation(*args, **kwargs): task_kwargs={ "point": point, "color": color, - "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, + "frame_id": client.active_client.config.copter_frame_id, + "use_leds": client.active_client.config.led_use, "flight_func": FlightLib.navto, "auto_arm": True, } ) # Calculate first frame start time - frame_time += client.active_client.FRAME_DELAY # TODO Think about arming time + frame_time += client.active_client.config.animation_frame_delay # TODO Think about arming time logger.debug(task_manager.task_queue) # Play animation file for frame in corrected_frames: point, color, yaw = animation.convert_frame(frame) - if client.active_client.YAW == "animation": + if client.active_client.config.animation_yaw == "animation": yaw = frame["yaw"] else: - yaw = math.radians(float(client.active_client.YAW)) + yaw = math.radians(float(client.active_client.config.animation_yaw)) task_manager.add_task(frame_time, 0, animation.execute_frame, task_kwargs={ "point": point, "color": color, "yaw": yaw, - "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, + "frame_id": client.active_client.config.copter_frame_id, + "use_leds": client.active_client.config.led_use, "flight_func": FlightLib.navto, } ) frame_time += frame["delay"] # Calculate land_time - land_time = frame_time + client.active_client.LAND_TIME + land_time = frame_time + client.active_client.config.copter_land_time # Land task_manager.add_task(land_time, 0, animation.land, task_kwargs={ - "timeout": client.active_client.LAND_TIMEOUT, - "frame_id": client.active_client.FRAME_ID, - "use_leds": client.active_client.USE_LEDS, + "timeout": client.active_client.config.copter_land_timeout, + "frame_id": client.active_client.config.copter_frame_id, + "use_leds": client.active_client.config.led_use, }, ) @@ -743,17 +731,13 @@ class Telemetry: @classmethod def get_start_position(cls): x_start, y_start = animation.get_start_xy(os.path.abspath("animation.csv"), - x_ratio=client.active_client.X_RATIO, - y_ratio=client.active_client.Y_RATIO, - ) - x_delta = client.active_client.X0 + client.active_client.X0_COMMON - y_delta = client.active_client.Y0 + client.active_client.Y0_COMMON - z_delta = client.active_client.Z0 + client.active_client.Z0_COMMON - - x = x_start + x_delta - y = y_start + y_delta - if not FlightLib._check_nans(x, y, z_delta): - return x, y, z_delta + *client.active_client.config.animation_ratio) + offset = numpy.array(client.active_client.config.private_offset) + numpy.array(client.active_client.config.copter_common_offset) + x = x_start + offset[0] + y = y_start + offset[1] + z = offset[2] + if not FlightLib._check_nans(x, y, z): + return x, y, z return 'NO_POS' @classmethod @@ -790,13 +774,13 @@ class Telemetry: def get_position(cls, ros_telemetry): x, y, z = ros_telemetry.x, ros_telemetry.y, ros_telemetry.z if not math.isnan(x): - return x, y, z, math.degrees(ros_telemetry.yaw), client.active_client.FRAME_ID + return x, y, z, math.degrees(ros_telemetry.yaw), client.active_client.config.copter_frame_id return 'NO_POS' def update_telemetry_fast(self): self.start_position = self.get_start_position() try: - self.ros_telemetry = FlightLib.get_telemetry_locked(client.active_client.FRAME_ID) + self.ros_telemetry = FlightLib.get_telemetry_locked(client.active_client.config.copter_frame_id) if self.ros_telemetry.connected: self.armed = self.ros_telemetry.armed self.mode = self.ros_telemetry.mode @@ -913,10 +897,10 @@ class Telemetry: self.update_telemetry_fast() self.check_failsafe_and_interruption() - if client.active_client.TELEM_TRANSMIT and client.active_client.connected: + if client.active_client.config.telemetry_transmit and client.active_client.connected: self.transmit_message() - if client.active_client.LOG_CPU_AND_MEMORY: + if client.active_client.config.telemetry_log_resources: self.log_cpu_and_memory() rate.sleep() @@ -928,9 +912,9 @@ class Telemetry: rate.sleep() def start_loop(self): - if client.active_client.TELEM_FREQ > 0: + if client.active_client.config.telemetry_frequency > 0: telemetry_thread = threading.Thread(target=self._update_loop, name="Telemetry getting thread", - args=(client.active_client.TELEM_FREQ,)) # TODO MOVE? Daemon? + args=(client.active_client.config.telemetry_frequency,)) # TODO MOVE? Daemon? slow_telemetry_thread = threading.Thread(target=self._slow_update_loop, name="Slow telemetry getting thread") slow_telemetry_thread.start() From 1754ac09a8925d87f392b2a0c52d391b4c22db71 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 14 Jan 2020 15:26:58 +0300 Subject: [PATCH 071/210] New copter table changes started --- Server/copter_table_models.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 39d23d3..f5b0840 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -188,11 +188,25 @@ def check_time_delta(item): return abs(item) < ModelChecks.time_delta_max +columns_names = {'copter_id': 'copter ID', + 'git_ver': 'version', + 'animation_id': ' animation ID ', + 'battery': ' battery ', + 'system_status': ' system ', + 'calibration_status': 'sensors', + 'mode': ' mode ', + 'selfcheck': ' checks ', + 'current_position': 'current x y z yaw frame_id', + 'start_position': ' start x y z ', + 'time_delta': 'dt' + } + + class CopterData: - class_basic_attrs = indexed.IndexedOrderedDict([('copter_id', None), ('git_ver', None), ('anim_id', None), - ('battery', None), ('sys_status', None), ('cal_status', None), - ('mode', None), ('selfcheck', None), ('position', None), - ('start_pos', None), ('time_delta', None), ('client', None)]) + class_basic_attrs = indexed.IndexedOrderedDict([('copter_id', None), ('git_ver', None), ('animation_id', None), + ('battery', None), ('system_status', None), ('calibration_status', None), + ('mode', None), ('selfcheck', None), ('current_position', None), + ('start_position', None), ('time_delta', None), ('client', None)]) def __init__(self, **kwargs): self.attrs_dict = self.class_basic_attrs.copy() @@ -214,7 +228,7 @@ class StatedCopterData(CopterData): def __init__(self, checks_class=ModelChecks, **kwargs): self.states = CopterData(**self.class_basic_states) - self.checks = ModelChecks + self.checks = checks_class super(StatedCopterData, self).__init__(**kwargs) @@ -225,10 +239,10 @@ class StatedCopterData(CopterData): try: self.states.__dict__[key] = \ ModelChecks.checks_dict[self.attrs_dict.keys().index(key)](value) - if key == 'start_pos': - if (self.__dict__['position'] is not None) and (self.__dict__['start_pos'] is not None): + if key == 'start_position': + if (self.__dict__['position'] is not None) and (self.__dict__['start_position'] is not None): current_pos = get_position(self.__dict__['position']) - start_pos = get_position(self.__dict__['start_pos']) + start_pos = get_position(self.__dict__['start_position']) delta = get_position_delta(current_pos, start_pos) if delta != 'NO_POS': self.states.__dict__[key] = (delta < ModelChecks.start_pos_delta_max) @@ -374,8 +388,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): def __init__(self, checks=ModelChecks, formatter=ModelFormatter, parent=None): super(CopterDataModel, self).__init__(parent) - self.headers = ('copter ID', 'version', ' animation ID ', ' battery ', ' system ', 'sensors', - ' mode ', ' checks ', 'current x y z yaw frame_id', ' start x y z ', 'dt') + self.headers = list(columns_names.values()) self.data_contents = [] self.checks = checks @@ -520,6 +533,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): if col == 0: self.data_contents[row].client.send_message("id", {"new_id": formatted_value}) self.data_contents[row].client.remove() # TODO change + self.remove_row(row) elif role == ModelDataRole: # For inner setting\editing of data self.data_contents[row][col] = value From 45652aa46b9d77c565ceb8340b22da24f2beb025 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 14 Jan 2020 16:28:20 +0300 Subject: [PATCH 072/210] Config editor auto-resizing on start --- Server/config_editor_models.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 5cb3783..c9c8258 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -699,6 +699,8 @@ class ConfigDialog(QtWidgets.QDialog): self.model.config_dict_setup(data, convert_types=convert_types) self.ui.config_view.expandAll() + self.ui.config_view.resizeColumnToContents(0) + self.ui.config_view.resizeColumnToContents(1) def setupUi(self): self.ui.setupUi(self) From d654dba7fa830e27e15d7b623719dd559000465f Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Tue, 14 Jan 2020 18:17:26 +0300 Subject: [PATCH 073/210] Update configspec_client.ini --- Drone/config/spec/configspec_client.ini | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index 37038bb..0a6f063 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -2,17 +2,17 @@ config_name = string(default='Copter config') config_version = float(default=0.0) [SERVER] -port = integer(default=25000) +port = integer(default=25000, min=1) host = ip_addr(default=192.168.1.101) # string? buffer_size = integer(default=1024) [BROADCAST] use = boolean(default=True) -port = integer(default=8181) +port = integer(default=8181, min=1) [TELEMETRY] transmit = boolean(default=True) -frequency = float(default=1.0) +frequency = float(default=1.0, min=0) log_resources = boolean(default=True) [VISUAL POSE WATCHDOG] @@ -31,9 +31,10 @@ frame_id = string(default=map) takeoff_height = float(default=1.0) takeoff_time = float(default=5.0, min=0) safe_takeoff = boolean(default=False) -reach_first_point_time = float(default=5.0) +reach_first_point_time = float(default=5.0, min=0) land_time = float(default=1.0, min=0) land_timeout = float(default=10.0, min=0) +# __list__ x y z common_offset = float_list(default=list(0, 0, 0), min=3, max=3) [FLOOR FRAME] @@ -75,4 +76,4 @@ restart_after_rename = boolean(default=True) [NTP] use = boolean(default=False) host = string(default=ntp1.stratum2.ru) -port = integer(default=123) +port = integer(default=123, min=1) From 45b08f2e3fa69cdb314780a96ef0185f2de08737 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 17 Jan 2020 19:55:59 +0300 Subject: [PATCH 074/210] Fix of possible exceptions with signal disconnection --- Server/copter_table.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index 2f9b1ac..0d89830 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -1,5 +1,6 @@ from functools import partial from copy import deepcopy +import logging from PyQt5 import QtWidgets, QtCore, QtGui from PyQt5.QtCore import Qt as Qt @@ -44,7 +45,7 @@ class CopterTableWidget(QTableView): self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.open_menu) - self._signal_connection = None + self.config_dialog_signal.connect(lambda x: x) # Adjust properties self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) @@ -120,12 +121,14 @@ class CopterTableWidget(QTableView): @pyqtSlot() def edit_config(self, copter): - if self._signal_connection is not None: - self.config_dialog_signal.disconnect(self._signal_connection) - - call = ConfigDialog().call_copter_dialog - self._signal_connection = self.config_dialog_signal.connect(call) - copter.client.get_response("config", self.config_dialog_signal.emit) + try: + self.config_dialog_signal.disconnect() + except (TypeError, RuntimeError) as error: + logging.error(f"Disconnection of signal failed: {error}") + else: + call = ConfigDialog().call_copter_dialog + self.config_dialog_signal.connect(call) + copter.client.get_response("config", self.config_dialog_signal.emit) # def _selfcheck_shortener(self, data): # TODO!!! # shortened = [] From 4431fb7e20b5922624c3ab2a37250d52ed0e7024 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 20 Jan 2020 14:55:08 +0000 Subject: [PATCH 075/210] Drone: Change type of log messages in animation lib --- Drone/animation_lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Drone/animation_lib.py b/Drone/animation_lib.py index 3906ac2..c78cbda 100644 --- a/Drone/animation_lib.py +++ b/Drone/animation_lib.py @@ -36,7 +36,7 @@ def get_id(filepath="animation.csv"): try: animation_file = open(filepath) except IOError: - logger.error("File {} can't be opened".format(filepath)) + logger.debug("File {} can't be opened".format(filepath)) anim_id = "No animation" return anim_id else: @@ -57,7 +57,7 @@ def get_start_xy(filepath="animation.csv", x_ratio=1, y_ratio=1, z_ratio=1): try: animation_file = open(filepath) except IOError: - logger.error("File {} can't be opened".format(filepath)) + logger.debug("File {} can't be opened".format(filepath)) anim_id = "No animation" return float('nan'), float('nan') else: @@ -89,7 +89,7 @@ def load_animation(filepath="animation.csv", x0=0, y0=0, z0=0, x_ratio=1, y_rati try: animation_file = open(filepath) except IOError: - logging.error("File {} can't be opened".format(filepath)) + logger.debug("File {} can't be opened".format(filepath)) anim_id = "No animation" else: with animation_file: From 106abf2fa0a31e5b6e0d2327888621024394a9f4 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 20 Jan 2020 17:57:06 +0300 Subject: [PATCH 076/210] Server: Change colors in config editor --- Server/config_editor_models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index c9c8258..20f9259 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -5,7 +5,7 @@ from functools import partial from copy import deepcopy from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtCore import Qt as Qt, pyqtSlot +from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu, QAction, QMessageBox, QInputDialog, QFileDialog @@ -23,8 +23,8 @@ import config states_colors = { 'normal': Qt.white, - 'unchanged': Qt.darkGray, - 'default': Qt.lightGray, + 'unchanged': Qt.blue, + 'default': Qt.cyan, 'edited': Qt.yellow, 'added': Qt.green, 'deleted': Qt.red, From 69e74ba22dc58fd280301006c71f6d0537dd0e37 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 20 Jan 2020 19:32:44 +0300 Subject: [PATCH 077/210] Fixed partial with .clicked behaviour --- Server/server_qt.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 77f273d..241bf47 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -34,6 +34,10 @@ def multi_glob(*patterns): return itertools.chain.from_iterable(glob.iglob(pattern) for pattern in patterns) +def b_partial(func, *args, **kwargs): # call argument blocker partial + return lambda *a: func(*args, **kwargs) + + def confirmation_required(text="Are you sure?", label="Confirm operation?"): def inner(f): @wraps(f) @@ -100,16 +104,16 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.start_button.clicked.connect(self.send_start_time_selected) self.ui.pause_button.clicked.connect(self.pause_resume_selected) - self.ui.land_all_button.clicked.connect(partial(Client.broadcast, "land")) - self.ui.land_selected_button.clicked.connect(partial(self.send_to_selected, "land")) - self.ui.disarm_all_button.clicked.connect(partial(Client.broadcast, "disarm")) - self.ui.disarm_selected_button.clicked.connect(partial(self.send_to_selected, "disarm")) + self.ui.land_all_button.clicked.connect(b_partial(Client.broadcast, "land")) + self.ui.land_selected_button.clicked.connect(b_partial(self.send_to_selected, "land")) + self.ui.disarm_all_button.clicked.connect(b_partial(Client.broadcast, "disarm")) + self.ui.disarm_selected_button.clicked.connect(b_partial(self.send_to_selected, "disarm")) self.ui.visual_land_button.clicked.connect(self.visual_land) - self.ui.emergency_land_button.clicked.connect(partial(self.send_to_selected, "emergency_land")) - self.ui.leds_button.clicked.connect(partial(self.send_to_selected, "led_test")) + self.ui.emergency_land_button.clicked.connect(b_partial(self.send_to_selected, "emergency_land")) + self.ui.leds_button.clicked.connect(b_partial(self.send_to_selected, "led_test")) self.ui.takeoff_button.clicked.connect(self.takeoff_selected) self.ui.flip_button.clicked.connect(self.flip_selected) - self.ui.reboot_fcu.clicked.connect(partial(self.send_to_selected, "reboot_fcu")) + self.ui.reboot_fcu.clicked.connect(b_partial(self.send_to_selected, "reboot_fcu")) self.ui.calibrate_gyro.clicked.connect(self.calibrate_gyro_selected) self.ui.calibrate_level.clicked.connect(self.calibrate_level_selected) self.ui.action_remove_row.triggered.connect(self.remove_selected) @@ -122,16 +126,16 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_send_any_file.triggered.connect(self.send_any_file) self.ui.action_send_any_command.triggered.connect(self.send_any_command) self.ui.action_restart_clever.triggered.connect( - partial(self.send_to_selected, "service_restart", {"name": "clever"})) + b_partial(self.send_to_selected, "service_restart", {"name": "clever"})) self.ui.action_restart_clever_show.triggered.connect( - partial(self.send_to_selected, "service_restart", {"name": "clever-show"})) - self.ui.action_update_client_repo.triggered.connect(partial(self.send_to_selected, "update_repo")) - self.ui.action_reboot_all.triggered.connect(partial(self.send_to_selected, "reboot_all")) - self.ui.action_set_start_to_current_position.triggered.connect(partial(self.send_to_selected, "move_start")) - self.ui.action_reset_start.triggered.connect(partial(self.send_to_selected, "reset_start")) - self.ui.action_set_z_offset_to_ground.triggered.connect(partial(self.send_to_selected, "set_z_to_ground")) - self.ui.action_reset_z_offset.triggered.connect(partial(self.send_to_selected, "reset_z_offset")) - self.ui.action_restart_chrony.triggered.connect(partial(self.send_to_selected, "repair_chrony")) + b_partial(self.send_to_selected, "service_restart", {"name": "clever-show"})) + self.ui.action_update_client_repo.triggered.connect(b_partial(self.send_to_selected, "update_repo")) + self.ui.action_reboot_all.triggered.connect(b_partial(self.send_to_selected, "reboot_all")) + self.ui.action_set_start_to_current_position.triggered.connect(b_partial(self.send_to_selected, "move_start")) + self.ui.action_reset_start.triggered.connect(b_partial(self.send_to_selected, "reset_start")) + self.ui.action_set_z_offset_to_ground.triggered.connect(b_partial(self.send_to_selected, "set_z_to_ground")) + self.ui.action_reset_z_offset.triggered.connect(b_partial(self.send_to_selected, "reset_z_offset")) + self.ui.action_restart_chrony.triggered.connect(b_partial(self.send_to_selected, "repair_chrony")) self.ui.action_select_music_file.triggered.connect(self.select_music_file) self.ui.action_play_music.triggered.connect(self.play_music) self.ui.action_stop_music.triggered.connect(self.stop_music) @@ -180,8 +184,8 @@ class MainWindow(QtWidgets.QMainWindow): yield f(copter, *args, **kwargs) @pyqtSlot() - def send_to_selected(self, *args, **kwargs): - return list(self.iterate_selected(lambda copter: copter.client.send_message(*args, **kwargs))) + def send_to_selected(self, command, command_args=None): + return list(self.iterate_selected(lambda copter: copter.client.send_message(command, command_args))) def new_client_connected(self, client: Client): logging.debug("Added client {}".format(client)) From 4acde114018da918d10569f38c3ef4beb15f651d Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 20 Jan 2020 19:38:15 +0300 Subject: [PATCH 078/210] Added restart checkbox functionality for config editor --- Server/config_editor_models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 20f9259..4690eb6 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -759,6 +759,10 @@ class ConfigDialog(QtWidgets.QDialog): edited_dict = self.model.to_config_dict() client.send_message("config", {"config": edited_dict, "mode": "rewrite"}) + + if self.ui.do_restart.isChecked(): + client.send_message("service_restart", {"name": "clever-show"}) + return True def call_standalone_dialog(self): @@ -776,6 +780,7 @@ class ConfigDialog(QtWidgets.QDialog): return False self.setupModel(cfg.full_dict, convert_types=(not cfg.validated)) + self.ui.do_restart.setDisabled(True) filename = cfg.config.filename validation_path = path if cfg.config.filename is None else cfg.config.filename From fc57f6abaedae4e5ef1445573ef72290ee002baf Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 20 Jan 2020 18:16:12 +0000 Subject: [PATCH 079/210] Client: Add current task information to telemetry --- Drone/copter_client.py | 19 ++++++++++--------- Drone/tasking_lib.py | 19 +++++++++++++++++-- 2 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 18a0f3d..c9d1aa5 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -690,12 +690,13 @@ class Telemetry: "animation_id": None, "battery": None, "armed": False, - "system_status": None, - "calibration_status": None, + "fcu_status": None, + "cal_status": None, "mode": None, "selfcheck": None, "current_position": None, "start_position": None, + "task": None, "time": None, } @@ -779,6 +780,7 @@ class Telemetry: def update_telemetry_fast(self): self.start_position = self.get_start_position() + self.task = task_manager.get_current_task() try: self.ros_telemetry = FlightLib.get_telemetry_locked(client.active_client.config.copter_frame_id) if self.ros_telemetry.connected: @@ -802,8 +804,8 @@ class Telemetry: self.animation_id = animation.get_id() self.git_version = self.get_git_version() try: - self.calibration_status = get_calibration_status() - self.system_status = get_sys_status() + self.cal_status = get_calibration_status() + self.fcu_status = get_sys_status() self.battery = self.get_battery(self.ros_telemetry) except rospy.ServiceException: rospy.logdebug("Some service is unavailable") @@ -825,8 +827,8 @@ class Telemetry: def reset_telemetry_values(self): self.battery = float('nan'), float('nan') - self.calibration_status = 'NO_FCU' - self.system_status = 'NO_FCU' + self.cal_status = 'NO_FCU' + self.fcu_status = 'NO_FCU' self.mode = 'NO_FCU' self.selfcheck = ['NO_FCU'] self.current_position = 'NO_POS' @@ -900,15 +902,14 @@ class Telemetry: if client.active_client.config.telemetry_transmit and client.active_client.connected: self.transmit_message() - if client.active_client.config.telemetry_log_resources: - self.log_cpu_and_memory() - rate.sleep() def _slow_update_loop(self): rate = rospy.Rate(1) while not rospy.is_shutdown(): self.update_telemetry_slow() + if client.active_client.config.telemetry_log_resources: + self.log_cpu_and_memory() rate.sleep() def start_loop(self): diff --git a/Drone/tasking_lib.py b/Drone/tasking_lib.py index 68b06de..1827c3c 100644 --- a/Drone/tasking_lib.py +++ b/Drone/tasking_lib.py @@ -89,6 +89,21 @@ class TaskManager(object): def get_last_task_name(self): return self._last_task + + def get_current_task(self): + try: + start_time, priority, count, task = self.task_queue[0] + except IndexError as e: + logger.debug("Task queue checking exception: {}".format(e)) + return "No task" + else: + if self._running_event.is_set(): + time_to_start = start_time - time.time() + if time_to_start > 0: + return "{} in {:.1f} s".format(task.func.__name__,time_to_start) + return task.func.__name__ + else: + return "paused" def start(self): #print("Task manager is started") @@ -138,7 +153,7 @@ class TaskManager(object): with self._task_queue_lock: try: start_time, priority, count, task = self.task_queue[0] - except Exception as e: + except IndexError as e: logger.debug("Task queue checking exception: {}".format(e)) self._timeshift = 0.0 self._wait_interrupt_event.clear() @@ -180,7 +195,7 @@ class TaskManager(object): if time.time() > start_time: try: start_time_n, priority_n, count_n, task_n = self.task_queue[0] - except Exception as e: + except IndexError as e: logger.warning("Timeout checking exception: {}".format(e)) self._timeshift = 0.0 self._wait_interrupt_event.clear() From eba6412d44a4b238767c079ee64fda1c7fb03236 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 20 Jan 2020 21:19:51 +0300 Subject: [PATCH 080/210] Server: Add ability to view task information in table --- Server/copter_table_models.py | 111 ++++++---------------------------- Server/server_qt.py | 7 ++- 2 files changed, 21 insertions(+), 97 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 2f5da09..8f8ba84 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -96,94 +96,11 @@ def check_pos_status(item): def check_start_pos_status(item): return item != 'NO_POS' - @ModelChecks.col_check(10) -def check_time_delta(item): - return abs(item) < ModelChecks.time_delta_max - - -class ModelChecks: - checks_dict = {} - takeoff_checklist = (3, 4, 6, 7, 8) - - @classmethod - def col_check(cls, col): - def inner(f): - def wrapper(item): - if item is not None: - return f(item) - return None - - cls.checks_dict[col] = wrapper - return wrapper - - return inner - - @classmethod - def all_checks(cls, copter_item): - for col, check in cls.checks_dict.items(): - if not check(copter_item[col]): - return False - return True - - @classmethod - def takeoff_checks(cls, copter_item): - for col in cls.takeoff_checklist: - if not cls.checks_dict[col](copter_item[col]): - return False - return True - - -@ModelChecks.col_check(1) -def check_ver(item): - return True # TODO git version! - - -@ModelChecks.col_check(2) -def check_anim(item): - return str(item) != 'No animation' - - -@ModelChecks.col_check(3) -def check_bat(item): - if item == "NO_INFO": - return False - return item[1]*100 > ModelChecks.battery_min - - -@ModelChecks.col_check(4) -def check_sys_status(item): - return item == "STANDBY" - - -@ModelChecks.col_check(5) -def check_cal_status(item): - return item == "OK" - - -@ModelChecks.col_check(6) -def check_mode(item): - return (item != "NO_FCU") and not ("CMODE" in item) - - -@ModelChecks.col_check(7) def check_selfcheck(item): - return item == "OK" + return True - -@ModelChecks.col_check(8) -def check_pos_status(item): - if item == 'NO_POS': - return False - return not math.isnan(item[0]) - - -@ModelChecks.col_check(9) -def check_start_pos_status(item): - return item != 'NO_POS' - - -@ModelChecks.col_check(10) +@ModelChecks.col_check(11) def check_time_delta(item): return abs(item) < ModelChecks.time_delta_max @@ -192,21 +109,22 @@ columns_names = {'copter_id': 'copter ID', 'git_ver': 'version', 'animation_id': ' animation ID ', 'battery': ' battery ', - 'system_status': ' system ', - 'calibration_status': 'sensors', + 'fcu_status': 'FCU status', + 'cal_status': 'sensors', 'mode': ' mode ', 'selfcheck': ' checks ', 'current_position': 'current x y z yaw frame_id', 'start_position': ' start x y z ', + 'last_task': 'last task', 'time_delta': 'dt' } class CopterData: class_basic_attrs = indexed.IndexedOrderedDict([('copter_id', None), ('git_ver', None), ('animation_id', None), - ('battery', None), ('system_status', None), ('calibration_status', None), + ('battery', None), ('fcu_status', None), ('cal_status', None), ('mode', None), ('selfcheck', None), ('current_position', None), - ('start_position', None), ('time_delta', None), ('client', None)]) + ('start_position', None), ('last_task', None), ('time_delta', None), ('client', None)]) def __init__(self, **kwargs): self.attrs_dict = self.class_basic_attrs.copy() @@ -370,11 +288,16 @@ def view_selfcheck(value): return value @ModelFormatter.col_format(10, ModelFormatter.PLACE_FORMATTER) +def view_last_task(value): + if value is None: + return 'No task' + return value + +@ModelFormatter.col_format(11, ModelFormatter.PLACE_FORMATTER) def place_time_delta(value): - return abs(value - time.time()) + return abs(value - time.time()) - -@ModelFormatter.col_format(10, ModelFormatter.VIEW_FORMATTER) +@ModelFormatter.col_format(11, ModelFormatter.VIEW_FORMATTER) def view_time_delta(value): return "{:.3f}".format(value) @@ -389,8 +312,8 @@ class CopterDataModel(QtCore.QAbstractTableModel): def __init__(self, checks=ModelChecks, formatter=ModelFormatter, parent=None): super(CopterDataModel, self).__init__(parent) # self.headers = list(columns_names.values()) # todo - self.headers = (' copter ID ', ' version ', ' animation ID ', ' battery ', ' system ', ' sensors ', - ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' dt ') + self.headers = (' copter ID ', ' version ', ' animation ID ', ' battery ', ' fcu_status ', ' sensors ', + ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' task ', ' dt ') self.data_contents = [] self.checks = checks diff --git a/Server/server_qt.py b/Server/server_qt.py index 241bf47..438d0ba 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -220,13 +220,14 @@ class MainWindow(QtWidgets.QMainWindow): "git_version": 1, "animation_id": 2, "battery": 3, - "system_status": 4, - "calibration_status": 5, + "fcu_status": 4, + "cal_status": 5, "mode": 6, "selfcheck": 7, "current_position": 8, "start_position": 9, - "time": 10, + "task": 10, + "time": 11, } for key, value in telems.items(): From 0aad314ffca611307f7d5f43c81346a427ead6b2 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 20 Jan 2020 21:25:53 +0300 Subject: [PATCH 081/210] Fix broadcast usage error --- Server/server_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 241bf47..e1df7d6 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -104,9 +104,9 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.start_button.clicked.connect(self.send_start_time_selected) self.ui.pause_button.clicked.connect(self.pause_resume_selected) - self.ui.land_all_button.clicked.connect(b_partial(Client.broadcast, "land")) + self.ui.land_all_button.clicked.connect(b_partial(Client.broadcast_message, "land")) self.ui.land_selected_button.clicked.connect(b_partial(self.send_to_selected, "land")) - self.ui.disarm_all_button.clicked.connect(b_partial(Client.broadcast, "disarm")) + self.ui.disarm_all_button.clicked.connect(b_partial(Client.broadcast_message, "disarm")) self.ui.disarm_selected_button.clicked.connect(b_partial(self.send_to_selected, "disarm")) self.ui.visual_land_button.clicked.connect(self.visual_land) self.ui.emergency_land_button.clicked.connect(b_partial(self.send_to_selected, "emergency_land")) From f24e1512a42d06b0549f32f7880becca3d8cb62f Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 20 Jan 2020 21:50:49 +0300 Subject: [PATCH 082/210] Update signals in calibration to new API --- Server/server_qt.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 8983d8c..5e81626 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -298,7 +298,7 @@ class MainWindow(QtWidgets.QMainWindow): row = self.model.get_row_index(copter_data_row) col = 5 data = 'CALIBRATING' - self.signals.update_data_signal.emit(row, col, data, table.ModelDataRole) + self.ui.copter_table.update_data(row, col, data, table.ModelDataRole) # Send request client.get_response("calibrate_gyro", self._get_calibration_info) @@ -310,7 +310,7 @@ class MainWindow(QtWidgets.QMainWindow): row = self.model.get_row_index(copter_data_row) col = 5 data = 'CALIBRATING' - self.signals.update_data_signal.emit(row, col, data, table.ModelDataRole) + self.ui.copter_table.update_data(row, col, data, table.ModelDataRole) # Send request client.get_response("calibrate_level", self._get_calibration_info) @@ -320,7 +320,7 @@ class MainWindow(QtWidgets.QMainWindow): row = self.model.get_row_index(row_data) if row is not None: data = str(value) - self.signals.update_data_signal.emit(row, col, data, table.ModelDataRole) + self.ui.copter_table.update_data(row, col, data, table.ModelDataRole) def _send_files(self, files, copters=None, client_path="", client_filename="", match_id=False, callback=None): if copters is None: From ae11bb2cd1a2fe288821be47633837a3b9f4f56d Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 20 Jan 2020 19:18:45 +0000 Subject: [PATCH 083/210] Client: Remove old config files --- Drone/client_config.ini | 72 --------------------------------- Drone/default_clinet_config.ini | 19 --------- 2 files changed, 91 deletions(-) delete mode 100644 Drone/client_config.ini delete mode 100644 Drone/default_clinet_config.ini diff --git a/Drone/client_config.ini b/Drone/client_config.ini deleted file mode 100644 index eab1781..0000000 --- a/Drone/client_config.ini +++ /dev/null @@ -1,72 +0,0 @@ -[SERVER] -port = 25000 -broadcast_port = 8181 -host = 192.168.1.19 -buffer_size = 10000 - -[FILETRANSFER] -files_directory = animation -animation_file = animation.csv - -[NTP] -use_ntp = False -host = ntp1.stratum2.ru -port = 123 - -[VISUAL_POSE_WATCHDOG] -timeout = 1.0 -pos_delta_max = 3.0 -action = emergency_land -emergency_land_thrust = 0.45 -emergency_land_decrease_thrust_after = 5.0 -timeout_to_disarm = 10.0 - -[TELEMETRY] -transmit = True -land_if_pos_delta_bigger_than = 3.0 -log_cpu_and_memory = True - -[COPTERS] -frame_id = map -takeoff_height = 1.0 -takeoff_time = 5.0 -safe_takeoff = False -reach_first_point_time = 5.0 -land_time = 1.0 -x0_common = 0 -y0_common = 0 -z0_common = 0 -yaw = 180 -land_timeout = 10.0 - -[FLOOR FRAME] -parent = aruco_map -x = 2.4 -y = 12.4 -z = 6.4 -roll = 180 -pitch = 0 -yaw = -90 - -[ANIMATION] -takeoff_animation_check = True -land_animation_check = True -frame_delay = 0.1 -x_ratio = 1.0 -y_ratio = 1.0 -z_ratio = 1.0 - -[PRIVATE] -id = /hostname -restart_after_rename = True -use_leds = True -led_pin = 21 -x0 = 0 -y0 = 0 -z0 = 0 - -[NTP] -use_ntp = False -host = ntp1.stratum2.ru -port = 123 - diff --git a/Drone/default_clinet_config.ini b/Drone/default_clinet_config.ini deleted file mode 100644 index 6a05e75..0000000 --- a/Drone/default_clinet_config.ini +++ /dev/null @@ -1,19 +0,0 @@ -# DO NOT EDIT this file in production -# Use non-default config_attrs files for configuration purposes - -[SERVER] -port = 25000 -host = 192.168.1.101 -buffer_size = 1024 - -[BROADCAST] -use = True -port = 8181 - -[NTP] -use = False -host = ntp1.stratum2.ru -port = 123 - -[PRIVATE] -id = /hostname \ No newline at end of file From 0c21c12581434e8a522580d2d74e67988e55ee3b Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 20 Jan 2020 19:22:30 +0000 Subject: [PATCH 084/210] Update .gitignore --- .gitignore | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 8d782f8..62d0bf4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,15 @@ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] *$py.class # Logs *.log +# Config files +Drone/config/client.ini +Server/config/server.ini + # IDE .mypy_cache/ .vscode/settings.json @@ -18,6 +19,8 @@ __pycache__/ # Development images/ show-env/ +builder/clever-config + Server/tests.py Server/convert_ui.sh Server/config/server.ini @@ -32,10 +35,3 @@ Drone/client_logs Drone/config/client.ini Drone/_copter_client_old_\.py Drone/test_cl\.py - -images/ -.vscode/ -\.idea/ -builder/clever-config - - From 056084db8b5e62a6986cdfa5470b04024a58909f Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 20 Jan 2020 19:25:19 +0000 Subject: [PATCH 085/210] Drone: remove client config file --- Drone/config/client.ini | 80 ----------------------------------------- 1 file changed, 80 deletions(-) delete mode 100644 Drone/config/client.ini diff --git a/Drone/config/client.ini b/Drone/config/client.ini deleted file mode 100644 index 5faa4c5..0000000 --- a/Drone/config/client.ini +++ /dev/null @@ -1,80 +0,0 @@ -# This is generated config with default values -# Modify to configure -config_name = Copter config -config_version = 0.0 - -[SERVER] -port = 25000 -host = 192.168.1.101 -buffer_size = 1024 - -[BROADCAST] -use = True -port = 8181 - -[TELEMETRY] -transmit = True -frequency = 1.0 -log_resources = True - -[VISUAL POSE WATCHDOG] -timeout = 1.0 -pos_delta_max = 3.0 -# Available options: emergency_land, land, disarm -action = emergency_land - -[EMERGENCY LAND] -thrust = 0.45 -decrease_thrust_after = 5.0 -disarm_timeout = 10.0 - -[COPTER] -frame_id = map -takeoff_height = 1.0 -takeoff_time = 5.0 -safe_takeoff = False -reach_first_point_time = 5.0 -land_time = 1.0 -land_timeout = 10.0 -common_offset = 0.0, 0.0, 0.0 - -[FLOOR FRAME] -enabled = False -parent = map -# Frame translation (x, y, z) -# __list__ x y z -translation = 0.0, 0.0, 0.0 -# Frame rotation (roll, pitch, yaw) in degrees -# __list__ roll pitch yaw -rotation = 0.0, 0.0, 0.0 - -[ANIMATION] -takeoff_detection = True -land_detection = True -frame_delay = 0.1 -# Animation ratio (x, y, z) -# __list__ x y z -ratio = 1.0, 1.0, 1.0 -# Available options: 'animation', 'nan' or a number in degrees -yaw = 180.0 - -[LED] -use = True -pin = 21 -count = 60 - -[PRIVATE] -# Available options: /hostname ; /default ; /ip ; any string 63 characters length -id = /hostname -# Drone's individual offset (x, y, z) -# __list__ x y z -offset = 0.0, 0.0, 0.0 - -[SYSTEM] -change_hostname = True -restart_after_rename = True - -[NTP] -use = False -host = ntp1.stratum2.ru -port = 123 From c7fd283f19f43d81496e58d9abf726f7862e3b40 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 20 Jan 2020 19:25:41 +0000 Subject: [PATCH 086/210] Update .gitignore --- .gitignore | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.gitignore b/.gitignore index 62d0bf4..2032e7f 100644 --- a/.gitignore +++ b/.gitignore @@ -6,10 +6,6 @@ __pycache__/ # Logs *.log -# Config files -Drone/config/client.ini -Server/config/server.ini - # IDE .mypy_cache/ .vscode/settings.json From 2f3e2bb987459ddde9d2b7b4aeffdd5d655797e3 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 20 Jan 2020 22:26:27 +0300 Subject: [PATCH 087/210] Server: Change columns sizes --- Server/copter_table_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 8f8ba84..17dd975 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -313,7 +313,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): super(CopterDataModel, self).__init__(parent) # self.headers = list(columns_names.values()) # todo self.headers = (' copter ID ', ' version ', ' animation ID ', ' battery ', ' fcu_status ', ' sensors ', - ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' task ', ' dt ') + ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' task ', ' dt ') self.data_contents = [] self.checks = checks From abfe7e86cd9a6468a7a12d37c89705c42fe10537 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 21 Jan 2020 03:14:49 +0000 Subject: [PATCH 088/210] Drone: Fix bugs --- Drone/animation_lib.py | 7 +------ Drone/copter_client.py | 16 +++++++++------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/Drone/animation_lib.py b/Drone/animation_lib.py index c78cbda..2018fdb 100644 --- a/Drone/animation_lib.py +++ b/Drone/animation_lib.py @@ -21,11 +21,6 @@ logger = logging.getLogger(__name__) interrupt_event = threading.Event() -config = ConfigParser.ConfigParser() -config.read("client_config.ini") - -default_delay = config.getfloat('ANIMATION', 'frame_delay') - anim_id = "Empty id" # TODO refactor as class @@ -83,7 +78,7 @@ def get_start_xy(filepath="animation.csv", x_ratio=1, y_ratio=1, z_ratio=1): return float(x)*x_ratio, float(y)*y_ratio -def load_animation(filepath="animation.csv", x0=0, y0=0, z0=0, x_ratio=1, y_ratio=1, z_ratio=1): +def load_animation(filepath="animation.csv", default_delay = 0.1, x0=0, y0=0, z0=0, x_ratio=1, y_ratio=1, z_ratio=1): imported_frames = [] global anim_id try: diff --git a/Drone/copter_client.py b/Drone/copter_client.py index c9d1aa5..e4be387 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -135,7 +135,7 @@ class CopterClient(client.Client): if self.config.floor_frame_enabled: self.start_floor_frame_broadcast() else: - rospy.logerror("Can't make floor frame!") + rospy.logerr("Can't make floor frame!") start_subscriber() telemetry.start_loop() @@ -143,9 +143,9 @@ class CopterClient(client.Client): def start_floor_frame_broadcast(self): trans = TransformStamped() - trans.transform.translation.x = self.config.floor_frame_transtation[0] - trans.transform.translation.y = self.config.floor_frame_transtation[1] - trans.transform.translation.z = self.config.floor_frame_transtation[2] + trans.transform.translation.x = self.config.floor_frame_translation[0] + trans.transform.translation.y = self.config.floor_frame_translation[1] + trans.transform.translation.z = self.config.floor_frame_translation[2] trans.transform.rotation = Quaternion(*quaternion_from_euler(math.radians(self.config.floor_frame_rotation[0]), math.radians(self.config.floor_frame_rotation[1]), math.radians(self.config.floor_frame_rotation[2]))) @@ -332,7 +332,8 @@ def _response_animation_id(*args, **kwargs): if result != 'No animation': logger.debug("Saving corrected animation") offset = numpy.array(client.active_client.config.private_offset) + numpy.array(client.active_client.config.copter_common_offset) - frames = animation.load_animation(os.path.abspath("animation.csv"), offset[0], offset[1], offset[2], *client.active_client.config.animation_ratio) + frames = animation.load_animation(os.path.abspath("animation.csv"), client.active_client.config.animation_frame_delay, + offset[0], offset[1], offset[2], *client.active_client.config.animation_ratio) # Correct start and land frames in animation corrected_frames, start_action, start_delay = animation.correct_animation(frames, check_takeoff=client.active_client.config.animation_takeoff_detection, @@ -597,7 +598,8 @@ def _play_animation(*args, **kwargs): logger.info("Start time = {}, wait for {} seconds".format(start_time, start_time - time.time())) # Load animation offset = numpy.array(client.active_client.config.private_offset) + numpy.array(client.active_client.config.copter_common_offset) - frames = animation.load_animation(os.path.abspath("animation.csv"), offset[0], offset[1], offset[2], *client.active_client.config.animation_ratio) + frames = animation.load_animation(os.path.abspath("animation.csv"), client.active_client.config.animation_frame_delay, + offset[0], offset[1], offset[2], *client.active_client.config.animation_ratio) # Correct start and land frames in animation corrected_frames, start_action, start_delay = animation.correct_animation(frames, check_takeoff=client.active_client.config.animation_takeoff_detection, @@ -651,7 +653,7 @@ def _play_animation(*args, **kwargs): } ) # Calculate first frame start time - frame_time += client.active_client.config.animation_frame_delay # TODO Think about arming time + frame_time += corrected_frames[0]["delay"] # TODO Think about arming time logger.debug(task_manager.task_queue) # Play animation file for frame in corrected_frames: From d08bbc3b727c0c5071065d5a5fd7e9646c21c267 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 21 Jan 2020 06:16:43 +0300 Subject: [PATCH 089/210] Server: Fix start position delta detection --- Server/copter_table_models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 17dd975..95e4627 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -158,8 +158,8 @@ class StatedCopterData(CopterData): self.states.__dict__[key] = \ ModelChecks.checks_dict[self.attrs_dict.keys().index(key)](value) if key == 'start_position': - if (self.__dict__['position'] is not None) and (self.__dict__['start_position'] is not None): - current_pos = get_position(self.__dict__['position']) + if (self.__dict__['current_position'] is not None) and (self.__dict__['start_position'] is not None): + current_pos = get_position(self.__dict__['current_position']) start_pos = get_position(self.__dict__['start_position']) delta = get_position_delta(current_pos, start_pos) if delta != 'NO_POS': @@ -313,7 +313,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): super(CopterDataModel, self).__init__(parent) # self.headers = list(columns_names.values()) # todo self.headers = (' copter ID ', ' version ', ' animation ID ', ' battery ', ' fcu_status ', ' sensors ', - ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' task ', ' dt ') + ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' task ', 'dt') self.data_contents = [] self.checks = checks From 28d1213456750ef5df114c01b681089787a50965 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 21 Jan 2020 06:18:27 +0300 Subject: [PATCH 090/210] Server: Fix logger and send launch files --- Server/server.py | 2 ++ Server/server_qt.py | 67 ++++++++++++++++++++++++++++----------------- 2 files changed, 44 insertions(+), 25 deletions(-) diff --git a/Server/server.py b/Server/server.py index 373aced..94be295 100644 --- a/Server/server.py +++ b/Server/server.py @@ -31,6 +31,8 @@ if not os.path.exists(log_path): else: print("Successfully created the directory {}".format(log_path)) +logger = logging.getLogger(__name__) + ConfigOption = collections.namedtuple("ConfigOption", ["section", "option", "value"]) diff --git a/Server/server_qt.py b/Server/server_qt.py index 5e81626..6d9cea1 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -29,6 +29,23 @@ from copter_table import CopterTableWidget from visual_land_dialog import VisualLandDialog from config_editor_models import ConfigDialog +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s [%(name)-7.7s] [%(threadName)-19.19s] [%(levelname)-7.7s] %(message)s", + handlers=[ + logging.FileHandler("server_logs/{}.log".format(now)), + logging.StreamHandler(sys.stdout) + ]) + +handler = logging.StreamHandler(sys.stdout) +handler.setLevel(logging.DEBUG) +formatter = logging.Formatter("%(asctime)s [%(name)-7.7s] [%(threadName)-12.12s] [%(levelname)-5.5s] %(message)s") +handler.setFormatter(formatter) + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) +logger.addHandler(handler) + def multi_glob(*patterns): return itertools.chain.from_iterable(glob.iglob(pattern) for pattern in patterns) @@ -48,10 +65,10 @@ def confirmation_required(text="Are you sure?", label="Confirm operation?"): QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply == QMessageBox.Yes: - logging.debug("Dialog accepted") + logger.debug("Dialog accepted") return f(*args, **kwargs) - logging.debug("Dialog declined") + logger.debug("Dialog declined") return wrapper @@ -188,26 +205,26 @@ class MainWindow(QtWidgets.QMainWindow): return list(self.iterate_selected(lambda copter: copter.client.send_message(command, command_args))) def new_client_connected(self, client: Client): - logging.debug("Added client {}".format(client)) + logger.debug("Added client {}".format(client)) self.ui.copter_table.add_client(copter_id=client.copter_id, client=client) def client_connection_changed(self, client: Client): - logging.debug("Connection {} changed {}".format(client, client.connected)) + logger.debug("Connection {} changed {}".format(client, client.connected)) row_data = self.model.get_row_by_attr("client", client) if row_data is None: - logging.error("No row for client presented") + logger.error("No row for client presented") return if self.server.config.table_remove_disconnected and (not client.connected): client.remove() self.ui.copter_table.remove_client_data(row_data) - logging.debug("Removing from table") + logger.debug("Removing from table") else: row_num = self.model.get_row_index(row_data) if row_num is not None: self.ui.copter_table.update_data(row_num, 0, client.connected, table.ModelStateRole) - logging.debug("Client status updated") + logger.debug("Client status updated") @pyqtSlot() def selfcheck_selected(self): @@ -233,7 +250,7 @@ class MainWindow(QtWidgets.QMainWindow): for key, value in telems.items(): col = cols_dict.get(key, None) if col is None: - logging.error("No column {} present!".format(key)) + logger.error("No column {} present!".format(key)) continue row_data = self.model.get_row_by_attr("client", client) @@ -247,18 +264,18 @@ class MainWindow(QtWidgets.QMainWindow): copter.client.remove() if not self.server.config.table_remove_disconnected: self.ui.copter_table.remove_client_data(copter) - logging.info("Client removed from table!") + logger.info("Client removed from table!") @pyqtSlot() @confirmation_required("This operation will takeoff selected copters with delay and start animation. Proceed?") def send_start_time_selected(self): time_now = server.time_now() dt = self.ui.start_delay_spin.value() - logging.info('Wait {} seconds to start animation'.format(dt)) + logger.info('Wait {} seconds to start animation'.format(dt)) if self.ui.music_checkbox.isChecked(): music_dt = self.ui.music_delay_spin.value() asyncio.ensure_future(self.play_music_at_time(music_dt + time_now), loop=loop) - logging.info('Wait {} seconds to play music'.format(music_dt)) + logger.info('Wait {} seconds to play music'.format(music_dt)) # self.selfcheck_selected() for copter in filter(self.model.checks.all_checks, self.model.user_selected()): server.send_starttime(copter.client, dt + time_now) @@ -329,7 +346,7 @@ class MainWindow(QtWidgets.QMainWindow): for num, file in enumerate(files): filepath, filename = os.path.split(file) - logging.info("Preparing file for sending: {} {}".format(filepath, filename)) + logger.info("Preparing file for sending: {} {}".format(filepath, filename)) if match_id: name = os.path.splitext(filename)[0] @@ -338,10 +355,10 @@ class MainWindow(QtWidgets.QMainWindow): to_send = copters if not to_send: - logging.warning(f"No copters to send file {filename} to") + logger.error(f"No copters to send file {filename} to") continue - logging.info(f"Sending file {filename} to clients: {to_send}") + logger.info(f"Sending file {filename} to clients: {to_send}") filename = client_filename.format(num, filename) or filename for copter in to_send: @@ -417,13 +434,13 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_launch(self): - self.send_directory_files("Select directory with calibrations", ('.yaml', ), match_id=False, - client_path='"/home/pi/catkin_ws/src/clever/clever/launch/') # TODO clever restart callback? + self.send_directory_files("Select directory with launch files", ('.launch', ), match_id=False, + client_path='/home/pi/catkin_ws/src/clever/clever/launch/') # TODO clever restart callback? @pyqtSlot() def send_fcu_parameters(self): def request_callback(copter, value): - logging.info("Send parameters to {} success: {}".format(copter.client.copter_id, value)) + logger.info("Send parameters to {} success: {}".format(copter.client.copter_id, value)) def callback(copter): copter.client.get_response("load_params", request_callback) @@ -445,7 +462,7 @@ class MainWindow(QtWidgets.QMainWindow): config = cfg.ConfigManager() config.load_only_config(path) data = config.full_dict - logging.info(f"Loaded config from {path}") + logger.info(f"Loaded config from {path}") copters = self.model.user_selected() for copter in copters: @@ -472,10 +489,10 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def play_music(self): if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.InvalidMedia: - logging.info("Can't play media") + logger.info("Can't play media") return if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.NoMedia: - logging.info("No media file") + logger.info("No media file") return if self.player.state() == QtMultimedia.QMediaPlayer.StoppedState or \ @@ -489,24 +506,24 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def stop_music(self): if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.InvalidMedia: - logging.error("Can't stop media") + logger.error("Can't stop media") return if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.NoMedia: - logging.error("No media file") + logger.error("No media file") return self.player.stop() @asyncio.coroutine def play_music_at_time(self, t): if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.InvalidMedia: - logging.error("Can't play media") + logger.error("Can't play media") return if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.NoMedia: - logging.error("No media file") + logger.error("No media file") return self.player.stop() yield from asyncio.sleep(t - time.time()) - logging.info("Playing music") + logger.info("Playing music") self.player.play() @pyqtSlot() From e1308e9ee8d6c61e084cd0ffbbfc1c6bd3435baa Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 21 Jan 2020 03:31:16 +0000 Subject: [PATCH 091/210] Drone: Update visual_pose_watchdog to latest config --- Drone/visual_pose_watchdog.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/Drone/visual_pose_watchdog.py b/Drone/visual_pose_watchdog.py index 1978071..db206a0 100644 --- a/Drone/visual_pose_watchdog.py +++ b/Drone/visual_pose_watchdog.py @@ -1,10 +1,10 @@ import rospy +import os import sys import time import math import logging import threading -import ConfigParser from clever.srv import SetAttitude from sensor_msgs.msg import Range from mavros_msgs.msg import State, PositionTarget @@ -13,15 +13,22 @@ from std_msgs.msg import Bool from std_srvs.srv import Trigger, TriggerResponse from geometry_msgs.msg import PoseStamped -config = ConfigParser.ConfigParser() -config.read("client_config.ini") +import inspect # Add parent dir to PATH to import messaging_lib +current_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) +parent_dir = os.path.dirname(current_dir) +sys.path.insert(0, parent_dir) -visual_pose_timeout = config.getfloat('VISUAL_POSE_WATCHDOG', 'timeout') -pos_delta_max = config.getfloat('VISUAL_POSE_WATCHDOG', 'pos_delta_max') -timeout_action = config.get('VISUAL_POSE_WATCHDOG', 'action') -emergency_land_thrust = config.getfloat('VISUAL_POSE_WATCHDOG', 'emergency_land_thrust') -emergency_land_decrease_thrust_after = config.getfloat('VISUAL_POSE_WATCHDOG', 'emergency_land_decrease_thrust_after') -timeout_to_disarm = config.getfloat('VISUAL_POSE_WATCHDOG', 'timeout_to_disarm') +from config import ConfigManager + +config = ConfigManager() +config.load_config_and_spec("config/client.ini") + +visual_pose_timeout = config.visual_pose_watchdog_timeout +pos_delta_max = config.visual_pose_watchdog_pos_delta_max +timeout_action = config.visual_pose_watchdog_action +emergency_land_thrust = config.emergency_land_thrust +emergency_land_decrease_thrust_after = config.emergency_land_decrease_thrust_after +timeout_to_disarm = config.emergency_land_disarm_timeout logging.basicConfig( # TODO all prints as logs level=logging.DEBUG, # INFO From 24a0c9e0ca93e7f7c28f0539f75c4db6a20de4d7 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 21 Jan 2020 06:56:30 +0300 Subject: [PATCH 092/210] Server: Fix parameters sending --- Server/server_qt.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 6d9cea1..8ac8ee2 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -439,8 +439,8 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_fcu_parameters(self): - def request_callback(copter, value): - logger.info("Send parameters to {} success: {}".format(copter.client.copter_id, value)) + def request_callback(client, value): + logger.info("Send parameters to {} success: {}".format(client.copter_id, value)) def callback(copter): copter.client.get_response("load_params", request_callback) From 1320029378dd6141db4493e1619976eea169c3c8 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 21 Jan 2020 07:14:52 +0300 Subject: [PATCH 093/210] Server: Fix send_any_file --- Server/server_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 8ac8ee2..364d74f 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -405,7 +405,7 @@ class MainWindow(QtWidgets.QMainWindow): if not ok: return - c_filename, c_filepath = os.path.split(c_path) + c_filepath, c_filename = os.path.split(c_path) files = [file] self._send_files(files, client_path=c_filepath, client_filename=c_filename) From 0b2f9834ea15df3be3c365ac0c2da0ffb0c796e6 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 21 Jan 2020 11:28:12 +0300 Subject: [PATCH 094/210] Revert logger to logging calls --- Server/server_qt.py | 63 +++++++++++++++++---------------------------- 1 file changed, 23 insertions(+), 40 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 8ac8ee2..8854489 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -29,23 +29,6 @@ from copter_table import CopterTableWidget from visual_land_dialog import VisualLandDialog from config_editor_models import ConfigDialog -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s [%(name)-7.7s] [%(threadName)-19.19s] [%(levelname)-7.7s] %(message)s", - handlers=[ - logging.FileHandler("server_logs/{}.log".format(now)), - logging.StreamHandler(sys.stdout) - ]) - -handler = logging.StreamHandler(sys.stdout) -handler.setLevel(logging.DEBUG) -formatter = logging.Formatter("%(asctime)s [%(name)-7.7s] [%(threadName)-12.12s] [%(levelname)-5.5s] %(message)s") -handler.setFormatter(formatter) - -logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) -logger.addHandler(handler) - def multi_glob(*patterns): return itertools.chain.from_iterable(glob.iglob(pattern) for pattern in patterns) @@ -65,10 +48,10 @@ def confirmation_required(text="Are you sure?", label="Confirm operation?"): QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply == QMessageBox.Yes: - logger.debug("Dialog accepted") + logging.debug("Dialog accepted") return f(*args, **kwargs) - logger.debug("Dialog declined") + logging.debug("Dialog declined") return wrapper @@ -205,26 +188,26 @@ class MainWindow(QtWidgets.QMainWindow): return list(self.iterate_selected(lambda copter: copter.client.send_message(command, command_args))) def new_client_connected(self, client: Client): - logger.debug("Added client {}".format(client)) + logging.debug("Added client {}".format(client)) self.ui.copter_table.add_client(copter_id=client.copter_id, client=client) def client_connection_changed(self, client: Client): - logger.debug("Connection {} changed {}".format(client, client.connected)) + logging.debug("Connection {} changed {}".format(client, client.connected)) row_data = self.model.get_row_by_attr("client", client) if row_data is None: - logger.error("No row for client presented") + logging.error("No row for client presented") return if self.server.config.table_remove_disconnected and (not client.connected): client.remove() self.ui.copter_table.remove_client_data(row_data) - logger.debug("Removing from table") + logging.debug("Removing from table") else: row_num = self.model.get_row_index(row_data) if row_num is not None: self.ui.copter_table.update_data(row_num, 0, client.connected, table.ModelStateRole) - logger.debug("Client status updated") + logging.debug("Client status updated") @pyqtSlot() def selfcheck_selected(self): @@ -250,7 +233,7 @@ class MainWindow(QtWidgets.QMainWindow): for key, value in telems.items(): col = cols_dict.get(key, None) if col is None: - logger.error("No column {} present!".format(key)) + logging.error("No column {} present!".format(key)) continue row_data = self.model.get_row_by_attr("client", client) @@ -264,18 +247,18 @@ class MainWindow(QtWidgets.QMainWindow): copter.client.remove() if not self.server.config.table_remove_disconnected: self.ui.copter_table.remove_client_data(copter) - logger.info("Client removed from table!") + logging.info("Client removed from table!") @pyqtSlot() @confirmation_required("This operation will takeoff selected copters with delay and start animation. Proceed?") def send_start_time_selected(self): time_now = server.time_now() dt = self.ui.start_delay_spin.value() - logger.info('Wait {} seconds to start animation'.format(dt)) + logging.info('Wait {} seconds to start animation'.format(dt)) if self.ui.music_checkbox.isChecked(): music_dt = self.ui.music_delay_spin.value() asyncio.ensure_future(self.play_music_at_time(music_dt + time_now), loop=loop) - logger.info('Wait {} seconds to play music'.format(music_dt)) + logging.info('Wait {} seconds to play music'.format(music_dt)) # self.selfcheck_selected() for copter in filter(self.model.checks.all_checks, self.model.user_selected()): server.send_starttime(copter.client, dt + time_now) @@ -346,7 +329,7 @@ class MainWindow(QtWidgets.QMainWindow): for num, file in enumerate(files): filepath, filename = os.path.split(file) - logger.info("Preparing file for sending: {} {}".format(filepath, filename)) + logging.info("Preparing file for sending: {} {}".format(filepath, filename)) if match_id: name = os.path.splitext(filename)[0] @@ -355,10 +338,10 @@ class MainWindow(QtWidgets.QMainWindow): to_send = copters if not to_send: - logger.error(f"No copters to send file {filename} to") + logging.error(f"No copters to send file {filename} to") continue - logger.info(f"Sending file {filename} to clients: {to_send}") + logging.info(f"Sending file {filename} to clients: {to_send}") filename = client_filename.format(num, filename) or filename for copter in to_send: @@ -440,7 +423,7 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_fcu_parameters(self): def request_callback(client, value): - logger.info("Send parameters to {} success: {}".format(client.copter_id, value)) + logging.info("Send parameters to {} success: {}".format(client.copter_id, value)) def callback(copter): copter.client.get_response("load_params", request_callback) @@ -462,7 +445,7 @@ class MainWindow(QtWidgets.QMainWindow): config = cfg.ConfigManager() config.load_only_config(path) data = config.full_dict - logger.info(f"Loaded config from {path}") + logging.info(f"Loaded config from {path}") copters = self.model.user_selected() for copter in copters: @@ -489,10 +472,10 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def play_music(self): if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.InvalidMedia: - logger.info("Can't play media") + logging.info("Can't play media") return if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.NoMedia: - logger.info("No media file") + logging.info("No media file") return if self.player.state() == QtMultimedia.QMediaPlayer.StoppedState or \ @@ -506,24 +489,24 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def stop_music(self): if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.InvalidMedia: - logger.error("Can't stop media") + logging.error("Can't stop media") return if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.NoMedia: - logger.error("No media file") + logging.error("No media file") return self.player.stop() @asyncio.coroutine def play_music_at_time(self, t): if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.InvalidMedia: - logger.error("Can't play media") + logging.error("Can't play media") return if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.NoMedia: - logger.error("No media file") + logging.error("No media file") return self.player.stop() yield from asyncio.sleep(t - time.time()) - logger.info("Playing music") + logging.info("Playing music") self.player.play() @pyqtSlot() From 2a1e16c61223888c3a74d0546717e8cf27f3a651 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 21 Jan 2020 15:26:29 +0300 Subject: [PATCH 095/210] Debug code for messaging lib --- messaging_lib.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/messaging_lib.py b/messaging_lib.py index ccb6852..dab54f5 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -8,6 +8,7 @@ import random import logging import threading import collections +import traceback from contextlib import closing @@ -392,9 +393,15 @@ class ConnectionManager(object): logger.debug( "Request {} successfully closed with value {}".format(request, message.content["value"]) ) + print(self, "CALLBACK", request.callback, "VAL", value, "ARGS",request.callback_args, request.callback_kwargs) + try: + request.callback(self, value, *request.callback_args, **request.callback_kwargs) + print(1) + except Exception as e: + logging.error("Error during callback call of request") # TODO more info + traceback.print_exc() + print(e) - f = request.callback - f(self, value, *request.callback_args, **request.callback_kwargs) else: logger.warning("Unexpected response!") From 089d3905870791608ecfa084e16bb7b18bf0bf19 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 21 Jan 2020 15:28:57 +0300 Subject: [PATCH 096/210] Fix for copter config dialog call --- Server/config_editor_models.py | 8 ++++++++ Server/copter_table.py | 14 ++------------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 4690eb6..cf5dec2 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -686,11 +686,14 @@ class ConfigTreeWidget(QTreeView): class ConfigDialog(QtWidgets.QDialog): + copter_editor_signal = QtCore.pyqtSignal(object, object) + def __init__(self, parent=None): super(ConfigDialog, self).__init__(parent) self.ui = config_editor.Ui_config_dialog() self.model = ConfigModel(widget=self) self.setupUi() + self.copter_editor_signal.connect(self._call_copter_dialog) def setupModel(self, data, pure_dict=False, convert_types=False): if pure_dict: @@ -749,6 +752,11 @@ class ConfigDialog(QtWidgets.QDialog): return True def call_copter_dialog(self, client, value): + self.copter_editor_signal.emit(client, value) + + @pyqtSlot(object, object) + def _call_copter_dialog(self, client, value): + logging.info("Opening dialog") config_dict, spec_dict = value["config"], value["configspec"] cfg = config.ConfigManager() cfg.load_from_dict(config_dict, spec_dict) diff --git a/Server/copter_table.py b/Server/copter_table.py index 0d89830..abb22c2 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -14,8 +14,6 @@ import copter_table_models as table class CopterTableWidget(QTableView): - config_dialog_signal = QtCore.pyqtSignal(object, object) - def __init__(self, model, data_model=table.StatedCopterData): QTableView.__init__(self) @@ -45,8 +43,6 @@ class CopterTableWidget(QTableView): self.setContextMenuPolicy(Qt.CustomContextMenu) self.customContextMenuRequested.connect(self.open_menu) - self.config_dialog_signal.connect(lambda x: x) - # Adjust properties self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) self.resizeColumnsToContents() @@ -121,14 +117,8 @@ class CopterTableWidget(QTableView): @pyqtSlot() def edit_config(self, copter): - try: - self.config_dialog_signal.disconnect() - except (TypeError, RuntimeError) as error: - logging.error(f"Disconnection of signal failed: {error}") - else: - call = ConfigDialog().call_copter_dialog - self.config_dialog_signal.connect(call) - copter.client.get_response("config", self.config_dialog_signal.emit) + dialog = ConfigDialog() + copter.client.get_response("config", dialog.call_copter_dialog) # def _selfcheck_shortener(self, data): # TODO!!! # shortened = [] From 7294089bbc04b40a799dd2c9737c4c9dec0e4426 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 22 Jan 2020 11:12:13 +0300 Subject: [PATCH 097/210] WIP1 column state saving --- Server/config/spec/configspec_server.ini | 17 ++ Server/copter_table.py | 274 ++++++++++++++++++++--- Server/copter_table_models.py | 10 +- Server/server_qt.py | 2 +- 4 files changed, 270 insertions(+), 33 deletions(-) diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index 618aa20..905a6be 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -9,6 +9,23 @@ buffer_size = integer(default=1024) # True -> clients are removed on disconnection # False -> disconnected clients indicated remove_disconnected = boolean(default=False) + [[PRESETS]] + current = string(default="DEFAULT") + [[[DEFAULT]]] + copter_id = boolean(default=True) + git_version = boolean(default=True) + animation_id = boolean(default=True) + battery = boolean(default=True) + fcu_status = boolean(default=True) + calibration_status = boolean(default=True) + mode = boolean(default=True) + selfcheck = boolean(default=True) + current_position = boolean(default=True) + start_position = boolean(default=True) + last_task = boolean(default=True) + time_delta = boolean(default=True) + [[[__many__]]] + __many__ = boolean [CHECKS] battery_min = float(default=50.0, min=0, max=100) diff --git a/Server/copter_table.py b/Server/copter_table.py index abb22c2..254c11a 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -7,16 +7,17 @@ from PyQt5.QtCore import Qt as Qt from PyQt5.QtCore import pyqtSlot from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QTableView, QMessageBox, QMenu, QAction, QWidgetAction, QListWidget, \ - QAbstractItemView, QListWidgetItem + QAbstractItemView, QListWidgetItem, QVBoxLayout, QHBoxLayout, QPushButton, QInputDialog, QLineEdit from config_editor_models import ConfigDialog import copter_table_models as table class CopterTableWidget(QTableView): - def __init__(self, model, data_model=table.StatedCopterData): + def __init__(self, model, config, data_model=table.StatedCopterData): QTableView.__init__(self) + self.config = config self.model = model self._data_model = data_model @@ -29,7 +30,7 @@ class CopterTableWidget(QTableView): # Initiate table and table self.model self.setModel(self.proxy_model) - self.columns = [header.strip() for header in self.model.headers] # header keys + self.columns = list(table.columns_names.keys()) #[header.strip() for header in self.model.headers] # header keys self.current_columns = self.columns[:] header = self.horizontalHeader() @@ -44,6 +45,9 @@ class CopterTableWidget(QTableView): self.customContextMenuRequested.connect(self.open_menu) # Adjust properties + self.setTextElideMode(QtCore.Qt.ElideMiddle) + self.setWordWrap(True) + self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) self.resizeColumnsToContents() self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) @@ -62,6 +66,14 @@ class CopterTableWidget(QTableView): if index_to != index_from: self.horizontalHeader().moveSection(index_from, index_to) + def load_columns(self, item_dict: dict=None): + if item_dict is None: + item_dict = self.config.table_presets[self.config.table_presets_current] + + self.set_column_order(list(item_dict.keys())) + for index, show in enumerate(item_dict.values()): + self.horizontalHeader().setColumnHidden(index, not show) + # Some fancy wrappers to simplify syntax def add_client(self, **kwargs): self.signals.add_client_signal.emit(self._data_model(**kwargs)) @@ -91,13 +103,14 @@ class CopterTableWidget(QTableView): def showHeaderMenu(self, event): menu = QMenu(self) - header_view = HeaderListWidget(menu, self) - header_view.setFixedHeight((header_view.geometry().height()-2) * len(header_view.columns)) - #box.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) + header_view = HeaderEditWidget(self, self.config, menu_mode=True, parent=menu) + #header_view.setFixedHeight((header_view.geometry().height()-2) * len(header_view.columns)) + # box.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) action = QWidgetAction(menu) action.setDefaultWidget(header_view) menu.addAction(action) menu.exec_(QCursor.pos()) + # todo header_view. @pyqtSlot(QtCore.QPoint) def open_menu(self, point): @@ -107,7 +120,7 @@ class CopterTableWidget(QTableView): # print(item, index.row(), index.column()) edit_config = QAction("Edit config") - edit_config.triggered.connect(partial(self.edit_config, item)) + edit_config.triggered.connect(partial(self.edit_copter_config, item)) menu.addAction(edit_config) if item is None: @@ -116,7 +129,7 @@ class CopterTableWidget(QTableView): menu.exec_(QCursor.pos()) @pyqtSlot() - def edit_config(self, copter): + def edit_copter_config(self, copter): dialog = ConfigDialog() copter.client.get_response("config", dialog.call_copter_dialog) @@ -129,34 +142,241 @@ class CopterTableWidget(QTableView): class HeaderListWidget(QListWidget): - def __init__(self, parent, source: CopterTableWidget): - super().__init__(parent) - self.source_widget = source - self.source_model = source.proxy_model + ColumnKeyRole = 998 + def __init__(self, config, parent=None, default_items=None): + super().__init__(parent) + self.populated_items = {} + if default_items is not None: + self.populate_items(default_items) + + self.config = config self.setDragDropMode(QAbstractItemView.InternalMove) self.setDefaultDropAction(Qt.MoveAction) - self.current_columns = source.current_columns - self.columns = source.columns - self.populate_items() - self.itemChanged.connect(self.on_itemChanged) - - def populate_items(self): - for column, name in enumerate(self.current_columns): - hidden = self.source_widget.isColumnHidden(column) + def populate_items(self, item_dict: dict): + self.populated_items = item_dict + self.clear() + for name, visible in item_dict.items(): flags = Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsEnabled - state = Qt.Unchecked if hidden else Qt.Checked + state = Qt.Checked if visible else Qt.Unchecked - item = QListWidgetItem(name, self) + item = QListWidgetItem(table.columns_names.get(name, "").strip() or name, self) item.setFlags(flags) item.setCheckState(state) + item.setData(HeaderListWidget.ColumnKeyRole, name) - def dropEvent(self, event: QtGui.QDropEvent): - super().dropEvent(event) - column_order = [self.item(i).text() for i in range(self.count())] - self.source_widget.set_column_order(column_order) + @property + def item_dict(self): + return {self.item(i).data(HeaderListWidget.ColumnKeyRole): bool(self.item(i).checkState()) + for i in range(self.count())} + + def save_to_config(self, current=None, write=False): + if self.count() != len(self.populated_items): # Do not save when populating + print("nosave") + return + + if current is None: + current = self.config.table_presets_current + print("sAVe", current) + presets = self.config.table_presets + header_dict = self.item_dict + + for key in presets[HeaderEditWidget.default]: + print(key, current, header_dict, presets) + if key not in presets[current] and not header_dict[key]: + header_dict.pop(key) + + presets[current] = header_dict + if write: + self.config.write() + + +class ActiveHeaderListWidget(HeaderListWidget): + def __init__(self, source: CopterTableWidget, config, parent=None): + super().__init__(config, parent=parent) + self.source_widget = source + + self.config = config + self.current_columns = source.current_columns + self.columns = source.columns + + self._populate_from_widget() + + self.itemChanged.connect(self.on_itemChanged) + + def _populate_from_widget(self): + item_dict = {} + for column, name in enumerate(self.current_columns): + visible = not self.source_widget.isColumnHidden(column) + item_dict[name] = visible + + self.populate_items(item_dict) @pyqtSlot(QListWidgetItem) def on_itemChanged(self, item): - self.source_widget.setColumnHidden(self.columns.index(item.text()), not bool(item.checkState())) + key = item.data(HeaderListWidget.ColumnKeyRole) + if key is None: + return + self.source_widget.setColumnHidden(self.columns.index(key), + not bool(item.checkState())) + self.save_to_config(write=True) + + def dropEvent(self, event: QtGui.QDropEvent): + super().dropEvent(event) + column_order = [self.item(i).data(HeaderListWidget.ColumnKeyRole) for i in range(self.count())] + self.source_widget.set_column_order(column_order) + self.save_to_config(write=True) + + +class HeaderEditWidget(QtWidgets.QWidget): + add_new_text = "< add new >" + default = "DEFAULT" + + def __init__(self, source, config_data, menu_mode=False, *args, **kwargs): + super().__init__(*args, **kwargs) + # self.auto_apply = auto_apply + self.source = source # source = copter table + self.config = config_data + self.menu_mode = menu_mode + + self.preset_widget = QtWidgets.QComboBox() + self.header_widget = ActiveHeaderListWidget(self.source, self.config) \ + if self.menu_mode else HeaderListWidget(self.config) + + self.previous = self.config.table_presets_current + + self.setupUi() + + def setupUi(self): + self.header_widget.setSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) + + self.update_preset_list() + self.preset_widget.currentTextChanged.connect(self.on_preset_changed) + self.on_preset_changed(self.previous) # to init + + vbox = QVBoxLayout() + vbox.addWidget(self.header_widget) + vbox.addWidget(self.preset_widget) + + hbox = QHBoxLayout() + if not self.menu_mode: + add_button = QPushButton("Add") + add_button.clicked.connect(self.add_preset) + remove_button = QPushButton("Remove") + remove_button.setToolTip("Permanently remove preset from config") + remove_button.clicked.connect(self.remove_preset) + save_button = QPushButton("Save") + save_button.clicked.connect(self.save_preset) + apply_button = QPushButton("Apply") + apply_button.clicked.connect(self.apply_preset) + + hbox.addWidget(add_button) + hbox.addWidget(remove_button) + hbox.addStretch() + hbox.addWidget(save_button) + hbox.addWidget(apply_button) + else: + dialog_button = QPushButton("Manage presets") + hbox.addWidget(dialog_button) + + vbox.addLayout(hbox) + self.setLayout(vbox) + + def update_preset_list(self): + self.preset_widget.clear() + for name, preset in self.config.table_presets.items(): + if isinstance(preset, dict): # looking only for preset sections + self.preset_widget.addItem(name) + + self.preset_widget.addItem(self.add_new_text) + self.preset_widget.setCurrentText(self.previous) + + def on_preset_changed(self, index): + if not index: + return + + if index == self.add_new_text: + self.add_preset() + return + + self.previous = index + presets = self.config.table_presets + items = {key: value for key, value in presets[index].items()} + items.update({key: False for key in presets[self.default] if key not in items}) + + if self.menu_mode: + self.source.set_column_order(list(items.keys())) + self.config.table_presets_current = index + print(index) + self.config.write() + self.header_widget.populate_items(items) + + def add_preset(self): + name, ok = QInputDialog.getText(None, "Enter new preset name", "Name:", + QLineEdit.Normal, "") + if not ok or not name: + self.preset_widget.setCurrentText(self.previous) + return + + if name in self.config.table_presets or name == self.default or name == self.add_new_text: + QMessageBox.warning(None, "Preset already exists!", "Preset already exists!") + self.preset_widget.setCurrentText(self.previous) + return + + self.config.table_presets[name] = deepcopy(dict(self.config.table_presets[self.default])) + self.config.write() + + self.update_preset_list() + self.preset_widget.setCurrentText(name) + + def remove_preset(self): + if self.preset_widget.currentText() == self.default: + QMessageBox.warning(None, "Can't delete default preset!", "Can't delete default preset!") + return + + reply = QMessageBox.question(None, "Action can't be undone", "Remove anyway?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + if reply != QMessageBox.Yes: + return + + self.config.table_presets.pop(self.preset_widget.currentText()) + self.config.write() + + self.previous = self.default + self.update_preset_list() + + def save_preset(self): + self.header_widget.save_to_config(current=self.preset_widget.currentText(), write=True) + + def apply_preset(self): + self.config.table_presets_current = self.preset_widget.currentText() + self.save_preset() + self.source.load_columns() + + +if __name__ == '__main__': + import sys + + def except_hook(cls, exception, traceback): + sys.__excepthook__(cls, exception, traceback) + sys.excepthook = except_hook # for debugging (exceptions traceback) + + app = QtWidgets.QApplication(sys.argv) + import copter_table_models + + model = copter_table_models.CopterDataModel() + # for i in range(10): + # model.add_client(copter_table_models.StatedCopterData()) + + import config + c = config.ConfigManager() + c.load_config_and_spec("config\server.ini") + #print(c.config) + #print(c._name_dict) + w1 = CopterTableWidget(model, c) + w = HeaderEditWidget(w1, c) + # print(*w1.current_columns, sep='\n') + w.show() + app.exec() diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 95e4627..e3e5a20 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -106,11 +106,11 @@ def check_time_delta(item): columns_names = {'copter_id': 'copter ID', - 'git_ver': 'version', + 'git_version': 'version', 'animation_id': ' animation ID ', 'battery': ' battery ', 'fcu_status': 'FCU status', - 'cal_status': 'sensors', + 'calibration_status': 'sensors', 'mode': ' mode ', 'selfcheck': ' checks ', 'current_position': 'current x y z yaw frame_id', @@ -311,9 +311,9 @@ class CopterDataModel(QtCore.QAbstractTableModel): def __init__(self, checks=ModelChecks, formatter=ModelFormatter, parent=None): super(CopterDataModel, self).__init__(parent) - # self.headers = list(columns_names.values()) # todo - self.headers = (' copter ID ', ' version ', ' animation ID ', ' battery ', ' fcu_status ', ' sensors ', - ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' task ', 'dt') + # self.headers = (' copter ID ', ' version ', ' animation ID ', ' battery ', ' fcu_status ', ' sensors ', + # ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' task ', 'dt') + self.headers = list(columns_names.values()) self.data_contents = [] self.checks = checks diff --git a/Server/server_qt.py b/Server/server_qt.py index 8854489..3eb8d05 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -154,7 +154,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.horizontalLayout.removeWidget(self.ui.tableView) self.ui.tableView.close() # Init our custom widget - self.ui.copter_table = CopterTableWidget(self.model) + self.ui.copter_table = CopterTableWidget(self.model, self.server.config) self.ui.copter_table.setObjectName("copter_table") # Insert to layout at right self.ui.horizontalLayout.insertWidget(0, self.ui.copter_table, 0) From 3a709d46bd78238fce7fb163a9dcd1f0315a2c82 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 22 Jan 2020 11:13:28 +0300 Subject: [PATCH 098/210] Enchanced attr support in config --- config.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/config.py b/config.py index a00f638..f3e8e81 100644 --- a/config.py +++ b/config.py @@ -133,10 +133,9 @@ class ConfigManager: keys = parent_keys + (key,) if isinstance(value, dict): items.update(cls.flatten_keys(value, keys, sep=sep)) - else: - formatted_keys = [key.lower().strip().replace(' ', sep) for key in keys] - formatted_key = sep.join(formatted_keys) - items.update({formatted_key: keys}) + formatted_keys = [key.lower().strip().replace(' ', sep) for key in keys] + formatted_key = sep.join(formatted_keys) + items.update({formatted_key: keys}) return dict(items) def __getattr__(self, item): From 74396a4d5e67f1baf445e79ef3166d39ba161832 Mon Sep 17 00:00:00 2001 From: artem30801 <38689676+artem30801@users.noreply.github.com> Date: Wed, 22 Jan 2020 11:48:48 +0300 Subject: [PATCH 099/210] Enable sorting --- Server/copter_table.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Server/copter_table.py b/Server/copter_table.py index abb22c2..d5095de 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -44,6 +44,7 @@ class CopterTableWidget(QTableView): self.customContextMenuRequested.connect(self.open_menu) # Adjust properties + self.setSortingEnabled(True) self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) self.resizeColumnsToContents() self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) From 5320602159efabb7d4b2bb3a67bbf3e5ab19bb08 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Wed, 22 Jan 2020 08:49:59 +0000 Subject: [PATCH 100/210] Drone: Fix chrony ip configuration --- Drone/copter_client.py | 53 +++++------------------------------------- 1 file changed, 6 insertions(+), 47 deletions(-) diff --git a/Drone/copter_client.py b/Drone/copter_client.py index e4be387..7b42a54 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -78,52 +78,9 @@ class CopterClient(client.Client): def load_config(self): super(CopterClient, self).load_config() - #print(self.config) - # self.FLOOR_FRAME_EXISTS = False - # self.TELEM_FREQ = self.config.getfloat('TELEMETRY', 'frequency') - # self.TELEM_TRANSMIT = self.config.getboolean('TELEMETRY', 'transmit') - # self.LOG_CPU_AND_MEMORY = self.config.getboolean('TELEMETRY', 'log_cpu_and_memory') - # self.LAND_POS_DELTA = self.config.getfloat('TELEMETRY', 'land_if_pos_delta_bigger_than') - # self.FRAME_ID = self.config.get('COPTERS', 'frame_id') - # self.FRAME_FLIPPED_HEIGHT = 0. - # self.TAKEOFF_HEIGHT = self.config.getfloat('COPTERS', 'takeoff_height') - # self.TAKEOFF_TIME = self.config.getfloat('COPTERS', 'takeoff_time') - # self.SAFE_TAKEOFF = self.config.getboolean('COPTERS', 'safe_takeoff') - # self.RFP_TIME = self.config.getfloat('COPTERS', 'reach_first_point_time') - # self.LAND_TIME = self.config.getfloat('COPTERS', 'land_time') - # self.LAND_TIMEOUT = self.config.getfloat('COPTERS', 'land_timeout') - # self.X0_COMMON = self.config.getfloat('COPTERS', 'x0_common') - # self.Y0_COMMON = self.config.getfloat('COPTERS', 'y0_common') - # self.Z0_COMMON = self.config.getfloat('COPTERS', 'z0_common') - # self.YAW = self.config.get('COPTERS', 'yaw') - # self.TAKEOFF_CHECK = self.config.getboolean('ANIMATION', 'takeoff_animation_check') - # self.LAND_CHECK = self.config.getboolean('ANIMATION', 'land_animation_check') - # self.FRAME_DELAY = self.config.getfloat('ANIMATION', 'frame_delay') - # self.X_RATIO = self.config.getfloat('ANIMATION', 'x_ratio') - # self.Y_RATIO = self.config.getfloat('ANIMATION', 'y_ratio') - # self.Z_RATIO = self.config.getfloat('ANIMATION', 'z_ratio') - # self.X0 = self.config.getfloat('PRIVATE', 'x0') - # self.Y0 = self.config.getfloat('PRIVATE', 'y0') - # self.Z0 = self.config.getfloat('PRIVATE', 'z0') - # self.USE_LEDS = self.config.getboolean('PRIVATE', 'use_leds') - # self.LED_PIN = self.config.getint('PRIVATE', 'led_pin') - # try: - # self.FLOOR_DX = self.config.getfloat('FLOOR FRAME', 'x') - # self.FLOOR_DY = self.config.getfloat('FLOOR FRAME', 'y') - # self.FLOOR_DZ = self.config.getfloat('FLOOR FRAME', 'z') - # self.FLOOR_ROLL = self.config.getfloat('FLOOR FRAME', 'roll') - # self.FLOOR_PITCH = self.config.getfloat('FLOOR FRAME', 'pitch') - # self.FLOOR_YAW = self.config.getfloat('FLOOR FRAME', 'yaw') - # self.FLOOR_PARENT = self.config.get('FLOOR FRAME', 'parent') - # self.FLOOR_FRAME_EXISTS = True - # except ConfigParser.Error: - # rospy.logerror("No floor frame!") - # self.FLOOR_FRAME_EXISTS = False - # self.RESTART_AFTER_RENAME = self.config.getboolean('PRIVATE', 'restart_after_rename') def on_broadcast_bind(self): - configure_chrony_ip(self.config.server_host) - restart_service("chrony") + repair_chrony(self.config.server_host) def start(self, task_manager_instance): rospy.loginfo("Init ROS node") @@ -157,6 +114,10 @@ class CopterClient(client.Client): def restart_service(name): os.system("systemctl restart {}".format(name)) +def repair_chrony(ip): + logger.info("Configure chrony ip to {}".format(ip)) + configure_chrony_ip(ip) + restart_service("chrony") def execute_command(command): os.system(command) @@ -180,7 +141,6 @@ def configure_chrony_ip(ip, path="/etc/chrony/chrony.conf", ip_index=1): if "." not in current_ip: logger.debug("That's not ip!") - return False if current_ip != ip: content[ip_index] = ip @@ -486,8 +446,7 @@ def _command_service_restart(*args, **kwargs): @messaging.message_callback("repair_chrony") def _command_chrony_repair(*args, **kwargs): - configure_chrony_ip(client.active_client.config.server_host) - restart_service("chrony") + repair_chrony(client.active_client.config.server_host) @messaging.message_callback("led_test") From dc178c5ceba73cec053a5c7b8d7900855bb25eab Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 22 Jan 2020 16:21:02 +0300 Subject: [PATCH 101/210] Working column editors --- Server/copter_table.py | 81 +++++++++++++++++------------------ Server/copter_table_models.py | 2 +- Server/server_qt.py | 4 ++ 3 files changed, 44 insertions(+), 43 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index 254c11a..c1a96c4 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -1,6 +1,5 @@ from functools import partial from copy import deepcopy -import logging from PyQt5 import QtWidgets, QtCore, QtGui from PyQt5.QtCore import Qt as Qt @@ -67,12 +66,15 @@ class CopterTableWidget(QTableView): self.horizontalHeader().moveSection(index_from, index_to) def load_columns(self, item_dict: dict=None): + presets = self.config.table_presets if item_dict is None: - item_dict = self.config.table_presets[self.config.table_presets_current] + item_dict = presets[self.config.table_presets_current] + + item_dict.update({key: False for key in presets[HeaderEditWidget.default] if key not in item_dict}) self.set_column_order(list(item_dict.keys())) - for index, show in enumerate(item_dict.values()): - self.horizontalHeader().setColumnHidden(index, not show) + for name, show in item_dict.items(): # for index, name in enumerate(self.columns): + self.setColumnHidden(self.columns.index(name), not show) # self.setColumnHidden(index, not item_dict.get(name, False)) # Some fancy wrappers to simplify syntax def add_client(self, **kwargs): @@ -110,14 +112,13 @@ class CopterTableWidget(QTableView): action.setDefaultWidget(header_view) menu.addAction(action) menu.exec_(QCursor.pos()) - # todo header_view. + header_view.save_preset() @pyqtSlot(QtCore.QPoint) def open_menu(self, point): menu = QMenu(self) index = self.indexAt(point) item = self.model.get_row_data(index) - # print(item, index.row(), index.column()) edit_config = QAction("Edit config") edit_config.triggered.connect(partial(self.edit_copter_config, item)) @@ -144,18 +145,15 @@ class CopterTableWidget(QTableView): class HeaderListWidget(QListWidget): ColumnKeyRole = 998 - def __init__(self, config, parent=None, default_items=None): + def __init__(self, parent=None, default_items=None): super().__init__(parent) - self.populated_items = {} if default_items is not None: self.populate_items(default_items) - self.config = config self.setDragDropMode(QAbstractItemView.InternalMove) self.setDefaultDropAction(Qt.MoveAction) def populate_items(self, item_dict: dict): - self.populated_items = item_dict self.clear() for name, visible in item_dict.items(): flags = Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsEnabled @@ -171,33 +169,12 @@ class HeaderListWidget(QListWidget): return {self.item(i).data(HeaderListWidget.ColumnKeyRole): bool(self.item(i).checkState()) for i in range(self.count())} - def save_to_config(self, current=None, write=False): - if self.count() != len(self.populated_items): # Do not save when populating - print("nosave") - return - - if current is None: - current = self.config.table_presets_current - print("sAVe", current) - presets = self.config.table_presets - header_dict = self.item_dict - - for key in presets[HeaderEditWidget.default]: - print(key, current, header_dict, presets) - if key not in presets[current] and not header_dict[key]: - header_dict.pop(key) - - presets[current] = header_dict - if write: - self.config.write() - class ActiveHeaderListWidget(HeaderListWidget): - def __init__(self, source: CopterTableWidget, config, parent=None): - super().__init__(config, parent=parent) + def __init__(self, source: CopterTableWidget, parent=None): + super().__init__(parent=parent) self.source_widget = source - self.config = config self.current_columns = source.current_columns self.columns = source.columns @@ -220,31 +197,30 @@ class ActiveHeaderListWidget(HeaderListWidget): return self.source_widget.setColumnHidden(self.columns.index(key), not bool(item.checkState())) - self.save_to_config(write=True) def dropEvent(self, event: QtGui.QDropEvent): super().dropEvent(event) column_order = [self.item(i).data(HeaderListWidget.ColumnKeyRole) for i in range(self.count())] self.source_widget.set_column_order(column_order) - self.save_to_config(write=True) class HeaderEditWidget(QtWidgets.QWidget): add_new_text = "< add new >" default = "DEFAULT" - def __init__(self, source, config_data, menu_mode=False, *args, **kwargs): + def __init__(self, source, config, menu_mode=False, *args, **kwargs): super().__init__(*args, **kwargs) # self.auto_apply = auto_apply self.source = source # source = copter table - self.config = config_data + self.config = config self.menu_mode = menu_mode self.preset_widget = QtWidgets.QComboBox() - self.header_widget = ActiveHeaderListWidget(self.source, self.config) \ - if self.menu_mode else HeaderListWidget(self.config) + self.header_widget = ActiveHeaderListWidget(self.source) \ + if self.menu_mode else HeaderListWidget() self.previous = self.config.table_presets_current + self._dialog = None self.setupUi() @@ -270,6 +246,8 @@ class HeaderEditWidget(QtWidgets.QWidget): save_button.clicked.connect(self.save_preset) apply_button = QPushButton("Apply") apply_button.clicked.connect(self.apply_preset) + apply_button.setDefault(True) + apply_button.setFocus() hbox.addWidget(add_button) hbox.addWidget(remove_button) @@ -277,7 +255,9 @@ class HeaderEditWidget(QtWidgets.QWidget): hbox.addWidget(save_button) hbox.addWidget(apply_button) else: + self._dialog = HeaderEditDialog(self.source, self.config) dialog_button = QPushButton("Manage presets") + dialog_button.clicked.connect(self._dialog.show) hbox.addWidget(dialog_button) vbox.addLayout(hbox) @@ -308,8 +288,6 @@ class HeaderEditWidget(QtWidgets.QWidget): if self.menu_mode: self.source.set_column_order(list(items.keys())) self.config.table_presets_current = index - print(index) - self.config.write() self.header_widget.populate_items(items) def add_preset(self): @@ -348,7 +326,16 @@ class HeaderEditWidget(QtWidgets.QWidget): self.update_preset_list() def save_preset(self): - self.header_widget.save_to_config(current=self.preset_widget.currentText(), write=True) + current = self.preset_widget.currentText() + presets = self.config.table_presets + header_dict = self.header_widget.item_dict + + for key in presets[self.default]: + if key not in presets[current] and not header_dict[key]: + header_dict.pop(key) + + presets[current] = header_dict + self.config.write() def apply_preset(self): self.config.table_presets_current = self.preset_widget.currentText() @@ -356,6 +343,16 @@ class HeaderEditWidget(QtWidgets.QWidget): self.source.load_columns() +class HeaderEditDialog(QtWidgets.QDialog): + def __init__(self, source, config, parent=None): + super(HeaderEditDialog, self).__init__(parent=None) + self.ui = HeaderEditWidget(source, config, menu_mode=False) + self.setWindowTitle("Column preset editor") + layout = QVBoxLayout() + layout.addWidget(self.ui) + self.setLayout(layout) + + if __name__ == '__main__': import sys diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index e3e5a20..b992aaf 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -230,7 +230,7 @@ class ModelFormatter: @ModelFormatter.col_format(0, ModelFormatter.PLACE_FORMATTER) def place_id(value): - value = value.strip() + value = str(value).strip() # check user hostname spelling http://man7.org/linux/man-pages/man7/hostname.7.html # '-' (hyphen) not first; latin letters/numbers/hyphens; length form 1 to 63 # or matches command pattern diff --git a/Server/server_qt.py b/Server/server_qt.py index 3eb8d05..4af8040 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -179,6 +179,10 @@ class MainWindow(QtWidgets.QMainWindow): self.model.selected_calibration_ready_signal.connect(self.ui.calibrate_gyro.setEnabled) self.model.selected_calibration_ready_signal.connect(self.ui.calibrate_level.setEnabled) + def show(self): + self.ui.copter_table.load_columns() + super().show() + def iterate_selected(self, f, *args, **kwargs): for copter in self.model.user_selected(): yield f(copter, *args, **kwargs) From c8da71a6b1978679b97e91a1bde76d67237d72a9 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 22 Jan 2020 16:21:35 +0300 Subject: [PATCH 102/210] Server exit confirmation --- Server/server_qt.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Server/server_qt.py b/Server/server_qt.py index 4af8040..d8cba8c 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -183,6 +183,18 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.copter_table.load_columns() super().show() + def closeEvent(self, event): + # TODO if any connected copters + reply = QMessageBox.question(self, "Confirm exit", "There are copters connected to the server. " + "Are you sure you want to exit?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + if reply != QMessageBox.Yes: + event.ignore() + else: + event.accept() + QApplication.quit() + def iterate_selected(self, f, *args, **kwargs): for copter in self.model.user_selected(): yield f(copter, *args, **kwargs) From 61eacb769a9437e27241b3ea9809ee514380b843 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 22 Jan 2020 16:21:51 +0300 Subject: [PATCH 103/210] Fixed c_filepath, c_filename order --- Server/server_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index d8cba8c..c81cd42 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -404,7 +404,7 @@ class MainWindow(QtWidgets.QMainWindow): if not ok: return - c_filename, c_filepath = os.path.split(c_path) + c_filepath, c_filename = os.path.split(c_path) files = [file] self._send_files(files, client_path=c_filepath, client_filename=c_filename) From 0e02e2ee9d879ebaad6e914ce082b4c988c6171b Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 23 Jan 2020 17:05:59 +0300 Subject: [PATCH 104/210] Updated messaging. File requests + args --- Drone/client.py | 22 ++--- Drone/copter_client.py | 8 +- Server/server.py | 12 +-- Server/server_qt.py | 15 ++-- messaging_lib.py | 186 +++++++++++++++++++++++------------------ 5 files changed, 134 insertions(+), 109 deletions(-) diff --git a/Drone/client.py b/Drone/client.py index 315168e..43f9b74 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -148,18 +148,18 @@ class Client(object): message = messaging.MessageManager() message.income_raw = data message.process_message() - if message.content: + if message.content and message.jsonheader["action"] == "server_ip": logger.info("Received broadcast message {} from {}".format(message.content, addr)) - if message.content["command"] == "server_ip": - args = message.content["args"] - self.config.set("SERVER", "port", int(args["port"])) - self.config.set("SERVER", "host", args["host"]) - self.config.write() - logger.info("Binding to new IP: {}:{}".format( - self.config.server_host, self.config.server_port)) - self.on_broadcast_bind() - break + kwargs = message.content["kwargs"] + self.config.set("SERVER", "port", int(kwargs["port"])) + self.config.set("SERVER", "host", kwargs["host"]) + self.config.write() + + logger.info("Binding to new IP: {}:{}".format( + self.config.server_host, self.config.server_port)) + self.on_broadcast_bind() + break finally: broadcast_client.close() @@ -168,6 +168,7 @@ class Client(object): def _process_connections(self): while True: + #self.server_connection.send_message("telemetry", kwargs={"value":{"time": time.time()}}) events = self.selector.select(timeout=1) for key, mask in events: @@ -226,6 +227,7 @@ def _response_id(*args, **kwargs): if new_id is not None: active_client.config.set("PRIVATE", "id", new_id, True) active_client.load_config() + # TODO renaming here return active_client.client_id diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 7b42a54..85c6fe2 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -123,7 +123,7 @@ def execute_command(command): os.system(command) -def configure_chrony_ip(ip, path="/etc/chrony/chrony.conf", ip_index=1): +def configure_chrony_ip(ip, path="/etc/chrony/chrony.conf", ip_index=1): # TODO simplify try: with open(path, 'r') as f: raw_content = f.read() @@ -245,7 +245,7 @@ def _execute(*args, **kwargs): logger.info("Executing done") -@messaging.message_callback("id") +@messaging.message_callback("id") # TODO redo def _response_id(*args, **kwargs): new_id = kwargs.get("new_id", None) if new_id is not None: @@ -828,9 +828,9 @@ class Telemetry: self._tasks_cleared = False self._last_state = state - def transmit_message(self): + def transmit_message(self): # todo if connected try: - client.active_client.server_connection.send_message('telemetry', args={'value': self.create_msg_contents()}) + client.active_client.server_connection.send_message('telemetry', kwargs={'value': self.create_msg_contents()}) except AttributeError as e: logger.debug(e) diff --git a/Server/server.py b/Server/server.py index 94be295..c4726d5 100644 --- a/Server/server.py +++ b/Server/server.py @@ -185,7 +185,7 @@ class Server(messaging.Singleton): def _ip_broadcast(self): logging.info("Broadcast sender thread started!") - msg = messaging.MessageManager.create_simple_message( + msg = messaging.MessageManager.create_action_message( "server_ip", {"host": self.ip, "port": str(self.config.server_port), "id": self.id, "start_time": str(self.time_started)}) logging.debug("Formed broadcast message: {}".format(msg)) @@ -231,11 +231,11 @@ class Server(messaging.Singleton): message.process_message() content = message.content - right_command = (content and content["command"] == "server_ip") + right_command = (content and message.jsonheader["action"] == "server_ip") if right_command: - different_id = content["args"]["id"] != str(self.id) - self_younger = float(message.content["args"]["start_time"]) <= self.time_started + different_id = content["kwargs"]["id"] != str(self.id) + self_younger = float(content["kwargs"]["start_time"]) <= self.time_started if different_id and self_younger: # younger server should shut down @@ -253,7 +253,7 @@ class Server(messaging.Singleton): logging.info("Broadcast listener thread stopped, socked closed!") def send_starttime(self, copter, start_time): - copter.send_message("start", {"time": str(start_time)}) + copter.send_message("start", kwargs={"time": str(start_time)}) def requires_connect(f): @@ -356,7 +356,7 @@ class Client(messaging.ConnectionManager): @classmethod @requires_any_connected def broadcast_message(cls, command, args=None, force_all=False): - cls.broadcast(messaging.MessageManager.create_simple_message(command, args), force_all) + cls.broadcast(messaging.MessageManager.create_action_message(command, args), force_all) if __name__ == '__main__': diff --git a/Server/server_qt.py b/Server/server_qt.py index c81cd42..242cd71 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -126,9 +126,9 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_send_any_file.triggered.connect(self.send_any_file) self.ui.action_send_any_command.triggered.connect(self.send_any_command) self.ui.action_restart_clever.triggered.connect( - b_partial(self.send_to_selected, "service_restart", {"name": "clever"})) + b_partial(self.send_to_selected, "service_restart", kwargs={"name": "clever"})) self.ui.action_restart_clever_show.triggered.connect( - b_partial(self.send_to_selected, "service_restart", {"name": "clever-show"})) + b_partial(self.send_to_selected, "service_restart", kwargs={"name": "clever-show"})) self.ui.action_update_client_repo.triggered.connect(b_partial(self.send_to_selected, "update_repo")) self.ui.action_reboot_all.triggered.connect(b_partial(self.send_to_selected, "reboot_all")) self.ui.action_set_start_to_current_position.triggered.connect(b_partial(self.send_to_selected, "move_start")) @@ -200,8 +200,9 @@ class MainWindow(QtWidgets.QMainWindow): yield f(copter, *args, **kwargs) @pyqtSlot() - def send_to_selected(self, command, command_args=None): - return list(self.iterate_selected(lambda copter: copter.client.send_message(command, command_args))) + def send_to_selected(self, command, command_args=(), command_kwargs=None): + return list(self.iterate_selected(lambda copter: copter.client.send_message( + command, command_args, command_kwargs))) def new_client_connected(self, client: Client): logging.debug("Added client {}".format(client)) @@ -295,7 +296,7 @@ class MainWindow(QtWidgets.QMainWindow): for copter in self.model.user_selected(): if self.model.checks.takeoff_checks(copter): if self.ui.z_checkbox.isChecked(): - copter.client.send_message("takeoff_z", {"z": str(self.ui.z_spin.value())}) # todo int + copter.client.send_message("takeoff_z", {"z": str(self.ui.z_spin.value())}) # todo int, merge commands else: copter.client.send_message("takeoff") @@ -425,7 +426,7 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_aruco(self): def callback(copter): - copter.client.send_message("service_restart", {"name": "clever"}) + copter.client.send_message("service_restart", kwargs={"name": "clever"}) self.send_files("Select aruco map configuration file", "Aruco map files (*.txt)", onefile=True, client_path="/home/pi/catkin_ws/src/clever/aruco_pose/map/", @@ -465,7 +466,7 @@ class MainWindow(QtWidgets.QMainWindow): copters = self.model.user_selected() for copter in copters: - copter.client.send_message("config", {"config": data, "mode": mode.lower()}) + copter.client.send_message("config", kwargs={"config": data, "mode": mode.lower()}) @pyqtSlot() def send_any_command(self): diff --git a/messaging_lib.py b/messaging_lib.py index dab54f5..b6938dc 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -94,35 +94,41 @@ class MessageManager: return message @classmethod - def create_json_message(cls, contents): - message = cls.create_message(cls._json_encode(contents), "json", "message") + def create_json_message(cls, contents, additional_headers=None): + message = cls.create_message(cls._json_encode(contents), "json", "message", + additional_headers=additional_headers) return message @classmethod - def create_simple_message(cls, command, args=None): - if args is None: - args = {} - message = cls.create_json_message({"command": command, "args": args}) + def create_action_message(cls, action, args=(), kwargs=None): + if kwargs is None: + kwargs = {} + message = cls.create_json_message({"args": args, "kwargs": kwargs}, {"action": action, }) return message @classmethod - def create_request(cls, requested_value, request_id, args=None): - if args is None: - args = {} + def create_request(cls, requested_value, request_id, args=(), kwargs=None): + if kwargs is None: + kwargs = {} contents = {"requested_value": requested_value, "request_id": request_id, "args": args, + "kwargs": kwargs, } message = cls.create_message(cls._json_encode(contents), "json", "request") return message @classmethod - def create_response(cls, requested_value, request_id, value): - contents = {"requested_value": requested_value, - "request_id": request_id, - "value": value, - } - message = cls.create_message(cls._json_encode(contents), "json", "response") + def create_response(cls, requested_value, request_id, value, filetransfer=False): + headers = {"requested_value": requested_value, + "request_id": request_id, # TODO status + } + if filetransfer: + contents = value + else: + contents = cls._json_encode({"value": value, }) + message = cls.create_message(contents, "binary" if filetransfer else "json", + "response", additional_headers=headers) return message def _process_protoheader(self): @@ -171,10 +177,10 @@ class MessageManager: self._process_content() -def message_callback(string_command): +def message_callback(action_string): def inner(f): - ConnectionManager.messages_callbacks[string_command] = f - logger.debug("Registered message function {} for {}".format(f, string_command)) + ConnectionManager.messages_callbacks[action_string] = f + logger.debug("Registered message function {} for {}".format(f, action_string)) def wrapper(*args, **kwargs): return f(*args, **kwargs) @@ -340,84 +346,99 @@ class ConnectionManager(object): raise RuntimeError("Peer closed.") - def process_received(self, income_message): - message_type = income_message.jsonheader["message-type"] - content = income_message.content if message_type != "filetransfer" else income_message.content[:256] + def process_received(self, message): + message_type = message.jsonheader["message-type"] + content = message.content if message.jsonheader["content-type"] != "binary"\ + else message.content[:256] logger.debug( - "Received message! Header: {}, content: {}".format(income_message.jsonheader, content)) + "Received message! Header: {}, content: {}".format(message.jsonheader, content)) if message_type == "message": - self._process_message(income_message) + self._process_message(message) elif message_type == "response": - self._process_response(income_message) + self._process_response(message) elif message_type == "request": - self._process_request(income_message) - elif message_type == "filetransfer": - self._process_filetransfer(income_message) + self._process_request(message) def _process_message(self, message): - command = message.content["command"] + if message.jsonheader["action"] == "filetransfer": + self._process_filetransfer(message.content, message.jsonheader["filepath"]) + else: + self._process_action(message) + + def _process_action(self, message): + action = message.jsonheader["action"] args = message.content["args"] - callback = self.messages_callbacks.get(command, None) + kwargs = message.content["kwargs"] + callback = self.messages_callbacks.get(action, None) if callback is None: - logger.warning("Command {} does not exist!".format(command)) + logger.warning("Action {} does not exist!".format(action)) return try: - callback(self, **args) + callback(self, *args, **kwargs) except Exception as error: - logger.error("Error during command {} execution: {}".format(command, error)) + logger.error("Error during action {} execution: {}".format(action, error)) def _process_request(self, message): - command = message.content["requested_value"] + requested_value = message.content["requested_value"] request_id = message.content["request_id"] args = message.content["args"] - callback = self.requests_callbacks.get(command, None) + kwargs = message.content["kwargs"] + + callback = self.requests_callbacks.get(requested_value, None) if callback is None: - logger.warning("Request {} does not exist!".format(command)) + logger.warning("Request {} does not exist!".format(requested_value)) return + filetransfer = requested_value == "filetransfer" try: - value = callback(self, **args) + if filetransfer: + value = self._read_file(kwargs["filepath"]) + else: + value = callback(self, *args, **kwargs) except Exception as error: # TODO send response error\cancel - logger.error("Error during request {} processing: {}".format(command, error)) + logger.error("Error during request {} processing: {}".format(requested_value, error)) else: - self._send_response(command, request_id, value) + self._send_response(requested_value, request_id, value, filetransfer) def _process_response(self, message): - request_id, requested_value = message.content["request_id"], message.content["requested_value"] + request_id, requested_value = message.jsonheader["request_id"], message.jsonheader["requested_value"] with self._request_lock: request = self._request_queue.pop(request_id, None) + if (request is None) or (request.requested_value != requested_value): + logger.warning("Unexpected response!") + return - if (request is not None) and (request.requested_value == requested_value): - value = message.content["value"] - logger.debug( - "Request {} successfully closed with value {}".format(request, message.content["value"]) - ) - print(self, "CALLBACK", request.callback, "VAL", value, "ARGS",request.callback_args, request.callback_kwargs) - try: - request.callback(self, value, *request.callback_args, **request.callback_kwargs) - print(1) - except Exception as e: - logging.error("Error during callback call of request") # TODO more info - traceback.print_exc() - print(e) - + if requested_value == "filetransfer": + value = True + self._process_filetransfer(message.content, request.callback_kwargs["filepath"]) else: - logger.warning("Unexpected response!") + value = message.content["value"] - def _process_filetransfer(self, message): # TODO path? - if message.jsonheader["content-type"] == "binary": - filepath = message.jsonheader["filepath"] - try: - with open(filepath, 'wb') as f: - f.write(message.content) - except OSError as error: - logger.error("File {} can not be written due error: {}".format(filepath, error)) - else: - logger.info("File {} successfully received ".format(filepath)) - if self.whoami == "pi": - logger.info("Return rights to pi:pi after file transfer") - os.system("chown pi:pi {}".format(filepath)) + logger.debug( + "Request {} successfully closed with value {}".format(request, message.content["value"]) + ) + try: + request.callback(self, value, *request.callback_args, **request.callback_kwargs) + except Exception as error: + logger.error("Error during response {} processing: {}".format(request, error)) + + @staticmethod + def _read_file(filepath): + with open(filepath, mode='rb') as f: + return f.read() + + def _process_filetransfer(self, content, filepath): + try: + with open(filepath, 'wb') as f: + f.write(content) + except OSError as error: + logger.error("File {} can not be written due error: {}".format(filepath, error)) + else: + logger.info("File {} successfully received ".format(filepath)) + if self.whoami == "pi": + logger.info("Return rights to pi:pi after file transfer") + os.system("chown pi:pi {}".format(filepath)) def write(self): with self._send_lock: @@ -453,14 +474,15 @@ class ConnectionManager(object): self._set_selector_events_mask('rw') NotifierSock().notify() - def get_response(self, requested_value, callback, request_args=None, # timeout=30, - callback_args=(), callback_kwargs=None): - if request_args is None: - request_args = {} + def get_response(self, requested_value, callback, # timeout=30, + request_args=(), request_kwargs=None, + callback_args=(), callback_kwargs=None, ): + if request_kwargs is None: + request_kwargs = {} if callback_kwargs is None: callback_kwargs = {} - request_id = str(random.randint(0, 9999)).zfill(4) + request_id = str(random.randint(0, 9999)).zfill(4) # maybe hash with self._request_lock: self._request_queue[request_id] = PendingRequest( requested_value=requested_value, @@ -470,24 +492,25 @@ class ConnectionManager(object): callback_args=callback_args, callback_kwargs=callback_kwargs, request_args=request_args, + request_kwargs=request_kwargs, resend=True, ) - self._send(MessageManager.create_request(requested_value, request_id, request_args)) + self._send(MessageManager.create_request(requested_value, request_id, request_args, request_kwargs)) def _resend_requests(self): with self._request_lock: - for request_id, request in self._request_queue.items(): #TODO filter + for request_id, request in self._request_queue.items(): # TODO filter if request.resend: self._send(MessageManager.create_request( - request.requested_value, request_id, request.request_args.update(resend=request.resend)) + request.requested_value, request_id, request.request_kwargs.update(resend=request.resend)) ) request.resend = False - def send_message(self, command, args=None): - self._send(MessageManager.create_simple_message(command, args)) + def send_message(self, action, args=(), kwargs=None): + self._send(MessageManager.create_action_message(action, args, kwargs)) - def _send_response(self, requested_value, request_id, value): - self._send(MessageManager.create_response(requested_value, request_id, value)) + def _send_response(self, requested_value, request_id, value, filetransfer=False): + self._send(MessageManager.create_response(requested_value, request_id, value, filetransfer)) def send_file(self, filepath, dest_filepath): # clever_restart=False try: @@ -497,9 +520,8 @@ class ConnectionManager(object): logger.warning("File can not be opened due error: ".format(error)) else: logger.info("Sending file {} to {} (as: {})".format(filepath, self.addr, dest_filepath)) - self._send(MessageManager.create_message( - data, "binary", "filetransfer", "binary", {"filepath": dest_filepath} - )) + self._send(MessageManager.create_message(data, "binary", "message", + additional_headers={"action": "filetransfer", "filepath": dest_filepath})) class NotifierSock(Singleton): From 44264a020c044f141ad1b9391827a6c02048d63f Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 23 Jan 2020 17:55:38 +0300 Subject: [PATCH 105/210] Added get_file method --- messaging_lib.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/messaging_lib.py b/messaging_lib.py index b6938dc..00ff5b3 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -418,10 +418,13 @@ class ConnectionManager(object): logger.debug( "Request {} successfully closed with value {}".format(request, message.content["value"]) ) - try: - request.callback(self, value, *request.callback_args, **request.callback_kwargs) - except Exception as error: - logger.error("Error during response {} processing: {}".format(request, error)) + if request.callback is not None: + try: + request.callback(self, value, *request.callback_args, **request.callback_kwargs) + except Exception as error: + logger.error("Error during response {} processing: {}".format(request, error)) + else: + logger.info("No callback were registered for response: {}".format(request)) @staticmethod def _read_file(filepath): @@ -497,6 +500,20 @@ class ConnectionManager(object): ) self._send(MessageManager.create_request(requested_value, request_id, request_args, request_kwargs)) + def get_file(self, client_filepath, filepath=None, callback=None, + callback_args=(), callback_kwargs=None, ): + if callback_kwargs is None: + callback_kwargs = {} + + if filepath is None: + filepath = os.path.split(client_filepath)[1] + + request_kwargs = {"filepath": client_filepath} + callback_kwargs.update({"filepath": filepath}) + + self.get_response("filetransfer", callback, request_kwargs=request_kwargs, + callback_args=callback_args, callback_kwargs=callback_kwargs) + def _resend_requests(self): with self._request_lock: for request_id, request in self._request_queue.items(): # TODO filter From 881a3a00da796422cd60a2f42a3d61e405ee5172 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 23 Jan 2020 21:39:27 +0300 Subject: [PATCH 106/210] Tested and fixed filetransfer and request --- messaging_lib.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/messaging_lib.py b/messaging_lib.py index 00ff5b3..5f49ee9 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -385,15 +385,16 @@ class ConnectionManager(object): args = message.content["args"] kwargs = message.content["kwargs"] - callback = self.requests_callbacks.get(requested_value, None) - if callback is None: - logger.warning("Request {} does not exist!".format(requested_value)) - return filetransfer = requested_value == "filetransfer" try: if filetransfer: value = self._read_file(kwargs["filepath"]) else: + callback = self.requests_callbacks.get(requested_value, None) + if callback is None: + logger.warning("Request {} does not exist!".format(requested_value)) + return + value = callback(self, *args, **kwargs) except Exception as error: # TODO send response error\cancel logger.error("Error during request {} processing: {}".format(requested_value, error)) @@ -412,12 +413,14 @@ class ConnectionManager(object): if requested_value == "filetransfer": value = True self._process_filetransfer(message.content, request.callback_kwargs["filepath"]) + logger.debug( + "Request {} successfully closed with file bytes {}...".format(request, message.content[:256]) + ) else: value = message.content["value"] - - logger.debug( - "Request {} successfully closed with value {}".format(request, message.content["value"]) - ) + logger.debug( + "Request {} successfully closed with value {}".format(request, message.content["value"]) + ) if request.callback is not None: try: request.callback(self, value, *request.callback_args, **request.callback_kwargs) From 206cbec04e56c6a2a0caacbe06a71732f8496524 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 23 Jan 2020 21:48:09 +0300 Subject: [PATCH 107/210] Fixed broadcast sending + debug traceback --- Server/server.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Server/server.py b/Server/server.py index c4726d5..3e6e21c 100644 --- a/Server/server.py +++ b/Server/server.py @@ -8,6 +8,7 @@ import datetime import threading import selectors import collections +import traceback import inspect # Add parent dir to PATH to import messaging_lib and config_lib @@ -156,6 +157,7 @@ class Server(messaging.Singleton): client.process_events(mask) except Exception as error: logging.error("Exception {} occurred for {}! Resetting connection!".format(error, client.addr)) + traceback.print_exc() client.close(True) else: # Notifier client.process_events(mask) @@ -186,8 +188,8 @@ class Server(messaging.Singleton): def _ip_broadcast(self): logging.info("Broadcast sender thread started!") msg = messaging.MessageManager.create_action_message( - "server_ip", {"host": self.ip, "port": str(self.config.server_port), "id": self.id, - "start_time": str(self.time_started)}) + "server_ip", kwargs={"host": self.ip, "port": str(self.config.server_port), "id": self.id, + "start_time": str(self.time_started)}) logging.debug("Formed broadcast message: {}".format(msg)) broadcast_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) From ac1702c648a12825614f8a6e9677c1b9f0bd3c2b Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 23 Jan 2020 23:14:37 +0300 Subject: [PATCH 108/210] Added new config column + drag support --- Drone/copter_client.py | 9 +++- Server/config/spec/configspec_server.ini | 1 + Server/config_editor_models.py | 2 +- Server/copter_table.py | 4 +- Server/copter_table_models.py | 52 ++++++++++++++++++++---- Server/server_qt.py | 3 +- 6 files changed, 60 insertions(+), 11 deletions(-) diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 85c6fe2..597e6c1 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -645,6 +645,7 @@ def _play_animation(*args, **kwargs): ) +# noinspection PyAttributeOutsideInit class Telemetry: params_default_dict = { "git_version": None, @@ -652,13 +653,14 @@ class Telemetry: "battery": None, "armed": False, "fcu_status": None, - "cal_status": None, + "calibration_status": None, "mode": None, "selfcheck": None, "current_position": None, "start_position": None, "task": None, "time": None, + "config_version": None, } def __init__(self): @@ -690,6 +692,10 @@ class Telemetry: def get_git_version(cls): return subprocess.check_output("git log --pretty=format:'%h' -n 1", shell=True) + @classmethod + def get_config_version(cls): + return "{} V{}".format(client.active_client.config.config_name, client.active_client.config.config_version) + @classmethod def get_start_position(cls): x_start, y_start = animation.get_start_xy(os.path.abspath("animation.csv"), @@ -764,6 +770,7 @@ class Telemetry: def update_telemetry_slow(self): self.animation_id = animation.get_id() self.git_version = self.get_git_version() + self.config_version = self.get_config_varsion() try: self.cal_status = get_calibration_status() self.fcu_status = get_sys_status() diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index 905a6be..b0efd85 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -24,6 +24,7 @@ remove_disconnected = boolean(default=False) start_position = boolean(default=True) last_task = boolean(default=True) time_delta = boolean(default=True) + config_version = boolean(default=True) [[[__many__]]] __many__ = boolean diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index cf5dec2..898db7b 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -372,7 +372,7 @@ class ConfigModel(QtCore.QAbstractItemModel): return QtCore.Qt.CopyAction | QtCore.Qt.MoveAction def mimeTypes(self): - return ['app/configitem', 'text/xml'] + return ['app/configitem'] def mimeData(self, indexes): mimedata = QtCore.QMimeData() diff --git a/Server/copter_table.py b/Server/copter_table.py index b9b3a15..725c12b 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -49,8 +49,10 @@ class CopterTableWidget(QTableView): self.setSortingEnabled(True) self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents) self.resizeColumnsToContents() - self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectItems) + self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) self.doubleClicked.connect(self.on_double_click) + self.setDragEnabled(True) def moved(self, logical_index, old_index, new_index): name = self.current_columns.pop(old_index) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index b992aaf..d414760 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -1,12 +1,14 @@ import re import sys +import os import time import math import indexed +from contextlib import suppress from PyQt5 import QtCore, QtGui, QtWidgets -from PyQt5.QtCore import Qt as Qt - +from PyQt5.QtCore import Qt as Qt, QUrl, QDir +from PyQt5.QtWidgets import QApplication ModelDataRole = 998 ModelStateRole = 999 @@ -116,15 +118,18 @@ columns_names = {'copter_id': 'copter ID', 'current_position': 'current x y z yaw frame_id', 'start_position': ' start x y z ', 'last_task': 'last task', - 'time_delta': 'dt' + 'time_delta': 'dt', + 'config_version': 'configuration', } +columns = list(columns_names.keys()) class CopterData: class_basic_attrs = indexed.IndexedOrderedDict([('copter_id', None), ('git_ver', None), ('animation_id', None), - ('battery', None), ('fcu_status', None), ('cal_status', None), + ('battery', None), ('fcu_status', None), ('calibration_status', None), ('mode', None), ('selfcheck', None), ('current_position', None), - ('start_position', None), ('last_task', None), ('time_delta', None), ('client', None)]) + ('start_position', None), ('last_task', None), ('time_delta', None), + ("config_version", None), ('client', None)]) def __init__(self, **kwargs): self.attrs_dict = self.class_basic_attrs.copy() @@ -299,7 +304,11 @@ def place_time_delta(value): @ModelFormatter.col_format(11, ModelFormatter.VIEW_FORMATTER) def view_time_delta(value): - return "{:.3f}".format(value) + return "{:.3f}".format(value) + + +def is_column(index, column_name): + return index.column() == columns.index(column_name) class CopterDataModel(QtCore.QAbstractTableModel): @@ -470,7 +479,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.update_model(index, role) return True - def select_all(self): # probably NOT thread-safe! + def select_all(self): # probably NOT thread-safe! TODO remake self.first_col_is_checked = not self.first_col_is_checked for row_num, copter in enumerate(self.data_contents): copter.states.checked = int(self.first_col_is_checked)*2 @@ -480,8 +489,37 @@ class CopterDataModel(QtCore.QAbstractTableModel): roles = Qt.ItemIsSelectable | Qt.ItemIsEnabled if index.column() == 0: roles |= Qt.ItemIsUserCheckable | Qt.ItemIsEditable + if is_column(index, "config_version"): + roles |= Qt.ItemIsDragEnabled # | Qt.ItemIsDropEnabled + return roles + def supportedDropActions(self): + return QtCore.Qt.CopyAction + + def mimeTypes(self): + return ['text/plain'] + + def mimeData(self, indexes): + index = indexes[0] + if is_column(index, "config_version"): + return self._config_mime(index) + + return None + + def _config_mime(self, index): + mimedata = QtCore.QMimeData() + path = os.path.join(QDir.tempPath(), "config_{}.ini".format( + self.data_contents[index.row()].copter_id)) + + with suppress(OSError): # remove if file exists + os.remove(path) + + self.data_contents[index.row()].client.get_file("config/client.ini", path,) + mimedata.setUrls([QUrl.fromLocalFile(path)]) + + return mimedata + @QtCore.pyqtSlot(int, int, QtCore.QVariant, QtCore.QVariant) def update_item(self, row, col, value, role=Qt.EditRole): self.setData(self.index(row, col), value, role) diff --git a/Server/server_qt.py b/Server/server_qt.py index 242cd71..8a39c84 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -238,13 +238,14 @@ class MainWindow(QtWidgets.QMainWindow): "animation_id": 2, "battery": 3, "fcu_status": 4, - "cal_status": 5, + "calibration_status": 5, "mode": 6, "selfcheck": 7, "current_position": 8, "start_position": 9, "task": 10, "time": 11, + "config_version": 12, } for key, value in telems.items(): From 96d559dbc99d517e58460a2eef22bcda96d37cbd Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Fri, 24 Jan 2020 10:46:03 +0300 Subject: [PATCH 109/210] Server: Add .yaml settings files for sending with launch files --- Server/server_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index c81cd42..5eb8779 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -433,7 +433,7 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_launch(self): - self.send_directory_files("Select directory with launch files", ('.launch', ), match_id=False, + self.send_directory_files("Select directory with launch files", ('.launch', '.yaml'), match_id=False, client_path='/home/pi/catkin_ws/src/clever/clever/launch/') # TODO clever restart callback? @pyqtSlot() From 9465e2e7d5269f0500f6456ec69afc7d8061950f Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Fri, 24 Jan 2020 10:57:56 +0300 Subject: [PATCH 110/210] Server: Restart visual_pose_watchdog service with clever-show --- Server/server_qt.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 5eb8779..8e51d3d 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -127,8 +127,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_send_any_command.triggered.connect(self.send_any_command) self.ui.action_restart_clever.triggered.connect( b_partial(self.send_to_selected, "service_restart", {"name": "clever"})) - self.ui.action_restart_clever_show.triggered.connect( - b_partial(self.send_to_selected, "service_restart", {"name": "clever-show"})) + self.ui.action_restart_clever_show.triggered.connect(self.restart_clever_show) self.ui.action_update_client_repo.triggered.connect(b_partial(self.send_to_selected, "update_repo")) self.ui.action_reboot_all.triggered.connect(b_partial(self.send_to_selected, "reboot_all")) self.ui.action_set_start_to_current_position.triggered.connect(b_partial(self.send_to_selected, "move_start")) @@ -474,6 +473,12 @@ class MainWindow(QtWidgets.QMainWindow): if ok and text: self.send_to_selected("execute", {"command": text}) + @pyqtSlot() + def restart_clever_show(self): + for copter in self.model.user_selected(): + copter.client.send_message("service_restart", {"name": "visual_pose_watchdog"}) + copter.client.send_message("service_restart", {"name": "clever-show"}) + @pyqtSlot() def select_music_file(self): path = QFileDialog.getOpenFileName(self, "Select music file", filter="Music files (*.mp3 *.wav)")[0] From d0eed91f7362aacd1d15791238aaeabe6fe6f3cd Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Fri, 24 Jan 2020 11:22:23 +0300 Subject: [PATCH 111/210] Server: Restart chrony on Linux server --- Server/server_qt.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 8e51d3d..dc27723 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -5,6 +5,7 @@ import glob import time import logging import asyncio +import platform import itertools from functools import partial, wraps @@ -134,7 +135,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_reset_start.triggered.connect(b_partial(self.send_to_selected, "reset_start")) self.ui.action_set_z_offset_to_ground.triggered.connect(b_partial(self.send_to_selected, "set_z_to_ground")) self.ui.action_reset_z_offset.triggered.connect(b_partial(self.send_to_selected, "reset_z_offset")) - self.ui.action_restart_chrony.triggered.connect(b_partial(self.send_to_selected, "repair_chrony")) + self.ui.action_restart_chrony.triggered.connect(self.restart_chrony) self.ui.action_select_music_file.triggered.connect(self.select_music_file) self.ui.action_play_music.triggered.connect(self.play_music) self.ui.action_stop_music.triggered.connect(self.stop_music) @@ -479,6 +480,13 @@ class MainWindow(QtWidgets.QMainWindow): copter.client.send_message("service_restart", {"name": "visual_pose_watchdog"}) copter.client.send_message("service_restart", {"name": "clever-show"}) + @pyqtSlot() + def restart_chrony(self): + if platform.system() == 'Linux': + os.system("pkexec systemctl restart chrony") + for copter in self.model.user_selected(): + copter.client.send_message("repair_chrony") + @pyqtSlot() def select_music_file(self): path = QFileDialog.getOpenFileName(self, "Select music file", filter="Music files (*.mp3 *.wav)")[0] From e38b396da5d69f15583b30384849d6e4c9ade79c Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 24 Jan 2020 15:45:34 +0300 Subject: [PATCH 112/210] Added save as button for config editor --- Server/config_editor.py | 18 +++++++++++------- Server/config_editor.ui | 28 +++++++++++++++++++++------- Server/config_editor_models.py | 13 +++++++++++++ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/Server/config_editor.py b/Server/config_editor.py index e6e5f67..aa4a7de 100644 --- a/Server/config_editor.py +++ b/Server/config_editor.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'config_editor.ui' # -# Created by: PyQt5 UI code generator 5.13.2 +# Created by: PyQt5 UI code generator 5.14.0 # # WARNING! All changes made in this file will be lost! @@ -25,19 +25,22 @@ class Ui_config_dialog(object): self.gridLayout.addWidget(self.config_view, 0, 0, 1, 1) self.gridLayout_2 = QtWidgets.QGridLayout() self.gridLayout_2.setObjectName("gridLayout_2") - self.buttonBox = QtWidgets.QDialogButtonBox(config_dialog) - self.buttonBox.setOrientation(QtCore.Qt.Horizontal) - self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Save) - self.buttonBox.setCenterButtons(False) - self.buttonBox.setObjectName("buttonBox") - self.gridLayout_2.addWidget(self.buttonBox, 0, 2, 1, 1) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.gridLayout_2.addItem(spacerItem, 0, 2, 1, 1) self.do_restart = QtWidgets.QCheckBox(config_dialog) self.do_restart.setObjectName("do_restart") self.gridLayout_2.addWidget(self.do_restart, 0, 1, 1, 1) + self.buttonBox = QtWidgets.QDialogButtonBox(config_dialog) + self.buttonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Save) + self.buttonBox.setObjectName("buttonBox") + self.gridLayout_2.addWidget(self.buttonBox, 0, 4, 1, 1) self.do_coloring = QtWidgets.QCheckBox(config_dialog) self.do_coloring.setChecked(True) self.do_coloring.setObjectName("do_coloring") self.gridLayout_2.addWidget(self.do_coloring, 0, 0, 1, 1) + self.save_as_button = QtWidgets.QPushButton(config_dialog) + self.save_as_button.setObjectName("save_as_button") + self.gridLayout_2.addWidget(self.save_as_button, 0, 3, 1, 1) self.gridLayout.addLayout(self.gridLayout_2, 2, 0, 1, 1) self.line = QtWidgets.QFrame(config_dialog) self.line.setFrameShape(QtWidgets.QFrame.HLine) @@ -56,6 +59,7 @@ class Ui_config_dialog(object): self.do_restart.setText(_translate("config_dialog", "Restart")) self.do_restart.setShortcut(_translate("config_dialog", "R")) self.do_coloring.setText(_translate("config_dialog", "Color Indication")) + self.save_as_button.setText(_translate("config_dialog", "Save as")) if __name__ == "__main__": diff --git a/Server/config_editor.ui b/Server/config_editor.ui index 6527d93..d9110f3 100644 --- a/Server/config_editor.ui +++ b/Server/config_editor.ui @@ -33,17 +33,17 @@ - + Qt::Horizontal - - QDialogButtonBox::Cancel|QDialogButtonBox::Save + + + 40 + 20 + - - false - - + @@ -55,6 +55,13 @@ + + + + QDialogButtonBox::Cancel|QDialogButtonBox::Save + + + @@ -65,6 +72,13 @@ + + + + Save as + + + diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 898db7b..b12627c 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -715,6 +715,7 @@ class ConfigDialog(QtWidgets.QDialog): self.ui.config_view.expandAll() self.ui.do_coloring.stateChanged.connect(self.model.enable_color) + self.ui.save_as_button.clicked.connect(self.save_as) # self.ui.delete_button.pressed.connect(self.remove_selected) @@ -728,6 +729,17 @@ class ConfigDialog(QtWidgets.QDialog): ) return reply == QMessageBox.Yes + def save_as(self): + cfg = config.ConfigManager() + cfg.load_from_dict(self.model.to_config_dict()) + save_path = QFileDialog.getSaveFileName(self, "Save as configuration file", + filter="Config files (*.ini)")[0] + if not save_path: + return + + cfg.config.filename = save_path + cfg.write() + @pyqtSlot() def run(self): self.show() @@ -807,6 +819,7 @@ class ConfigDialog(QtWidgets.QDialog): cfg.config.filename = save_path cfg.write() + return True if __name__ == '__main__': From 013422774258d44fe63319c26e003610cc09eed6 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 24 Jan 2020 15:46:48 +0300 Subject: [PATCH 113/210] Optimization of config sending via network --- Drone/client.py | 3 +-- Server/config_editor_models.py | 4 ++-- Server/server_qt.py | 2 +- config.py | 17 +++++++++-------- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/Drone/client.py b/Drone/client.py index 43f9b74..73a6188 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -170,7 +170,6 @@ class Client(object): while True: #self.server_connection.send_message("telemetry", kwargs={"value":{"time": time.time()}}) events = self.selector.select(timeout=1) - for key, mask in events: connection = key.data if connection is not None: @@ -217,7 +216,7 @@ def _command_config_write(*args, **kwargs): @messaging.request_callback("config") def _response_config(*args, **kwargs): - response = {"config": active_client.config.full_dict, + response = {"config": active_client.config.full_dict(), "configspec": dict(active_client.config.config.configspec)} return response diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index b12627c..eb7aeff 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -773,7 +773,7 @@ class ConfigDialog(QtWidgets.QDialog): cfg = config.ConfigManager() cfg.load_from_dict(config_dict, spec_dict) - self.setupModel(config_dict) + self.setupModel(cfg.full_dict(include_defaults=True)) if not self.validation_loop(cfg, spec_dict): return False @@ -799,7 +799,7 @@ class ConfigDialog(QtWidgets.QDialog): "Config cannot be opened or validated: {}".format(error)) return False - self.setupModel(cfg.full_dict, convert_types=(not cfg.validated)) + self.setupModel(cfg.full_dict(include_defaults=True), convert_types=(not cfg.validated)) self.ui.do_restart.setDisabled(True) filename = cfg.config.filename diff --git a/Server/server_qt.py b/Server/server_qt.py index 862741c..8292258 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -462,7 +462,7 @@ class MainWindow(QtWidgets.QMainWindow): config = cfg.ConfigManager() config.load_only_config(path) - data = config.full_dict + data = config.full_dict() logging.info(f"Loaded config from {path}") copters = self.model.user_selected() diff --git a/config.py b/config.py index f3e8e81..f5ade20 100644 --- a/config.py +++ b/config.py @@ -92,7 +92,7 @@ class ConfigManager: self.validated = True @classmethod - def _full_dict(cls, item): + def _full_dict(cls, item, include_defaults=False): if not isinstance(item, Section): return item @@ -103,15 +103,17 @@ class ConfigManager: inline_comments = item.inline_comments for key, value in item.items(): - result = cls._full_dict(value) + result = cls._full_dict(value, include_defaults) if not isinstance(result, dict): item_d = {'__option__': True, 'value': value, - 'default': default_values.get(key, None), - 'unchanged': key in defaults, 'comments': comments.get(key, []), 'inline_comment': inline_comments.get(key, None), } + if include_defaults: + item_d.update({'default': default_values.get(key, None), + 'unchanged': key in defaults, + }) data[key] = item_d else: @@ -119,9 +121,8 @@ class ConfigManager: return data - @property - def full_dict(self): - d = self._full_dict(self.config) + def full_dict(self, include_defaults=False): + d = self._full_dict(self.config, include_defaults=include_defaults) d['initial_comment'] = self.config.initial_comment d['final_comment'] = self.config.final_comment return d @@ -322,7 +323,7 @@ if __name__ == '__main__': #pprint.pprint(cfg2.full_dict) cfg.merge(cfg2) #pprint.pprint(cfg.full_dict) - print(cfg.full_dict) + print(cfg.full_dict(include_defaults=True)) print(dict(cfg.config.configspec)) #print(dict(ConfigManager(cfg.config.configspec).config)) From 842c9ef11e015e64fcdf27d9cbb7b7acbe75e9b0 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 24 Jan 2020 16:01:27 +0300 Subject: [PATCH 114/210] Added deleting shortcut --- Server/config_editor_models.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index eb7aeff..b51f282 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -6,8 +6,9 @@ from copy import deepcopy from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt, pyqtSlot -from PyQt5.QtGui import QCursor -from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu, QAction, QMessageBox, QInputDialog, QFileDialog +from PyQt5.QtGui import QCursor, QKeySequence +from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu, QAction, QMessageBox, QInputDialog, QFileDialog, \ + QShortcut import config_editor @@ -542,6 +543,17 @@ class ConfigTreeWidget(QTreeView): self.setDropIndicatorShown(True) self.setAnimated(True) + self.delete_shortcut = QShortcut(QKeySequence('Del'), self) + self.delete_shortcut.activated.connect( + self.with_selected(self.remove)) + + def with_selected(self, f, *args, **kwargs): + def decorated(): + index = self.selectedIndexes()[0] + return f(index, *args, **kwargs) + + return decorated + def open_menu(self, point): index = self.indexAt(point) item = index.internalPointer() @@ -719,8 +731,6 @@ class ConfigDialog(QtWidgets.QDialog): # self.ui.delete_button.pressed.connect(self.remove_selected) - # index = self.config_view.selectedIndexes()[0] - def edit_caution(self): reply = QMessageBox().warning(self, "Editing caution", "Are you sure you want to edit section/option name? " From 0d39727425a8532e457a58a3845d76b72c62064c Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 24 Jan 2020 17:01:56 +0300 Subject: [PATCH 115/210] Add other shortcuts --- Server/config_editor_models.py | 37 +++++++++++++++++++++++++++++----- 1 file changed, 32 insertions(+), 5 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index b51f282..a835d86 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -543,9 +543,22 @@ class ConfigTreeWidget(QTreeView): self.setDropIndicatorShown(True) self.setAnimated(True) - self.delete_shortcut = QShortcut(QKeySequence('Del'), self) - self.delete_shortcut.activated.connect( - self.with_selected(self.remove)) + self.duplicate_shortcut = QShortcut(QKeySequence('Shift+D'), self) + self.duplicate_shortcut.activated.connect(self.with_selected(self.duplicate)) + self.exclude_shortcut = QShortcut(QKeySequence('Alt+Del'), self) + self.exclude_shortcut.activated.connect(self.with_selected(self.exclude)) + self.remove_shortcut = QShortcut(QKeySequence('Del'), self) + self.remove_shortcut.activated.connect(self.with_selected(self.remove)) + self.clear_shortcut = QShortcut(QKeySequence('Shift+R'), self) + self.clear_shortcut.activated.connect(self.with_selected(self.reset_item, 'clear_value')) + self.default_shortcut = QShortcut(QKeySequence('Ctrl+R'), self) + self.default_shortcut.activated.connect(self.with_selected(self.reset_item, 'default')) + self.reset_shortcut = QShortcut(QKeySequence('Alt+R'), self) + self.reset_shortcut.activated.connect(self.with_selected(self.reset_item, 'all')) + self.item_shortcut = QShortcut(QKeySequence('Shift+A'), self) + self.item_shortcut.activated.connect(self.with_selected(self.add_item, False)) + self.section_shortcut = QShortcut(QKeySequence('Ctrl+A'), self) + self.section_shortcut.activated.connect(self.with_selected(self.add_item, True)) def with_selected(self, f, *args, **kwargs): def decorated(): @@ -561,38 +574,46 @@ class ConfigTreeWidget(QTreeView): menu = QMenu() duplicate = QAction("Duplicate") + duplicate.setShortcut(self.duplicate_shortcut.key()) duplicate.triggered.connect(partial(self.duplicate, index)) menu.addAction(duplicate) exclude = QAction("Toggle exclude") + exclude.setShortcut(self.exclude_shortcut.key()) exclude.triggered.connect(partial(self.exclude, index)) menu.addAction(exclude) remove = QAction("Remove from config") + remove.setShortcut(self.remove_shortcut.key()) remove.triggered.connect(partial(self.remove, index)) menu.addAction(remove) menu.addSeparator() clear = QAction("Clear item value") + clear.setShortcut(self.clear_shortcut.key()) clear.triggered.connect(partial(self.reset_item, index, 'clear_value')) menu.addAction(clear) reset_default = QAction("Reset value to default") + reset_default.setShortcut(self.default_shortcut.key()) reset_default.triggered.connect(partial(self.reset_item, index, 'default')) menu.addAction(reset_default) reset_all = QAction("Reset all changes") + reset_all.setShortcut(self.reset_shortcut.key()) reset_all.triggered.connect(partial(self.reset_item, index, 'all')) menu.addAction(reset_all) menu.addSeparator() add_option = QAction("Add option") + add_option.setShortcut(self.item_shortcut.key()) add_option.triggered.connect(partial(self.add_item, index, False)) menu.addAction(add_option) add_section = QAction("Add section") + add_section.setShortcut(self.section_shortcut.key()) add_section.triggered.connect(partial(self.add_item, index, True)) menu.addAction(add_section) @@ -637,6 +658,8 @@ class ConfigTreeWidget(QTreeView): parentItem = self.model().nodeFromIndex(index) if parentItem.type in ('list', 'list_item'): + if is_section: + return item_type = 'list_item' else: item_type = 'section' if is_section else 'option' @@ -686,9 +709,13 @@ class ConfigTreeWidget(QTreeView): model.setData(index, 'unchanged', role=StateRole) elif reset_type == 'clear_value': - model.setData(itemdataindex, None) + item_type = model.data(itemdataindex, TypeRole) + if item_type == 'list': + return + if item_type != 'section': + model.setData(itemdataindex, None) - # if model.data(itemdataindex, TypeRole) == 'list': + # if model.data(itemdataindex, TypeRole) == 'list': # TODO # model.removeRows(0, item.childCount(), index) # model.setData(index, 'option', role=TypeRole) # return From 97a3b2f23ff24107fc64d28c7af4c0b7dff1721b Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 26 Jan 2020 20:20:28 +0300 Subject: [PATCH 116/210] Confirmation for exit while usaved file opened --- Server/config_editor_models.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index a835d86..2ac0e0c 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -731,6 +731,7 @@ class ConfigDialog(QtWidgets.QDialog): super(ConfigDialog, self).__init__(parent) self.ui = config_editor.Ui_config_dialog() self.model = ConfigModel(widget=self) + self.unsaved = False self.setupUi() self.copter_editor_signal.connect(self._call_copter_dialog) @@ -744,6 +745,8 @@ class ConfigDialog(QtWidgets.QDialog): self.ui.config_view.resizeColumnToContents(0) self.ui.config_view.resizeColumnToContents(1) + self.model.dataChanged.connect(self.unsaved_call) # connect after setup + def setupUi(self): self.ui.setupUi(self) @@ -758,6 +761,26 @@ class ConfigDialog(QtWidgets.QDialog): # self.ui.delete_button.pressed.connect(self.remove_selected) + def unsaved_call(self): + name = self.windowTitle()+'*' + self.setWindowTitle(name) + self.unsaved = True + self.model.dataChanged.disconnect(self.unsaved_call) + + def closeEvent(self, event): + if not self.unsaved or self.result(): + event.accept() + return + + reply = QMessageBox.question(self, "Confirm exit", "There are unsaved changes in config file. " + "Are you sure you want to exit?", + QMessageBox.No | QMessageBox.Yes, QMessageBox.No) + + if reply != QMessageBox.Yes: + event.ignore() + else: + event.accept() + def edit_caution(self): reply = QMessageBox().warning(self, "Editing caution", "Are you sure you want to edit section/option name? " From 1fb6670fd07c6b992f6bd9ebb0fd8f28174e8a23 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sun, 26 Jan 2020 21:56:13 +0300 Subject: [PATCH 117/210] better state handling for sections --- Server/config_editor_models.py | 35 +++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 2ac0e0c..8471878 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -79,7 +79,7 @@ class ConfigModelItem: if comments: try: raw_spec = comments.split('\n')[-1].split()[1:] - if raw_spec[0] == '__list__': # and len(raw_spec[1:]) == len(data): + if raw_spec[0] == '__list__': # and len(raw_spec[1:]) == len(data): return raw_spec[1:] except IndexError: pass @@ -156,10 +156,21 @@ class ConfigModelItem: def check_state(self): if self.spec_default is not None and self.data(1) == self.spec_default \ - and self.data(0) == self.default_values[0]: + and self.data(0) == self.default_values[0] and self.type != 'section': self.set_state('default') + print('def', self.data(1), self.data(0), self.spec_default) - def set_state(self, state): + child_states = [child.state for child in self.childItems] + if any(state in child_states for state in ['edited', 'added', 'deleted']): + self.state = 'edited' + if len(set(child_states)) == 1: # if all states equal + self.set_state(child_states[0], set_children=False) + print(child_states) + + if self.parentItem is not None: + self.parentItem.check_state() + + def set_state(self, state, set_children=True): if self.state == 'unchanged' and state == 'default': return @@ -168,11 +179,12 @@ class ConfigModelItem: self.state = state - for child in self.childItems: - child.set_state(state) + if set_children: # to prevent cycle state set + for child in self.childItems: + child.set_state(state) - if state == 'edited': - self.parentItem.state = state + # if state == 'edited': + # self.parentItem.state = state def set_type(self, item_type): self.type = item_type @@ -355,10 +367,10 @@ class ConfigModel(QtCore.QAbstractItemModel): if index.column() == 0: if item.type != 'list_item': - flags |= int(QtCore.Qt.ItemIsDragEnabled) + flags |= int(Qt.ItemIsDragEnabled) if item.type == 'section': - flags |= int(QtCore.Qt.ItemIsDropEnabled) + flags |= int(Qt.ItemIsDropEnabled) not_section = not (index.column() > 0 and item.type == 'section') not_list_item = not (index.column() > 1 and item.type == 'list_item') @@ -472,6 +484,7 @@ class ConfigModel(QtCore.QAbstractItemModel): else: section = ConfigModelItem((key,), parent=parent, item_type='section') self.config_dict_setup(item, convert_types=convert_types, parent=section) + section.check_state() def to_dict(self, parent=None) -> dict: if parent is None: @@ -642,7 +655,7 @@ class ConfigTreeWidget(QTreeView): item.set_state('added') ensure_unique_names(item) self.model().insertItems(index.row() + 1, [item], index.parent()) - self.expandAll() # fix not expanded duplicated section + self.expandAll() # fixes not expanded duplicated section def remove(self, index): self.model().removeRow(index) @@ -650,7 +663,7 @@ class ConfigTreeWidget(QTreeView): def exclude(self, index): item = self.model().nodeFromIndex(index) if item.state == 'deleted': - self.model().setData(index, item.default_state, StateRole) + self.model().setData(index, item.previous_state, StateRole) else: self.model().setData(index, 'deleted', StateRole) From d3c06ea2b7715f0f3115a2177fbb6f61cde5e58b Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 28 Jan 2020 19:05:16 +0300 Subject: [PATCH 118/210] CopterData and table update --- Server/copter_table.py | 17 +-- Server/copter_table_models.py | 277 +++++++++++++++++----------------- Server/server_qt.py | 18 +-- 3 files changed, 150 insertions(+), 162 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index 725c12b..598e884 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -13,23 +13,20 @@ import copter_table_models as table class CopterTableWidget(QTableView): - def __init__(self, model, config, data_model=table.StatedCopterData): + def __init__(self, model, config): QTableView.__init__(self) self.config = config self.model = model - self._data_model = data_model self.proxy_model = table.CopterProxyModel() - self.signals = table.SignalManager(self.model) - self.proxy_model.setSourceModel(self.model) self.proxy_model.setDynamicSortFilter(True) # Initiate table and table self.model self.setModel(self.proxy_model) - self.columns = list(table.columns_names.keys()) #[header.strip() for header in self.model.headers] # header keys + self.columns = self.model.columns #[header.strip() for header in self.model.headers] # header keys self.current_columns = self.columns[:] header = self.horizontalHeader() @@ -78,16 +75,6 @@ class CopterTableWidget(QTableView): for name, show in item_dict.items(): # for index, name in enumerate(self.columns): self.setColumnHidden(self.columns.index(name), not show) # self.setColumnHidden(index, not item_dict.get(name, False)) - # Some fancy wrappers to simplify syntax - def add_client(self, **kwargs): - self.signals.add_client_signal.emit(self._data_model(**kwargs)) - - def remove_client_data(self, row_data): - self.signals.remove_client_signal.emit(row_data) - - def update_data(self, row, col, data, role=table.ModelDataRole): - self.signals.update_data_signal.emit(row, col, data, role) - @pyqtSlot(QtCore.QModelIndex) def on_double_click(self, index): col = index.column() diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index d414760..739c4ad 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -1,14 +1,12 @@ +import os import re import sys -import os import time import math -import indexed from contextlib import suppress from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt as Qt, QUrl, QDir -from PyQt5.QtWidgets import QApplication ModelDataRole = 998 ModelStateRole = 999 @@ -18,8 +16,8 @@ class ModelChecks: checks_dict = {} takeoff_checklist = (3, 4, 6, 7, 8) - battery_min = 50.0 # config.getfloat('CHECKS', 'battery_percentage_min') - start_pos_delta_max = 1.0 # config.getfloat('CHECKS', 'start_pos_delta_max') + battery_min = 50.0 + start_pos_delta_max = 1.0 time_delta_max = 1.0 @classmethod @@ -64,7 +62,7 @@ def check_anim(item): def check_bat(item): if item == "NO_INFO": return False - return item[1]*100 > ModelChecks.battery_min + return item[1] * 100 > ModelChecks.battery_min @ModelChecks.col_check(4) @@ -98,87 +96,76 @@ def check_pos_status(item): def check_start_pos_status(item): return item != 'NO_POS' + @ModelChecks.col_check(10) def check_selfcheck(item): return True + @ModelChecks.col_check(11) def check_time_delta(item): return abs(item) < ModelChecks.time_delta_max -columns_names = {'copter_id': 'copter ID', - 'git_version': 'version', - 'animation_id': ' animation ID ', - 'battery': ' battery ', - 'fcu_status': 'FCU status', - 'calibration_status': 'sensors', - 'mode': ' mode ', - 'selfcheck': ' checks ', - 'current_position': 'current x y z yaw frame_id', - 'start_position': ' start x y z ', - 'last_task': 'last task', - 'time_delta': 'dt', - 'config_version': 'configuration', - } -columns = list(columns_names.keys()) + class CopterData: - class_basic_attrs = indexed.IndexedOrderedDict([('copter_id', None), ('git_ver', None), ('animation_id', None), - ('battery', None), ('fcu_status', None), ('calibration_status', None), - ('mode', None), ('selfcheck', None), ('current_position', None), - ('start_position', None), ('last_task', None), ('time_delta', None), - ("config_version", None), ('client', None)]) + def __init__(self, columns=(), **kwargs): + self._columns = columns + for column in columns: + setattr(self, column, None) - def __init__(self, **kwargs): - self.attrs_dict = self.class_basic_attrs.copy() - self.attrs_dict.update(kwargs) - - for attr, value in self.attrs_dict.items(): + for attr, value in kwargs.items(): setattr(self, attr, value) def __getitem__(self, key): - return getattr(self, self.attrs_dict.keys()[key]) + if key in self._columns: + return getattr(self, key) + return getattr(self, self._columns[key]) def __setitem__(self, key, value): - setattr(self, self.attrs_dict.keys()[key], value) + if key in self._columns: + setattr(self, key, value) + else: + setattr(self, self._columns[key], value) class StatedCopterData(CopterData): - class_basic_states = indexed.IndexedOrderedDict([("checked", 0), ("selfchecked", None), ("takeoff_ready", None), - ("copter_id", True), ]) + def __init__(self, columns=(), checks_defaults=None, checks_class=ModelChecks, **kwargs): + if checks_defaults is None: + checks_defaults = {} - def __init__(self, checks_class=ModelChecks, **kwargs): - self.states = CopterData(**self.class_basic_states) - self.checks = checks_class + self.__dict__['states'] = CopterData(columns, **checks_defaults) + self.__dict__['checks'] = checks_class + self.__dict__['all_checks'] = None - super(StatedCopterData, self).__init__(**kwargs) + super().__init__(columns, **kwargs) def __setattr__(self, key, value): self.__dict__[key] = value - if key in self.class_basic_attrs.keys(): - try: - self.states.__dict__[key] = \ - ModelChecks.checks_dict[self.attrs_dict.keys().index(key)](value) - if key == 'start_position': - if (self.__dict__['current_position'] is not None) and (self.__dict__['start_position'] is not None): - current_pos = get_position(self.__dict__['current_position']) - start_pos = get_position(self.__dict__['start_position']) - delta = get_position_delta(current_pos, start_pos) - if delta != 'NO_POS': - self.states.__dict__[key] = (delta < ModelChecks.start_pos_delta_max) - except KeyError: # No check present for that col - pass - else: # update selfchecked and takeoff_ready - self.states.__dict__["selfchecked"] = all( - [self.states[i] for i in ModelChecks.checks_dict.keys()] - ) + if key in self._columns: + with suppress(KeyError): + # print(self.__dict__) + self.states.__dict__[key] = self.checks.checks_dict[self._columns.index(key)](value) + self.states.__dict__["all_checks"] = all([self.states[i] for i in ModelChecks.checks_dict.keys()]) + + # if key == 'start_position': + # if (self.__dict__['current_position'] is not None) and ( + # self.__dict__['start_position'] is not None): + # current_pos = get_position(self.__dict__['current_position']) + # start_pos = get_position(self.__dict__['start_position']) + # delta = get_position_delta(current_pos, start_pos) + # if delta != 'NO_POS': + # self.states.__dict__[key] = (delta < ModelChecks.start_pos_delta_max) + + # update all_checks and takeoff_ready + + # self.states.__dict__["takeoff_ready"] = all( + # [self.states[i] for i in ModelChecks.takeoff_checklist] + # ) - self.states.__dict__["takeoff_ready"] = all( - [self.states[i] for i in ModelChecks.takeoff_checklist] - ) def get_position(pos_array): if pos_array[0] != 'nan' and pos_array != 'NO_POS': @@ -194,7 +181,7 @@ def get_position_delta(pos1, pos2): if pos1 != 'NO_POS' and pos2 != 'NO_POS': delta_squared = 0 for i in range(3): - delta_squared += (pos1[i]-pos2[i])**2 + delta_squared += (pos1[i] - pos2[i]) ** 2 return math.sqrt(delta_squared) return 'NO_POS' @@ -266,18 +253,20 @@ def place_battery(value): def view_battery(value): if isinstance(value, list): battery_v, battery_p = value - return "{:.1f}V {:d}%".format(battery_v, int(battery_p*100)) + return "{:.1f}V {:d}%".format(battery_v, int(battery_p * 100)) return value + @ModelFormatter.col_format(7, ModelFormatter.VIEW_FORMATTER) def view_selfcheck(value): if isinstance(value, list): - if len(value)==1: + if len(value) == 1: if len(value[0]) <= 8: return value[0] return "ERROR" return value + @ModelFormatter.col_format(8, ModelFormatter.VIEW_FORMATTER) def view_selfcheck(value): if isinstance(value, list): @@ -285,6 +274,7 @@ def view_selfcheck(value): return "{:.2f} {:.2f} {:.2f} {:d} {}".format(x, y, z, int(yaw), frame) return value + @ModelFormatter.col_format(9, ModelFormatter.VIEW_FORMATTER) def view_selfcheck(value): if isinstance(value, list): @@ -292,44 +282,71 @@ def view_selfcheck(value): return "{:.2f} {:.2f} {:.2f}".format(x, y, z) return value + @ModelFormatter.col_format(10, ModelFormatter.PLACE_FORMATTER) def view_last_task(value): if value is None: return 'No task' return value + @ModelFormatter.col_format(11, ModelFormatter.PLACE_FORMATTER) def place_time_delta(value): - return abs(value - time.time()) + return abs(value - time.time()) + @ModelFormatter.col_format(11, ModelFormatter.VIEW_FORMATTER) def view_time_delta(value): return "{:.3f}".format(value) -def is_column(index, column_name): - return index.column() == columns.index(column_name) - - class CopterDataModel(QtCore.QAbstractTableModel): + columns_dict = {'copter_id': 'copter ID', + 'git_version': 'version', + 'animation_id': ' animation ID ', + 'battery': ' battery ', + 'fcu_status': 'FCU status', + 'calibration_status': 'sensors', + 'mode': ' mode ', + 'selfcheck': ' checks ', + 'current_position': 'current x y z yaw frame_id', + 'start_position': ' start x y z ', + 'last_task': 'last task', + 'time_delta': 'dt', + 'config_version': 'configuration', + } + + columns = list(columns_dict.keys()) + selected_ready_signal = QtCore.pyqtSignal(bool) selected_takeoff_ready_signal = QtCore.pyqtSignal(bool) selected_flip_ready_signal = QtCore.pyqtSignal(bool) # TODO fix this signals selected_calibrating_signal = QtCore.pyqtSignal(bool) selected_calibration_ready_signal = QtCore.pyqtSignal(bool) - def __init__(self, checks=ModelChecks, formatter=ModelFormatter, parent=None): + update_data_signal = QtCore.pyqtSignal(int, int, QtCore.QVariant, QtCore.QVariant) + add_client_signal = QtCore.pyqtSignal(object) + remove_row_signal = QtCore.pyqtSignal(int) + remove_client_signal = QtCore.pyqtSignal(object) + + def __init__(self, checks=ModelChecks, formatter=ModelFormatter, data_model=StatedCopterData, parent=None): super(CopterDataModel, self).__init__(parent) # self.headers = (' copter ID ', ' version ', ' animation ID ', ' battery ', ' fcu_status ', ' sensors ', # ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' task ', 'dt') - self.headers = list(columns_names.values()) + self.headers = list(self.columns_dict.values()) self.data_contents = [] self.checks = checks self.formatter = formatter + self.data_model = data_model self.first_col_is_checked = False + self.update_data_signal.connect(self._update_item) + self.add_client_signal.connect(self._add_client) + self.remove_row_signal.connect(self._remove_row) + self.remove_client_signal.connect(self._remove_row_data) + def insertRows(self, contents, position='last', parent=QtCore.QModelIndex()): rows = len(contents) position = len(self.data_contents) if position == 'last' else position @@ -345,56 +362,37 @@ class CopterDataModel(QtCore.QAbstractTableModel): return True + @classmethod + def is_column(cls, index, column_name): + return index.column() == cls.columns.index(column_name) + + def filter(self, f, contents=()): + contents = contents or self.data_contents + return filter(f, contents) + def user_selected(self, contents=()): - contents = contents or self.data_contents - return filter(lambda x: x.states.checked == Qt.Checked, contents) - - def selfchecked_ready(self, contents=()): - contents = contents or self.data_contents - return filter(lambda x: x.states.selfchecked, contents) - - def takeoff_ready(self, contents=()): - contents = contents or self.data_contents - return filter(lambda x: x.states.takeoff_ready, contents) - - def flip_ready(self, contents=()): - contents = contents or self.data_contents - return filter(flip_checks, contents) # possibly change as takeoff checks - - def calibrating(self, contents=()): - contents = contents or self.data_contents - return filter(calibrating_check, contents) - - def calibration_ready(self, contents=()): - contents = contents or self.data_contents - return filter(calibration_ready_check, contents) + return self.filter(lambda x: x.states.checked == Qt.Checked, contents) def get_row_data(self, index): row = index.row() if row == -1: return None try: - data = self.data_contents[row] + return self.data_contents[row] except IndexError: return None - else: - return data def get_row_index(self, row_data): try: - index = self.data_contents.index(row_data) + return self.data_contents.index(row_data) except ValueError: return None - else: - return index def get_row_by_attr(self, attr, value): try: - row_data = next(filter(lambda x: getattr(x, attr, None) == value, self.data_contents)) + return next(filter(lambda x: getattr(x, attr, None) == value, self.data_contents)) except StopIteration: return None - else: - return row_data def rowCount(self, n=None): return len(self.data_contents) @@ -438,12 +436,11 @@ class CopterDataModel(QtCore.QAbstractTableModel): def update_model(self, index=QtCore.QModelIndex(), role=QtCore.Qt.EditRole): selected = set(self.user_selected()) - self.selected_ready_signal.emit(selected.issubset(self.selfchecked_ready())) - self.selected_takeoff_ready_signal.emit(selected.issubset(self.takeoff_ready())) - - self.selected_flip_ready_signal.emit(selected.issubset(self.flip_ready())) - self.selected_calibrating_signal.emit(selected.issubset(self.calibrating())) - self.selected_calibration_ready_signal.emit(selected.issubset(self.calibration_ready())) + self.selected_ready_signal.emit(selected.issubset(self.filter(lambda x: x.states.all_checks))) + #self.selected_takeoff_ready_signal.emit(selected.issubset(self.filter(lambda x: x.states.takeoff_ready))) + self.selected_flip_ready_signal.emit(selected.issubset(self.filter(flip_checks))) + self.selected_calibrating_signal.emit(selected.issubset(self.filter(calibrating_check))) + self.selected_calibration_ready_signal.emit(selected.issubset(self.filter(calibration_ready_check))) self.dataChanged.emit(index, index, (role,)) @@ -467,7 +464,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): if col == 0: self.data_contents[row].client.send_message("id", {"new_id": formatted_value}) self.data_contents[row].client.remove() # TODO change - self.remove_row(row) + self._remove_row(row) elif role == ModelDataRole: # For inner setting\editing of data self.data_contents[row][col] = value @@ -482,15 +479,15 @@ class CopterDataModel(QtCore.QAbstractTableModel): def select_all(self): # probably NOT thread-safe! TODO remake self.first_col_is_checked = not self.first_col_is_checked for row_num, copter in enumerate(self.data_contents): - copter.states.checked = int(self.first_col_is_checked)*2 + copter.states.checked = int(self.first_col_is_checked) * 2 self.update_model(self.index(row_num, 0), Qt.CheckStateRole) def flags(self, index): roles = Qt.ItemIsSelectable | Qt.ItemIsEnabled if index.column() == 0: roles |= Qt.ItemIsUserCheckable | Qt.ItemIsEditable - if is_column(index, "config_version"): - roles |= Qt.ItemIsDragEnabled # | Qt.ItemIsDropEnabled + if self.is_column(index, "config_version"): + roles |= Qt.ItemIsDragEnabled # | Qt.ItemIsDropEnabled return roles @@ -502,7 +499,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): def mimeData(self, indexes): index = indexes[0] - if is_column(index, "config_version"): + if self.is_column(index, "config_version"): return self._config_mime(index) return None @@ -515,25 +512,41 @@ class CopterDataModel(QtCore.QAbstractTableModel): with suppress(OSError): # remove if file exists os.remove(path) - self.data_contents[index.row()].client.get_file("config/client.ini", path,) + self.data_contents[index.row()].client.get_file("config/client.ini", path, ) mimedata.setUrls([QUrl.fromLocalFile(path)]) return mimedata + # Thread-safe wrappers + def add_client(self, **kwargs): + default_states = {"checked": 0, "copter_id": True} + # class_basic_attrs = {'client': None} + # class_basic_states = OrderedDict([("checked", 0), ("selfchecked", None), ("takeoff_ready", None)]) + self.add_client_signal.emit(self.data_model(self.columns, default_states, **kwargs)) + + def remove_client_data(self, row_data): + self.remove_client_signal.emit(row_data) + + def remove_row(self, row): + self.remove_row_signal.emit(row) + + def update_data(self, row, col, data, role=ModelDataRole): + self.update_data_signal.emit(row, col, data, role) + @QtCore.pyqtSlot(int, int, QtCore.QVariant, QtCore.QVariant) - def update_item(self, row, col, value, role=Qt.EditRole): + def _update_item(self, row, col, value, role=Qt.EditRole): self.setData(self.index(row, col), value, role) @QtCore.pyqtSlot(object) - def add_client(self, client): + def _add_client(self, client): self.insertRows([client]) @QtCore.pyqtSlot(int) # Probably deprecated now - def remove_row(self, row): + def _remove_row(self, row): self.removeRows(row) @QtCore.pyqtSlot(object) - def remove_row_data(self, data): + def _remove_row_data(self, data): row = self.get_row_index(data) if row is not None: self.removeRows(row) @@ -577,25 +590,12 @@ class CopterProxyModel(QtCore.QSortFilterProxyModel): return self.human_sort_prepare(leftData) < self.human_sort_prepare(rightData) -class SignalManager(QtCore.QObject): - update_data_signal = QtCore.pyqtSignal(int, int, QtCore.QVariant, QtCore.QVariant) - add_client_signal = QtCore.pyqtSignal(object) - remove_row_signal = QtCore.pyqtSignal(int) - remove_client_signal = QtCore.pyqtSignal(object) - - def __init__(self, model): - super().__init__() - - self.update_data_signal.connect(model.update_item) - self.add_client_signal.connect(model.add_client) - self.remove_row_signal.connect(model.remove_row) - self.remove_client_signal.connect(model.remove_row_data) - if __name__ == '__main__': import threading import time + def timer(): idc = 1001 while True: @@ -603,11 +603,12 @@ if __name__ == '__main__': idc += 1 time.sleep(1) + app = QtWidgets.QApplication.instance() if app is None: app = QtWidgets.QApplication(sys.argv) tableView = QtWidgets.QTableView() - myModel = CopterDataModel(None) + myModel = CopterDataModel() proxyModel = CopterProxyModel() proxyModel.setDynamicSortFilter(True) @@ -628,14 +629,14 @@ if __name__ == '__main__': msg = "[{}]: Failure: {}".format("FCU connection2", "Angular velocities estimation is not available") msgs.append(msg) - myModel.add_client(StatedCopterData(copter_id=1000, checked=0, selfcheck=msgs, time_utc=1)) - myModel.add_client(StatedCopterData(checked=2, selfcheck="OK", time_utc=2)) - myModel.add_client(StatedCopterData(checked=2, selfcheck="not ok", time_utc="no")) + #myModel._add_client(StatedCopterData(copter_id=1000, checked=0, selfcheck=msgs, time_utc=1)) + #myModel._add_client(StatedCopterData(checked=2, selfcheck="OK", time_utc=2)) + #myModel._add_client(StatedCopterData(checked=2, selfcheck="not ok", time_utc="no")) + myModel.add_client(copter_id=1000, client=None) + #myModel.setData(myModel.index(0, 1), "test") - myModel.setData(myModel.index(0, 1), "test") - - t = threading.Thread(target=timer, daemon=True) - t.start() + # t = threading.Thread(target=timer, daemon=True) + #t.start() print(QtCore.QT_VERSION_STR) app.exec_() diff --git a/Server/server_qt.py b/Server/server_qt.py index 8292258..84c59e5 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -187,7 +187,7 @@ class MainWindow(QtWidgets.QMainWindow): # TODO if any connected copters reply = QMessageBox.question(self, "Confirm exit", "There are copters connected to the server. " "Are you sure you want to exit?", - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + QMessageBox.No | QMessageBox.Yes, QMessageBox.No) if reply != QMessageBox.Yes: event.ignore() @@ -206,7 +206,7 @@ class MainWindow(QtWidgets.QMainWindow): def new_client_connected(self, client: Client): logging.debug("Added client {}".format(client)) - self.ui.copter_table.add_client(copter_id=client.copter_id, client=client) + self.model.add_client(copter_id=client.copter_id, client=client) def client_connection_changed(self, client: Client): logging.debug("Connection {} changed {}".format(client, client.connected)) @@ -218,12 +218,12 @@ class MainWindow(QtWidgets.QMainWindow): if self.server.config.table_remove_disconnected and (not client.connected): client.remove() - self.ui.copter_table.remove_client_data(row_data) + self.model.remove_client_data(row_data) logging.debug("Removing from table") else: row_num = self.model.get_row_index(row_data) if row_num is not None: - self.ui.copter_table.update_data(row_num, 0, client.connected, table.ModelStateRole) + self.model.update_data(row_num, 0, client.connected, table.ModelStateRole) logging.debug("Client status updated") @pyqtSlot() @@ -257,14 +257,14 @@ class MainWindow(QtWidgets.QMainWindow): row_data = self.model.get_row_by_attr("client", client) row_num = self.model.get_row_index(row_data) if row_num is not None: - self.ui.copter_table.update_data(row_num, col, value, Qt.EditRole) + self.model.update_data(row_num, col, value, Qt.EditRole) @pyqtSlot() def remove_selected(self): for copter in self.model.user_selected(): copter.client.remove() if not self.server.config.table_remove_disconnected: - self.ui.copter_table.remove_client_data(copter) + self.model.remove_client_data(copter) logging.info("Client removed from table!") @pyqtSlot() @@ -316,7 +316,7 @@ class MainWindow(QtWidgets.QMainWindow): row = self.model.get_row_index(copter_data_row) col = 5 data = 'CALIBRATING' - self.ui.copter_table.update_data(row, col, data, table.ModelDataRole) + self.model.update_data(row, col, data, table.ModelDataRole) # Send request client.get_response("calibrate_gyro", self._get_calibration_info) @@ -328,7 +328,7 @@ class MainWindow(QtWidgets.QMainWindow): row = self.model.get_row_index(copter_data_row) col = 5 data = 'CALIBRATING' - self.ui.copter_table.update_data(row, col, data, table.ModelDataRole) + self.model.update_data(row, col, data, table.ModelDataRole) # Send request client.get_response("calibrate_level", self._get_calibration_info) @@ -338,7 +338,7 @@ class MainWindow(QtWidgets.QMainWindow): row = self.model.get_row_index(row_data) if row is not None: data = str(value) - self.ui.copter_table.update_data(row, col, data, table.ModelDataRole) + self.model.update_data(row, col, data, table.ModelDataRole) def _send_files(self, files, copters=None, client_path="", client_filename="", match_id=False, callback=None): if copters is None: From 11318caaab946131c9d4bd72abc6a8348ddcc3db Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 28 Jan 2020 19:06:00 +0300 Subject: [PATCH 119/210] Fully working closeEvent for qt server window --- Server/server_qt.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 84c59e5..018794c 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -184,7 +184,10 @@ class MainWindow(QtWidgets.QMainWindow): super().show() def closeEvent(self, event): - # TODO if any connected copters + if not any(copter.connected for copter in Client.clients.values()): + event.accept() + return + reply = QMessageBox.question(self, "Confirm exit", "There are copters connected to the server. " "Are you sure you want to exit?", QMessageBox.No | QMessageBox.Yes, QMessageBox.No) From 28a3586a8242163cbe9769ccbd7d25f4ace2dab5 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 29 Jan 2020 23:09:22 +0300 Subject: [PATCH 120/210] Refactor of table columns handling: checks and formatters --- Server/copter_table.py | 2 +- Server/copter_table_models.py | 252 +++++++++++++++++----------------- 2 files changed, 126 insertions(+), 128 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index 598e884..2430f39 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -148,7 +148,7 @@ class HeaderListWidget(QListWidget): flags = Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsEnabled state = Qt.Checked if visible else Qt.Unchecked - item = QListWidgetItem(table.columns_names.get(name, "").strip() or name, self) + item = QListWidgetItem(table.CopterDataModel.columns_dict.get(name, "").strip() or name, self) item.setFlags(flags) item.setCheckState(state) item.setData(HeaderListWidget.ColumnKeyRole, name) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 739c4ad..756567c 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -1,38 +1,55 @@ +import math import os import re +import subprocess import sys -import time -import math from contextlib import suppress +from functools import partialmethod from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt as Qt, QUrl, QDir +# Additional custom roles to interact with various table data ModelDataRole = 998 ModelStateRole = 999 +def get_git_version(): # TODO import from animation + return subprocess.check_output("git log --pretty=format:%h -n 1").decode('UTF-8') + + class ModelChecks: checks_dict = {} - takeoff_checklist = (3, 4, 6, 7, 8) battery_min = 50.0 start_pos_delta_max = 1.0 time_delta_max = 1.0 @classmethod - def col_check(cls, col): + def column_check(cls, column, pass_context=False): def inner(f): - def wrapper(item): - if item is not None: - return f(item) - return None + def wrapper(item, context=None): + if item is None: + return None + if pass_context: + return f(item, context) + return f(item) - cls.checks_dict[col] = wrapper + cls.checks_dict[column] = wrapper return wrapper return inner + @classmethod + def check(cls, column, context): + if isinstance(column, int): + column = context.columns[column] + item = context[column] + try: + return cls.checks_dict[column](item, context) + except KeyError: # When there is no check + return None if item is None else True # item is not None + @classmethod def all_checks(cls, copter_item): for col, check in cls.checks_dict.items(): @@ -40,79 +57,88 @@ class ModelChecks: return False return True - @classmethod - def takeoff_checks(cls, copter_item): - for col in cls.takeoff_checklist: - if not cls.checks_dict[col](copter_item[col]): - return False - return True - -@ModelChecks.col_check(1) +@ModelChecks.column_check("git_version") def check_ver(item): - return True # TODO git version! + return get_git_version() == item -@ModelChecks.col_check(2) +@ModelChecks.column_check("animation_id") def check_anim(item): return str(item) != 'No animation' -@ModelChecks.col_check(3) +@ModelChecks.column_check("battery") def check_bat(item): if item == "NO_INFO": return False return item[1] * 100 > ModelChecks.battery_min -@ModelChecks.col_check(4) +@ModelChecks.column_check("fcu_status") def check_sys_status(item): return item == "STANDBY" -@ModelChecks.col_check(5) +@ModelChecks.column_check("calibration_status") def check_cal_status(item): return item == "OK" -@ModelChecks.col_check(6) +@ModelChecks.column_check("mode") def check_mode(item): return (item != "NO_FCU") and not ("CMODE" in item) -@ModelChecks.col_check(7) +@ModelChecks.column_check("selfcheck") def check_selfcheck(item): return item == "OK" -@ModelChecks.col_check(8) -def check_pos_status(item): +@ModelChecks.column_check("current_position") +def check_pos(item): if item == 'NO_POS': return False return not math.isnan(item[0]) - -@ModelChecks.col_check(9) -def check_start_pos_status(item): - return item != 'NO_POS' +# @ModelChecks.column_check("last_task") +# def check_task(item): +# return True -@ModelChecks.col_check(10) -def check_selfcheck(item): - return True - - -@ModelChecks.col_check(11) +@ModelChecks.column_check('time_delta') def check_time_delta(item): return abs(item) < ModelChecks.time_delta_max +@ModelChecks.column_check("start_position", pass_context=True) +def check_start_pos(item, context): + if context.current_position is None: + return item != 'NO_POS' # maybe should return true + delta = get_distance(get_position(context.current_position), + get_position(context.start_position)) + if math.isnan(delta): + return False + + return delta < ModelChecks.start_pos_delta_max + + +def get_position(position): + if position != 'NO_POS' and position[0] != 'nan': + return position + return [float('nan')]*3 + + +def get_distance(pos1, pos2): # todo as common function + if any(math.isnan(x) for x in pos1+pos2): + return float('nan') + return math.sqrt(sum(map(lambda p: p[0] - p[1], zip(pos1, pos2)))**2) # point distance formula class CopterData: def __init__(self, columns=(), **kwargs): - self._columns = columns + self.columns = columns for column in columns: setattr(self, column, None) @@ -120,15 +146,15 @@ class CopterData: setattr(self, attr, value) def __getitem__(self, key): - if key in self._columns: + if key in self.columns: return getattr(self, key) - return getattr(self, self._columns[key]) + return getattr(self, self.columns[key]) def __setitem__(self, key, value): - if key in self._columns: + if key in self.columns: setattr(self, key, value) else: - setattr(self, self._columns[key], value) + setattr(self, self.columns[key], value) class StatedCopterData(CopterData): @@ -145,52 +171,18 @@ class StatedCopterData(CopterData): def __setattr__(self, key, value): self.__dict__[key] = value - if key in self._columns: + if key in self.columns: with suppress(KeyError): - # print(self.__dict__) - self.states.__dict__[key] = self.checks.checks_dict[self._columns.index(key)](value) - self.states.__dict__["all_checks"] = all([self.states[i] for i in ModelChecks.checks_dict.keys()]) - - # if key == 'start_position': - # if (self.__dict__['current_position'] is not None) and ( - # self.__dict__['start_position'] is not None): - # current_pos = get_position(self.__dict__['current_position']) - # start_pos = get_position(self.__dict__['start_position']) - # delta = get_position_delta(current_pos, start_pos) - # if delta != 'NO_POS': - # self.states.__dict__[key] = (delta < ModelChecks.start_pos_delta_max) - - # update all_checks and takeoff_ready - - # self.states.__dict__["takeoff_ready"] = all( - # [self.states[i] for i in ModelChecks.takeoff_checklist] - # ) - - -def get_position(pos_array): - if pos_array[0] != 'nan' and pos_array != 'NO_POS': - pos = [] - for i in range(3): - pos.append(pos_array[i]) - else: - pos = 'NO_POS' - return pos - - -def get_position_delta(pos1, pos2): - if pos1 != 'NO_POS' and pos2 != 'NO_POS': - delta_squared = 0 - for i in range(3): - delta_squared += (pos1[i] - pos2[i]) ** 2 - return math.sqrt(delta_squared) - return 'NO_POS' + self.states.__dict__[key] = \ + self.checks.check(key, self) # {key: self.__dict__[key] for key in self.columns}) + self.states.__dict__["all_checks"] = all([self.states[i] for i in self.checks.checks_dict.keys()]) class ModelFormatter: view_formatters = {} place_formatters = {} - VIEW_FORMATTER = False - PLACE_FORMATTER = True + VIEW_FORMATTER = 1 + PLACE_FORMATTER = 2 @classmethod def format_view(cls, col, value): @@ -205,11 +197,11 @@ class ModelFormatter: return value @classmethod - def col_format(cls, col, format_type): + def column_formatter(cls, col, formatter_type): def inner(f): - if format_type: + if formatter_type == cls.PLACE_FORMATTER: cls.place_formatters[col] = f - else: + elif formatter_type == cls.VIEW_FORMATTER: cls.view_formatters[col] = f def wrapper(*args, **kwargs): @@ -219,8 +211,10 @@ class ModelFormatter: return inner + place_formatter = partialmethod(column_formatter, formatter_type=PLACE_FORMATTER) + view_formatter = partialmethod(column_formatter, formatter_type=VIEW_FORMATTER) -@ModelFormatter.col_format(0, ModelFormatter.PLACE_FORMATTER) +@ModelFormatter.place_formatter("copter_id") def place_id(value): value = str(value).strip() # check user hostname spelling http://man7.org/linux/man-pages/man7/hostname.7.html @@ -240,7 +234,7 @@ def place_id(value): return None -@ModelFormatter.col_format(3, ModelFormatter.PLACE_FORMATTER) +@ModelFormatter.place_formatter("battery") def place_battery(value): if isinstance(value, list): battery_v, battery_p = value @@ -248,56 +242,54 @@ def place_battery(value): return "NO_INFO" return value - -@ModelFormatter.col_format(3, ModelFormatter.VIEW_FORMATTER) +@ModelFormatter.view_formatter("battery") def view_battery(value): if isinstance(value, list): battery_v, battery_p = value - return "{:.1f}V {:d}%".format(battery_v, int(battery_p * 100)) + return f"{battery_v:4.1f}V {min(battery_p, 1):4.0%}" return value -@ModelFormatter.col_format(7, ModelFormatter.VIEW_FORMATTER) +@ModelFormatter.view_formatter("selfcheck") def view_selfcheck(value): if isinstance(value, list): - if len(value) == 1: - if len(value[0]) <= 8: - return value[0] + if len(value) == 1 and len(value[0]) <= 8: + return value[0] return "ERROR" return value -@ModelFormatter.col_format(8, ModelFormatter.VIEW_FORMATTER) +@ModelFormatter.view_formatter("current_position") def view_selfcheck(value): if isinstance(value, list): x, y, z, yaw, frame = value - return "{:.2f} {:.2f} {:.2f} {:d} {}".format(x, y, z, int(yaw), frame) + return f"{x: .2f} {y: .2f} {z: .2f} {int(yaw): d} {frame}" return value -@ModelFormatter.col_format(9, ModelFormatter.VIEW_FORMATTER) +@ModelFormatter.view_formatter("start_position") def view_selfcheck(value): if isinstance(value, list): x, y, z = value - return "{:.2f} {:.2f} {:.2f}".format(x, y, z) + return f"{x: .2f} {y: .2f} {z: .2f}" return value -@ModelFormatter.col_format(10, ModelFormatter.PLACE_FORMATTER) -def view_last_task(value): - if value is None: +@ModelFormatter.place_formatter("last_task") +def place_last_task(value): + if value is None: # TODO possible behaviour deviation return 'No task' return value -@ModelFormatter.col_format(11, ModelFormatter.PLACE_FORMATTER) +@ModelFormatter.place_formatter("time_delta") def place_time_delta(value): return abs(value - time.time()) -@ModelFormatter.col_format(11, ModelFormatter.VIEW_FORMATTER) +@ModelFormatter.view_formatter("time_delta") def view_time_delta(value): - return "{:.3f}".format(value) + return f"{value:.3f}" class CopterDataModel(QtCore.QAbstractTableModel): @@ -366,12 +358,17 @@ class CopterDataModel(QtCore.QAbstractTableModel): def is_column(cls, index, column_name): return index.column() == cls.columns.index(column_name) + def user_selected(self, contents=()): + return self.filter(lambda x: x.states.checked == Qt.Checked, contents) + def filter(self, f, contents=()): contents = contents or self.data_contents return filter(f, contents) - def user_selected(self, contents=()): - return self.filter(lambda x: x.states.checked == Qt.Checked, contents) + def selected_check(self, f, selected=()): + selected = selected or set(self.user_selected()) + print(selected and all(f(item) for item in selected)) + return bool(selected) and all(f(item) for item in selected) #selected.issubset(self.filter(f)) def get_row_data(self, index): row = index.row() @@ -409,7 +406,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): col = index.column() if role == Qt.DisplayRole or role == Qt.EditRole: # Separate editRole in case of editing non-text item = self.data_contents[row][col] - return str(self.formatter.format_view(col, item)) if item is not None else "" + return str(self.formatter.format_view(self.columns[col], item)) if item is not None else "" elif role == ModelDataRole: return self.data_contents[row][col] @@ -436,11 +433,11 @@ class CopterDataModel(QtCore.QAbstractTableModel): def update_model(self, index=QtCore.QModelIndex(), role=QtCore.Qt.EditRole): selected = set(self.user_selected()) - self.selected_ready_signal.emit(selected.issubset(self.filter(lambda x: x.states.all_checks))) - #self.selected_takeoff_ready_signal.emit(selected.issubset(self.filter(lambda x: x.states.takeoff_ready))) - self.selected_flip_ready_signal.emit(selected.issubset(self.filter(flip_checks))) - self.selected_calibrating_signal.emit(selected.issubset(self.filter(calibrating_check))) - self.selected_calibration_ready_signal.emit(selected.issubset(self.filter(calibration_ready_check))) + self.selected_ready_signal.emit(self.selected_check(lambda x: x.states.all_checks, selected)) + self.selected_takeoff_ready_signal.emit(self.selected_check(takeoff_checks, selected)) + self.selected_flip_ready_signal.emit(self.selected_check(flip_checks, selected)) + self.selected_calibrating_signal.emit(self.selected_check(calibrating_check, selected)) + self.selected_calibration_ready_signal.emit(self.selected_check(calibration_ready_check, selected)) self.dataChanged.emit(index, index, (role,)) @@ -455,7 +452,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): if role == Qt.CheckStateRole: self.data_contents[row].states.checked = value elif role == Qt.EditRole: # For user/outer actions with data, place modifiers applied - formatted_value = self.formatter.format_place(col, value) + formatted_value = self.formatter.format_place(self.columns[col], value) if formatted_value is None: # todo use new := syntax return False @@ -520,8 +517,6 @@ class CopterDataModel(QtCore.QAbstractTableModel): # Thread-safe wrappers def add_client(self, **kwargs): default_states = {"checked": 0, "copter_id": True} - # class_basic_attrs = {'client': None} - # class_basic_states = OrderedDict([("checked", 0), ("selfchecked", None), ("takeoff_ready", None)]) self.add_client_signal.emit(self.data_model(self.columns, default_states, **kwargs)) def remove_client_data(self, row_data): @@ -552,22 +547,28 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.removeRows(row) +def check_checklist(copter_item, checklist=()): + return all(copter_item.states[col] for col in checklist) + + +def takeoff_checks(copter_item): + checklist = ("battery", "fcu_status", "mode", "selfcheck", "current_position") + return check_checklist(copter_item, checklist) + def flip_checks(copter_item): - for col in ModelChecks.takeoff_checklist: - if col != 4 or col != 7: - if not ModelChecks.checks_dict[col](copter_item[col]): - return False - elif copter_item[4] != "ACTIVE": - return False + checklist = ("battery", "mode", "current_position") + if not check_checklist(copter_item, checklist): + return False + if copter_item["fcu_status"] != "ACTIVE": return True +# for col in checklist: + # if not copter_item.state[col]: # ModelChecks.check(col, copter_item): def calibrating_check(copter_item): - return copter_item[5] == "CALIBRATING" def calibration_ready_check(copter_item): - if not ModelChecks.checks_dict[4](copter_item[4]): return False return not calibrating_check(copter_item) @@ -592,7 +593,6 @@ class CopterProxyModel(QtCore.QSortFilterProxyModel): if __name__ == '__main__': - import threading import time @@ -632,11 +632,9 @@ if __name__ == '__main__': #myModel._add_client(StatedCopterData(copter_id=1000, checked=0, selfcheck=msgs, time_utc=1)) #myModel._add_client(StatedCopterData(checked=2, selfcheck="OK", time_utc=2)) #myModel._add_client(StatedCopterData(checked=2, selfcheck="not ok", time_utc="no")) - myModel.add_client(copter_id=1000, client=None) #myModel.setData(myModel.index(0, 1), "test") - # t = threading.Thread(target=timer, daemon=True) #t.start() print(QtCore.QT_VERSION_STR) - - app.exec_() + print(get_git_version()) + myModel.update_data(0, 3, [1, 2], role=Qt.EditRole) From cfcbfbe9a3414133485d5df7d53e57256130cf65 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 30 Jan 2020 19:27:34 +0300 Subject: [PATCH 121/210] Added CheckStates to table model To hold both bool representation and different colors --- Server/copter_table.py | 17 ++++++-- Server/copter_table_models.py | 77 ++++++++++++++++++++--------------- Server/server_qt.py | 8 ++-- requirements.txt | 1 - 4 files changed, 62 insertions(+), 41 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index 2430f39..50b0c25 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -13,7 +13,7 @@ import copter_table_models as table class CopterTableWidget(QTableView): - def __init__(self, model, config): + def __init__(self, model: table.CopterDataModel, config): QTableView.__init__(self) self.config = config @@ -26,7 +26,7 @@ class CopterTableWidget(QTableView): # Initiate table and table self.model self.setModel(self.proxy_model) - self.columns = self.model.columns #[header.strip() for header in self.model.headers] # header keys + self.columns = self.model.columns # [header.strip() for header in self.model.headers] # header keys self.current_columns = self.columns[:] header = self.horizontalHeader() @@ -75,6 +75,17 @@ class CopterTableWidget(QTableView): for name, show in item_dict.items(): # for index, name in enumerate(self.columns): self.setColumnHidden(self.columns.index(name), not show) # self.setColumnHidden(index, not item_dict.get(name, False)) + def select_all(self, state): + for i in self.model.rowCount(): + self.model.update_data(i, 0, state, Qt.CheckStateRole) + + def toggle_select(self): + if len(list(self.model.user_selected())) == self.model.rowCount(): # if all items are selected + state = Qt.Unchecked + else: + state = Qt.Checked + self.select_all(state) + @pyqtSlot(QtCore.QModelIndex) def on_double_click(self, index): col = index.column() @@ -95,7 +106,7 @@ class CopterTableWidget(QTableView): def showHeaderMenu(self, event): menu = QMenu(self) header_view = HeaderEditWidget(self, self.config, menu_mode=True, parent=menu) - #header_view.setFixedHeight((header_view.geometry().height()-2) * len(header_view.columns)) + # header_view.setFixedHeight((header_view.geometry().height()-2) * len(header_view.columns)) # box.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding) action = QWidgetAction(menu) action.setDefaultWidget(header_view) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 756567c..5f8f055 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -17,6 +17,23 @@ def get_git_version(): # TODO import from animation return subprocess.check_output("git log --pretty=format:%h -n 1").decode('UTF-8') +class CheckState: + def __init__(self, bool_state, color): + self._bool = bool_state + self.color = color + self.brush = QtGui.QBrush(self.color) + + def __bool__(self): + return self._bool + + +# State objects providing both boolean and color information for table +# Add more if required +true_state = CheckState(True, Qt.green) +false_state = CheckState(False, Qt.red) +missing_state = CheckState(False, Qt.yellow) +outdated_state = CheckState(False, Qt.magenta) + class ModelChecks: checks_dict = {} @@ -46,9 +63,13 @@ class ModelChecks: column = context.columns[column] item = context[column] try: - return cls.checks_dict[column](item, context) + result = cls.checks_dict[column](item, context) except KeyError: # When there is no check - return None if item is None else True # item is not None + return None if item is None else true_state # item is not None + else: + if isinstance(result, bool): + return true_state if result else false_state + return result @classmethod def all_checks(cls, copter_item): @@ -125,7 +146,7 @@ def check_start_pos(item, context): def get_position(position): - if position != 'NO_POS' and position[0] != 'nan': + if position != 'NO_POS' and position[0] != 'nan': # float('nan')? return position return [float('nan')]*3 @@ -156,6 +177,9 @@ class CopterData: else: setattr(self, self.columns[key], value) + def __repr__(self): + return str({key: self[key] for key in self.columns}) + class StatedCopterData(CopterData): def __init__(self, columns=(), checks_defaults=None, checks_class=ModelChecks, **kwargs): @@ -332,9 +356,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.formatter = formatter self.data_model = data_model - self.first_col_is_checked = False - - self.update_data_signal.connect(self._update_item) + self.update_data_signal.connect(self._update_data) self.add_client_signal.connect(self._add_client) self.remove_row_signal.connect(self._remove_row) self.remove_client_signal.connect(self._remove_row_data) @@ -351,7 +373,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.beginRemoveRows(QtCore.QModelIndex(), position, position + rows - 1) self.data_contents = self.data_contents[:position] + self.data_contents[position + rows:] self.endRemoveRows() - + self.emit_signals() return True @classmethod @@ -367,7 +389,6 @@ class CopterDataModel(QtCore.QAbstractTableModel): def selected_check(self, f, selected=()): selected = selected or set(self.user_selected()) - print(selected and all(f(item) for item in selected)) return bool(selected) and all(f(item) for item in selected) #selected.issubset(self.filter(f)) def get_row_data(self, index): @@ -411,18 +432,10 @@ class CopterDataModel(QtCore.QAbstractTableModel): return self.data_contents[row][col] elif role == Qt.BackgroundRole: - try: - item = self.data_contents[row] - result = item.states[col] - except KeyError: - return QtGui.QBrush(Qt.white) - else: - if result is None: - return QtGui.QBrush(Qt.yellow) - if result: - return QtGui.QBrush(Qt.green) - else: - return QtGui.QBrush(Qt.red) + state = self.data_contents[row].states[col] + if state is None: + state = missing_state + return state.brush elif role == Qt.CheckStateRole and col == 0: return self.data_contents[row].states.checked @@ -430,7 +443,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): if role == QtCore.Qt.TextAlignmentRole and col != 0: return QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter - def update_model(self, index=QtCore.QModelIndex(), role=QtCore.Qt.EditRole): + def emit_signals(self): selected = set(self.user_selected()) self.selected_ready_signal.emit(self.selected_check(lambda x: x.states.all_checks, selected)) @@ -439,8 +452,6 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.selected_calibrating_signal.emit(self.selected_check(calibrating_check, selected)) self.selected_calibration_ready_signal.emit(self.selected_check(calibration_ready_check, selected)) - self.dataChanged.emit(index, index, (role,)) - @QtCore.pyqtSlot() def setData(self, index, value, role=Qt.EditRole): if not index.isValid(): @@ -470,22 +481,16 @@ class CopterDataModel(QtCore.QAbstractTableModel): else: return False - self.update_model(index, role) + self.emit_signals() + self.dataChanged.emit(index, index, (role,)) return True - def select_all(self): # probably NOT thread-safe! TODO remake - self.first_col_is_checked = not self.first_col_is_checked - for row_num, copter in enumerate(self.data_contents): - copter.states.checked = int(self.first_col_is_checked) * 2 - self.update_model(self.index(row_num, 0), Qt.CheckStateRole) - def flags(self, index): roles = Qt.ItemIsSelectable | Qt.ItemIsEnabled if index.column() == 0: roles |= Qt.ItemIsUserCheckable | Qt.ItemIsEditable if self.is_column(index, "config_version"): roles |= Qt.ItemIsDragEnabled # | Qt.ItemIsDropEnabled - return roles def supportedDropActions(self): @@ -529,7 +534,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.update_data_signal.emit(row, col, data, role) @QtCore.pyqtSlot(int, int, QtCore.QVariant, QtCore.QVariant) - def _update_item(self, row, col, value, role=Qt.EditRole): + def _update_data(self, row, col, value, role=Qt.EditRole): self.setData(self.index(row, col), value, role) @QtCore.pyqtSlot(object) @@ -555,20 +560,25 @@ def takeoff_checks(copter_item): checklist = ("battery", "fcu_status", "mode", "selfcheck", "current_position") return check_checklist(copter_item, checklist) + def flip_checks(copter_item): checklist = ("battery", "mode", "current_position") if not check_checklist(copter_item, checklist): return False if copter_item["fcu_status"] != "ACTIVE": + return False return True # for col in checklist: # if not copter_item.state[col]: # ModelChecks.check(col, copter_item): + # return False def calibrating_check(copter_item): + return copter_item["calibration_status"] == "CALIBRATING" def calibration_ready_check(copter_item): + if not copter_item.states["fcu_status"]: # ModelChecks.check("fcu_status", copter_item): return False return not calibrating_check(copter_item) @@ -632,9 +642,12 @@ if __name__ == '__main__': #myModel._add_client(StatedCopterData(copter_id=1000, checked=0, selfcheck=msgs, time_utc=1)) #myModel._add_client(StatedCopterData(checked=2, selfcheck="OK", time_utc=2)) #myModel._add_client(StatedCopterData(checked=2, selfcheck="not ok", time_utc="no")) + myModel.add_client(copter_id=1000, client=None, git_version='11318ca', selfcheck=msgs) #myModel.setData(myModel.index(0, 1), "test") + # t = threading.Thread(target=timer, daemon=True) #t.start() print(QtCore.QT_VERSION_STR) print(get_git_version()) myModel.update_data(0, 3, [1, 2], role=Qt.EditRole) + app.exec_() \ No newline at end of file diff --git a/Server/server_qt.py b/Server/server_qt.py index 018794c..8d2c714 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -144,11 +144,6 @@ class MainWindow(QtWidgets.QMainWindow): self.init_table() - # Set most safety-important buttons disabled - self.ui.start_button.setEnabled(False) - self.ui.takeoff_button.setEnabled(False) - self.ui.flip_button.setEnabled(False) - def init_table(self): # Remove standard table widget self.ui.horizontalLayout.removeWidget(self.ui.tableView) @@ -179,6 +174,9 @@ class MainWindow(QtWidgets.QMainWindow): self.model.selected_calibration_ready_signal.connect(self.ui.calibrate_gyro.setEnabled) self.model.selected_calibration_ready_signal.connect(self.ui.calibrate_level.setEnabled) + # Set most safety-important buttons disabled + self.model.emit_signals() + def show(self): self.ui.copter_table.load_columns() super().show() diff --git a/requirements.txt b/requirements.txt index 8a2d17b..bc250b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ configobj==5.0.6 -indexed.py==0.0.1 numpy==1.18.1 PyQt5==5.13.0 PyQt5-sip==4.19.18 From fc57f33465d359c3cbaa3731b22a3575230f051a Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 31 Jan 2020 21:50:06 +0300 Subject: [PATCH 122/210] Behaviour fix and prefomance improvemnts in header editors --- Server/copter_table.py | 59 ++++++++++++++++++++++++++++-------------- Server/server_qt.py | 7 +++++ 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index 50b0c25..46f7edd 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -12,6 +12,17 @@ from config_editor_models import ConfigDialog import copter_table_models as table +def save_preset(config, current, header_dict): + presets = config.table_presets + + for key in presets[HeaderEditWidget.default]: + if key not in presets[current] and not header_dict[key]: + header_dict.pop(key) + + presets[current] = header_dict + # config.write() + + class CopterTableWidget(QTableView): def __init__(self, model: table.CopterDataModel, config): QTableView.__init__(self) @@ -75,8 +86,17 @@ class CopterTableWidget(QTableView): for name, show in item_dict.items(): # for index, name in enumerate(self.columns): self.setColumnHidden(self.columns.index(name), not show) # self.setColumnHidden(index, not item_dict.get(name, False)) + @property + def item_dict(self): + return {column: not self.isColumnHidden(self.columns.index(column)) for column in self.current_columns} + + def save_columns(self): + current = self.config.table_presets_current + header_dict = self.item_dict + save_preset(self.config, current, header_dict) + def select_all(self, state): - for i in self.model.rowCount(): + for i in range(self.model.rowCount()): self.model.update_data(i, 0, state, Qt.CheckStateRole) def toggle_select(self): @@ -104,6 +124,7 @@ class CopterTableWidget(QTableView): dialog.exec() def showHeaderMenu(self, event): + self.save_columns() menu = QMenu(self) header_view = HeaderEditWidget(self, self.config, menu_mode=True, parent=menu) # header_view.setFixedHeight((header_view.geometry().height()-2) * len(header_view.columns)) @@ -183,12 +204,7 @@ class ActiveHeaderListWidget(HeaderListWidget): self.itemChanged.connect(self.on_itemChanged) def _populate_from_widget(self): - item_dict = {} - for column, name in enumerate(self.current_columns): - visible = not self.source_widget.isColumnHidden(column) - item_dict[name] = visible - - self.populate_items(item_dict) + self.populate_items(self.source_widget.item_dict) @pyqtSlot(QListWidgetItem) def on_itemChanged(self, item): @@ -220,10 +236,17 @@ class HeaderEditWidget(QtWidgets.QWidget): if self.menu_mode else HeaderListWidget() self.previous = self.config.table_presets_current - self._dialog = None + self.save = True + # self._dialog = None self.setupUi() + @pyqtSlot() + def call_dialog(self): + self.save_preset() + self.save = False + HeaderEditDialog(self.source, self.config).exec() + def setupUi(self): self.header_widget.setSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Expanding) @@ -255,9 +278,8 @@ class HeaderEditWidget(QtWidgets.QWidget): hbox.addWidget(save_button) hbox.addWidget(apply_button) else: - self._dialog = HeaderEditDialog(self.source, self.config) dialog_button = QPushButton("Manage presets") - dialog_button.clicked.connect(self._dialog.show) + dialog_button.clicked.connect(self.call_dialog) hbox.addWidget(dialog_button) vbox.addLayout(hbox) @@ -297,13 +319,14 @@ class HeaderEditWidget(QtWidgets.QWidget): self.preset_widget.setCurrentText(self.previous) return + name = name.strip() if name in self.config.table_presets or name == self.default or name == self.add_new_text: QMessageBox.warning(None, "Preset already exists!", "Preset already exists!") self.preset_widget.setCurrentText(self.previous) return self.config.table_presets[name] = deepcopy(dict(self.config.table_presets[self.default])) - self.config.write() + # self.config.write() self.update_preset_list() self.preset_widget.setCurrentText(name) @@ -320,22 +343,18 @@ class HeaderEditWidget(QtWidgets.QWidget): return self.config.table_presets.pop(self.preset_widget.currentText()) - self.config.write() + # self.config.write() self.previous = self.default self.update_preset_list() def save_preset(self): + if not self.save: # don't save after calling dialog to avoid overrides + return + current = self.preset_widget.currentText() - presets = self.config.table_presets header_dict = self.header_widget.item_dict - - for key in presets[self.default]: - if key not in presets[current] and not header_dict[key]: - header_dict.pop(key) - - presets[current] = header_dict - self.config.write() + save_preset(self.config, current, header_dict) def apply_preset(self): self.config.table_presets_current = self.preset_widget.currentText() diff --git a/Server/server_qt.py b/Server/server_qt.py index 8d2c714..aa25d0a 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -196,6 +196,11 @@ class MainWindow(QtWidgets.QMainWindow): event.accept() QApplication.quit() + def on_quit(self): + self.ui.copter_table.save_columns() + self.server.config.write() + logging.info("Exit actions completed: config saved") + def iterate_selected(self, f, *args, **kwargs): for copter in self.model.user_selected(): yield f(copter, *args, **kwargs) @@ -611,6 +616,8 @@ if __name__ == "__main__": Client.on_connect = window.client_connection_changed Client.on_disconnect = window.client_connection_changed + app.aboutToQuit.connect(window.on_quit) + server.start() window.show() From 3724f21af3ac06c8df53e543c43d0eea7b742d7b Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 31 Jan 2020 22:57:31 +0300 Subject: [PATCH 123/210] Fix and formatter improvements --- Server/copter_table_models.py | 56 +++++++++++++++++------------------ Server/server_qt.py | 2 +- 2 files changed, 28 insertions(+), 30 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 5f8f055..8e681ed 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -63,20 +63,9 @@ class ModelChecks: column = context.columns[column] item = context[column] try: - result = cls.checks_dict[column](item, context) + return cls.checks_dict[column](item, context) except KeyError: # When there is no check return None if item is None else true_state # item is not None - else: - if isinstance(result, bool): - return true_state if result else false_state - return result - - @classmethod - def all_checks(cls, copter_item): - for col, check in cls.checks_dict.items(): - if not check(copter_item[col]): - return False - return True @ModelChecks.column_check("git_version") @@ -198,7 +187,7 @@ class StatedCopterData(CopterData): if key in self.columns: with suppress(KeyError): self.states.__dict__[key] = \ - self.checks.check(key, self) # {key: self.__dict__[key] for key in self.columns}) + self.checks.check(key, self) self.states.__dict__["all_checks"] = all([self.states[i] for i in self.checks.checks_dict.keys()]) @@ -209,27 +198,34 @@ class ModelFormatter: PLACE_FORMATTER = 2 @classmethod - def format_view(cls, col, value): - if col in cls.view_formatters: - return cls.view_formatters[col](value) - return value + def get_formatter(cls, formatter_type): + if formatter_type == cls.PLACE_FORMATTER: + return cls.place_formatters + if formatter_type == cls.VIEW_FORMATTER: + return cls.view_formatters + raise ValueError('Unknown formatter type') @classmethod - def format_place(cls, col, value): - if col in cls.place_formatters: - return cls.place_formatters[col](value) - return value + def format(cls, column, value, formatter_type): + formatters_dict = cls.get_formatter(formatter_type) + if isinstance(column, int): + column = CopterDataModel.columns[column] + try: + return formatters_dict[column](value) + except KeyError: + return value # when there is no formatter for the column + + format_place = partialmethod(format, formatter_type=PLACE_FORMATTER) + format_view = partialmethod(format, formatter_type=VIEW_FORMATTER) @classmethod - def column_formatter(cls, col, formatter_type): + def column_formatter(cls, column, formatter_type): def inner(f): - if formatter_type == cls.PLACE_FORMATTER: - cls.place_formatters[col] = f - elif formatter_type == cls.VIEW_FORMATTER: - cls.view_formatters[col] = f + formatters_dict = cls.get_formatter(formatter_type) + formatters_dict[column] = f - def wrapper(*args, **kwargs): - return f(*args, **kwargs) + def wrapper(value): + return f(value) return wrapper @@ -435,6 +431,8 @@ class CopterDataModel(QtCore.QAbstractTableModel): state = self.data_contents[row].states[col] if state is None: state = missing_state + elif isinstance(state, bool): + state = true_state if state else false_state return state.brush elif role == Qt.CheckStateRole and col == 0: @@ -474,7 +472,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.data_contents[row].client.remove() # TODO change self._remove_row(row) - elif role == ModelDataRole: # For inner setting\editing of data + elif role == ModelDataRole: # For inner setting\editing of raw data self.data_contents[row][col] = value elif role == ModelStateRole: self.data_contents[row].states[col] = value diff --git a/Server/server_qt.py b/Server/server_qt.py index aa25d0a..e7d2899 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -284,7 +284,7 @@ class MainWindow(QtWidgets.QMainWindow): asyncio.ensure_future(self.play_music_at_time(music_dt + time_now), loop=loop) logging.info('Wait {} seconds to play music'.format(music_dt)) # self.selfcheck_selected() - for copter in filter(self.model.checks.all_checks, self.model.user_selected()): + for copter in filter(lambda copter: copter.all_checks, self.model.user_selected()): server.send_starttime(copter.client, dt + time_now) @pyqtSlot() From e2fc35805f7a02225efee0c07519fb410955158e Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 31 Jan 2020 23:00:06 +0300 Subject: [PATCH 124/210] New script to automatically update default configspec --- update_configspec.py | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 update_configspec.py diff --git a/update_configspec.py b/update_configspec.py new file mode 100644 index 0000000..3400119 --- /dev/null +++ b/update_configspec.py @@ -0,0 +1,9 @@ +import config +from Server.copter_table_models import CopterDataModel + +cfg_server = config.ConfigObj('SERVER/config/spec/configspec_server.ini') +default = {key: 'boolean(default=True)' for key in CopterDataModel.columns} +cfg_server['TABLE']['PRESETS']['DEFAULT'] = default + +cfg_server.write() +print('Server configspec updated') From 6a9e7930cce2e519aeb2ffc0721c0447d61d9f37 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 31 Jan 2020 23:13:14 +0300 Subject: [PATCH 125/210] New server gui and functionality, updated configspec --- Server/config/spec/configspec_server.ini | 70 ++++---- Server/server_gui.py | 204 ++++++++++++++-------- Server/server_gui.ui | 210 +++++++++++++++-------- Server/server_qt.py | 99 +++++------ 4 files changed, 353 insertions(+), 230 deletions(-) diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index b0efd85..fe8cd18 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -2,47 +2,47 @@ config_name = string(default='Copter config') config_version = float(default='0.0') [SERVER] -port = integer(default=25000) -buffer_size = integer(default=1024) + port = integer(default=25000) + buffer_size = integer(default=1024) [TABLE] -# True -> clients are removed on disconnection -# False -> disconnected clients indicated -remove_disconnected = boolean(default=False) + # True -> clients are removed on disconnection + # False -> disconnected clients indicated + remove_disconnected = boolean(default=False) [[PRESETS]] - current = string(default="DEFAULT") - [[[DEFAULT]]] - copter_id = boolean(default=True) - git_version = boolean(default=True) - animation_id = boolean(default=True) - battery = boolean(default=True) - fcu_status = boolean(default=True) - calibration_status = boolean(default=True) - mode = boolean(default=True) - selfcheck = boolean(default=True) - current_position = boolean(default=True) - start_position = boolean(default=True) - last_task = boolean(default=True) - time_delta = boolean(default=True) - config_version = boolean(default=True) - [[[__many__]]] - __many__ = boolean + current = string(default="DEFAULT") + [[[DEFAULT]]] + copter_id = boolean(default=True) + git_version = boolean(default=True) + animation_id = boolean(default=True) + battery = boolean(default=True) + fcu_status = boolean(default=True) + calibration_status = boolean(default=True) + mode = boolean(default=True) + selfcheck = boolean(default=True) + current_position = boolean(default=True) + start_position = boolean(default=True) + last_task = boolean(default=True) + time_delta = boolean(default=True) + config_version = boolean(default=True) + [[[__many__]]] + __many__ = boolean [CHECKS] -battery_min = float(default=50.0, min=0, max=100) -# in meters -start_pos_delta_max = float(default=1.0, min=0) -# in meters -time_delta_max = float(default=1.0, min=0) + battery_min = float(default=50.0, min=0, max=100) + # in meters + start_pos_delta_max = float(default=1.0, min=0) + # in meters + time_delta_max = float(default=1.0, min=0) [BROADCAST] -send = boolean(default=True) -listen = boolean(default=True) -port = integer(default=8181) -# delay for message sending in seconds -delay = float(default=5.0, min=0) + send = boolean(default=True) + listen = boolean(default=True) + port = integer(default=8181) + # delay for message sending in seconds + delay = float(default=5.0, min=0) [NTP] -use = boolean(default=False) -host = string(default=ntp1.stratum2.ru) -port = integer(default=123) + use = boolean(default=False) + host = string(default=ntp1.stratum2.ru) + port = integer(default=123) diff --git a/Server/server_gui.py b/Server/server_gui.py index b448385..5bc9ec3 100644 --- a/Server/server_gui.py +++ b/Server/server_gui.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'server_gui.ui' # -# Created by: PyQt5 UI code generator 5.13.0 +# Created by: PyQt5 UI code generator 5.14.0 # # WARNING! All changes made in this file will be lost! @@ -13,7 +13,7 @@ from PyQt5 import QtCore, QtGui, QtWidgets class Ui_MainWindow(object): def setupUi(self, MainWindow): MainWindow.setObjectName("MainWindow") - MainWindow.resize(1360, 761) + MainWindow.resize(1360, 816) self.centralwidget = QtWidgets.QWidget(MainWindow) self.centralwidget.setEnabled(True) self.centralwidget.setObjectName("centralwidget") @@ -51,23 +51,23 @@ class Ui_MainWindow(object): self.start_text.setLayoutDirection(QtCore.Qt.RightToLeft) self.start_text.setAlignment(QtCore.Qt.AlignCenter) self.start_text.setObjectName("start_text") - self.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.start_text) + self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.start_text) self.start_delay_spin = QtWidgets.QSpinBox(self.centralwidget) self.start_delay_spin.setObjectName("start_delay_spin") - self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.start_delay_spin) + self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.start_delay_spin) self.music_text = QtWidgets.QLabel(self.centralwidget) self.music_text.setLayoutDirection(QtCore.Qt.RightToLeft) self.music_text.setObjectName("music_text") - self.formLayout.setWidget(3, QtWidgets.QFormLayout.LabelRole, self.music_text) + self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.music_text) self.music_delay_spin = QtWidgets.QDoubleSpinBox(self.centralwidget) self.music_delay_spin.setDecimals(1) self.music_delay_spin.setMaximum(1000.0) self.music_delay_spin.setObjectName("music_delay_spin") - self.formLayout.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.music_delay_spin) + self.formLayout.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.music_delay_spin) self.music_play_text = QtWidgets.QLabel(self.centralwidget) self.music_play_text.setLayoutDirection(QtCore.Qt.RightToLeft) self.music_play_text.setObjectName("music_play_text") - self.formLayout.setWidget(4, QtWidgets.QFormLayout.LabelRole, self.music_play_text) + self.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.music_play_text) self.music_checkbox = QtWidgets.QCheckBox(self.centralwidget) sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) sizePolicy.setHorizontalStretch(0) @@ -76,12 +76,12 @@ class Ui_MainWindow(object): self.music_checkbox.setSizePolicy(sizePolicy) self.music_checkbox.setFocusPolicy(QtCore.Qt.NoFocus) self.music_checkbox.setContextMenuPolicy(QtCore.Qt.DefaultContextMenu) - self.music_checkbox.setLayoutDirection(QtCore.Qt.RightToLeft) + self.music_checkbox.setLayoutDirection(QtCore.Qt.LeftToRight) self.music_checkbox.setAutoFillBackground(False) self.music_checkbox.setText("") self.music_checkbox.setChecked(False) self.music_checkbox.setObjectName("music_checkbox") - self.formLayout.setWidget(4, QtWidgets.QFormLayout.FieldRole, self.music_checkbox) + self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.music_checkbox) self.verticalLayout.addLayout(self.formLayout) self.line = QtWidgets.QFrame(self.centralwidget) self.line.setFrameShape(QtWidgets.QFrame.HLine) @@ -117,10 +117,10 @@ class Ui_MainWindow(object): self.formLayout_5.setObjectName("formLayout_5") self.land_selected_button = QtWidgets.QPushButton(self.centralwidget) self.land_selected_button.setObjectName("land_selected_button") - self.formLayout_5.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.land_selected_button) + self.formLayout_5.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.land_selected_button) self.land_all_button = QtWidgets.QPushButton(self.centralwidget) self.land_all_button.setObjectName("land_all_button") - self.formLayout_5.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.land_all_button) + self.formLayout_5.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.land_all_button) self.verticalLayout.addLayout(self.formLayout_5) self.line_6 = QtWidgets.QFrame(self.centralwidget) self.line_6.setFrameShape(QtWidgets.QFrame.HLine) @@ -146,14 +146,13 @@ class Ui_MainWindow(object): self.formLayout_3 = QtWidgets.QFormLayout() self.formLayout_3.setLabelAlignment(QtCore.Qt.AlignCenter) self.formLayout_3.setFormAlignment(QtCore.Qt.AlignCenter) - self.formLayout_3.setVerticalSpacing(6) self.formLayout_3.setObjectName("formLayout_3") self.disarm_all_button = QtWidgets.QPushButton(self.centralwidget) self.disarm_all_button.setObjectName("disarm_all_button") - self.formLayout_3.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.disarm_all_button) + self.formLayout_3.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.disarm_all_button) self.disarm_selected_button = QtWidgets.QPushButton(self.centralwidget) self.disarm_selected_button.setObjectName("disarm_selected_button") - self.formLayout_3.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.disarm_selected_button) + self.formLayout_3.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.disarm_selected_button) self.verticalLayout.addLayout(self.formLayout_3) self.line_3 = QtWidgets.QFrame(self.centralwidget) self.line_3.setFrameShape(QtWidgets.QFrame.HLine) @@ -164,22 +163,17 @@ class Ui_MainWindow(object): self.formLayout_4.setLabelAlignment(QtCore.Qt.AlignCenter) self.formLayout_4.setFormAlignment(QtCore.Qt.AlignCenter) self.formLayout_4.setObjectName("formLayout_4") - self.flip_button = QtWidgets.QPushButton(self.centralwidget) - self.flip_button.setObjectName("flip_button") - self.formLayout_4.setWidget(7, QtWidgets.QFormLayout.FieldRole, self.flip_button) + self.leds_button = QtWidgets.QPushButton(self.centralwidget) + self.leds_button.setObjectName("leds_button") + self.formLayout_4.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.leds_button) self.takeoff_button = QtWidgets.QPushButton(self.centralwidget) self.takeoff_button.setEnabled(True) self.takeoff_button.setObjectName("takeoff_button") self.formLayout_4.setWidget(1, QtWidgets.QFormLayout.FieldRole, self.takeoff_button) - self.leds_button = QtWidgets.QPushButton(self.centralwidget) - self.leds_button.setObjectName("leds_button") - self.formLayout_4.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.leds_button) self.horizontalLayout_2 = QtWidgets.QHBoxLayout() self.horizontalLayout_2.setContentsMargins(-1, 0, -1, -1) self.horizontalLayout_2.setObjectName("horizontalLayout_2") self.z_checkbox = QtWidgets.QCheckBox(self.centralwidget) - self.z_checkbox.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) - self.z_checkbox.setFocusPolicy(QtCore.Qt.NoFocus) self.z_checkbox.setLayoutDirection(QtCore.Qt.LeftToRight) self.z_checkbox.setObjectName("z_checkbox") self.horizontalLayout_2.addWidget(self.z_checkbox) @@ -189,7 +183,10 @@ class Ui_MainWindow(object): self.z_spin.setProperty("value", 1.0) self.z_spin.setObjectName("z_spin") self.horizontalLayout_2.addWidget(self.z_spin) - self.formLayout_4.setLayout(4, QtWidgets.QFormLayout.FieldRole, self.horizontalLayout_2) + self.formLayout_4.setLayout(2, QtWidgets.QFormLayout.FieldRole, self.horizontalLayout_2) + self.flip_button = QtWidgets.QPushButton(self.centralwidget) + self.flip_button.setObjectName("flip_button") + self.formLayout_4.setWidget(3, QtWidgets.QFormLayout.FieldRole, self.flip_button) self.verticalLayout.addLayout(self.formLayout_4) self.line_4 = QtWidgets.QFrame(self.centralwidget) self.line_4.setFrameShape(QtWidgets.QFrame.HLine) @@ -215,27 +212,29 @@ class Ui_MainWindow(object): self.gridLayout.addLayout(self.horizontalLayout, 0, 0, 1, 1) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 1360, 22)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1360, 26)) self.menubar.setObjectName("menubar") self.menuOptions = QtWidgets.QMenu(self.menubar) self.menuOptions.setObjectName("menuOptions") - self.menuDeveloper_mode = QtWidgets.QMenu(self.menuOptions) - self.menuDeveloper_mode.setObjectName("menuDeveloper_mode") - self.menuAnimation = QtWidgets.QMenu(self.menubar) - self.menuAnimation.setObjectName("menuAnimation") - self.menuDrone = QtWidgets.QMenu(self.menubar) - self.menuDrone.setObjectName("menuDrone") - self.menuDeveloper_mode_2 = QtWidgets.QMenu(self.menuDrone) - self.menuDeveloper_mode_2.setObjectName("menuDeveloper_mode_2") - self.menuMusic = QtWidgets.QMenu(self.menubar) - self.menuMusic.setObjectName("menuMusic") + self.menuMusic_2 = QtWidgets.QMenu(self.menuOptions) + self.menuMusic_2.setObjectName("menuMusic_2") + self.menuTable = QtWidgets.QMenu(self.menubar) + self.menuTable.setObjectName("menuTable") + self.menuDrone_2 = QtWidgets.QMenu(self.menubar) + self.menuDrone_2.setObjectName("menuDrone_2") + self.menuSend = QtWidgets.QMenu(self.menuDrone_2) + self.menuSend.setObjectName("menuSend") + self.menuRestart = QtWidgets.QMenu(self.menuDrone_2) + self.menuRestart.setObjectName("menuRestart") + self.menuRetrive = QtWidgets.QMenu(self.menuDrone_2) + self.menuRetrive.setObjectName("menuRetrive") MainWindow.setMenuBar(self.menubar) self.action_send_animations = QtWidgets.QAction(MainWindow) self.action_send_animations.setObjectName("action_send_animations") self.action_send_configurations = QtWidgets.QAction(MainWindow) self.action_send_configurations.setObjectName("action_send_configurations") - self.action_send_Aruco_map = QtWidgets.QAction(MainWindow) - self.action_send_Aruco_map.setObjectName("action_send_Aruco_map") + self.action_send_aruco_map = QtWidgets.QAction(MainWindow) + self.action_send_aruco_map.setObjectName("action_send_aruco_map") self.action_update_client_repo = QtWidgets.QAction(MainWindow) self.action_update_client_repo.setObjectName("action_update_client_repo") self.actionSend_launch_file_for_clever = QtWidgets.QAction(MainWindow) @@ -280,38 +279,69 @@ class Ui_MainWindow(object): self.action_restart_chrony.setObjectName("action_restart_chrony") self.action_send_fcu_parameters = QtWidgets.QAction(MainWindow) self.action_send_fcu_parameters.setObjectName("action_send_fcu_parameters") - self.menuDeveloper_mode.addAction(self.action_send_any_file) - self.menuDeveloper_mode.addAction(self.action_send_any_command) - self.menuOptions.addAction(self.action_send_animations) - self.menuOptions.addAction(self.action_send_configurations) - self.menuOptions.addAction(self.action_send_launch_file) - self.menuOptions.addAction(self.action_send_Aruco_map) - self.menuOptions.addAction(self.action_send_calibrations) - self.menuOptions.addAction(self.action_send_fcu_parameters) + self.action_toggle_select = QtWidgets.QAction(MainWindow) + self.action_toggle_select.setObjectName("action_toggle_select") + self.action_select_all = QtWidgets.QAction(MainWindow) + self.action_select_all.setObjectName("action_select_all") + self.action_deselect_all = QtWidgets.QAction(MainWindow) + self.action_deselect_all.setObjectName("action_deselect_all") + self.action_edit_server_config = QtWidgets.QAction(MainWindow) + self.action_edit_server_config.setObjectName("action_edit_server_config") + self.action_edit_any_config = QtWidgets.QAction(MainWindow) + self.action_edit_any_config.setObjectName("action_edit_any_config") + self.action_update_server_git = QtWidgets.QAction(MainWindow) + self.action_update_server_git.setObjectName("action_update_server_git") + self.action_retrive_any_file = QtWidgets.QAction(MainWindow) + self.action_retrive_any_file.setObjectName("action_retrive_any_file") + self.action_restart_server = QtWidgets.QAction(MainWindow) + self.action_restart_server.setObjectName("action_restart_server") + self.action_configure_columns = QtWidgets.QAction(MainWindow) + self.action_configure_columns.setObjectName("action_configure_columns") + self.menuMusic_2.addAction(self.action_select_music_file) + self.menuMusic_2.addAction(self.action_play_music) + self.menuMusic_2.addAction(self.action_stop_music) + self.menuOptions.addAction(self.menuMusic_2.menuAction()) self.menuOptions.addSeparator() - self.menuOptions.addAction(self.menuDeveloper_mode.menuAction()) + self.menuOptions.addAction(self.action_edit_server_config) + self.menuOptions.addAction(self.action_edit_any_config) self.menuOptions.addSeparator() - self.menuOptions.addAction(self.action_select_all_rows) - self.menuAnimation.addAction(self.action_set_start_to_current_position) - self.menuAnimation.addAction(self.action_reset_start) - self.menuDeveloper_mode_2.addAction(self.action_restart_clever) - self.menuDeveloper_mode_2.addAction(self.action_restart_clever_show) - self.menuDeveloper_mode_2.addAction(self.action_update_client_repo) - self.menuDeveloper_mode_2.addAction(self.action_reboot_all) - self.menuDrone.addAction(self.action_set_z_offset_to_ground) - self.menuDrone.addAction(self.action_reset_z_offset) - self.menuDrone.addSeparator() - self.menuDrone.addAction(self.action_restart_chrony) - self.menuDrone.addAction(self.action_remove_row) - self.menuDrone.addSeparator() - self.menuDrone.addAction(self.menuDeveloper_mode_2.menuAction()) - self.menuMusic.addAction(self.action_select_music_file) - self.menuMusic.addAction(self.action_play_music) - self.menuMusic.addAction(self.action_stop_music) + self.menuOptions.addAction(self.action_update_server_git) + self.menuOptions.addAction(self.action_restart_server) + self.menuTable.addAction(self.action_toggle_select) + self.menuTable.addAction(self.action_select_all) + self.menuTable.addAction(self.action_deselect_all) + self.menuTable.addSeparator() + self.menuTable.addAction(self.action_remove_row) + self.menuTable.addSeparator() + self.menuTable.addAction(self.action_configure_columns) + self.menuSend.addAction(self.action_send_animations) + self.menuSend.addAction(self.action_send_configurations) + self.menuSend.addAction(self.action_send_launch_file) + self.menuSend.addAction(self.action_send_aruco_map) + self.menuSend.addAction(self.action_send_calibrations) + self.menuSend.addAction(self.action_send_fcu_parameters) + self.menuSend.addSeparator() + self.menuSend.addAction(self.action_send_any_file) + self.menuSend.addAction(self.action_send_any_command) + self.menuRestart.addAction(self.action_restart_chrony) + self.menuRestart.addAction(self.action_restart_clever) + self.menuRestart.addAction(self.action_restart_clever_show) + self.menuRestart.addSeparator() + self.menuRestart.addAction(self.action_reboot_all) + self.menuRetrive.addAction(self.action_retrive_any_file) + self.menuDrone_2.addAction(self.menuSend.menuAction()) + self.menuDrone_2.addAction(self.menuRetrive.menuAction()) + self.menuDrone_2.addAction(self.menuRestart.menuAction()) + self.menuDrone_2.addSeparator() + self.menuDrone_2.addAction(self.action_reset_z_offset) + self.menuDrone_2.addAction(self.action_set_z_offset_to_ground) + self.menuDrone_2.addAction(self.action_set_start_to_current_position) + self.menuDrone_2.addAction(self.action_reset_start) + self.menuDrone_2.addSeparator() + self.menuDrone_2.addAction(self.action_update_client_repo) self.menubar.addAction(self.menuOptions.menuAction()) - self.menubar.addAction(self.menuDrone.menuAction()) - self.menubar.addAction(self.menuAnimation.menuAction()) - self.menubar.addAction(self.menuMusic.menuAction()) + self.menubar.addAction(self.menuTable.menuAction()) + self.menubar.addAction(self.menuDrone_2.menuAction()) self.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) @@ -334,23 +364,24 @@ class Ui_MainWindow(object): self.emergency_land_button.setText(_translate("MainWindow", "Emergency land")) self.disarm_all_button.setText(_translate("MainWindow", "Disarm ALL")) self.disarm_selected_button.setText(_translate("MainWindow", "Disarm selected")) - self.flip_button.setText(_translate("MainWindow", "Flip")) - self.takeoff_button.setText(_translate("MainWindow", "Takeoff")) self.leds_button.setText(_translate("MainWindow", "Test leds")) + self.takeoff_button.setText(_translate("MainWindow", "Takeoff")) self.z_checkbox.setText(_translate("MainWindow", " Z =")) self.z_spin.setSuffix(_translate("MainWindow", " m")) + self.flip_button.setText(_translate("MainWindow", "Flip")) self.reboot_fcu.setText(_translate("MainWindow", "Reboot FCU")) self.calibrate_gyro.setText(_translate("MainWindow", "Calibrate gyro")) self.calibrate_level.setText(_translate("MainWindow", "Calibrate level")) self.menuOptions.setTitle(_translate("MainWindow", "Server")) - self.menuDeveloper_mode.setTitle(_translate("MainWindow", "Developer mode")) - self.menuAnimation.setTitle(_translate("MainWindow", "Animation")) - self.menuDrone.setTitle(_translate("MainWindow", "Drone")) - self.menuDeveloper_mode_2.setTitle(_translate("MainWindow", "Developer mode")) - self.menuMusic.setTitle(_translate("MainWindow", "Music")) + self.menuMusic_2.setTitle(_translate("MainWindow", "Music")) + self.menuTable.setTitle(_translate("MainWindow", "Table")) + self.menuDrone_2.setTitle(_translate("MainWindow", "Drone")) + self.menuSend.setTitle(_translate("MainWindow", "Send")) + self.menuRestart.setTitle(_translate("MainWindow", "Restart")) + self.menuRetrive.setTitle(_translate("MainWindow", "Retrive")) self.action_send_animations.setText(_translate("MainWindow", "Send animations")) self.action_send_configurations.setText(_translate("MainWindow", "Send configurations")) - self.action_send_Aruco_map.setText(_translate("MainWindow", "Send aruco map")) + self.action_send_aruco_map.setText(_translate("MainWindow", "Send aruco map")) self.action_update_client_repo.setText(_translate("MainWindow", "Update clever-show git")) self.actionSend_launch_file_for_clever.setText(_translate("MainWindow", "Send launch file for clever")) self.action_send_launch_file.setText(_translate("MainWindow", "Send launch files")) @@ -369,8 +400,31 @@ class Ui_MainWindow(object): self.action_send_any_file.setText(_translate("MainWindow", "Send any file")) self.action_send_any_command.setText(_translate("MainWindow", "Send any command")) self.action_stop_music.setText(_translate("MainWindow", "Stop music")) - self.action_remove_row.setText(_translate("MainWindow", "Remove from table")) + self.action_remove_row.setText(_translate("MainWindow", "Remove drone")) + self.action_remove_row.setShortcut(_translate("MainWindow", "Ctrl+Del")) self.action_send_calibrations.setText(_translate("MainWindow", "Send camera calibrations")) - self.action_reboot_all.setText(_translate("MainWindow", "Reboot all")) + self.action_reboot_all.setText(_translate("MainWindow", "Reboot system")) self.action_restart_chrony.setText(_translate("MainWindow", "Restart chrony")) self.action_send_fcu_parameters.setText(_translate("MainWindow", "Send FCU parameters")) + self.action_toggle_select.setText(_translate("MainWindow", "Toggle select")) + self.action_toggle_select.setShortcut(_translate("MainWindow", "Ctrl+A")) + self.action_select_all.setText(_translate("MainWindow", "Select all")) + self.action_select_all.setShortcut(_translate("MainWindow", "Shift+A")) + self.action_deselect_all.setText(_translate("MainWindow", "Deselect all")) + self.action_deselect_all.setShortcut(_translate("MainWindow", "Alt+A")) + self.action_edit_server_config.setText(_translate("MainWindow", "Edit server config")) + self.action_edit_any_config.setText(_translate("MainWindow", "Edit any config")) + self.action_update_server_git.setText(_translate("MainWindow", "Update server git")) + self.action_retrive_any_file.setText(_translate("MainWindow", "Retrive any file")) + self.action_restart_server.setText(_translate("MainWindow", "Restart server")) + self.action_configure_columns.setText(_translate("MainWindow", "Configure columns")) + + +if __name__ == "__main__": + import sys + app = QtWidgets.QApplication(sys.argv) + MainWindow = QtWidgets.QMainWindow() + ui = Ui_MainWindow() + ui.setupUi(MainWindow) + MainWindow.show() + sys.exit(app.exec_()) diff --git a/Server/server_gui.ui b/Server/server_gui.ui index 59e73ad..0dd485a 100644 --- a/Server/server_gui.ui +++ b/Server/server_gui.ui @@ -7,7 +7,7 @@ 0 0 1360 - 761 + 816 @@ -76,7 +76,7 @@ Qt::AlignHCenter|Qt::AlignTop - + Qt::RightToLeft @@ -89,14 +89,14 @@ - + s - + Qt::RightToLeft @@ -106,7 +106,7 @@ - + s @@ -119,7 +119,7 @@ - + Qt::RightToLeft @@ -129,7 +129,7 @@ - + @@ -144,7 +144,7 @@ Qt::DefaultContextMenu - Qt::RightToLeft + Qt::LeftToRight false @@ -224,14 +224,14 @@ 0 - + Land selected - + Land ALL @@ -286,17 +286,14 @@ Qt::AlignCenter - - 6 - - + Disarm ALL - + Disarm selected @@ -320,10 +317,10 @@ Qt::AlignCenter - - + + - Flip + Test leds @@ -337,26 +334,13 @@ - - - - Test leds - - - - + 0 - - ArrowCursor - - - Qt::NoFocus - Qt::LeftToRight @@ -383,6 +367,13 @@ + + + + Flip + + + @@ -435,71 +426,89 @@ 0 0 1360 - 22 + 26 Server - + - Developer mode + Music - - + + + - - - - - - + - + + - + + - + - Animation + Table - - + + + + + + + - + Drone - + - Developer mode + Send + + + + + + + + + + + + + Restart + + - + - + + + Retrive + + + + + + + + + + - - - - - - - - Music - - - - + - - - + + @@ -511,7 +520,7 @@ Send configurations - + Send aruco map @@ -606,7 +615,10 @@ - Remove from table + Remove drone + + + Ctrl+Del @@ -616,7 +628,7 @@ - Reboot all + Reboot system @@ -629,6 +641,60 @@ Send FCU parameters + + + Toggle select + + + Ctrl+A + + + + + Select all + + + Shift+A + + + + + Deselect all + + + Alt+A + + + + + Edit server config + + + + + Edit any config + + + + + Update server git + + + + + Retrive any file + + + + + Restart server + + + + + Configure columns + + start_delay_spin diff --git a/Server/server_qt.py b/Server/server_qt.py index e7d2899..7f422ba 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -26,7 +26,7 @@ import messaging_lib as messaging import config as cfg import copter_table_models as table -from copter_table import CopterTableWidget +from copter_table import CopterTableWidget, HeaderEditDialog from visual_land_dialog import VisualLandDialog from config_editor_models import ConfigDialog @@ -38,6 +38,8 @@ def multi_glob(*patterns): def b_partial(func, *args, **kwargs): # call argument blocker partial return lambda *a: func(*args, **kwargs) +def restart(): + os.execl(sys.executable, os.path.abspath(__file__), *sys.argv) def confirmation_required(text="Are you sure?", label="Confirm operation?"): def inner(f): @@ -100,11 +102,15 @@ class MainWindow(QtWidgets.QMainWindow): self.player = QtMultimedia.QMediaPlayer() def init_ui(self): + self.init_table() + # Connecting self.ui.check_button.clicked.connect(self.selfcheck_selected) self.ui.start_button.clicked.connect(self.send_start_time_selected) self.ui.pause_button.clicked.connect(self.pause_resume_selected) + self.ui.z_checkbox.clicked.connect(self.ui.z_spin.setEnabled) + self.ui.land_all_button.clicked.connect(b_partial(Client.broadcast_message, "land")) self.ui.land_selected_button.clicked.connect(b_partial(self.send_to_selected, "land")) self.ui.disarm_all_button.clicked.connect(b_partial(Client.broadcast_message, "disarm")) @@ -117,32 +123,40 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.reboot_fcu.clicked.connect(b_partial(self.send_to_selected, "reboot_fcu")) self.ui.calibrate_gyro.clicked.connect(self.calibrate_gyro_selected) self.ui.calibrate_level.clicked.connect(self.calibrate_level_selected) - self.ui.action_remove_row.triggered.connect(self.remove_selected) - self.ui.action_send_animations.triggered.connect(self.send_animations) - self.ui.action_send_calibrations.triggered.connect(self.send_calibrations) - self.ui.action_send_configurations.triggered.connect(self.send_config) - self.ui.action_send_Aruco_map.triggered.connect(self.send_aruco) - self.ui.action_send_launch_file.triggered.connect(self.send_launch) - self.ui.action_send_fcu_parameters.triggered.connect(self.send_fcu_parameters) - self.ui.action_send_any_file.triggered.connect(self.send_any_file) - self.ui.action_send_any_command.triggered.connect(self.send_any_command) - self.ui.action_restart_clever.triggered.connect( - b_partial(self.send_to_selected, "service_restart", kwargs={"name": "clever"})) - self.ui.action_restart_clever_show.triggered.connect(self.restart_clever_show) - self.ui.action_update_client_repo.triggered.connect(b_partial(self.send_to_selected, "update_repo")) - self.ui.action_reboot_all.triggered.connect(b_partial(self.send_to_selected, "reboot_all")) - self.ui.action_set_start_to_current_position.triggered.connect(b_partial(self.send_to_selected, "move_start")) - self.ui.action_reset_start.triggered.connect(b_partial(self.send_to_selected, "reset_start")) - self.ui.action_set_z_offset_to_ground.triggered.connect(b_partial(self.send_to_selected, "set_z_to_ground")) - self.ui.action_reset_z_offset.triggered.connect(b_partial(self.send_to_selected, "reset_z_offset")) - self.ui.action_restart_chrony.triggered.connect(self.restart_chrony) + self.ui.action_select_music_file.triggered.connect(self.select_music_file) self.ui.action_play_music.triggered.connect(self.play_music) self.ui.action_stop_music.triggered.connect(self.stop_music) - self.ui.action_select_all_rows.triggered.connect(self.model.select_all) + self.ui.action_restart_server.triggered.connect(restart) - self.init_table() + self.ui.action_select_all.triggered.connect(partial(self.ui.copter_table.select_all, Qt.Checked)) + self.ui.action_deselect_all.triggered.connect(partial(self.ui.copter_table.select_all, Qt.Unchecked)) + self.ui.action_toggle_select.triggered.connect(self.ui.copter_table.toggle_select) + self.ui.action_remove_row.triggered.connect(self.remove_selected) + self.ui.action_configure_columns.triggered.connect(self.configure_columns) + + self.ui.action_send_animations.triggered.connect(self.send_animations) + self.ui.action_send_calibrations.triggered.connect(self.send_calibrations) + self.ui.action_send_configurations.triggered.connect(self.send_config) + self.ui.action_send_aruco_map.triggered.connect(self.send_aruco) + self.ui.action_send_launch_file.triggered.connect(self.send_launch) + self.ui.action_send_fcu_parameters.triggered.connect(self.send_fcu_parameters) + self.ui.action_send_any_file.triggered.connect(self.send_any_file) + self.ui.action_send_any_command.triggered.connect(self.send_any_command) + + self.ui.action_restart_clever.triggered.connect( + b_partial(self.send_to_selected, "service_restart", kwargs={"name": "clever"})) + self.ui.action_restart_clever_show.triggered.connect(self.restart_clever_show) + self.ui.action_restart_chrony.triggered.connect(self.restart_chrony) + self.ui.action_reboot_all.triggered.connect(b_partial(self.send_to_selected, "reboot_all")) + + self.ui.action_set_start_to_current_position.triggered.connect(b_partial(self.send_to_selected, "move_start")) + self.ui.action_reset_start.triggered.connect(b_partial(self.send_to_selected, "reset_start")) + self.ui.action_set_z_offset_to_ground.triggered.connect(b_partial(self.send_to_selected, "set_z_to_ground")) + self.ui.action_reset_z_offset.triggered.connect(b_partial(self.send_to_selected, "reset_z_offset")) + + self.ui.action_update_client_repo.triggered.connect(b_partial(self.send_to_selected, "update_repo")) def init_table(self): # Remove standard table widget @@ -153,6 +167,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.copter_table.setObjectName("copter_table") # Insert to layout at right self.ui.horizontalLayout.insertWidget(0, self.ui.copter_table, 0) + self.ui.copter_table.setFocus() def init_model(self): # Connect model signals to UI @@ -239,31 +254,16 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot(object, dict) def update_table_data(self, client, telems: dict): - cols_dict = { - "git_version": 1, - "animation_id": 2, - "battery": 3, - "fcu_status": 4, - "calibration_status": 5, - "mode": 6, - "selfcheck": 7, - "current_position": 8, - "start_position": 9, - "task": 10, - "time": 11, - "config_version": 12, - } - for key, value in telems.items(): - col = cols_dict.get(key, None) - if col is None: - logging.error("No column {} present!".format(key)) - continue - - row_data = self.model.get_row_by_attr("client", client) - row_num = self.model.get_row_index(row_data) - if row_num is not None: - self.model.update_data(row_num, col, value, Qt.EditRole) + try: + col = self.model.index(key) + except ValueError: + logging.error(f"No column {key} present!") + else: + row_data = self.model.get_row_by_attr("client", client) + row_num = self.model.get_row_index(row_data) + if row_num is not None: + self.model.update_data(row_num, col, value, Qt.EditRole) @pyqtSlot() def remove_selected(self): @@ -548,8 +548,11 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def visual_land(self): - dialog = VisualLandDialog(self.model) - dialog.start() + VisualLandDialog(self.model).start() + + @pyqtSlot() + def configure_columns(self): + HeaderEditDialog(self.ui.copter_table, self.server.config).exec() def register_callbacks(self): @messaging.message_callback("telemetry") From eee1b9700234bc9eacd1050e19a3c0dd7760a3cd Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sat, 1 Feb 2020 11:39:23 +0300 Subject: [PATCH 126/210] Updated columns names at client --- Drone/copter_client.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 597e6c1..a8c506b 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -658,8 +658,8 @@ class Telemetry: "selfcheck": None, "current_position": None, "start_position": None, - "task": None, - "time": None, + "last_task": None, + "time_delta": None, "config_version": None, } @@ -747,7 +747,7 @@ class Telemetry: def update_telemetry_fast(self): self.start_position = self.get_start_position() - self.task = task_manager.get_current_task() + self.last_task = task_manager.get_current_task() try: self.ros_telemetry = FlightLib.get_telemetry_locked(client.active_client.config.copter_frame_id) if self.ros_telemetry.connected: @@ -764,7 +764,7 @@ class Telemetry: rospy.logdebug(e) except rospy.TransportException as e: rospy.logdebug(e) - self.time = time.time() + self.time_delta = time.time() self.round_telemetry() def update_telemetry_slow(self): From 3332f14309d3111771f21d90fae8dd06a1704041 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sat, 1 Feb 2020 14:17:39 +0300 Subject: [PATCH 127/210] Added file requesting functionality --- Server/server_qt.py | 29 ++++++++++++++++++++++++++++- config.py | 2 +- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 7f422ba..fb1e33c 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -145,6 +145,8 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_send_any_file.triggered.connect(self.send_any_file) self.ui.action_send_any_command.triggered.connect(self.send_any_command) + self.ui.action_retrive_any_file.triggered.connect(b_partial(self.request_any_file, client_path=None)) + self.ui.action_restart_clever.triggered.connect( b_partial(self.send_to_selected, "service_restart", kwargs={"name": "clever"})) self.ui.action_restart_clever_show.triggered.connect(self.restart_clever_show) @@ -401,6 +403,31 @@ class MainWindow(QtWidgets.QMainWindow): files = multi_glob(*patterns) self._send_files(files, copters, client_path, client_filename, match_id, callback) + def request_any_file(self, client_path=None, copters=None): + if client_path is None: + _client_path, ok = QInputDialog.getText(self, "Enter path of file to request from client", "Source:", + QLineEdit.Normal, "/home/pi/") + if not ok: + return + client_path = _client_path + + save_path = QFileDialog.getSaveFileName(self, "Save file to:", directory=os.path.split(client_path)[1], + filter=f"Current ext(*{os.path.splitext(client_path)[1]});;" + f"All files(*.*)")[0] + if not save_path: + return + + if copters is None: + copters = self.model.user_selected() + copters = list(copters) + + logging.info(f'Requesting file {client_path} to local {save_path} from clients: {copters}') + for copter in copters: + if len(copters) > 1: + save_path = cfg.modify_filename(save_path, f"{{}}_{copter.copter_id}") + copter.client.get_file(client_path, save_path) + logging.info('Files requested') + @pyqtSlot() def send_any_file(self): file = QFileDialog.getOpenFileName(self, "Select any file")[0] @@ -412,7 +439,7 @@ class MainWindow(QtWidgets.QMainWindow): if not ok: return - c_filepath, c_filename = os.path.split(c_path) + c_filepath, c_filename = os.path.split(c_path) # c stands for client files = [file] self._send_files(files, client_path=c_filepath, client_filename=c_filename) diff --git a/config.py b/config.py index f5ade20..d1c878f 100644 --- a/config.py +++ b/config.py @@ -6,7 +6,7 @@ from configobj import ConfigObj, Section, flatten_errors from validate import Validator -def modify_filename(path, pattern): +def modify_filename(path, pattern): # TODO move to core old_path, filename = os.path.split(path) filename, ext = os.path.splitext(filename) newfilename = pattern.format(filename) + ext From ed9041d04ab86f68853d2127aa473d3576af0542 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sat, 1 Feb 2020 14:41:00 +0300 Subject: [PATCH 128/210] Added server update functionality --- Server/server_qt.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index fb1e33c..5c41637 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -7,6 +7,7 @@ import logging import asyncio import platform import itertools +import subprocess from functools import partial, wraps from PyQt5 import QtWidgets, QtMultimedia, QtCore @@ -38,9 +39,15 @@ def multi_glob(*patterns): def b_partial(func, *args, **kwargs): # call argument blocker partial return lambda *a: func(*args, **kwargs) -def restart(): + +def restart(): # move to core os.execl(sys.executable, os.path.abspath(__file__), *sys.argv) + +def update_server(): + subprocess.call("git pull --rebase") + restart() + def confirmation_required(text="Are you sure?", label="Confirm operation?"): def inner(f): @wraps(f) @@ -129,6 +136,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_stop_music.triggered.connect(self.stop_music) self.ui.action_restart_server.triggered.connect(restart) + self.ui.action_update_server_git.triggered.connect(update_server) self.ui.action_select_all.triggered.connect(partial(self.ui.copter_table.select_all, Qt.Checked)) self.ui.action_deselect_all.triggered.connect(partial(self.ui.copter_table.select_all, Qt.Unchecked)) @@ -420,7 +428,7 @@ class MainWindow(QtWidgets.QMainWindow): if copters is None: copters = self.model.user_selected() copters = list(copters) - + logging.info(f'Requesting file {client_path} to local {save_path} from clients: {copters}') for copter in copters: if len(copters) > 1: From 67b86d303bdc46d4df38344ca47f3e28977d66ae Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 4 Feb 2020 15:52:58 +0300 Subject: [PATCH 129/210] Added dialog for existing configs and server config dialog, improved config.py --- Server/config_editor_models.py | 87 +++++++++++++++++++++------------- Server/server_qt.py | 14 ++++++ config.py | 38 +++++++++------ 3 files changed, 92 insertions(+), 47 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 8471878..45699af 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -158,14 +158,14 @@ class ConfigModelItem: if self.spec_default is not None and self.data(1) == self.spec_default \ and self.data(0) == self.default_values[0] and self.type != 'section': self.set_state('default') - print('def', self.data(1), self.data(0), self.spec_default) + # print('def', self.data(1), self.data(0), self.spec_default) child_states = [child.state for child in self.childItems] if any(state in child_states for state in ['edited', 'added', 'deleted']): self.state = 'edited' if len(set(child_states)) == 1: # if all states equal self.set_state(child_states[0], set_children=False) - print(child_states) + # print(child_states) if self.parentItem is not None: self.parentItem.check_state() @@ -285,7 +285,7 @@ class ConfigModel(QtCore.QAbstractItemModel): childItem = index.internalPointer() if not isinstance(childItem, ConfigModelItem): - print(childItem, index.column()), # index.row(), index.parent().internalPointer()) + # print(childItem, index.column()), # index.row(), index.parent().internalPointer()) return QtCore.QModelIndex() parentItem = childItem.parent() @@ -744,10 +744,15 @@ class ConfigDialog(QtWidgets.QDialog): super(ConfigDialog, self).__init__(parent) self.ui = config_editor.Ui_config_dialog() self.model = ConfigModel(widget=self) + self._filename = None self.unsaved = False self.setupUi() self.copter_editor_signal.connect(self._call_copter_dialog) + @property + def filename(self): + return self._filename or 'Untitled.ini' + def setupModel(self, data, pure_dict=False, convert_types=False): if pure_dict: self.model.dict_setup(data, convert_types=convert_types) @@ -774,10 +779,12 @@ class ConfigDialog(QtWidgets.QDialog): # self.ui.delete_button.pressed.connect(self.remove_selected) + def update_title(self): + self.setWindowTitle(f"Config editor - {self.filename}" + "*"*self.unsaved) + def unsaved_call(self): - name = self.windowTitle()+'*' - self.setWindowTitle(name) self.unsaved = True + self.update_title() self.model.dataChanged.disconnect(self.unsaved_call) def closeEvent(self, event): @@ -803,13 +810,14 @@ class ConfigDialog(QtWidgets.QDialog): return reply == QMessageBox.Yes def save_as(self): - cfg = config.ConfigManager() - cfg.load_from_dict(self.model.to_config_dict()) save_path = QFileDialog.getSaveFileName(self, "Save as configuration file", + directory=self.filename, filter="Config files (*.ini)")[0] if not save_path: return + cfg = config.ConfigManager() + cfg.load_from_dict(self.model.to_config_dict()) cfg.config.filename = save_path cfg.write() @@ -841,21 +849,36 @@ class ConfigDialog(QtWidgets.QDialog): @pyqtSlot(object, object) def _call_copter_dialog(self, client, value): - logging.info("Opening dialog") + logging.info("Opening copter config dialog") config_dict, spec_dict = value["config"], value["configspec"] cfg = config.ConfigManager() cfg.load_from_dict(config_dict, spec_dict) - self.setupModel(cfg.full_dict(include_defaults=True)) - if not self.validation_loop(cfg, spec_dict): - return False + def save_callback(): + edited_dict = cfg.full_dict(include_defaults=False) + client.send_message("config", {"config": edited_dict, "mode": "rewrite"}) - edited_dict = self.model.to_config_dict() - client.send_message("config", {"config": edited_dict, "mode": "rewrite"}) - - if self.ui.do_restart.isChecked(): + def restart_callback(): client.send_message("service_restart", {"name": "clever-show"}) + if not self.call_config_dialog(cfg, save_callback, restart_callback, f"{client.copter_id}"): + return False + return True + + def call_config_dialog(self, cfg: config.ConfigManager, on_save=None, on_restart=None, name="Untitled.ini"): + self.setupModel(cfg.full_dict(include_defaults=True), convert_types=(not cfg.validated)) + self.ui.do_restart.setEnabled(on_restart is not None) + self._filename = name + self.update_title() + + if not self.validation_loop(cfg, cfg.config.configspec): + return False + + if on_save is not None: + on_save() + + if on_restart is not None and self.ui.do_restart.isChecked(): + on_restart() return True def call_standalone_dialog(self): @@ -872,26 +895,26 @@ class ConfigDialog(QtWidgets.QDialog): "Config cannot be opened or validated: {}".format(error)) return False - self.setupModel(cfg.full_dict(include_defaults=True), convert_types=(not cfg.validated)) - self.ui.do_restart.setDisabled(True) + def save_callback(): + if cfg.config.filename is None: + save_path = QFileDialog.getSaveFileName(self, "Save configuration file", + directory=self.filename, + filter="Config files (*.ini)")[0] + if not save_path: + return False + else: + save_path = cfg.config.filename - filename = cfg.config.filename - validation_path = path if cfg.config.filename is None else cfg.config.filename - validation_path = validation_path if cfg.validated else None + cfg.config.filename = save_path + cfg.write() - if not self.validation_loop(cfg, validation_path): + if cfg.config.filename is not None: + name = os.path.split(cfg.config.filename)[1] + else: # when editing only configspec-based file + name = os.path.split(path)[1] + + if not self.call_config_dialog(cfg, on_save=save_callback, name=name): return False - - if filename is None: - save_path = QFileDialog.getSaveFileName(self, "Save configuration file", - filter="Config files (*.ini)")[0] - if not save_path: - return False - else: - save_path = filename - - cfg.config.filename = save_path - cfg.write() return True diff --git a/Server/server_qt.py b/Server/server_qt.py index 5c41637..1e47482 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -41,6 +41,7 @@ def b_partial(func, *args, **kwargs): # call argument blocker partial def restart(): # move to core + # ANY prints will break restarting or opening new windows after restart os.execl(sys.executable, os.path.abspath(__file__), *sys.argv) @@ -135,6 +136,10 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_play_music.triggered.connect(self.play_music) self.ui.action_stop_music.triggered.connect(self.stop_music) + self.ui.action_edit_any_config.triggered.connect(ConfigDialog.call_standalone_dialog) + self.ui.action_edit_server_config.triggered.connect(self.edit_server_config) + + self.ui.action_restart_server.triggered.connect(restart) self.ui.action_update_server_git.triggered.connect(update_server) @@ -589,6 +594,15 @@ class MainWindow(QtWidgets.QMainWindow): def configure_columns(self): HeaderEditDialog(self.ui.copter_table, self.server.config).exec() + @pyqtSlot() + def edit_server_config(self): + config = self.server.config + + def save_callback(): + config.write() + + ConfigDialog().call_config_dialog(config, save_callback, restart, name="Server config") + def register_callbacks(self): @messaging.message_callback("telemetry") def get_telem_data(client, value, **kwargs): diff --git a/config.py b/config.py index d1c878f..047ed39 100644 --- a/config.py +++ b/config.py @@ -48,7 +48,6 @@ class ValidationError(ValueError): class ConfigManager: def __init__(self, config=None): self.config = ConfigObj() if config is None else config - self.validated = False self._name_dict = {} @@ -75,13 +74,16 @@ class ConfigManager: def write(self): self.config.write() + @property + def validated(self): + return self.config.configspec is not None + def set_config(self, config): self.config = config self._name_dict = self.flatten_keys(config) - self.validated = False def validate_config(self, config=None, copy_defaults=False): - config = config or self.config + config = self.config if config is None else config vdt = Validator() test = config.validate(vdt, copy=copy_defaults, preserve_errors=True) @@ -89,7 +91,6 @@ class ConfigManager: raise ValidationError('Some config values are wrong', config, test) self.set_config(config) - self.validated = True @classmethod def _full_dict(cls, item, include_defaults=False): @@ -263,21 +264,27 @@ class ConfigManager: final_comment = d.pop('final_comment', ['']) kwargs = {'infile': self._extract_values(d), 'indent_type': ''} + filename = None if isinstance(configspec, dict): kwargs.update({'configspec': configspec}) elif isinstance(configspec, str): - spec_path = self._get_spec_path(configspec) # check for /spec, then for config - if not self._config_exists(spec_path): - spec_path = configspec - if self._config_exists(spec_path): + spec_path = self._get_spec_path(configspec) + if self._config_exists(spec_path): # when 'configspec' points to configuration file and configspec exists kwargs.update({'configspec': spec_path}) + filename = configspec + elif self._config_exists(configspec): # when 'configspec' points to configspec file + kwargs.update({'configspec': configspec}) + if parent_dir(configspec) == 'spec': + filename = self._get_config_path(configspec) + else: + raise ValueError("Configspec does not exist") config = ConfigObj(**kwargs) - config.filename = configspec if isinstance(configspec, str) else None + config.filename = filename config.initial_comment = initial_comment config.final_comment = final_comment - if configspec is not None: + if config.configspec is not None: self.validate_config(config) else: self.set_config(config) @@ -318,13 +325,14 @@ if __name__ == '__main__': import pprint #pprint.pprint(cfg.full_dict) cfg2 = ConfigManager() - cfg2.load_from_dict({"PRIVATE": {"offset": [1, 2, 3]}}, configspec='Drone/config/spec/configspec_client.ini') - #cfg2.load_from_dict({"PRIVATE": {"id": 123132}}) + #cfg2.load_from_dict({"PRIVATE": {"offset": [1, 2, 3]}}, configspec='Drone/config/spec/configspec_client.ini') + cfg2.load_from_dict({"PRIVATE": {"id": "heh"}}) #pprint.pprint(cfg2.full_dict) - cfg.merge(cfg2) + #cfg.merge(cfg2) #pprint.pprint(cfg.full_dict) - print(cfg.full_dict(include_defaults=True)) - print(dict(cfg.config.configspec)) + print(cfg2.full_dict(include_defaults=True)) + #print(dict(cfg2.config.configspec)) + print(cfg2.config.PRIVATE) #print(dict(ConfigManager(cfg.config.configspec).config)) # #print(cfg.full_dict) From 7fa3a692a175e887350d5bcd117aff82d7d726a1 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 4 Feb 2020 20:47:46 +0300 Subject: [PATCH 130/210] Added drop support for config cells --- Server/copter_table.py | 2 +- Server/copter_table_models.py | 65 ++++++++++++++++++++++++++--------- Server/server_qt.py | 2 +- config.py | 14 ++++---- 4 files changed, 58 insertions(+), 25 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index 46f7edd..06b6c7b 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -60,7 +60,7 @@ class CopterTableWidget(QTableView): self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectItems) self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) self.doubleClicked.connect(self.on_double_click) - self.setDragEnabled(True) + self.setDragDropMode(QAbstractItemView.DragDrop) def moved(self, logical_index, old_index, new_index): name = self.current_columns.pop(old_index) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 8e681ed..75834f1 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -9,10 +9,13 @@ from functools import partialmethod from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt as Qt, QUrl, QDir +from config import ConfigManager + # Additional custom roles to interact with various table data ModelDataRole = 998 ModelStateRole = 999 + def get_git_version(): # TODO import from animation return subprocess.check_output("git log --pretty=format:%h -n 1").decode('UTF-8') @@ -111,6 +114,7 @@ def check_pos(item): return False return not math.isnan(item[0]) + # @ModelChecks.column_check("last_task") # def check_task(item): # return True @@ -137,13 +141,13 @@ def check_start_pos(item, context): def get_position(position): if position != 'NO_POS' and position[0] != 'nan': # float('nan')? return position - return [float('nan')]*3 + return [float('nan')] * 3 def get_distance(pos1, pos2): # todo as common function - if any(math.isnan(x) for x in pos1+pos2): + if any(math.isnan(x) for x in pos1 + pos2): return float('nan') - return math.sqrt(sum(map(lambda p: p[0] - p[1], zip(pos1, pos2)))**2) # point distance formula + return math.sqrt(sum(map(lambda p: p[0] - p[1], zip(pos1, pos2))) ** 2) # point distance formula class CopterData: @@ -234,6 +238,7 @@ class ModelFormatter: place_formatter = partialmethod(column_formatter, formatter_type=PLACE_FORMATTER) view_formatter = partialmethod(column_formatter, formatter_type=VIEW_FORMATTER) + @ModelFormatter.place_formatter("copter_id") def place_id(value): value = str(value).strip() @@ -262,6 +267,7 @@ def place_battery(value): return "NO_INFO" return value + @ModelFormatter.view_formatter("battery") def view_battery(value): if isinstance(value, list): @@ -315,6 +321,7 @@ def view_time_delta(value): class CopterDataModel(QtCore.QAbstractTableModel): columns_dict = {'copter_id': 'copter ID', 'git_version': 'version', + 'config_version': 'configuration', 'animation_id': ' animation ID ', 'battery': ' battery ', 'fcu_status': 'FCU status', @@ -325,7 +332,6 @@ class CopterDataModel(QtCore.QAbstractTableModel): 'start_position': ' start x y z ', 'last_task': 'last task', 'time_delta': 'dt', - 'config_version': 'configuration', } columns = list(columns_dict.keys()) @@ -385,7 +391,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): def selected_check(self, f, selected=()): selected = selected or set(self.user_selected()) - return bool(selected) and all(f(item) for item in selected) #selected.issubset(self.filter(f)) + return bool(selected) and all(f(item) for item in selected) # selected.issubset(self.filter(f)) def get_row_data(self, index): row = index.row() @@ -488,14 +494,14 @@ class CopterDataModel(QtCore.QAbstractTableModel): if index.column() == 0: roles |= Qt.ItemIsUserCheckable | Qt.ItemIsEditable if self.is_column(index, "config_version"): - roles |= Qt.ItemIsDragEnabled # | Qt.ItemIsDropEnabled + roles |= Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled return roles def supportedDropActions(self): - return QtCore.Qt.CopyAction + return Qt.CopyAction | Qt.MoveAction def mimeTypes(self): - return ['text/plain'] + return ['text/uri-list'] def mimeData(self, indexes): index = indexes[0] @@ -513,10 +519,36 @@ class CopterDataModel(QtCore.QAbstractTableModel): os.remove(path) self.data_contents[index.row()].client.get_file("config/client.ini", path, ) + mimedata.setData("application/copter_row_info", + bytes(self.data_contents[index.row()].copter_id, encoding="UTF-8")) mimedata.setUrls([QUrl.fromLocalFile(path)]) return mimedata + def dropMimeData(self, mimedata, action, row, column, index): + if action == Qt.IgnoreAction: + return True + + if self.is_column(index, "config_version"): + if not mimedata.hasUrls(): + return False + if str(mimedata.data("application/copter_row_info")) == self.data_contents[index.row()].copter_id: + return False # to protect from dropping to the same cell + + # print(mimedata.hasUrls(), mimedata.urls, mimedata.formats()) + return self.drop_config(mimedata.urls()[0].toLocalFile(), index.row()) + + return True + + def drop_config(self, path, row): + if not ConfigManager.config_exists(path): + return False + config = ConfigManager() + config.load_only_config(path) + self.data_contents[row].client.send_message("config", kwargs={ + "config": config.full_dict(include_defaults=False), "mode": "rewrite"}) + return False + # Thread-safe wrappers def add_client(self, **kwargs): default_states = {"checked": 0, "copter_id": True} @@ -567,9 +599,10 @@ def flip_checks(copter_item): return False return True + # for col in checklist: - # if not copter_item.state[col]: # ModelChecks.check(col, copter_item): - # return False +# if not copter_item.state[col]: # ModelChecks.check(col, copter_item): +# return False def calibrating_check(copter_item): return copter_item["calibration_status"] == "CALIBRATING" @@ -637,15 +670,15 @@ if __name__ == '__main__': msg = "[{}]: Failure: {}".format("FCU connection2", "Angular velocities estimation is not available") msgs.append(msg) - #myModel._add_client(StatedCopterData(copter_id=1000, checked=0, selfcheck=msgs, time_utc=1)) - #myModel._add_client(StatedCopterData(checked=2, selfcheck="OK", time_utc=2)) - #myModel._add_client(StatedCopterData(checked=2, selfcheck="not ok", time_utc="no")) + # myModel._add_client(StatedCopterData(copter_id=1000, checked=0, selfcheck=msgs, time_utc=1)) + # myModel._add_client(StatedCopterData(checked=2, selfcheck="OK", time_utc=2)) + # myModel._add_client(StatedCopterData(checked=2, selfcheck="not ok", time_utc="no")) myModel.add_client(copter_id=1000, client=None, git_version='11318ca', selfcheck=msgs) - #myModel.setData(myModel.index(0, 1), "test") + # myModel.setData(myModel.index(0, 1), "test") # t = threading.Thread(target=timer, daemon=True) - #t.start() + # t.start() print(QtCore.QT_VERSION_STR) print(get_git_version()) myModel.update_data(0, 3, [1, 2], role=Qt.EditRole) - app.exec_() \ No newline at end of file + app.exec_() diff --git a/Server/server_qt.py b/Server/server_qt.py index 1e47482..f091aff 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -508,7 +508,7 @@ class MainWindow(QtWidgets.QMainWindow): config = cfg.ConfigManager() config.load_only_config(path) - data = config.full_dict() + data = config.full_dict(include_defaults=False) logging.info(f"Loaded config from {path}") copters = self.model.user_selected() diff --git a/config.py b/config.py index 047ed39..1a94016 100644 --- a/config.py +++ b/config.py @@ -155,7 +155,7 @@ class ConfigManager: self.__dict__[key] = value @staticmethod - def _config_exists(path): + def config_exists(path): return os.path.isfile(path) and os.path.splitext(path)[1] == '.ini' @staticmethod @@ -169,14 +169,14 @@ class ConfigManager: filename.replace('configspec_', '')) def load_from_file(self, path): - if not self._config_exists(path): + if not self.config_exists(path): raise ValueError('Config file do not exist!') f_path, filename = os.path.split(path) if filename.startswith('configspec_'): config_path = self._get_config_path(path) - if self._config_exists(config_path): + if self.config_exists(config_path): return self.load_config_and_spec(config_path) generate_file = parent_dir(f_path) == 'spec' @@ -187,7 +187,7 @@ class ConfigManager: else: spec_path = self._get_spec_path(path) - if self._config_exists(spec_path): + if self.config_exists(spec_path): return self.load_config_and_spec(path) return self.load_only_config(path) @@ -212,7 +212,7 @@ class ConfigManager: @classmethod def generate_default_config(cls, cfg_path): - if cls._config_exists(cfg_path): + if cls.config_exists(cfg_path): return False vdt = Validator() @@ -269,10 +269,10 @@ class ConfigManager: kwargs.update({'configspec': configspec}) elif isinstance(configspec, str): spec_path = self._get_spec_path(configspec) - if self._config_exists(spec_path): # when 'configspec' points to configuration file and configspec exists + if self.config_exists(spec_path): # when 'configspec' points to configuration file and configspec exists kwargs.update({'configspec': spec_path}) filename = configspec - elif self._config_exists(configspec): # when 'configspec' points to configspec file + elif self.config_exists(configspec): # when 'configspec' points to configspec file kwargs.update({'configspec': configspec}) if parent_dir(configspec) == 'spec': filename = self._get_config_path(configspec) From a3e3a2b97e7f8b33a6cd12469f78f1d0b18fbdc5 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 4 Feb 2020 20:48:25 +0300 Subject: [PATCH 131/210] Config sending optimization (up to x2 less data transmitted) --- config.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/config.py b/config.py index 1a94016..de1ac2f 100644 --- a/config.py +++ b/config.py @@ -108,9 +108,15 @@ class ConfigManager: if not isinstance(result, dict): item_d = {'__option__': True, 'value': value, - 'comments': comments.get(key, []), - 'inline_comment': inline_comments.get(key, None), } + comment = comments.get(key, []), + if comment and comment != ['']: + item.update({'comments': comment}) + + inline_comment = inline_comments.get(key, None) + if inline_comment: + item.update({'inline_comment': inline_comments}) + if include_defaults: item_d.update({'default': default_values.get(key, None), 'unchanged': key in defaults, From 0bb3fd1b72f7cb15b87baf6cb9142e8b55329429 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 4 Feb 2020 22:01:47 +0300 Subject: [PATCH 132/210] Further optimiztion and fix of config dict sending --- config.py | 40 +++++++++++++++++++--------------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/config.py b/config.py index de1ac2f..6b1acf6 100644 --- a/config.py +++ b/config.py @@ -106,23 +106,21 @@ class ConfigManager: for key, value in item.items(): result = cls._full_dict(value, include_defaults) if not isinstance(result, dict): - item_d = {'__option__': True, - 'value': value, - } - comment = comments.get(key, []), + item_d = {'__value__': value} + + comment = comments.get(key, []) if comment and comment != ['']: - item.update({'comments': comment}) + item_d.update({'comments': comment}) inline_comment = inline_comments.get(key, None) if inline_comment: - item.update({'inline_comment': inline_comments}) + item_d.update({'inline_comment': inline_comments}) if include_defaults: item_d.update({'default': default_values.get(key, None), 'unchanged': key in defaults, }) data[key] = item_d - else: data[key] = result @@ -237,9 +235,9 @@ class ConfigManager: for key, val in d.items(): if not isinstance(val, dict): # Pure dict option result[key] = val - elif val.get('__option__', False): # Full-dict option with params + elif '__value__' in val: # Full-dict option with params if not val.get('unchanged', False): - result[key] = val.get('value') + result[key] = val.get('__value__') else: # Section result[key] = cls._extract_values(val) return result @@ -253,7 +251,7 @@ class ConfigManager: if not isinstance(val, dict): # Pure dict option comments[key] = [] inline_comments[key] = None - elif val.get('__option__', False): # Full-dict option with params + elif '__value__' in val: # Full-dict option with params comment = val.get('comments', []) comments[key] = [] if comment == [''] else comment inline_comments[key] = val.get('inline_comment', None) @@ -308,9 +306,9 @@ class ConfigManager: if __name__ == '__main__': cfg = ConfigManager() - #cfg.load_from_file('Drone/config/client.ini') - cfg.load_from_file('Drone/config/spec/configspec_client.ini') - + cfg.load_from_file('Server/config/server.ini') + #cfg.load_from_file('Drone/config/spec/configspec_client.ini') + print(dict(cfg.full_dict(include_defaults=True))) # cfg.load_config_and_spec('Drone/config/client.ini') # #print(cfg.config.comments) @@ -330,15 +328,15 @@ if __name__ == '__main__': # # print(11111) import pprint #pprint.pprint(cfg.full_dict) - cfg2 = ConfigManager() - #cfg2.load_from_dict({"PRIVATE": {"offset": [1, 2, 3]}}, configspec='Drone/config/spec/configspec_client.ini') - cfg2.load_from_dict({"PRIVATE": {"id": "heh"}}) - #pprint.pprint(cfg2.full_dict) - #cfg.merge(cfg2) - #pprint.pprint(cfg.full_dict) - print(cfg2.full_dict(include_defaults=True)) + # cfg2 = ConfigManager() + # #cfg2.load_from_dict({"PRIVATE": {"offset": [1, 2, 3]}}, configspec='Drone/config/spec/configspec_client.ini') + # cfg2.load_from_dict({"PRIVATE": {"id": "heh"}}) + # #pprint.pprint(cfg2.full_dict) + # #cfg.merge(cfg2) + # #pprint.pprint(cfg.full_dict) + # print(cfg2.full_dict(include_defaults=True)) #print(dict(cfg2.config.configspec)) - print(cfg2.config.PRIVATE) + #print(cfg2.config.PRIVATE) #print(dict(ConfigManager(cfg.config.configspec).config)) # #print(cfg.full_dict) From a2b087fcd94ace25ca8decc19f2f6bb0647d2464 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 5 Feb 2020 14:37:36 +0300 Subject: [PATCH 133/210] Added copy config menu option --- Drone/client.py | 6 ++++-- Server/copter_table.py | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/Drone/client.py b/Drone/client.py index 73a6188..c7fe3e6 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -216,8 +216,10 @@ def _command_config_write(*args, **kwargs): @messaging.request_callback("config") def _response_config(*args, **kwargs): - response = {"config": active_client.config.full_dict(), - "configspec": dict(active_client.config.config.configspec)} + send_configspec = kwargs.get("send_configspec", False) + response = {"config": active_client.config.full_dict()} + if send_configspec: + response.update({"configspec": dict(active_client.config.config.configspec)}) return response @messaging.request_callback("id") diff --git a/Server/copter_table.py b/Server/copter_table.py index 06b6c7b..a6182e4 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -145,6 +145,10 @@ class CopterTableWidget(QTableView): edit_config.triggered.connect(partial(self.edit_copter_config, item)) menu.addAction(edit_config) + copy_config = QAction("Copy config to selected") + copy_config.triggered.connect(partial(self.copy_config, item)) + menu.addAction(copy_config) + if item is None: edit_config.setDisabled(True) @@ -153,7 +157,20 @@ class CopterTableWidget(QTableView): @pyqtSlot() def edit_copter_config(self, copter): dialog = ConfigDialog() - copter.client.get_response("config", dialog.call_copter_dialog) + copter.client.get_response("config", dialog.call_copter_dialog, request_kwargs={'send_configspec': True}) + + @pyqtSlot() + def copy_config(self, copter): + def send_callback(client, value): + config = value["config"] + config.pop("PRIVATE", None) # delete private section + + for _copter in self.model.user_selected(): + if _copter.client is client: + continue # don't send config back to the same copter + _copter.client.send_message("config", kwargs={"config": config, "mode": "modify"}) + + copter.client.get_response("config", send_callback, request_kwargs={'send_configspec': False}) # def _selfcheck_shortener(self, data): # TODO!!! # shortened = [] From 56c016452ebfcc5c9ff2d8ba7b4c2db1cc7824e6 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 5 Feb 2020 21:08:48 +0300 Subject: [PATCH 134/210] Unsaved preset indication --- Server/copter_table.py | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index a6182e4..35283ac 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -183,6 +183,8 @@ class CopterTableWidget(QTableView): class HeaderListWidget(QListWidget): ColumnKeyRole = 998 + dropped = QtCore.pyqtSignal(bool) + def __init__(self, parent=None, default_items=None): super().__init__(parent) if default_items is not None: @@ -207,6 +209,10 @@ class HeaderListWidget(QListWidget): return {self.item(i).data(HeaderListWidget.ColumnKeyRole): bool(self.item(i).checkState()) for i in range(self.count())} + def dropEvent(self, event: QtGui.QDropEvent): + super().dropEvent(event) + self.dropped.emit(True) + class ActiveHeaderListWidget(HeaderListWidget): def __init__(self, source: CopterTableWidget, parent=None): @@ -241,6 +247,8 @@ class HeaderEditWidget(QtWidgets.QWidget): add_new_text = "< add new >" default = "DEFAULT" + saved_signal = QtCore.pyqtSignal(bool) + def __init__(self, source, config, menu_mode=False, *args, **kwargs): super().__init__(*args, **kwargs) # self.auto_apply = auto_apply @@ -251,10 +259,12 @@ class HeaderEditWidget(QtWidgets.QWidget): self.preset_widget = QtWidgets.QComboBox() self.header_widget = ActiveHeaderListWidget(self.source) \ if self.menu_mode else HeaderListWidget() + #self.header_widget.itemChanged.connect(partial(self.saved_signal.emit, False)) + self.header_widget.model().dataChanged.connect(partial(self.saved_signal.emit, False)) + self.header_widget.dropped.connect(partial(self.saved_signal.emit, False)) self.previous = self.config.table_presets_current self.save = True - # self._dialog = None self.setupUi() @@ -328,6 +338,7 @@ class HeaderEditWidget(QtWidgets.QWidget): self.source.set_column_order(list(items.keys())) self.config.table_presets_current = index self.header_widget.populate_items(items) + self.saved_signal.emit(True) def add_preset(self): name, ok = QInputDialog.getText(None, "Enter new preset name", "Name:", @@ -365,6 +376,7 @@ class HeaderEditWidget(QtWidgets.QWidget): self.previous = self.default self.update_preset_list() + @pyqtSlot() def save_preset(self): if not self.save: # don't save after calling dialog to avoid overrides return @@ -372,7 +384,9 @@ class HeaderEditWidget(QtWidgets.QWidget): current = self.preset_widget.currentText() header_dict = self.header_widget.item_dict save_preset(self.config, current, header_dict) + self.saved_signal.emit(True) + @pyqtSlot() def apply_preset(self): self.config.table_presets_current = self.preset_widget.currentText() self.save_preset() @@ -382,12 +396,25 @@ class HeaderEditWidget(QtWidgets.QWidget): class HeaderEditDialog(QtWidgets.QDialog): def __init__(self, source, config, parent=None): super(HeaderEditDialog, self).__init__(parent=None) - self.ui = HeaderEditWidget(source, config, menu_mode=False) - self.setWindowTitle("Column preset editor") + self.widget = HeaderEditWidget(source, config, menu_mode=False) + self.setupUI() + self.unsaved = False + + self.widget.saved_signal.connect(self.update_title) + self.update_title(True) + + def setupUI(self): layout = QVBoxLayout() - layout.addWidget(self.ui) + layout.addWidget(self.widget) self.setLayout(layout) + @pyqtSlot(bool) + def update_title(self, saved): + unsaved = not saved + self.unsaved = unsaved + self.setWindowTitle(f"Column preset editor - {self.widget.preset_widget.currentText()}" + + "*"*unsaved) + if __name__ == '__main__': import sys From 01bf95e8630190ba8b9976e8b4d5254bd7989c26 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 5 Feb 2020 21:36:42 +0300 Subject: [PATCH 135/210] Preset dialog close confirmation --- Server/copter_table.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Server/copter_table.py b/Server/copter_table.py index 35283ac..3192f3a 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -415,6 +415,20 @@ class HeaderEditDialog(QtWidgets.QDialog): self.setWindowTitle(f"Column preset editor - {self.widget.preset_widget.currentText()}" + "*"*unsaved) + def closeEvent(self, event): + if not self.unsaved: + event.accept() + return + + reply = QMessageBox.question(self, "Confirm exit", "There are unsaved changes in current preset. " + "Are you sure you want to exit?", + QMessageBox.No | QMessageBox.Yes, QMessageBox.No) + + if reply != QMessageBox.Yes: + event.ignore() + else: + event.accept() + if __name__ == '__main__': import sys From ec419849e880986a0aa9b5ed4da2e1a04b528002 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 10 Feb 2020 15:50:20 +0300 Subject: [PATCH 136/210] Fix telemetry and improts --- Server/copter_table_models.py | 5 +++-- Server/server_qt.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 75834f1..4b4e147 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -1,8 +1,9 @@ -import math import os import re -import subprocess import sys +import math +import time +import subprocess from contextlib import suppress from functools import partialmethod diff --git a/Server/server_qt.py b/Server/server_qt.py index f091aff..0b980f0 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -271,7 +271,7 @@ class MainWindow(QtWidgets.QMainWindow): def update_table_data(self, client, telems: dict): for key, value in telems.items(): try: - col = self.model.index(key) + col = self.model.columns.index(key) except ValueError: logging.error(f"No column {key} present!") else: From 0fcdc1efba49fbd1e05aecc21b7579705f8ac23f Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 10 Feb 2020 16:13:22 +0300 Subject: [PATCH 137/210] Telem fix --- Server/copter_table_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 4b4e147..8e90029 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -131,8 +131,8 @@ def check_start_pos(item, context): if context.current_position is None: return item != 'NO_POS' # maybe should return true - delta = get_distance(get_position(context.current_position), - get_position(context.start_position)) + delta = get_distance(get_position(context.current_position[:2]), + get_position(context.start_position[:2])) if math.isnan(delta): return False From 42aee960cd6539e429347e18e6f8228933a80a25 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 10 Feb 2020 13:41:03 +0000 Subject: [PATCH 138/210] Drone: Fix typo in code --- Drone/copter_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Drone/copter_client.py b/Drone/copter_client.py index a8c506b..9939e48 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -770,7 +770,7 @@ class Telemetry: def update_telemetry_slow(self): self.animation_id = animation.get_id() self.git_version = self.get_git_version() - self.config_version = self.get_config_varsion() + self.config_version = self.get_config_version() try: self.cal_status = get_calibration_status() self.fcu_status = get_sys_status() From acd5945e959d78503899661ca7db9da988f9aba6 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 10 Feb 2020 17:03:52 +0300 Subject: [PATCH 139/210] Fix telem (2) --- Server/copter_table_models.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 8e90029..36ee4b6 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -131,8 +131,8 @@ def check_start_pos(item, context): if context.current_position is None: return item != 'NO_POS' # maybe should return true - delta = get_distance(get_position(context.current_position[:2]), - get_position(context.start_position[:2])) + delta = get_distance(get_position(context.current_position), + get_position(context.start_position)) if math.isnan(delta): return False @@ -141,7 +141,7 @@ def check_start_pos(item, context): def get_position(position): if position != 'NO_POS' and position[0] != 'nan': # float('nan')? - return position + return position[:2] return [float('nan')] * 3 From 42b6eb7c8e13473178c2c5eefbf08507c64259f8 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 10 Feb 2020 17:06:51 +0300 Subject: [PATCH 140/210] Server: Fix getting server git version --- Server/copter_table_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 36ee4b6..ea56d57 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -18,7 +18,7 @@ ModelStateRole = 999 def get_git_version(): # TODO import from animation - return subprocess.check_output("git log --pretty=format:%h -n 1").decode('UTF-8') + return subprocess.check_output("git log --pretty=format:'%h' -n 1", shell=True).decode('UTF-8') class CheckState: From 0086b71279738b49b4448139a1997af799689e1c Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 10 Feb 2020 17:09:14 +0300 Subject: [PATCH 141/210] Fix cal_status --- Drone/copter_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 9939e48..1aa9797 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -772,7 +772,7 @@ class Telemetry: self.git_version = self.get_git_version() self.config_version = self.get_config_version() try: - self.cal_status = get_calibration_status() + self.calibration_status = get_calibration_status() self.fcu_status = get_sys_status() self.battery = self.get_battery(self.ros_telemetry) except rospy.ServiceException: @@ -795,7 +795,7 @@ class Telemetry: def reset_telemetry_values(self): self.battery = float('nan'), float('nan') - self.cal_status = 'NO_FCU' + self.calibration_status = 'NO_FCU' self.fcu_status = 'NO_FCU' self.mode = 'NO_FCU' self.selfcheck = ['NO_FCU'] From 57df7958ee1419e86dba7e05ec19c71c46417fd1 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 10 Feb 2020 17:14:20 +0300 Subject: [PATCH 142/210] New config dict format support for editor --- Server/config_editor_models.py | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 45699af..137fd4b 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -462,14 +462,14 @@ class ConfigModel(QtCore.QAbstractItemModel): self.final_comment = '\n'.join(data.pop('final_comment', [''])) for key, item in data.items(): - if item.get('__option__', False): - value = item['value'] + if '__value__' in item: + value = item.get('__value__') if convert_types: value = convert_type(value) default = item['default'] - comments = '\n'.join(item['comments']) or '' - inline_comment = item['inline_comment'] or '' + comments = '\n'.join(item.get('comments', '')) or '' + inline_comment = item.get('inline_comment', '') or '' if item['unchanged']: state = 'unchanged' @@ -522,13 +522,15 @@ class ConfigModel(QtCore.QAbstractItemModel): else: value = item.data(1) - d = {'__option__': True, - 'value': value, - # 'default': item.default, - # 'unchanged': False, - 'comments': (item.data(2) or '').split('\n'), - 'inline_comment': item.data(3) or '' - } + d = {'__value__': value} + + comment = item.data(2) + if comment: + d.update({'comments': comment.split('\n')}) + + inline_comment = item.data(3) + if inline_comment: + d.update({'inline_comment': inline_comment}) data[key] = d From 1e2aa311ec3d0a0f809f4b97dcde7b3523f14077 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 10 Feb 2020 17:33:59 +0300 Subject: [PATCH 143/210] FIx pos (x3) --- Server/copter_table_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index ea56d57..4f74a81 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -141,7 +141,7 @@ def check_start_pos(item, context): def get_position(position): if position != 'NO_POS' and position[0] != 'nan': # float('nan')? - return position[:2] + return position[:3] return [float('nan')] * 3 @@ -295,7 +295,7 @@ def view_selfcheck(value): @ModelFormatter.view_formatter("start_position") -def view_selfcheck(value): +def view_start_position(value): if isinstance(value, list): x, y, z = value return f"{x: .2f} {y: .2f} {z: .2f}" From 475914ffa6ae5c54fb765c96cd62fc6e10b890c9 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Wed, 12 Feb 2020 17:18:19 +0300 Subject: [PATCH 144/210] Server: Modify restart function to work in different environments --- Server/server_qt.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 0b980f0..3d90c13 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -31,6 +31,7 @@ from copter_table import CopterTableWidget, HeaderEditDialog from visual_land_dialog import VisualLandDialog from config_editor_models import ConfigDialog +startup_cwd = os.getcwd() def multi_glob(*patterns): return itertools.chain.from_iterable(glob.iglob(pattern) for pattern in patterns) @@ -40,10 +41,14 @@ def b_partial(func, *args, **kwargs): # call argument blocker partial return lambda *a: func(*args, **kwargs) -def restart(): # move to core - # ANY prints will break restarting or opening new windows after restart - os.execl(sys.executable, os.path.abspath(__file__), *sys.argv) - +def restart(): # move to core + args = sys.argv[:] + logging.info('Restarting {}'.format(args)) + args.insert(0, sys.executable) + if sys.platform == 'win32': + args = ['"%s"' % arg for arg in args] + os.chdir(startup_cwd) + os.execv(sys.executable, args) def update_server(): subprocess.call("git pull --rebase") From 6fef1b0d59550bd8c0f7ab37904797c2277e2560 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 12 Feb 2020 17:43:44 +0300 Subject: [PATCH 145/210] Added mock telemetry sending to basic client for testing --- Drone/client.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Drone/client.py b/Drone/client.py index c7fe3e6..ff293c2 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -168,7 +168,6 @@ class Client(object): def _process_connections(self): while True: - #self.server_connection.send_message("telemetry", kwargs={"value":{"time": time.time()}}) events = self.selector.select(timeout=1) for key, mask in events: connection = key.data @@ -239,6 +238,18 @@ def _response_time(*args, **kwargs): if __name__ == "__main__": + import threading + def mock_telem(): + while True: + time.sleep(5) + #t = dict([('fcu_status', None), ('current_position', [-2.89, 2.12, 3.64, 15.22, 'aruco_map']), ('animation_id', 'two_drones_test'), ('selfcheck', 'OK'), ('battery', None), ('git_version', '01bf95e'), ('calibration_status', None), ('start_position', [0.2, 0.2, 0.0]), ('mode', 'MANUAL'), ('time_delta', 1581338473.438682), ('armed', False), ('config_version', None), ('last_task', 'No task')]) + t = dict([('fcu_status', 'STANDBY'), ('current_position', [-1.17, 2.04, 3.45, 0, "11"]), ('animation_id', 'two_drones_test'), ('selfcheck', 'OK'), ('battery', [12.2, 1.0]), ('git_version', '42aee96'), ('calibration_status', None), ('start_position', [0.2, 0.2, 0.0]), ('mode', 'MANUAL'), ('time_delta', 1581342970.889573), ('armed', False), ('config_version', 'Copter config V0.0'), ('last_task', 'No task')]) + if active_client.connected: + active_client.server_connection.send_message("telemetry", kwargs={"value": t}) + logging.basicConfig(level=logging.DEBUG) client = Client() + tr = threading.Thread(target=mock_telem) + tr.start() client.start() + From 82d9821b9abf4ef1b893cec85a37a7e0c2cbff77 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 12 Feb 2020 17:56:33 +0300 Subject: [PATCH 146/210] Added hover cursors to cells --- Server/copter_table.py | 70 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 2 deletions(-) diff --git a/Server/copter_table.py b/Server/copter_table.py index 3192f3a..204f552 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -2,11 +2,11 @@ from functools import partial from copy import deepcopy from PyQt5 import QtWidgets, QtCore, QtGui -from PyQt5.QtCore import Qt as Qt +from PyQt5.QtCore import Qt as Qt, QObject, QEvent, QModelIndex from PyQt5.QtCore import pyqtSlot from PyQt5.QtGui import QCursor from PyQt5.QtWidgets import QTableView, QMessageBox, QMenu, QAction, QWidgetAction, QListWidget, \ - QAbstractItemView, QListWidgetItem, QVBoxLayout, QHBoxLayout, QPushButton, QInputDialog, QLineEdit + QAbstractItemView, QListWidgetItem, QVBoxLayout, QHBoxLayout, QPushButton, QInputDialog, QLineEdit, QApplication from config_editor_models import ConfigDialog import copter_table_models as table @@ -23,7 +23,31 @@ def save_preset(config, current, header_dict): # config.write() +class HeaderViewFilter(QObject): + def __init__(self, parent, header, *args): + super().__init__(parent, *args) + self.header = header + self._parent = parent + + def eventFilter(self, object, event): + if event.type() == QEvent.Enter: + # logicalIndex = self.header.logicalIndexAt(event.pos()) + self.parent().cellHover.emit(QModelIndex()) + + return True + + class CopterTableWidget(QTableView): + override_cursors = { + "copter_id": Qt.IBeamCursor, + "config_version": Qt.OpenHandCursor, + "selfcheck": Qt.PointingHandCursor, + } + + cellHover = QtCore.pyqtSignal(QModelIndex) + cellEntered = QtCore.pyqtSignal(int, int) + cellExited = QtCore.pyqtSignal(int, int) + def __init__(self, model: table.CopterDataModel, config): QTableView.__init__(self) @@ -40,7 +64,16 @@ class CopterTableWidget(QTableView): self.columns = self.model.columns # [header.strip() for header in self.model.headers] # header keys self.current_columns = self.columns[:] + self._last_hover_index = QtCore.QModelIndex() + self._previous_cursor = None + + self.cellHover.connect(self.cell_hover) + self.cellExited.connect(self.cell_exited) + self.cellEntered.connect(self.cell_entered) + header = self.horizontalHeader() + self.filter = HeaderViewFilter(self, header) + header.installEventFilter(self.filter) header.setCascadingSectionResizes(False) header.setStretchLastSection(True) header.setSectionsMovable(True) @@ -61,6 +94,39 @@ class CopterTableWidget(QTableView): self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) self.doubleClicked.connect(self.on_double_click) self.setDragDropMode(QAbstractItemView.DragDrop) + self.setMouseTracking(True) + + def mouseMoveEvent(self, event): + self.cell_hover(self.indexAt(event.pos())) + super().mouseMoveEvent(event) + + def leaveEvent(self, event): + self.cell_hover(QtCore.QModelIndex()) + + def dragEnterEvent(self, *args, **kwargs): + self.cell_hover(QtCore.QModelIndex()) + super().dragEnterEvent(*args, **kwargs) + + def cell_hover(self, index): + if index != self._last_hover_index: + self.cellExited.emit(self._last_hover_index.row(), self._last_hover_index.column()) + self.cellEntered.emit(index.row(), index.column()) + + self._last_hover_index = QtCore.QPersistentModelIndex(index) + + @pyqtSlot(int, int) + def cell_entered(self, row, column): + if column != -1 and self.columns[column] in self.override_cursors: + self._previous_cursor = QApplication.overrideCursor() + if self._previous_cursor is None: + QApplication.setOverrideCursor(self.override_cursors[self.columns[column]]) + + @pyqtSlot(int, int) + def cell_exited(self, row, column): + # if self._previous_cursor is not None: + # QApplication.setOverrideCursor(self._previous_cursor) + if self._previous_cursor is None: + QApplication.restoreOverrideCursor() def moved(self, logical_index, old_index, new_index): name = self.current_columns.pop(old_index) From c29fb57391c1cd478d84532c0cdae27af64d30d2 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 12 Feb 2020 18:27:27 +0300 Subject: [PATCH 147/210] Disable copy config option when empty click --- Server/copter_table.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Server/copter_table.py b/Server/copter_table.py index 204f552..af3f4b2 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -217,6 +217,7 @@ class CopterTableWidget(QTableView): if item is None: edit_config.setDisabled(True) + copy_config.setDisabled(True) menu.exec_(QCursor.pos()) From 4ed2ba7af9488f5da5f0176a3d79bf35ecbd1db0 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 12 Feb 2020 18:30:34 +0300 Subject: [PATCH 148/210] Server: Deselect on empty click in table --- Server/copter_table.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Server/copter_table.py b/Server/copter_table.py index af3f4b2..aef2ccc 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -96,6 +96,12 @@ class CopterTableWidget(QTableView): self.setDragDropMode(QAbstractItemView.DragDrop) self.setMouseTracking(True) + def mousePressEvent(self, event): + super().mousePressEvent(event) + index = self.indexAt(event.pos()) + if index.column() == -1 and index.row() == -1: + self.clearSelection() + def mouseMoveEvent(self, event): self.cell_hover(self.indexAt(event.pos())) super().mouseMoveEvent(event) From 294cd4dfd8e5893a25fef37e7c237e7f53fb2001 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 12 Feb 2020 18:44:35 +0300 Subject: [PATCH 149/210] Fixes for standalone and server config dialog --- Server/config_editor_models.py | 9 +++++++-- Server/server_qt.py | 1 - 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 137fd4b..146126c 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -229,10 +229,10 @@ def ensure_unique_names(item, include_self=True): class ConfigModel(QtCore.QAbstractItemModel): def __init__(self, parent=None, widget=None, headers=("Option", "Value", 'Comment', 'Inline Comment')): + self.rootItem = ConfigModelItem(headers) super(ConfigModel, self).__init__(parent) self.widget = widget - self.rootItem = ConfigModelItem(headers) self.do_color = True self.initial_comment = '' @@ -883,7 +883,12 @@ class ConfigDialog(QtWidgets.QDialog): on_restart() return True - def call_standalone_dialog(self): + @classmethod + def call_standalone_dialog(cls): + dialog = cls() + dialog._call_standalone_dialog() + + def _call_standalone_dialog(self): path = QFileDialog.getOpenFileName(self, "Select configuration or specification file", filter="Config and spec files (*.ini)")[0] if not path: diff --git a/Server/server_qt.py b/Server/server_qt.py index 0b980f0..ae94d15 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -139,7 +139,6 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_edit_any_config.triggered.connect(ConfigDialog.call_standalone_dialog) self.ui.action_edit_server_config.triggered.connect(self.edit_server_config) - self.ui.action_restart_server.triggered.connect(restart) self.ui.action_update_server_git.triggered.connect(update_server) From f0ef4ea167455cab5149e141d055ea9078e055b2 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Wed, 12 Feb 2020 18:53:16 +0300 Subject: [PATCH 150/210] Server: Modify update git command for the future usage --- Server/server_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 45eb0bb..0507215 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -51,7 +51,7 @@ def restart(): # move to core os.execv(sys.executable, args) def update_server(): - subprocess.call("git pull --rebase") + subprocess.call("git fetch && git pull --rebase", shell=True) restart() def confirmation_required(text="Are you sure?", label="Confirm operation?"): From 20c2fc22c8c1be8ba52f4596e0143e20301056e4 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Wed, 12 Feb 2020 19:18:01 +0300 Subject: [PATCH 151/210] Server: Update GUI --- Server/server_gui.py | 78 +++++++++++++++++++++----------------------- Server/server_gui.ui | 70 +++++++++++++++++++++++---------------- 2 files changed, 78 insertions(+), 70 deletions(-) diff --git a/Server/server_gui.py b/Server/server_gui.py index 5bc9ec3..448c60a 100644 --- a/Server/server_gui.py +++ b/Server/server_gui.py @@ -2,7 +2,7 @@ # Form implementation generated from reading ui file 'server_gui.ui' # -# Created by: PyQt5 UI code generator 5.14.0 +# Created by: PyQt5 UI code generator 5.13.0 # # WARNING! All changes made in this file will be lost! @@ -212,7 +212,7 @@ class Ui_MainWindow(object): self.gridLayout.addLayout(self.horizontalLayout, 0, 0, 1, 1) MainWindow.setCentralWidget(self.centralwidget) self.menubar = QtWidgets.QMenuBar(MainWindow) - self.menubar.setGeometry(QtCore.QRect(0, 0, 1360, 26)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1360, 25)) self.menubar.setObjectName("menubar") self.menuOptions = QtWidgets.QMenu(self.menubar) self.menuOptions.setObjectName("menuOptions") @@ -226,8 +226,8 @@ class Ui_MainWindow(object): self.menuSend.setObjectName("menuSend") self.menuRestart = QtWidgets.QMenu(self.menuDrone_2) self.menuRestart.setObjectName("menuRestart") - self.menuRetrive = QtWidgets.QMenu(self.menuDrone_2) - self.menuRetrive.setObjectName("menuRetrive") + self.menuDeveloper_mode = QtWidgets.QMenu(self.menuDrone_2) + self.menuDeveloper_mode.setObjectName("menuDeveloper_mode") MainWindow.setMenuBar(self.menubar) self.action_send_animations = QtWidgets.QAction(MainWindow) self.action_send_animations.setObjectName("action_send_animations") @@ -290,6 +290,8 @@ class Ui_MainWindow(object): self.action_edit_any_config = QtWidgets.QAction(MainWindow) self.action_edit_any_config.setObjectName("action_edit_any_config") self.action_update_server_git = QtWidgets.QAction(MainWindow) + self.action_update_server_git.setEnabled(False) + self.action_update_server_git.setVisible(False) self.action_update_server_git.setObjectName("action_update_server_git") self.action_retrive_any_file = QtWidgets.QAction(MainWindow) self.action_retrive_any_file.setObjectName("action_retrive_any_file") @@ -297,6 +299,8 @@ class Ui_MainWindow(object): self.action_restart_server.setObjectName("action_restart_server") self.action_configure_columns = QtWidgets.QAction(MainWindow) self.action_configure_columns.setObjectName("action_configure_columns") + self.actionSomething = QtWidgets.QAction(MainWindow) + self.actionSomething.setObjectName("actionSomething") self.menuMusic_2.addAction(self.action_select_music_file) self.menuMusic_2.addAction(self.action_play_music) self.menuMusic_2.addAction(self.action_stop_music) @@ -327,21 +331,22 @@ class Ui_MainWindow(object): self.menuRestart.addAction(self.action_restart_clever) self.menuRestart.addAction(self.action_restart_clever_show) self.menuRestart.addSeparator() - self.menuRestart.addAction(self.action_reboot_all) - self.menuRetrive.addAction(self.action_retrive_any_file) + self.menuDeveloper_mode.addAction(self.action_update_client_repo) self.menuDrone_2.addAction(self.menuSend.menuAction()) - self.menuDrone_2.addAction(self.menuRetrive.menuAction()) + self.menuDrone_2.addAction(self.action_retrive_any_file) self.menuDrone_2.addAction(self.menuRestart.menuAction()) self.menuDrone_2.addSeparator() - self.menuDrone_2.addAction(self.action_reset_z_offset) - self.menuDrone_2.addAction(self.action_set_z_offset_to_ground) self.menuDrone_2.addAction(self.action_set_start_to_current_position) self.menuDrone_2.addAction(self.action_reset_start) + self.menuDrone_2.addAction(self.action_set_z_offset_to_ground) + self.menuDrone_2.addAction(self.action_reset_z_offset) self.menuDrone_2.addSeparator() - self.menuDrone_2.addAction(self.action_update_client_repo) + self.menuDrone_2.addAction(self.menuDeveloper_mode.menuAction()) + self.menuDrone_2.addSeparator() + self.menuDrone_2.addAction(self.action_reboot_all) + self.menubar.addAction(self.menuDrone_2.menuAction()) self.menubar.addAction(self.menuOptions.menuAction()) self.menubar.addAction(self.menuTable.menuAction()) - self.menubar.addAction(self.menuDrone_2.menuAction()) self.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) @@ -375,37 +380,37 @@ class Ui_MainWindow(object): self.menuOptions.setTitle(_translate("MainWindow", "Server")) self.menuMusic_2.setTitle(_translate("MainWindow", "Music")) self.menuTable.setTitle(_translate("MainWindow", "Table")) - self.menuDrone_2.setTitle(_translate("MainWindow", "Drone")) + self.menuDrone_2.setTitle(_translate("MainWindow", "Selected drones")) self.menuSend.setTitle(_translate("MainWindow", "Send")) - self.menuRestart.setTitle(_translate("MainWindow", "Restart")) - self.menuRetrive.setTitle(_translate("MainWindow", "Retrive")) - self.action_send_animations.setText(_translate("MainWindow", "Send animations")) - self.action_send_configurations.setText(_translate("MainWindow", "Send configurations")) - self.action_send_aruco_map.setText(_translate("MainWindow", "Send aruco map")) + self.menuRestart.setTitle(_translate("MainWindow", "Restart service")) + self.menuDeveloper_mode.setTitle(_translate("MainWindow", "Developer mode")) + self.action_send_animations.setText(_translate("MainWindow", "Animations")) + self.action_send_configurations.setText(_translate("MainWindow", "Configuration")) + self.action_send_aruco_map.setText(_translate("MainWindow", "Aruco map")) self.action_update_client_repo.setText(_translate("MainWindow", "Update clever-show git")) self.actionSend_launch_file_for_clever.setText(_translate("MainWindow", "Send launch file for clever")) - self.action_send_launch_file.setText(_translate("MainWindow", "Send launch files")) - self.action_restart_clever.setText(_translate("MainWindow", "Restart clever service")) - self.action_restart_clever_show.setText(_translate("MainWindow", "Restart clever-show service")) + self.action_send_launch_file.setText(_translate("MainWindow", "Launch files")) + self.action_restart_clever.setText(_translate("MainWindow", "clever")) + self.action_restart_clever_show.setText(_translate("MainWindow", "clever-show")) self.action_select_all_rows.setText(_translate("MainWindow", "Select all drones")) self.action_select_all_rows.setShortcut(_translate("MainWindow", "Ctrl+A")) self.action_set_start_to_current_position.setText(_translate("MainWindow", "Set start X Y to current position")) self.action_reset_start.setText(_translate("MainWindow", "Reset start position")) self.action_set_z_offset_to_ground.setText(_translate("MainWindow", "Set Z offset to ground")) self.action_reset_z_offset.setText(_translate("MainWindow", "Reset Z offset")) - self.action_select_music_file.setText(_translate("MainWindow", "Select music file")) - self.action_play_music.setText(_translate("MainWindow", "Play music")) + self.action_select_music_file.setText(_translate("MainWindow", "Select file")) + self.action_play_music.setText(_translate("MainWindow", "Play")) self.action_test_music_after.setText(_translate("MainWindow", "Test music after")) self.actionFill.setText(_translate("MainWindow", "fill")) - self.action_send_any_file.setText(_translate("MainWindow", "Send any file")) - self.action_send_any_command.setText(_translate("MainWindow", "Send any command")) - self.action_stop_music.setText(_translate("MainWindow", "Stop music")) - self.action_remove_row.setText(_translate("MainWindow", "Remove drone")) + self.action_send_any_file.setText(_translate("MainWindow", "File")) + self.action_send_any_command.setText(_translate("MainWindow", "Command")) + self.action_stop_music.setText(_translate("MainWindow", "Stop")) + self.action_remove_row.setText(_translate("MainWindow", "Remove selected drones")) self.action_remove_row.setShortcut(_translate("MainWindow", "Ctrl+Del")) - self.action_send_calibrations.setText(_translate("MainWindow", "Send camera calibrations")) - self.action_reboot_all.setText(_translate("MainWindow", "Reboot system")) - self.action_restart_chrony.setText(_translate("MainWindow", "Restart chrony")) - self.action_send_fcu_parameters.setText(_translate("MainWindow", "Send FCU parameters")) + self.action_send_calibrations.setText(_translate("MainWindow", "Camera calibrations")) + self.action_reboot_all.setText(_translate("MainWindow", "Reboot")) + self.action_restart_chrony.setText(_translate("MainWindow", "chrony")) + self.action_send_fcu_parameters.setText(_translate("MainWindow", "FCU parameters")) self.action_toggle_select.setText(_translate("MainWindow", "Toggle select")) self.action_toggle_select.setShortcut(_translate("MainWindow", "Ctrl+A")) self.action_select_all.setText(_translate("MainWindow", "Select all")) @@ -415,16 +420,7 @@ class Ui_MainWindow(object): self.action_edit_server_config.setText(_translate("MainWindow", "Edit server config")) self.action_edit_any_config.setText(_translate("MainWindow", "Edit any config")) self.action_update_server_git.setText(_translate("MainWindow", "Update server git")) - self.action_retrive_any_file.setText(_translate("MainWindow", "Retrive any file")) + self.action_retrive_any_file.setText(_translate("MainWindow", "Retrive file")) self.action_restart_server.setText(_translate("MainWindow", "Restart server")) self.action_configure_columns.setText(_translate("MainWindow", "Configure columns")) - - -if __name__ == "__main__": - import sys - app = QtWidgets.QApplication(sys.argv) - MainWindow = QtWidgets.QMainWindow() - ui = Ui_MainWindow() - ui.setupUi(MainWindow) - MainWindow.show() - sys.exit(app.exec_()) + self.actionSomething.setText(_translate("MainWindow", "something")) diff --git a/Server/server_gui.ui b/Server/server_gui.ui index 0dd485a..e00e6f4 100644 --- a/Server/server_gui.ui +++ b/Server/server_gui.ui @@ -426,7 +426,7 @@ 0 0 1360 - 26 + 25 @@ -463,7 +463,7 @@ - Drone + Selected drones @@ -481,48 +481,49 @@ - Restart + Restart service - - + - Retrive + Developer mode - + - + - - + + - + + + + - - Send animations + Animations - Send configurations + Configuration - Send aruco map + Aruco map @@ -537,17 +538,17 @@ - Send launch files + Launch files - Restart clever service + clever - Restart clever-show service + clever-show @@ -580,12 +581,12 @@ - Select music file + Select file - Play music + Play @@ -600,22 +601,22 @@ - Send any file + File - Send any command + Command - Stop music + Stop - Remove drone + Remove selected drones Ctrl+Del @@ -623,22 +624,22 @@ - Send camera calibrations + Camera calibrations - Reboot system + Reboot - Restart chrony + chrony - Send FCU parameters + FCU parameters @@ -676,13 +677,19 @@ + + false + Update server git + + false + - Retrive any file + Retrive file @@ -695,6 +702,11 @@ Configure columns + + + something + + start_delay_spin From addcdbb78b20c5dbb50ef21ee7d6c2681841aad8 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Wed, 12 Feb 2020 20:43:51 +0300 Subject: [PATCH 152/210] Server: Disable start position check if start_pos_delta_max is 0 --- Server/copter_table_models.py | 4 ++++ Server/server_qt.py | 2 ++ 2 files changed, 6 insertions(+) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 4f74a81..aebb24b 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -128,6 +128,10 @@ def check_time_delta(item): @ModelChecks.column_check("start_position", pass_context=True) def check_start_pos(item, context): + + if ModelChecks.start_pos_delta_max == 0: + return True + if context.current_position is None: return item != 'NO_POS' # maybe should return true diff --git a/Server/server_qt.py b/Server/server_qt.py index 0507215..10de5cf 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -50,10 +50,12 @@ def restart(): # move to core os.chdir(startup_cwd) os.execv(sys.executable, args) + def update_server(): subprocess.call("git fetch && git pull --rebase", shell=True) restart() + def confirmation_required(text="Are you sure?", label="Confirm operation?"): def inner(f): @wraps(f) From 60dcafa53c4f58848f29700498222930e45df741 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 13 Feb 2020 18:03:26 +0300 Subject: [PATCH 153/210] Fix saving issues for config editor --- Server/config_editor_models.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 146126c..3bdbf48 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -830,6 +830,7 @@ class ConfigDialog(QtWidgets.QDialog): return self.result() def validation_loop(self, cfg, configspec=None): # modifies cfg object + filename = cfg.config.filename while True: if not self.run(): return False @@ -846,6 +847,10 @@ class ConfigDialog(QtWidgets.QDialog): else: return True + finally: + if filename is not None: + cfg.config.filename = filename + def call_copter_dialog(self, client, value): self.copter_editor_signal.emit(client, value) From 22f77a62a4bcbe9e5f5dff3b9a1451c3daeef920 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 13 Feb 2020 18:03:57 +0300 Subject: [PATCH 154/210] Fix naming --- Server/copter_table_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 4f74a81..2307951 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -287,7 +287,7 @@ def view_selfcheck(value): @ModelFormatter.view_formatter("current_position") -def view_selfcheck(value): +def view_current_position(value): if isinstance(value, list): x, y, z, yaw, frame = value return f"{x: .2f} {y: .2f} {z: .2f} {int(yaw): d} {frame}" From 7f8201431ec5873f1e90b20b3fe53a09c9c07a2a Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 10 Feb 2020 14:07:51 +0300 Subject: [PATCH 155/210] Drone: Modify failsafe parameters --- Drone/FCU/clever_failsafe_and_power.params | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Drone/FCU/clever_failsafe_and_power.params b/Drone/FCU/clever_failsafe_and_power.params index 5f95317..5933717 100644 --- a/Drone/FCU/clever_failsafe_and_power.params +++ b/Drone/FCU/clever_failsafe_and_power.params @@ -2,4 +2,5 @@ 1 1 COM_OBL_ACT 0 6 1 1 COM_OBL_RC_ACT 4 6 1 1 BAT_V_CHARGED 4.050000190734863281 9 -1 1 BAT_V_EMPTY 3.500000000000000000 9 +1 1 BAT_V_EMPTY 3.400000000000000000 9 +1 1 NAV_RCL_ACT 0 6 From 30c24dbe753e808192dbaa0b0a9a68bfbd21c74b Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 13 Feb 2020 19:17:37 +0300 Subject: [PATCH 156/210] Fix takeoff_checks --- Server/server_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 10de5cf..2cb251b 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -322,7 +322,7 @@ class MainWindow(QtWidgets.QMainWindow): @confirmation_required("This operation will takeoff copters immediately. Proceed?") def takeoff_selected(self): for copter in self.model.user_selected(): - if self.model.checks.takeoff_checks(copter): + if table.takeoff_checks(copter): if self.ui.z_checkbox.isChecked(): copter.client.send_message("takeoff_z", {"z": str(self.ui.z_spin.value())}) # todo int, merge commands else: From 1eeb8209697ded95830ac95ec05a972c56ef0179 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 13 Feb 2020 19:20:43 +0300 Subject: [PATCH 157/210] Fix copter config editor sending --- Server/config_editor_models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index 3bdbf48..d60aa51 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -863,10 +863,10 @@ class ConfigDialog(QtWidgets.QDialog): def save_callback(): edited_dict = cfg.full_dict(include_defaults=False) - client.send_message("config", {"config": edited_dict, "mode": "rewrite"}) + client.send_message("config", kwargs={"config": edited_dict, "mode": "rewrite"}) def restart_callback(): - client.send_message("service_restart", {"name": "clever-show"}) + client.send_message("service_restart", kwargs={"name": "clever-show"}) if not self.call_config_dialog(cfg, save_callback, restart_callback, f"{client.copter_id}"): return False From fe38e8257334b88bad82b790cbce3a03d3bb10b0 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Sat, 15 Feb 2020 00:41:43 +0300 Subject: [PATCH 158/210] Added ability to save and load widths of columns to config --- Server/config/spec/configspec_server.ini | 28 ++++----- Server/copter_table.py | 80 ++++++++++++++++-------- Server/copter_table_models.py | 2 - config.py | 15 ++++- update_configspec.py | 9 ++- 5 files changed, 87 insertions(+), 47 deletions(-) diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index fe8cd18..8c8e96a 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -12,21 +12,21 @@ config_version = float(default='0.0') [[PRESETS]] current = string(default="DEFAULT") [[[DEFAULT]]] - copter_id = boolean(default=True) - git_version = boolean(default=True) - animation_id = boolean(default=True) - battery = boolean(default=True) - fcu_status = boolean(default=True) - calibration_status = boolean(default=True) - mode = boolean(default=True) - selfcheck = boolean(default=True) - current_position = boolean(default=True) - start_position = boolean(default=True) - last_task = boolean(default=True) - time_delta = boolean(default=True) - config_version = boolean(default=True) + copter_id = preset_param(default=list(True, 150)) + git_version = preset_param(default=list(True, 100)) + config_version = preset_param(default=list(True, 100)) + animation_id = preset_param(default=list(True, 100)) + battery = preset_param(default=list(True, 100)) + fcu_status = preset_param(default=list(True, 100)) + calibration_status = preset_param(default=list(True, 100)) + mode = preset_param(default=list(True, 100)) + selfcheck = preset_param(default=list(True, 100)) + current_position = preset_param(default=list(True, 100)) + start_position = preset_param(default=list(True, 100)) + last_task = preset_param(default=list(True, 100)) + time_delta = preset_param(default=list(True, 100)) [[[__many__]]] - __many__ = boolean + __many__ = preset_param [CHECKS] battery_min = float(default=50.0, min=0, max=100) diff --git a/Server/copter_table.py b/Server/copter_table.py index aef2ccc..2a1df89 100644 --- a/Server/copter_table.py +++ b/Server/copter_table.py @@ -16,7 +16,7 @@ def save_preset(config, current, header_dict): presets = config.table_presets for key in presets[HeaderEditWidget.default]: - if key not in presets[current] and not header_dict[key]: + if key not in presets[current] and not header_dict[key][0]: header_dict.pop(key) presets[current] = header_dict @@ -33,7 +33,8 @@ class HeaderViewFilter(QObject): if event.type() == QEvent.Enter: # logicalIndex = self.header.logicalIndexAt(event.pos()) self.parent().cellHover.emit(QModelIndex()) - + else: + return False return True @@ -147,20 +148,36 @@ class CopterTableWidget(QTableView): if index_to != index_from: self.horizontalHeader().moveSection(index_from, index_to) - def load_columns(self, item_dict: dict=None): + def load_columns(self, item_dict: dict = None): presets = self.config.table_presets if item_dict is None: item_dict = presets[self.config.table_presets_current] - item_dict.update({key: False for key in presets[HeaderEditWidget.default] if key not in item_dict}) + item_dict.update({key: (False, presets[HeaderEditWidget.default][key][1]) + for key in presets[HeaderEditWidget.default] if key not in item_dict}) - self.set_column_order(list(item_dict.keys())) - for name, show in item_dict.items(): # for index, name in enumerate(self.columns): - self.setColumnHidden(self.columns.index(name), not show) # self.setColumnHidden(index, not item_dict.get(name, False)) + self.set_column_order(item_dict.keys()) + # self.set_column_widths({key: val[1] for key, val in item_dict.items()}) + + for name, value in item_dict.items(): # for index, name in enumerate(self.columns): + index = self.columns.index(name) + show, width = value + self.setColumnHidden(index, not show) # self.setColumnHidden(index, not item_dict.get(name, False)) + self.setColumnWidth(index, width) + + def _get_column_item(self, column): + index = self.columns.index(column) + presets = self.config.table_presets + show = not self.isColumnHidden(index) + # columnWidth is 0 when hidden, trying to get previous width from config or default + width = self.columnWidth(index) or \ + presets[self.config.table_presets_current].get(column, 0)[1] or \ + presets[HeaderEditWidget.default][column][1] + return show, width @property def item_dict(self): - return {column: not self.isColumnHidden(self.columns.index(column)) for column in self.current_columns} + return {column: self._get_column_item(column) for column in self.current_columns} def save_columns(self): current = self.config.table_presets_current @@ -180,8 +197,7 @@ class CopterTableWidget(QTableView): @pyqtSlot(QtCore.QModelIndex) def on_double_click(self, index): - col = index.column() - if col == 7: + if self.model.is_column(index, "selfcheck"): data = self.proxy_model.data(index, role=table.ModelDataRole) if data and data != "OK": self._show_info("Selfcheck info", data) @@ -254,7 +270,8 @@ class CopterTableWidget(QTableView): class HeaderListWidget(QListWidget): - ColumnKeyRole = 998 + ColumnKeyRole = Qt.UserRole + 1000 + ColumnWidthRole = Qt.UserRole + 1001 dropped = QtCore.pyqtSignal(bool) @@ -268,18 +285,21 @@ class HeaderListWidget(QListWidget): def populate_items(self, item_dict: dict): self.clear() - for name, visible in item_dict.items(): + for name, value in item_dict.items(): + visible, width = value flags = Qt.ItemIsUserCheckable | Qt.ItemIsSelectable | Qt.ItemIsDragEnabled | Qt.ItemIsEnabled state = Qt.Checked if visible else Qt.Unchecked item = QListWidgetItem(table.CopterDataModel.columns_dict.get(name, "").strip() or name, self) item.setFlags(flags) item.setCheckState(state) - item.setData(HeaderListWidget.ColumnKeyRole, name) + item.setData(self.ColumnKeyRole, name) + item.setData(self.ColumnWidthRole, width) @property def item_dict(self): - return {self.item(i).data(HeaderListWidget.ColumnKeyRole): bool(self.item(i).checkState()) + return {self.item(i).data(self.ColumnKeyRole): + (bool(self.item(i).checkState()), self.item(i).data(self.ColumnWidthRole)) for i in range(self.count())} def dropEvent(self, event: QtGui.QDropEvent): @@ -307,8 +327,7 @@ class ActiveHeaderListWidget(HeaderListWidget): key = item.data(HeaderListWidget.ColumnKeyRole) if key is None: return - self.source_widget.setColumnHidden(self.columns.index(key), - not bool(item.checkState())) + self.source_widget.setColumnHidden(self.columns.index(key), not bool(item.checkState())) def dropEvent(self, event: QtGui.QDropEvent): super().dropEvent(event) @@ -332,7 +351,7 @@ class HeaderEditWidget(QtWidgets.QWidget): self.preset_widget = QtWidgets.QComboBox() self.header_widget = ActiveHeaderListWidget(self.source) \ if self.menu_mode else HeaderListWidget() - #self.header_widget.itemChanged.connect(partial(self.saved_signal.emit, False)) + # self.header_widget.itemChanged.connect(partial(self.saved_signal.emit, False)) self.header_widget.model().dataChanged.connect(partial(self.saved_signal.emit, False)) self.header_widget.dropped.connect(partial(self.saved_signal.emit, False)) @@ -404,13 +423,17 @@ class HeaderEditWidget(QtWidgets.QWidget): self.previous = index presets = self.config.table_presets - items = {key: value for key, value in presets[index].items()} - items.update({key: False for key in presets[self.default] if key not in items}) + item_dict = {key: value for key, value in presets[index].items()} + item_dict.update({key: (False, presets[self.default][key][1]) + for key in presets[self.default] if key not in item_dict}) if self.menu_mode: - self.source.set_column_order(list(items.keys())) + self.source.set_column_order(list(item_dict.keys())) # hidden\shown is hold by header widget's itemChanged + for name, value in item_dict.items(): + self.source.setColumnWidth(self.source.columns.index(name), value[1]) + self.config.table_presets_current = index - self.header_widget.populate_items(items) + self.header_widget.populate_items(item_dict) self.saved_signal.emit(True) def add_preset(self): @@ -438,7 +461,7 @@ class HeaderEditWidget(QtWidgets.QWidget): return reply = QMessageBox.question(None, "Action can't be undone", "Remove anyway?", - QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) if reply != QMessageBox.Yes: return @@ -486,7 +509,7 @@ class HeaderEditDialog(QtWidgets.QDialog): unsaved = not saved self.unsaved = unsaved self.setWindowTitle(f"Column preset editor - {self.widget.preset_widget.currentText()}" - + "*"*unsaved) + + "*" * unsaved) def closeEvent(self, event): if not self.unsaved: @@ -506,8 +529,11 @@ class HeaderEditDialog(QtWidgets.QDialog): if __name__ == '__main__': import sys + def except_hook(cls, exception, traceback): sys.__excepthook__(cls, exception, traceback) + + sys.excepthook = except_hook # for debugging (exceptions traceback) app = QtWidgets.QApplication(sys.argv) @@ -518,12 +544,14 @@ if __name__ == '__main__': # model.add_client(copter_table_models.StatedCopterData()) import config + c = config.ConfigManager() c.load_config_and_spec("config\server.ini") - #print(c.config) - #print(c._name_dict) + # print(c.config) + # print(c._name_dict) w1 = CopterTableWidget(model, c) w = HeaderEditWidget(w1, c) + print(w1.item_dict) # print(*w1.current_columns, sep='\n') - w.show() + # w.show() app.exec() diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index c467fbb..f643e32 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -354,8 +354,6 @@ class CopterDataModel(QtCore.QAbstractTableModel): def __init__(self, checks=ModelChecks, formatter=ModelFormatter, data_model=StatedCopterData, parent=None): super(CopterDataModel, self).__init__(parent) - # self.headers = (' copter ID ', ' version ', ' animation ID ', ' battery ', ' fcu_status ', ' sensors ', - # ' mode ', ' checks ', ' current x y z yaw frame_id ', ' start x y z ', ' task ', 'dt') self.headers = list(self.columns_dict.values()) self.data_contents = [] diff --git a/config.py b/config.py index 6b1acf6..55c911e 100644 --- a/config.py +++ b/config.py @@ -3,7 +3,7 @@ import copy import collections from configobj import ConfigObj, Section, flatten_errors -from validate import Validator +from validate import Validator, is_tuple, is_boolean, is_integer def modify_filename(path, pattern): # TODO move to core @@ -23,6 +23,11 @@ def parent_dir(path): return os.path.basename(os.path.normpath(path)) +def is_preset_param(value): + parsed = is_tuple(value, min=2, max=2) + return is_boolean(parsed[0]), is_integer(parsed[1], min=0) + + class ValidationError(ValueError): def __init__(self, message, config, errors): super(ValidationError, self).__init__(message) @@ -84,7 +89,7 @@ class ConfigManager: def validate_config(self, config=None, copy_defaults=False): config = self.config if config is None else config - vdt = Validator() + vdt = Validator({"preset_param": is_preset_param}) test = config.validate(vdt, copy=copy_defaults, preserve_errors=True) if test != True: # Important syntax, do no change @@ -306,9 +311,13 @@ class ConfigManager: if __name__ == '__main__': cfg = ConfigManager() - cfg.load_from_file('Server/config/server.ini') + cfg.load_from_file('Drone/config/client.ini') + # cfg.load_from_file('Server/config/server.ini') #cfg.load_from_file('Drone/config/spec/configspec_client.ini') print(dict(cfg.full_dict(include_defaults=True))) + cfg.config.pop("PRIVATE", None) + print(cfg.config) + # cfg.load_config_and_spec('Drone/config/client.ini') # #print(cfg.config.comments) diff --git a/update_configspec.py b/update_configspec.py index 3400119..590b32d 100644 --- a/update_configspec.py +++ b/update_configspec.py @@ -1,8 +1,13 @@ import config from Server.copter_table_models import CopterDataModel -cfg_server = config.ConfigObj('SERVER/config/spec/configspec_server.ini') -default = {key: 'boolean(default=True)' for key in CopterDataModel.columns} +cfg_server = config.ConfigObj('SERVER/config/spec/configspec_server.ini', list_values=False) +widths = {"copter_id": 150} +default_width = 100 + +default = {key: f"preset_param(default=list(True, {widths.get(key, default_width)}))" + for key in CopterDataModel.columns} + cfg_server['TABLE']['PRESETS']['DEFAULT'] = default cfg_server.write() From 0da40e19e7ee75bfb9b84983a278d28e14328d0c Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 17 Feb 2020 17:12:04 +0300 Subject: [PATCH 159/210] Server: Update config specs --- Server/config/spec/configspec_server.ini | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index 8c8e96a..99c1bc8 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -1,5 +1,5 @@ -config_name = string(default='Copter config') -config_version = float(default='0.0') +config_name = string(default='server') +config_version = float(default='1.0') [SERVER] port = integer(default=25000) @@ -12,18 +12,18 @@ config_version = float(default='0.0') [[PRESETS]] current = string(default="DEFAULT") [[[DEFAULT]]] - copter_id = preset_param(default=list(True, 150)) - git_version = preset_param(default=list(True, 100)) - config_version = preset_param(default=list(True, 100)) + copter_id = preset_param(default=list(True, 100)) + git_version = preset_param(default=list(True, 75)) + config_version = preset_param(default=list(True, 140)) animation_id = preset_param(default=list(True, 100)) battery = preset_param(default=list(True, 100)) fcu_status = preset_param(default=list(True, 100)) - calibration_status = preset_param(default=list(True, 100)) + calibration_status = preset_param(default=list(True, 65)) mode = preset_param(default=list(True, 100)) - selfcheck = preset_param(default=list(True, 100)) - current_position = preset_param(default=list(True, 100)) - start_position = preset_param(default=list(True, 100)) - last_task = preset_param(default=list(True, 100)) + selfcheck = preset_param(default=list(True, 65)) + current_position = preset_param(default=list(True, 250)) + start_position = preset_param(default=list(True, 150)) + last_task = preset_param(default=list(True, 250)) time_delta = preset_param(default=list(True, 100)) [[[__many__]]] __many__ = preset_param From d87ea4c3899a3d5097d46212e36ede43a54df3bd Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 17 Feb 2020 17:40:30 +0300 Subject: [PATCH 160/210] Server: Add check_current_position parameter to config --- Server/config/spec/configspec_server.ini | 4 +++- Server/copter_table_models.py | 3 +++ Server/server_qt.py | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index 99c1bc8..8798f79 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -29,10 +29,12 @@ config_version = float(default='1.0') __many__ = preset_param [CHECKS] + # in meters battery_min = float(default=50.0, min=0, max=100) + check_current_position = boolean(default=True) # in meters start_pos_delta_max = float(default=1.0, min=0) - # in meters + # in seconds time_delta_max = float(default=1.0, min=0) [BROADCAST] diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index f643e32..90fdf3b 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -45,6 +45,7 @@ class ModelChecks: battery_min = 50.0 start_pos_delta_max = 1.0 time_delta_max = 1.0 + check_current_pos = True @classmethod def column_check(cls, column, pass_context=False): @@ -111,6 +112,8 @@ def check_selfcheck(item): @ModelChecks.column_check("current_position") def check_pos(item): + if not ModelChecks.check_current_pos: + return True if item == 'NO_POS': return False return not math.isnan(item[0]) diff --git a/Server/server_qt.py b/Server/server_qt.py index 2cb251b..b013b33 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -91,6 +91,7 @@ class ServerQt(Server): def load_config(self): super().load_config() table.ModelChecks.battery_min = self.config.checks_battery_min + table.ModelChecks.check_current_pos = self.config.checks_check_current_position table.ModelChecks.start_pos_delta_max = self.config.checks_start_pos_delta_max table.ModelChecks.time_delta_max = self.config.checks_time_delta_max From 07456bd4148905afb2a3a2f06dcfcfa9921a906b Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 17 Feb 2020 17:52:52 +0300 Subject: [PATCH 161/210] Server: Add check_git_version parameter --- Server/config/spec/configspec_server.ini | 3 ++- Server/copter_table_models.py | 3 +++ Server/server_qt.py | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index 8798f79..185126d 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -29,9 +29,10 @@ config_version = float(default='1.0') __many__ = preset_param [CHECKS] + check_git_version = boolean(default=True) + check_current_position = boolean(default=True) # in meters battery_min = float(default=50.0, min=0, max=100) - check_current_position = boolean(default=True) # in meters start_pos_delta_max = float(default=1.0, min=0) # in seconds diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 90fdf3b..c063ed6 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -46,6 +46,7 @@ class ModelChecks: start_pos_delta_max = 1.0 time_delta_max = 1.0 check_current_pos = True + check_git = True @classmethod def column_check(cls, column, pass_context=False): @@ -75,6 +76,8 @@ class ModelChecks: @ModelChecks.column_check("git_version") def check_ver(item): + if not ModelChecks.check_git: + return True return get_git_version() == item diff --git a/Server/server_qt.py b/Server/server_qt.py index b013b33..3c30fb9 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -90,8 +90,9 @@ class ExitMsgbox(logging.Handler): class ServerQt(Server): def load_config(self): super().load_config() - table.ModelChecks.battery_min = self.config.checks_battery_min + table.ModelChecks.check_git = self.config.checks_check_git_version table.ModelChecks.check_current_pos = self.config.checks_check_current_position + table.ModelChecks.battery_min = self.config.checks_battery_min table.ModelChecks.start_pos_delta_max = self.config.checks_start_pos_delta_max table.ModelChecks.time_delta_max = self.config.checks_time_delta_max From 3f518dd6bd2de0d0ea45b192653d8e990fe1918c Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 17 Feb 2020 18:24:40 +0300 Subject: [PATCH 162/210] Server: Add comments to configspec --- Server/config/spec/configspec_server.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index 185126d..8d1a21a 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -32,9 +32,9 @@ config_version = float(default='1.0') check_git_version = boolean(default=True) check_current_position = boolean(default=True) # in meters - battery_min = float(default=50.0, min=0, max=100) + battery_min = float(default=50.0, min=0, max=100) # Set 0 to disable this check # in meters - start_pos_delta_max = float(default=1.0, min=0) + start_pos_delta_max = float(default=1.0, min=0) # Set 0 to disable this check # in seconds time_delta_max = float(default=1.0, min=0) From c114197761712060eac2274e1020b760e27d161c Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 17 Feb 2020 16:41:07 +0000 Subject: [PATCH 163/210] Client: Rework position watchdog parameters --- Drone/config/spec/configspec_client.ini | 16 ++++++----- Drone/visual_pose_watchdog.py | 35 +++++++++++++------------ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index 0a6f063..8b595ab 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -1,5 +1,5 @@ -config_name = string(default='Copter config') -config_version = float(default=0.0) +config_name = string(default='client') +config_version = float(default=1.0) [SERVER] port = integer(default=25000, min=1) @@ -15,16 +15,20 @@ transmit = boolean(default=True) frequency = float(default=1.0, min=0) log_resources = boolean(default=True) -[VISUAL POSE WATCHDOG] -timeout = float(default=1.0, min=0) -pos_delta_max = float(default=3.0, min=0) +[POSITION WATCHDOG] +enabled = boolean(default=True) # Available options: emergency_land, land, disarm action = string(default=emergency_land) +# Timeout for the last vision pose in /mavros/vision_pose/pose +vision_pose_timeout = float(default=1.0, min=0) # Set 0 to disable this check +# Max delta between current position and setpoint +position_delta_max = float(default=3.0, min=0) # Set 0 to disable this check +# Time to disarm after action is triggered +disarm_timeout = float(default=10.0, min=0) [EMERGENCY LAND] thrust = float(default=0.45, min=0, max=1) decrease_thrust_after = float(default=5.0, min=0) -disarm_timeout = float(default=10.0, min=0) [COPTER] frame_id = string(default=map) diff --git a/Drone/visual_pose_watchdog.py b/Drone/visual_pose_watchdog.py index db206a0..ebb9336 100644 --- a/Drone/visual_pose_watchdog.py +++ b/Drone/visual_pose_watchdog.py @@ -23,12 +23,13 @@ from config import ConfigManager config = ConfigManager() config.load_config_and_spec("config/client.ini") -visual_pose_timeout = config.visual_pose_watchdog_timeout -pos_delta_max = config.visual_pose_watchdog_pos_delta_max -timeout_action = config.visual_pose_watchdog_action +watchdog_is_enabled = config.position_watchdog_enabled +visual_pose_timeout = config.position_watchdog_vision_pose_timeout +pos_delta_max = config.position_watchdog_position_delta_max +watchdog_action = config.position_watchdog_action +timeout_to_disarm = config.position_watchdog_disarm_timeout emergency_land_thrust = config.emergency_land_thrust emergency_land_decrease_thrust_after = config.emergency_land_decrease_thrust_after -timeout_to_disarm = config.emergency_land_disarm_timeout logging.basicConfig( # TODO all prints as logs level=logging.DEBUG, # INFO @@ -69,9 +70,9 @@ emergency_land_called = False rospy.init_node('visual_pose_watchdog') logger.info('visual_pose_watchdog inited') -logger.info('timeout = {} | timeout_action = {}'.format(visual_pose_timeout, timeout_action)) +logger.info('visual_pose_timeout = {} | position_delta_max = {} | watchdog_action = {}'.format(visual_pose_timeout, pos_delta_max, watchdog_action)) logger.info('timeout_to_disarm = {}'.format(timeout_to_disarm)) -if timeout_action == 'emergency_land': +if watchdog_action == 'emergency_land': logger.info('emergency_land_thrust: {}'.format(emergency_land_thrust)) rate = rospy.Rate(10) @@ -174,21 +175,21 @@ def emergency_land_service(request): return responce def watchdog_callback(event): - global visual_pose_last_timestamp, armed, mode, timeout_action, laser_range, emergency, local_pose, setpoint_pose, offboard_start_time, emergency_land_called + global visual_pose_last_timestamp, armed, mode, watchdog_action, laser_range, emergency, local_pose, setpoint_pose, offboard_start_time, emergency_land_called pos_delta = get_pos_delta(local_pose, setpoint_pose) pos_dt = get_time_delta(local_pose, setpoint_pose) - logger.debug("armed: {} | mode: {} | viz_dt: {:.2f} | pos_delta: {:.2f} | pos_dt: {:.2f} | action: {} | range: {:.2f}".format( - armed, mode, abs(time.time() - visual_pose_last_timestamp), pos_delta, pos_dt, timeout_action, laser_range)) - if mode == 'OFFBOARD': + logger.debug("armed: {} | mode: {} | viz_dt: {:.2f} | pos_delta: {:.2f} | pos_dt: {:.2f} | range: {:.2f} | watchdog_action: {}".format( + armed, mode, abs(time.time() - visual_pose_last_timestamp), pos_delta, pos_dt, laser_range, watchdog_action)) + if mode == 'OFFBOARD' and watchdog_is_enabled: if offboard_start_time is None: offboard_start_time = time.time() - if armed: + if armed and time.time() - offboard_start_time > visual_pose_timeout: visual_pose_dt = abs(time.time() - visual_pose_last_timestamp) - if visual_pose_dt > visual_pose_timeout or pos_delta > pos_delta_max: + if (visual_pose_dt > visual_pose_timeout and visual_pose_timeout != 0.) or (pos_delta > pos_delta_max and pos_delta_max != 0.): action_timestamp = time.time() - if timeout_action in ['land', 'emergency_land', 'disarm']: + if watchdog_action in ['land', 'emergency_land', 'disarm']: emergency = True - if timeout_action == 'land': + if watchdog_action == 'land': logger.info('Visual pose data is too old, copter is armed, landing...') while mode != "AUTO.LAND": try: @@ -207,7 +208,7 @@ def watchdog_callback(event): except rospy.ServiceException as e: logger.info(e) rate.sleep() - elif timeout_action == 'disarm': + elif watchdog_action == 'disarm': logger.info('Visual pose data is too old, copter is armed, disarming...') while armed: try: @@ -215,7 +216,7 @@ def watchdog_callback(event): except rospy.ServiceException as e: logger.info(e) rate.sleep() - elif timeout_action == 'emergency_land': + elif watchdog_action == 'emergency_land': if visual_pose_dt > visual_pose_timeout: logger.info('Visual pose data is too old, copter is armed, emergency landing...') if pos_delta > pos_delta_max: @@ -238,7 +239,7 @@ def watchdog_callback(event): logger.info(e) else: offboard_start_time = None - if abs(time.time() - visual_pose_last_timestamp) > visual_pose_timeout: + if (abs(time.time() - visual_pose_last_timestamp) > visual_pose_timeout and visual_pose_timeout != 0.0): logger.info('Visual pose data is too old') rospy.Subscriber('/mavros/vision_pose/pose', PoseStamped, visual_pose_callback) From 97194d1271616a267a23820483dd9e043dacd7e7 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Mon, 17 Feb 2020 20:25:29 +0300 Subject: [PATCH 164/210] Fixed server message broadcasting --- Server/server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Server/server.py b/Server/server.py index 3e6e21c..79e4e60 100644 --- a/Server/server.py +++ b/Server/server.py @@ -357,8 +357,8 @@ class Client(messaging.ConnectionManager): @classmethod @requires_any_connected - def broadcast_message(cls, command, args=None, force_all=False): - cls.broadcast(messaging.MessageManager.create_action_message(command, args), force_all) + def broadcast_message(cls, command, args=(), kwargs=None, force_all=False): + cls.broadcast(messaging.MessageManager.create_action_message(command, args, kwargs), force_all) if __name__ == '__main__': From fc0a56836f36b5a5c8be6911be1e4fa12c5bc8d5 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Wed, 19 Feb 2020 18:44:32 +0000 Subject: [PATCH 165/210] Client: Add log_state and vision_pose_delay_after_arm parameters for position_watchdog --- Drone/config/spec/configspec_client.ini | 11 ++- Drone/visual_pose_watchdog.py | 95 ++++++++++++++----------- 2 files changed, 63 insertions(+), 43 deletions(-) diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index 8b595ab..b3c1575 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -17,12 +17,19 @@ log_resources = boolean(default=True) [POSITION WATCHDOG] enabled = boolean(default=True) +log_state = boolean(default=True) # Available options: emergency_land, land, disarm action = string(default=emergency_land) +# Time to get vision position after arm +# No visual position will be checked +# during this time after arming +vision_pose_delay_after_arm = float(default=3.0, min=0) # Timeout for the last vision pose in /mavros/vision_pose/pose -vision_pose_timeout = float(default=1.0, min=0) # Set 0 to disable this check +# Set 0 to disable vision pose check +vision_pose_timeout = float(default=0.0, min=0) # Max delta between current position and setpoint -position_delta_max = float(default=3.0, min=0) # Set 0 to disable this check +# Set 0 to disable position delta check +position_delta_max = float(default=3.0, min=0) # Time to disarm after action is triggered disarm_timeout = float(default=10.0, min=0) diff --git a/Drone/visual_pose_watchdog.py b/Drone/visual_pose_watchdog.py index ebb9336..4ca07cb 100644 --- a/Drone/visual_pose_watchdog.py +++ b/Drone/visual_pose_watchdog.py @@ -24,6 +24,8 @@ config = ConfigManager() config.load_config_and_spec("config/client.ini") watchdog_is_enabled = config.position_watchdog_enabled +log_state = config.position_watchdog_log_state +vision_pose_delay_after_arm = config.position_watchdog_vision_pose_delay_after_arm visual_pose_timeout = config.position_watchdog_vision_pose_timeout pos_delta_max = config.position_watchdog_position_delta_max watchdog_action = config.position_watchdog_action @@ -63,6 +65,7 @@ setpoint_raw = None setpoint_position = None setpoint_pose = None +arm_start_time = None offboard_start_time = None offboard_disarmed_timeout = 3. @@ -175,56 +178,65 @@ def emergency_land_service(request): return responce def watchdog_callback(event): - global visual_pose_last_timestamp, armed, mode, watchdog_action, laser_range, emergency, local_pose, setpoint_pose, offboard_start_time, emergency_land_called + global visual_pose_last_timestamp, armed, mode, watchdog_action, laser_range + global emergency, local_pose, setpoint_pose, emergency_land_called, log_state + global offboard_start_time, arm_start_time, vision_pose_delay_after_arm pos_delta = get_pos_delta(local_pose, setpoint_pose) pos_dt = get_time_delta(local_pose, setpoint_pose) - logger.debug("armed: {} | mode: {} | viz_dt: {:.2f} | pos_delta: {:.2f} | pos_dt: {:.2f} | range: {:.2f} | watchdog_action: {}".format( - armed, mode, abs(time.time() - visual_pose_last_timestamp), pos_delta, pos_dt, laser_range, watchdog_action)) - if mode == 'OFFBOARD' and watchdog_is_enabled: + visual_pose_dt = abs(time.time() - visual_pose_last_timestamp) + if log_state: + logger.info("armed: {} | mode: {} | vis_dt: {:.2f} | pos_delta: {:.2f} | pos_dt: {:.2f} | range: {:.2f} | watchdog_action: {}".format( + armed, mode, visual_pose_dt, pos_delta, pos_dt, laser_range, watchdog_action)) + if mode == 'OFFBOARD': if offboard_start_time is None: offboard_start_time = time.time() - if armed and time.time() - offboard_start_time > visual_pose_timeout: - visual_pose_dt = abs(time.time() - visual_pose_last_timestamp) - if (visual_pose_dt > visual_pose_timeout and visual_pose_timeout != 0.) or (pos_delta > pos_delta_max and pos_delta_max != 0.): - action_timestamp = time.time() - if watchdog_action in ['land', 'emergency_land', 'disarm']: + if armed: + if arm_start_time is None: + arm_start_time = time.time() + arm_time = time.time() - arm_start_time + logger.debug('arm time: {}'.format(arm_time)) + if arm_time > vision_pose_delay_after_arm and watchdog_is_enabled: + if (visual_pose_dt > visual_pose_timeout and visual_pose_timeout != 0.) or (pos_delta > pos_delta_max and pos_delta_max != 0.): + action_timestamp = time.time() emergency = True - if watchdog_action == 'land': - logger.info('Visual pose data is too old, copter is armed, landing...') - while mode != "AUTO.LAND": - try: - set_mode(custom_mode='AUTO.LAND') - except rospy.ServiceException as e: - logger.info(e) - if time.time() - action_timestamp > timeout_to_disarm: - break - rate.sleep() - else: - logger.info('Land mode is set') - while armed: - if time.time() - action_timestamp > timeout_to_disarm: + if watchdog_action not in ['land', 'emergency_land', 'disarm']: + watchdog_action = 'land' + if watchdog_action == 'land': + logger.info('Visual pose data is too old, copter is armed, landing...') + while mode != "AUTO.LAND": + try: + set_mode(custom_mode='AUTO.LAND') + except rospy.ServiceException as e: + logger.info(e) + if time.time() - action_timestamp > timeout_to_disarm: + break + rate.sleep() + else: + logger.info('Land mode is set') + while armed: + if time.time() - action_timestamp > timeout_to_disarm: + try: + arming(False) + except rospy.ServiceException as e: + logger.info(e) + rate.sleep() + elif watchdog_action == 'disarm': + logger.info('Visual pose data is too old, copter is armed, disarming...') + while armed: try: arming(False) except rospy.ServiceException as e: logger.info(e) - rate.sleep() - elif watchdog_action == 'disarm': - logger.info('Visual pose data is too old, copter is armed, disarming...') - while armed: - try: - arming(False) - except rospy.ServiceException as e: - logger.info(e) - rate.sleep() - elif watchdog_action == 'emergency_land': - if visual_pose_dt > visual_pose_timeout: - logger.info('Visual pose data is too old, copter is armed, emergency landing...') - if pos_delta > pos_delta_max: - logger.info('Position delta is {} m, copter is armed, emergency landing...'.format(pos_delta)) - emergency_land() - logger.info('Disarmed') - emergency = False - elif emergency_land_called: + rate.sleep() + elif watchdog_action == 'emergency_land': + if visual_pose_dt > visual_pose_timeout: + logger.info('Visual pose data is too old, copter is armed, emergency landing...') + if pos_delta > pos_delta_max: + logger.info('Position delta is {} m, copter is armed, emergency landing...'.format(pos_delta)) + emergency_land() + logger.info('Disarmed') + emergency = False + if emergency_land_called: emergency = True logger.info('/emergency_land service was called, start emergency landing...') emergency_land() @@ -232,6 +244,7 @@ def watchdog_callback(event): emergency = False emergency_land_called = False else: + arm_start_time = None if time.time() - offboard_start_time > offboard_disarmed_timeout: try: set_mode(custom_mode='AUTO.LAND') From 5b44c1a591c80186ae0206560496db776f5f9761 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 19 Feb 2020 22:48:56 +0300 Subject: [PATCH 166/210] Show server window maximalized on startup --- Server/server_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 3c30fb9..b6afe2f 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -680,7 +680,7 @@ if __name__ == "__main__": server.start() - window.show() + window.showMaximized() splash.close() loop.run_forever() From 130cb1484a7b9fe8145e70e54e4f6da6f7cd2f2f Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Fri, 21 Feb 2020 13:46:04 +0300 Subject: [PATCH 167/210] Server: Fix start_animation and takeoff_z commands --- Server/server_qt.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index b6afe2f..3ebbb87 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -306,8 +306,9 @@ class MainWindow(QtWidgets.QMainWindow): music_dt = self.ui.music_delay_spin.value() asyncio.ensure_future(self.play_music_at_time(music_dt + time_now), loop=loop) logging.info('Wait {} seconds to play music'.format(music_dt)) - # self.selfcheck_selected() - for copter in filter(lambda copter: copter.all_checks, self.model.user_selected()): + # This filter constraints takeoff in real world, when copter state was normal and then some checks were failed for a while + # for copter in filter(lambda copter: copter.states.all_checks, self.model.user_selected()): + for copter in self.model.user_selected(): server.send_starttime(copter.client, dt + time_now) @pyqtSlot() @@ -326,7 +327,7 @@ class MainWindow(QtWidgets.QMainWindow): for copter in self.model.user_selected(): if table.takeoff_checks(copter): if self.ui.z_checkbox.isChecked(): - copter.client.send_message("takeoff_z", {"z": str(self.ui.z_spin.value())}) # todo int, merge commands + copter.client.send_message("takeoff_z", kwargs={"z": str(self.ui.z_spin.value())}) # todo int, merge commands else: copter.client.send_message("takeoff") From e208b7959fd09d508557831f75d154197468a1a5 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Fri, 21 Feb 2020 13:47:05 +0300 Subject: [PATCH 168/210] Server: Fix renaming --- Server/copter_table_models.py | 4 +--- Server/server.py | 4 ++-- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index c063ed6..fd87467 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -483,9 +483,7 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.data_contents[row][col] = formatted_value if col == 0: - self.data_contents[row].client.send_message("id", {"new_id": formatted_value}) - self.data_contents[row].client.remove() # TODO change - self._remove_row(row) + self.data_contents[row].client.send_message("id", kwargs={"new_id": formatted_value}) elif role == ModelDataRole: # For inner setting\editing of raw data self.data_contents[row][col] = value diff --git a/Server/server.py b/Server/server.py index 79e4e60..1ad1fc5 100644 --- a/Server/server.py +++ b/Server/server.py @@ -305,8 +305,8 @@ class Client(messaging.ConnectionManager): self.connected = True - if self.copter_id is None: - self.get_response("id", self._got_id) + #if self.copter_id is None: + self.get_response("id", self._got_id) if self.on_connect: self.on_connect(self) From 6b1649941bdd2cfbb9912d0fee8a86c67a9021f9 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Fri, 21 Feb 2020 10:56:36 +0000 Subject: [PATCH 169/210] Client: Fix responce id --- Drone/client.py | 2 ++ Drone/copter_client.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Drone/client.py b/Drone/client.py index ff293c2..c7aaf45 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -51,6 +51,8 @@ class Client(object): self.client_id = socket.gethostname() elif config_id == '/ip': self.client_id = messaging.get_ip_address() + else: + self.client_id = config_id logger.info("Config loaded") diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 1aa9797..150e694 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -252,14 +252,15 @@ def _response_id(*args, **kwargs): old_id = client.active_client.client_id if new_id != old_id: client.active_client.config.set('PRIVATE', 'id', new_id, write=True) + client.active_client.client_id = new_id if new_id != '/hostname': - if client.active_client.system_restart_after_rename: + if client.active_client.config.system_restart_after_rename: hostname = client.active_client.client_id configure_hostname(hostname) configure_hosts(hostname) configure_bashrc(hostname) configure_motd(hostname) - execute_command("reboot") + execute_command("systemctl stop clever-show & reboot") # execute_command("hostname {}".format(hostname)) # restart_service("dhcpcd") # restart_service("avahi-daemon") From e5c60637faf641f9fb6f790826cb66101d7f76be Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 25 Feb 2020 16:31:40 +0300 Subject: [PATCH 170/210] Fixed git version check on server #71 --- Server/copter_table_models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index c063ed6..53d482e 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -18,7 +18,10 @@ ModelStateRole = 999 def get_git_version(): # TODO import from animation - return subprocess.check_output("git log --pretty=format:'%h' -n 1", shell=True).decode('UTF-8') + try: + return subprocess.check_output("git log --pretty=format:'%h' -n 1", shell=True).decode('UTF-8') + except subprocess.CalledProcessError: # when no git repository info present + return True # todo probably add special file class CheckState: From e0c6fdc720de9ffed2f01687ad65c84760a8bdd5 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 25 Feb 2020 16:32:43 +0300 Subject: [PATCH 171/210] Added restart to client for testing --- Drone/client.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Drone/client.py b/Drone/client.py index ff293c2..16de4ed 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -238,7 +238,20 @@ def _response_time(*args, **kwargs): if __name__ == "__main__": + startup_cwd = os.getcwd() + import threading + + + def restart(): # move to core + args = sys.argv[:] + logging.info('Restarting {}'.format(args)) + args.insert(0, sys.executable) + if sys.platform == 'win32': + args = ['"%s"' % arg for arg in args] + os.chdir(startup_cwd) + os.execv(sys.executable, args) + def mock_telem(): while True: time.sleep(5) From 978c71d3c0cb52637450997b80da346e5df2034c Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 25 Feb 2020 16:41:19 +0300 Subject: [PATCH 172/210] Fix #71's fix --- Server/copter_table_models.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index 314d45e..ae4eb27 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -21,7 +21,7 @@ def get_git_version(): # TODO import from animation try: return subprocess.check_output("git log --pretty=format:'%h' -n 1", shell=True).decode('UTF-8') except subprocess.CalledProcessError: # when no git repository info present - return True # todo probably add special file + return None # todo probably add special file class CheckState: @@ -81,7 +81,11 @@ class ModelChecks: def check_ver(item): if not ModelChecks.check_git: return True - return get_git_version() == item + + version = get_git_version() + if version is not None: + return version == item + return True @ModelChecks.column_check("animation_id") From 1b07f4d3f85bfffae5ea006462e2740a6eee7a00 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 28 Feb 2020 16:29:20 +0300 Subject: [PATCH 173/210] Fix #72 - sending led_fill commands --- Server/visual_land_dialog.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Server/visual_land_dialog.py b/Server/visual_land_dialog.py index aed194c..90deb77 100644 --- a/Server/visual_land_dialog.py +++ b/Server/visual_land_dialog.py @@ -38,10 +38,10 @@ class VisualLandDialog(QtWidgets.QDialog): def row_mid(self): return int(math.ceil((self.row_min + self.row_max) / 2.0)) - def send_to_row(self, row, message, args=None): - logging.debug(f"Send {message}: {args} to {row}") - self.model.data_contents[row].client.send_message(message, args) - # test[row] = args # for testing + def send_to_row(self, row, message, args=(), kwargs=None): + logging.debug(f"Send {message}: {args}, {kwargs} to {row}") + self.model.data_contents[row].client.send_message(message, args=args, kwargs=kwargs) + # test[row] = args, kwargs # for testing # print(test) def clear_leds(self, rows): @@ -56,10 +56,10 @@ class VisualLandDialog(QtWidgets.QDialog): def send_led_indication(self): for row in range(self.row_min, self.row_mid): - self.send_to_row(row, "led_fill", {"green": 255}) + self.send_to_row(row, "led_fill", kwargs={"green": 255}) for row in range(self.row_mid, self.row_max + 1): - self.send_to_row(row, "led_fill", {"red": 255}) + self.send_to_row(row, "led_fill", kwargs={"red": 255}) @pyqtSlot() def selection_choice(self, choice): @@ -104,7 +104,7 @@ if __name__ == '__main__': import copter_table_models model = copter_table_models.CopterDataModel() for i in range(10): - model.add_client(copter_table_models.StatedCopterData()) + model.add_client() dialog = VisualLandDialog(model) test = list(range(10)) From fe85255a87183fd00633fa4c40fe8d5641a8b6ba Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 28 Feb 2020 16:55:16 +0300 Subject: [PATCH 174/210] Fixed sending (requesting) of empty files --- messaging_lib.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/messaging_lib.py b/messaging_lib.py index 5f49ee9..793b4b7 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -315,21 +315,23 @@ class ConnectionManager(object): def read(self): self._read() while self._recv_buffer: + # add new message object if queue is empty or last message already processed if not self._received_queue or (self._received_queue[0].content is not None): self._received_queue.appendleft(MessageManager()) - self._received_queue[0].income_raw += self._recv_buffer + last_message = self._received_queue[0] + + last_message.income_raw += self._recv_buffer self._recv_buffer = b'' - self._received_queue[0].process_message() + last_message.process_message() # if something left after processing message - put it back - if self._received_queue[0].content and self._received_queue[0].income_raw: - self._recv_buffer = self._received_queue[0].income_raw + self._recv_buffer - self._received_queue[0].income_raw = b'' + if last_message.content is not None and last_message.income_raw: + self._recv_buffer = last_message.income_raw + self._recv_buffer + last_message.income_raw = b'' - if self._received_queue: - if self._received_queue[0].content: - self.process_received(self._received_queue.popleft()) + if self._received_queue and last_message.content is not None: + self.process_received(self._received_queue.popleft()) def _read(self): try: @@ -378,6 +380,7 @@ class ConnectionManager(object): callback(self, *args, **kwargs) except Exception as error: logger.error("Error during action {} execution: {}".format(action, error)) + traceback.print_exc() def _process_request(self, message): requested_value = message.content["requested_value"] From c314f50a4a781191cae32284fe0594e6d71e6a55 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 28 Feb 2020 18:09:21 +0300 Subject: [PATCH 175/210] Fix #72 landing buttons message sending --- Server/visual_land_dialog.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Server/visual_land_dialog.py b/Server/visual_land_dialog.py index 90deb77..c0ca348 100644 --- a/Server/visual_land_dialog.py +++ b/Server/visual_land_dialog.py @@ -8,6 +8,7 @@ import logging import sys from functools import partial +from server_qt import b_partial # TODO: previous step and reset class VisualLandDialog(QtWidgets.QDialog): @@ -26,8 +27,8 @@ class VisualLandDialog(QtWidgets.QDialog): self.ui.setupUi(self) self.ui.one_button.clicked.connect(partial(self.selection_choice, 1)) self.ui.two_button.clicked.connect(partial(self.selection_choice, 2)) - self.ui.land_emergency_button.clicked.connect(partial(self.send_to_selected, "land", None)) - self.ui.disarm_emergency_button.clicked.connect(partial(self.send_to_selected, "disarm", None)) + self.ui.land_emergency_button.clicked.connect(b_partial(self.send_to_selected, "land")) + self.ui.disarm_emergency_button.clicked.connect(b_partial(self.send_to_selected, "disarm")) self.ui.one_button.setShortcut(QKeySequence("1")) self.ui.two_button.setShortcut(QKeySequence("2")) @@ -83,9 +84,9 @@ class VisualLandDialog(QtWidgets.QDialog): self.send_led_indication() @pyqtSlot() - def send_to_selected(self, message, args=None): + def send_to_selected(self, message, args=(), kwargs=None): for row in range(self.row_min, self.row_max + 1): - self.send_to_row(row, message, args) + self.send_to_row(row, message, args, kwargs) self._finished = True self.close() From 91f46e7d11c0dff3852bbcc4d5ab1e27692feab3 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 28 Feb 2020 18:21:08 +0300 Subject: [PATCH 176/210] Extracted b_partial to lib to fix import errors --- Server/server_qt.py | 2 ++ Server/visual_land_dialog.py | 2 +- lib.py | 3 +++ 3 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 lib.py diff --git a/Server/server_qt.py b/Server/server_qt.py index 3ebbb87..6d7fd67 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -31,6 +31,8 @@ from copter_table import CopterTableWidget, HeaderEditDialog from visual_land_dialog import VisualLandDialog from config_editor_models import ConfigDialog +from lib import b_partial + startup_cwd = os.getcwd() def multi_glob(*patterns): diff --git a/Server/visual_land_dialog.py b/Server/visual_land_dialog.py index c0ca348..86ed229 100644 --- a/Server/visual_land_dialog.py +++ b/Server/visual_land_dialog.py @@ -8,7 +8,7 @@ import logging import sys from functools import partial -from server_qt import b_partial +from lib import b_partial # TODO: previous step and reset class VisualLandDialog(QtWidgets.QDialog): diff --git a/lib.py b/lib.py new file mode 100644 index 0000000..d3bfc11 --- /dev/null +++ b/lib.py @@ -0,0 +1,3 @@ + +def b_partial(func, *args, **kwargs): # call argument blocker partial + return lambda *a: func(*args, **kwargs) From 1cc93d8814b15260eaafdb26860dd753463ed8f2 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 28 Feb 2020 18:35:06 +0300 Subject: [PATCH 177/210] Land on button press when one copter selected in visual land --- Server/visual_land_dialog.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Server/visual_land_dialog.py b/Server/visual_land_dialog.py index 86ed229..6466a9e 100644 --- a/Server/visual_land_dialog.py +++ b/Server/visual_land_dialog.py @@ -67,6 +67,7 @@ class VisualLandDialog(QtWidgets.QDialog): if self.row_min == self.row_max: # self.ui.one_button.setDisabled(True) # maybe? # self.ui.two_button.setDisabled(True) + self.send_to_selected("land") return if choice == 1: From c71cb60c04f94b007fd16a5e0a85f9886d443e25 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 3 Mar 2020 13:04:36 +0300 Subject: [PATCH 178/210] Server: Fix restarting clever service --- Server/server_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 6d7fd67..722d9b9 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -171,7 +171,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_retrive_any_file.triggered.connect(b_partial(self.request_any_file, client_path=None)) self.ui.action_restart_clever.triggered.connect( - b_partial(self.send_to_selected, "service_restart", kwargs={"name": "clever"})) + b_partial(self.send_to_selected, "service_restart", command_kwargs={"name": "clever"})) self.ui.action_restart_clever_show.triggered.connect(self.restart_clever_show) self.ui.action_restart_chrony.triggered.connect(self.restart_chrony) self.ui.action_reboot_all.triggered.connect(b_partial(self.send_to_selected, "reboot_all")) From 55b1ed0f9dd5bb9e47cabad18610de572d3c8c16 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 3 Mar 2020 13:08:34 +0300 Subject: [PATCH 179/210] Server: Fix sending commands --- Server/server_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 722d9b9..7bb50ba 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -531,7 +531,7 @@ class MainWindow(QtWidgets.QMainWindow): text, ok = QInputDialog.getText(self, "Enter command to send on copter", "Command:", QLineEdit.Normal, "") if ok and text: - self.send_to_selected("execute", kwargs={"command": text}) + self.send_to_selected("execute", command_kwargs={"command": text}) @pyqtSlot() def restart_clever_show(self): From 47762bb3c3c4d2f4b0e00743a5f48c4299c32571 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 3 Mar 2020 18:09:40 +0300 Subject: [PATCH 180/210] Server: Fix Resume button --- Server/server_qt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 7bb50ba..417dc36 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -320,7 +320,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.pause_button.setText('Resume') else: time_gap = 0.1 # TODO config? automatic delay detection? - self.send_to_selected("resume", kwargs={"time": server.time_now() + time_gap}) + self.send_to_selected("resume", command_kwargs={"time": server.time_now() + time_gap}) self.ui.pause_button.setText('Pause') @pyqtSlot() From 58c6939cf824145740bb6888294e07a3b4b738cd Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 3 Mar 2020 19:54:55 +0300 Subject: [PATCH 181/210] Config editor: Fix filename extension --- Server/config_editor_models.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index d60aa51..b934521 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -812,12 +812,18 @@ class ConfigDialog(QtWidgets.QDialog): return reply == QMessageBox.Yes def save_as(self): - save_path = QFileDialog.getSaveFileName(self, "Save as configuration file", + save_path = QFileDialog.getSaveFileName(self, "Save as configuration file (.ini)", directory=self.filename, - filter="Config files (*.ini)")[0] + options=QFileDialog.DontConfirmOverwrite, + filter="Config files (*.ini);;All files (*.*)")[0] if not save_path: return + split_path = save_path.split('.') + + if not (len(split_path) > 1 and split_path[-1] == 'ini'): + save_path += '.ini' + cfg = config.ConfigManager() cfg.load_from_dict(self.model.to_config_dict()) cfg.config.filename = save_path From 60cc8a940a328f3ac950330df6c51750c7657567 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Wed, 4 Mar 2020 16:35:52 +0300 Subject: [PATCH 182/210] Config editor: Little filename fix --- Server/config_editor_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index b934521..0b3cab1 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -813,7 +813,7 @@ class ConfigDialog(QtWidgets.QDialog): def save_as(self): save_path = QFileDialog.getSaveFileName(self, "Save as configuration file (.ini)", - directory=self.filename, + directory=self.filename+'.ini', options=QFileDialog.DontConfirmOverwrite, filter="Config files (*.ini);;All files (*.*)")[0] if not save_path: From d6b5db095855aeaca3ca9cdbffb19ecc20959e7b Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Wed, 4 Mar 2020 22:10:55 +0300 Subject: [PATCH 183/210] Remvoe b_partial code from server_qt --- Server/server_qt.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 6d7fd67..2626185 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -38,11 +38,6 @@ startup_cwd = os.getcwd() def multi_glob(*patterns): return itertools.chain.from_iterable(glob.iglob(pattern) for pattern in patterns) - -def b_partial(func, *args, **kwargs): # call argument blocker partial - return lambda *a: func(*args, **kwargs) - - def restart(): # move to core args = sys.argv[:] logging.info('Restarting {}'.format(args)) From c8348d31475dca33827d20ac54e2ad719608d073 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 5 Mar 2020 00:06:50 +0300 Subject: [PATCH 184/210] Fixed table loading when maximized --- Server/server_qt.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Server/server_qt.py b/Server/server_qt.py index 2626185..ca393ad 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -216,6 +216,10 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.copter_table.load_columns() super().show() + def showMaximized(self): + self.ui.copter_table.load_columns() + super().showMaximized() + def closeEvent(self, event): if not any(copter.connected for copter in Client.clients.values()): event.accept() From 4a0491d9268bf8392a877d4c49f01d4953858cf6 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 5 Mar 2020 00:08:48 +0300 Subject: [PATCH 185/210] Added client presense check in table when addding new copter --- Server/server_qt.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index ca393ad..8a8324d 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -216,7 +216,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.copter_table.load_columns() super().show() - def showMaximized(self): + def showMaximized(self): # TODO move to widget self.ui.copter_table.load_columns() super().showMaximized() @@ -250,8 +250,12 @@ class MainWindow(QtWidgets.QMainWindow): command, command_args, command_kwargs))) def new_client_connected(self, client: Client): - logging.debug("Added client {}".format(client)) + if self.model.get_row_by_attr('client', client) is not None: + logging.warning("Client is already in table! {}".format(client)) + return + self.model.add_client(copter_id=client.copter_id, client=client) + logging.debug("Added client {}".format(client)) def client_connection_changed(self, client: Client): logging.debug("Connection {} changed {}".format(client, client.connected)) From df85486490f699d6c06c1571f96ceaf65bf33dd9 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 5 Mar 2020 00:09:23 +0300 Subject: [PATCH 186/210] Fix for server config editing --- Server/config_editor_models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Server/config_editor_models.py b/Server/config_editor_models.py index d60aa51..9bc5e19 100644 --- a/Server/config_editor_models.py +++ b/Server/config_editor_models.py @@ -51,7 +51,7 @@ class ConfigModelItem: self.state = state self.type = item_type - if isinstance(self.data(1), list): + if isinstance(self.data(1), (list, tuple)): self.type = 'list' self.default_values = deepcopy(self.itemData) From c9d888e23c8df4ad732eca790c3695fcfd50d04f Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 20 Mar 2020 19:17:41 +0300 Subject: [PATCH 187/210] Fixed config saving on server restart --- Server/server_qt.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index 1f3e36c..bff49f4 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -38,7 +38,11 @@ startup_cwd = os.getcwd() def multi_glob(*patterns): return itertools.chain.from_iterable(glob.iglob(pattern) for pattern in patterns) -def restart(): # move to core +def restart(): # move to core + window.server.stop() + window.on_quit() + QApplication.quit() + args = sys.argv[:] logging.info('Restarting {}'.format(args)) args.insert(0, sys.executable) From 0290679b00ea558adb16040de4df91653afb0384 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Mon, 23 Mar 2020 20:29:04 +0300 Subject: [PATCH 188/210] Fix music stop bug --- Server/server_qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Server/server_qt.py b/Server/server_qt.py index bff49f4..6c7e560 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -590,6 +590,7 @@ class MainWindow(QtWidgets.QMainWindow): logging.error("No media file") return self.player.stop() + self.ui.action_play_music.setText("Play music") @asyncio.coroutine def play_music_at_time(self, t): From 64e375d3bb47a2f183bae78cfed7d9496fe9e27e Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 24 Mar 2020 14:02:17 +0300 Subject: [PATCH 189/210] docs: Update client documentation --- docs/ru/client.md | 161 ++++++++++++++++++---------------------------- 1 file changed, 61 insertions(+), 100 deletions(-) diff --git a/docs/ru/client.md b/docs/ru/client.md index 2e166f9..aa74cf7 100644 --- a/docs/ru/client.md +++ b/docs/ru/client.md @@ -5,101 +5,40 @@ * [Установка и запуск](start-tutorial.md#установка-и-запуск-клиента) * [Настройка клиента](#настройка-клиента) +## Схема работы клиента + +Клиент является сервисом `clever-show` в операционной системе коптера. Сервис запускает скрипт [copter_client.py](../../Drone/copter_client.py) и автоматически запускается при загрузке операционной системы. В случае необходимости применения параметров обновлённой конфигурации клиента сервис может быть перезапущен. Сервис `clever-show` предназначен для управления и настройки коптера для группового полёта с помощью приложения сервера. + +Вместе с клиентом в операционной системе зарегистрирован сервис экстренной защиты дрона `visual_pose_watchdog`. Данный сервис запускает скрипт [visual_pose_watchdog.py](../../Drone/visual_pose_watchdog.py) и автоматически запускается при загрузке операционной системы. В случае необходимости применения параметров обновлённой конфигурации клиента сервис может быть перезапущен. Сервис `visual_pose_watchdog` предназначен для автоматической посадки коптера в экстренных ситуациях: при отсутствии сообщений о позиции дрона из топика `/mavros/vision_pose/pose` и при столкновении с объектами в полёте, когда расстояние между текущей точкой, где находится коптер, и точкой, где ему следует находиться по полётному заданию, превышает пороговое значение. Также `visual_pose_watchdog` предоставляет ROS сервис `/emergency_land`, к которому может обратиться клиент по команде с сервера для экстренной посадки. + +Логи обоих сервисов записываются в файл `/var/log/syslog` операционной системы бортового компьютера Raspberry Pi на дроне. Логи текущего сеанса доступны для просмотра при выполнении команд `journalctl -u clever-show` для клиента и `journalctl -u visual_pose_watchdog` для сервиса экстренной защиты дрона в терминале, подключенном к Raspberry Pi. Логи могут быть полезны при анализе возникших нештатных или аварийных ситуаций при полёте коптера под управлением приложения клиента. + ## Настройка клиента ### Файл конфигурации -Конфигурация клиента задаётся в файле [client_config.ini](../../Drone/client_config.ini), имеющем следующий вид по умолчанию: - -```ini -[SERVER] -port = 25000 -broadcast_port = 8181 -host = 192.168.1.101 -buffer_size = 10000 - -[VISUAL_POSE_WATCHDOG] -timeout = 1.0 -pos_delta_max = 3.0 -action = emergency_land -emergency_land_thrust = 0.45 -emergency_land_decrease_thrust_after = 5.0 -timeout_to_disarm = 10.0 - -[TELEMETRY] -transmit = True -frequency = 1 -log_cpu_and_memory = True - -[COPTERS] -frame_id = map -takeoff_height = 1.0 -takeoff_time = 5.0 -safe_takeoff = False -reach_first_point_time = 5.0 -land_time = 1.0 -x0_common = 0 -y0_common = 0 -z0_common = 0 -yaw = 180 -land_timeout = 10.0 - -[FLOOR FRAME] -parent = aruco_map -x = 2.4 -y = 12.4 -z = 6.4 -roll = 180 -pitch = 0 -yaw = -90 - -[ANIMATION] -takeoff_animation_check = True -land_animation_check = True -frame_delay = 0.1 -x_ratio = 1.0 -y_ratio = 1.0 -z_ratio = 1.0 - -[PRIVATE] -id = /hostname -restart_after_rename = True -use_leds = True -led_pin = 21 -x0 = 0 -y0 = 0 -z0 = 0 - -[NTP] -use_ntp = False -host = ntp1.stratum2.ru -port = 123 - -``` +Конфигурация клиента создаётся согласно [спецификации](../../Drone/config/spec/configspec_client.ini), в ней можно посмотреть значения по умолчанию для любого параметра после ключевого слова `default`. Все изменения сохраняются в файл конфигурации `client.ini` в папке `clever-show/Drone/config`. Доступно редактирование конфигурации клиента через GUI модуль `Config editor` в приложении сервера. Для редактирования конфигурации с сервера нужно выбрать строку с клиентом, для которого требуется изменение конфигурации, кликнуть левой кнопкой мыши по любой ячейке из строки и выбрать `Edit config` из контекстного меню. Конфигурация по умолчанию является полностью работоспособной и не требует изменений для быстрого старта клиента. -Для централизованной загрузки конфигурации на все коптеры нужно использовать пункт меню `Send configurations` на [сервере](server.md#раздел-server). Допускается загрузка неполного файла параметров конфигурации, с отсутствующими разделами или параметрами относительно конфигурации по умолчанию. +Для централизованной загрузки конфигурации на все коптеры нужно использовать пункт меню `Send configurations` на [сервере](server.md#раздел-server). Допускается загрузка неполного файла параметров конфигурации, с отсутствующими разделами или параметрами относительно конфигурации по умолчанию. Также доступна опция загрузки конфигурации конкретного клиента на выделенные коптеры: для этого нужно с сервера выбрать строку с клиентом, с которого требуется скопировать конфигурацию на выделенные клиенты, кликнуть левой кнопкой мыши по любой ячейке из строки и выбрать `Copy config to selected` из контекстного меню. + +### Описание параметров #### Раздел SERVER В этом разделе задаются параметры сетевого взаимодействия клиента с сервером. Доступны следующие параметры: * `port` - TCP порт, на который будут приниматься входящие соединения от сервера. При использовании настройки [use_broadcast](server.md#раздел-broadcast) на сервере, данный порт будет сконфигурирован у клиента автоматически. *Рекомендуется изменить значение по умолчанию в целях безопасности* (любое пятизначное и более число, если другое ПО не использует выбранный порт). -* `broadcast_port` - UDP порт, на который по широковещательному каналу сервер передаёт свои настройки. С помощью данного механизма возможно автоматическое подключение клиента к серверу. * `host` - IP адрес сервера. * `buffer_size` - размер буфера при приёме и передаче данных. *Не рекомендуется изменять. Рекомендуется использовать единое значение у сервера и клиентов.* -#### Раздел VISUAL_POSE_WATCHDOG +#### Раздел BROADCAST -В данном разделе настраивается программа экстренной защиты коптера от потери позиции или столкновения с объектом. +В этом разделе включается/выключается механизм получения broadcast пакетов по UDP с сервера. -* `timeout` - время срабатывания экстренной защиты после потери визуальной позиции, в секундах. -* `pos_delta_max` - максимальная разница между текущим положением и точкой, в которой сейчас должен находиться коптер, в метрах. Требуется для проверки на столкновение коптера с объектом. Если расстояние между текущим положением коптера и положением, в котором он должен сейчас находиться, больше этого числа (в метрах), срабатывает экстренная защита. -* `action` - действие при срабатывании экстренной защиты. Доступные варианты: `land` - посадка коптера в режиме полётного контроллера AUTO.LAND, `emergency_land` - посадка коптера с постепенным уменьшением мощности моторов, `disarm`- выключение моторов. **Внимание!** Не рекомендуется использовать режим AUTO.LAND с выключенным барометром - при потере источника высоты в полёте, например показаний лазера или визуальной позиции, режим AUTO.LAND не гарантирует посадку коптера, так как ориентируется на показания высоты. Для посадки коптера при его позиционировании с использованием визуальной позиции или лазера и возможности потери данных с этих систем рекомендуется использовать режим `emergency_land`. -* `emergency_land_thrust` - начальная мощность, подаваемая на моторы в случае выбора действия `emergency_land` при срабатывании экстренной защиты. Безразмерная величина, от 0 (отсутствие мошности) до 1 (полная мощность). Для гарантированной посадки рекомендуется устанавливать в значение, меньшее по величине на 5-10 процентов, чем газ висения (параметр `MPC_THR_HOVER` в px4). -* `emergency_land_decrease_thrust_after` - время, через которое мощность на моторах плавно начинает уменьшаться в случае выбора действия `emergency_land` при срабатывании экстренной защиты, в секундах. -* `timeout_to_disarm` - время, через которое коптер безусловно выключает моторы после срабатывания экстренной защиты, в секундах. +* `use` - логическое значение, определяет, использовать или не использовать механизм получения broadcast пакетов по UDP с сервера. +* `port` - порт UDP для приёма broadcast сообщений. #### Раздел TELEMETRY @@ -107,9 +46,28 @@ port = 123 * `transmit` - логическое значение, определяет, нужно ли передавать данные на сервер. * `frequency` - частота передачи данных на сервер, целочисленное значение, количество раз в секунду. -* `log_cpu_and_memory` - логическое значение, определяет, будет ли записываться в лог сервиса клиента clever-show состояние процессора и памяти. +* `log_resources` - логическое значение, определяет, будет ли записываться в лог сервиса клиента clever-show состояние бортового компьютера Raspberry Pi: загрузка процессора и оперативной памяти, температура процессора, состояние температуры, состояние системы питания. -#### Раздел COPTERS +#### Раздел POSITION WATCHDOG + +В данном разделе настраивается программа экстренной защиты коптера от потери позиции или столкновения с объектом. + +* `enabled` - логическое значение, определяет, использовать или нет экстренную защиту при потере визуальной позиции или столкновении с объектом. +* `log_state` - логическое значение, определяет, будет ли записываться в лог сервиса состояние коптера: `armed: {} | mode: {} | vis_dt: {:.2f} | pos_delta: {:.2f} | pos_dt: {:.2f} | range: {:.2f} | watchdog_action: {}`. +* `action` - действие при срабатывании экстренной защиты. Доступные варианты: `land` - посадка коптера в режиме полётного контроллера AUTO.LAND, `emergency_land` - посадка коптера с постепенным уменьшением мощности моторов, `disarm`- выключение моторов. **Внимание!** Не рекомендуется использовать режим AUTO.LAND с выключенным барометром - при потере источника высоты в полёте, например показаний лазера или визуальной позиции, режим AUTO.LAND не гарантирует посадку коптера, так как ориентируется на показания высоты. Для посадки коптера при его позиционировании с использованием визуальной позиции или лазера и возможности потери данных с этих систем рекомендуется использовать режим `emergency_land`. +* `vision_pose_delay_after_arm` - время после взлёта коптера в секундах, которое требуется для получения визуальной позиции. В течение этого времени после взлёта защита по потере визуальной позиции не будет срабатывать. Этот параметр полезен при использовании модуля экстренной защиты совместно с системой позиционирования по aruco маркерам, расположенным на полу: при взлёте коптер какое-то время не имеет визуальной позиции. +* `vision_pose_timeout` - время в секундах после потери визуальной позиции, через которое срабатывает экстренная защита. +* `position_delta_max` - максимальная разница между текущим положением и точкой, в которой сейчас должен находиться коптер, в метрах. Требуется для проверки на столкновение коптера с объектом. Если расстояние между текущим положением коптера и положением, в котором он должен сейчас находиться, больше этого числа (в метрах), срабатывает экстренная защита. +* `disarm_timeout` - время, через которое коптер безусловно выключает моторы после срабатывания экстренной защиты, в секундах. + +#### Раздел EMERGENCY LAND + +Настройки параметров экстренной посадки при действии `emergency_land` экстренной защиты или при вызове ROS сервиса `/emergency_land`. + +* `thrust` - начальная мощность, подаваемая на моторы в случае выбора действия `emergency_land` при срабатывании экстренной защиты. Безразмерная величина, от 0 (отсутствие мошности) до 1 (полная мощность). Для гарантированной посадки рекомендуется устанавливать в значение, меньшее по величине на 5-10 процентов, чем газ висения (параметр `MPC_THR_HOVER` в px4). **Внимание!** Неправильная настройка этого параметра может привести к взлёту коптера вверх вместо посадки! +* `decrease_thrust_after` - время, через которое мощность на моторах плавно начинает уменьшаться в случае выбора действия `emergency_land` при срабатывании экстренной защиты, в секундах. + +#### Раздел COPTER В данном разделе находятся настройки, влияющие на процесс полёта коптера. @@ -119,23 +77,17 @@ port = 123 * `safe_takeoff` - логическое значение, определяет, нужно ли производить посадку в безопасном режиме. * `reach_first_point_time` - максимальное время полёта к первой точке анимации, в секундах. * `land_time` - время зависания в конечной точке анимации перед посадкой, в секундах. -* `x0_common` - смещение по оси x, общее для всех коптеров, в метрах. -* `y0_common` - смещение по оси y, общее для всех коптеров, в метрах. -* `z0_common` - смещение по оси z, общее для всех коптеров, в метрах. -* `yaw` - поворот коптера при полёте по точкам, в градусах. Если значение `nan` - коптер сохраняет изначальную ориентацию в полёте. * `land_timeout` - время таймаута посадки, после которого происходит выключение моторов коптера, в секундах. +* `common_offset` - смещение координат относительно текущей системы, общее для всех коптеров, в метрах. Список из 3 величин (x, y, z): каждая величина задаёт смещение по соответствующей оси. #### Раздел FLOOR_FRAME Данный раздел описывает смещение системы координат с названием `floor` и используется только при указании параметра `frame_id` как `floor` в разделе [COPTERS](#раздел-copters). +* `enabled` - логическое значение, определяет, нужно ли публиковать фрейм `floor` * `parent` - название опорной системы координат, относительно которой будет располагаться система координат `floor`. -* `x` - смещение системы координат `floor` по оси x относительно системы координат `parent`, в метрах. -* `y` - смещение системы координат `floor` по оси y относительно системы координат `parent`, в метрах. -* `z` - смещение системы координат `floor` по оси z относительно системы координат `parent`, в метрах. -* `roll` - поворот системы координат `floor` вокруг оси x относительно системы координат `parent`, в градусах. -* `pitch` - поворот системы координат `floor` вокруг оси y относительно системы координат `parent`, в градусах. -* `yaw` - поворот системы координат `floor` вокруг оси z относительно системы координат `parent`, в градусах. +* `translation` - смещение системы координат `floor` по осям (x, y, z) относительно системы координат `parent`, в метрах. +* `rotation` - поворот системы координат `floor` на углы (roll, pitch, yaw) вокруг осей (x, y, z) относительно системы координат `parent`, в градусах. **Внимание!** Повороты `roll`, `pitch`, `yaw` производятся последовательно в указанном порядке. @@ -143,24 +95,33 @@ port = 123 В данном разделе настраивается обработка анимации. -* `takeoff_animation_check` - логическое значение, определяет, будет ли производиться автоматическая обработка старта анимации. **Если значение True**, при загрузке анимации проверяется взлёт коптеров. Если в файле анимации коптер взлетает с земли, при старте анимации будет применена *логика немедленного воспроизведения*: коптер сразу начинает следовать точкам, указанным в анимации. Если в файле анимации коптер начинает полёт в воздухе, при старте анимации будет применена *логика полёта к первой точке*: коптер в начале взлетает на высоту `takeoff_height` за время `takeoff_time`, затем перемещается к первой точке за время `reach_first_point_time`, и затем начинает следовать точкам, указанным в анимации. **Если значение False**, при загрузке анимации не проверяется взлёт коптеров, а при старте анимации действует *логика полёта к первой точке*. -* `land_animation_check` - логическое значение, определяет, будет ли производиться автоматическая обработка завершения анимации. **Если значение True**, при загрузке анимации проверяется посадка коптеров. Если в файле анимации коптер садится на землю и стоит до завершения анимации, проверка удалит все точки в анимации после начала посадки коптера. Таким образом, коптер в конце анимации зависнет над точкой посадки на время `land_time`, сядет автоматически и выключит моторы. **Если значение False**, при загрузке анимации не проверяется посадка коптеров и точкой посадки считается последняя точка в анимации. Например, если анимация посадки нарисована полностью и коптер стоит после посадки на земле некоторое время, а значение данного параметра **False**, всё это время у коптера будут включены моторы и он будет пытаться удержать указанную позицию посадки вплоть до завершении файла анимации, затем через время `land_time` перейдёт в редим посадки. +* `takeoff_detection` - логическое значение, определяет, будет ли производиться автоматическая обработка старта анимации. **Если значение True**, при загрузке анимации проверяется взлёт коптеров. Если в файле анимации коптер взлетает с земли, при старте анимации будет применена *логика немедленного воспроизведения*: коптер сразу начинает следовать точкам, указанным в анимации. Если в файле анимации коптер начинает полёт в воздухе, при старте анимации будет применена *логика полёта к первой точке*: коптер в начале взлетает на высоту `takeoff_height` за время `takeoff_time`, затем перемещается к первой точке за время `reach_first_point_time`, и затем начинает следовать точкам, указанным в анимации. **Если значение False**, при загрузке анимации не проверяется взлёт коптеров, а при старте анимации действует *логика полёта к первой точке*. +* `land_detection` - логическое значение, определяет, будет ли производиться автоматическая обработка завершения анимации. **Если значение True**, при загрузке анимации проверяется посадка коптеров. Если в файле анимации коптер садится на землю и стоит до завершения анимации, проверка удалит все точки в анимации после начала посадки коптера. Таким образом, коптер в конце анимации зависнет над точкой посадки на время `land_time`, сядет автоматически и выключит моторы. **Если значение False**, при загрузке анимации не проверяется посадка коптеров и точкой посадки считается последняя точка в анимации. Например, если анимация посадки нарисована полностью и коптер стоит после посадки на земле некоторое время, а значение данного параметра **False**, всё это время у коптера будут включены моторы и он будет пытаться удержать указанную позицию посадки вплоть до завершении файла анимации, затем через время `land_time` перейдёт в редим посадки. * `frame_delay` - время воспроизведения одного кадра в секундах. -* `x_ratio` - масштаб анимации по оси x -* `y_ratio` - масштаб анимации по оси y -* `z_ratio` - масштаб анимации по оси z +* `ratio` - масштаб анимации (ratio_x, ratio_y, ratio_z) по осям (x, y, z) +* `yaw` - поворот коптера при полёте по точкам, в градусах. Если значение `nan` - коптер сохраняет изначальную ориентацию в полёте. Если значение `animation` - коптер берёт поворот по yaw из файла с анимацией. + +#### Раздел LED + +Настройки адресуемой светодиодной ленты на коптере + +* `use` - логическое значение, определяет, используется или нет светодиодная лента. +* `pin` - номер пина GPIO на Raspberry, к которому подключается лента +* `count` - количество задействованных светодиодов в ленте #### Раздел PRIVATE В данном разделе находятся параметры, специфичные для конкретного коптера. * `id` - имя коптера, отображаемое в таблице. Если значение `/hostname` - имя определяется из файла `/etc/hostname`. -* `restart_dhcpcd` - логический параметр, определяет, требуется ли перезагрузка коптера при переименовании его `id` удалённо с сервера. -* `use_leds` - логический параметр, определяет, использует ли коптер светодиодную ленту. -* `led_pin` - номер пина GPIO на Raspberry Pi, к которому подключена светодиодная лента. -* `x0` - смещение по оси x, только для данного коптера. -* `y0` - смещение по оси y, только для данного коптера. -* `z0` - смещение по оси z, только для данного коптера. +* `offset` - смещение в метрах по осям (x, y, z) относительно текущей системы координат, только для данного коптера. + +#### Раздел SYSTEM + +Системные настройки служебных команд клиента + +* `change_hostname` - логический параметр, определяет, требуется ли смена hostname при переименовании `id` коптера. +* `restart_after_rename` - логический параметр, определяет, требуется ли перезагрузка коптера при переименовании его `id` удалённо с сервера. #### Раздел NTP From 928a1d8184e200841200435d112e633bd1a1310b Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 24 Mar 2020 17:27:33 +0300 Subject: [PATCH 190/210] Updated server screenshot --- docs/assets/server-gui.png | Bin 132844 -> 138641 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/server-gui.png b/docs/assets/server-gui.png index fa75efaac7b00603fd1d37862a4eace91348cede..b8bcd54863f55c6087a518ccee82a2ea54de847a 100644 GIT binary patch literal 138641 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 literal 132844 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 Date: Tue, 24 Mar 2020 18:02:16 +0300 Subject: [PATCH 191/210] Prevent sending private section via drag-n-drop --- Server/copter_table_models.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index ae4eb27..72bad16 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -559,8 +559,11 @@ class CopterDataModel(QtCore.QAbstractTableModel): return False config = ConfigManager() config.load_only_config(path) + config_dict = config.full_dict(include_defaults=False) + config_dict.pop("PRIVATE", None) + self.data_contents[row].client.send_message("config", kwargs={ - "config": config.full_dict(include_defaults=False), "mode": "rewrite"}) + "config": config_dict, "mode": "rewrite"}) return False # Thread-safe wrappers From d012fc69b1d8d3a64cdbd3fe826057ae41388305 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 24 Mar 2020 18:30:58 +0300 Subject: [PATCH 192/210] Updated table docs --- docs/ru/server.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/ru/server.md b/docs/ru/server.md index b9b011a..311ba60 100644 --- a/docs/ru/server.md +++ b/docs/ru/server.md @@ -30,13 +30,17 @@ #### Столбцы таблицы * `copter ID` - имя клиента. Может быть сконфигурирован на стороне клиента. Отображается сразу при подключении клиента. Рядом с каждым ID коптера расположен чекбокс - коптеры, чей ID отмечен чекбоксом положительно (галочка), считаются *выбранными*. Ячейки в этом столбце всегда проходят проверку. -* `version` - хеш-код текущей git версии клиента. Ячейки в этом столбце всегда проходят проверку. + * При двойном нажатии на это поле можно ввести новый `copter ID` клиента и переименовать его. В качестве имени допустимы сочетания латинских букв, цифр и тире (A-Z, a-z, 0-9, '-') длинной не более 63 символов. Тире не может являться первым символом. +* `version` - хеш-код текущей git версии клиента. Ячейки в этом столбце проверяются при включенном (значение `true`) параметре [check_git_version](#раздел-checks), задаваемом в настройках сервера. Ячейка в данном столбце проходит проверку если хеш-код git версии данного клиента и сервера совпадают (если сервер не расположен в git-репозитории, то проверка проходится автоматически). +* `configuration` - заданная пользователем версия конфигурации клиента. Ячейки в этом столбце всегда проходят проверку. + * Ячейки этого столбца поддерживают *drag-and-drop*. При перетаскивании ячейки в любое стороннее приложение, поддерживающее файлы (к примеру, "Проводник"), файл конфигурации клиента будет скопирован в указанное место. При перетаскивании ячейки на другую ячейку файл конфигурации будет скопирован с одного на другой. При перетаскивании файла на ячейку он будет записан на клиент в качестве конфигурации (при условии валидации). При передаче конфигурации на клиент секция `PRIVATE` не будет отправляться. * `animation ID` - внутреннее название файла анимации, подгруженного клиентом. Ячейка в данном столбце не проходит проверку, если анимация отсутствует (значение `No animation`). В остальных случаях, если ячейка не пустая, она проходит проверку. **Внимание!** Проверьте соответствие названий файлов анимаций у коптеров перед запуском. * `battery` - значение напряжения на аккумуляторе коптера в вольтах и заряд в процентах по данным полётного контроллера. Ячейка в данном столбце проходит проверку, если значение заряда батареи выше значения [battery_percentage_min](#раздел-checks), задаваемого в настройках сервера. В остальных случаях, если ячейка не пустая, она не проходит проверку. * `system` - состояние полётного контроллера. Ячейка в данном столбце проходит проверку, если её значение `STANDBY`. В остальных случаях, если ячейка не пустая, она не проходит проверку. * `sensors` - состояние калибровки компаса, акселлерометра и гироскопа полётного контроллера. Ячейка в данном столбце проходит проверку, если её значение `OK`. В остальных случаях, если ячейка не пустая, она не проходит проверку. * `mode` - режим полётного контроллера. Ячейка в данном столбце не проходит проверку, если её значение `NO_FCU` или содержит `CMODE`. В остальных случаях, если ячейка не пустая, она проходит проверку. * `checks` - состояние самодиагностики коптера. Ячейка в данном столбце проходит проверку, если её значение `OK`. В остальных случаях, если ячейка не пустая, она не проходит проверку. + * При двойном клике на ячейку при наличии ошибок будет показано диалоговое окно с полной детализацией всех ошибок. * `current x y z yaw frame_id` - текущее положение коптера с указанием названия системы координат. Ячейка в данном столбце не проходит проверку, если её значение `NO_POS` или содержит `nan`. В остальных случаях, если ячейка не пустая, она проходит проверку. * `start x y z` - стартовое положение коптера для воспроизведения анимации. Ячейка в данном столбце не проходит проверку, если её значение `NO_POS` или разница между текущим и стартовым положением коптера больше значения [start_pos_delta_max](#раздел-checks). В остальных случаях, если ячейка не пустая, она проходит проверку. * `dt` - разница между временем на сервере и клиенте в секундах, включая сетевую задержку. Ячейка в данном столбце проходит проверку, если её значение меньше значения [time_delta_max](#раздел-checks), задаваемого в настройках сервера. В остальных случаях, если ячейка не пустая, она не проходит проверку. При слишком больших значениях сигнализирует об отсутствии синхронизации времени между коптером и клиентом. From 167cc59d387b01f4f16cc7fb18347e09bea1db56 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 24 Mar 2020 18:37:14 +0300 Subject: [PATCH 193/210] Updated emergency land screenshot --- docs/assets/server-led-emergency-land.png | Bin 16961 -> 14735 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/assets/server-led-emergency-land.png b/docs/assets/server-led-emergency-land.png index 8105c1e0cca0f58f2e5c9c169c2b10c2d3d90c0a..fc253f17fa728a63ab45b170c7c8cb3eb3e8445a 100644 GIT binary patch literal 14735 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 g(3uO~aMvKM!|r~bb}n-Px# literal 16961 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 X`P}d-aV_D63q(qCYO*D-%|HAP06-d! From 2c48d1cddc8615157b469a17e398efa6210b963d Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Thu, 2 Apr 2020 15:34:53 +0300 Subject: [PATCH 194/210] Updated server config docs --- docs/ru/server.md | 102 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 77 insertions(+), 25 deletions(-) diff --git a/docs/ru/server.md b/docs/ru/server.md index 311ba60..3d6dc7b 100644 --- a/docs/ru/server.md +++ b/docs/ru/server.md @@ -41,7 +41,7 @@ * `mode` - режим полётного контроллера. Ячейка в данном столбце не проходит проверку, если её значение `NO_FCU` или содержит `CMODE`. В остальных случаях, если ячейка не пустая, она проходит проверку. * `checks` - состояние самодиагностики коптера. Ячейка в данном столбце проходит проверку, если её значение `OK`. В остальных случаях, если ячейка не пустая, она не проходит проверку. * При двойном клике на ячейку при наличии ошибок будет показано диалоговое окно с полной детализацией всех ошибок. -* `current x y z yaw frame_id` - текущее положение коптера с указанием названия системы координат. Ячейка в данном столбце не проходит проверку, если её значение `NO_POS` или содержит `nan`. В остальных случаях, если ячейка не пустая, она проходит проверку. +* `current x y z yaw frame_id` - текущее положение коптера с указанием названия системы координат. Ячейка автоматически проходит проверку если у параметра [check_current_position ](#раздел-checks) установлено значение `false`. Иначе, ячейка в данном столбце не проходит проверку, если её значение `NO_POS` или содержит `nan`. В остальных случаях, если ячейка не пустая, она проходит проверку. * `start x y z` - стартовое положение коптера для воспроизведения анимации. Ячейка в данном столбце не проходит проверку, если её значение `NO_POS` или разница между текущим и стартовым положением коптера больше значения [start_pos_delta_max](#раздел-checks). В остальных случаях, если ячейка не пустая, она проходит проверку. * `dt` - разница между временем на сервере и клиенте в секундах, включая сетевую задержку. Ячейка в данном столбце проходит проверку, если её значение меньше значения [time_delta_max](#раздел-checks), задаваемого в настройках сервера. В остальных случаях, если ячейка не пустая, она не проходит проверку. При слишком больших значениях сигнализирует об отсутствии синхронизации времени между коптером и клиентом. @@ -143,31 +143,68 @@ ### Файл конфигурации -Конфигурация сервера задаётся в файле [server_config.ini](../../Server/server_config.ini), имеющем следующий вид по умолчанию: +Конфигурация сервера задаётся в файле [server.ini](../../Server/config/server.ini), имеющем следующий вид по умолчанию: ```ini -[SERVER] -port = 25000 -buffer_size = 1024 -remove_disconnected = False +# This is generated config with default values +# Modify to configure +config_name = server +config_version = 1.0 + +[SERVER] + port = 25000 + buffer_size = 1024 + +[TABLE] + # True -> clients are removed on disconnection + # False -> disconnected clients indicated + remove_disconnected = False + [[PRESETS]] + current = DEFAULT + [[[DEFAULT]]] + copter_id = True, 100 + git_version = True, 75 + config_version = True, 140 + animation_id = True, 100 + battery = True, 100 + fcu_status = True, 100 + calibration_status = True, 65 + mode = True, 100 + selfcheck = True, 65 + current_position = True, 250 + start_position = True, 150 + last_task = True, 250 + time_delta = True, 241 [CHECKS] -battery_percentage_min = 50.0 -start_pos_delta_max = 1.0 -time_delta_max = 1.0 - -[BROADCAST] -use_broadcast = True -broadcast_port = 8181 -broadcast_delay = 5.0 - -[NTP] -use_ntp = False -host = ntp1.stratum2.ru -port = 123 + check_git_version = True + check_current_position = True + # in percents; set 0 to disable this check + battery_min = 50.0 + # in meters; set 0 to disable this check + start_pos_delta_max = 1.0 + # in seconds + time_delta_max = 1.0 + +[BROADCAST] + send = True + listen = True + port = 8181 + # delay for message sending in seconds + delay = 5.0 + +[NTP] + use = False + host = ntp1.stratum2.ru + port = 123 ``` -Конфигурация по умолчанию является полностью работоспособной и не требует изменений для быстрого начала работы системы. +Данный файл конфигурации автоматически генерируется при первом запуске сервера, если отсутствует файл конфигурации. Пользовательский файл может содержать неполный набор параметров - в этом случае будут использоваться значения по умолчанию для отсутствующих параметров. Конфигурация по умолчанию является полностью работоспособной и не требует изменений для быстрого начала работы системы. + +#### Корневой раздел + +- `config_name` - Произвольная строка, название файла конфигурации для удобства хранения и быстрого отличия одного файла конфигурации от другого. +- `config_version` - Произвольное дробное число, версия файла конфигурации для удобства хранения и быстрого отличия одного файла конфигурации от другого. #### Раздел SERVER @@ -175,12 +212,26 @@ port = 123 * `port` - TCP порт, на который будут приниматься входящие соединения от клиентов. При использовании broadcast данный порт будет сконфигурирован у клиента автоматически. *Рекомендуется изменить значение по умолчанию в целях безопасности* (любое пятизначное и более число, если другое ПО не использует выбранный порт). * `buffer_size` - размер буфера при приёме и передаче данных. *Не рекомендуется изменять. Рекомендуется использовать единое значение у сервера и клиентов.* + +#### Раздел TABLE + * `remove_disconnected` - Определяет поведение при разрыве связи с клиентом. При значении `True` вся информация о клиенте *будет удалена* как из внутренней памяти, так и *из таблицы*. *Это может привести к 'скачкам' таблицы при отключении клиентов.* При значении `False` отключённые клиенты *не будут* удалены из таблицы, но будут отображены с подсвечиванием ячейки в столбце `copter ID` красным цветом. Все данные будут сохранены. При переподключении клиента, он будет ассоциирован с той же строкой таблицы, а ячейка со значением `copter ID` вновь станет зелёного цвета. +##### Подраздел PRESETS + +Не рекомендуется изменять данный раздел вручную - для редактирования данных параметров можно взаимодействовать с таблицей или используя встроенный диалог конфигурации таблицы. + +* `current` - Название текущего выбранного набора настроек столбцов таблицы +* `<название_набора>` + * `<название_столбца>` - значение представляет собой список (через ",") из булевого значения (отображается ли столбец в таблице) и целого числа больше 0 (ширину столбца в пикселах) + #### Раздел CHECKS В этом разделе задаются параметры проверок коптера, которые регулируются на стороне сервера. Доступны следующие параметры: +- `check_git_version` - Будет ли производиться проверка соответствия git-версий клиента и сервера для индикации в ячейках столбца `version` +- `check_current_position` - Будет ли производиться проверка корректности текущих координат коптера для индикации в ячейках столбца `current x y z yaw frame_id`. + * `battery_percentage_min` - Минимальный заряд батарии коптера, допустимый для взлёта. Указывается *в процентах* (дробное значение от 0 до 100). Значение меньше указанного будет отмечено в столбце `battery` как неудовлетворительное. * `start_pos_delta_max` - Максимальное расстояние от текущего положения коптера до его точки взлёта в файле анимации, допустимое для взлёта. Указывается *в метрах* (дробное значение от 0 до 'inf'). Значение больше указанного будет отмечено в столбце `start x y z` как неудовлетворительное. Допустимо использование строки 'inf' для любого допустимого расстояния. * `time_delta_max` - Максимальная разница (абсолютное значение) между временем сервера и клиента (включая сетевую задержку), допустимая для взлёта. Указывается *в секундах* (дробное значение от 0 до 'inf'). Значение больше указанного будет отмечено в столбце `dt` как неудовлетворительное. @@ -189,15 +240,16 @@ port = 123 Сервер может использовать UDP broadcast, чтобы передавать клиентам актуальную информацию о конфигурации сервера. Таким образом становится возможным автоматическое подключение клиентов к серверу без необходимости дополнительной ручной конфигурации. В данном разделе задаются параметры этого механизма: -* `use_broadcast` - будут ли использованы broadcast'ы для передачи данных (при значении `False` broadcast'ы НЕ будут отправляться). Используйте `False` в случае повышенных требований безопасности, перегруженности сети или невозможности передачи по широковещательному каналу (из-за конфигурации брандмауэра или сети) -* `broadcast_port` - UDP порт, по которому будет осуществляться отправка сообщений. *Рекомендуется изменить значение по умолчанию в целях безопасности.* **Внимание!** При изменении этого параметра клиенты НЕ смогут принимать сообщения автоконфигурации до изменения (вручную) соответствующего параметра в конфигурации клиента на равное значение. -* `broadcast_delay` - периодичность (в секундах, целочисленное значение), с которой будет происходить отправка broadcast сообщений. Увеличьте задержку для уменьшения нагрузки на сеть. Уменьшите задержку для уменьшения времени отклика и подключения при первом запуске клиентов. +* `send` - будут ли использованы broadcast'ы для передачи данных (при значении `False` broadcast'ы НЕ будут отправляться). Используйте `False` в случае повышенных требований безопасности, перегруженности сети или невозможности передачи по широковещательному каналу (из-за конфигурации брандмауэра или сети) +* `listen` - будет ли сервер прослушивать порт бродкастов для автоматического выключения во избежание наличия нескольких серверов в одной сети. +* `port` - UDP порт, по которому будет осуществляться отправка сообщений. *Рекомендуется изменить значение по умолчанию в целях безопасности.* **Внимание!** При изменении этого параметра клиенты НЕ смогут принимать сообщения автоконфигурации до изменения (вручную) соответствующего параметра в конфигурации клиента на равное значение. +* `delay` - периодичность (в секундах, дробное значение), с которой будет происходить отправка broadcast сообщений. Увеличьте задержку для уменьшения нагрузки на сеть. Уменьшите задержку для уменьшения времени отклика и подключения при первом запуске клиентов. #### Раздел NTP Помимо синхронизации времени (с миллисекундной точностью) с помощью пакета chrony, предоставляется альтернатива - возможность использования внешних (при наличии соединения локальной сети с интернетом) или внутрисетевых NTP-серверов. **Внимание!** Для корректной работы системы, **и сервер, и клиенты** должны использовать единый способ синхронизации времени (набор параметров в этом разделе). Данный раздел полностью унифицирован и для сервера, и для клиентов. -* `use_ntp` - определяет, будет ли использоваться синхронизация времени с помощью NTP. (при значении `False` будет использовано локальное время ОС (синхронизируется автоматически при использовании chrony). *Рекомендуется использование chrony, а не NTP* +* `use` - определяет, будет ли использоваться синхронизация времени с помощью NTP. (при значении `False` будет использовано локальное время ОС (синхронизируется автоматически при использовании chrony). *Рекомендуется использование chrony, а не NTP* * `host` - имя хоста или IP адрес NTP сервера (локального или удаленного) * `port` - порт, используемый NTP сервером @@ -213,6 +265,6 @@ port = 123 При нажатии на кнопку `Visual land` все коптеры делятся на 2 равные группы по порядку расположения в таблице. Первая половина коптеров зажигает светодиодную ленту зелёным цветом, вторая - красным. При нажатии на зелёную или красную кнопку происходит выбор группы, соответствующей цвету нажатой кнопки. Коптеры выбранного цвета снова делятся на две половины и каждая половина зажигает светодиодную ленту зелёным и красным цветом соответственно. Остальные коптеры выключают светодиодную ленту. -Нажимая на кнопки, соответствующие цвету группы, в которой находится неисправный коптер, можно определить его номер и выполнить экстренную посадку за логорифмическое количество шагов от количества коптеров, т.е. гораздо быстрее, чем перебирая коптеры по одному. +Нажимая на кнопки, соответствующие цвету группы, в которой находится неисправный коптер, можно определить его номер и выполнить экстренную посадку за логарифмическое количество шагов от количества коптеров, т.е. гораздо быстрее, чем перебирая коптеры по одному. На любом шаге можно произвести посадку или выключение моторов всех коптеров, на которых включена светодиодная лента, нажав кнопку `Land` или `Disarm`. From 0a6b771ab4ae069034fb6806bafc98d44fa1f741 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 3 Apr 2020 12:53:48 +0300 Subject: [PATCH 195/210] Update server's configspec comments --- Server/config/spec/configspec_server.ini | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index 8d1a21a..13e6199 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -31,10 +31,10 @@ config_version = float(default='1.0') [CHECKS] check_git_version = boolean(default=True) check_current_position = boolean(default=True) - # in meters - battery_min = float(default=50.0, min=0, max=100) # Set 0 to disable this check - # in meters - start_pos_delta_max = float(default=1.0, min=0) # Set 0 to disable this check + # in percents; set 0 to disable this check + battery_min = float(default=50.0, min=0, max=100) + # in meters; set 0 to disable this check + start_pos_delta_max = float(default=1.0, min=0) # in seconds time_delta_max = float(default=1.0, min=0) From 6f7cf65c8d03691caa9b93443b7995cbac4375ee Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 3 Apr 2020 12:54:23 +0300 Subject: [PATCH 196/210] Set z_spin disabled on default --- Server/server_qt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Server/server_qt.py b/Server/server_qt.py index 6c7e560..7c033ab 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -128,6 +128,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.pause_button.clicked.connect(self.pause_resume_selected) self.ui.z_checkbox.clicked.connect(self.ui.z_spin.setEnabled) + self.ui.z_spin.setEnabled(False) self.ui.land_all_button.clicked.connect(b_partial(Client.broadcast_message, "land")) self.ui.land_selected_button.clicked.connect(b_partial(self.send_to_selected, "land")) From 5c0d8802b9c825d4971d94bf96aeb9bdd539b640 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 3 Apr 2020 15:32:04 +0300 Subject: [PATCH 197/210] Updated server menu docs and images --- docs/assets/server-drone-restart.png | Bin 0 -> 7115 bytes docs/assets/server-drone-send.png | Bin 0 -> 8843 bytes docs/assets/server-drone.png | Bin 15438 -> 0 bytes docs/assets/server-music.png | Bin 3482 -> 3684 bytes docs/assets/server-server.png | Bin 16430 -> 0 bytes docs/assets/server-table.png | Bin 0 -> 3364 bytes docs/ru/server.md | 104 +++++++++++++++++++-------- 7 files changed, 73 insertions(+), 31 deletions(-) create mode 100644 docs/assets/server-drone-restart.png create mode 100644 docs/assets/server-drone-send.png delete mode 100644 docs/assets/server-drone.png delete mode 100644 docs/assets/server-server.png create mode 100644 docs/assets/server-table.png diff --git a/docs/assets/server-drone-restart.png b/docs/assets/server-drone-restart.png new file mode 100644 index 0000000000000000000000000000000000000000..9431851d84e3ea1e1e287b5ddb8eae74fe20f600 GIT binary patch literal 7115 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 literal 0 HcmV?d00001 diff --git a/docs/assets/server-drone-send.png b/docs/assets/server-drone-send.png new file mode 100644 index 0000000000000000000000000000000000000000..53dc779b58255b8ecc3324c7702d2363e89754fd GIT binary patch literal 8843 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 diff --git a/docs/assets/server-table.png b/docs/assets/server-table.png new file mode 100644 index 0000000000000000000000000000000000000000..b565c125b9d0e5fb5b04782e211f5d4fe6395b7a GIT binary patch literal 3364 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 literal 0 HcmV?d00001 diff --git a/docs/ru/server.md b/docs/ru/server.md index 3d6dc7b..3829c40 100644 --- a/docs/ru/server.md +++ b/docs/ru/server.md @@ -7,6 +7,8 @@ * [Настройка](#настройка-сервера) * [Дополнительные операции](#дополнительные-операции) +[TOC] + ## Интерфейс сервера Сервер имеет визуальный графический интерфейс для удобства взаимодействия. @@ -47,51 +49,83 @@ ### Меню -#### Раздел Server +#### Раздел Selected drones -![Скриншот раздела Server](../assets/server-server.png) +![Скриншот раздела Selected drones - Send](../assets/server-drone-send.png) -Данный раздел содержит несколько утилит по отправке различных данных на *выбранные* клиенты. **Внимание!** Не используйте данные команды во время полёта коптеров! +Данный раздел содержит несколько утилит по отправке различных данных и команд на *выбранные* клиенты. **Внимание!** Не используйте данные команды во время полёта коптеров! -* `Send animations` - отправка файлов анимации, экспортированных аддоном к Blender, на выбранные коптеры. В диалоговом окне необходимо выбрать *папку*, содержащую файлы анимации. Каждый файл анимации будет отправлен на клиент с именем, соответствующим имени файла без расширения. -* `Send configurations` - отправка *единого* файла конфигурации клиента на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл конфигурации в установленном формате. Файл конфигурации может быть неполным, в таком случае будут перезаписаны лишь указанные в файле параметры. **Внимание!** Не рекомендуется использовать данное действие для массовой перезаписи `copter ID`, кроме значения `/hostname`. **Внимание!** НЕ отправляйте на клиенты файл конфигурации сервера. -* `Send launch files` - отправка launch-файлов конфигурации сервиса `clever`. В диалоговом окне необходимо выбрать *папку*, содержащую файлы конфигурации с сширением `.launch`. Все файлы с таким расширением будут отправлены *на каждый* из клиентов. **Внимание!** Существующие файлы конфигурации на коптерах будут перезаписаны, однако файлы, не отправленные сервером, не будут удалены или модифицированы. -* `Send aruco map` - отправка *единого* файла карты aruco маркеров на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл карты в установленном формате. Файл на клиенте будет перезаписан. После получения и записи файла клиент автоматически перезапустит сервис `clever`. Для возобновления работоспособности полётных функций и получения некоторых значений телеметрии *необходимо подождать* некоторое время до полного запуска сервиса. -* `Send camera calibrations` - отправка yaml-файлов калибровки камеры для сервиса `clever`. В диалоговом окне необходимо выбрать *папку*, содержащую файлы конфигурации с расширением `.yaml`. Каждый файл калибровки будет отправлен на клиент с именем (copter ID), соответствующим имени файла без расширения. **Внимание!** Существующий файл калибровки на коптере будет перезаписан. -* `Send FCU parameters` - отправка и запись *единого* файла конфигураций полётного контроллера (FCU) на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл параметров в установленном формате. Параметры на полётном контроллере будут перезаписаны. -* `Developer mode`: **Внимание!** Используйте данные действия с большой осторожностью. - * `Send any file` - отправка *одного* любого файла на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл. Далее, необходимо указать путь, по которому данный файл будет записан на клиенты (не включая имя файла). - * `Send any command` - отправка и выполнение любой команды терминала на все выбранные клиенты. В диалоговом окне необходимо ввести требуемую команду. Команды *могут* использовать `sudo`-права. -* `Select all drones` (`Ctrl+A`) - выделяет все коптеры в таблице. При следующем вызове команды, выделение всех коптеров будет отменено. +- ##### Подраздел Send -#### Раздел Drone + - `Animations` - отправка файлов анимации, экспортированных аддоном к Blender, на выбранные коптеры. В диалоговом окне необходимо выбрать *папку*, содержащую файлы анимации. Каждый файл анимации будет отправлен на клиент с именем, соответствующим имени файла без расширения. -![Скриншот раздела Drone](../assets/server-drone.png) + - `Configuration` - отправка *единого* файла конфигурации клиента на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл конфигурации в установленном формате. Файл конфигурации может быть неполным, в таком случае будут перезаписаны лишь указанные в файле параметры. **Внимание!** Не рекомендуется использовать данное действие для массовой перезаписи `copter ID`, кроме значения `/hostname`. **Внимание!** НЕ отправляйте на клиенты файл конфигурации сервера. + + - `Launch files` - отправка launch-файлов конфигурации сервиса `clever`. В диалоговом окне необходимо выбрать *папку*, содержащую файлы конфигурации с сширением `.launch`. Все файлы с таким расширением будут отправлены *на каждый* из клиентов. **Внимание!** Существующие файлы конфигурации на коптерах будут перезаписаны, однако файлы, не отправленные сервером, не будут удалены или модифицированы. + + - `Aruco map` - отправка *единого* файла карты aruco маркеров на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл карты в установленном формате. Файл на клиенте будет перезаписан. После получения и записи файла клиент автоматически перезапустит сервис `clever`. Для возобновления работоспособности полётных функций и получения некоторых значений телеметрии *необходимо подождать* некоторое время до полного запуска сервиса. + + - `Camera calibrations` - отправка yaml-файлов калибровки камеры для сервиса `clever`. В диалоговом окне необходимо выбрать *папку*, содержащую файлы конфигурации с расширением `.yaml`. Каждый файл калибровки будет отправлен на клиент с именем (copter ID), соответствующим имени файла без расширения. **Внимание!** Существующий файл калибровки на коптере будет перезаписан. + + - `FCU parameters` - отправка и запись *единого* файла конфигураций полётного контроллера (FCU) на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл параметров в установленном формате. Параметры на полётном контроллере будут перезаписаны. + + - `File` - отправка *одного* любого файла на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл. Далее, необходимо указать путь, по которому данный файл будет записан на клиенты (не включая имя файла). + + - `Command` - отправка и выполнение любой команды терминала на все выбранные клиенты. В диалоговом окне необходимо ввести требуемую команду. Команды *могут* использовать `sudo`-права. + + ------ + +- `Retrive file` - позволяет скачать любой файл с клиентов в выбранную директорию в файловой системе сервера. Если при скачивании был выбран более чем один клиент, то к имени файла от каждого клиента будет добавлен его ID. В диалоговом окне сначала введите путь к требуемому файлу на клиенте. Далее, в диалоговом окне необходимо указать путь, по которому данный файл будет записан на сервер. + + ------ + +- ##### Подраздел Restart + + ![Скриншот раздела Selected drones - Restart](../assets/server-drone-restart.png) + + * `chrony` - перезапускает сервис синхронизации времени `chrony` на выбранных клиентах. Используйте для ручной синхронизации в случаях, если время между сервером и клиентами не синхронизировано. + * `clever` - перезапускает сервис `clever` на выбранных клиентах. Для возобновления работоспособности полётных функций и получения некоторых значений телеметрии *необходимо подождать* некоторое время до полного запуска сервиса. + * `clever-show` - перезапускает сервис шоу коптеров `clever-show` на выбранных клиентах. Во время перезапуска клиенты будут отключены. + +- ------ + + `Set start X Y to current position` - устанавливает точку старта анимации у выбранных клиентов в значения текущей позиции по X Y. + +- `Reset start position` - устанавливает точку старта анимации у выбранных клиентов в значения `0.0`, `0.0`. * `Set Z offfset to ground` - устанавливает собственный отступ по Z каждого из выбранных клиентов в значение, равное текущему положению по координате Z. Можно применять для выравнимания общей высоты полёта коптеров. * `Reset Z offfset` - устанавливает собственный отступ по Z каждого из выбранных клиентов в значение `0`. -* `Restart chrony` - перезапускает сервис синхронизации времени `chrony` на выбранных клиентах. Используйте для ручной синхронизации в случаях, если время между сервером и клиентами не синхронихированно. -* `Remove from table` - удаляет выбранные коптеры из таблицы. **Внимание!** В случае, если клиент был подключен, будет произведено отключение. В случае если удалённый таким образом клиент исправно функционировал, он переподключится в кратчайшие сроки. * `Developer mode`: **Внимание!** Используйте данные действия с большой осторожностью. - * `Restart clever service` - перезапускает сервис `clever` на выбранных клиентах. Для возобновления работоспособности полётных функций и получения некоторых значений телеметрии *необходимо подождать* некоторое время до полного запуска сервиса. - * `Restart clever-show service` - перезапускает сервис шоу коптеров `clever-show` на выбранных клиентах. Во время перезапуска клиенты будут отключены. * `Update clever-show git` - обновляет папку репозитория `clever-show` на выбранных клиентах. Файлы конфигурации клиента *не будут* перезаписаны. **Внимание!** Для того, чтобы изменения вступили в силу, *необходимо* перезапустить сервис `clever-show`. - * `Reboot all` - полностью перезагружает полётный контроллер и компьютер на выбранных коптерах. Во время перезапуска клиенты будут отключены. +* `Reboot` - полностью перезагружает полётный контроллер и компьютер на выбранных коптерах. Во время перезапуска клиенты будут отключены. -#### Раздел Animation +#### Раздел Server -![Скриншот раздела Animation](../assets/server-animation.png) +- ##### Подраздел Music -* `Set start X Y to current position` - устанавливает точку старта анимации у выбранных клиентов в значения текущей позиции по X Y. -* `Reset start position` - устанавливает точку старта анимации у выбранных клиентов в значения `0.0`, `0.0`. + ![Скриншот раздела Server - music](../assets/server-music.png) -#### Раздел Music + - `Select music file` - загружает выбранный музыкальный файл для дальнейшего воспроизведения вручную или через определённое время после старта анимации. Поддерживаемые расширения: `.mp3` или `.wav`. + - `Play music` - воспроизводит загруженную музыку. + - `Stop music` - останавливает воспроизведение проигрываемой музыки. -![Скриншот раздела Music](../assets/server-music.png) +- ------ -* `Select music file` - загружает выбранный музыкальный файл для дальнейшего воспроизведения вручную или через определённое время после старта анимации. Поддерживаемые расширения: `.mp3` или `.wav`. -* `Play music` - воспроизводит загруженную музыку. -* `Stop music` - останавливает воспроизведение проигрываемой музыки. + `Edit server config` - открывает [встроенный редактор конфигурационных файлов](#config-editor) с текущей конфигурацией сервера для редактирования. Доступен чекбокс `Restart` - в случае, если он нажат, то при сохранении конфигурации сервер будет перезапущен. **Внимание!** Изменённые параметры конфигурации будут применены к серверу только после его перезапуска (ручного или автоматического). + +- `Edit any config` - открывает [встроенный редактор конфигурационных файлов](#config-editor) и позволяет выбрать для редактирования в файловой системе любой файл конфигурации c расширением `.ini` или же открыть файл спецификации конфигурации для создания файла конфигурации на его основе. + +- `Restart server` - полностью перезапускает сервер. **Внимание!** После перезапуска сервер более не будет соединён с консолью, из которой был запущен, если сервер изначально был запущен из консоли. + +#### Раздел Table + +![Скриншот раздела Table](../assets/server-table.png) + +- `Toggle select` (`Ctrl+A`) - выделят все коптеры\снимает выделение со всех коптеров. Если в таблице выбраны не все коптеры, то *выделяет все* коптеры. Иначе (если были выделены все коптеры) *снимает выделение* со всех коптеров. +- `Select all` - выделят все коптеры в таблице. +- `Deselect all` - снимает выделение со всех коптеров в таблице. +- `Remove selected drones` - удаляет выбранные коптеры из таблицы. **Внимание!** В случае, если клиент был подключен, будет произведено отключение. В случае если удалённый таким образом клиент исправно функционировал, он переподключится в кратчайшие сроки. +- `Configure columns` - открывает [встроенный конфигуратор](#column-preset-editor) наборов настроек столбцов таблицы. ### Боковая панель команд @@ -116,7 +150,7 @@ * Кнопка `Land ALL` - ВСЕ коптеры прекращают выполнение своих полётных заданий, очищают очередь заданий и немедленно переходят в режим посадки AUTO.LAND. **Используйте в экстренных случаях как одно из средств перехвата.** * Кнопка `Emergency land` - все выбранные коптеры прекращают выполнение своих полётных заданий, очищают очередь заданий и немедленно переходят в режим экстренной посадки - на все моторы подаётся небольшая мощность, которая уменьшается через определённое время до нуля. **Используйте в экстренных случаях как одно из средств перехвата.** -* Кнопка `Visual land` - открывает диалоговое окно модуля визуальной посадки неисправного коптера. Полное описание находится в [конце статьи](#visual-land). +* Кнопка `Visual land` - открывает [диалоговое окно](#visual-land) модуля визуальной посадки неисправного коптера. * Кнопка `Disarm selected` - все выбранные коптеры прекращают выполнение своих полётных заданий, очищают очередь заданий и немедленно отключают моторы. Это может привести к падению и повреждению коптеров. * Кнопка `Disarm ALL` - ВСЕ коптеры прекращают выполнение своих полётных заданий, очищают очередь заданий и немедленно отключают моторы. Это может привести к падению и повреждению коптеров **Используйте в крайних случаях как последнее из средств перехвата.** @@ -253,7 +287,7 @@ config_version = 1.0 * `host` - имя хоста или IP адрес NTP сервера (локального или удаленного) * `port` - порт, используемый NTP сервером -## Дополнительные операции +## Дополнительные операции и окна ### Visual land @@ -268,3 +302,11 @@ config_version = 1.0 Нажимая на кнопки, соответствующие цвету группы, в которой находится неисправный коптер, можно определить его номер и выполнить экстренную посадку за логарифмическое количество шагов от количества коптеров, т.е. гораздо быстрее, чем перебирая коптеры по одному. На любом шаге можно произвести посадку или выключение моторов всех коптеров, на которых включена светодиодная лента, нажав кнопку `Land` или `Disarm`. + +### Config editor + +... + +### Column preset editor + +... \ No newline at end of file From 140101411bf687f595b8d78a0b0ffef9b7bffd60 Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Fri, 3 Apr 2020 15:32:39 +0300 Subject: [PATCH 198/210] Added preset editor images --- docs/assets/server-column-editor.png | Bin 0 -> 9370 bytes docs/assets/server-column-popup.png | Bin 0 -> 6528 bytes 2 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/assets/server-column-editor.png create mode 100644 docs/assets/server-column-popup.png diff --git a/docs/assets/server-column-editor.png b/docs/assets/server-column-editor.png new file mode 100644 index 0000000000000000000000000000000000000000..ed22aa2d075c47ea4a6c4b5d6e56a43613fffbf2 GIT binary patch literal 9370 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 BLu>#5 literal 0 HcmV?d00001 diff --git a/docs/assets/server-column-popup.png b/docs/assets/server-column-popup.png new file mode 100644 index 0000000000000000000000000000000000000000..234d4fe54894d9a3067cf75b08740e0cc9c28557 GIT binary patch literal 6528 zcmaiZ2Q*yo*Df=(6FpiGj5;F9=!xDXdWqf%B8V1@7Ih?|g^3y^xyZ5u7=h^#2Khjhtxj}aW2M32l1*V{lgM)hy zxP-2QfG1v^4!FPPjp#FQ5KCR)&lwH-F0ZCdw!}&hIDA8c zyOZ;J#M>w24#xOu(W%-=VR4V7VdP}apa^}1{@x>NaKq_iHGHibcQ0|TR?ek|q(u(< zcl&K^econpO`mK(@m-%c{+=)-(kS)TsOIx$L)?W|6KAbB400^EkvCy&taub|*F*6b zs*Z|2dU|@Ek-wlm!eF+Pd$sYa8XHZZxbSar^x~s5T_?<3_vqz*%5 z;Fj~Js!)*Rklov#V+l)mP~y+ePtjd*^mWgs?_8SJxy)sXIyI4~I}=$Sw%)3^erI%&QA*Yy<4|Q4se#jTpsnk7(>qF3BtRb{gW2Lw`mp1cF>x7oN6Qy)2Sg9ma8K`pOctn$GqF1L`tT|DG2YO zYVUuVm{CpN14MR1xrHU}ZSYyKd5YXho@9fb7KzicOdL_?s1w-MM_~h_Ke(ZnI#OcH z(N*5LnX5YQr@@!AL8G?vV34G*=w-I+tH{q{KDk`q1(F@LELMMz400HRvxF`$FQdY~ z3OfkFm-r6a$)MPi`Htozz0dD22an=U#(@i24fGp%{f2i zKh9tKYHee0FbCnFaoG_AvsvAYR|}HSyMVz|qokw6LymQo?T+bK1CIEpT};OkDk$(I zDEqr>jfhu%)Ht=CuF(r7cPB6p!Iie}FelxZw);Thc=#zOjCmzaBzrWy4LHdOgK*Ev z5cDdWKd!RKI*w@dGiAKvr$f;Tm_380jJ_tu_7wqVBvZBjC~nuU*yMbJ>bkAGNlTyIbdlo4N~NRBBG_g*#S=d+E;}c(R+>aeXi%)> zvSNPsRU%t#*pc;HhiidE6Q}Jv8ONXqJYn(ExCrW3NT-z;b6bIllEprlJQjhC=t>L= z>v2x9XG~{Oz2jrm(`!V0BD`F1 zIoYeXX{grH>+B0RL4=TSptdS0U>6ZejsfEDmZ#}hk7?+5HW+d4+2Ci7@`UORFnl5F z!&L^*FK@Dblwfc=!eeTn$zq_t%`hy+fhVl7ot^!Z?v}cLZ|*!P7qe4uggfe?iYps6 zo(WOcgMJ!x{H>4oPjF(Mh{Nkm*3W!NXw!}bu>mfVjZ7BSHk)4|l_PDUdcs__wBtw; zG)wPHiI{q^86?3-WW3#FC z))&&x^C|Hv1}#}k91$WE4l6=tPfe!?!b+IiZexhm^rjT2YMO%x?7M&s&k%>VXxARA z=-e(U+nmy4J}AbUwFbZuvYoxa;nCG&RrnQ>(Pyk~gw~$$mW%#*O}l4WlB-xD9zhhi zs!iQBK%CWX88rUpV;Q*5N(L_9GAZ$Z)1%gMlI*02=QR@ZtJw>EYr0FK6aq`$l6~KgAPJiO!QU=hdwBNx}L|y)fFH}7U)7wHqFXpFJ=REV~(qK75?AniA0H5Ot!W8S4 zOEOoh2gyqV(RrUlBNwc=!>7BiF@KBh%!uO(#4SiPZt?AP~K&o->x?Yw@e)^#~!d--hGfc+%wL+A((AG|a z5+}C1Dw;*{%XNhBA22u~lIPr%YEp}Utt5JqRnA-y4TmF7Vt9&*)!rYHY8Vk)$Ci3d zPORw+JjzP6i&b*>>?5a!{l$yvvoEHBM9XyXZ_Un_xHy*t^`KDcje%Y`bQQ5oY6BS zNyC$l%t7iNf5lLBdB>SPW{wLWXsl2RWm`YlnE%@NC4@V{|7MopyJkHn8sgK%)gXp!*2%T6n=c6qp!&_QHLdIWpIuZ zV=`jugKGP+79@<4rcn3P)(sZBp3WStw)Di2$NMnV$CpqaXt<_Ab?eZsH{Qg0fQ zebXsdJ1NPfwaQ1+K($XXJ+WqYehqrhGfva;4?md3%h!GMXbL|bgg6pOBIe|7NSLg% zUzN@qZcK{x3O;^rmz^yW3%TaJv!O5v=2>w-IB9Q83J7?0ot6e@dtX+hOWf)nRN7{c zWGwY0|68$2P3pfZl6Q%LLQeG`3-`Deq@9)GW#_*vNq*aBtelTLw8TaJrUo}Wxx6Yl)22W`Ms9XH1L z=jmZ;ArGj!Hnkx^gdT=8;=k(6M_QfiF|*~UMO`Le_b>NuSPQ{R>jo7Ymfl}PEv|@Y zm}IjMEjOJaiyZZRZkIxy-sxqFu>d9c1+s*ie^vX4xRJt!2(&! zx4*lgFkk>nYayRiTOsyWAOvXHJ{Kr=xl`t*p5Ts_ig|u-6Vv_b%6l%5&1Id`9wBrT zg&|o!yvSQ~lm6{4?V;wWHS|3b46?tr6h#gzkCIzs zLwnlfhpfcN&WmXMYIs?2C(ls|YJ5r8>^`S+JP{>`#bQ+3d)bc{xuZ+vwuq1bi9)Ow zSH83REAV{8`>nm1r~%w90x5K`=i)me%APqMC!V8hG0geRHrYR7`+lrB<6r9Ap~?ct zZSm&Zi+DSy@NoSX3fMoSe`iZrq2*FLP6{hzm1m)gb$E;Kx*u*n2!-iab%OsLZ4Q$-vXvl82 zkeNfIMHYjPK0$h4c<~tTtmImXG64GkIdw&ds1|QQ%N^n@94DX@Qa{!lYee|npR+^* z)dXaS?7sZ@;H^g4+q$Bl0X8O0W1ZR`_@j)>Q8q&G9an@&$%nghutG;X$K|P)E(rl> z%6kS#gkA44IQnPK!`knX23n-DpYpgY=T{ufsu%8V6vwja=nx^(G)|3o91jz#N;$g; zlBP-Whqbp&We&s)Wc1b|(?r@)_f|Bh+F6UsiUby1_XxrCDxR78QbOhgSCTz6I%G)+ zWcg13@!p-Rhg{r3alPHbYSL|w8Tc`2!Ak<4g`V;%by)Uz~roO&)e)~(EM6N1%d%@{vKa+###h&fwxXje@UzB%= zI1^XTH3yy?<^AjkyaalY&MXUP!l!^51`MF+?x*NgpsRA3u2RI-yUaC+I!*e~r$38> zZ&j7(0?i0t9K20qkCzF+Do zSH$xN0xwca|4?o0wu{Kt!pjKrd6SB+&-xzGZ0R&dfdmIWkJKFEAJPIU3>cv6u!p`` zhPL;eo=rHr^{up_`eB=Ym$xkv9;gpzM4uR8ISYm%g;ffAVXs7Z>65z zbIu}MO1#TZ~<>t9ZvrkEI>6Qe|pK6EgKlLv;U z7*Esm4DCu-BTGGnX+-DdYIO;$o7R-!sU9OjW7fMwlwVy~BC<<8J9-68fg>@2f zMP^;&7$C>Qc=mOfHvs=!{x3NQlr@%ySPFgcPyQAtYfqgL&UU-g%fsJPka57Bo21lp z!>f8w5%9vpk_5nqh3Q{139vQLKK?dRV~x0sLzCD2MMeJ+N=a@wQAxo=C~mf{F^dBN zOqY5KX1xBw5HQO&+q3=qC3RYZc#Q5IRXkjX#ZPjd4Wl$w2X-N?_R1od_Xeb874*Mp zukYtZ`V@x&y#bYYgOR4pVHlT3sxCX%=xwiVex@Aed#Zjb=7jh@a?wDTRwwIKZ8j== zFDjZQTX!>dm+ZCK#XX-2bLS;bacadrv>&+f^998}B50wd2TgiEB&`v%LU-n2T@YX7 z5X$)I4&b0ly~}KBP78Uy={_N#?D!jEHAijssfxSm_AMZ(Hrc<%reHiDHfXXsr!SXI z%ane^)VVX!)JDo3b1Zkm-*{cy3HSx{F@5>3q8J*ViLMJzSdGCekkmVFMLc2VMB2Q( z%4rcjkupUm#yHVi7wBmBLhg9-s9pS&E`65c2)*CU$KQZ|cJlJlg~q5Fqs;LrK1`)I zTaI$ygt5HJX#4u-#`5{|*}#R>doZht*}EXZapbrBN9uI=yrI8|w9prv{gkt!;{FZ5 zBu{gt#iMZDUZ=I+8RG^z8w#pyrKkS*+LJET&z(a|NI`~=|7WVQpHQ}EB=ZBX!dWYpRt9p0SSkuu5(jx78 zMu|0$5y4ub8(W10$BysZfX3Gt0KlIX`s%C`aH&_SUGG#h-*gTPB5Nn|sp~t!t~am? zH&Kqdc5E>(ZoyItoYO3V4no+@2!~aE1fJ^6fQp%M5#9B2AlO&@zBUiV|Frp|Ia)>` zR~Nn)*Egj1TF%RYDT~6(WfWNI_n8r!TN6lUVs;(<|26ITZ>#Ya%P8sQ^VY|WTJz)E zDX&P5&SE2vJcn{)9i3Em9{W<(41ZT5kK7keRu<8pVl+k0Gjk``kb4Y`LvxGk;$6x^ z_LRjSts%+gvwgyn@_ztw9$OBVn>fi8dW*UXqd-g+!Pad&% zUG7?Yd%L*nq*fP`>~mn|^x&c+69gTfHr**Ty^`wA)>(fXeuhA>Q(zb+y*9pATG?_c z(zX6tibY8IZmmyJ-tN)JnjcL_`E-8NefQ;o!$7w5%+Us+3tX8Z+Va_t>fU~9c$Y8A zzk8-F2L$#%+}l1&v<&`mE4a`q_(PtoYrX%OE?gPWqvOq73v#BhMgzm1yL@r-p`qAD zF&a+ZQsB_ScJY}r2{A=$k)`#VU*tqi_U36qu4NF9Zt&NYRLGf2SMUAI0WZSR>o!m2(CMZAo)dwT1%U1B$kml=m!wyBVc51+u_Q=@0`UhXhYy~tK;xbnI zD9n+amPUE^YwG`R- ze7h0q*+mcTp;!)DiL=FR7OqBSMm$LiR>fkHGH>uUYFX%>Z&M{Amby7zhi`}@ud@8Q zCzW4AGf&ZLgPG>sbw7Od=il{9na{?>US?wPy^QD+jgS^P)Dibxds8oVum@F z{EeS2VX3aGF~u^T%4Az%?9PIy3A82$Did52q}=Xqm~VM5c&Vx5s%YNMBK+Q>e9_h8 zV%$})zO`vi6+6-~y0BVw)GA(SvJ2t73ORC?={#8SQ3${j&eAw1`$?$DzB*vN?OIvq za3vHHIy>1EVzZa%+N>w_Dgm!a&Yk0y)#=X<%l%j+{lvC3Q;x_wHibnq`@yNOU zE0wrubCk1W)#+NvRefR;xX_)z#G7cwuvyP)B_5?EEA2Hp(nNoe!OT?je6EWz5kItF z{WS^I=}3H!Rl%Zp3L4v+J{7TS{4#v!s!a4Ezo&_@Ddn^)i@Z{KruwQWd#l9c0)J8U zu%S}w4nHUZL5CMC#cu0Ha#EJUW&+Vk8Ol`qVNM#Am{k)==xmtE?&0bn(W0nquXUK$ z>t;5ZJU3p>n;~_F2$T^fJuMZeADlh*iE&@+!fZ_rUuIriVsdV~9MK&tb$85)eHJ~!Sd>&=3FrnSsOxF@&-9%4D(~?z#z23p zm4sM0O5|b+3!}@CvI`?R@Ymu|w8 zdlM|c=XZ7ZgOg%xUdlwN-4V~;5KAwP6{Z0ldP=2`ZC2Xe7RgjM_3d$_|yhm^_G{rpVcw)*Tq#21+ z!}5SOFE*Mq*W_e_vA+fpQoxA3A@F23nMhtR82tw7M} z(WXvf@A=6dG~j4s`jZ_V1#ELQ%_4X;)nKyRLNMucR86qdxJI@7o;7KHdfxB3eup+z zg)a0+0i;NZ?~dVSs^fyAFl%pdM)xV~FQ>UZS#ZC%pZtqb{tt%v-^ Date: Fri, 3 Apr 2020 19:50:16 +0300 Subject: [PATCH 199/210] Client: Updates for compability with clover and simulations --- Drone/FlightLib/FlightLib.py | 10 ++++++++-- Drone/copter_client.py | 10 ++++++++-- Drone/visual_pose_watchdog.py | 10 ++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/Drone/FlightLib/FlightLib.py b/Drone/FlightLib/FlightLib.py index 8c70b09..6ecb302 100644 --- a/Drone/FlightLib/FlightLib.py +++ b/Drone/FlightLib/FlightLib.py @@ -6,7 +6,13 @@ import time import logging import threading import rospy -from clever import srv + +# for backward compatibility with clever +try: + from clever import srv +except ImportError: + from clover import srv + from mavros_msgs.srv import SetMode from mavros_msgs.srv import CommandBool from std_srvs.srv import Trigger @@ -337,7 +343,7 @@ def takeoff(height=TAKEOFF_HEIGHT, speed=TAKEOFF_SPEED, tolerance=TOLERANCE, fra return 'interrupted' climb = abs(get_telemetry_locked(frame_id=frame_id).z - start.z) - rospy.logdebug("Takeoff to {:.2f} of {:.2f} meters".format(climb, height)) + rospy.loginfo("Takeoff to {:.2f} of {:.2f} meters".format(climb, height)) time_passed = time.time() - time_start diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 150e694..4efba23 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -4,7 +4,13 @@ import time import math import rospy import numpy -from clever import srv + +# for backward compatibility with clever +try: + from clever import srv +except ImportError: + from clover import srv + import datetime import logging import threading @@ -13,7 +19,6 @@ import subprocess from collections import namedtuple from FlightLib import FlightLib -from FlightLib import LedLib import client @@ -86,6 +91,7 @@ class CopterClient(client.Client): rospy.loginfo("Init ROS node") rospy.init_node('clever_show_client') if self.config.led_use: + from FlightLib import LedLib LedLib.init_led(self.config.led_pin) task_manager_instance.start() # TODO move to self if self.config.copter_frame_id == "floor": diff --git a/Drone/visual_pose_watchdog.py b/Drone/visual_pose_watchdog.py index 4ca07cb..7f62957 100644 --- a/Drone/visual_pose_watchdog.py +++ b/Drone/visual_pose_watchdog.py @@ -5,7 +5,13 @@ import time import math import logging import threading -from clever.srv import SetAttitude + +# for backward compatibility with clever +try: + from clever import SetAttitude +except ImportError: + from clover import SetAttitude + from sensor_msgs.msg import Range from mavros_msgs.msg import State, PositionTarget from mavros_msgs.srv import SetMode, CommandBool @@ -16,7 +22,7 @@ from geometry_msgs.msg import PoseStamped import inspect # Add parent dir to PATH to import messaging_lib current_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) parent_dir = os.path.dirname(current_dir) -sys.path.insert(0, parent_dir) +sys.path.insert(0, parent_dir) from config import ConfigManager From 7630e8b51bac0fa94678ff612233180116e31d05 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Fri, 3 Apr 2020 19:50:40 +0300 Subject: [PATCH 200/210] Client: Add requirements file for standalone install --- Drone/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Drone/requirements.txt diff --git a/Drone/requirements.txt b/Drone/requirements.txt new file mode 100644 index 0000000..b9b5ed8 --- /dev/null +++ b/Drone/requirements.txt @@ -0,0 +1,2 @@ +selectors2 +psutil \ No newline at end of file From d8bc67692ff2d606830aa93e4be8bad2a471792a Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 7 Apr 2020 10:12:31 +0300 Subject: [PATCH 201/210] Client: Update FCU parameters parser --- Drone/copter_client.py | 6 +++++- Drone/mavros_mavlink.py | 46 +++++++++++++++++++++++++++++++---------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 4efba23..f6856d0 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -74,6 +74,10 @@ flightlib_logger = logging.getLogger('FlightLib') flightlib_logger.setLevel(logging.INFO) flightlib_logger.addHandler(handler) +mavros_mavlink_logger = logging.getLogger('mavros_mavlink') +mavros_mavlink_logger.setLevel(logging.INFO) +mavros_mavlink_logger.addHandler(handler) + class CopterClient(client.Client): def __init__(self, config_path="config/client.ini"): @@ -89,7 +93,7 @@ class CopterClient(client.Client): def start(self, task_manager_instance): rospy.loginfo("Init ROS node") - rospy.init_node('clever_show_client') + rospy.init_node('clever_show_client', anonymous=True) if self.config.led_use: from FlightLib import LedLib LedLib.init_led(self.config.led_pin) diff --git a/Drone/mavros_mavlink.py b/Drone/mavros_mavlink.py index 7a6005e..b4602c4 100644 --- a/Drone/mavros_mavlink.py +++ b/Drone/mavros_mavlink.py @@ -6,6 +6,8 @@ from mavros_msgs.srv import ParamGet, ParamSet from mavros_msgs.msg import State, ParamValue from pymavlink.dialects.v20 import common as mavlink +logger = logging.getLogger(__name__) + send_command_long = rospy.ServiceProxy('/mavros/cmd/command', CommandLong) get_param = rospy.ServiceProxy('/mavros/param/get', ParamGet) set_param = rospy.ServiceProxy('/mavros/param/set', ParamSet) @@ -127,23 +129,45 @@ def stop_subscriber(): def load_param_file(px4_file): result = True + err_lines = "" + err_params = "" + lines_commented = "" + params_loaded = "" try: px4_params = open(px4_file) except IOError: - logging.error("File {} can't be opened".format(filepath)) + logger.error("File {} can't be opened".format(filepath)) result = False - else: + else: with open(px4_file) as px4_params: + row = 0 for line in px4_params: - param_str_array = line[:-1].split('\t') - param_name = param_str_array[2] - param_value_str = param_str_array[3] - param_type = param_str_array[4] - if param_type == '6': - param_value = ParamValue(integer=int(param_value_str)) + row += 1 + param_str_array = line.split('\t') + if len(param_str_array) == 5 and '#' not in param_str_array[0]: + param_name = param_str_array[2] + param_value_str = param_str_array[3] + param_type = int(param_str_array[4]) + if param_type == 6: + param_value = ParamValue(integer=int(param_value_str)) + else: + param_value = ParamValue(real=float(param_value_str)) + if not set_param(param_name, param_value): + err_params += "{} ,".format(row) + result = False + else: + params_loaded += "{} ,".format(row) + elif '#' in param_str_array[0]: + lines_commented += "{} ,".format(row) else: - param_value = ParamValue(real=float(param_value_str)) - if not set_param(param_name, param_value): - result = False + err_lines += "{} ,".format(row) + if err_lines: + logger.info("Can't parse lines: {}".format(err_lines[:-1])) + if err_params: + logger.info("Can't set params from lines: {}".format(err_params[:-1])) + if lines_commented: + logger.info("Lines commented: {}".format(lines_commented[:-1])) + if params_loaded: + logger.info("Params are successfully loaded from lines: {}".format(params_loaded[:-1])) return result From 6354d524f638ef3d693863c24da19715da98bf3d Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 7 Apr 2020 10:34:25 +0300 Subject: [PATCH 202/210] Client: Remove waste spaces --- Drone/client.py | 2 +- Drone/client_setup.sh | 4 ++-- Drone/copter_client.py | 14 +++++++------- Drone/mavros_mavlink.py | 4 ++-- Drone/tasking_lib.py | 20 ++++++++++---------- Drone/visual_pose_watchdog.py | 18 +++++++++--------- 6 files changed, 31 insertions(+), 31 deletions(-) diff --git a/Drone/client.py b/Drone/client.py index 9fb5081..88c5057 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -13,7 +13,7 @@ from contextlib import closing import inspect # Add parent dir to PATH to import messaging_lib current_dir = os.path.dirname(os.path.abspath(inspect.getfile(inspect.currentframe()))) parent_dir = os.path.dirname(current_dir) -sys.path.insert(0, parent_dir) +sys.path.insert(0, parent_dir) logger = logging.getLogger(__name__) diff --git a/Drone/client_setup.sh b/Drone/client_setup.sh index 81f1496..b92b777 100755 --- a/Drone/client_setup.sh +++ b/Drone/client_setup.sh @@ -2,7 +2,7 @@ # $1 - ssid, $2 - password of wifi router # $3 - hostname of rpi -# $4 - server ip +# $4 - server ip if [ $(whoami) != "root" ]; then echo -e "\nThis should be run as root!\n" @@ -38,7 +38,7 @@ country=GB network={ ssid="$1" psk="$2" - scan_ssid=1 + scan_ssid=1 } EOF diff --git a/Drone/copter_client.py b/Drone/copter_client.py index f6856d0..6fdcf33 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -303,7 +303,7 @@ def _response_animation_id(*args, **kwargs): if result != 'No animation': logger.debug("Saving corrected animation") offset = numpy.array(client.active_client.config.private_offset) + numpy.array(client.active_client.config.copter_common_offset) - frames = animation.load_animation(os.path.abspath("animation.csv"), client.active_client.config.animation_frame_delay, + frames = animation.load_animation(os.path.abspath("animation.csv"), client.active_client.config.animation_frame_delay, offset[0], offset[1], offset[2], *client.active_client.config.animation_ratio) # Correct start and land frames in animation corrected_frames, start_action, start_delay = animation.correct_animation(frames, @@ -390,10 +390,10 @@ def _command_move_start_to_current_position(*args, **kwargs): telem = FlightLib.get_telemetry_locked(client.active_client.config.copter_frame_id) logger.debug("x_telem = {}, y_telem = {}".format(telem.x, telem.y)) if not math.isnan(telem.x): - client.active_client.config.set('PRIVATE', 'offset', + client.active_client.config.set('PRIVATE', 'offset', [telem.x - x_start, telem.y - y_start, client.active_client.config.private_offset[2]], write=True) - logger.info("Set start delta: {:.2f} {:.2f}".format(client.active_client.config.private_offset[0], + logger.info("Set start delta: {:.2f} {:.2f}".format(client.active_client.config.private_offset[0], client.active_client.config.private_offset[1])) else: logger.debug("Wrong telemetry") @@ -403,17 +403,17 @@ def _command_move_start_to_current_position(*args, **kwargs): @messaging.message_callback("reset_start") def _command_reset_start(*args, **kwargs): - client.active_client.config.set('PRIVATE', 'offset', + client.active_client.config.set('PRIVATE', 'offset', [0, 0, client.active_client.config.private_offset[2]], write=True) - logger.info("Reset start to {:.2f} {:.2f}".format(client.active_client.config.private_offset[0], + logger.info("Reset start to {:.2f} {:.2f}".format(client.active_client.config.private_offset[0], client.active_client.config.private_offset[1])) @messaging.message_callback("set_z_to_ground") def _command_set_z(*args, **kwargs): telem = FlightLib.get_telemetry_locked(client.active_client.config.copter_frame_id) - client.active_client.config.set('PRIVATE', 'offset', + client.active_client.config.set('PRIVATE', 'offset', [client.active_client.config.private_offset[0], client.active_client.config.private_offset[1], telem.z], write=True) logger.info("Set z offset to {:.2f}".format(client.active_client.config.private_offset[2])) @@ -421,7 +421,7 @@ def _command_set_z(*args, **kwargs): @messaging.message_callback("reset_z_offset") def _command_reset_z(*args, **kwargs): - client.active_client.config.set('PRIVATE', 'offset', + client.active_client.config.set('PRIVATE', 'offset', [client.active_client.config.private_offset[0], client.active_client.config.private_offset[1], 0], write=True) logger.info("Reset z offset to {:.2f}".format(client.active_client.config.private_offset[2])) diff --git a/Drone/mavros_mavlink.py b/Drone/mavros_mavlink.py index b4602c4..90ee00c 100644 --- a/Drone/mavros_mavlink.py +++ b/Drone/mavros_mavlink.py @@ -63,7 +63,7 @@ def calibrate(sensor): return False # Make calibration message calibration_message = calibration_msg(sensor) - # Send mavlink calibration command + # Send mavlink calibration command send_command_long(False, mavlink.MAV_CMD_PREFLIGHT_CALIBRATION, 0, *calibration_message) rospy.loginfo('Send {} calibration message'.format(sensor)) # Wait until system status to uninit (during calibration on px4) @@ -87,7 +87,7 @@ def get_calibration_status(): if mag_status.value.integer == 0 and mag_status.success: status_text += "mag: uncalibrated; " if acc_status.value.integer == 0 and acc_status.success: - status_text += "acc: uncalibrated; " + status_text += "acc: uncalibrated; " if status_text == "": if not gyro_status.success or not mag_status.success or not acc_status.success: status_text = "NO_INFO" diff --git a/Drone/tasking_lib.py b/Drone/tasking_lib.py index 1827c3c..c64dbcb 100644 --- a/Drone/tasking_lib.py +++ b/Drone/tasking_lib.py @@ -54,7 +54,7 @@ class TaskManager(object): self._wait_interrupt_event.set() self._running_event.clear() - task = Task(task_function, task_args, task_kwargs, task_delayable) + task = Task(task_function, task_args, task_kwargs, task_delayable) count = next(self._counter) entry = (timestamp, priority, count, task) @@ -64,21 +64,21 @@ class TaskManager(object): entry_old = self.task_queue[0] else: entry_old = entry - + heapq.heappush(self.task_queue, entry) if self.task_queue[0] != entry_old: self._task_interrupt_event.set() #print("Task queue updated with more priority task") - + if self._reset_event.is_set(): self._task_interrupt_event.set() self._reset_event.clear() #print("Task queue updated after reset") - + self._wait_interrupt_event.clear() self._running_event.set() - + # #print(self.task_queue) def pop_task(self): @@ -89,7 +89,7 @@ class TaskManager(object): def get_last_task_name(self): return self._last_task - + def get_current_task(self): try: start_time, priority, count, task = self.task_queue[0] @@ -134,7 +134,7 @@ class TaskManager(object): if self.task_queue: next_task_time = self.task_queue[0][0] if time_to_start_next_task > next_task_time: - self._timeshift = time_to_start_next_task - next_task_time + self._timeshift = time_to_start_next_task - next_task_time self._wait_interrupt_event.clear() self._task_interrupt_event.clear() self._running_event.set() @@ -178,7 +178,7 @@ class TaskManager(object): #print("Interrupter is set: {}".format(self._task_interrupt_event.is_set())) try: task.func(*task.args, interrupter=self._task_interrupt_event, **task.kwargs) - + except Exception as e: logger.error("Error '{}' occurred in task {}".format(e, task)) #print("Error '{}' occurred in task {}".format(e, task)) @@ -211,10 +211,10 @@ class TaskManager(object): #try: #print("Pop {} function!".format(task.func.__name__)) #except Exception as e: - #print("Pop something!") + #print("Pop something!") if self._task_interrupt_event.is_set(): - self._task_interrupt_event.clear() + self._task_interrupt_event.clear() logger.info("Execution done") #print("Execution done") diff --git a/Drone/visual_pose_watchdog.py b/Drone/visual_pose_watchdog.py index 7f62957..d36aba2 100644 --- a/Drone/visual_pose_watchdog.py +++ b/Drone/visual_pose_watchdog.py @@ -144,7 +144,7 @@ def laser_callback(data): laser_range = data.range def emergency_land(disarm_if_timeout = True): - global emergency_land_thrust, laser_range + global emergency_land_thrust, laser_range current_thrust = emergency_land_thrust action_timestamp = time.time() while armed: @@ -153,13 +153,13 @@ def emergency_land(disarm_if_timeout = True): try: set_attitude(thrust = current_thrust, yaw = 0, frame_id = 'body', auto_arm = True) except rospy.ServiceException as e: - logger.info(e) + logger.info(e) delta = time.time() - action_timestamp if delta > timeout_to_disarm and disarm_if_timeout: try: arming(False) except rospy.ServiceException as e: - logger.info(e) + logger.info(e) if (laser_range < 0.1 or delta > emergency_land_decrease_thrust_after) and current_thrust >= 0.: current_thrust -= 0.02 if current_thrust <= 0.03: @@ -167,7 +167,7 @@ def emergency_land(disarm_if_timeout = True): try: arming(False) except rospy.ServiceException as e: - logger.info(e) + logger.info(e) rate.sleep() def emergency_land_service(request): @@ -181,7 +181,7 @@ def emergency_land_service(request): responce.success = False responce.message = "Copter is disarmed, no need for emergency landing!" emergency_land_called = False - return responce + return responce def watchdog_callback(event): global visual_pose_last_timestamp, armed, mode, watchdog_action, laser_range @@ -224,7 +224,7 @@ def watchdog_callback(event): try: arming(False) except rospy.ServiceException as e: - logger.info(e) + logger.info(e) rate.sleep() elif watchdog_action == 'disarm': logger.info('Visual pose data is too old, copter is armed, disarming...') @@ -232,15 +232,15 @@ def watchdog_callback(event): try: arming(False) except rospy.ServiceException as e: - logger.info(e) - rate.sleep() + logger.info(e) + rate.sleep() elif watchdog_action == 'emergency_land': if visual_pose_dt > visual_pose_timeout: logger.info('Visual pose data is too old, copter is armed, emergency landing...') if pos_delta > pos_delta_max: logger.info('Position delta is {} m, copter is armed, emergency landing...'.format(pos_delta)) emergency_land() - logger.info('Disarmed') + logger.info('Disarmed') emergency = False if emergency_land_called: emergency = True From d81d2800df7607593c0fd31192d6ec1002d1cb9a Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 7 Apr 2020 11:23:14 +0300 Subject: [PATCH 203/210] Server: Add clever_dir parameter --- Server/config/spec/configspec_server.ini | 3 +++ Server/server_qt.py | 12 ++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index 13e6199..84c8c12 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -5,6 +5,9 @@ config_version = float(default='1.0') port = integer(default=25000) buffer_size = integer(default=1024) +[CLIENT] + clever_dir = string(default=/home/pi/catkin_ws/src/clever/clover) + [TABLE] # True -> clients are removed on disconnection # False -> disconnected clients indicated diff --git a/Server/server_qt.py b/Server/server_qt.py index 7c033ab..ff26e59 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -438,7 +438,7 @@ class MainWindow(QtWidgets.QMainWindow): def request_any_file(self, client_path=None, copters=None): if client_path is None: _client_path, ok = QInputDialog.getText(self, "Enter path of file to request from client", "Source:", - QLineEdit.Normal, "/home/pi/") + QLineEdit.Normal, "") if not ok: return client_path = _client_path @@ -467,7 +467,7 @@ class MainWindow(QtWidgets.QMainWindow): return c_path, ok = QInputDialog.getText(self, "Enter path (and name) to send on client", "Destination:", - QLineEdit.Normal, "/home/pi/") # TODO config? + QLineEdit.Normal, "") # TODO config? if not ok: return @@ -483,7 +483,7 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def send_calibrations(self): self.send_directory_files("Select directory with calibrations", ('.yaml', ), match_id=True, - client_path="/home/pi/catkin_ws/src/clever/clever/camera_info/", + client_path=os.path.join(self.server.config.client_clever_dir,"camera_info/"), client_filename="calibration.yaml") # TODO callback to reload clever? # from os.path import expanduser # TODO on client @@ -495,13 +495,13 @@ class MainWindow(QtWidgets.QMainWindow): copter.client.send_message("service_restart", kwargs={"name": "clever"}) self.send_files("Select aruco map configuration file", "Aruco map files (*.txt)", onefile=True, - client_path="/home/pi/catkin_ws/src/clever/aruco_pose/map/", + client_path=os.path.abspath(os.path.join(self.server.config.client_clever_dir,"../aruco_pose/map/")), client_filename="animation_map.txt", callback=callback) @pyqtSlot() def send_launch(self): self.send_directory_files("Select directory with launch files", ('.launch', '.yaml'), match_id=False, - client_path='/home/pi/catkin_ws/src/clever/clever/launch/') # TODO clever restart callback? + client_path=os.path.join(self.server.config.client_clever_dir,"launch/")) # TODO clever restart callback? @pyqtSlot() def send_fcu_parameters(self): @@ -652,7 +652,7 @@ if __name__ == "__main__": logging.StreamHandler(), msgbox_handler ]) - + sys.excepthook = except_hook # for debugging (exceptions traceback) app = QApplication(sys.argv) From bea1f013c3e12c3325d36a5adc73a69c4450432c Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 7 Apr 2020 12:12:43 +0300 Subject: [PATCH 204/210] Server: Fix playing music after 0 seconds time --- Server/server_qt.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Server/server_qt.py b/Server/server_qt.py index ff26e59..2c315a8 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -310,16 +310,17 @@ class MainWindow(QtWidgets.QMainWindow): @confirmation_required("This operation will takeoff selected copters with delay and start animation. Proceed?") def send_start_time_selected(self): time_now = server.time_now() + time_lag = 0.1 dt = self.ui.start_delay_spin.value() logging.info('Wait {} seconds to start animation'.format(dt)) if self.ui.music_checkbox.isChecked(): music_dt = self.ui.music_delay_spin.value() - asyncio.ensure_future(self.play_music_at_time(music_dt + time_now), loop=loop) + asyncio.ensure_future(self.play_music_at_time(music_dt + time_now + time_lag), loop=loop) logging.info('Wait {} seconds to play music'.format(music_dt)) # This filter constraints takeoff in real world, when copter state was normal and then some checks were failed for a while # for copter in filter(lambda copter: copter.states.all_checks, self.model.user_selected()): for copter in self.model.user_selected(): - server.send_starttime(copter.client, dt + time_now) + server.send_starttime(copter.client, dt + time_now + time_lag) @pyqtSlot() def pause_resume_selected(self): From 3d96569b3cc89d04dee46bed2f410d54a942d33e Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 7 Apr 2020 14:44:53 +0300 Subject: [PATCH 205/210] Docs: Update server.md --- docs/ru/server.md | 68 +++++++---------------------------------------- 1 file changed, 10 insertions(+), 58 deletions(-) diff --git a/docs/ru/server.md b/docs/ru/server.md index 3829c40..1f94914 100644 --- a/docs/ru/server.md +++ b/docs/ru/server.md @@ -7,8 +7,6 @@ * [Настройка](#настройка-сервера) * [Дополнительные операции](#дополнительные-операции) -[TOC] - ## Интерфейс сервера Сервер имеет визуальный графический интерфейс для удобства взаимодействия. @@ -17,7 +15,11 @@ ### Таблица состояния коптеров -При первом подключении клиента к серверу в таблицу добавляется строка для отображения состояния клиента, содержащая только имя клиента (`copter ID`). Если на клиентах настроена автоматическая передача телеметрии, данные в таблице будут обновляться автоматически. Так же возможно запросить телеметрию выбранных клиентов с помощью кнопки [`Preflight check`](#управление) Строки можно сортировать по возрастанию или убыванию значений любого из столбцов, кликнув по его заголовку. +При первом подключении клиента к серверу в таблицу добавляется строка для отображения состояния клиента, содержащая только имя клиента (`copter ID`). Если на клиентах настроена автоматическая передача телеметрии, данные в таблице будут обновляться автоматически. Так же возможно запросить телеметрию выбранных клиентов с помощью кнопки [`Preflight check`](#управление). + +Строки можно сортировать по возрастанию или убыванию значений любого из столбцов, кликнув по его заголовку. + +Столбцы можно менять местами и изменять их ширину: все изменения сохраняются в настройках сервера. Ячейки таблицы подсвечиваются: @@ -177,63 +179,13 @@ ### Файл конфигурации -Конфигурация сервера задаётся в файле [server.ini](../../Server/config/server.ini), имеющем следующий вид по умолчанию: +Конфигурация сервера создаётся согласно [спецификации](../../Server/config/spec/configspec_server.ini), в ней можно посмотреть значения по умолчанию для любого параметра после ключевого слова `default`. Все изменения сохраняются в файл конфигурации `server.ini` в папке `clever-show/Server/config`. -```ini -# This is generated config with default values -# Modify to configure -config_name = server -config_version = 1.0 +Доступно редактирование конфигурации сервера через GUI модуль `Config editor` через меню `Server -> Edit server config`. -[SERVER] - port = 25000 - buffer_size = 1024 +Конфигурация по умолчанию является полностью работоспособной и не требует изменений для быстрого старта клиента. -[TABLE] - # True -> clients are removed on disconnection - # False -> disconnected clients indicated - remove_disconnected = False - [[PRESETS]] - current = DEFAULT - [[[DEFAULT]]] - copter_id = True, 100 - git_version = True, 75 - config_version = True, 140 - animation_id = True, 100 - battery = True, 100 - fcu_status = True, 100 - calibration_status = True, 65 - mode = True, 100 - selfcheck = True, 65 - current_position = True, 250 - start_position = True, 150 - last_task = True, 250 - time_delta = True, 241 - -[CHECKS] - check_git_version = True - check_current_position = True - # in percents; set 0 to disable this check - battery_min = 50.0 - # in meters; set 0 to disable this check - start_pos_delta_max = 1.0 - # in seconds - time_delta_max = 1.0 - -[BROADCAST] - send = True - listen = True - port = 8181 - # delay for message sending in seconds - delay = 5.0 - -[NTP] - use = False - host = ntp1.stratum2.ru - port = 123 -``` - -Данный файл конфигурации автоматически генерируется при первом запуске сервера, если отсутствует файл конфигурации. Пользовательский файл может содержать неполный набор параметров - в этом случае будут использоваться значения по умолчанию для отсутствующих параметров. Конфигурация по умолчанию является полностью работоспособной и не требует изменений для быстрого начала работы системы. +### Описание параметров #### Корневой раздел @@ -309,4 +261,4 @@ config_version = 1.0 ### Column preset editor -... \ No newline at end of file +... From 8a57938da7023476de5288da63b1e92e25ad2d1a Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 7 Apr 2020 15:51:13 +0300 Subject: [PATCH 206/210] Added cross-platform set_keepalive function --- messaging_lib.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/messaging_lib.py b/messaging_lib.py index 793b4b7..35ab4a4 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -8,6 +8,7 @@ import random import logging import threading import collections +import platform import traceback from contextlib import closing @@ -45,6 +46,30 @@ def get_ip_address(): return "localhost" +def set_keepalive(sock, after_idle_sec=1, interval_sec=3, max_fails=5): + current_platform = platform.system() # could be empty + if current_platform == "Linux": + return _set_keepalive_linux(sock, after_idle_sec, interval_sec, max_fails) + if current_platform == "Windows": + return _set_keepalive_windows(sock, after_idle_sec, interval_sec) + if current_platform == "Darwin": + return _set_keepalive_osx(sock, interval_sec) + +def _set_keepalive_linux(sock, after_idle_sec, interval_sec, max_fails): + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, after_idle_sec) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, interval_sec) + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, max_fails) + +def _set_keepalive_windows(sock, after_idle_sec, interval_sec): + sock.ioctl(socket.SIO_KEEPALIVE_VALS, (1, after_idle_sec*1000, interval_sec*1000)) + +def _set_keepalive_osx(sock, interval_sec): + TCP_KEEPALIVE = 0x10 + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + sock.setsockopt(socket.IPPROTO_TCP, TCP_KEEPALIVE, interval_sec) + + class _Singleton(type): """ A metaclass that creates a Singleton base class when called. """ _instances = {} From 8d595134da45e9080ad5062e9bb960fb7e67867b Mon Sep 17 00:00:00 2001 From: Artem30801 Date: Tue, 7 Apr 2020 15:52:10 +0300 Subject: [PATCH 207/210] Added new keepalive to server and client --- Drone/client.py | 2 +- Server/server.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Drone/client.py b/Drone/client.py index 9fb5081..9026920 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -98,7 +98,7 @@ class Client(object): try: self.client_socket = socket.socket() self.client_socket.settimeout(timeout) - self.client_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + messaging.set_keepalive(self.client_socket) self.client_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.client_socket.connect((self.config.server_host, self.config.server_port)) except socket.error as error: diff --git a/Server/server.py b/Server/server.py index 1ad1fc5..09a9976 100644 --- a/Server/server.py +++ b/Server/server.py @@ -46,7 +46,7 @@ class Server(messaging.Singleton): self.sel = selectors.DefaultSelector() self.server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - self.server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + messaging.set_keepalive(self.server_socket) self.server_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.host = socket.gethostname() From 2f59dd089d2e6563eeecc733acf57da58e2af06a Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Tue, 7 Apr 2020 17:50:16 +0300 Subject: [PATCH 208/210] docs: Fix style in server.md --- docs/ru/server.md | 69 +++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/docs/ru/server.md b/docs/ru/server.md index 1f94914..f725068 100644 --- a/docs/ru/server.md +++ b/docs/ru/server.md @@ -57,31 +57,31 @@ Данный раздел содержит несколько утилит по отправке различных данных и команд на *выбранные* клиенты. **Внимание!** Не используйте данные команды во время полёта коптеров! -- ##### Подраздел Send +* Подраздел `Send` - - `Animations` - отправка файлов анимации, экспортированных аддоном к Blender, на выбранные коптеры. В диалоговом окне необходимо выбрать *папку*, содержащую файлы анимации. Каждый файл анимации будет отправлен на клиент с именем, соответствующим имени файла без расширения. + * `Animations` - отправка файлов анимации, экспортированных аддоном к Blender, на выбранные коптеры. В диалоговом окне необходимо выбрать *папку*, содержащую файлы анимации. Каждый файл анимации будет отправлен на клиент с именем, соответствующим имени файла без расширения. - - `Configuration` - отправка *единого* файла конфигурации клиента на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл конфигурации в установленном формате. Файл конфигурации может быть неполным, в таком случае будут перезаписаны лишь указанные в файле параметры. **Внимание!** Не рекомендуется использовать данное действие для массовой перезаписи `copter ID`, кроме значения `/hostname`. **Внимание!** НЕ отправляйте на клиенты файл конфигурации сервера. + * `Configuration` - отправка *единого* файла конфигурации клиента на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл конфигурации в установленном формате. Файл конфигурации может быть неполным, в таком случае будут перезаписаны лишь указанные в файле параметры. **Внимание!** Не рекомендуется использовать данное действие для массовой перезаписи `copter ID`, кроме значения `/hostname`. **Внимание!** НЕ отправляйте на клиенты файл конфигурации сервера. - - `Launch files` - отправка launch-файлов конфигурации сервиса `clever`. В диалоговом окне необходимо выбрать *папку*, содержащую файлы конфигурации с сширением `.launch`. Все файлы с таким расширением будут отправлены *на каждый* из клиентов. **Внимание!** Существующие файлы конфигурации на коптерах будут перезаписаны, однако файлы, не отправленные сервером, не будут удалены или модифицированы. + * `Launch files` - отправка launch-файлов конфигурации сервиса `clever`. В диалоговом окне необходимо выбрать *папку*, содержащую файлы конфигурации с сширением `.launch`. Все файлы с таким расширением будут отправлены *на каждый* из клиентов. **Внимание!** Существующие файлы конфигурации на коптерах будут перезаписаны, однако файлы, не отправленные сервером, не будут удалены или модифицированы. - - `Aruco map` - отправка *единого* файла карты aruco маркеров на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл карты в установленном формате. Файл на клиенте будет перезаписан. После получения и записи файла клиент автоматически перезапустит сервис `clever`. Для возобновления работоспособности полётных функций и получения некоторых значений телеметрии *необходимо подождать* некоторое время до полного запуска сервиса. + * `Aruco map` - отправка *единого* файла карты aruco маркеров на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл карты в установленном формате. Файл на клиенте будет перезаписан. После получения и записи файла клиент автоматически перезапустит сервис `clever`. Для возобновления работоспособности полётных функций и получения некоторых значений телеметрии *необходимо подождать* некоторое время до полного запуска сервиса. - - `Camera calibrations` - отправка yaml-файлов калибровки камеры для сервиса `clever`. В диалоговом окне необходимо выбрать *папку*, содержащую файлы конфигурации с расширением `.yaml`. Каждый файл калибровки будет отправлен на клиент с именем (copter ID), соответствующим имени файла без расширения. **Внимание!** Существующий файл калибровки на коптере будет перезаписан. + * `Camera calibrations` - отправка yaml-файлов калибровки камеры для сервиса `clever`. В диалоговом окне необходимо выбрать *папку*, содержащую файлы конфигурации с расширением `.yaml`. Каждый файл калибровки будет отправлен на клиент с именем (copter ID), соответствующим имени файла без расширения. **Внимание!** Существующий файл калибровки на коптере будет перезаписан. - - `FCU parameters` - отправка и запись *единого* файла конфигураций полётного контроллера (FCU) на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл параметров в установленном формате. Параметры на полётном контроллере будут перезаписаны. + * `FCU parameters` - отправка и запись *единого* файла конфигураций полётного контроллера (FCU) на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл параметров в установленном формате. Параметры на полётном контроллере будут перезаписаны. - - `File` - отправка *одного* любого файла на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл. Далее, необходимо указать путь, по которому данный файл будет записан на клиенты (не включая имя файла). + * `File` - отправка *одного* любого файла на все выбранные клиенты. В диалоговом окне необходимо выбрать *один* файл. Далее, необходимо указать путь, по которому данный файл будет записан на клиенты (не включая имя файла). - - `Command` - отправка и выполнение любой команды терминала на все выбранные клиенты. В диалоговом окне необходимо ввести требуемую команду. Команды *могут* использовать `sudo`-права. - - ------ - -- `Retrive file` - позволяет скачать любой файл с клиентов в выбранную директорию в файловой системе сервера. Если при скачивании был выбран более чем один клиент, то к имени файла от каждого клиента будет добавлен его ID. В диалоговом окне сначала введите путь к требуемому файлу на клиенте. Далее, в диалоговом окне необходимо указать путь, по которому данный файл будет записан на сервер. + * `Command` - отправка и выполнение любой команды терминала на все выбранные клиенты. В диалоговом окне необходимо ввести требуемую команду. Команды *могут* использовать `sudo`-права. ------ -- ##### Подраздел Restart +* `Retrive file` - позволяет скачать любой файл с клиентов в выбранную директорию в файловой системе сервера. Если при скачивании был выбран более чем один клиент, то к имени файла от каждого клиента будет добавлен его ID. В диалоговом окне сначала введите путь к требуемому файлу на клиенте. Далее, в диалоговом окне необходимо указать путь, по которому данный файл будет записан на сервер. + + ------ + +* Подраздел `Restart Service` ![Скриншот раздела Selected drones - Restart](../assets/server-drone-restart.png) @@ -89,11 +89,11 @@ * `clever` - перезапускает сервис `clever` на выбранных клиентах. Для возобновления работоспособности полётных функций и получения некоторых значений телеметрии *необходимо подождать* некоторое время до полного запуска сервиса. * `clever-show` - перезапускает сервис шоу коптеров `clever-show` на выбранных клиентах. Во время перезапуска клиенты будут отключены. -- ------ + ------ - `Set start X Y to current position` - устанавливает точку старта анимации у выбранных клиентов в значения текущей позиции по X Y. +* `Set start X Y to current position` - устанавливает точку старта анимации у выбранных клиентов в значения текущей позиции по X Y. -- `Reset start position` - устанавливает точку старта анимации у выбранных клиентов в значения `0.0`, `0.0`. +* `Reset start position` - устанавливает точку старта анимации у выбранных клиентов в значения `0.0`, `0.0`. * `Set Z offfset to ground` - устанавливает собственный отступ по Z каждого из выбранных клиентов в значение, равное текущему положению по координате Z. Можно применять для выравнимания общей высоты полёта коптеров. * `Reset Z offfset` - устанавливает собственный отступ по Z каждого из выбранных клиентов в значение `0`. @@ -103,31 +103,31 @@ #### Раздел Server -- ##### Подраздел Music +* Подраздел `Music` ![Скриншот раздела Server - music](../assets/server-music.png) - - `Select music file` - загружает выбранный музыкальный файл для дальнейшего воспроизведения вручную или через определённое время после старта анимации. Поддерживаемые расширения: `.mp3` или `.wav`. - - `Play music` - воспроизводит загруженную музыку. - - `Stop music` - останавливает воспроизведение проигрываемой музыки. + * `Select music file` - загружает выбранный музыкальный файл для дальнейшего воспроизведения вручную или через определённое время после старта анимации. Поддерживаемые расширения: `.mp3` или `.wav`. + * `Play music` - воспроизводит загруженную музыку. + * `Stop music` - останавливает воспроизведение проигрываемой музыки. -- ------ + ------ - `Edit server config` - открывает [встроенный редактор конфигурационных файлов](#config-editor) с текущей конфигурацией сервера для редактирования. Доступен чекбокс `Restart` - в случае, если он нажат, то при сохранении конфигурации сервер будет перезапущен. **Внимание!** Изменённые параметры конфигурации будут применены к серверу только после его перезапуска (ручного или автоматического). +* `Edit server config` - открывает [встроенный редактор конфигурационных файлов](#config-editor) с текущей конфигурацией сервера для редактирования. Доступен чекбокс `Restart` - в случае, если он нажат, то при сохранении конфигурации сервер будет перезапущен. **Внимание!** Изменённые параметры конфигурации будут применены к серверу только после его перезапуска (ручного или автоматического). -- `Edit any config` - открывает [встроенный редактор конфигурационных файлов](#config-editor) и позволяет выбрать для редактирования в файловой системе любой файл конфигурации c расширением `.ini` или же открыть файл спецификации конфигурации для создания файла конфигурации на его основе. +* `Edit any config` - открывает [встроенный редактор конфигурационных файлов](#config-editor) и позволяет выбрать для редактирования в файловой системе любой файл конфигурации c расширением `.ini` или же открыть файл спецификации конфигурации для создания файла конфигурации на его основе. -- `Restart server` - полностью перезапускает сервер. **Внимание!** После перезапуска сервер более не будет соединён с консолью, из которой был запущен, если сервер изначально был запущен из консоли. +* `Restart server` - полностью перезапускает сервер. **Внимание!** После перезапуска сервер более не будет соединён с консолью, из которой был запущен, если сервер изначально был запущен из консоли. #### Раздел Table ![Скриншот раздела Table](../assets/server-table.png) -- `Toggle select` (`Ctrl+A`) - выделят все коптеры\снимает выделение со всех коптеров. Если в таблице выбраны не все коптеры, то *выделяет все* коптеры. Иначе (если были выделены все коптеры) *снимает выделение* со всех коптеров. -- `Select all` - выделят все коптеры в таблице. -- `Deselect all` - снимает выделение со всех коптеров в таблице. -- `Remove selected drones` - удаляет выбранные коптеры из таблицы. **Внимание!** В случае, если клиент был подключен, будет произведено отключение. В случае если удалённый таким образом клиент исправно функционировал, он переподключится в кратчайшие сроки. -- `Configure columns` - открывает [встроенный конфигуратор](#column-preset-editor) наборов настроек столбцов таблицы. +* `Toggle select` (`Ctrl+A`) - выделят все коптеры\снимает выделение со всех коптеров. Если в таблице выбраны не все коптеры, то *выделяет все* коптеры. Иначе (если были выделены все коптеры) *снимает выделение* со всех коптеров. +* `Select all` - выделят все коптеры в таблице. +* `Deselect all` - снимает выделение со всех коптеров в таблице. +* `Remove selected drones` - удаляет выбранные коптеры из таблицы. **Внимание!** В случае, если клиент был подключен, будет произведено отключение. В случае если удалённый таким образом клиент исправно функционировал, он переподключится в кратчайшие сроки. +* `Configure columns` - открывает [встроенный конфигуратор](#column-preset-editor) наборов настроек столбцов таблицы. ### Боковая панель команд @@ -189,8 +189,8 @@ #### Корневой раздел -- `config_name` - Произвольная строка, название файла конфигурации для удобства хранения и быстрого отличия одного файла конфигурации от другого. -- `config_version` - Произвольное дробное число, версия файла конфигурации для удобства хранения и быстрого отличия одного файла конфигурации от другого. +* `config_name` - Произвольная строка, название файла конфигурации для удобства хранения и быстрого отличия одного файла конфигурации от другого. +* `config_version` - Произвольное дробное число, версия файла конфигурации для удобства хранения и быстрого отличия одного файла конфигурации от другого. #### Раздел SERVER @@ -215,9 +215,8 @@ В этом разделе задаются параметры проверок коптера, которые регулируются на стороне сервера. Доступны следующие параметры: -- `check_git_version` - Будет ли производиться проверка соответствия git-версий клиента и сервера для индикации в ячейках столбца `version` -- `check_current_position` - Будет ли производиться проверка корректности текущих координат коптера для индикации в ячейках столбца `current x y z yaw frame_id`. - +* `check_git_version` - Будет ли производиться проверка соответствия git-версий клиента и сервера для индикации в ячейках столбца `version` +* `check_current_position` - Будет ли производиться проверка корректности текущих координат коптера для индикации в ячейках столбца `current x y z yaw frame_id`. * `battery_percentage_min` - Минимальный заряд батарии коптера, допустимый для взлёта. Указывается *в процентах* (дробное значение от 0 до 100). Значение меньше указанного будет отмечено в столбце `battery` как неудовлетворительное. * `start_pos_delta_max` - Максимальное расстояние от текущего положения коптера до его точки взлёта в файле анимации, допустимое для взлёта. Указывается *в метрах* (дробное значение от 0 до 'inf'). Значение больше указанного будет отмечено в столбце `start x y z` как неудовлетворительное. Допустимо использование строки 'inf' для любого допустимого расстояния. * `time_delta_max` - Максимальная разница (абсолютное значение) между временем сервера и клиента (включая сетевую задержку), допустимая для взлёта. Указывается *в секундах* (дробное значение от 0 до 'inf'). Значение больше указанного будет отмечено в столбце `dt` как неудовлетворительное. From 9627bdbdb75988fce43516cc7de196b7b983ca04 Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Fri, 10 Apr 2020 23:39:08 +0300 Subject: [PATCH 209/210] Drone: Update defaults in config and update requirements --- Drone/config/spec/configspec_client.ini | 6 +++--- Drone/requirements.txt | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/Drone/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini index b3c1575..d054bf0 100644 --- a/Drone/config/spec/configspec_client.ini +++ b/Drone/config/spec/configspec_client.ini @@ -13,7 +13,7 @@ port = integer(default=8181, min=1) [TELEMETRY] transmit = boolean(default=True) frequency = float(default=1.0, min=0) -log_resources = boolean(default=True) +log_resources = boolean(default=False) [POSITION WATCHDOG] enabled = boolean(default=True) @@ -65,11 +65,11 @@ frame_delay = float(default=0.1, min=0.01) # Animation ratio (x, y, z) # __list__ x y z ratio = float_list(default=list(1.0, 1.0, 1.0), min=3, max=3) -# Available options: 'animation', 'nan' or a number in degrees +# Available options: 'animation', 'nan' or a number in degrees yaw = string(default=180.0) [LED] -use = boolean(default=True) +use = boolean(default=False) pin = integer(default=21, min=0, max=100) count = integer(default=60, min=1) diff --git a/Drone/requirements.txt b/Drone/requirements.txt index b9b5ed8..f46bac3 100644 --- a/Drone/requirements.txt +++ b/Drone/requirements.txt @@ -1,2 +1,3 @@ selectors2 -psutil \ No newline at end of file +psutil +configobj From 7c6af7f2fab52f412952d1825509775391dffe1e Mon Sep 17 00:00:00 2001 From: Arthur Golubtsov Date: Sat, 11 Apr 2020 03:57:17 +0300 Subject: [PATCH 210/210] Server: Add broadcast ip as parameter to configuration --- Server/config/spec/configspec_server.ini | 1 + Server/server.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini index 84c8c12..7242d1d 100644 --- a/Server/config/spec/configspec_server.ini +++ b/Server/config/spec/configspec_server.ini @@ -45,6 +45,7 @@ config_version = float(default='1.0') send = boolean(default=True) listen = boolean(default=True) port = integer(default=8181) + send_ip = string(default=255.255.255.255) # delay for message sending in seconds delay = float(default=5.0, min=0) diff --git a/Server/server.py b/Server/server.py index 09a9976..8771c81 100644 --- a/Server/server.py +++ b/Server/server.py @@ -190,7 +190,7 @@ class Server(messaging.Singleton): msg = messaging.MessageManager.create_action_message( "server_ip", kwargs={"host": self.ip, "port": str(self.config.server_port), "id": self.id, "start_time": str(self.time_started)}) - logging.debug("Formed broadcast message: {}".format(msg)) + logging.debug("Formed broadcast message to {}:{}: {}".format(self.config.broadcast_send_ip, self.config.broadcast_port, msg)) broadcast_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) broadcast_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) @@ -200,7 +200,7 @@ class Server(messaging.Singleton): while self.broadcast_thread_running.is_set(): self.broadcast_thread_interrupt.wait(timeout=self.config.broadcast_delay) try: - broadcast_sock.sendto(msg, ('255.255.255.255', self.config.broadcast_port)) + broadcast_sock.sendto(msg, (self.config.broadcast_send_ip, self.config.broadcast_port)) except OSError as e: logging.error(f"Cannot send broadcast due error {e}") else: