diff --git a/client/python/example_user_scripts/world_consumer.py b/client/python/example_user_scripts/world_consumer.py new file mode 100644 index 00000000..9fc84cf6 --- /dev/null +++ b/client/python/example_user_scripts/world_consumer.py @@ -0,0 +1,64 @@ +""" +Loads the existing world from saved config and flies a drone. +""" + +import asyncio +from projectairsim import ProjectAirSimClient, Drone, World +from projectairsim.utils import projectairsim_log +from projectairsim.image_utils import ImageDisplay + +CONFIG_PATH = "./world_config.json" + +async def main(): + client = ProjectAirSimClient() + image_display = ImageDisplay() + + try: + client.connect() + world = World(client) # sin argumentos + drone = Drone(client, world, "Drone1") + + # --- Set up image windows --- + chase_cam_window = "ChaseCam" + image_display.add_chase_cam(chase_cam_window) + client.subscribe( + drone.sensors["Chase"]["scene_camera"], + lambda _, chase: image_display.receive(chase, chase_cam_window), + ) + + rgb_name = "RGB-Image" + image_display.add_image(rgb_name, subwin_idx=0) + client.subscribe( + drone.sensors["DownCamera"]["scene_camera"], + lambda _, rgb: image_display.receive(rgb, rgb_name), + ) + + depth_name = "Depth-Image" + image_display.add_image(depth_name, subwin_idx=2) + client.subscribe( + drone.sensors["DownCamera"]["depth_camera"], + lambda _, depth: image_display.receive(depth, depth_name), + ) + + image_display.start() + + # --- Flight demo --- + drone.enable_api_control() + drone.arm() + + await (await drone.takeoff_async()) + await (await drone.move_by_velocity_async(v_north=0.0, v_east=0.0, v_down=-1.0, duration=4.0)) + await (await drone.move_by_velocity_async(v_north=0.0, v_east=0.0, v_down=1.0, duration=4.0)) + await (await drone.land_async()) + + drone.disarm() + drone.disable_api_control() + + except Exception as err: + projectairsim_log().error(f"❌ Exception: {err}", exc_info=True) + finally: + client.disconnect() + image_display.stop() + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/client/python/example_user_scripts/world_creator.py b/client/python/example_user_scripts/world_creator.py new file mode 100644 index 00000000..b68a8121 --- /dev/null +++ b/client/python/example_user_scripts/world_creator.py @@ -0,0 +1,25 @@ +""" +Creates the world and saves its configuration to shared volume. +""" + +import asyncio +import os +from projectairsim import ProjectAirSimClient, World +from projectairsim.utils import projectairsim_log + +CONFIG_PATH = "./world_config.json" + +async def main(): + client = ProjectAirSimClient() + + try: + client.connect() + world = World(client, "scene_basic_drone.jsonc", delay_after_load_sec=2) + projectairsim_log().info(f"✅ World created") + except Exception as err: + projectairsim_log().error(f"❌ Exception: {err}", exc_info=True) + finally: + client.disconnect() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/client/python/projectairsim/src/projectairsim/utils.py b/client/python/projectairsim/src/projectairsim/utils.py index 593c6874..3150e0fd 100644 --- a/client/python/projectairsim/src/projectairsim/utils.py +++ b/client/python/projectairsim/src/projectairsim/utils.py @@ -483,7 +483,7 @@ def load_scene_config_as_dict( """ projectairsim_log().info(f"Loading scene config: {config_name}") total_path = os.path.join(sim_config_path, config_name) - filepaths = [total_path, [], []] + filepaths = [total_path, [], [], []] robot_config_schema = pkg_resources.resource_string( __name__, "schema/robot_config_schema.jsonc" @@ -504,14 +504,19 @@ def load_scene_config_as_dict( if "actors" in data: # read and write the robot-config param in each actor for actor in data["actors"]: if actor["type"] == "robot": - actor_path = actor["robot-config"] - total_actor_path = os.path.join(sim_config_path, actor_path) - filepaths[1].append(total_actor_path) - with open(total_actor_path) as e: - temp = commentjson.load(e) - validate_json(temp, robot_config_schema) - actor["robot-config"] = temp - + actor_configs = actor["robot-config"] + combined_config = {} + if not isinstance(actor_configs, list): + actor_configs = [actor_configs] + for actor_path in actor_configs: + total_actor_path = os.path.join(sim_config_path, actor_path) + filepaths[1].append(total_actor_path) + with open(total_actor_path) as e: + temp = commentjson.load(e) + combined_config = merge_dicts(combined_config,temp) + validate_json(combined_config, robot_config_schema) + actor["robot-config"] = combined_config + if "environment-actors" in data: # read and write the env-actor-config param in each env actor for env_actor in data["environment-actors"]: @@ -535,6 +540,34 @@ def load_scene_config_as_dict( return (data, filepaths) +def merge_dicts(d1, d2): + """Recursively merges dict d2 into dict d1""" + for k, v in d2.items(): + if isinstance(v, collections.abc.Mapping): + d1[k] = merge_dicts(d1.get(k, {}), v) + elif isinstance(v, list) and isinstance(d1.get(k), list): + d1[k] = merge_lists(d1[k], v) + else: + d1[k] = v + return d1 + +def merge_lists(l1, l2): + """Merges two lists of dictionaries, matching elements by the 'name' key if present""" + result = l1[:] + for item2 in l2: + if isinstance(item2, dict) and "name" in item2: + # Try to find the corresponding item in l1 based on 'name' + matching_item = next((item1 for item1 in result if item1.get("name") == item2["name"]), None) + if matching_item: + # Merge the dictionaries + merge_dicts(matching_item, item2) + else: + # If no match, append the item from l2 + result.append(item2) + else: + # If it's not a dictionary or doesn't have a 'name', just append it + result.append(item2) + return result def validate_json(json_data, file_name) -> None: """Validates a JSON according to a given schema diff --git a/client/python/projectairsim/src/projectairsim/world.py b/client/python/projectairsim/src/projectairsim/world.py index b01aee60..ea7bf1d3 100644 --- a/client/python/projectairsim/src/projectairsim/world.py +++ b/client/python/projectairsim/src/projectairsim/world.py @@ -4,12 +4,15 @@ MIT License. Python API class for ProjectAirSim World. """ +from pathlib import Path import commentjson from typing import Dict, List import time from datetime import datetime import numpy as np import math +import json +import os import random from projectairsim import ProjectAirSimClient @@ -33,6 +36,10 @@ ) from projectairsim.planners import AStarPlanner +if os.environ.get("INSIDE_DOCKER") == "1": + DEFAULT_CONFIG_PATH = Path("/home/ue4/airsim_shared/world_config.json") +else: + DEFAULT_CONFIG_PATH = Path.home() / "airsim_shared" / "world_config.json" class World(object): def __init__( @@ -55,21 +62,53 @@ def __init__( self.client = client self.sim_config_path = sim_config_path self.sim_instance_idx = sim_instance_idx - self.parent_topic = "/Sim/SceneBasicDrone" # default-scene's ID + self.parent_topic = "/Sim" # default-scene's ID + self._scene_config_name = scene_config_name + self._delay_after_load_sec = delay_after_load_sec self.sim_config = None self.home_geo_point = None - if scene_config_name: - config_loaded, config_paths = load_scene_config_as_dict( - scene_config_name, - sim_config_path, - sim_instance_idx, - ) - config_dict = config_loaded - self.scene_config_path = config_paths[0] - self.robot_config_paths = config_paths[1] - self.envactor_config_paths = config_paths[2] - self.load_scene(config_dict, delay_after_load_sec=delay_after_load_sec) + + if not scene_config_name: + try: + with open(DEFAULT_CONFIG_PATH, "r") as f: + cfg = json.load(f) + scene_config_name = cfg["scene_config_name"] + delay_after_load_sec = cfg.get("delay_after_load_sec", 0) + sim_config_path = cfg.get("sim_config_path", sim_config_path) + sim_instance_idx = cfg.get("sim_instance_idx", sim_instance_idx) + projectairsim_log().info(f"Loaded world config from {DEFAULT_CONFIG_PATH}") + except Exception as e: + raise RuntimeError(f"Could not load previous world config: {e}") + else: + try: + os.makedirs(DEFAULT_CONFIG_PATH.parent, exist_ok=True) + with open(DEFAULT_CONFIG_PATH, "w") as f: + json.dump( + { + "scene_config_name": scene_config_name, + "delay_after_load_sec": delay_after_load_sec, + "sim_config_path": "./sim_config/", + "sim_instance_idx": sim_instance_idx, + }, + f, + ) + projectairsim_log().info(f"Saved world config to {DEFAULT_CONFIG_PATH}") + except Exception as e: + projectairsim_log().warning(f"Could not save world config: {e}") + + # Load scene config + config_loaded, config_paths = load_scene_config_as_dict( + scene_config_name, + sim_config_path, + sim_instance_idx, + ) + config_dict = config_loaded + self.scene_config_path = config_paths[0] + self.robot_config_paths = config_paths[1] + self.envactor_config_paths = config_paths[2] + self.envobject_config_paths = config_paths[3] + self.load_scene(config_dict, delay_after_load_sec=delay_after_load_sec) random.seed() self.import_ned_trajectory( "null_trajectory", [0, 1], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0] @@ -2391,4 +2430,4 @@ def generate_intercept_trajectory( # Import the trajectory self.import_ned_trajectory( traj_name, traj_times, traj_poses_x, traj_poses_y, traj_poses_z - ) + ) \ No newline at end of file