mirror of
https://github.com/CopterExpress/clever-show.git
synced 2026-06-04 19:19:33 +00:00
Messaging and server_core improvements. Refactored client_core to use asyncio. Autoconnection is fully working
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Is a client-side module (meant to be run on Python 2.7) containing base Client class, utility functions and basic callbacks declarations. Main focus of the module is client-specific communication without reliance on `clover` Raspberry Pi environment.
|
||||
Is a client-side module containing base Client class, utility functions and basic callbacks declarations. Main focus of the module is client-specific communication without reliance on `clover` Raspberry Pi environment.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -8,11 +8,8 @@ import time
|
||||
import errno
|
||||
import random
|
||||
import socket
|
||||
import struct
|
||||
import asyncio
|
||||
import logging
|
||||
import selectors2 as selectors
|
||||
|
||||
from contextlib import closing
|
||||
|
||||
# Add parent dir to PATH to import messaging_lib and config_lib
|
||||
current_dir = os.path.dirname(os.path.realpath(__file__))
|
||||
@@ -23,10 +20,15 @@ logger = logging.getLogger(__name__)
|
||||
import lib.messaging as messaging
|
||||
from lib.config import ConfigManager
|
||||
|
||||
active_client = None # needs to be refactored: Singleton \ factory callbacks
|
||||
class ServerPeer(messaging.PeerProtocol):
|
||||
def connection_lost(self, exc):
|
||||
super().connection_lost(exc)
|
||||
if exc is not None:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.call_soon(self._parent.reconnect)
|
||||
self._parent._server_connection = None
|
||||
|
||||
|
||||
class Client(object):
|
||||
class Client:
|
||||
"""
|
||||
Client base class provides config loading, communication with server (including automatic reconnection, broadcast listening and binding). You can inherit this class in order to extend functionality for practical applications.
|
||||
|
||||
@@ -48,22 +50,25 @@ class Client(object):
|
||||
Args:
|
||||
config_path (string, optional): Path to the file with configuration. There also should be config specification file at `<config_path>\config\configspec_client.ini`. Defaults to `<current_dir>\os.pardir\config\client.ini`.
|
||||
"""
|
||||
self.selector = selectors.DefaultSelector()
|
||||
self.client_socket = None
|
||||
|
||||
self.callbacks = messaging.CallbackManager()
|
||||
self.server_connection = messaging.ConnectionManager(self.callbacks)
|
||||
|
||||
self.connected = False
|
||||
self._server_connection: messaging.PeerProtocol = None
|
||||
|
||||
self.client_id = None
|
||||
|
||||
# Init configs
|
||||
self.config = ConfigManager()
|
||||
self.config_path = config_path
|
||||
|
||||
self._reconnect_task = None
|
||||
|
||||
global active_client
|
||||
active_client = self
|
||||
self._stopping: asyncio.Future = None
|
||||
self._stopped: asyncio.Future = None
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
return self._server_connection is not None
|
||||
|
||||
def load_config(self):
|
||||
"""
|
||||
@@ -74,7 +79,7 @@ class Client(object):
|
||||
config_id = self.config.id.lower()
|
||||
if config_id == '/default':
|
||||
self.client_id = 'copter' + str(random.randrange(9999)).zfill(4)
|
||||
self.config.set('', 'id', self.client_id, write=True) # set and write
|
||||
self.config.set('', 'id', self.client_id, write=True) # set and write
|
||||
elif config_id == '/hostname':
|
||||
self.client_id = socket.gethostname()
|
||||
elif config_id == '/ip':
|
||||
@@ -84,27 +89,6 @@ class Client(object):
|
||||
|
||||
logger.info("Config loaded")
|
||||
|
||||
@staticmethod
|
||||
def get_ntp_time(ntp_host, ntp_port):
|
||||
"""Gets and returns time from specified host and port of NTP server.
|
||||
|
||||
Args:
|
||||
ntp_host (string): hostname or address of the NTP server.
|
||||
ntp_port (int): port of the NTP server.
|
||||
|
||||
Returns:
|
||||
int: Current time recieved from the NTP server
|
||||
"""
|
||||
NTP_PACKET_FORMAT = "!12I"
|
||||
NTP_DELTA = 2208988800 # 1970-01-01 00:00:00
|
||||
NTP_QUERY = '\x1b' + 47 * '\0'
|
||||
|
||||
with closing(socket.socket(socket.AF_INET, socket.SOCK_DGRAM)) as s:
|
||||
s.sendto(bytes(NTP_QUERY), (ntp_host, ntp_port))
|
||||
msg, address = s.recvfrom(1024)
|
||||
unpacked = struct.unpack(NTP_PACKET_FORMAT, msg[0:struct.calcsize(NTP_PACKET_FORMAT)])
|
||||
return unpacked[10] + float(unpacked[11]) / 2 ** 32 - NTP_DELTA
|
||||
|
||||
def time_now(self):
|
||||
"""gets and returns system time or NTP time depending on the config.
|
||||
|
||||
@@ -112,141 +96,132 @@ class Client(object):
|
||||
int: Current time.
|
||||
"""
|
||||
if self.config.ntp_use:
|
||||
timenow = self.get_ntp_time(self.config.ntp_host, self.config.ntp_port)
|
||||
timenow = messaging.get_ntp_time(self.config.ntp_host, self.config.ntp_port)
|
||||
else:
|
||||
timenow = time.time()
|
||||
return timenow
|
||||
|
||||
def start(self):
|
||||
def serve_forever(self):
|
||||
asyncio.run(self.run(serve_forever=True))
|
||||
|
||||
async def run(self, serve_forever=False):
|
||||
"""
|
||||
Reloads config and starts infinite loop of connecting to the server and processing said connection. Calling of this method will indefinitely halt execution of any subsequent code.
|
||||
"""
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
self._stopping = loop.create_future()
|
||||
self._stopped = loop.create_future()
|
||||
|
||||
self.load_config()
|
||||
self.register_callbacks()
|
||||
|
||||
logger.info("Starting client")
|
||||
messaging.NotifierSock().init(self.selector)
|
||||
logger.info(f"Starting client with id: '{self.client_id}' on '{socket.gethostname()}'"
|
||||
f" ({messaging.get_ip_address()})")
|
||||
|
||||
try:
|
||||
while True:
|
||||
self._reconnect()
|
||||
self._process_connections()
|
||||
self.reconnect()
|
||||
|
||||
except (KeyboardInterrupt, ):
|
||||
logger.critical("Caught interrupt, exiting!")
|
||||
self.selector.close()
|
||||
if serve_forever:
|
||||
await self._stopped
|
||||
|
||||
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)
|
||||
messaging.set_keepalive(self.client_socket)
|
||||
self.client_socket.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
|
||||
self.client_socket.connect((self.config.server_host, self.config.server_port))
|
||||
except socket.error as error:
|
||||
if isinstance(error, OSError):
|
||||
if error.errno == errno.EINTR:
|
||||
logger.critical("Shutting down on keyboard interrupt")
|
||||
raise KeyboardInterrupt
|
||||
|
||||
logger.warning("Can not connect due error: {}".format(error))
|
||||
attempt_count += 1
|
||||
time.sleep(timeout)
|
||||
|
||||
else:
|
||||
logger.info("Connection to server successful!")
|
||||
self._connect()
|
||||
break
|
||||
|
||||
if attempt_count >= attempt_limit:
|
||||
logger.info("Too many attempts. Trying to get new server IP")
|
||||
self.broadcast_bind(timeout*2, attempt_limit)
|
||||
attempt_count = 0
|
||||
|
||||
def _connect(self):
|
||||
self.connected = True
|
||||
self.client_socket.setblocking(False)
|
||||
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.settimeout(timeout)
|
||||
try:
|
||||
broadcast_client.bind(("", self.config.broadcast_port))
|
||||
except socket.error as error:
|
||||
logger.error("Error during broadcast listening binding: {}".format(error))
|
||||
async def stop(self, reason: str=''):
|
||||
if self._stopping.done():
|
||||
logging.error("Client is already stopping")
|
||||
return
|
||||
|
||||
attempt_count = 0
|
||||
self._stopping.set_result(True)
|
||||
logging.info("Stopping client")
|
||||
self._server_connection.transport.close()
|
||||
to_await = [self._server_connection.closed]
|
||||
|
||||
if self._reconnect_task is not None:
|
||||
logging.info("Cancelling reconnection")
|
||||
self._reconnect_task.cancel()
|
||||
to_await.append(self._reconnect_task)
|
||||
|
||||
await asyncio.gather(*to_await, return_exceptions=True) # wait until everything shuts down
|
||||
|
||||
if not self._stopped.done():
|
||||
self._stopped.set_result(True)
|
||||
|
||||
logging.info(f"Client stopped: {reason}")
|
||||
|
||||
def reconnect(self):
|
||||
if self._reconnect_task is not None:
|
||||
logger.warning("Reconnection task is already running")
|
||||
|
||||
logger.info("Starting reconnection task")
|
||||
loop = asyncio.get_event_loop()
|
||||
self._reconnect_task = loop.create_task(self._reconnect()) # todo args
|
||||
|
||||
async def _reconnect(self, attempt_limit=3, timeout=20):
|
||||
logger.info(f"Reconnection task started")
|
||||
|
||||
try:
|
||||
while attempt_count <= attempt_limit:
|
||||
try:
|
||||
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
|
||||
while not self.connected:
|
||||
logger.info(f"Trying to connect to {self.config.server_host}:{self.config.server_port} ...")
|
||||
for attempt_count in range(1, attempt_limit+1):
|
||||
logger.info(f"Waiting for connection, attempt {attempt_count}/{attempt_limit}")
|
||||
await self._connect()
|
||||
if self.connected:
|
||||
return
|
||||
else:
|
||||
message = messaging.MessageManager()
|
||||
message._income_raw = data
|
||||
message.process_message()
|
||||
if message.content and message.jsonheader["action"] == "server_ip":
|
||||
logger.info("Received broadcast message {} from {}".format(message.content, addr))
|
||||
|
||||
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
|
||||
logger.info("Too many attempts. Trying to get new server IP")
|
||||
await self._broadcast_listen(timeout)
|
||||
finally:
|
||||
broadcast_client.close()
|
||||
logging.info("Reconnection task stopped")
|
||||
self._reconnect_task = None
|
||||
|
||||
def on_broadcast_bind(self): # TODO move ALL binding code here
|
||||
async def _connect(self):
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
try:
|
||||
transport, protocol = await loop.create_connection(lambda: ServerPeer(self, self.callbacks),
|
||||
host=self.config.server_host,
|
||||
port=self.config.server_port,
|
||||
)
|
||||
|
||||
except OSError as e:
|
||||
logger.error(f"Cannot connect to server due error: {e}")
|
||||
self._server_connection = None
|
||||
else:
|
||||
logger.info("Connection to server successful!")
|
||||
|
||||
messaging.set_keepalive(transport.get_extra_info('socket'))
|
||||
self._server_connection = protocol
|
||||
|
||||
async def _broadcast_listen(self, listen_timeout=None):
|
||||
logging.info("Broadcast listener started")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
try:
|
||||
transport, protocol = await loop.create_datagram_endpoint(
|
||||
lambda: messaging.BroadcastProtocol(self._on_broadcast_bind),
|
||||
local_addr=('', self.config.broadcast_port),
|
||||
family=socket.AF_INET)
|
||||
except OSError as e:
|
||||
logging.info(f"Broadcast listener exited: port is busy: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(protocol.closed, timeout=listen_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
logging.warning("Broadcast listener timed out")
|
||||
finally:
|
||||
transport.close()
|
||||
await protocol.closed
|
||||
logging.info("Broadcast listener stopped")
|
||||
|
||||
def _on_broadcast_bind(self, message: messaging.MessageManager):
|
||||
"""
|
||||
Method called on binding to the server by broadcast. Override that method in order to add functionality.
|
||||
"""
|
||||
pass
|
||||
kwargs = message.content["kwargs"]
|
||||
self.config.set("SERVER", "port", int(kwargs["port"]))
|
||||
self.config.set("SERVER", "host", kwargs["host"])
|
||||
self.config.write()
|
||||
|
||||
def _process_connections(self):
|
||||
while True:
|
||||
events = self.selector.select(timeout=1)
|
||||
for key, mask in events:
|
||||
connection = key.data
|
||||
if connection is not None:
|
||||
try:
|
||||
connection.process_events(mask)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
"Exception {} occurred for {}! Resetting connection!".format(error, connection.addr)
|
||||
)
|
||||
self.server_connection._close()
|
||||
self.connected = False
|
||||
|
||||
if isinstance(error, OSError):
|
||||
if error.errno == errno.EINTR:
|
||||
raise KeyboardInterrupt
|
||||
try:
|
||||
mapping_fds = self.selector.get_map().keys() # file descriptors
|
||||
notifier_fd = messaging.NotifierSock().get_sock().fileno()
|
||||
except (KeyError, RuntimeError) as e:
|
||||
logger.error("Exception {} occurred when getting connections map!".format(e))
|
||||
logger.error("Connections changed during getting connections map, passing")
|
||||
else:
|
||||
notify_only= len(mapping_fds) == 1 and notifier_fd in mapping_fds
|
||||
if notify_only or not mapping_fds:
|
||||
logger.warning("No active connections left!")
|
||||
return
|
||||
logger.info(f"Got new server IP: {self.config.server_host}:{self.config.server_port}")
|
||||
|
||||
def register_callbacks(self):
|
||||
@self.callbacks.action_callback("config")
|
||||
@@ -254,50 +229,48 @@ class Client(object):
|
||||
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
|
||||
self.config.load_from_dict(kwargs["config"],
|
||||
configspec=self.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)
|
||||
self.config.merge(new_config, validate=True)
|
||||
|
||||
active_client.config.write()
|
||||
self.config.write()
|
||||
logger.info("Config successfully updated from command")
|
||||
active_client.load_config()
|
||||
self.load_config()
|
||||
|
||||
@self.callbacks.request_callback("config")
|
||||
def _response_config(*args, **kwargs):
|
||||
send_configspec = kwargs.get("send_configspec", False)
|
||||
response = {"config": active_client.config.full_dict()}
|
||||
response = {"config": self.config.full_dict()}
|
||||
if send_configspec:
|
||||
response.update({"configspec": dict(active_client.config.config.configspec)})
|
||||
response.update({"configspec": dict(self.config.config.configspec)})
|
||||
return response
|
||||
|
||||
@self.callbacks.request_callback("clover_dir")
|
||||
def _response_clover_dir(*args, **kwargs):
|
||||
return active_client.config.clover_dir
|
||||
return self.config.clover_dir
|
||||
|
||||
@self.callbacks.request_callback("id")
|
||||
def _response_id(*args, **kwargs):
|
||||
new_id = kwargs.get("new_id", None)
|
||||
if new_id is not None:
|
||||
active_client.config.set("PRIVATE", "id", new_id, True)
|
||||
active_client.load_config()
|
||||
self.config.set("PRIVATE", "id", new_id, True)
|
||||
self.load_config()
|
||||
# TODO renaming here
|
||||
|
||||
return active_client.client_id
|
||||
return self.client_id
|
||||
|
||||
@self.callbacks.request_callback("time")
|
||||
def _response_time(*args, **kwargs):
|
||||
return active_client.time_now()
|
||||
return self.time_now()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
startup_cwd = os.getcwd()
|
||||
|
||||
import threading
|
||||
|
||||
print(Client.get_ntp_time("ntp1.stratum2.ru", 123))
|
||||
|
||||
# print(Client.get_ntp_time("ntp1.stratum2.ru", 123))
|
||||
|
||||
def restart(): # move to core
|
||||
args = sys.argv[:]
|
||||
@@ -308,17 +281,23 @@ if __name__ == "__main__":
|
||||
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')])
|
||||
# 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()
|
||||
|
||||
# tr = threading.Thread(target=mock_telem)
|
||||
# tr.start()
|
||||
client.serve_forever()
|
||||
#asyncio.run(client.run())
|
||||
|
||||
Reference in New Issue
Block a user