robosim.protocol
protocol.py - implements all the robosim-related protocol handlers. New commands of protocol.json go here
1"""protocol.py - implements all the robosim-related protocol handlers. New commands of protocol.json go here""" 2import traceback 3import json 4import sys 5from queue import Empty 6from datetime import datetime 7 8import numpy as np 9import raylib as rl 10from micronetcode import Message, Channel, SourceType 11 12from robolib.components import HasFPV 13from robolib.constants import SOCKET_TIMEOUT_S, HZ, RESOURCES_PATH 14from robolib.utils import FPVData, logger, get_project_root 15from robolib.serialization import add_entity, add_component 16from robolib.netcode import RobosimClientState, RobosimClient 17from robolib.plugin import Response, ResponseKind 18from plugins.trajectory_mission_plugin import TrajectoryMissionPlugin 19 20sys.path.append(str(get_project_root() / "src/robosim/")) 21from simulator_singleton import Simulator # pylint: disable=wrong-import-order, import-error, wrong-import-position 22 23SCRIPT_CLIENT = RobosimClient(socket=None, channel=Channel(idx=-69, channel_queue_maxsize=1)) 24KEYBOARD_CLIENT = RobosimClient(socket=None, channel=Channel(idx=-67, channel_queue_maxsize=1)) 25 26# Public API 27 28def network_handler(msg: Message, sim: Simulator) -> dict: 29 """ 30 [TCP client thread]. All the client messages arrive here. 31 The FPV command is 'fast handled' while all the others are pushed to the simulator (global/plugins). 32 Note: stop making admin/global channels. We don't need it! Each connection has its own channel of communication. 33 """ 34 timestamp_recv = datetime.now().isoformat() 35 if not isinstance(msg.data, dict) or (cmd := msg.data.get("cmd")) is None: 36 resp = {"error": f"'cmd' not in message: {msg.data}"} 37 # Special command: streaming FPV camera is responded straight from the client thread asap 38 elif cmd == "robot_get_state_with_camera": 39 resp = _get_state_with_camera(msg, sim) 40 # If it's any other message, then the io_handler reads it below via get_one_message(). 41 # It can be a sim or a plugin message, both are handled the same. 42 else: 43 raw_resp: Response = msg.client.channel.put_then_get(msg, timeout=SOCKET_TIMEOUT_S) 44 resp = raw_resp.payload 45 resp["timestamp_recv"] = timestamp_recv 46 resp["timestamp_resp"] = datetime.now().isoformat() 47 return resp 48 49def io_handler(sim: Simulator, extra_messages: list[Message], init_config: dict) -> list[tuple[Message, Response]]: 50 """ 51 [main thread] Polls the messages (global or plugin) of all the clients which were pushed from fast handler. 52 Additional messages (extra_messages) can be passed from the caller. This is used by (tick-aware) scripting messages 53 """ 54 if len(extra_messages) > 0: 55 logger.debug(f"Handling {len(extra_messages)} extra script messages (t={sim.clock.tick_count})") 56 keyboard_messages = _keyboard_handler(sim) 57 messages_to_process: list[Message] = [*extra_messages, *keyboard_messages] 58 messages_for_plugins: list[Message] = [] 59 responses: list[tuple[Message, Response]] = [] 60 61 while True: 62 try: 63 messages_to_process.append(sim.connection_manager.get_one_message()) 64 except Empty: 65 break 66 67 for msg in messages_to_process: 68 cmd = msg.data["cmd"] # Existence of 'cmd' was validated in network_handler 69 70 # If it is a message for a plugin, then put it in this list and which is used by the plugins manager at the end 71 if cmd in sim.plugins_manager.all_endpoints: 72 messages_for_plugins.append(msg) 73 else: # core messages are responded asap 74 resp = _handle_message(sim, msg, init_config=init_config) 75 kind = ResponseKind.Ok if "error" not in resp else ResponseKind.Error 76 responses.append((msg, Response(kind, payload=resp))) 77 78 # Call the plugins at the end. Note that this is needed even for zero messages as we call on_tick() over there. 79 plugin_responses = sim.plugins_manager.io_handler(world=sim.world, messages_for_plugins=messages_for_plugins) 80 return responses + plugin_responses # these are passed by the main loop in responses_handler 81 82def responses_handler(sim: Simulator, message_responses: list[tuple[Message, Response]], script_responses: list): 83 """[main thread] Handler that ensures that all the messages of this tick are responded to.""" 84 plugin_responses = sim.plugins_manager.responses_handler(world=sim.world) 85 for msg, response in message_responses + plugin_responses: 86 assert isinstance(response, Response), f"\n{msg}\n{response}" 87 if msg.client is SCRIPT_CLIENT: 88 script_responses.append(response.payload) 89 elif msg.client is KEYBOARD_CLIENT: 90 if "error" in response.payload: 91 logger.error(response.payload["error"]) 92 else: 93 if not (msg.data["cmd"] == "entity_set_data" and msg.data.get("component", "") == "HasMotion"): 94 logger.debug(response.payload["status"]) 95 else: 96 msg.client.channel.main2tcp.put(response) 97 98# Private functions below 99 100def _get_state_with_camera(msg: Message, sim: Simulator) -> dict: 101 if (err := sim.protocol.validate_endpoint(endpoint=msg.data["cmd"], data=msg.data)) is not None: 102 return {"error": err.error} 103 104 robot_eid = sim.robot_eids[msg.data["robot_ix"]] 105 if robot_eid not in sim.robot_eid_to_channel_ix: 106 return {"error": f"Robot '{msg.client.channel.idx}' is not connected. Cannot get FPV data"} 107 108 robot_entity = sim.world.get_entity(robot_eid) 109 robot_state = robot_entity.to_dict(serialization_field="serializable") 110 robot_state["connected"] = True 111 112 fpv_data: FPVData = robot_entity.fpv_data.item() 113 with fpv_data.lock: 114 res = {"robot": robot_state, "fpv_compressed": (fc := fpv_data.frame_compressed), 115 "fpv_shape": (fs := fpv_data.frame_shape), "fpv_frame_id": fpv_data.frame_id} 116 logger.log_every_s(f"FPV: {len(fc)} bytes (cr {len(fc)/(np.prod(fs)*3/4)*100:.2f}%)", "DEBUG", True) # RGB 117 return res 118 119def _handle_load_state(sim: Simulator, new_state: dict) -> dict: 120 try: 121 for plugin in sim.plugins_manager.plugins: 122 # TODO(181): leaky abstraction, we need to make sure that some plugins are "unstoppable". 123 if isinstance(plugin, TrajectoryMissionPlugin) and plugin.state.phase == "running": 124 return {"error": "cannot reset while mission is running"} 125 sim.load_state_dict(new_state) 126 return {"status": "Loaded state"} 127 except Exception as e: 128 return {"error": f"Error: {e}\n{''.join(traceback.format_exception(e))}"} 129 130def _keyboard_handler(sim: Simulator) -> list[Message]: 131 """ 132 Handle keyboard input for the main UI. We create a message in the same protocol of robosim-ncat/global hanlder 133 Note: network handler is in protocol.py definit the entire protocol handlers of protocol.json. 134 """ 135 res: list[dict] = [] 136 137 if rl.IsKeyPressed(rl.KEY_ESCAPE): 138 res += [{"cmd": "sim_exit"}] 139 140 # camera management 141 if rl.IsKeyPressed(rl.KEY_F1): 142 res += [{"cmd": "sim_set_camera", "type": "world", "id": 0}] # dummy ids, needed for protocol 143 if rl.IsKeyPressed(rl.KEY_F2): 144 res += [{"cmd": "sim_set_camera", "type": "topdown", "id": 0}] # dummy ids, needed for protocol 145 if rl.IsKeyPressed(rl.KEY_F3): # we still keep the 'fpv' camera here but cannot control it. 146 if sim.state.active_camera_type == "fpv": 147 sim.state.fpv_ix = (sim.state.fpv_ix + 1) % len(sim.world.query(HasFPV)) 148 res += [{"cmd": "sim_set_camera", "type": "fpv", "id": sim.state.fpv_ix}] 149 150 # sim state management 151 if rl.IsKeyPressed(rl.KEY_F5): # save the state of the scene on the server side 152 res += [{"cmd": "sim_save_state"}] 153 if rl.IsKeyPressed(rl.KEY_F6): # load the state of the scene on the server side 154 try: 155 with open(RESOURCES_PATH/"state.json", "r") as fp: 156 state = json.load(fp) 157 res += [{"cmd": "sim_load_state", "state": state}] 158 except Exception as e: 159 logger.error(str(e)) 160 if rl.IsKeyPressed(rl.KEY_R): 161 res += [{"cmd": "sim_reset"}] 162 163 # render management 164 if rl.IsKeyPressed(rl.KEY_I): 165 res += [{"cmd": "sim_set_wireframe_mode", "value": not sim.state.wireframe_mode}] 166 if rl.IsKeyPressed(rl.KEY_O): 167 res += [{"cmd": "sim_set_collision_render_mode", "value": not sim.state.collision_render_mode}] 168 169 # move robot via keyboard 170 if sim.state.active_camera_type == "fpv": 171 entity_id = sim.robot_eids[sim.state.fpv_ix] 172 control_input = np.float32([ 173 rl.IsKeyDown(rl.KEY_A) - rl.IsKeyDown(rl.KEY_D), # left/right (x) 174 rl.IsKeyDown(rl.KEY_PAGE_UP) - rl.IsKeyDown(rl.KEY_PAGE_DOWN), # up/down (y) 175 rl.IsKeyDown(rl.KEY_W) - rl.IsKeyDown(rl.KEY_S), # forward/backward (z) 176 rl.IsKeyDown(rl.KEY_DOWN) - rl.IsKeyDown(rl.KEY_UP), # pitch 177 rl.IsKeyDown(rl.KEY_Q) - rl.IsKeyDown(rl.KEY_E), # yaw 178 rl.IsKeyDown(rl.KEY_RIGHT) - rl.IsKeyDown(rl.KEY_LEFT), # roll 179 ]) 180 if (control_input != 0).any(): 181 res += [{"cmd": "entity_set_data", "entity_id": entity_id, "component": "HasMotion", 182 "data": {"motion_input": control_input}}] 183 184 # misc / other commands 185 if rl.IsKeyPressed(rl.KEY_F9): 186 res += [{"cmd": "sim_set_collision_cell_size", "value": np.maximum(sim.collision_cell_size - 0.1, 0.1)}] 187 if rl.IsKeyPressed(rl.KEY_F10): 188 res += [{"cmd": "sim_set_collision_cell_size", "value": np.minimum(sim.collision_cell_size + 0.1, 30)}] 189 if rl.IsKeyPressed(rl.KEY_T): 190 res += [{"cmd": "sim_display_uav_trace", "value": not sim.state.display_uav_trace}] 191 192 return [Message(source_type=SourceType.SCRIPT, data=item, client=KEYBOARD_CLIENT) for item in res] 193 194def _handle_message(sim: Simulator, msg: Message, init_config: dict) -> dict: 195 """handles an admin message (either global or keyboard) that needs to run on the main thread""" 196 client: RobosimClient = msg.client 197 data = msg.data 198 cmd = data["cmd"] 199 200 try: 201 if (err := sim.protocol.validate_endpoint(endpoint=cmd, data=msg.data)) is not None: 202 return {"error": err.error} 203 except KeyError: 204 return {"error": f"Unknown command: '{cmd}'. Supported commands: {sorted(sim.all_commands)}"} 205 206 if cmd == "help": 207 return {"supported_commands": sorted(sim.all_commands)} 208 209 if cmd == "connect": 210 if client.state == RobosimClientState.STAGED: 211 robot_ix = sim.assign_channel_to_first_free_robot(client.channel.idx) 212 if robot_ix is None: 213 return {"error": "no robot is free, try again later"} 214 215 client.robot_eid = sim.robot_eids[robot_ix] 216 client.state = RobosimClientState.CONNECTED 217 return {"status": "connected", "robot_ix": robot_ix, "eid": client.robot_eid} 218 else: # CONNECTED 219 return {"error": "already connected"} 220 221 if cmd == "disconnect": 222 if client.state == RobosimClientState.CONNECTED: 223 sim.connection_manager.on_disconnect(client) 224 return {"status": "disconnected"} 225 else: 226 return {"error": "client not connected"} 227 228 if cmd == "robot_get_state": 229 robot_ix = data["robot_ix"] 230 robot_eid = sim.robot_eids[robot_ix] 231 robot_entity = sim.world.get_entity(robot_eid) 232 robot_state = robot_entity.to_dict(serialization_field="serializable") 233 robot_state["connected"] = robot_eid in sim.robot_eid_to_channel_ix # released at 'on_disconnect' time 234 return {"robot": robot_state} 235 236 if cmd == "robot_get_state_with_camera": # this is normally responded via (fast) network_handler (except in tests) 237 return _get_state_with_camera(msg, sim) 238 239 if cmd == "sim_get_info": 240 return {"control_loop_rate_hz": HZ, 241 "physics_tick_s": {"window": len(pts := sim.physics_ticks_stats), "p50": np.median(pts).item()}, 242 "render_tick_s": {"window": len(rts := sim.render_ticks_stats), "p50": np.median(rts).item()},} 243 244 if cmd == "sim_get_state": 245 return sim.to_dict() 246 247 if cmd == "sim_set_camera": 248 if data["type"] == "fpv": 249 sim.state.fpv_ix = data["id"] 250 sim.state.active_camera_type = data["type"] 251 return {"status": f"changed camera to '{sim.state.active_camera_type}'"} 252 253 elif cmd == "map_load": 254 raise NotImplementedError 255 256 elif cmd == "sim_exit": 257 sim.state.must_exit = True 258 return {"status": "Exitting"} 259 260 # sim state management 261 262 elif cmd == "sim_reset": 263 # TODO: validate loaded state 264 return _handle_load_state(sim, new_state=init_config) 265 266 elif cmd == "sim_load_state": 267 # TODO: validate loaded state 268 return _handle_load_state(sim, new_state=data["state"]) 269 270 elif cmd == "sim_save_state": 271 try: 272 with open(RESOURCES_PATH/"state.json", "w") as fp: 273 json.dump(sim.to_dict(), fp, indent=4) 274 except Exception as e: 275 return {"error": str(e)} 276 return {"status": "Stored the state of the simulator to 'resources/state.json'"} 277 278 # render management 279 280 elif cmd == "sim_set_wireframe_mode": 281 sim.state.wireframe_mode = data["value"] 282 return {"status": f"Set wireframe mode to: {sim.state.wireframe_mode}"} 283 284 elif cmd == "sim_set_collision_render_mode": 285 sim.state.collision_render_mode = data["value"] 286 return {"status": f"Set collision render mode to: {sim.state.collision_render_mode}"} 287 288 # entity control management (e.g. move entities or in the future add/remove components or set values) 289 290 elif cmd == "entity_spawn": 291 try: 292 components = [sim.world.component_name_to_type[c] for c in data["components"]] 293 eid = add_entity(sim.world, components, **data["data"]) 294 sim.world.update() 295 return {"status": "Added a new entity to the world", "entity_id": eid} 296 except Exception as e: 297 return {"error": str(e)} 298 299 elif cmd == "entity_destroy": 300 try: 301 sim.world.remove_entity(entity_id := data["entity_id"]) 302 sim.world.update() 303 return {"status": f"Entity {entity_id} destroyed"} 304 except Exception as e: 305 return {"error": str(e)} 306 307 elif cmd == "entity_add_component": 308 try: 309 entity = sim.world.get_entity(entity_id := data["entity_id"]) 310 comp = sim.world.component_name_to_type[data["component"]] 311 add_component(entity, component=comp, **data["data"]) 312 sim.world.update() 313 return {"status": f"Added component {data['component']} to entity {entity_id}"} 314 except Exception as e: 315 return {"error": str(e)} 316 317 elif cmd == "entity_remove_component": 318 try: 319 entity = sim.world.get_entity(entity_id := data["entity_id"]) 320 component = sim.world.component_name_to_type[data["component"]] 321 entity.remove_component(component) 322 sim.world.update() 323 return {"status": f"Component {data['component']} removed from entity {entity_id}"} 324 except Exception as e: 325 return {"error": str(e)} 326 327 elif cmd == "entity_set_data": 328 try: 329 entity = sim.world.get_entity(entity_id := data["entity_id"]) 330 entity.set_data(**data["data"]) 331 sim.world.update() 332 return {"status": f"Entity id: {entity_id}. Updated data of following fields: {list(data['data'])}"} 333 except Exception as e: 334 return {"error": str(e)} 335 336 # misc / other commands 337 338 elif cmd == "sim_set_collision_cell_size": 339 sim.collision_cell_size = data["value"] 340 return {"status": f"Set collision cell size to: {data['value']}"} 341 342 elif cmd == "sim_display_uav_trace": 343 sim.state.uav_traces.clear() 344 sim.state.display_uav_trace = data["value"] 345 return {"status": f"Display UAV trace set to to: {data['value']}"} 346 347 raise ValueError(f"Shouldn't reach here: {msg}")
SCRIPT_CLIENT =
RobosimClient(channel=[Channel] Id: -69. Q maxsize: 1 / 1, socket=None, state='staged', robot_eid=None)
KEYBOARD_CLIENT =
RobosimClient(channel=[Channel] Id: -67. Q maxsize: 1 / 1, socket=None, state='staged', robot_eid=None)
def
network_handler( msg: micronetcode.message.Message, sim: simulator_singleton.Simulator) -> dict:
29def network_handler(msg: Message, sim: Simulator) -> dict: 30 """ 31 [TCP client thread]. All the client messages arrive here. 32 The FPV command is 'fast handled' while all the others are pushed to the simulator (global/plugins). 33 Note: stop making admin/global channels. We don't need it! Each connection has its own channel of communication. 34 """ 35 timestamp_recv = datetime.now().isoformat() 36 if not isinstance(msg.data, dict) or (cmd := msg.data.get("cmd")) is None: 37 resp = {"error": f"'cmd' not in message: {msg.data}"} 38 # Special command: streaming FPV camera is responded straight from the client thread asap 39 elif cmd == "robot_get_state_with_camera": 40 resp = _get_state_with_camera(msg, sim) 41 # If it's any other message, then the io_handler reads it below via get_one_message(). 42 # It can be a sim or a plugin message, both are handled the same. 43 else: 44 raw_resp: Response = msg.client.channel.put_then_get(msg, timeout=SOCKET_TIMEOUT_S) 45 resp = raw_resp.payload 46 resp["timestamp_recv"] = timestamp_recv 47 resp["timestamp_resp"] = datetime.now().isoformat() 48 return resp
[TCP client thread]. All the client messages arrive here. The FPV command is 'fast handled' while all the others are pushed to the simulator (global/plugins). Note: stop making admin/global channels. We don't need it! Each connection has its own channel of communication.
def
io_handler( sim: simulator_singleton.Simulator, extra_messages: list[micronetcode.message.Message], init_config: dict) -> list[tuple[micronetcode.message.Message, robolib.plugin.Response]]:
50def io_handler(sim: Simulator, extra_messages: list[Message], init_config: dict) -> list[tuple[Message, Response]]: 51 """ 52 [main thread] Polls the messages (global or plugin) of all the clients which were pushed from fast handler. 53 Additional messages (extra_messages) can be passed from the caller. This is used by (tick-aware) scripting messages 54 """ 55 if len(extra_messages) > 0: 56 logger.debug(f"Handling {len(extra_messages)} extra script messages (t={sim.clock.tick_count})") 57 keyboard_messages = _keyboard_handler(sim) 58 messages_to_process: list[Message] = [*extra_messages, *keyboard_messages] 59 messages_for_plugins: list[Message] = [] 60 responses: list[tuple[Message, Response]] = [] 61 62 while True: 63 try: 64 messages_to_process.append(sim.connection_manager.get_one_message()) 65 except Empty: 66 break 67 68 for msg in messages_to_process: 69 cmd = msg.data["cmd"] # Existence of 'cmd' was validated in network_handler 70 71 # If it is a message for a plugin, then put it in this list and which is used by the plugins manager at the end 72 if cmd in sim.plugins_manager.all_endpoints: 73 messages_for_plugins.append(msg) 74 else: # core messages are responded asap 75 resp = _handle_message(sim, msg, init_config=init_config) 76 kind = ResponseKind.Ok if "error" not in resp else ResponseKind.Error 77 responses.append((msg, Response(kind, payload=resp))) 78 79 # Call the plugins at the end. Note that this is needed even for zero messages as we call on_tick() over there. 80 plugin_responses = sim.plugins_manager.io_handler(world=sim.world, messages_for_plugins=messages_for_plugins) 81 return responses + plugin_responses # these are passed by the main loop in responses_handler
[main thread] Polls the messages (global or plugin) of all the clients which were pushed from fast handler. Additional messages (extra_messages) can be passed from the caller. This is used by (tick-aware) scripting messages
def
responses_handler( sim: simulator_singleton.Simulator, message_responses: list[tuple[micronetcode.message.Message, robolib.plugin.Response]], script_responses: list):
83def responses_handler(sim: Simulator, message_responses: list[tuple[Message, Response]], script_responses: list): 84 """[main thread] Handler that ensures that all the messages of this tick are responded to.""" 85 plugin_responses = sim.plugins_manager.responses_handler(world=sim.world) 86 for msg, response in message_responses + plugin_responses: 87 assert isinstance(response, Response), f"\n{msg}\n{response}" 88 if msg.client is SCRIPT_CLIENT: 89 script_responses.append(response.payload) 90 elif msg.client is KEYBOARD_CLIENT: 91 if "error" in response.payload: 92 logger.error(response.payload["error"]) 93 else: 94 if not (msg.data["cmd"] == "entity_set_data" and msg.data.get("component", "") == "HasMotion"): 95 logger.debug(response.payload["status"]) 96 else: 97 msg.client.channel.main2tcp.put(response)
[main thread] Handler that ensures that all the messages of this tick are responded to.