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
  9from micronetcode import Message, SourceType
 10
 11from robolib.constants import SOCKET_TIMEOUT_S, HZ
 12from robolib.utils import FPVData, logger, ecs_data_as_np, get_project_root
 13from robolib.entities import make_nonserializable_data, add_entity
 14from robolib.netcode import RobosimClientState, RobosimClient
 15from plugins.trajectory_mission_plugin import TrajectoryMissionPlugin
 16
 17sys.path.append(str(get_project_root() / "src/robosim/"))
 18from simulator_singleton import Simulator # pylint: disable=wrong-import-order, import-error, wrong-import-position
 19
 20RESOURCES_PATH = get_project_root() / "resources"
 21
 22def _fast_handler(msg: Message, sim: Simulator) -> dict:
 23    client: RobosimClient = msg.client
 24    if not isinstance(msg.data, dict) or (cmd := msg.data.get("cmd")) is None:
 25        return {"error": f"'cmd' not in message: {msg.data}"}
 26
 27    # Special command: streaming FPV camera is responded straight from the client thread asap
 28    if cmd == "robot_get_state_with_camera":
 29        if (err := sim.protocol.validate_endpoint(endpoint=cmd, data=msg.data)) is not None:
 30            return {"error": err.error}
 31
 32        robot_eid = sim.robot_eids[msg.data["robot_ix"]]
 33        if robot_eid not in sim.robot_eid_to_channel_ix:
 34            return {"error": f"Robot '{client.channel.idx}' is not connected. Cannot get FPV data"}
 35
 36        robot_entity = sim.world.get_entity(robot_eid)
 37        robot_state = robot_entity.to_dict(serialization_field="serializable")
 38        robot_state["connected"] = True
 39
 40        fpv_data: FPVData = robot_entity.fpv_data.item()
 41        with fpv_data.lock:
 42            res = {"robot": robot_state, "fpv_compressed": (fc := fpv_data.frame_compressed),
 43                   "fpv_shape": (fs := fpv_data.frame_shape), "fpv_frame_id": fpv_data.frame_id}
 44            logger.log_every_s(f"FPV: {len(fc)} bytes (cr {len(fc)/(np.prod(fs)*3/4)*100:.2f}%)", "DEBUG", True) # RGB
 45            return res
 46
 47    # If it's any other message, then the slow handler reads it. It can be a sim or a plugin message, both are handled.
 48    return msg.client.channel.put_then_get(msg, timeout=SOCKET_TIMEOUT_S)
 49
 50def network_handler(msg: Message, sim: Simulator) -> dict:
 51    """
 52    [TCP client thread]. All the client messages arrive here.
 53    The FPV command is 'fast handled' while all the others are pushed to the simulator (global/plugins).
 54    Note: stop making admin/global channels. We don't need it! Each robot has its own channel of communication.
 55    """
 56    timestamp_recv = datetime.now().isoformat()
 57    resp = _fast_handler(msg, sim)
 58    resp["timestamp_recv"] = timestamp_recv
 59    resp["timestamp_resp"] = datetime.now().isoformat()
 60    return resp
 61
 62def _handle_load_state(sim: Simulator, new_state: dict) -> dict:
 63    try:
 64        for plugin in sim.plugins_manager.plugins:
 65            # TODO(181): leaky abstraction, we need to make sure that some plugins are "unstoppable".
 66            if isinstance(plugin, TrajectoryMissionPlugin) and plugin.state.phase == "running":
 67                return {"error": "cannot reset while mission is running"}
 68        sim.load_state_dict(new_state)
 69        return {"status": "Loaded state"}
 70    except Exception as e:
 71        return {"error": f"Error: {e}\n{''.join(traceback.format_exception(e))}"}
 72
 73def _handle_message(sim: Simulator, msg: Message, init_config: dict) -> dict:
 74    """handles an admin message (either global or keyboard) that needs to run on the main thread"""
 75    client: RobosimClient = msg.client
 76    data = msg.data
 77    cmd = data["cmd"]
 78
 79    try:
 80        if (err := sim.protocol.validate_endpoint(endpoint=cmd, data=msg.data)) is not None:
 81            return {"error": err.error}
 82    except KeyError:
 83        return {"error": f"Unknown command: '{cmd}'. Supported commands: {list(sim.all_commands)}"}
 84
 85    if cmd == "help":
 86        return {"supported_commands": list(sim.all_commands)}
 87
 88    if cmd == "connect":
 89        if client.state == RobosimClientState.STAGED:
 90            robot_ix = sim.assign_channel_to_first_free_robot(client.channel.idx)
 91            if robot_ix is None:
 92                return {"error": "no robot is free, try again later"}
 93
 94            client.robot_eid = sim.robot_eids[robot_ix]
 95            client.state = RobosimClientState.CONNECTED
 96            return {"status": "connected", "robot_ix": robot_ix, "eid": client.robot_eid}
 97        else: # CONNECTED
 98            return {"error": "already connected"}
 99
100    if cmd == "robot_get_state":
101        robot_ix = data["robot_ix"]
102        robot_eid = sim.robot_eids[robot_ix]
103        robot_entity = sim.world.get_entity(robot_eid)
104        robot_state = robot_entity.to_dict(serialization_field="serializable")
105        robot_state["connected"] = robot_eid in sim.robot_eid_to_channel_ix # released at 'on_disconnect' time
106        return {"robot": robot_state}
107
108    if cmd == "sim_get_info":
109        return {"control_loop_rate_hz": HZ,
110                "physics_tick_s": {"window": len(pts := sim.physics_ticks_stats), "p50": np.median(pts).item()},
111                "render_tick_s": {"window": len(rts := sim.render_ticks_stats), "p50": np.median(rts).item()},}
112
113    if cmd == "sim_get_state":
114        return sim.to_dict()
115
116    if cmd == "sim_set_camera":
117        if data["type"] == "fpv":
118            sim.state.fpv_ix = data["id"]
119        sim.state.active_camera_type = data["type"]
120        return {"status": f"changed camera to '{sim.state.active_camera_type}'"}
121
122    elif cmd == "map_load":
123        raise NotImplementedError
124
125    # sim state management
126
127    elif cmd == "sim_reset":
128        # TODO: validate loaded state
129        return _handle_load_state(sim, new_state=init_config)
130
131    elif cmd == "sim_load_state":
132        # TODO: validate loaded state
133        return _handle_load_state(sim, new_state=data["state"])
134
135    elif cmd == "sim_save_state":
136        try:
137            with open(RESOURCES_PATH/"state.json", "w") as fp:
138                json.dump(sim.to_dict(), fp, indent=4)
139        except Exception as e:
140            return {"error": str(e)}
141        return {"status": "Stored the state of the simulator to 'resources/state.json'"}
142
143    # render management
144
145    elif cmd == "sim_set_wireframe_mode":
146        sim.state.wireframe_mode = data["value"]
147        return {"status": f"Set wireframe mode to: {sim.state.wireframe_mode}"}
148
149    elif cmd == "sim_set_collision_render_mode":
150        sim.state.collision_render_mode = data["value"]
151        return {"status": f"Set collision render mode to: {sim.state.collision_render_mode}"}
152
153    # entity control management (e.g. move entities or in the future add/remove components or set values)
154
155    elif cmd == "entity_spawn":
156        try:
157            components = [sim.world.component_name_to_type[c] for c in data["components"]]
158            eid = add_entity(sim.world, components, **data["data"])
159            sim.world.update()
160            return {"status": "Added a new entity to the world", "entity_id": eid}
161        except Exception as e:
162            return {"error": str(e)}
163
164    elif cmd == "entity_destroy":
165        try:
166            sim.world.remove_entity(entity_id := data["entity_id"])
167            sim.world.update()
168            return {"status": f"Entity {entity_id} destroyed"}
169        except Exception as e:
170            return {"error": str(e)}
171
172    elif cmd == "entity_add_component":
173        try:
174            entity = sim.world.get_entity(entity_id := data["entity_id"])
175            comp = sim.world.component_name_to_type[data["component"]]
176
177            field_dtype = dict(zip(sim.world.component_to_field_names[comp], sim.world.component_to_dtypes[comp]))
178            component_data = {k: ecs_data_as_np(v, dtype=field_dtype[k]) for k, v in data["data"].items()}
179
180            entity.add_component(comp, **component_data, **make_nonserializable_data([comp], component_data))
181            sim.world.update()
182            return {"status": f"Added component {data['component']} to entity {entity_id}"}
183        except Exception as e:
184            return {"error": str(e)}
185
186    elif cmd == "entity_remove_component":
187        try:
188            entity = sim.world.get_entity(entity_id := data["entity_id"])
189            component = sim.world.component_name_to_type[data["component"]]
190
191            entity.remove_component(component)
192            sim.world.update()
193            return {"status": f"Component {data['component']} removed from entity {entity_id}"}
194        except Exception as e:
195            return {"error": str(e)}
196
197    elif cmd == "entity_set_data":
198        try:
199            entity = sim.world.get_entity(entity_id := data["entity_id"])
200            comp = sim.world.component_name_to_type[data["component"]]
201            # TODO(microecs-30): can microecs autoconvert the data using numpy's rules?
202            field_dtype = dict(zip(sim.world.component_to_field_names[comp], sim.world.component_to_dtypes[comp]))
203            component_data = {k: ecs_data_as_np(v, dtype=field_dtype[k]) for k, v in data["data"].items()}
204
205            entity.set_data(**component_data)
206            sim.world.update()
207            return {"status": f"Entity id: {entity_id}. Updated data of following fields: {list(data['data'])}"}
208        except Exception as e:
209            return {"error": str(e)}
210
211    # misc / other commands
212
213    elif cmd == "sim_set_collision_cell_size":
214        sim.state.collision_cell_size = data["value"]
215        return {"status": f"Set collision cell size to: {data['value']}"}
216
217    elif cmd == "sim_display_uav_trace":
218        sim.state.uav_traces.clear()
219        sim.state.display_uav_trace = data["value"]
220        return {"status": f"Display UAV trace set to to: {data['value']}"}
221
222    raise ValueError(f"Shouldn't reach here: {msg}")
223
224def network_slow_handler(sim: Simulator, init_config: dict):
225    """[main thread] Polls the messages (global or plugin) of all the clients which were pushed from fast handler."""
226    messages_for_plugins: list[Message] = []
227
228    while True:
229        try:
230            msg = sim.connection_manager.get_one_message()
231        except Empty:
232            break
233
234        cmd = msg.data["cmd"] # Existence of 'cmd' was validated in _fast_handler
235
236        # 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
237        if cmd in sim.plugins_manager.all_endpoints:
238            messages_for_plugins.append(msg)
239            continue
240
241        # Below only messages for the core simulator (not plugins).
242
243        resp = _handle_message(sim, msg, init_config=init_config)
244        # Only respond if the messages are from a socket client (e.g. client is not None). Ignore if SCRIPT (keyboard).
245        if msg.source_type == SourceType.SOCKET:
246            msg.client.channel.main2tcp.put(resp)
247
248    # Call the plugins at the end. Note that this is needed even for zero messages as we call on_tick() over there.
249    # Maybe we need to split this? plugins.io_handler vs plugins.on_tick ?
250    sim.plugins_manager.io_handler(world=sim.world, messages_for_plugins=messages_for_plugins)
RESOURCES_PATH = PosixPath('/builds/open-visual-robotics/robosim/resources')
def network_handler( msg: micronetcode.message.Message, sim: simulator_singleton.Simulator) -> dict:
51def network_handler(msg: Message, sim: Simulator) -> dict:
52    """
53    [TCP client thread]. All the client messages arrive here.
54    The FPV command is 'fast handled' while all the others are pushed to the simulator (global/plugins).
55    Note: stop making admin/global channels. We don't need it! Each robot has its own channel of communication.
56    """
57    timestamp_recv = datetime.now().isoformat()
58    resp = _fast_handler(msg, sim)
59    resp["timestamp_recv"] = timestamp_recv
60    resp["timestamp_resp"] = datetime.now().isoformat()
61    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 robot has its own channel of communication.

def network_slow_handler(sim: simulator_singleton.Simulator, init_config: dict):
225def network_slow_handler(sim: Simulator, init_config: dict):
226    """[main thread] Polls the messages (global or plugin) of all the clients which were pushed from fast handler."""
227    messages_for_plugins: list[Message] = []
228
229    while True:
230        try:
231            msg = sim.connection_manager.get_one_message()
232        except Empty:
233            break
234
235        cmd = msg.data["cmd"] # Existence of 'cmd' was validated in _fast_handler
236
237        # 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
238        if cmd in sim.plugins_manager.all_endpoints:
239            messages_for_plugins.append(msg)
240            continue
241
242        # Below only messages for the core simulator (not plugins).
243
244        resp = _handle_message(sim, msg, init_config=init_config)
245        # Only respond if the messages are from a socket client (e.g. client is not None). Ignore if SCRIPT (keyboard).
246        if msg.source_type == SourceType.SOCKET:
247            msg.client.channel.main2tcp.put(resp)
248
249    # Call the plugins at the end. Note that this is needed even for zero messages as we call on_tick() over there.
250    # Maybe we need to split this? plugins.io_handler vs plugins.on_tick ?
251    sim.plugins_manager.io_handler(world=sim.world, messages_for_plugins=messages_for_plugins)

[main thread] Polls the messages (global or plugin) of all the clients which were pushed from fast handler.