diff --git a/.gitignore b/.gitignore
index aa5f5b4..2032e7f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,6 @@
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
-# Byte-compiled / optimized / DLL files
-__pycache__/
-*.py[cod]
*$py.class
# Logs
@@ -18,14 +15,19 @@ __pycache__/
# Development
images/
show-env/
+builder/clever-config
+
Server/tests.py
Server/convert_ui.sh
+Server/config/server.ini
Server/server_logs
Server/testj\.ipynb
Server/tst_client\.py
Server/tst\.py
+
Drone/test_animation/
Drone/animation.csv
Drone/client_logs
+Drone/config/client.ini
Drone/_copter_client_old_\.py
Drone/test_cl\.py
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
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/animation_lib.py b/Drone/animation_lib.py
index be51fd9..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
@@ -36,7 +31,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:
@@ -53,11 +48,11 @@ 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:
- 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:
@@ -83,13 +78,13 @@ def get_start_xy(filepath="animation.csv", x_ratio=1, y_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:
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:
diff --git a/Drone/client.py b/Drone/client.py
index db1dc3f..5f97e59 100644
--- a/Drone/client.py
+++ b/Drone/client.py
@@ -1,86 +1,60 @@
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)
+sys.path.insert(0, parent_dir)
logger = logging.getLogger(__name__)
import messaging_lib as messaging
+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()
+ else:
+ self.client_id = config_id
- 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()
+ logger.info("Config loaded")
@staticmethod
def get_ntp_time(ntp_host, ntp_port):
@@ -95,13 +69,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)
@@ -114,17 +90,17 @@ 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))
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.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:
@@ -148,21 +124,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
@@ -170,37 +150,30 @@ 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.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.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()
- 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)
@@ -227,19 +200,36 @@ 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 options: {}".format(options))
- active_client.write_config(kwargs["reload"], *options)
+ 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"], 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()
+ logger.info("Config successfully updated from command")
+ active_client.load_config()
+
+@messaging.request_callback("config")
+def _response_config(*args, **kwargs):
+ 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")
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()
+ # TODO renaming here
return active_client.client_id
@@ -250,6 +240,31 @@ 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)
+ #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()
+
diff --git a/Drone/client_config.ini b/Drone/client_config.ini
deleted file mode 100644
index 6fe44a4..0000000
--- a/Drone/client_config.ini
+++ /dev/null
@@ -1,63 +0,0 @@
-[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
-
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/config/spec/configspec_client.ini b/Drone/config/spec/configspec_client.ini
new file mode 100644
index 0000000..d054bf0
--- /dev/null
+++ b/Drone/config/spec/configspec_client.ini
@@ -0,0 +1,90 @@
+config_name = string(default='client')
+config_version = float(default=1.0)
+
+[SERVER]
+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, min=1)
+
+[TELEMETRY]
+transmit = boolean(default=True)
+frequency = float(default=1.0, min=0)
+log_resources = boolean(default=False)
+
+[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
+# Set 0 to disable vision pose check
+vision_pose_timeout = float(default=0.0, min=0)
+# Max delta between current position and setpoint
+# 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)
+
+[EMERGENCY LAND]
+thrust = float(default=0.45, min=0, max=1)
+decrease_thrust_after = float(default=5.0, min=0)
+
+[COPTER]
+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, 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]
+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)
+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
+yaw = string(default=180.0)
+
+[LED]
+use = boolean(default=False)
+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)
+
+[SYSTEM]
+change_hostname = boolean(default=True)
+restart_after_rename = boolean(default=True)
+
+[NTP]
+use = boolean(default=False)
+host = string(default=ntp1.stratum2.ru)
+port = integer(default=123, min=1)
diff --git a/Drone/copter_client.py b/Drone/copter_client.py
index bcbca24..6fdcf33 100644
--- a/Drone/copter_client.py
+++ b/Drone/copter_client.py
@@ -3,17 +3,22 @@ import sys
import time
import math
import rospy
-from clever import srv
+import numpy
+
+# for backward compatibility with clever
+try:
+ from clever import srv
+except ImportError:
+ from clover import srv
+
import datetime
import logging
import threading
import psutil
import subprocess
-import ConfigParser
from collections import namedtuple
from FlightLib import FlightLib
-from FlightLib import LedLib
import client
@@ -33,12 +38,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,65 +74,35 @@ 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"):
+ super(CopterClient, self).__init__(config_path)
+ self.load_config()
+ 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.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)
- restart_service("chrony")
+ repair_chrony(self.config.server_host)
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)
- task_manager_instance.start()
- if self.FRAME_ID == "floor":
- if self.FLOOR_FRAME_EXISTS:
+ rospy.init_node('clever_show_client', anonymous=True)
+ 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":
+ 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()
@@ -135,23 +110,30 @@ 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_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])))
+ trans.header.frame_id = self.config.floor_frame_parent
+ trans.child_frame_id = self.config.copter_frame_id
static_bloadcaster.sendTransform(trans)
+
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)
-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()
@@ -169,7 +151,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
@@ -223,7 +204,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)
@@ -233,10 +215,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:
@@ -261,6 +245,7 @@ def configure_bashrc(hostname):
return True
+
@messaging.message_callback("execute")
def _execute(*args, **kwargs):
command = kwargs.get("command", None)
@@ -269,28 +254,29 @@ def _execute(*args, **kwargs):
execute_command(command)
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:
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)
+ client.active_client.client_id = new_id
if new_id != '/hostname':
- if client.active_client.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("hostname {}".format(hostname))
- #restart_service("dhcpcd")
- #restart_service("avahi-daemon")
- #restart_service("smbd")
- #restart_service("roscore")
- #restart_service("clever")
+ execute_command("systemctl stop clever-show & 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")
@@ -315,25 +301,21 @@ def _response_animation_id(*args, **kwargs):
# Load animation
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,
- )
+ 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,
+ 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)
return result
+
@messaging.request_callback("batt_voltage")
def _response_batt(*args, **kwargs):
if check_state_topic(wait_new_status=True):
@@ -351,10 +333,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):
@@ -363,77 +347,84 @@ 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)
+ 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")
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,
- )
+ *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")
+ 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))
+ 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")
@@ -446,11 +437,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()
@@ -464,8 +457,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)
- restart_service("chrony")
+ repair_chrony(client.active_client.config.server_host)
@messaging.message_callback("led_test")
@@ -486,36 +478,38 @@ 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")
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,
}
)
+
@messaging.message_callback("takeoff_z")
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,
- "auto_arm": True,
- }
- )
+ task_kwargs={
+ "x": telem.x,
+ "y": telem.y,
+ "z": float(z_str),
+ "frame_id": client.active_client.config.copter_frame_id,
+ "timeout": client.active_client.config.copter_takeoff_time,
+ "auto_arm": True,
+ }
+ )
@messaging.message_callback("land")
@@ -523,10 +517,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,
}
)
@@ -570,115 +564,114 @@ 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,
- )
+ 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,
+ 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,
- }
- )
+ task_kwargs={
+ "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,
- "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.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
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.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 += corrected_frames[0]["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,
- "flight_func": FlightLib.navto,
- }
- )
+ task_kwargs={
+ "point": point,
+ "color": color,
+ "yaw": yaw,
+ "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,
- },
- )
+ task_kwargs={
+ "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,
+ },
+ )
+
+# noinspection PyAttributeOutsideInit
class Telemetry:
params_default_dict = {
"git_version": None,
"animation_id": None,
"battery": None,
"armed": False,
- "system_status": None,
+ "fcu_status": None,
"calibration_status": None,
"mode": None,
"selfcheck": None,
"current_position": None,
"start_position": None,
- "time": None,
+ "last_task": None,
+ "time_delta": None,
+ "config_version": None,
}
def __init__(self):
@@ -710,20 +703,20 @@ 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"),
- 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
@@ -760,13 +753,14 @@ 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()
+ self.start_position = self.get_start_position()
+ self.last_task = task_manager.get_current_task()
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
@@ -781,15 +775,16 @@ 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):
self.animation_id = animation.get_id()
self.git_version = self.get_git_version()
+ self.config_version = self.get_config_version()
try:
self.calibration_status = get_calibration_status()
- self.system_status = get_sys_status()
+ self.fcu_status = get_sys_status()
self.battery = self.get_battery(self.ros_telemetry)
except rospy.ServiceException:
rospy.logdebug("Some service is unavailable")
@@ -807,12 +802,12 @@ 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')
self.calibration_status = 'NO_FCU'
- self.system_status = 'NO_FCU'
+ self.fcu_status = 'NO_FCU'
self.mode = 'NO_FCU'
self.selfcheck = ['NO_FCU']
self.current_position = 'NO_POS'
@@ -851,9 +846,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)
@@ -865,7 +860,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'
@@ -874,7 +869,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)
@@ -883,11 +878,8 @@ 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:
- self.log_cpu_and_memory()
rate.sleep()
@@ -895,13 +887,16 @@ class Telemetry:
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):
- 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?
- slow_telemetry_thread = threading.Thread(target=self._slow_update_loop, name="Slow telemetry getting thread")
+ 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()
telemetry_thread.start()
else:
@@ -911,12 +906,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()
diff --git a/Drone/mavros_mavlink.py b/Drone/mavros_mavlink.py
index 7a6005e..90ee00c 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)
@@ -61,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)
@@ -85,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"
@@ -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
diff --git a/Drone/requirements.txt b/Drone/requirements.txt
new file mode 100644
index 0000000..f46bac3
--- /dev/null
+++ b/Drone/requirements.txt
@@ -0,0 +1,3 @@
+selectors2
+psutil
+configobj
diff --git a/Drone/tasking_lib.py b/Drone/tasking_lib.py
index 68b06de..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):
@@ -90,6 +90,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")
logger.info("Task manager is started")
@@ -119,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()
@@ -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()
@@ -163,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))
@@ -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()
@@ -196,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 1978071..d36aba2 100644
--- a/Drone/visual_pose_watchdog.py
+++ b/Drone/visual_pose_watchdog.py
@@ -1,11 +1,17 @@
import rospy
+import os
import sys
import time
import math
import logging
import threading
-import ConfigParser
-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
@@ -13,15 +19,25 @@ 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")
+
+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
+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
logging.basicConfig( # TODO all prints as logs
level=logging.DEBUG, # INFO
@@ -55,6 +71,7 @@ setpoint_raw = None
setpoint_position = None
setpoint_pose = None
+arm_start_time = None
offboard_start_time = None
offboard_disarmed_timeout = 3.
@@ -62,9 +79,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)
@@ -127,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:
@@ -136,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:
@@ -150,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):
@@ -164,59 +181,68 @@ 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, 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
+ 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} | action: {} | range: {:.2f}".format(
- armed, mode, abs(time.time() - visual_pose_last_timestamp), pos_delta, pos_dt, timeout_action, laser_range))
+ 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:
- visual_pose_dt = abs(time.time() - visual_pose_last_timestamp)
- if visual_pose_dt > visual_pose_timeout or pos_delta > pos_delta_max:
- action_timestamp = time.time()
- if timeout_action in ['land', 'emergency_land', 'disarm']:
+ 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 timeout_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 timeout_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 timeout_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:
+ 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
+ if emergency_land_called:
emergency = True
logger.info('/emergency_land service was called, start emergency landing...')
emergency_land()
@@ -224,6 +250,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')
@@ -231,7 +258,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)
diff --git a/Server/config/spec/configspec_server.ini b/Server/config/spec/configspec_server.ini
new file mode 100644
index 0000000..7242d1d
--- /dev/null
+++ b/Server/config/spec/configspec_server.ini
@@ -0,0 +1,55 @@
+config_name = string(default='server')
+config_version = float(default='1.0')
+
+[SERVER]
+ 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
+ remove_disconnected = boolean(default=False)
+ [[PRESETS]]
+ current = string(default="DEFAULT")
+ [[[DEFAULT]]]
+ 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, 65))
+ mode = 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
+
+[CHECKS]
+ check_git_version = boolean(default=True)
+ check_current_position = boolean(default=True)
+ # 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)
+
+[BROADCAST]
+ 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)
+
+[NTP]
+ use = boolean(default=False)
+ host = string(default=ntp1.stratum2.ru)
+ port = integer(default=123)
diff --git a/Server/config_editor.py b/Server/config_editor.py
new file mode 100644
index 0000000..aa4a7de
--- /dev/null
+++ b/Server/config_editor.py
@@ -0,0 +1,72 @@
+# -*- coding: utf-8 -*-
+
+# Form implementation generated from reading ui file 'config_editor.ui'
+#
+# Created by: PyQt5 UI code generator 5.14.0
+#
+# WARNING! All changes made in this file will be lost!
+
+
+from PyQt5 import QtCore, QtGui, QtWidgets
+
+
+class Ui_config_dialog(object):
+ def setupUi(self, config_dialog):
+ config_dialog.setObjectName("config_dialog")
+ config_dialog.resize(600, 700)
+ config_dialog.setModal(False)
+ self.gridLayout = QtWidgets.QGridLayout(config_dialog)
+ self.gridLayout.setObjectName("gridLayout")
+ 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_2 = QtWidgets.QGridLayout()
+ self.gridLayout_2.setObjectName("gridLayout_2")
+ 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)
+ self.line.setFrameShadow(QtWidgets.QFrame.Sunken)
+ self.line.setObjectName("line")
+ self.gridLayout.addWidget(self.line, 1, 0, 1, 1)
+
+ 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, config_dialog):
+ _translate = QtCore.QCoreApplication.translate
+ config_dialog.setWindowTitle(_translate("config_dialog", "Config Editor"))
+ 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__":
+ import sys
+ app = QtWidgets.QApplication(sys.argv)
+ 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
new file mode 100644
index 0000000..d9110f3
--- /dev/null
+++ b/Server/config_editor.ui
@@ -0,0 +1,128 @@
+
+
+ config_dialog
+
+
+
+ 0
+ 0
+ 600
+ 700
+
+
+
+ Config Editor
+
+
+ false
+
+
+ -
+
+
+ QAbstractItemView::DoubleClicked|QAbstractItemView::EditKeyPressed|QAbstractItemView::SelectedClicked
+
+
+ false
+
+
+ 250
+
+
+
+ -
+
+
-
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+ -
+
+
+ Restart
+
+
+ R
+
+
+
+ -
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Save
+
+
+
+ -
+
+
+ Color Indication
+
+
+ true
+
+
+
+ -
+
+
+ Save as
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+
+
+
+
+
+ buttonBox
+ accepted()
+ config_dialog
+ accept()
+
+
+ 260
+ 237
+
+
+ 157
+ 246
+
+
+
+
+ buttonBox
+ rejected()
+ config_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..c9466f0
--- /dev/null
+++ b/Server/config_editor_models.py
@@ -0,0 +1,954 @@
+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, pyqtSlot
+from PyQt5.QtGui import QCursor, QKeySequence
+from PyQt5.QtWidgets import QAbstractItemView, QTreeView, QMenu, QAction, QMessageBox, QInputDialog, QFileDialog, \
+ QShortcut
+
+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
+
+
+states_colors = {
+ 'normal': Qt.white,
+ 'unchanged': Qt.blue,
+ 'default': Qt.cyan,
+ 'edited': Qt.yellow,
+ 'added': Qt.green,
+ 'deleted': Qt.red,
+}
+
+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):
+ self.spec_default = default
+ self.itemData = list(values)
+ self.state = state
+ self.type = item_type
+
+ if isinstance(self.data(1), (list, tuple)):
+ self.type = 'list'
+
+ self.default_values = deepcopy(self.itemData)
+ self.default_state = state
+
+ 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:]
+ 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): # probably deprecated
+ return self.type == 'section'
+
+ def appendChild(self, item):
+ self.childItems.append(item)
+ item.parentItem = self
+
+ def addChildren(self, items, row):
+ if row == -1:
+ row = 0
+ self.childItems[row:row] = items
+
+ for item in items:
+ item.parentItem = self
+
+ 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 set_data(self, data, column):
+ old_data = self.data(column)
+ if old_data is None:
+ data = convert_type(data)
+
+ if data == '':
+ data = []
+
+ try:
+ self.itemData[column] = data
+ except IndexError:
+ return False
+
+ if old_data != data:
+ self.set_state('edited')
+ self.check_state()
+
+ return True
+
+ 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.type != 'section':
+ self.set_state('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)
+
+ 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
+
+ if self.state == 'added' and state in ('edited', 'unchanged', 'default', 'normal'):
+ return
+
+ self.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
+
+ def set_type(self, item_type):
+ self.type = item_type
+
+ def parent(self):
+ return self.parentItem
+
+ 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
+ child = self.childItems.pop(position)
+ child.parentItem = None
+ return True
+
+ def __repr__(self):
+ return str(self.itemData)
+
+
+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)
+
+ 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')):
+ self.rootItem = ConfigModelItem(headers)
+ super(ConfigModel, self).__init__(parent)
+ self.widget = widget
+
+ 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)
+
+ def columnCount(self, parent):
+ 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 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()
+
+ parentItem = self.nodeFromIndex(parent)
+ 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()
+ 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:
+ 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()
+ return self.rootItem
+
+ 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())
+
+ 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):
+ if not index.isValid():
+ return False
+
+ item = index.internalPointer()
+
+ if role == Qt.EditRole:
+ column = index.column()
+
+ if column == 0 and value != item.data(column):
+ if not self.widget.edit_caution():
+ return False
+
+ 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
+
+ def flags(self, index):
+ if not index.isValid():
+ return QtCore.Qt.ItemIsDragEnabled | QtCore.Qt.ItemIsDropEnabled # Qt.NoItemFlags
+ item = index.internalPointer()
+
+ flags = Qt.ItemIsEnabled | Qt.ItemIsSelectable
+
+ if index.column() == 0:
+ if item.type != 'list_item':
+ flags |= int(Qt.ItemIsDragEnabled)
+
+ if item.type == 'section':
+ 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')
+ 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
+
+ def supportedDropActions(self):
+ return QtCore.Qt.CopyAction | QtCore.Qt.MoveAction
+
+ def mimeTypes(self):
+ return ['app/configitem']
+
+ def mimeData(self, indexes):
+ mimedata = QtCore.QMimeData()
+ index = indexes[0]
+ mimedata.setData('app/configitem', pickle.dumps(self.nodeFromIndex(index)))
+ return mimedata
+
+ def dropMimeData(self, mimedata, action, row, column, parentIndex):
+ if action == Qt.IgnoreAction:
+ return True
+
+ 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 removeRows(self, row, count, parent):
+ self.beginRemoveRows(parent, row, row + count - 1)
+ parentItem = self.nodeFromIndex(parent)
+ for _ in range(count):
+ parentItem.removeChild(row)
+
+ self.endRemoveRows()
+ return True
+
+ def removeRow(self, index):
+ parent = index.parent()
+ self.beginRemoveRows(parent, index.row(), index.row())
+
+ parentItem = self.nodeFromIndex(parent)
+ parentItem.removeChild(index.row())
+
+ self.endRemoveRows()
+ return True
+
+ def insertItems(self, row, items, parentIndex):
+ parent = self.nodeFromIndex(parentIndex)
+ self.beginInsertRows(parentIndex, row, row + len(items) - 1) # parentIndex or QtCore.QModelIndex()
+
+ parent.addChildren(items, row)
+
+ self.endInsertRows()
+ self.update_all()
+
+ return True
+
+ def update_all(self):
+ self.dataChanged.emit(QtCore.QModelIndex(), QtCore.QModelIndex())
+
+ def dict_setup(self, data: dict, parent=None, convert_types=False):
+ if parent is None:
+ parent = self.rootItem
+
+ for key, value in data.items():
+ if isinstance(value, dict):
+ 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, convert_types=False, parent=None):
+ if parent is None:
+ 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 '__value__' in item:
+ value = item.get('__value__')
+ if convert_types:
+ value = convert_type(value)
+
+ default = item['default']
+ comments = '\n'.join(item.get('comments', '')) or ''
+ inline_comment = item.get('inline_comment', '') or ''
+
+ if item['unchanged']:
+ state = 'unchanged'
+ elif value == default:
+ state = 'default'
+ else:
+ state = 'normal'
+
+ parent.appendChild(ConfigModelItem((key, value, comments, inline_comment),
+ state=state, default=default))
+
+ 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:
+ parent = self.rootItem
+
+ data = {}
+ for item in parent.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:
+ 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:
+ key = item.data(0)
+
+ if item.is_section:
+ d = self.to_config_dict(item)
+ if d: # to prevent empty sections
+ data[key] = d
+
+ 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 = {'__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
+
+ return data
+
+ @property
+ def dict(self):
+ return self.to_dict()
+
+
+class ConfigTreeWidget(QTreeView):
+ def __init__(self):
+ QTreeView.__init__(self)
+
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.customContextMenuRequested.connect(self.open_menu)
+
+ self.setSelectionMode(self.SingleSelection)
+ # self.setSelectionBehavior(self.SelectItems)
+
+ self.setDragDropMode(QAbstractItemView.DragDrop)
+ self.setDefaultDropAction(Qt.MoveAction)
+ self.setDragEnabled(True)
+ self.setAcceptDrops(True)
+ self.setDropIndicatorShown(True)
+ self.setAnimated(True)
+
+ 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():
+ index = self.selectedIndexes()[0]
+ return f(index, *args, **kwargs)
+
+ return decorated
+
+ def open_menu(self, point):
+ index = self.indexAt(point)
+ item = index.internalPointer()
+
+ 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)
+
+ if item is None:
+ 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())
+
+ 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() # fixes not expanded duplicated section
+
+ def remove(self, index):
+ self.model().removeRow(index)
+
+ def exclude(self, index):
+ item = self.model().nodeFromIndex(index)
+ if item.state == 'deleted':
+ self.model().setData(index, item.previous_state, StateRole)
+ else:
+ self.model().setData(index, 'deleted', StateRole)
+
+ def add_item(self, index, is_section):
+ 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'
+
+ prompt = 'Enter {} name'.format(item_type.replace('_', ' '))
+ text, ok = QInputDialog.getText(self, prompt, prompt)
+ if not ok:
+ return
+
+ if parentItem.type in ('list', 'section'): # to append at first index in section or list
+ row = 0
+ parent = index
+ else:
+ 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):
+ 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':
+ 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': # TODO
+ # 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)
+
+
+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._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)
+ else:
+ 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)
+
+ self.model.dataChanged.connect(self.unsaved_call) # connect after setup
+
+ 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.save_as_button.clicked.connect(self.save_as)
+
+ # 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):
+ self.unsaved = True
+ self.update_title()
+ 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? "
+ "Proceed with caution!",
+ QMessageBox.Yes | QMessageBox.No, QMessageBox.No
+ )
+ return reply == QMessageBox.Yes
+
+ def save_as(self):
+ save_path = QFileDialog.getSaveFileName(self, "Save as configuration file (.ini)",
+ directory=self.filename+'.ini',
+ 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
+ cfg.write()
+
+ @pyqtSlot()
+ def run(self):
+ self.show()
+ self.exec()
+ 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
+
+ try:
+ 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)
+
+ if reply == QMessageBox.Cancel:
+ return False
+
+ 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)
+
+ @pyqtSlot(object, object)
+ def _call_copter_dialog(self, client, value):
+ 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)
+
+ def save_callback():
+ edited_dict = cfg.full_dict(include_defaults=False)
+ client.send_message("config", kwargs={"config": edited_dict, "mode": "rewrite"})
+
+ def restart_callback():
+ 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
+ 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
+
+ @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:
+ return False
+
+ cfg = config.ConfigManager()
+ try:
+ cfg.load_from_file(path)
+ 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
+
+ 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
+
+ cfg.config.filename = save_path
+ cfg.write()
+
+ 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
+ return True
+
+
+if __name__ == '__main__':
+ def except_hook(cls, exception, traceback):
+ print(cls, exception, traceback)
+ sys.__excepthook__(cls, exception, traceback)
+
+ sys.excepthook = except_hook
+
+ app = QtWidgets.QApplication(sys.argv)
+
+ ui = ConfigDialog()
+ 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())
diff --git a/Server/copter_table.py b/Server/copter_table.py
new file mode 100644
index 0000000..2a1df89
--- /dev/null
+++ b/Server/copter_table.py
@@ -0,0 +1,557 @@
+from functools import partial
+from copy import deepcopy
+
+from PyQt5 import QtWidgets, QtCore, QtGui
+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, QApplication
+
+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][0]:
+ header_dict.pop(key)
+
+ presets[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())
+ else:
+ return False
+ 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)
+
+ self.config = config
+ self.model = model
+
+ self.proxy_model = table.CopterProxyModel()
+ self.proxy_model.setSourceModel(self.model)
+ self.proxy_model.setDynamicSortFilter(True)
+
+ # 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.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)
+ header.sectionMoved.connect(self.moved)
+ header.setContextMenuPolicy(Qt.CustomContextMenu)
+ header.customContextMenuRequested.connect(self.showHeaderMenu)
+
+ self.setContextMenuPolicy(Qt.CustomContextMenu)
+ self.customContextMenuRequested.connect(self.open_menu)
+
+ # Adjust properties
+ self.setTextElideMode(QtCore.Qt.ElideMiddle)
+ self.setWordWrap(True)
+ self.setSortingEnabled(True)
+ self.setSizeAdjustPolicy(QtWidgets.QAbstractScrollArea.AdjustToContents)
+ self.resizeColumnsToContents()
+ self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectItems)
+ self.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection)
+ self.doubleClicked.connect(self.on_double_click)
+ 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)
+
+ 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)
+ self.current_columns.insert(new_index, name)
+
+ def set_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)
+
+ 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, presets[HeaderEditWidget.default][key][1])
+ for key in presets[HeaderEditWidget.default] if key not in item_dict})
+
+ 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: self._get_column_item(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 range(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):
+ 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)
+
+ 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 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))
+ # box.setSizePolicy(QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding)
+ action = QWidgetAction(menu)
+ action.setDefaultWidget(header_view)
+ menu.addAction(action)
+ menu.exec_(QCursor.pos())
+ 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)
+
+ edit_config = QAction("Edit config")
+ 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)
+ copy_config.setDisabled(True)
+
+ menu.exec_(QCursor.pos())
+
+ @pyqtSlot()
+ def edit_copter_config(self, copter):
+ dialog = ConfigDialog()
+ 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 = []
+ # for line in data:
+ # if len(line) > 89:
+ # pass
+ # return shortened
+
+
+class HeaderListWidget(QListWidget):
+ ColumnKeyRole = Qt.UserRole + 1000
+ ColumnWidthRole = Qt.UserRole + 1001
+
+ dropped = QtCore.pyqtSignal(bool)
+
+ def __init__(self, parent=None, default_items=None):
+ super().__init__(parent)
+ if default_items is not None:
+ self.populate_items(default_items)
+
+ self.setDragDropMode(QAbstractItemView.InternalMove)
+ self.setDefaultDropAction(Qt.MoveAction)
+
+ def populate_items(self, item_dict: dict):
+ self.clear()
+ 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(self.ColumnKeyRole, name)
+ item.setData(self.ColumnWidthRole, width)
+
+ @property
+ def item_dict(self):
+ 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):
+ super().dropEvent(event)
+ self.dropped.emit(True)
+
+
+class ActiveHeaderListWidget(HeaderListWidget):
+ def __init__(self, source: CopterTableWidget, parent=None):
+ super().__init__(parent=parent)
+ self.source_widget = source
+
+ 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):
+ self.populate_items(self.source_widget.item_dict)
+
+ @pyqtSlot(QListWidgetItem)
+ def on_itemChanged(self, item):
+ key = item.data(HeaderListWidget.ColumnKeyRole)
+ if key is None:
+ return
+ self.source_widget.setColumnHidden(self.columns.index(key), not bool(item.checkState()))
+
+ 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)
+
+
+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
+ self.source = source # source = copter table
+ self.config = config
+ self.menu_mode = menu_mode
+
+ 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.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)
+
+ 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)
+ apply_button.setDefault(True)
+ apply_button.setFocus()
+
+ hbox.addWidget(add_button)
+ hbox.addWidget(remove_button)
+ hbox.addStretch()
+ hbox.addWidget(save_button)
+ hbox.addWidget(apply_button)
+ else:
+ dialog_button = QPushButton("Manage presets")
+ dialog_button.clicked.connect(self.call_dialog)
+ 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
+ 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(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(item_dict)
+ self.saved_signal.emit(True)
+
+ 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
+
+ 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.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()
+
+ @pyqtSlot()
+ def save_preset(self):
+ if not self.save: # don't save after calling dialog to avoid overrides
+ return
+
+ 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()
+ self.source.load_columns()
+
+
+class HeaderEditDialog(QtWidgets.QDialog):
+ def __init__(self, source, config, parent=None):
+ super(HeaderEditDialog, self).__init__(parent=None)
+ 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.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)
+
+ 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
+
+
+ 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.item_dict)
+ # 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 1e6dcb2..72bad16 100644
--- a/Server/copter_table_models.py
+++ b/Server/copter_table_models.py
@@ -1,222 +1,265 @@
+import os
import re
import sys
-import time
import math
-import configparser
-import collections
-import indexed
+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
+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
-config = configparser.ConfigParser()
-config.read("server_config.ini")
-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')
+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 = {}
- takeoff_checklist = (3, 4, 6, 7, 8)
+
+ battery_min = 50.0
+ start_pos_delta_max = 1.0
+ time_delta_max = 1.0
+ check_current_pos = True
+ check_git = True
@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 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
+ 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.col_check(1)
+@ModelChecks.column_check("git_version")
def check_ver(item):
- return True # TODO git version!
+ if not ModelChecks.check_git:
+ return True
+
+ version = get_git_version()
+ if version is not None:
+ return version == item
+ return True
-@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 > battery_min
+ 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 not ModelChecks.check_current_pos:
+ return True
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)
+@ModelChecks.column_check('time_delta')
def check_time_delta(item):
- return abs(item) < time_delta_max
+ return abs(item) < ModelChecks.time_delta_max
+
+
+@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
+
+ 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': # 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], zip(pos1, pos2))) ** 2) # point distance formula
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)])
+ 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)
+
+ def __repr__(self):
+ return str({key: self[key] for key in self.columns})
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 = ModelChecks
+ 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:
+ if key in self.columns:
+ with suppress(KeyError):
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):
- 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 < 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()]
- )
-
- 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.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 = False
- PLACE_FORMATTER = True
+ VIEW_FORMATTER = 1
+ 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 col_format(cls, col, format_type):
+ def column_formatter(cls, column, formatter_type):
def inner(f):
- if format_type:
- cls.place_formatters[col] = f
- else:
- 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
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 = value.stip()
+ 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
@@ -234,7 +277,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
@@ -243,62 +286,98 @@ def place_battery(value):
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)
-def view_selfcheck(value):
+
+@ModelFormatter.view_formatter("current_position")
+def view_current_position(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)
-def view_selfcheck(value):
+
+@ModelFormatter.view_formatter("start_position")
+def view_start_position(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)
+
+@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.col_format(10, 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):
+ columns_dict = {'copter_id': 'copter ID',
+ 'git_version': 'version',
+ 'config_version': 'configuration',
+ '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',
+ }
+
+ 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 ', ' system ', 'sensors',
- ' mode ', ' checks ', 'current x y z yaw frame_id', ' start x y z ', 'dt')
+ 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_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)
@@ -312,48 +391,44 @@ 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
+ def is_column(cls, index, column_name):
+ return index.column() == cls.columns.index(column_name)
+
def user_selected(self, contents=()):
- contents = contents or self.data_contents
- return filter(lambda x: x.states.checked == Qt.Checked, contents)
+ return self.filter(lambda x: x.states.checked == Qt.Checked, contents)
- def selfchecked_ready(self, contents=()):
+ def filter(self, f, contents=()):
contents = contents or self.data_contents
- return filter(lambda x: x.states.selfchecked, contents)
+ return filter(f, contents)
- def takeoff_ready(self, contents=()):
- contents = contents or self.data_contents
- return filter(lambda x: x.states.takeoff_ready, 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 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)
+ 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:
- 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)
@@ -362,32 +437,25 @@ 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()
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]
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
+ 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
@@ -395,17 +463,14 @@ 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(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,))
+ 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):
@@ -418,70 +483,150 @@ 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)
- if formatted_value is not None: # todo use new := syntax
- self.data_contents[row][col] = formatted_value
+ formatted_value = self.formatter.format_place(self.columns[col], 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
- elif role == ModelDataRole: # For inner setting\editing of data
+ 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.update_model(index, role)
+ self.emit_signals()
+ self.dataChanged.emit(index, index, (role,))
return True
- 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
- 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):
+ 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_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)
- 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)
+
+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 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[5] == "CALIBRATING"
+ return copter_item["calibration_status"] == "CALIBRATING"
def calibration_ready_check(copter_item):
- if not ModelChecks.checks_dict[4](copter_item[4]):
+ if not copter_item.states["fcu_status"]: # ModelChecks.check("fcu_status", copter_item):
return False
return not calibrating_check(copter_item)
@@ -505,18 +650,10 @@ 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)
-
-
-
if __name__ == '__main__':
- import threading
import time
+
def timer():
idc = 1001
while True:
@@ -524,11 +661,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)
@@ -549,14 +687,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 = 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_()
diff --git a/Server/icons/coex_splash.jpg b/Server/icons/coex_splash.jpg
new file mode 100644
index 0000000..5c77c63
Binary files /dev/null and b/Server/icons/coex_splash.jpg differ
diff --git a/Server/icons/image.ico b/Server/icons/image.ico
new file mode 100644
index 0000000..76bea99
Binary files /dev/null and b/Server/icons/image.ico differ
diff --git a/Server/server.py b/Server/server.py
index 7cdf020..8771c81 100644
--- a/Server/server.py
+++ b/Server/server.py
@@ -1,3 +1,4 @@
+import os
import sys
import time
import socket
@@ -7,60 +8,53 @@ import datetime
import threading
import selectors
import collections
-import configparser
+import traceback
-import os, inspect # Add parent dir to PATH to import messaging_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)
sys.path.insert(0, parent_dir)
+
import messaging_lib as messaging
+from config import ConfigManager
random.seed()
now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
-path = 'server_logs'
-if not os.path.exists(path):
+log_path = 'server_logs'
+if not os.path.exists(log_path):
try:
- os.mkdir(path)
+ os.mkdir(log_path)
except OSError:
- print("Creation of the directory %s failed" % path)
+ print("Creation of the directory {} failed".format(log_path))
else:
- print("Successfully created the directory %s " % path)
+ print("Successfully created the directory {}".format(log_path))
-logging.basicConfig( # TODO all prints as logs
- 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()
- ])
+logger = logging.getLogger(__name__)
ConfigOption = collections.namedtuple("ConfigOption", ["section", "option", "value"])
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"):
self.id = server_id if server_id else str(random.randint(0, 9999)).zfill(4)
self.time_started = 0
- self.on_stop = on_stop
-
# Init socket
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()
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,
@@ -69,63 +63,63 @@ 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()
+ 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,
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
+ self.config.load_config_and_spec(self.config_path)
- self.remove_disconnected = self.config.getboolean('SERVER', 'remove_disconnected')
+ def start(self):
+ # load config on startup
+ self.load_config()
- 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,
+ socket.gethostname()))
+ 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_interrupt.set()
self.broadcast_thread_running.clear()
+
self.listener_thread_running.clear()
+
+ messaging.NotifierSock().notify()
+
self.server_socket.close()
self.sel.close()
+
+ messaging.NotifierSock().close()
+
logging.info("Server stopped")
- if self.on_stop is not None:
- self.on_stop()
-
- sys.exit("Stopped")
+ def terminate(self, reason="Terminated"):
+ self.stop()
+ logging.critical(reason)
@staticmethod
def get_ntp_time(ntp_host, ntp_port):
@@ -137,11 +131,10 @@ 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)
- else:
- timenow = time.time()
- return timenow
+ if self.config.ntp_use:
+ return self.get_ntp_time(self.config.ntp_host, self.config.ntp_port)
+
+ return time.time()
# noinspection PyArgumentList
def _client_processor(self):
@@ -151,14 +144,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()
- #logging.error('tick')
+ events = self.sel.select(timeout=1)
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)
@@ -167,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)
@@ -174,13 +165,18 @@ class Server(messaging.Singleton):
logging.info("Client autoconnect thread stopped!")
def _connect_client(self, sock):
- conn, addr = sock.accept()
+ try:
+ conn, addr = sock.accept()
+ except OSError:
+ logging.error("Error while connecting socket!")
+ return
+
logging.info("Got connection from: {}".format(str(addr)))
conn.setblocking(False)
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]]
@@ -191,72 +187,82 @@ 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)})
+ 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 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)
broadcast_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
- logging.info("Formed broadcast message: {}".format(msg))
- time.sleep(self.BROADCAST_DELAY)
-
- while self.broadcast_thread_running.is_set():
- try:
- broadcast_sock.sendto(msg, ('255.255.255.255', self.broadcast_port))
- except OSError as e:
- logging.error("Exception occured while broadcasting: {}".format(e))
- except Exception as e:
- broadcast_sock.close()
- logging.info("Broadcast sender thread stopped, socked closed!")
- else:
- logging.debug("Broadcast sent")
- finally:
- time.sleep(self.BROADCAST_DELAY)
+ try:
+ while self.broadcast_thread_running.is_set():
+ self.broadcast_thread_interrupt.wait(timeout=self.config.broadcast_delay)
+ try:
+ 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:
+ logging.debug("Broadcast sent")
+ except Exception as e:
+ logging.error(f"Unexpected error {e}!")
+ raise
def _broadcast_listen(self):
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.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
- self.stop()
+ self.terminate("Another server is running on this computer, shutting down!")
+ return
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()
- 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 message.jsonheader["action"] == "server_ip")
+
+ if right_command:
+ 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
+ self.terminate("Another server detected over the network, shutting down!")
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)})
+ copter.send_message("start", kwargs={"time": str(start_time)})
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
@@ -265,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
@@ -279,7 +284,7 @@ class Client(messaging.ConnectionManager):
on_disconnect = None
def __init__(self, ip):
- super(Client, self).__init__()
+ super().__init__()
self.copter_id = None
self.connected = False
@@ -296,12 +301,12 @@ class Client(messaging.ConnectionManager):
if not self.resume_queue:
self._send_queue = collections.deque()
- super(Client, self).connect(client_selector, client_socket, client_addr)
+ super().connect(client_selector, client_socket, client_addr)
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)
@@ -321,9 +326,9 @@ class Client(messaging.ConnectionManager):
self.on_disconnect(self)
if inner:
- super(Client, self)._close()
+ super()._close()
else:
- super(Client, self).close()
+ super().close()
logging.info("Connection to {} closed!".format(self.copter_id))
@@ -340,18 +345,9 @@ class Client(messaging.ConnectionManager):
@requires_connect
def _send(self, data):
- super(Client, self)._send(data)
+ 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):
@@ -361,13 +357,21 @@ 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)
+ 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__':
+ 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()
+ ])
+
server = Server()
server.start()
while True:
- pass
\ No newline at end of file
+ pass
diff --git a/Server/server_config.ini b/Server/server_config.ini
deleted file mode 100644
index 653133d..0000000
--- a/Server/server_config.ini
+++ /dev/null
@@ -1,19 +0,0 @@
-[SERVER]
-port = 25000
-buffer_size = 10000
-remove_disconnected = False
-
-[CHECKS]
-battery_percentage_min = 50
-start_pos_delta_max = 1
-time_delta_max = 1
-
-[BROADCAST]
-use_broadcast = True
-broadcast_port = 8181
-broadcast_delay = 5
-
-[NTP]
-use_ntp = False
-host = ntp1.stratum2.ru
-port = 123
diff --git a/Server/server_gui.py b/Server/server_gui.py
index b448385..448c60a 100644
--- a/Server/server_gui.py
+++ b/Server/server_gui.py
@@ -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, 25))
self.menubar.setObjectName("menubar")
self.menuOptions = QtWidgets.QMenu(self.menubar)
self.menuOptions.setObjectName("menuOptions")
- self.menuDeveloper_mode = QtWidgets.QMenu(self.menuOptions)
+ 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.menuDeveloper_mode = QtWidgets.QMenu(self.menuDrone_2)
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")
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,74 @@ 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.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")
+ 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.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)
+ 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.menuDeveloper_mode.addAction(self.action_update_client_repo)
+ self.menuDrone_2.addAction(self.menuSend.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_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.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.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)
@@ -334,43 +369,58 @@ 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.menuMusic_2.setTitle(_translate("MainWindow", "Music"))
+ self.menuTable.setTitle(_translate("MainWindow", "Table"))
+ self.menuDrone_2.setTitle(_translate("MainWindow", "Selected drones"))
+ self.menuSend.setTitle(_translate("MainWindow", "Send"))
+ self.menuRestart.setTitle(_translate("MainWindow", "Restart service"))
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.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_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 from table"))
- self.action_send_calibrations.setText(_translate("MainWindow", "Send camera calibrations"))
- self.action_reboot_all.setText(_translate("MainWindow", "Reboot all"))
- self.action_restart_chrony.setText(_translate("MainWindow", "Restart chrony"))
- self.action_send_fcu_parameters.setText(_translate("MainWindow", "Send FCU parameters"))
+ 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", "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"))
+ 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 file"))
+ self.action_restart_server.setText(_translate("MainWindow", "Restart server"))
+ self.action_configure_columns.setText(_translate("MainWindow", "Configure columns"))
+ self.actionSomething.setText(_translate("MainWindow", "something"))
diff --git a/Server/server_gui.ui b/Server/server_gui.ui
index 59e73ad..e00e6f4 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,85 +426,104 @@
0
0
1360
- 22
+ 25
+
+
-
-
-
+
-
-
-
+
- Send animations
+ Animations
- Send configurations
+ Configuration
-
+
- Send aruco map
+ Aruco map
@@ -528,17 +538,17 @@
- Send launch files
+ Launch files
- Restart clever service
+ clever
- Restart clever-show service
+ clever-show
@@ -571,12 +581,12 @@
- Select music file
+ Select file
- Play music
+ Play
@@ -591,42 +601,110 @@
- Send any file
+ File
- Send any command
+ Command
- Stop music
+ Stop
- Remove from table
+ Remove selected drones
+
+
+ Ctrl+Del
- Send camera calibrations
+ Camera calibrations
- Reboot all
+ Reboot
- Restart chrony
+ chrony
- Send FCU parameters
+ FCU parameters
+
+
+
+
+ Toggle select
+
+
+ Ctrl+A
+
+
+
+
+ Select all
+
+
+ Shift+A
+
+
+
+
+ Deselect all
+
+
+ Alt+A
+
+
+
+
+ Edit server config
+
+
+
+
+ Edit any config
+
+
+
+
+ false
+
+
+ Update server git
+
+
+ false
+
+
+
+
+ Retrive file
+
+
+
+
+ Restart server
+
+
+
+
+ Configure columns
+
+
+
+
+ something
diff --git a/Server/server_qt.py b/Server/server_qt.py
index f6fee60..2c315a8 100644
--- a/Server/server_qt.py
+++ b/Server/server_qt.py
@@ -1,43 +1,65 @@
import os
+import re
+import sys
import glob
-import math
import time
+import logging
import asyncio
-import functools
+import platform
+import itertools
+import subprocess
+from functools import partial, wraps
-from PyQt5 import QtWidgets, QtMultimedia
-from PyQt5.QtGui import QStandardItemModel, QStandardItem
-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
-from quamash import QEventLoop, QThreadExecutor
+from PyQt5.QtWidgets import QFileDialog, QMessageBox, QApplication, QInputDialog, QLineEdit, QStatusBar, \
+ QSplashScreen, QProgressBar
+from quamash import QEventLoop
# Importing gui form
from server_gui import Ui_MainWindow
-from server import *
+from server import Server, Client, now
+
import messaging_lib as messaging
-from copter_table_models import *
+import config as cfg
+
+import copter_table_models as table
+from copter_table import CopterTableWidget, HeaderEditDialog
from visual_land_dialog import VisualLandDialog
+from config_editor_models import ConfigDialog
-import threading
+from lib import b_partial
+
+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
+ window.server.stop()
+ window.on_quit()
+ QApplication.quit()
+
+ 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 wait(end, interrupter=threading.Event(), maxsleep=0.1):
- # Added features to interrupter sleep and set max sleeping interval
-
- while not interrupter.is_set(): # Basic implementation of pause module until()
- now = time.time()
- diff = min(end - now, maxsleep)
- if diff <= 0:
- break
- else:
- time.sleep(diff / 2)
+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):
- @functools.wraps(f)
+ @wraps(f)
def wrapper(*args, **kwargs):
reply = QMessageBox.question(
args[0], label,
@@ -55,42 +77,124 @@ def confirmation_required(text="Are you sure?", label="Confirm operation?"):
return inner
-# noinspection PyArgumentList,PyCallByClass
+class ExitMsgbox(logging.Handler):
+ def emit(self, record):
+ loop.call_soon_threadsafe(self._emit, record) # TODO replace loop call
+
+ def _emit(self, record):
+ # window.close()
+ QMessageBox.warning(None, "Critical error in {}: {}". format(record.name, record.threadName), record.msg)
+ QApplication.quit()
+ sys.exit(record.msg)
+
+
+class ServerQt(Server):
+ def load_config(self):
+ super().load_config()
+ 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
+
+
+# noinspection PyCallByClass,PyArgumentList
class MainWindow(QtWidgets.QMainWindow):
- def __init__(self):
+ def __init__(self, server):
super(MainWindow, self).__init__()
+ self.server = server
+ self.model = table.CopterDataModel()
+
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.init_ui()
- self.model = CopterDataModel()
- self.proxy_model = CopterProxyModel()
- self.signals = SignalManager()
- self.player = QtMultimedia.QMediaPlayer()
-
self.init_model()
- self.show()
+ # self.statusBar = QStatusBar()
+ # self.setStatusBar(self.statusBar)
+ # self.statusBar.showMessage("Hey", 2000)
+
+ self.register_callbacks()
+ 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.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"))
+ 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"))
+ 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(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_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_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)
+
+ 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_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", 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"))
+
+ 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
+ self.ui.horizontalLayout.removeWidget(self.ui.tableView)
+ self.ui.tableView.close()
+ # Init our custom widget
+ 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)
+ self.ui.copter_table.setFocus()
def init_model(self):
- # self.model.on_id_changed = self.set_copter_id
-
- self.proxy_model.setDynamicSortFilter(True)
- self.proxy_model.setSourceModel(self.model)
-
- # Initiate table and table self.model
- 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_row_signal.connect(self.model.remove_row)
- self.signals.remove_client_signal.connect(self.model.remove_row_data)
-
# 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)
@@ -98,245 +202,162 @@ 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.land_selected_button.setDisabled)
self.model.selected_calibrating_signal.connect(self.ui.land_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.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.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)
+ # Set most safety-important buttons disabled
+ self.model.emit_signals()
+
+ def show(self):
+ self.ui.copter_table.load_columns()
+ super().show()
+
+ def showMaximized(self): # TODO move to widget
+ 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()
+ 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)
+
+ if reply != QMessageBox.Yes:
+ event.ignore()
+ else:
+ 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)
+
+ @pyqtSlot()
+ 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):
+ 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))
- self.signals.add_client_signal.emit(StatedCopterData(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().remove_disconnected and (not client.connected):
+ if self.server.config.table_remove_disconnected and (not client.connected):
client.remove()
- self.signals.remove_client_signal.emit(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.signals.update_data_signal.emit(row_num, 0, client.connected, ModelStateRole)
- logging.debug("DATA: connected")
-
- def init_ui(self):
- # Connecting
- self.ui.check_button.clicked.connect(self.selfcheck_selected)
- self.ui.start_button.clicked.connect(self.send_starttime_selected)
- self.ui.pause_button.clicked.connect(self.pause_resume_selected)
-
- self.ui.land_selected_button.clicked.connect(self.land_selected)
- self.ui.land_all_button.clicked.connect(self.land_all)
-
- self.ui.visual_land_button.clicked.connect(self.visual_land)
- self.ui.emergency_land_button.clicked.connect(self.emergency_land_selected)
-
- self.ui.disarm_selected_button.clicked.connect(self.disarm_selected)
- self.ui.disarm_all_button.clicked.connect(self.disarm_all)
-
- self.ui.leds_button.clicked.connect(self.test_leds_selected)
- self.ui.takeoff_button.clicked.connect(self.takeoff_selected)
- self.ui.flip_button.clicked.connect(self.flip_selected)
-
- self.ui.reboot_fcu.clicked.connect(self.reboot_selected)
- 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)
- 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(self.restart_clever)
- self.ui.action_restart_clever_show.triggered.connect(self.restart_clever_show)
- self.ui.action_update_client_repo.triggered.connect(self.update_client_repo)
- self.ui.action_reboot_all.triggered.connect(self.reboot_all_on_selected)
- self.ui.action_set_start_to_current_position.triggered.connect(self.update_start_to_current_position)
- self.ui.action_reset_start.triggered.connect(self.reset_start)
- self.ui.action_set_z_offset_to_ground.triggered.connect(self.set_z_offset_to_ground)
- self.ui.action_reset_z_offset.triggered.connect(self.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)
-
- # Set most safety-important buttons disabled
- self.ui.start_button.setEnabled(False)
- self.ui.takeoff_button.setEnabled(False)
- self.ui.flip_button.setEnabled(False)
+ self.model.update_data(row_num, 0, client.connected, table.ModelStateRole)
+ logging.debug("Client status updated")
@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):
- cols_dict = {
- "git_version": 1,
- "animation_id": 2,
- "battery": 3,
- "system_status": 4,
- "calibration_status": 5,
- "mode": 6,
- "selfcheck": 7,
- "current_position": 8,
- "start_position": 9,
- "time": 10,
- }
-
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.signals.update_data_signal.emit(row_num, col, value, Qt.EditRole)
-
- @pyqtSlot(QtCore.QModelIndex)
- def selfcheck_info_dialog(self, index):
- col = index.column()
- if col == 7:
- 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): # TODO!!!
- shortened = []
- for line in data:
- if len(line) > 89:
- pass
- return shortened
+ try:
+ col = self.model.columns.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):
for copter in self.model.user_selected():
copter.client.remove()
-
- if not Server().remove_disconnected:
- self.signals.remove_client_signal.emit(copter)
+ if not self.server.config.table_remove_disconnected:
+ self.model.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()
+ 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))
- # self.selfcheck_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():
- if self.model.checks.all_checks(copter):
- server.send_starttime(copter.client, dt + time_now)
+ server.send_starttime(copter.client, dt + time_now + time_lag)
@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')
-
-
- @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 emergency_land_selected(self):
- for copter in self.model.user_selected():
- copter.client.send_message("emergency_land")
-
- @pyqtSlot()
- def disarm_selected(self):
- for copter in self.model.user_selected():
- copter.client.send_message("disarm")
-
- @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")
+ time_gap = 0.1 # TODO config? automatic delay detection?
+ self.send_to_selected("resume", command_kwargs={"time": server.time_now() + time_gap})
+ self.ui.pause_button.setText('Pause')
@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 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
+ copter.client.send_message("takeoff_z", kwargs={"z": str(self.ui.z_spin.value())}) # todo int, merge commands
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 reboot_selected(self):
- for copter in self.model.user_selected():
- copter.client.send_message("reboot_fcu")
-
- @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
row = self.model.get_row_index(copter_data_row)
col = 5
data = 'CALIBRATING'
- self.signals.update_data_signal.emit(row, col, data, ModelDataRole)
+ self.model.update_data(row, col, data, table.ModelDataRole)
# Send request
client.get_response("calibrate_gyro", self._get_calibration_info)
@@ -348,7 +369,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, ModelDataRole)
+ self.model.update_data(row, col, data, table.ModelDataRole)
# Send request
client.get_response("calibrate_level", self._get_calibration_info)
@@ -358,162 +379,192 @@ 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, ModelDataRole)
+ self.model.update_data(row, col, data, table.ModelDataRole)
- @pyqtSlot()
- def send_animations(self):
- path = str(QFileDialog.getExistingDirectory(self, "Select Animation Directory"))
+ 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)
- 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")
+ for num, file in enumerate(files):
+ filepath, filename = os.path.split(file)
+ logging.info("Preparing file for sending: {} {}".format(filepath, filename))
- @pyqtSlot()
- def send_calibrations(self):
- path = str(QFileDialog.getExistingDirectory(self, "Select directory with calibration files"))
+ if match_id:
+ name = os.path.splitext(filename)[0]
+ to_send = [copter for copter in copters if re.fullmatch(name, copter.copter_id)]
+ else:
+ to_send = copters
- 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")
+ if not to_send:
+ logging.error(f"No copters to send file {filename} to")
+ continue
- @pyqtSlot()
- def send_configurations(self):
- 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.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))
+ logging.info(f"Sending file {filename} to clients: {to_send}")
+ filename = client_filename.format(num, filename) or filename
- for copter in self.model.user_selected():
- copter.client.send_config_options(*options)
+ for copter in to_send:
+ copter.client.send_file(file, os.path.join(client_path, filename))
+ if callback is not None:
+ callback(copter)
- @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"})
+ def send_files(self, prompt, ext_filter, copters=None, client_path="", client_filename="", match_id=False,
+ 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]
- @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, ))
+ if not files:
+ return
- def _print_send_fcu_params_result(self, value, copter):
- logging.info("Send parameters to {} success: {}".format(copter.client.copter_id, value))
+ 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:
+ 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)
+
+ 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, "")
+ 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):
- 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)
+ 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:",
+ QLineEdit.Normal, "") # TODO config?
+ if not ok:
+ return
+
+ 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)
+
+ @pyqtSlot()
+ def send_animations(self):
+ self.send_directory_files("Select directory with animations", ('.csv', '.txt'), match_id=True,
+ client_path="", client_filename="animation.csv")
+
+ @pyqtSlot()
+ def send_calibrations(self):
+ self.send_directory_files("Select directory with calibrations", ('.yaml', ), match_id=True,
+ 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
+ # home = expanduser("~") -> "~catkin_ws/src/clever/clever/camera_info/"
+
+ @pyqtSlot()
+ def send_aruco(self):
+ def callback(copter):
+ 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=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=os.path.join(self.server.config.client_clever_dir,"launch/")) # TODO clever restart callback?
+
+ @pyqtSlot()
+ def send_fcu_parameters(self):
+ def request_callback(client, value):
+ logging.info("Send parameters to {} success: {}".format(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_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
+
+ config = cfg.ConfigManager()
+ config.load_only_config(path)
+ data = config.full_dict(include_defaults=False)
+ logging.info(f"Loaded config from {path}")
+
+ copters = self.model.user_selected()
+ for copter in copters:
+ copter.client.send_message("config", kwargs={"config": data, "mode": mode.lower()})
@pyqtSlot()
def send_any_command(self):
- 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"})
+ text, ok = QInputDialog.getText(self, "Enter command to send on copter",
+ "Command:", QLineEdit.Normal, "")
+ if ok and text:
+ self.send_to_selected("execute", command_kwargs={"command": text})
@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")
+ copter.client.send_message("service_restart", kwargs={"name": "visual_pose_watchdog"})
+ copter.client.send_message("service_restart", kwargs={"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]
- 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):
@@ -541,6 +592,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):
@@ -557,36 +609,93 @@ 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()
+
+ @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):
+ self.update_table_data(client, value)
-@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():
+ import ctypes
+
+ myappid = 'COEX.droneshow.droneserver'
+ ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid)
+
+
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)
+ app = 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 = QIcon()
+ app_icon.addFile('icons/image.ico', QtCore.QSize(256, 256))
+ app.setWindowIcon(app_icon)
+
+ if sys.platform == 'win32':
+ set_taskbar_icon()
+
loop = QEventLoop(app)
asyncio.set_event_loop(loop)
# 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)
+ app.aboutToQuit.connect(window.on_quit)
+
server.start()
+
+ window.showMaximized()
+ splash.close()
+
loop.run_forever()
server.stop()
diff --git a/Server/visual_land_dialog.py b/Server/visual_land_dialog.py
index aed194c..6466a9e 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 lib 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"))
@@ -38,10 +39,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,16 +57,17 @@ 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):
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:
@@ -83,9 +85,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()
@@ -104,7 +106,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))
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..55c911e
--- /dev/null
+++ b/config.py
@@ -0,0 +1,357 @@
+import os
+import copy
+import collections
+
+from configobj import ConfigObj, Section, flatten_errors
+from validate import Validator, is_tuple, is_boolean, is_integer
+
+
+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
+ 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))
+
+
+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)
+ 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
+ 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
+
+ self._name_dict = {}
+
+ 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 get_chain(self, *keys):
+ current = self.config
+ for key in keys:
+ current = current[key]
+ return current
+
+ 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
+
+ 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)
+
+ def validate_config(self, config=None, copy_defaults=False):
+ config = self.config if config is None else config
+ 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
+ raise ValidationError('Some config values are wrong', config, test)
+
+ self.set_config(config)
+
+ @classmethod
+ def _full_dict(cls, item, include_defaults=False):
+ if not isinstance(item, Section):
+ return item
+
+ data = collections.OrderedDict()
+ 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, include_defaults)
+ if not isinstance(result, dict):
+ item_d = {'__value__': value}
+
+ comment = comments.get(key, [])
+ if comment and comment != ['']:
+ item_d.update({'comments': comment})
+
+ inline_comment = inline_comments.get(key, None)
+ if inline_comment:
+ 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
+
+ return data
+
+ 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
+
+ @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))
+ 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:
+ keys = self.__dict__['_name_dict'][item]
+ return self.get_chain(*keys)
+ except (ValueError, KeyError):
+ return self.__dict__[item]
+
+ def __setattr__(self, key, value):
+ try:
+ keys = self.__dict__['_name_dict'][key]
+ self.set_chain(value, *keys)
+ 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_{}')
+
+ @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.indent_type = ''
+ config.initial_comment = ('This is generated config with default values',
+ 'Modify to configure')
+ config.write()
+ return True
+
+ @classmethod
+ def _extract_values(cls, d):
+ result = collections.OrderedDict()
+ for key, val in d.items():
+ if not isinstance(val, dict): # Pure dict option
+ result[key] = val
+ elif '__value__' in val: # Full-dict option with params
+ if not val.get('unchanged', False):
+ result[key] = val.get('__value__')
+ else: # Section
+ result[key] = cls._extract_values(val)
+ return result
+
+ @classmethod
+ def _load_comments(cls, d, section):
+ comments = section.comments
+ inline_comments = section.inline_comments
+
+ for key, val in d.items():
+ if not isinstance(val, dict): # Pure dict option
+ comments[key] = []
+ inline_comments[key] = None
+ 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)
+ else: # Section
+ 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, configspec=None):
+ initial_comment = d.pop('initial_comment', [''])
+ 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)
+ 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 = filename
+ config.initial_comment = initial_comment
+ config.final_comment = final_comment
+
+ if config.configspec is not None:
+ self.validate_config(config)
+ else:
+ self.set_config(config)
+
+ self._load_comments(d, self.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__':
+ cfg = ConfigManager()
+ 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)
+ # #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.get('SERVER', 'host'))
+ #
+ # 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)
+ # 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(dict(ConfigManager(cfg.config.configspec).config))
+
+ # #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()
+ #
+
diff --git a/docs/assets/server-column-editor.png b/docs/assets/server-column-editor.png
new file mode 100644
index 0000000..ed22aa2
Binary files /dev/null and b/docs/assets/server-column-editor.png differ
diff --git a/docs/assets/server-column-popup.png b/docs/assets/server-column-popup.png
new file mode 100644
index 0000000..234d4fe
Binary files /dev/null and b/docs/assets/server-column-popup.png differ
diff --git a/docs/assets/server-drone-restart.png b/docs/assets/server-drone-restart.png
new file mode 100644
index 0000000..9431851
Binary files /dev/null and b/docs/assets/server-drone-restart.png differ
diff --git a/docs/assets/server-drone-send.png b/docs/assets/server-drone-send.png
new file mode 100644
index 0000000..53dc779
Binary files /dev/null and b/docs/assets/server-drone-send.png differ
diff --git a/docs/assets/server-drone.png b/docs/assets/server-drone.png
deleted file mode 100644
index 41c3b46..0000000
Binary files a/docs/assets/server-drone.png and /dev/null differ
diff --git a/docs/assets/server-gui.png b/docs/assets/server-gui.png
index fa75efa..b8bcd54 100644
Binary files a/docs/assets/server-gui.png and b/docs/assets/server-gui.png differ
diff --git a/docs/assets/server-led-emergency-land.png b/docs/assets/server-led-emergency-land.png
index 8105c1e..fc253f1 100644
Binary files a/docs/assets/server-led-emergency-land.png and b/docs/assets/server-led-emergency-land.png differ
diff --git a/docs/assets/server-music.png b/docs/assets/server-music.png
index dcd3882..d43052f 100644
Binary files a/docs/assets/server-music.png and b/docs/assets/server-music.png differ
diff --git a/docs/assets/server-server.png b/docs/assets/server-server.png
deleted file mode 100644
index 1c3f914..0000000
Binary files a/docs/assets/server-server.png and /dev/null differ
diff --git a/docs/assets/server-table.png b/docs/assets/server-table.png
new file mode 100644
index 0000000..b565c12
Binary files /dev/null and b/docs/assets/server-table.png differ
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
diff --git a/docs/ru/server.md b/docs/ru/server.md
index b9b011a..f725068 100644
--- a/docs/ru/server.md
+++ b/docs/ru/server.md
@@ -15,7 +15,11 @@
### Таблица состояния коптеров
-При первом подключении клиента к серверу в таблицу добавляется строка для отображения состояния клиента, содержащая только имя клиента (`copter ID`). Если на клиентах настроена автоматическая передача телеметрии, данные в таблице будут обновляться автоматически. Так же возможно запросить телеметрию выбранных клиентов с помощью кнопки [`Preflight check`](#управление) Строки можно сортировать по возрастанию или убыванию значений любого из столбцов, кликнув по его заголовку.
+При первом подключении клиента к серверу в таблицу добавляется строка для отображения состояния клиента, содержащая только имя клиента (`copter ID`). Если на клиентах настроена автоматическая передача телеметрии, данные в таблице будут обновляться автоматически. Так же возможно запросить телеметрию выбранных клиентов с помощью кнопки [`Preflight check`](#управление).
+
+Строки можно сортировать по возрастанию или убыванию значений любого из столбцов, кликнув по его заголовку.
+
+Столбцы можно менять местами и изменять их ширину: все изменения сохраняются в настройках сервера.
Ячейки таблицы подсвечиваются:
@@ -30,64 +34,100 @@
#### Столбцы таблицы
* `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`. В остальных случаях, если ячейка не пустая, она проходит проверку.
+ * При двойном клике на ячейку при наличии ошибок будет показано диалоговое окно с полной детализацией всех ошибок.
+* `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), задаваемого в настройках сервера. В остальных случаях, если ячейка не пустая, она не проходит проверку. При слишком больших значениях сигнализирует об отсутствии синхронизации времени между коптером и клиентом.
### Меню
-#### Раздел Server
+#### Раздел Selected drones
-
+
-Данный раздел содержит несколько утилит по отправке различных данных на *выбранные* клиенты. **Внимание!** Не используйте данные команды во время полёта коптеров!
+Данный раздел содержит несколько утилит по отправке различных данных и команд на *выбранные* клиенты. **Внимание!** Не используйте данные команды во время полёта коптеров!
-* `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, на выбранные коптеры. В диалоговом окне необходимо выбрать *папку*, содержащую файлы анимации. Каждый файл анимации будет отправлен на клиент с именем, соответствующим имени файла без расширения.
-
+ * `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 Service`
+
+ 
+
+ * `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
-
+* Подраздел `Music`
-* `Set start X Y to current position` - устанавливает точку старта анимации у выбранных клиентов в значения текущей позиции по X Y.
-* `Reset start position` - устанавливает точку старта анимации у выбранных клиентов в значения `0.0`, `0.0`.
+ 
-#### Раздел Music
+ * `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 any config` - открывает [встроенный редактор конфигурационных файлов](#config-editor) и позволяет выбрать для редактирования в файловой системе любой файл конфигурации c расширением `.ini` или же открыть файл спецификации конфигурации для создания файла конфигурации на его основе.
+
+* `Restart server` - полностью перезапускает сервер. **Внимание!** После перезапуска сервер более не будет соединён с консолью, из которой был запущен, если сервер изначально был запущен из консоли.
+
+#### Раздел Table
+
+
+
+* `Toggle select` (`Ctrl+A`) - выделят все коптеры\снимает выделение со всех коптеров. Если в таблице выбраны не все коптеры, то *выделяет все* коптеры. Иначе (если были выделены все коптеры) *снимает выделение* со всех коптеров.
+* `Select all` - выделят все коптеры в таблице.
+* `Deselect all` - снимает выделение со всех коптеров в таблице.
+* `Remove selected drones` - удаляет выбранные коптеры из таблицы. **Внимание!** В случае, если клиент был подключен, будет произведено отключение. В случае если удалённый таким образом клиент исправно функционировал, он переподключится в кратчайшие сроки.
+* `Configure columns` - открывает [встроенный конфигуратор](#column-preset-editor) наборов настроек столбцов таблицы.
### Боковая панель команд
@@ -112,7 +152,7 @@
* Кнопка `Land ALL` - ВСЕ коптеры прекращают выполнение своих полётных заданий, очищают очередь заданий и немедленно переходят в режим посадки AUTO.LAND. **Используйте в экстренных случаях как одно из средств перехвата.**
* Кнопка `Emergency land` - все выбранные коптеры прекращают выполнение своих полётных заданий, очищают очередь заданий и немедленно переходят в режим экстренной посадки - на все моторы подаётся небольшая мощность, которая уменьшается через определённое время до нуля. **Используйте в экстренных случаях как одно из средств перехвата.**
-* Кнопка `Visual land` - открывает диалоговое окно модуля визуальной посадки неисправного коптера. Полное описание находится в [конце статьи](#visual-land).
+* Кнопка `Visual land` - открывает [диалоговое окно](#visual-land) модуля визуальной посадки неисправного коптера.
* Кнопка `Disarm selected` - все выбранные коптеры прекращают выполнение своих полётных заданий, очищают очередь заданий и немедленно отключают моторы. Это может привести к падению и повреждению коптеров.
* Кнопка `Disarm ALL` - ВСЕ коптеры прекращают выполнение своих полётных заданий, очищают очередь заданий и немедленно отключают моторы. Это может привести к падению и повреждению коптеров **Используйте в крайних случаях как последнее из средств перехвата.**
@@ -139,31 +179,18 @@
### Файл конфигурации
-Конфигурация сервера задаётся в файле [server_config.ini](../../Server/server_config.ini), имеющем следующий вид по умолчанию:
+Конфигурация сервера создаётся согласно [спецификации](../../Server/config/spec/configspec_server.ini), в ней можно посмотреть значения по умолчанию для любого параметра после ключевого слова `default`. Все изменения сохраняются в файл конфигурации `server.ini` в папке `clever-show/Server/config`.
-```ini
-[SERVER]
-port = 25000
-buffer_size = 1024
-remove_disconnected = False
+Доступно редактирование конфигурации сервера через GUI модуль `Config editor` через меню `Server -> Edit server config`.
-[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
-```
+Конфигурация по умолчанию является полностью работоспособной и не требует изменений для быстрого старта клиента.
-Конфигурация по умолчанию является полностью работоспособной и не требует изменений для быстрого начала работы системы.
+### Описание параметров
+
+#### Корневой раздел
+
+* `config_name` - Произвольная строка, название файла конфигурации для удобства хранения и быстрого отличия одного файла конфигурации от другого.
+* `config_version` - Произвольное дробное число, версия файла конфигурации для удобства хранения и быстрого отличия одного файла конфигурации от другого.
#### Раздел SERVER
@@ -171,12 +198,25 @@ 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` как неудовлетворительное.
@@ -185,19 +225,20 @@ 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 сервером
-## Дополнительные операции
+## Дополнительные операции и окна
### Visual land
@@ -209,6 +250,14 @@ port = 123
При нажатии на кнопку `Visual land` все коптеры делятся на 2 равные группы по порядку расположения в таблице. Первая половина коптеров зажигает светодиодную ленту зелёным цветом, вторая - красным. При нажатии на зелёную или красную кнопку происходит выбор группы, соответствующей цвету нажатой кнопки. Коптеры выбранного цвета снова делятся на две половины и каждая половина зажигает светодиодную ленту зелёным и красным цветом соответственно. Остальные коптеры выключают светодиодную ленту.
-Нажимая на кнопки, соответствующие цвету группы, в которой находится неисправный коптер, можно определить его номер и выполнить экстренную посадку за логорифмическое количество шагов от количества коптеров, т.е. гораздо быстрее, чем перебирая коптеры по одному.
+Нажимая на кнопки, соответствующие цвету группы, в которой находится неисправный коптер, можно определить его номер и выполнить экстренную посадку за логарифмическое количество шагов от количества коптеров, т.е. гораздо быстрее, чем перебирая коптеры по одному.
На любом шаге можно произвести посадку или выключение моторов всех коптеров, на которых включена светодиодная лента, нажав кнопку `Land` или `Disarm`.
+
+### Config editor
+
+...
+
+### Column preset editor
+
+...
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)
diff --git a/messaging_lib.py b/messaging_lib.py
index 4591842..35ab4a4 100644
--- a/messaging_lib.py
+++ b/messaging_lib.py
@@ -5,10 +5,11 @@ import json
import socket
import struct
import random
-import inspect
import logging
import threading
import collections
+import platform
+import traceback
from contextlib import closing
@@ -18,9 +19,6 @@ except ImportError:
import selectors2 as selectors
-# import logging_lib
-
-
class Namespace:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
@@ -38,9 +36,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:
@@ -51,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 = {}
@@ -78,7 +97,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
@@ -100,35 +119,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):
@@ -177,10 +202,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)
@@ -315,21 +340,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:
@@ -346,73 +373,106 @@ class ConnectionManager(object):
raise RuntimeError("Peer closed.")
- def process_received(self, income_message):
- message_type = income_message.jsonheader["message-type"]
+ 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, income_message.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"]
+ kwargs = message.content["kwargs"]
+ callback = self.messages_callbacks.get(action, None)
+ if callback is None:
+ logger.warning("Action {} does not exist!".format(action))
+ return
try:
- self.messages_callbacks[command](self, **args)
- except KeyError:
- logger.warning("Command {} does not exist!".format(command))
+ 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))
+ traceback.print_exc()
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"]
+ kwargs = message.content["kwargs"]
+
+ filetransfer = requested_value == "filetransfer"
try:
- value = self.requests_callbacks[command](self, **args)
- except KeyError:
- logger.warning("Request {} does not exist!".format(command))
+ 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(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):
+ 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"])
)
-
- f = request.callback
- f(self, value, *request.callback_args, **request.callback_kwargs)
- else:
- logger.warning("Unexpected response!")
-
- def _process_filetransfer(self, message): # TODO path?
- if message.jsonheader["content-type"] == "binary":
- filepath = message.jsonheader["filepath"]
+ if request.callback is not None:
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))
+ 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):
+ 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:
@@ -438,8 +498,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:
@@ -449,14 +508,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,
@@ -466,24 +526,39 @@ 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 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
+ 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:
@@ -493,9 +568,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):
@@ -527,9 +601,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:
@@ -539,4 +614,12 @@ 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._server_socket.close()
+ self._sending_sock.close()
+ self._receiving_sock.close()
+ except (OSError, KeyError) as error:
+ logger.error("Error during unregistring notifier socket: {}".format(error))
diff --git a/requirements.txt b/requirements.txt
index 410aa59..bc250b6 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,6 +1,7 @@
-indexed.py==0.0.1
+configobj==5.0.6
numpy==1.18.1
PyQt5==5.13.0
PyQt5-sip==4.19.18
Quamash==0.6.1
selectors2==2.0.1
+six==1.13.0
diff --git a/update_configspec.py b/update_configspec.py
new file mode 100644
index 0000000..590b32d
--- /dev/null
+++ b/update_configspec.py
@@ -0,0 +1,14 @@
+import config
+from Server.copter_table_models import CopterDataModel
+
+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()
+print('Server configspec updated')