Files
clever-show/server/modules/copter_table_models.py
2020-06-02 18:09:34 +03:00

724 lines
23 KiB
Python

import os
import re
import sys
import math
import time
import subprocess
from contextlib import suppress
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
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 None # todo probably add special file
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 = {}
battery_min = 50.0
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):
def inner(f):
def wrapper(item, context=None):
if item is None:
return None
if pass_context:
return f(item, context)
return f(item)
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_state # item is not None
@ModelChecks.column_check("git_version")
def check_ver(item):
if not ModelChecks.check_git:
return True
version = get_git_version()
if version is not None:
return version == item
return True
@ModelChecks.column_check("animation_info")
def check_anim(item):
if item:
return str(item[1]) == 'OK'
else:
return False
@ModelChecks.column_check("battery")
def check_bat(item):
if item == "NO_INFO":
return False
return item[1] * 100 > ModelChecks.battery_min
@ModelChecks.column_check("fcu_status")
def check_sys_status(item):
return item == "STANDBY"
@ModelChecks.column_check("calibration_status")
def check_cal_status(item):
return item == "OK"
@ModelChecks.column_check("mode")
def check_mode(item):
return (item != "NO_FCU") and not ("CMODE" in item)
@ModelChecks.column_check("selfcheck")
def check_selfcheck(item):
return item == "OK"
@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])
# @ModelChecks.column_check("last_task")
# def check_task(item):
# return True
@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 len(item) == 6:
if not item[4] in ["takeoff", "fly"]:
return False
if ModelChecks.start_pos_delta_max == 0:
return True
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 not isinstance(position, str) and position[0] != float('nan'):
return position[:3]
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]) ** 2, zip(pos1, pos2)))) # point distance formula
class CopterData:
def __init__(self, columns=(), **kwargs):
self.columns = columns
for column in columns:
setattr(self, column, None)
for attr, value in kwargs.items():
setattr(self, attr, value)
def __getitem__(self, key):
if key in self.columns:
return getattr(self, key)
return getattr(self, self.columns[key])
def __setitem__(self, key, value):
if key in self.columns:
setattr(self, key, value)
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):
if checks_defaults is None:
checks_defaults = {}
self.__dict__['states'] = CopterData(columns, **checks_defaults)
self.__dict__['checks'] = checks_class
self.__dict__['all_checks'] = None
super().__init__(columns, **kwargs)
def __setattr__(self, key, value):
self.__dict__[key] = value
if key in self.columns:
with suppress(KeyError):
self.states.__dict__[key] = \
self.checks.check(key, self)
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 = 1
PLACE_FORMATTER = 2
@classmethod
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(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, column, formatter_type):
def inner(f):
formatters_dict = cls.get_formatter(formatter_type)
formatters_dict[column] = f
def wrapper(value):
return f(value)
return wrapper
return inner
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()
# 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.view_formatter("animation_info")
def view_animation_info(value):
try:
id, state = value
except ValueError:
return ""
else:
if state == 'OK':
return id
else:
return state
@ModelFormatter.place_formatter("battery")
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.view_formatter("battery")
def view_battery(value):
if isinstance(value, list):
battery_v, battery_p = value
return f"{battery_v:4.1f}V {min(battery_p, 1):4.0%}"
return value
@ModelFormatter.view_formatter("selfcheck")
def view_selfcheck(value):
if isinstance(value, list):
if len(value) == 1 and len(value[0]) <= 8:
return value[0]
return "ERROR"
return value
@ModelFormatter.view_formatter("current_position")
def view_current_position(value):
if isinstance(value, list):
x, y, z, yaw, frame = value
return f"{x: .2f} {y: .2f} {z: .2f} {yaw: .0f} {frame}"
return value
@ModelFormatter.view_formatter("start_position")
def view_start_position(value):
if isinstance(value, list):
x, y, z, yaw, action, delay = value
if action in ['fly', 'takeoff']:
return f"{x: .2f} {y: .2f} {z: .2f} {yaw: .0f} {action} {delay: .1f}"
else:
return f"{action}"
return value
@ModelFormatter.place_formatter("last_task")
def place_last_task(value):
if value is None: # TODO possible behaviour deviation
return 'No task'
return value
@ModelFormatter.place_formatter("time_delta")
def place_time_delta(value):
return abs(value - time.time())
@ModelFormatter.view_formatter("time_delta")
def view_time_delta(value):
return f"{value:.3f}"
class CopterDataModel(QtCore.QAbstractTableModel):
columns_dict = {'copter_id': 'copter ID',
'git_version': 'version',
'config_version': 'configuration',
'animation_info': '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 yaw action delay',
'last_task': 'last task',
'time_delta': 'dt',
}
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)
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 = list(self.columns_dict.values())
self.data_contents = []
self.checks = checks
self.formatter = formatter
self.data_model = data_model
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)
def insertRows(self, contents, position='last', parent=QtCore.QModelIndex()):
rows = len(contents)
position = len(self.data_contents) if position == 'last' else position
self.beginInsertRows(parent, position, position + rows - 1)
self.data_contents[position:position] = contents
self.endInsertRows()
def removeRows(self, position, rows=1, index=QtCore.QModelIndex()):
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
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 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))
def get_row_data(self, index):
row = index.row()
if row == -1:
return None
try:
return self.data_contents[row]
except IndexError:
return None
def get_row_index(self, row_data):
try:
return self.data_contents.index(row_data)
except ValueError:
return None
def get_row_by_attr(self, attr, value):
try:
return next(filter(lambda x: getattr(x, attr, None) == value, self.data_contents))
except StopIteration:
return None
def rowCount(self, n=None):
return len(self.data_contents)
def columnCount(self, n=None):
return len(self.headers)
def headerData(self, section, orientation, role=Qt.DisplayRole):
if role == Qt.DisplayRole and orientation == Qt.Horizontal:
return self.headers[section]
def data(self, index, role=Qt.DisplayRole):
row = index.row()
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(self.columns[col], item)) if item is not None else ""
elif role == ModelDataRole:
return self.data_contents[row][col]
elif role == Qt.BackgroundRole:
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:
return self.data_contents[row].states.checked
if role == QtCore.Qt.TextAlignmentRole and col != 0:
return QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter
def emit_signals(self):
selected = set(self.user_selected())
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))
@QtCore.pyqtSlot()
def setData(self, index, value, role=Qt.EditRole):
if not index.isValid():
return False
col = index.column()
row = index.row()
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(self.columns[col], value)
if formatted_value is None: # todo use new := syntax
return False
self.data_contents[row][col] = formatted_value
if col == 0:
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
elif role == ModelStateRole:
self.data_contents[row].states[col] = value
else:
return False
self.emit_signals()
self.dataChanged.emit(index, index, (role,))
return True
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):
return Qt.CopyAction | Qt.MoveAction
def mimeTypes(self):
return ['text/uri-list']
def mimeData(self, indexes):
index = indexes[0]
if self.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.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)
config_dict = config.full_dict(include_defaults=False)
config_dict.pop("PRIVATE", None)
self.data_contents[row].client.send_message("config", kwargs={
"config": config_dict, "mode": "rewrite"})
return False
# Thread-safe wrappers
def add_client(self, **kwargs):
default_states = {"checked": 0, "copter_id": True}
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_data(self, row, col, value, role=Qt.EditRole):
self.setData(self.index(row, col), value, role)
@QtCore.pyqtSlot(object)
def _add_client(self, client):
self.insertRows([client])
@QtCore.pyqtSlot(int) # Probably deprecated now
def _remove_row(self, row):
self.removeRows(row)
@QtCore.pyqtSlot(object)
def _remove_row_data(self, data):
row = self.get_row_index(data)
if row is not None:
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):
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)
class CopterProxyModel(QtCore.QSortFilterProxyModel):
def __init__(self, parent=None):
super(CopterProxyModel, self).__init__(parent)
@staticmethod
def human_sort_prepare(item):
if item:
item = [int(x) if x.isdigit() else x.lower() for x in re.split('([0-9]+)', str(item))]
else:
item = []
return item
def lessThan(self, left, right):
leftData = self.sourceModel().data(left)
rightData = self.sourceModel().data(right)
return self.human_sort_prepare(leftData) < self.human_sort_prepare(rightData)
if __name__ == '__main__':
import time
def timer():
idc = 1001
while True:
myModel.setData(myModel.index(0, 0), idc)
idc += 1
time.sleep(1)
app = QtWidgets.QApplication.instance()
if app is None:
app = QtWidgets.QApplication(sys.argv)
tableView = QtWidgets.QTableView()
myModel = CopterDataModel()
proxyModel = CopterProxyModel()
proxyModel.setDynamicSortFilter(True)
proxyModel.setSourceModel(myModel)
tableView.setModel(proxyModel)
tableView.verticalHeader().hide()
tableView.setSortingEnabled(True)
tableView.show()
msgs = []
msg = "[{}]: Failure: {}".format("FCU connection", "Angular velocities estimation is not available")
msgs.append(msg)
msg = "[{}]: Failure: {}".format("FCU connection1", "Angular velocities estimation is not available")
msgs.append(msg)
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(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_()