diff --git a/Drone/FlightLib/FlightLib.py b/Drone/FlightLib/FlightLib.py index b7dae37..deae299 100644 --- a/Drone/FlightLib/FlightLib.py +++ b/Drone/FlightLib/FlightLib.py @@ -69,7 +69,7 @@ def check(check_name): failures = f(*args, **kwargs) msgs = [] for failure in failures: - msg = "[{}]: Failure: {}".format(check_name, failure) + msg = "[{}]: Err: {}".format(check_name, failure) msgs.append(msg) logger.warning(msg) diff --git a/Drone/animation_lib.py b/Drone/animation_lib.py index 28b7374..4c12b92 100644 --- a/Drone/animation_lib.py +++ b/Drone/animation_lib.py @@ -40,7 +40,7 @@ def get_id(filepath="animation.csv"): print("No animation id in file") return anim_id -def load_animation(filepath="animation.csv", x0=0, y0=0, z0=0, ratio=1): +def load_animation(filepath="animation.csv", x0=0, y0=0, z0=0, x_ratio=1, y_ratio=1, z_ratio=1): imported_frames = [] global anim_id try: @@ -62,9 +62,9 @@ def load_animation(filepath="animation.csv", x0=0, y0=0, z0=0, ratio=1): frame_number, x, y, z, yaw, red, green, blue = row_0 imported_frames.append({ 'number': int(frame_number), - 'x': ratio*float(x) + x0, - 'y': ratio*float(y) + y0, - 'z': ratio*float(z) + z0, + '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), @@ -74,9 +74,9 @@ def load_animation(filepath="animation.csv", x0=0, y0=0, z0=0, ratio=1): frame_number, x, y, z, yaw, red, green, blue = row imported_frames.append({ 'number': int(frame_number), - 'x': ratio*float(x) + x0, - 'y': ratio*float(y) + y0, - 'z': ratio*float(z) + z0, + '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), diff --git a/Drone/client.py b/Drone/client.py index 2b6664b..aed8dc4 100644 --- a/Drone/client.py +++ b/Drone/client.py @@ -47,6 +47,8 @@ class Client(object): global active_client active_client = self + # self._last_ping_time = 0 + def load_config(self): self.config.read(self.config_path) @@ -58,14 +60,16 @@ class Client(object): self.NTP_HOST = self.config.get('NTP', 'host') self.NTP_PORT = self.config.getint('NTP', 'port') - self.files_directory = self.config.get('FILETRANSFER', 'files_directory') + self.files_directory = self.config.get('FILETRANSFER', 'files_directory') # not used?! self.client_id = self.config.get('PRIVATE', 'id') - if self.client_id == 'default': + if self.client_id == '/default': self.client_id = 'copter' + str(random.randrange(9999)).zfill(4) - self.write_config(False, 'PRIVATE', 'id', self.client_id) + self.write_config(False, ConfigOption('PRIVATE', 'id', self.client_id)) elif self.client_id == '/hostname': self.client_id = socket.gethostname() + elif self.client_id == '/ip': + self.client_id = messaging.get_ip_address() def rewrite_config(self): with open(self.config_path, 'w') as file: @@ -103,7 +107,7 @@ class Client(object): try: while True: self._reconnect() - #self._process_connections() + self._process_connections() except (KeyboardInterrupt, ): logger.critical("Caught interrupt, exiting!") @@ -117,6 +121,8 @@ 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) + self.client_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) self.client_socket.connect((self.server_host, self.server_port)) except socket.error as error: if isinstance(error, OSError): @@ -138,15 +144,12 @@ class Client(object): self.broadcast_bind(timeout*2, attempt_limit) attempt_count = 0 - 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._process_connections() - def broadcast_bind(self, timeout=3.0, attempt_limit=5): broadcast_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -187,6 +190,9 @@ class Client(object): 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: # TODO add notifier to client! connection = key.data @@ -200,7 +206,7 @@ class Client(object): logger.error( "Exception {} occurred for {}! Resetting connection!".format(error, connection.addr) ) - self.server_connection.close() + self.server_connection._close() self.connected = False if isinstance(error, OSError): @@ -213,20 +219,28 @@ class Client(object): return -@messaging.request_callback("id") -def _response_id(): - return active_client.client_id - -@messaging.request_callback("time") -def _response_time(): - return active_client.time_now() - @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)) active_client.write_config(kwargs["reload"], *options) + +@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) + + return active_client.client_id + + +@messaging.request_callback("time") +def _response_time(*args, **kwargs): + return active_client.time_now() + + if __name__ == "__main__": client = Client() client.start() diff --git a/Drone/client_config.ini b/Drone/client_config.ini index 1170081..108adcd 100644 --- a/Drone/client_config.ini +++ b/Drone/client_config.ini @@ -17,7 +17,9 @@ port = 123 takeoff_animation_check = True land_animation_check = True frame_delay = 0.1 -ratio = 1.0 +x_ratio = 1.0 +y_ratio = 1.0 +z_ratio = 1.0 [COPTERS] frame_id = floor @@ -41,7 +43,8 @@ yaw = -90 [PRIVATE] id = /hostname -use_leds = False +restart_dhcpcd = True +use_leds = True led_pin = 21 x0 = 0 y0 = 0 diff --git a/Drone/copter_client.py b/Drone/copter_client.py index 92cd73b..b093424 100644 --- a/Drone/copter_client.py +++ b/Drone/copter_client.py @@ -49,13 +49,17 @@ class CopterClient(client.Client): 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.RATIO = self.config.getfloat('ANIMATION', 'ratio') + 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') + self.RESTART_DHCPCD = self.config.getboolean('PRIVATE', 'restart_dhcpcd') + def on_broadcast_bind(self): configure_chrony_ip(self.server_host) restart_service("chrony") @@ -97,6 +101,9 @@ class CopterClient(client.Client): 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: @@ -130,8 +137,114 @@ def configure_chrony_ip(ip, path="/etc/chrony/chrony.conf", ip_index=1): return True +def configure_hostname(hostname): + path = "/etc/hostname" + try: + with open(path, 'r') as f: + raw_content = f.read() + except IOError as e: + print("Reading error {}".format(e)) + return False + + current_hostname = str(raw_content) + + if current_hostname != hostname: + content = hostname + '\n' + try: + with open(path, 'w') as f: + f.write(content) + except IOError: + print("Error writing") + return False + + return True + + +def configure_hosts(hostname): + path = "/etc/hosts" + try: + with open(path, 'r') as f: + raw_content = f.read() + except IOError as e: + print("Reading error {}".format(e)) + return False + + index_start = raw_content.find("127.0.1.1", ) + index_stop = raw_content.find("\n", index_start) + + _ip, current_hostname = raw_content[index_start:index_stop].split() + if current_hostname != hostname: + content = raw_content[:index_start] + "{} {}".format(_ip, hostname) + raw_content[index_stop:] + try: + with open(path, 'w') as f: + f.write(content) + except IOError: + print("Error writing") + return False + + 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: + with open(path, 'r') as f: + raw_content = f.read() + except IOError as e: + print("Reading error {}".format(e)) + return False + + index_start = raw_content.find("ROS_HOSTNAME='", ) + 14 + index_stop = raw_content.find("'", index_start) + + current_hostname = raw_content[index_start:index_stop] + if current_hostname != hostname: + content = raw_content[:index_start] + hostname + raw_content[index_stop:] + try: + with open(path, 'w') as f: + f.write(content) + except IOError: + print("Error writing") + return False + + return True + +@messaging.message_callback("execute") +def _execute(*args, **kwargs): + command = kwargs.get("command", None) + if command: + execute_command(command) + +@messaging.message_callback("id") +def _response_id(*args, **kwargs): + new_id = kwargs.get("new_id", None) + 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) + if new_id != '/hostname': + if client.active_client.RESTART_DHCPCD: + hostname = client.active_client.client_id + configure_hostname(hostname) + configure_hosts(hostname) + 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") + restart_service("clever-show") + + @messaging.request_callback("selfcheck") -def _response_selfcheck(): +def _response_selfcheck(*args, **kwargs): if check_state_topic(wait_new_status=True): check = FlightLib.selfcheck() return check if check else "OK" @@ -141,7 +254,7 @@ def _response_selfcheck(): @messaging.request_callback("anim_id") -def _response_animation_id(): +def _response_animation_id(*args, **kwargs): # Load animation result = animation.get_id() if result != 'No animation': @@ -150,7 +263,9 @@ def _response_animation_id(): 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, - ratio=client.active_client.RATIO, + 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, @@ -163,7 +278,7 @@ def _response_animation_id(): return result @messaging.request_callback("batt_voltage") -def _response_batt(): +def _response_batt(*args, **kwargs): if check_state_topic(wait_new_status=True): return FlightLib.get_telemetry('body').voltage else: @@ -172,7 +287,7 @@ def _response_batt(): @messaging.request_callback("cell_voltage") -def _response_cell(): +def _response_cell(*args, **kwargs): if check_state_topic(wait_new_status=True): return FlightLib.get_telemetry('body').cell_voltage else: @@ -180,42 +295,45 @@ def _response_cell(): return float('nan') @messaging.request_callback("sys_status") -def _response_sys_status(): +def _response_sys_status(*args, **kwargs): return get_sys_status() @messaging.request_callback("cal_status") -def _response_cal_status(): +def _response_cal_status(*args, **kwargs): return get_calibration_status() @messaging.request_callback("position") -def _response_position(): +def _response_position(*args, **kwargs): telem = FlightLib.get_telemetry(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(): +def _calibrate_gyro(*args, **kwargs): calibrate('gyro') return get_calibration_status() @messaging.request_callback("calibrate_level") -def _calibrate_level(): +def _calibrate_level(*args, **kwargs): calibrate('level') return get_calibration_status() @messaging.message_callback("test") -def _command_test(**kwargs): +def _command_test(*args, **kwargs): logger.info("logging info test") print("stdout test") @messaging.message_callback("move_start") -def _command_move_start_to_current_position(**kwargs): +def _command_move_start_to_current_position(*args, **kwargs): # Load animation frames = animation.load_animation(os.path.abspath("animation.csv"), - x0=client.active_client.X0_COMMON, - y0=client.active_client.Y0_COMMON, - ratio=client.active_client.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, @@ -232,7 +350,7 @@ def _command_move_start_to_current_position(**kwargs): print ("Start delta: {:.2f} {:.2f}".format(client.active_client.X0, client.active_client.Y0)) @messaging.message_callback("reset_start") -def _command_reset_start(**kwargs): +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() @@ -240,7 +358,7 @@ def _command_reset_start(**kwargs): print ("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(**kwargs): +def _command_set_z(*args, **kwargs): telem = FlightLib.get_telemetry(client.active_client.FRAME_ID) client.active_client.config.set('PRIVATE', 'z0', telem.z) client.active_client.rewrite_config() @@ -248,7 +366,7 @@ def _command_set_z(**kwargs): print ("Set z offset to {:.2f}".format(client.active_client.Z0)) @messaging.message_callback("reset_z_offset") -def _command_reset_z(**kwargs): +def _command_reset_z(*args, **kwargs): client.active_client.config.set('PRIVATE', 'z0', 0) client.active_client.rewrite_config() client.active_client.load_config() @@ -256,36 +374,36 @@ def _command_reset_z(**kwargs): @messaging.message_callback("update_repo") -def _command_update_repo(**kwargs): +def _command_update_repo(*args, **kwargs): os.system("git reset --hard origin/master") os.system("git fetch") os.system("git pull") os.system("chown -R pi:pi ~/CleverSwarm") @messaging.message_callback("reboot_fcu") -def _command_reboot(): +def _command_reboot(*args, **kwargs): reboot_fcu() @messaging.message_callback("service_restart") -def _command_service_restart(**kwargs): +def _command_service_restart(*args, **kwargs): restart_service(kwargs["name"]) @messaging.message_callback("repair_chrony") -def _command_chrony_repair(): +def _command_chrony_repair(*args, **kwargs): configure_chrony_ip(client.active_client.server_host) restart_service("chrony") @messaging.message_callback("led_test") -def _command_led_test(**kwargs): +def _command_led_test(*args, **kwargs): LedLib.chase(255, 255, 255) time.sleep(2) LedLib.off() @messaging.message_callback("led_fill") -def _command_led_fill(**kwargs): +def _command_led_fill(*args, **kwargs): r = kwargs.get("red", 0) g = kwargs.get("green", 0) b = kwargs.get("blue", 0) @@ -294,11 +412,11 @@ def _command_led_fill(**kwargs): @messaging.message_callback("flip") -def _copter_flip(): +def _copter_flip(*args, **kwargs): FlightLib.flip(frame_id=client.active_client.FRAME_ID) @messaging.message_callback("takeoff") -def _command_takeoff(**kwargs): +def _command_takeoff(*args, **kwargs): task_manager.add_task(time.time(), 0, animation.takeoff, task_kwargs={ "z": client.active_client.TAKEOFF_HEIGHT, @@ -310,7 +428,7 @@ def _command_takeoff(**kwargs): @messaging.message_callback("land") -def _command_land(**kwargs): +def _command_land(*args, **kwargs): task_manager.reset() task_manager.add_task(0, 0, animation.land, task_kwargs={ @@ -323,7 +441,7 @@ def _command_land(**kwargs): @messaging.message_callback("disarm") -def _command_disarm(**kwargs): +def _command_disarm(*args, **kwargs): task_manager.reset() task_manager.add_task(-5, 0, FlightLib.arming_wrapper, task_kwargs={ @@ -333,22 +451,22 @@ def _command_disarm(**kwargs): @messaging.message_callback("stop") -def _command_stop(**kwargs): +def _command_stop(*args, **kwargs): task_manager.reset() @messaging.message_callback("pause") -def _command_pause(**kwargs): +def _command_pause(*args, **kwargs): task_manager.pause() @messaging.message_callback("resume") -def _command_resume(**kwargs): +def _command_resume(*args, **kwargs): task_manager.resume(time_to_start_next_task=kwargs.get("time", 0)) @messaging.message_callback("start") -def _play_animation(**kwargs): +def _play_animation(*args, **kwargs): start_time = float(kwargs["time"]) # Check if animation file is available if animation.get_id() == 'No animation': @@ -363,7 +481,9 @@ def _play_animation(**kwargs): 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, - ratio=client.active_client.RATIO, + 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, diff --git a/README.md b/README.md index 2f7bf21..cbde450 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,9 @@ Software for making the drone show controlled by Raspberry Pi and COEX [Clever]( * [Raspberry Pi image](https://github.com/CopterExpress/clever-show/releases/latest) for quick launch software on the drones ## Documentation -Start tutorial is located [here](docs/start-tutorial.md). +> Documentation is available only in Russian for now. + +Start tutorial is located [here](docs/ru/start-tutorial.md). Detailed documentation is located in the [docs](https://github.com/CopterExpress/clever-show/tree/master/docs) folder. diff --git a/README_RU.md b/README_RU.md index 19b7d9d..5659fdc 100644 --- a/README_RU.md +++ b/README_RU.md @@ -12,7 +12,7 @@ * [Образ для Raspberry Pi](https://github.com/CopterExpress/clever-show/releases/latest) для быстрого запуска ПО на коптере ## Документация -Инструкция по запуску ПО находится [здесь](docs/start-tutorial.md). +Инструкция по запуску ПО находится [здесь](docs/ru/start-tutorial.md). Подробная документация расположена в папке [docs](https://github.com/CopterExpress/clever-show/tree/master/docs). diff --git a/Server/copter_table_models.py b/Server/copter_table_models.py index b5f9e53..0905635 100644 --- a/Server/copter_table_models.py +++ b/Server/copter_table_models.py @@ -1,32 +1,72 @@ import sys import re import collections +import indexed +from server import ConfigOption from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import Qt as Qt +ModelDataRole = 998 +ModelStateRole = 999 + + class CopterData: - class_attrs = collections.OrderedDict([('copter_id', None), ('anim_id', None), ('batt_v', None), ('batt_p', None), - ('sys_status', None), ('cal_status', None), ('selfcheck', None), ('position', None), ("time_delta", None), - ("client", None), ("checked", 0)], ) + class_basic_attrs = indexed.IndexedOrderedDict([('copter_id', None), ('anim_id', None), + ('batt_v', None), ('batt_p', None), + ('sys_status', None), ('cal_status', None), ('selfcheck', None), + ('position', None), ("time_delta", None), + ("client", None), ]) def __init__(self, **kwargs): - self.attrs = self.class_attrs.copy() - self.attrs.update(kwargs) + self.attrs_dict = self.class_basic_attrs.copy() + self.attrs_dict.update(kwargs) - for attr, value in self.attrs.items(): + for attr, value in self.attrs_dict.items(): setattr(self, attr, value) def __getitem__(self, key): - return getattr(self, list(self.attrs.keys())[key]) + return getattr(self, self.attrs_dict.keys()[key]) def __setitem__(self, key, value): - setattr(self, list(self.attrs.keys())[key], value) + setattr(self, self.attrs_dict.keys()[key], value) + + +class StatedCopterData(CopterData): + class_basic_states = indexed.IndexedOrderedDict([("checked", 0), ("selfchecked", None), ("takeoff_ready", None), + ("copter_id", True), ]) + + def __init__(self, **kwargs): + self.states = CopterData(**self.class_basic_states) + + super(StatedCopterData, self).__init__(**kwargs) + + def __setattr__(self, key, value): + self.__dict__[key] = value + + if key in self.class_basic_attrs.keys(): + try: + self.states.__dict__[key] = \ + Checks.all_checks[self.attrs_dict.keys().index(key)](value) + 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.__dict__["takeoff_ready"] = all( + [self.states[i] for i in Checks.takeoff_checklist] + ) + + +class Checks: + all_checks = {} + takeoff_checklist = (2, 3, 4, 5, 6) class CopterDataModel(QtCore.QAbstractTableModel): - checks = {} selected_ready_signal = QtCore.pyqtSignal(bool) selected_takeoff_ready_signal = QtCore.pyqtSignal(bool) selected_flip_ready_signal = QtCore.pyqtSignal(bool) @@ -35,8 +75,12 @@ class CopterDataModel(QtCore.QAbstractTableModel): def __init__(self, parent=None): super(CopterDataModel, self).__init__(parent) - self.headers = ('copter ID', ' animation ID ', 'batt V', 'batt %', ' system ', 'calibration', 'selfcheck', 'current x y z yaw frame_id', 'time delta') + self.headers = ('copter ID', ' animation ID ', 'batt V', 'batt %', ' system ', + 'calibration', 'selfcheck', 'current x y z yaw frame_id', 'time delta') self.data_contents = [] + + self.on_id_changed = None + self.first_col_is_checked = False def insertRows(self, contents, position='last', parent=QtCore.QModelIndex()): @@ -48,20 +92,28 @@ class CopterDataModel(QtCore.QAbstractTableModel): self.endInsertRows() - def user_selected(self): - return filter(lambda x: x.checked == Qt.Checked, self.data_contents) + 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() + + return True + + 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: all_checks(x), contents) + return filter(lambda x: x.states.selfchecked, contents) def takeoff_ready(self, contents=()): contents = contents or self.data_contents - return filter(lambda x: takeoff_checks(x), contents) + return filter(lambda x: x.states.takeoff_ready, contents) def flip_ready(self, contents=()): contents = contents or self.data_contents - return filter(lambda x: flip_checks(x), contents) + return filter(lambda x: flip_checks(x), contents) # possibly change as takeoff checks def calibrating(self, contents=()): contents = contents or self.data_contents @@ -71,6 +123,22 @@ class CopterDataModel(QtCore.QAbstractTableModel): contents = contents or self.data_contents return filter(lambda x: calibration_ready_check(x), contents) + def get_row_index(self, row_data): + try: + index = 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)) + except StopIteration: + return None + else: + return row_data + def rowCount(self, n=None): return len(self.data_contents) @@ -85,15 +153,19 @@ class CopterDataModel(QtCore.QAbstractTableModel): def data(self, index, role=Qt.DisplayRole): row = index.row() col = index.column() - #print('row {}, col {}, role {}'.format(row, col, role)) - if role == Qt.DisplayRole: - #print(self.data_contents[row][col]) - return self.data_contents[row][col] or "" + 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 "" + elif role == ModelDataRole: + return self.data_contents[row][col] elif role == Qt.BackgroundRole: - if col in self.checks.keys(): - item = self.data_contents[row][col] - result = self.checks[col](item) + 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: @@ -102,61 +174,85 @@ class CopterDataModel(QtCore.QAbstractTableModel): return QtGui.QBrush(Qt.red) elif role == Qt.CheckStateRole and col == 0: - return self.data_contents[row].checked + return self.data_contents[row].states.checked if role == QtCore.Qt.TextAlignmentRole and col != 0: return QtCore.Qt.AlignHCenter | QtCore.Qt.AlignVCenter - def update_model(self, index=QtCore.QModelIndex()): - #self.modelReset.emit() - self.selected_ready_signal.emit(set(self.user_selected()).issubset(self.selfchecked_ready())) - self.selected_takeoff_ready_signal.emit(set(self.user_selected()).issubset(self.takeoff_ready())) - self.selected_flip_ready_signal.emit(set(self.user_selected()).issubset(self.flip_ready())) - self.selected_calibrating_signal.emit(set(self.user_selected()).issubset(self.calibrating())) - self.selected_calibration_ready_signal.emit(set(self.user_selected()).issubset(self.calibration_ready())) - self.dataChanged.emit(index, index, (QtCore.Qt.EditRole,)) + 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.dataChanged.emit(index, index, (role,)) @QtCore.pyqtSlot() def setData(self, index, value, role=Qt.EditRole): if not index.isValid(): return False - if role == Qt.CheckStateRole: - self.data_contents[index.row()].checked = value + col = index.column() + row = index.row() - elif role == Qt.EditRole: - self.data_contents[index.row()][index.column()] = value - self.update_model(index) + 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}) + 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 + elif role == ModelStateRole: + self.data_contents[row].states[col] = value else: return False + self.update_model(index, role) return True def select_all(self): self.first_col_is_checked = not self.first_col_is_checked - for copter in self.data_contents: - copter.checked = int(self.first_col_is_checked)*2 - for row in range(len(self.data_contents)): - self.update_model(self.index(row, 0)) + 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 + roles |= Qt.ItemIsUserCheckable | Qt.ItemIsEditable return roles - @QtCore.pyqtSlot(int, int, QtCore.QVariant) - def update_item(self, row, col, value): - self.setData(self.index(row, col), value) + @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) @QtCore.pyqtSlot(object) def add_client(self, client): self.insertRows([client]) + @QtCore.pyqtSlot(int) + def remove_client(self, row): + self.removeRows(row) + def col_check(col): def inner(f): - CopterDataModel.checks[col] = f + Checks.all_checks[col] = f def wrapper(*args, **kwargs): return f(*args, **kwargs) @@ -172,42 +268,49 @@ def check_anim(item): return None return str(item) != 'No animation' + @col_check(2) def check_bat_v(item): if not item: return None return float(item) > 3.2 + @col_check(3) def check_bat_p(item): if not item: return None return float(item) > 30 + @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_selfcheck(item): if not item: return None return item == "OK" + @col_check(7) def check_cal_status(item): if not item: return None return True + @col_check(8) def check_time_delta(item): if not item: @@ -216,35 +319,40 @@ def check_time_delta(item): def all_checks(copter_item): - for col, check in CopterDataModel.checks.items(): + for col, check in Checks.all_checks.items(): if not check(copter_item[col]): return False return True + def takeoff_checks(copter_item): - for i in range(5): - if not CopterDataModel.checks[2+i](copter_item[2+i]): + for col in Checks.takeoff_checklist: + if not Checks.all_checks[col](copter_item[col]): return False return True + def flip_checks(copter_item): - for i in range(5): - if 2+i != 4: - if not CopterDataModel.checks[2+i](copter_item[2+i]): + for col in Checks.takeoff_checklist: + if col != 4: + if not Checks.all_checks[col](copter_item[col]): return False else: if copter_item[4] != "ACTIVE": return False return True + def calibrating_check(copter_item): return copter_item[5] == "CALIBRATING" + def calibration_ready_check(copter_item): - if not CopterDataModel.checks[4](copter_item[4]): + if not Checks.all_checks[4](copter_item[4]): return False return not calibrating_check(copter_item) + class CopterProxyModel(QtCore.QSortFilterProxyModel): def __init__(self, parent=None): super(CopterProxyModel, self).__init__(parent) @@ -265,8 +373,9 @@ class CopterProxyModel(QtCore.QSortFilterProxyModel): class SignalManager(QtCore.QObject): - update_data_signal = QtCore.pyqtSignal(int, int, QtCore.QVariant) + update_data_signal = QtCore.pyqtSignal(int, int, QtCore.QVariant, QtCore.QVariant) add_client_signal = QtCore.pyqtSignal(object) + remove_client_signal = QtCore.pyqtSignal(int) if __name__ == '__main__': @@ -296,9 +405,18 @@ if __name__ == '__main__': tableView.setSortingEnabled(True) tableView.show() - myModel.add_client(CopterData(copter_id=1000, checked=0, time_utc=1)) - myModel.add_client(CopterData(checked=2, selfcheck="OK", time_utc=2)) - myModel.add_client(CopterData(checked=2, selfcheck="not ok", time_utc="no")) + + 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.setData(myModel.index(0, 1), "test") diff --git a/Server/server.py b/Server/server.py index e19ba2d..311b924 100644 --- a/Server/server.py +++ b/Server/server.py @@ -28,9 +28,7 @@ logging.basicConfig( # TODO all prints as logs ConfigOption = collections.namedtuple("ConfigOption", ["section", "option", "value"]) -class Server: - BUFFER_SIZE = 1024 - +class Server(messaging.Singleton): def __init__(self, server_id=None, config_path="server_config.ini", on_stop=None): self.id = server_id if server_id else str(random.randint(0, 9999)).zfill(4) self.time_started = 0 @@ -41,8 +39,11 @@ class Server: 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) + self.server_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + self.host = socket.gethostname() - self.ip = Server.get_ip_address() + self.ip = messaging.get_ip_address() # Init configs self.config_path = config_path @@ -65,7 +66,9 @@ class Server: def load_config(self): self.config.read(self.config_path) self.port = int(self.config['SERVER']['port']) # TODO try, init def - Server.BUFFER_SIZE = int(self.config['SERVER']['buffer_size']) + self.BUFFER_SIZE = int(self.config['SERVER']['buffer_size']) # TODO connect to connection manager + + 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']) @@ -112,16 +115,6 @@ class Server: sys.exit("Stopped") - @staticmethod - def get_ip_address(): - try: - with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as ip_socket: - ip_socket.connect(("8.8.8.8", 80)) - return ip_socket.getsockname()[0] - except OSError: - logging.warning("No network connection detected, starting on localhost") - return "localhost" - @staticmethod def get_ntp_time(ntp_host, ntp_port): NTP_DELTA = 2208988800 # 1970-01-01 00:00:00 @@ -149,7 +142,7 @@ class Server: while self.client_processor_thread_running.is_set(): events = self.sel.select() - logging.error('tick') + #logging.error('tick') for key, mask in events: # logging.error(mask) # logging.error(str(key.data)) @@ -161,7 +154,7 @@ class Server: client.process_events(mask) except Exception as error: logging.error("Exception {} occurred for {}! Resetting connection!".format(error, client.addr)) - client.close() + client.close(True) else: # Notifier client.process_events(mask) @@ -218,7 +211,7 @@ class Server: try: while self.listener_thread_running.is_set(): - data, addr = broadcast_client.recvfrom(1024) # TODO nonblock + data, addr = broadcast_client.recvfrom(1024) # TODO nonblock message = messaging.MessageManager() message.income_raw = data message.process_message() @@ -301,16 +294,28 @@ class Client(messaging.ConnectionManager): def _got_id(self, value): logging.info("Got copter id: {} for client {}".format(value, self.addr)) self.copter_id = value - if Client.on_first_connect: - Client.on_first_connect(self) + if self.on_first_connect: + self.on_first_connect(self) - def close(self): + def close(self, inner=False): self.connected = False - if Client.on_disconnect: - Client.on_disconnect(self) + if self.on_disconnect: + self.on_disconnect(self) - super(Client, self).close() + if inner: + super(Client, self)._close() + else: + super(Client, self).close() + + logging.info("Connection to {} closed!".format(self.copter_id)) + + def remove(self): + if self.connected: + self.close() + if self.clients: + self.clients.pop(self.addr[0]) + logging.info("Client {} successfully removed!".format(self.copter_id)) @requires_connect def _send(self, data): diff --git a/Server/server_config.ini b/Server/server_config.ini index 0614208..5160e2d 100644 --- a/Server/server_config.ini +++ b/Server/server_config.ini @@ -1,6 +1,7 @@ [SERVER] port = 25000 buffer_size = 1024 +remove_disconnected = True [BROADCAST] use_broadcast = True diff --git a/Server/server_gui.py b/Server/server_gui.py index 0826274..794d524 100644 --- a/Server/server_gui.py +++ b/Server/server_gui.py @@ -167,18 +167,18 @@ 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, 1220, 25)) + self.menubar.setGeometry(QtCore.QRect(0, 0, 1220, 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.menuTable = QtWidgets.QMenu(self.menubar) - self.menuTable.setObjectName("menuTable") 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") MainWindow.setMenuBar(self.menubar) @@ -214,28 +214,46 @@ class Ui_MainWindow(object): self.action_play_music.setObjectName("action_play_music") self.action_test_music_after = QtWidgets.QAction(MainWindow) self.action_test_music_after.setObjectName("action_test_music_after") - self.menuDeveloper_mode.addAction(self.action_send_launch_file) - self.menuDeveloper_mode.addAction(self.action_restart_clever) - self.menuDeveloper_mode.addAction(self.action_restart_clever_show) - self.menuDeveloper_mode.addAction(self.action_update_client_repo) + self.actionFill = QtWidgets.QAction(MainWindow) + self.actionFill.setObjectName("actionFill") + self.action_send_any_file = QtWidgets.QAction(MainWindow) + self.action_send_any_file.setObjectName("action_send_any_file") + self.actionSend_any_command = QtWidgets.QAction(MainWindow) + self.actionSend_any_command.setObjectName("actionSend_any_command") + self.action_stop_music = QtWidgets.QAction(MainWindow) + self.action_stop_music.setObjectName("action_stop_music") + self.action_remove_row = QtWidgets.QAction(MainWindow) + self.action_remove_row.setObjectName("action_remove_row") + self.action_send_calibrations = QtWidgets.QAction(MainWindow) + self.action_send_calibrations.setObjectName("action_send_calibrations") + self.menuDeveloper_mode.addAction(self.action_send_any_file) + self.menuDeveloper_mode.addAction(self.actionSend_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.addSeparator() self.menuOptions.addAction(self.menuDeveloper_mode.menuAction()) - self.menuTable.addAction(self.action_select_all_rows) + 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.menuDrone.addAction(self.action_set_z_offset_to_ground) self.menuDrone.addAction(self.action_reset_z_offset) + self.menuDrone.addSeparator() + self.menuDrone.addAction(self.menuDeveloper_mode_2.menuAction()) + self.menuDrone.addAction(self.action_remove_row) self.menuMusic.addAction(self.action_select_music_file) self.menuMusic.addAction(self.action_play_music) - self.menuMusic.addAction(self.action_test_music_after) + self.menuMusic.addAction(self.action_stop_music) self.menubar.addAction(self.menuOptions.menuAction()) - self.menubar.addAction(self.menuAnimation.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.retranslateUi(MainWindow) QtCore.QMetaObject.connectSlotsByName(MainWindow) @@ -243,7 +261,7 @@ class Ui_MainWindow(object): def retranslateUi(self, MainWindow): _translate = QtCore.QCoreApplication.translate - MainWindow.setWindowTitle(_translate("MainWindow", "Clever Drone Animation Player")) + MainWindow.setWindowTitle(_translate("MainWindow", "Clever Drone Show")) self.music_text.setText(_translate("MainWindow", " Music after")) self.music_delay_spin.setSuffix(_translate("MainWindow", " s")) self.music_play_text.setText(_translate("MainWindow", " Play music")) @@ -263,21 +281,21 @@ class Ui_MainWindow(object): 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", "Actions")) + self.menuOptions.setTitle(_translate("MainWindow", "Server")) self.menuDeveloper_mode.setTitle(_translate("MainWindow", "Developer mode")) - self.menuTable.setTitle(_translate("MainWindow", "Table")) 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.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_update_client_repo.setText(_translate("MainWindow", "Update client repo")) + 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_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 file to clever")) + self.action_send_launch_file.setText(_translate("MainWindow", "Send launch file to clever")) self.action_restart_clever.setText(_translate("MainWindow", "Restart clever service")) self.action_restart_clever_show.setText(_translate("MainWindow", "Restart clever-show service")) - self.action_select_all_rows.setText(_translate("MainWindow", "Select All")) + 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")) @@ -286,3 +304,9 @@ class Ui_MainWindow(object): self.action_select_music_file.setText(_translate("MainWindow", "Select music file")) self.action_play_music.setText(_translate("MainWindow", "Play music")) 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.actionSend_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_send_calibrations.setText(_translate("MainWindow", "Send camera calibrations")) diff --git a/Server/server_gui.ui b/Server/server_gui.ui index abeb6af..7141df1 100644 --- a/Server/server_gui.ui +++ b/Server/server_gui.ui @@ -11,7 +11,7 @@ - Clever Drone Animation Player + Clever Drone Show @@ -328,32 +328,28 @@ 0 0 1220 - 25 + 26 - Actions + Server Developer mode - - - - + + + + - - - - Table - + @@ -367,8 +363,19 @@ Drone + + + Developer mode + + + + + + + + @@ -376,32 +383,31 @@ - + - + - - Send Animations + Send animations - Send Configurations + Send configurations - Send Aruco map + Send aruco map - Update client repo + Update clever-show git @@ -411,7 +417,7 @@ - Send .launch file to clever + Send launch file to clever @@ -426,7 +432,7 @@ - Select All + Select all drones Ctrl+A @@ -467,6 +473,36 @@ Test music after + + + fill + + + + + Send any file + + + + + Send any command + + + + + Stop music + + + + + Remove from table + + + + + Send camera calibrations + + start_delay_spin diff --git a/Server/server_qt.py b/Server/server_qt.py index da30b9e..76343a7 100644 --- a/Server/server_qt.py +++ b/Server/server_qt.py @@ -3,6 +3,7 @@ import glob import math import time import asyncio +import functools from PyQt5 import QtWidgets, QtMultimedia from PyQt5.QtGui import QStandardItemModel, QStandardItem @@ -36,6 +37,7 @@ def wait(end, interrupter=threading.Event(), maxsleep=0.1): def confirmation_required(text="Are you sure?", label="Confirm operation?"): def inner(f): + @functools.wraps(f) def wrapper(*args, **kwargs): reply = QMessageBox.question( args[0], label, @@ -43,11 +45,10 @@ def confirmation_required(text="Are you sure?", label="Confirm operation?"): QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply == QMessageBox.Yes: - print("Dialog accepted") - #print(args) - return f(args[0]) - else: - print("Dialog declined") + logging.debug("Dialog accepted") + return f(*args, **kwargs) + + logging.debug("Dialog declined") return wrapper @@ -66,9 +67,6 @@ class MainWindow(QtWidgets.QMainWindow): self.model = CopterDataModel() self.proxy_model = CopterProxyModel() self.signals = SignalManager() - self.gyro_calibrated = {} - self.level_calibrated = {} - self.first_col_is_checked = False self.player = QtMultimedia.QMediaPlayer() self.init_model() @@ -76,6 +74,8 @@ class MainWindow(QtWidgets.QMainWindow): self.show() def init_model(self): + # self.model.on_id_changed = self.set_copter_id + self.proxy_model.setDynamicSortFilter(True) self.proxy_model.setSourceModel(self.model) @@ -83,9 +83,12 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.tableView.setModel(self.proxy_model) self.ui.tableView.resizeColumnsToContents() + self.ui.tableView.doubleClicked.connect(self.selfcheck_info_dialog) + # Connect signals to manipulate model from threads self.signals.update_data_signal.connect(self.model.update_item) self.signals.add_client_signal.connect(self.model.add_client) + self.signals.remove_client_signal.connect(self.model.remove_client) # Connect model signals to UI self.model.selected_ready_signal.connect(self.ui.start_button.setEnabled) @@ -106,9 +109,18 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_select_all_rows.triggered.connect(self.model.select_all) + def new_client_connected(self, client: Client): + self.signals.add_client_signal.emit(StatedCopterData(copter_id=client.copter_id, client=client)) - def client_connected(self, client: Client): - self.signals.add_client_signal.emit(CopterData(copter_id=client.copter_id, client=client)) + def client_connection_changed(self, client: Client): + 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: + if Server().remove_disconnected and (not client.connected): + client.remove() + self.signals.remove_client_signal.emit(row_num) + else: + self.signals.update_data_signal.emit(row_num, 0, client.connected, ModelStateRole) def init_ui(self): # Connecting @@ -130,7 +142,10 @@ class MainWindow(QtWidgets.QMainWindow): 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_configurations) self.ui.action_send_Aruco_map.triggered.connect(self.send_aruco) self.ui.action_send_launch_file.triggered.connect(self.send_launch) @@ -143,7 +158,7 @@ class MainWindow(QtWidgets.QMainWindow): self.ui.action_reset_z_offset.triggered.connect(self.reset_z_offset) 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_test_music_after.triggered.connect(self.test_music_after) + self.ui.action_stop_music.triggered.connect(self.stop_music) # Set most safety-important buttons disabled self.ui.start_button.setEnabled(False) @@ -152,21 +167,23 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def selfcheck_selected(self): - for copter in self.model.user_selected(): - client = copter.client + for copter_data_row in self.model.user_selected(): + client = copter_data_row.client - client.get_response("anim_id", self._set_copter_data, callback_args=(1, copter.copter_id)) - client.get_response("batt_voltage", self._set_copter_data, callback_args=(2, copter.copter_id)) - client.get_response("cell_voltage", self._set_copter_data, callback_args=(3, copter.copter_id)) - client.get_response("sys_status", self._set_copter_data, callback_args=(4, copter.copter_id)) - client.get_response("cal_status", self._set_copter_data, callback_args=(5, copter.copter_id)) - client.get_response("selfcheck", self._set_copter_data, callback_args=(6, copter.copter_id)) - client.get_response("position", self._set_copter_data, callback_args=(7, copter.copter_id)) - client.get_response("time", self._set_copter_data, callback_args=(8, copter.copter_id)) + client.get_response("anim_id", self.set_copter_data, callback_args=(1, copter_data_row)) + client.get_response("batt_voltage", self.set_copter_data, callback_args=(2, copter_data_row)) + client.get_response("cell_voltage", self.set_copter_data, callback_args=(3, copter_data_row)) + client.get_response("sys_status", self.set_copter_data, callback_args=(4, copter_data_row)) + client.get_response("cal_status", self.set_copter_data, callback_args=(5, copter_data_row)) + client.get_response("selfcheck", self.set_copter_data, callback_args=(6, copter_data_row)) + client.get_response("position", self.set_copter_data, callback_args=(7, copter_data_row)) + client.get_response("time", self.set_copter_data, callback_args=(8, copter_data_row)) - def _set_copter_data(self, value, col, copter_id): - row = self.model.data_contents.index(next( - filter(lambda x: x.copter_id == copter_id, self.model.data_contents))) + def set_copter_data(self, value, col, copter_data_row): + row = self.model.get_row_index(copter_data_row) + if row is None: + logging.error("No such client!") + return if col == 1: data = value @@ -180,23 +197,63 @@ class MainWindow(QtWidgets.QMainWindow): elif col == 5: data = str(value) elif col == 6: - data = str(value) + data = value elif col == 7: data = str(value) elif col == 8: - #data = time.ctime(int(value)) data = "{}".format(round(float(value) - time.time(), 3)) if abs(float(data)) > 1: - Client.get_by_id(copter_id).send_message("repair_chrony") - #self.signals.update_data_signal.emit(row, col + 1, data2) + copter_data_row.client.send_message("repair_chrony") else: - print("No column matched for response") + logging.error("No column matched for response") return - self.signals.update_data_signal.emit(row, col, data) + self.signals.update_data_signal.emit(row, col, data, ModelDataRole) + + + #def set_copter_id(self, value, copter_data_row): + # col = 0 + # row = self.model.get_row_index(copter_data_row) + # if row is None: + # logging.error("No such client!") + # return + # logging.info("SET COPTER ID TO {}".format(value)) + # + # copter_data_row.client.copter_id = value + # self.signals.update_data_signal.emit(row, col, value, ModelDataRole) + # self.signals.update_data_signal.emit(row, col, True, ModelStateRole) + + @pyqtSlot(QtCore.QModelIndex) + def selfcheck_info_dialog(self, index): + col = index.column() + if col == 6: + data = self.proxy_model.data(index, role=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): + shortened = [] + for line in data: + if len(line) > 89: + pass + return shortened - @confirmation_required("This operation will takeoff selected copters with delay and start animation. Proceed?") @pyqtSlot() + def remove_selected(self): + for copter in self.model.user_selected(): + row_num = self.model.data_contents.index(copter) + copter.client.remove() + self.signals.remove_client_signal.emit(row_num) + 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): time_now = server.time_now() dt = self.ui.start_delay_spin.value() @@ -243,15 +300,15 @@ class MainWindow(QtWidgets.QMainWindow): def disarm_all(self): Client.broadcast_message("disarm") - @confirmation_required("This operation will takeoff copters immediately. Proceed?") @pyqtSlot() + @confirmation_required("This operation will takeoff copters immediately. Proceed?") def takeoff_selected(self, **kwargs): for copter in self.model.user_selected(): if takeoff_checks(copter): copter.client.send_message("takeoff") - @confirmation_required("This operation will flip(!!!) copters immediately. Proceed?") @pyqtSlot() + @confirmation_required("This operation will flip(!!!) copters immediately. Proceed?") def flip_selected(self, **kwargs): for copter in self.model.user_selected(): if flip_checks(copter): @@ -269,36 +326,33 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def calibrate_gyro_selected(self): - for copter in self.model.user_selected(): - client = copter.client + for copter_data_row in self.model.user_selected(): + client = copter_data_row.client # Update calibration status - row = self.model.data_contents.index(next(filter( - lambda x: x.copter_id == client.copter_id, self.model.data_contents))) + row = self.model.get_row_index(copter_data_row) col = 5 data = 'CALIBRATING' - self.signals.update_data_signal.emit(row, col, data) + self.signals.update_data_signal.emit(row, col, data, ModelDataRole) # Send request - client.get_response("calibrate_gyro", self._get_calibration_info, callback_args=(5, copter.copter_id)) + client.get_response("calibrate_gyro", self._get_calibration_info, callback_args=(copter_data_row, )) @pyqtSlot() def calibrate_level_selected(self): - for copter in self.model.user_selected(): - client = copter.client + for copter_data_row in self.model.user_selected(): + client = copter_data_row.client # Update calibration status - row = self.model.data_contents.index(next(filter( - lambda x: x.copter_id == client.copter_id, self.model.data_contents))) + row = self.model.get_row_index(copter_data_row) col = 5 data = 'CALIBRATING' - self.signals.update_data_signal.emit(row, col, data) + self.signals.update_data_signal.emit(row, col, data, ModelDataRole) # Send request - client.get_response("calibrate_level", self._get_calibration_info, callback_args=(5, copter.copter_id)) + client.get_response("calibrate_level", self._get_calibration_info, callback_args=(copter_data_row, )) - def _get_calibration_info(self, value, col, copter_id): - row = self.model.data_contents.index(next( - filter(lambda x: x.copter_id == copter_id, self.model.data_contents))) + def _get_calibration_info(self, value, copter_data_row): + col = 5 + row = self.model.get_row_index(copter_data_row) data = str(value) - self.signals.update_data_signal.emit(row, col, data) - + self.signals.update_data_signal.emit(row, col, data, ModelDataRole) @pyqtSlot() def send_animations(self): @@ -308,7 +362,7 @@ class MainWindow(QtWidgets.QMainWindow): print("Selected directory:", path) files = [file for file in glob.glob(path + '/*.csv')] names = [os.path.basename(file).split(".")[0] for file in files] - print(files) + # print(files) for file, name in zip(files, names): for copter in self.model.user_selected(): if name == copter.copter_id: @@ -316,6 +370,22 @@ class MainWindow(QtWidgets.QMainWindow): else: print("Filename has no matches with any drone selected") + @pyqtSlot() + def send_calibrations(self): + path = str(QFileDialog.getExistingDirectory(self, "Select directory with calibration files")) + + 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: + print("Filename has no matches with any drone selected") + @pyqtSlot() def send_configurations(self): path = QFileDialog.getOpenFileName(self, "Select configuration file", filter="Configs (*.ini *.txt .cfg)")[0] @@ -390,47 +460,53 @@ class MainWindow(QtWidgets.QMainWindow): @pyqtSlot() def select_music_file(self): - path = QFileDialog.getOpenFileName(self, "Select music file", filter="Music files (*.mp3)")[0] + 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)") @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 \ self.player.state() == QtMultimedia.QMediaPlayer.PausedState: + self.ui.action_play_music.setText("Pause music") self.player.play() else: + self.ui.action_play_music.setText("Play music") self.player.pause() - + + @pyqtSlot() + def stop_music(self): + if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.InvalidMedia: + logging.error("Can't stop media") + return + if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.NoMedia: + 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.info("Can't play media") + logging.error("Can't play media") return if self.player.mediaStatus() == QtMultimedia.QMediaPlayer.NoMedia: - logger.info("No media file") + logging.error("No media file") return self.player.stop() yield from asyncio.sleep(t - time.time()) - #wait(t) logging.info("Playing music") self.player.play() - @pyqtSlot() - def test_music_after(self): - dt = self.ui.music_delay_spin.value() - asyncio.ensure_future(self.play_music_at_time(dt+time.time()), loop=loop) - logging.info('Wait {} seconds to play music'.format(dt)) - @pyqtSlot() def emergency(self): client_row_min = 0 @@ -487,7 +563,11 @@ if __name__ == "__main__": #app.exec_() with loop: window = MainWindow() - Client.on_first_connect = window.client_connected + + 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() loop.run_forever() diff --git a/builder/assets/clever-show.service b/builder/assets/clever-show.service index e1885e5..3c8466b 100644 --- a/builder/assets/clever-show.service +++ b/builder/assets/clever-show.service @@ -1,6 +1,8 @@ [Unit] Description=Clever Show Client Service -After=clever.service +Requires=clever.service +Requires=network.target +After=network.target [Service] WorkingDirectory=/home/pi/clever-show/Drone diff --git a/docs/blender-addon.md b/docs/ru/blender-addon.md similarity index 100% rename from docs/blender-addon.md rename to docs/ru/blender-addon.md diff --git a/docs/client.md b/docs/ru/client.md similarity index 100% rename from docs/client.md rename to docs/ru/client.md diff --git a/docs/image-building.md b/docs/ru/image-building.md similarity index 100% rename from docs/image-building.md rename to docs/ru/image-building.md diff --git a/docs/server.md b/docs/ru/server.md similarity index 100% rename from docs/server.md rename to docs/ru/server.md diff --git a/docs/start-tutorial.md b/docs/ru/start-tutorial.md similarity index 55% rename from docs/start-tutorial.md rename to docs/ru/start-tutorial.md index cf4edb2..4f565fe 100644 --- a/docs/start-tutorial.md +++ b/docs/ru/start-tutorial.md @@ -7,51 +7,60 @@ * Wifi роутер, работающий на частоте 2.4 ГГц, либо 5.8 ГГц, если эту частоту поддерживают wifi модули коптеров и компьютера. ## Подготовка ПО -Скачайте на компьютер последний образ (CleverSwarm-XXX.img.zip) и исходный код (Source code) из последнего [релиза](https://github.com/artem30801/CleverSwarm/releases/latest). Разархивируйте исходный код в удобную директорию. +Скачайте на компьютер последний образ (clever-show_XXX.img.zip) и исходный код (Source code) из последнего [релиза](https://github.com/copterexpress/clever-show/releases/latest). Разархивируйте исходный код в удобную директорию. ## Настройка роутера -Для управления одним или несколькими коптерами требуется подключение коптеров и сервера к одной сети. Для этого требуется отдельный wifi роутер с известным SSID и паролем. Подключите компьютер, который будет использоваться в качестве сервера, к сети роутера и узнайте его ip адрес - он понадобится для дальнейшей настройки. +Для управления одним или несколькими коптерами требуется подключение коптеров и сервера к одной сети. Для этого требуется отдельный wifi роутер с известным SSID и паролем. + +Подключите компьютер, который будет использоваться в качестве сервера, к сети роутера и узнайте его ip адрес - он понадобится для дальнейшей настройки. ## Настройка и запуск клиента - * Запишите образ на microSD карту, используя [Etcher](https://www.balena.io/etcher/). * Вставьте флешку в Raspberry Pi, включите коптер. Дождитесь появления сети `CLEVERSHOW-XXXX`. * Подключитесь к сети коптера, используя пароль `cleverwifi`. -* Настройте коптер, чтобы корректно работал режим позиции. По-умолчанию образ сконфигурирован для получения позиции с камеры с помощью aruco-маркеров и optical flow. Камера направлена вниз и вперёд, загружена тестовая карта меток. Если ваш способ позиционирования отличается - можно либо настроить данный образ, либо [собрать образ](image-building.md) со своими настройками. -* Перейдите в директорию клиента и запустите скрипт настройки клиента +* Подключитесь к Raspberry Pi на коптере с помощью ssh, используя статический ip `192.168.11.1`, имя пользователя `pi` и пароль `raspberry`. + ```bash -cd ~/CleverSwarm/Drone -sudo ./client_setup.sh +ssh pi@192.168.11.1 ``` -* Выполните скрипт настройки клиента с указанными параметрами - SSID, пароль точки доступа, имя коптера, ip сервера. -* Коптер переключится в режим клиента указанной точки доступа и настроит автозапуск клиента copter_client.py с помощью сервиса clever-show + +* Перейдите в директорию клиента и выполните скрипт настройки клиента с указанными параметрами - название точки доступа (`SSID`), пароль точки доступа (`password`), имя коптера (`copter name`), ip сервера (`server ip`). Коптер переключится в режим клиента указанной точки доступа и настроит автозапуск клиента на Raspberry Pi. + +```bash +cd ~/clever-show/Drone +sudo ./client_setup.sh +``` + +* Теперь при запуске серверного приложения настроенные коптеры будут отображаться в виде таблицы. Также можно подключаться к Raspberry Pi на коптере по его имени через `ssh` в указанной при настройке wifi сети, например `ssh pi@clever-1`, пароль `cleverwifi`. Документация по клиентской части находится [здесь](client.md). ## Настройка и запуск сервера -* Установите [chrony](https://chrony.tuxfamily.org/index.html) и Python 3 на ваш компьютер: +* Установите [chrony](https://chrony.tuxfamily.org/index.html), [samba](https://help.ubuntu.ru/wiki/samba) и Python 3 на ваш компьютер: + ```bash -sudo apt install chrony python3 python3-pip +sudo apt install chrony samba python3 python3-pip ``` + * Установите необходимые python-пакеты с помощью команды (запущенной из директории с исходным кодом) + ```bash pip3 install -r requirements.txt ``` * Подключитесь к wifi сети роутера, к которому подключены коптеры. -* Скопируйте [файл настроек chrony](../Server/chrony.conf) в `/etc/chrony/chrony.conf`. Если ip адрес сети начинается не с `192.168.`, то исправьте адрес после слова allow в скопированном файле настроек. +* Скопируйте [файл настроек chrony](../../Server/chrony.conf) в `/etc/chrony/chrony.conf`. Если ip адрес сети начинается не с `192.168.`, то исправьте адрес после слова allow в скопированном файле настроек. * Перезапустите сервис chrony + ```bash -cd source-code-dir sudo systemctl restart chrony ``` + * Перейдите в директорию сервера из директории с исходным кодом и запустите сервер + ```bash cd source-code-dir/Server python3 server_qt.py ``` Документация по серверной части находится [здесь](server.md). - diff --git a/messaging_lib.py b/messaging_lib.py index d2444fa..2e9a9b8 100644 --- a/messaging_lib.py +++ b/messaging_lib.py @@ -8,6 +8,8 @@ import logging import threading import collections +from contextlib import closing + try: import selectors except ImportError: @@ -24,6 +26,16 @@ 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: + ip_socket.connect(("8.8.8.8", 80)) + return ip_socket.getsockname()[0] + except OSError: + logging.warning("No network connection detected, using localhost") + return "localhost" + + class _Singleton(type): """ A metaclass that creates a Singleton base class when called. """ _instances = {} @@ -185,9 +197,7 @@ class ConnectionManager(object): self.socket = None self.addr = None - self.selector = None - self.socket = None - self.addr = None + self._should_close = False self._recv_buffer = b"" self._send_buffer = b"" @@ -198,6 +208,7 @@ class ConnectionManager(object): self._send_lock = threading.Lock() self._request_lock = threading.Lock() + self._close_lock = threading.Lock() self.BUFFER_SIZE = 1024 self.resume_queue = False @@ -225,8 +236,16 @@ class ConnectionManager(object): self._set_selector_events_mask('r') def close(self): + with self._close_lock: + self._should_close = True + + self._set_selector_events_mask('w') + NotifierSock().notify() + + def _close(self): logger.info("Closing connection to {}".format(self.addr)) try: + logger.info("Unregistering selector of {}".format(self.addr)) self.selector.unregister(self.socket) except AttributeError: pass @@ -236,6 +255,7 @@ class ConnectionManager(object): self.selector = None try: + logger.info("Closing socket of of {}".format(self.addr)) self.socket.close() except AttributeError: pass @@ -244,7 +264,18 @@ class ConnectionManager(object): finally: self.socket = None + with self._close_lock: + self._should_close = False + + logger.info("CLOSED connection to {}".format(self.addr)) + def process_events(self, mask): + with self._close_lock: + close = self._should_close + if close: + self._close() + return + if mask & selectors.EVENT_READ: self.read() if mask & selectors.EVENT_WRITE: @@ -304,7 +335,7 @@ class ConnectionManager(object): command = message.content["command"] args = message.content["args"] try: - self.messages_callbacks[command](**args) + self.messages_callbacks[command](self, **args) except KeyError: logger.warning("Command {} does not exist!".format(command)) except Exception as error: @@ -315,7 +346,7 @@ class ConnectionManager(object): request_id = message.content["request_id"] args = message.content["args"] try: - value = self.requests_callbacks[command](**args) + value = self.requests_callbacks[command](self, **args) except KeyError: logger.warning("Request {} does not exist!".format(command)) except Exception as error: # TODO send response error\cancel