robosim.network

Classes and functions related to networking and internal communication. Import level: 2

 1"""
 2Classes and functions related to networking and internal communication.
 3Import level: 2
 4"""
 5
 6from .msgpack_utils import send_packet, recv_packet
 7from .channel import ObjToSimChannel
 8from .connection_manager import ConnectionManager
 9
10__all__ = ["send_packet", "recv_packet",
11           "ObjToSimChannel",
12           "ConnectionManager"]
def send_packet( sock: socket.socket, data: dict, close_connection: bool = False, add_timestmap: bool = True):
14def send_packet(sock: socket.socket, data: dict, close_connection: bool=False, add_timestmap: bool=True):
15    """pack the message in messagepack format + length-prefixed for TCP stream then send it through the socket"""
16    assert isinstance(data, dict), (data, type(data))
17    if add_timestmap:
18        assert "timestamp" not in data.keys(), data
19        data["timestamp"] = datetime.now().isoformat()
20    try:
21        packed_data = msgpack.packb(data)
22    except TypeError as e:
23        _find_bad(data)
24        raise e
25    packet = len(packed_data).to_bytes(4, "big") + packed_data # <SIZE-4b><packed_data>
26    logger.trace(f"Sending {len(packet)} bytes through the wire")
27    sock.sendall(packet)
28
29    if close_connection:
30        sock.close()

pack the message in messagepack format + length-prefixed for TCP stream then send it through the socket

def recv_packet(sock: socket.socket) -> dict:
32def recv_packet(sock: socket.socket) -> dict:
33    """reads a packet from the server. First 4 bytes for len, then the rest of the message"""
34    packet_len = int.from_bytes(_recvall(sock, 4), "big")
35    packet: dict = msgpack.unpackb(_recvall(sock, packet_len), raw=False)
36    assert isinstance(packet, dict), f"packet is not dictionary: {type(packet)}"
37    return packet

reads a packet from the server. First 4 bytes for len, then the rest of the message

@dataclass
class ObjToSimChannel:
 8@dataclass
 9class ObjToSimChannel:
10    """Channel of two queues handling the communication between the main thread (sim) and the tcp handler of a robot"""
11    index: int # the global index of the channel. Should be unique across the simulator.
12    tcp2sim: Queue = field(default_factory=Queue) # user -> simulator. User can send many messages theoretically.
13    sim2tcp: Queue = field(default_factory=lambda: Queue(maxsize=1)) # sim -> user. One message at a time.
14    lock: threading.Lock = field(default_factory=threading.Lock)
15    client: socket.socket | None = None
16
17    def clear(self):
18        """clears the queues of this channel"""
19        with self.lock:
20            self.tcp2sim.queue.clear()
21            self.sim2tcp.queue.clear()

Channel of two queues handling the communication between the main thread (sim) and the tcp handler of a robot

ObjToSimChannel( index: int, tcp2sim: queue.Queue = <factory>, sim2tcp: queue.Queue = <factory>, lock: <built-in function allocate_lock> = <factory>, client: socket.socket | None = None)
index: int
tcp2sim: queue.Queue
sim2tcp: queue.Queue
lock: <built-in function allocate_lock>
client: socket.socket | None = None
def clear(self):
17    def clear(self):
18        """clears the queues of this channel"""
19        with self.lock:
20            self.tcp2sim.queue.clear()
21            self.sim2tcp.queue.clear()

clears the queues of this channel

class ConnectionManager:
 17class ConnectionManager:
 18    """connection class for managing connections and callbacks to the app. App defines the rest of the protocol"""
 19    def __init__(self, host: str, port: int, n_channels: int, connect_command: str):
 20        self.host = host
 21        self.port = port
 22        self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
 23        self.connect_command = connect_command
 24
 25        self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
 26        self.server.bind((host, port))
 27        self.server.listen(1)
 28        self.server.settimeout(SOCKET_TIMEOUT_S)
 29        atexit.register(lambda: self.server.close())
 30
 31        self.channels = [ObjToSimChannel(index=i) for i in range (n_channels)] # used to communicate with robots.
 32        self.global_channel = ObjToSimChannel(index=-1) # used to communicate with the simulator, not with a robot.
 33        self.tcp_thread: threading.Thread | None = None # created in start()
 34
 35    def start(self, network_handler: NetworkHandlerType):
 36        """Starts the main thread that listens to new connections"""
 37        args = (self.server, self.channels, network_handler, self.global_channel)
 38        self.tcp_thread = threading.Thread(target=self._tcp_listener, args=args, daemon=True)
 39        self.tcp_thread.start()
 40        logger.info(f"Started TCP server on port {self.port}")
 41
 42    def _tcp_listener(self, server: socket.socket, channels: list[ObjToSimChannel], network_handler: NetworkHandlerType,
 43                      global_channel: ObjToSimChannel):
 44        """the thread that listens to the standard input for commands"""
 45        while True: # server runs forever waiting for new connection
 46            try:
 47                client, addr = server.accept()
 48                logger.info(f"Connected: '{addr[0]}:{addr[1]}'")
 49                args = (client, addr, channels, network_handler, global_channel)
 50                threading.Thread(target=self._handle_client, args=args, daemon=True).start()
 51            except socket.timeout:
 52                pass
 53            except Exception as e:
 54                logger.error(f"Error: {e}\n{''.join(traceback.format_exception(e))}")
 55
 56    def _get_first_free_channel(self, client: socket.socket, channels: list[ObjToSimChannel]) -> int:
 57        # find first free channel to assign this client the robot
 58        for i, channel in enumerate(channels):
 59            with channel.lock:
 60                if channel.client is None:
 61                    channel.client = client
 62                    return i
 63        return -1
 64
 65    def _handle_stateless_client(self, client: socket.socket, addr: tuple[str, int],
 66                                 network_handler: NetworkHandlerType,
 67                                 global_channel: ObjToSimChannel) -> bool:
 68        """We keep the TCP connection on, so the same socket can be re-used, but they are not supposed to hold any app
 69           level state. Returns true if the client needs to be closed, false if we continue (connected)"""
 70        try:
 71            while True:
 72                msg = recv_packet(client)
 73                if "cmd" not in msg:
 74                    send_packet(client, {"error": "'cmd' not in message"}, close_connection=True)
 75                    return True
 76
 77                if msg["cmd"] == self.connect_command:
 78                    # Note: this message will be replied to in _handle_client
 79                    return False
 80
 81                res = network_handler(msg, global_channel, connected_channel=None)
 82                # network_handler returned none -> message was not handled (needed connection) -> close
 83                if res is None:
 84                    return True
 85                else:
 86                    send_packet(client, res)
 87        except ConnectionError:
 88            logger.info(f"Disconnected: '{addr[0]}:{addr[1]}'")
 89        except Exception as e:
 90            logger.error(f"Error: {e}\n{''.join(traceback.format_exception(e))}")
 91        return True
 92
 93    def _handle_client(self, client: socket.socket, addr: tuple, channels: list[ObjToSimChannel],
 94                       network_handler: NetworkHandlerType, global_channel: ObjToSimChannel):
 95        # A client is handled in two steps: either it's a stateles connection or a robot connection. In the second case,
 96        # the client asks for robot control ('connect') in which we first check if the robot is already controlled
 97        # or keep it long term. Further communication with the client follows further allows stateless messages too.
 98        should_close = self._handle_stateless_client(client, addr, network_handler, global_channel)
 99        if should_close:
100            send_packet(client, {"error": "Must connect first"}, close_connection=True)
101            return
102
103        if (channel_ix := self._get_first_free_channel(client, channels)) == -1:
104            send_packet(client, {"error": f"All {len(channels)} robots are already controlled"},
105                        close_connection=True)
106            return
107
108        # if we reach here, the user is connected and we keep this thread alive
109        send_packet(client, {"status": "connected", "id": channel_ix})
110        channel = channels[channel_ix]
111
112        try:
113            while True:
114                msg = recv_packet(client)
115                logger.log_every_s(f"Received: {msg}", "DEBUG", log_to_next_level=True)
116                # two-tier message handling: fast handler -> simulator queue via the channel (slowest)
117                # we past the message to the network handler which will route it properly (fast, global or connected)
118                if "cmd" not in msg:
119                    res = {"error": "'cmd' not in message"}
120                elif msg["cmd"] == self.connect_command:
121                    res = {"error": "robot already controlled"}
122                else:
123                    res = network_handler(msg, global_channel, connected_channel=channel)
124                send_packet(client, res)
125        except Empty as e:
126            logger.error(f"Timeout waiting for simulator response: {e}\n{''.join(traceback.format_exception(e))}")
127            raise e
128        except ConnectionError:
129            pass
130        except Exception as e:
131            logger.error(f"Error: {e}\n{''.join(traceback.format_exception(e))}")
132            if "ndarray" in str(e):
133                logger.error(f"input: {res}")
134        finally:
135            logger.info(f"Disconnected: '{addr[0]}:{addr[1]}'")
136            with channel.lock:
137                channel.client = None
138            channel.clear()
139
140    def __repr__(self):
141        return f"[Connection Manager]\n- Host: {self.host}:{self.port}\n- Channels: {len(self. channels)}"

connection class for managing connections and callbacks to the app. App defines the rest of the protocol

ConnectionManager(host: str, port: int, n_channels: int, connect_command: str)
19    def __init__(self, host: str, port: int, n_channels: int, connect_command: str):
20        self.host = host
21        self.port = port
22        self.server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
23        self.connect_command = connect_command
24
25        self.server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
26        self.server.bind((host, port))
27        self.server.listen(1)
28        self.server.settimeout(SOCKET_TIMEOUT_S)
29        atexit.register(lambda: self.server.close())
30
31        self.channels = [ObjToSimChannel(index=i) for i in range (n_channels)] # used to communicate with robots.
32        self.global_channel = ObjToSimChannel(index=-1) # used to communicate with the simulator, not with a robot.
33        self.tcp_thread: threading.Thread | None = None # created in start()
host
port
server
connect_command
channels
global_channel
tcp_thread: threading.Thread | None
def start( self, network_handler: Callable[[dict, ObjToSimChannel, ObjToSimChannel | None], dict | None]):
35    def start(self, network_handler: NetworkHandlerType):
36        """Starts the main thread that listens to new connections"""
37        args = (self.server, self.channels, network_handler, self.global_channel)
38        self.tcp_thread = threading.Thread(target=self._tcp_listener, args=args, daemon=True)
39        self.tcp_thread.start()
40        logger.info(f"Started TCP server on port {self.port}")

Starts the main thread that listens to new connections