From f358a2d17f7d90a54b44fe39cd44a451496bb35f Mon Sep 17 00:00:00 2001 From: RadAlpaca11 Date: Mon, 3 Aug 2026 07:12:16 -0700 Subject: [PATCH 1/2] feat: add drone (ArduPilot SITL + Gazebo) simulation Adds a `drone` option to `sim gazebo` for flying an S500-class quadcopter (ArduCopter SITL matching a Pixhawk 2.4.8) in Gazebo Harmonic via the ardupilot_gazebo plugin, flown through QGroundControl. - `sim gazebo setup-drone`: clones and builds ArduPilot SITL (pinned to Copter-4.3.7 to match real flight-controller firmware) and the ardupilot_gazebo plugin from source into .native/, separately from the Docker image. Handles several environment gaps along the way: Ubuntu 24.04 isn't supported by this ArduPilot release's own prereqs script, its vendored waf predates Python 3.12, and a couple of its C++ files need a missing standard header under GCC 13. - `gazebo/drone_description` / `gazebo/drone_bringup`: custom SDF world + launch file (not generated via the OnShape pipeline like arm/ chassis) that starts Gazebo and ArduCopter SITL together. The world needs the Imu/NavSat/Sensors system plugins and a element for the drone's IMU sensor to actually produce data, and the GUI runs in the same process as the server (`combined_gui`) since a split-process server has no render context for its Sensors system to use. - `robots.json` gains a `type` field to distinguish non-OnShape robots from the existing arm/chassis entries. - SITL's MAVLink output is pointed at the container's own default gateway (read from /proc/net/route at launch time) so QGroundControl, running on the host, can reach it - ArduPilot's serial-device parser has no listening/server UDP mode and can't resolve hostnames, so this has to be a real IP resolved on our side. - Also fixes two unrelated pre-existing bugs hit while getting this working: a stray backslash in docker-compose-gpu.yml's venv setup command that broke the NVIDIA devcontainer's `sim` install, and missing runtime dependencies (pypresence, trimesh, pyfqmr, requests) in pyproject.toml that only surfaced when installed into an isolated venv instead of system/pixi Python. --- .gitignore | 10 + cli/cli.py | 12 +- cli/gazebo/ardupilot.py | 238 ++++++++++++++++++ cli/gazebo/launch.py | 36 +++ cli/paths.py | 4 + docker/docker-compose-gpu.yml | 2 +- docker/docker-compose.yml | 1 + docs.config.json | 3 +- docs/gazebo/drone.mdx | 98 ++++++++ docs/gazebo/robot-packages.mdx | 16 ++ gazebo/drone_bringup/CMakeLists.txt | 20 ++ gazebo/drone_bringup/launch/drone.launch.py | 85 +++++++ gazebo/drone_bringup/package.xml | 20 ++ gazebo/drone_description/CMakeLists.txt | 19 ++ gazebo/drone_description/package.xml | 17 ++ .../drone_description/worlds/drone.world.sdf | 98 ++++++++ gazebo/sim_common/sim_common/launch_utils.py | 14 +- pyproject.toml | 2 +- robots.json | 4 + 19 files changed, 692 insertions(+), 7 deletions(-) create mode 100644 cli/gazebo/ardupilot.py create mode 100644 docs/gazebo/drone.mdx create mode 100644 gazebo/drone_bringup/CMakeLists.txt create mode 100644 gazebo/drone_bringup/launch/drone.launch.py create mode 100644 gazebo/drone_bringup/package.xml create mode 100644 gazebo/drone_description/CMakeLists.txt create mode 100644 gazebo/drone_description/package.xml create mode 100644 gazebo/drone_description/worlds/drone.world.sdf diff --git a/.gitignore b/.gitignore index aa2688e..142ab94 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ # pixi .pixi/ +# native builds +.native/ + # Python .mypy_cache __pycache__ @@ -22,6 +25,13 @@ gazebo/install gazebo/build gazebo/log +# drone stuff +gazebo/eeprom.bin +gazebo/logs/ +gazebo/terrain/ +gazebo/dumpcore.sh_arducopter.*.out +gazebo/dumpstack.sh_arducopter.*.out + # sim create tests cli/gazebo/create/tests diff --git a/cli/cli.py b/cli/cli.py index e9bb82c..5c70c46 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -10,7 +10,7 @@ from .output import die, info from .paths import GAZEBO_WORKSPACE_DIR -_GAZEBO_SUBCOMMANDS = frozenset({"clean", "create", "update", "auth"}) +_GAZEBO_SUBCOMMANDS = frozenset({"clean", "create", "update", "auth", "setup-drone"}) def _build_gazebo_parser(subparsers: argparse._SubParsersAction) -> tuple: @@ -60,6 +60,11 @@ def _build_gazebo_parser(subparsers: argparse._SubParsersAction) -> tuple: gsubs.add_parser("auth", help="Configure dashboard API key for OnShape access") + gsubs.add_parser( + "setup-drone", + help="Build ArduPilot SITL + ardupilot_gazebo plugin (one-time, for 'sim gazebo drone')", + ) + return gazebo, gsubs, create_p @@ -204,6 +209,11 @@ def _dispatch_gazebo(args, create_p) -> None: elif args.command == "auth": cmd_auth() + elif args.command == "setup-drone": + from .gazebo.ardupilot import setup as ardupilot_setup + + ardupilot_setup() + if __name__ == "__main__": main() diff --git a/cli/gazebo/ardupilot.py b/cli/gazebo/ardupilot.py new file mode 100644 index 0000000..70d9980 --- /dev/null +++ b/cli/gazebo/ardupilot.py @@ -0,0 +1,238 @@ +"""ArduPilot SITL + ardupilot_gazebo plugin setup for the drone simulation. + +Builds both from source into `.native/` (gitignored), separate from the +Docker image build - run once via `sim gazebo setup-drone`. +""" + +from __future__ import annotations + +import getpass +import os +import subprocess +from pathlib import Path + +from ..output import die, info, run_step +from ..paths import ARDUPILOT_DIR, ARDUPILOT_GAZEBO_DIR + +ARDUPILOT_REPO = "https://github.com/ArduPilot/ardupilot.git" +ARDUPILOT_REF = "Copter-4.3.7" + +# Copter-4.3.7 pins modules/waf at a 2019-era commit that still uses the +# `imp` module, removed in Python 3.12 (Ubuntu 24.04's default python3). +# waf is only a host-side build tool - it isn't part of the firmware - so +# it's safe to move just this submodule forward to the commit later +# ArduPilot releases (e.g. Copter-4.6.2) use, which supports Python 3.12. +ARDUPILOT_WAF_COMMIT = "35eadbb64e2052099a853b571e507c33032b392c" + +ARDUPILOT_GAZEBO_REPO = "https://github.com/ArduPilot/ardupilot_gazebo.git" + +ARDUCOPTER_BIN = ARDUPILOT_DIR / "build" / "sitl" / "bin" / "arducopter" +ARDUPILOT_GAZEBO_BUILD_DIR = ARDUPILOT_GAZEBO_DIR / "build" +ARDUPILOT_GAZEBO_PLUGIN = ARDUPILOT_GAZEBO_BUILD_DIR / "libArduPilotPlugin.so" + + +def is_installed() -> bool: + """Whether ArduCopter SITL and the ardupilot_gazebo plugin are already built""" + return ARDUCOPTER_BIN.is_file() and ARDUPILOT_GAZEBO_PLUGIN.is_file() + + +def _run(command: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None: + result = subprocess.run(command, cwd=cwd, env=env, check=False) # noqa: S603 + if result.returncode != 0: + raise subprocess.CalledProcessError(result.returncode, command) + + +def _clone_ardupilot() -> None: + if ARDUPILOT_DIR.is_dir(): + info(f"ArduPilot already cloned at {ARDUPILOT_DIR}") + return + ARDUPILOT_DIR.parent.mkdir(parents=True, exist_ok=True) + _run( + [ + "git", + "clone", + "--recurse-submodules", + "--branch", + ARDUPILOT_REF, + ARDUPILOT_REPO, + str(ARDUPILOT_DIR), + ], + cwd=ARDUPILOT_DIR.parent, + ) + + +_BUILD_APT_PACKAGES = [ + "build-essential", + "ccache", + "g++", + "gawk", + "git", + "make", + "wget", + "python3-dev", + "python3-pip", + "libtool-bin", + "rsync", +] + +# empy is pinned: newer empy (4.x) changed its API and breaks ArduPilot's +# waf build scripts, which still use the old empy 3.3.4 templating API. +_BUILD_PIP_PACKAGES = [ + "future", + "lxml", + "pymavlink", + "pyserial", + "ptyprocess", + "pexpect", + "setuptools<81", # older ArduPilot build scripts need the legacy pkg_resources module + "empy==3.3.4", +] + + +def _install_ardupilot_prereqs() -> None: + """Install just what's needed to configure/build SITL and generate MAVLink headers. + + ArduPilot's own Tools/environment_install/install-prereqs-ubuntu.sh (as + pinned at ARDUPILOT_REF) predates Ubuntu 24.04 "noble" support and also + pulls in a large MAVProxy/wxPython/SFML stack only needed for its + graphical GCS console - `sim gazebo drone` runs the `arducopter` binary + directly (not sim_vehicle.py/MAVProxy), so none of that is needed here. + """ + _run(["sudo", "apt-get", "update"], cwd=ARDUPILOT_DIR) + _run( + ["sudo", "apt-get", "install", "-y", "--no-install-recommends", *_BUILD_APT_PACKAGES], + cwd=ARDUPILOT_DIR, + ) + _run( + ["pip3", "install", "--break-system-packages", *_BUILD_PIP_PACKAGES], + cwd=ARDUPILOT_DIR, + ) + + env = os.environ.copy() + user = env.get("USER") or getpass.getuser() + _run(["sudo", "usermod", "-a", "-G", "dialout", user], cwd=ARDUPILOT_DIR) + + +def _pin_waf_for_python312() -> None: + waf_dir = ARDUPILOT_DIR / "modules" / "waf" + result = subprocess.run( # noqa: S603 + ["git", "rev-parse", "HEAD"], cwd=waf_dir, capture_output=True, text=True, check=False + ) + if result.stdout.strip() == ARDUPILOT_WAF_COMMIT: + info("modules/waf already pinned to the Python 3.12-compatible commit") + return + _run(["git", "fetch", "--depth", "1", "origin", ARDUPILOT_WAF_COMMIT], cwd=waf_dir) + _run(["git", "checkout", ARDUPILOT_WAF_COMMIT], cwd=waf_dir) + + +# ARDUPILOT_REF predates GCC 13 (Ubuntu 24.04's default): some files rely on +# standard headers transitively including /, which newer +# libstdc++ no longer does. Each entry adds a missing include right before +# an existing one, purely a header fix with no behavior change. Extend this +# list if `./waf copter` hits more "does not name a type" errors. +_GCC13_HEADER_FIXES: list[tuple[str, str, str]] = [ + ( + "libraries/AP_HAL_SITL/CANSocketIface.cpp", + "#include ", + "#include ", + ), +] + + +def _patch_gcc13_headers() -> None: + for rel_path, anchor, missing_include in _GCC13_HEADER_FIXES: + path = ARDUPILOT_DIR / rel_path + text = path.read_text() + if missing_include in text: + continue + path.write_text(text.replace(anchor, f"{anchor}\n{missing_include}", 1)) + + +def _build_arducopter() -> None: + if ARDUCOPTER_BIN.is_file(): + info(f"ArduCopter SITL already built at {ARDUCOPTER_BIN}") + return + _pin_waf_for_python312() + _patch_gcc13_headers() + _run(["./waf", "configure", "--board", "sitl"], cwd=ARDUPILOT_DIR) + _run(["./waf", "copter"], cwd=ARDUPILOT_DIR) + if not ARDUCOPTER_BIN.is_file(): + die(f"Build finished but {ARDUCOPTER_BIN} was not produced") + + +def _clone_ardupilot_gazebo() -> None: + if ARDUPILOT_GAZEBO_DIR.is_dir(): + info(f"ardupilot_gazebo already cloned at {ARDUPILOT_GAZEBO_DIR}") + return + ARDUPILOT_GAZEBO_DIR.parent.mkdir(parents=True, exist_ok=True) + _run( + ["git", "clone", ARDUPILOT_GAZEBO_REPO, str(ARDUPILOT_GAZEBO_DIR)], + cwd=ARDUPILOT_GAZEBO_DIR.parent, + ) + + +_GAZEBO_PLUGIN_APT_PACKAGES = [ + "libgstreamer1.0-dev", + "libgstreamer-plugins-base1.0-dev", +] + +# ardupilot_gazebo's CMakeLists.txt calls ament_package(), which needs catkin_pkg +# (to parse package.xml) on whatever python ROS2's ament_cmake_core invokes. +_GAZEBO_PLUGIN_PIP_PACKAGES = ["catkin_pkg"] + + +def _build_ardupilot_gazebo_plugin() -> None: + if ARDUPILOT_GAZEBO_PLUGIN.is_file(): + info(f"ardupilot_gazebo plugin already built at {ARDUPILOT_GAZEBO_PLUGIN}") + return + _run(["sudo", "apt-get", "update"], cwd=ARDUPILOT_GAZEBO_DIR) + _run( + [ + "sudo", + "apt-get", + "install", + "-y", + "--no-install-recommends", + *_GAZEBO_PLUGIN_APT_PACKAGES, + ], + cwd=ARDUPILOT_GAZEBO_DIR, + ) + _run( + ["pip3", "install", "--break-system-packages", *_GAZEBO_PLUGIN_PIP_PACKAGES], + cwd=ARDUPILOT_GAZEBO_DIR, + ) + env = os.environ.copy() + env["GZ_VERSION"] = "harmonic" + _run( + ["cmake", "-B", "build", "-DCMAKE_BUILD_TYPE=RelWithDebInfo"], + cwd=ARDUPILOT_GAZEBO_DIR, + env=env, + ) + _run( + ["cmake", "--build", "build", "--parallel", str(os.cpu_count() or 1)], + cwd=ARDUPILOT_GAZEBO_DIR, + env=env, + ) + if not ARDUPILOT_GAZEBO_PLUGIN.is_file(): + die(f"Build finished but {ARDUPILOT_GAZEBO_PLUGIN} was not produced") + + +def setup() -> None: + """Clone and build ArduPilot SITL (ArduCopter) and the ardupilot_gazebo plugin""" + if is_installed(): + info("ArduPilot SITL + ardupilot_gazebo plugin are already set up") + info(f" {ARDUCOPTER_BIN}") + info(f" {ARDUPILOT_GAZEBO_PLUGIN}") + return + + try: + run_step("Cloning ArduPilot", _clone_ardupilot) + run_step("Installing ArduPilot build prerequisites", _install_ardupilot_prereqs) + run_step("Building ArduCopter SITL (this takes a while)", _build_arducopter) + run_step("Cloning ardupilot_gazebo", _clone_ardupilot_gazebo) + run_step("Building ardupilot_gazebo plugin", _build_ardupilot_gazebo_plugin) + except subprocess.CalledProcessError as error: + die(f"Command failed with exit code {error.returncode}: {' '.join(error.cmd)}") + + info("ArduPilot SITL + ardupilot_gazebo plugin are ready") + info("Run 'sim gazebo drone' to launch the drone simulation") diff --git a/cli/gazebo/launch.py b/cli/gazebo/launch.py index 2af21fc..f0d9854 100644 --- a/cli/gazebo/launch.py +++ b/cli/gazebo/launch.py @@ -15,6 +15,41 @@ _ANSI_RE = re.compile(r"\x1B\[[0-9;]*[mK]") +def _robot_type(robot_name: str) -> str | None: + from .create.registry import load_robots_json + + entry = next((e for e in load_robots_json() if e["name"] == robot_name), None) + return entry.get("type") if entry else None + + +def _configure_ardupilot_env(robot_name: str, env: dict[str, str]) -> None: + """For ArduPilot-type robots, point Gazebo at the plugin/models built by + `sim gazebo setup-drone` and expose the SITL binary path to the launch file.""" + if _robot_type(robot_name) != "ardupilot": + return + + from . import ardupilot + + if not ardupilot.is_installed(): + die( + "ArduPilot SITL + ardupilot_gazebo plugin are not built yet.\n" + " Run: sim gazebo setup-drone" + ) + + for var, paths in ( + ( + "GZ_SIM_RESOURCE_PATH", + [ardupilot.ARDUPILOT_GAZEBO_DIR / "models", ardupilot.ARDUPILOT_GAZEBO_DIR / "worlds"], + ), + ("GZ_SIM_SYSTEM_PLUGIN_PATH", [ardupilot.ARDUPILOT_GAZEBO_BUILD_DIR]), + ): + existing = [p for p in env.get(var, "").split(":") if p] + additions = [str(p) for p in paths if str(p) not in existing] + env[var] = ":".join(additions + existing) + + env["ARDUCOPTER_BIN"] = str(ardupilot.ARDUCOPTER_BIN) + + def in_pixi() -> bool: return bool(os.environ.get("PIXI_PROJECT_ROOT") or os.environ.get("CONDA_PREFIX")) @@ -180,6 +215,7 @@ def build_and_launch(robot_name: str, *, build_only: bool = False, no_build: boo launch = not build_only env = os.environ.copy() render_prefix = _configure_force_vnc_rendering(env) + _configure_ardupilot_env(robot_name, env) bringup_pkg, description_pkg, launch_file_name = _validate_robot_layout(robot_name) if build: diff --git a/cli/paths.py b/cli/paths.py index 5471d47..4ec3e66 100644 --- a/cli/paths.py +++ b/cli/paths.py @@ -33,6 +33,10 @@ def _find_repo_root() -> Path: NATIVE_ENV_PREFIX = NATIVE_ENVS / "ros_env" NATIVE_CONTROL_WS = NATIVE_BUILD_DIR / "gz_ros2_control_ws" +# ArduPilot SITL + ardupilot_gazebo plugin, built by `sim gazebo setup-drone` +ARDUPILOT_DIR = NATIVE_BUILD_DIR / "ardupilot" +ARDUPILOT_GAZEBO_DIR = NATIVE_BUILD_DIR / "ardupilot_gazebo" + MACOS_SOURCE_FILES = GAZEBO_WORKSPACE_DIR / "sim_common" / "macos" MACOS_MAMBA_ROOT = NATIVE_BUILD_DIR / "mamba" MACOS_ROS_BASE = NATIVE_BUILD_DIR / "ros_base" diff --git a/docker/docker-compose-gpu.yml b/docker/docker-compose-gpu.yml index 5876f48..dac3218 100644 --- a/docker/docker-compose-gpu.yml +++ b/docker/docker-compose-gpu.yml @@ -14,5 +14,5 @@ services: command: >- bash -lc 'set -e; - python3 -m venv .venv && source .venv/bin/activate && pip install -e . && \ + python3 -m venv .venv && source .venv/bin/activate && pip install -e . && exec sleep infinity' diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 36004ac..6d15bbc 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -15,6 +15,7 @@ services: environment: DISPLAY: "${DISPLAY:-:0}" + XAUTHORITY: "${XAUTHORITY:-}" VNC_PORT: "${VNC_PORT:?VNC_PORT must be set, see docker/.env}" NOVNC_PORT: "${NOVNC_PORT:?NOVNC_PORT must be set, see docker/.env}" ROSBRIDGE_PORT: "${ROSBRIDGE_PORT:?ROSBRIDGE_PORT must be set, see docker/.env}" diff --git a/docs.config.json b/docs.config.json index 973b6b6..e928665 100644 --- a/docs.config.json +++ b/docs.config.json @@ -31,7 +31,8 @@ "label": "Robot Packages", "slug": "gazebo/robot-packages" }, - { "label": "Joint GUI", "slug": "gazebo/joint-gui" } + { "label": "Joint GUI", "slug": "gazebo/joint-gui" }, + { "label": "Drone (ArduPilot SITL)", "slug": "gazebo/drone" } ] }, { diff --git a/docs/gazebo/drone.mdx b/docs/gazebo/drone.mdx new file mode 100644 index 0000000..ecc62c3 --- /dev/null +++ b/docs/gazebo/drone.mdx @@ -0,0 +1,98 @@ +--- +title: Drone (ArduPilot SITL) +description: Fly a simulated S500 quadcopter with ArduPilot SITL, Gazebo Harmonic, and QGroundControl. +--- + +`sim gazebo drone` launches an S500-class quadcopter running ArduCopter +SITL (simulating a Pixhawk 2.4.8) alongside Gazebo Harmonic, using the +[`ardupilot_gazebo`](https://github.com/ArduPilot/ardupilot_gazebo) plugin +for the physics/sensor bridge. Unlike the arm and chassis, the drone isn't +generated from an OnShape model - it's flown through **QGroundControl**, +not RViz or the Joint GUI. + +## One-time setup + +ArduPilot SITL and the `ardupilot_gazebo` plugin are built from source the +first time you use the drone sim, rather than being baked into the Docker +image: + +```bash title="Terminal (inside the Dev Container)" +sim gazebo setup-drone +``` + +This clones and builds: + +- **ArduPilot** (`ArduCopter` SITL binary, via `waf`) +- **ardupilot_gazebo** (the Gazebo plugin that bridges SITL to Gazebo Harmonic) + +into `.native/` at the repo root (gitignored). It only needs to be run +once - re-running it is a no-op if both are already built. This step +downloads and compiles a fair amount of code, so expect it to take a +while. + +## Running the simulation + +```bash title="Terminal" +sim gazebo drone +``` + +This builds `drone_bringup`/`drone_description` like any other robot, then: + +1. Starts Gazebo Harmonic with a world containing the quadcopter model + (`ardupilot_gazebo`'s stock `iris_with_ardupilot` model, wired to the + `ArduPilotPlugin`) +2. Starts ArduCopter SITL, connected to the Gazebo plugin via its JSON + backend, with its primary MAVLink output on UDP port 14550 + +## Connecting QGroundControl + +QGroundControl and the Flysky i6X controller run on your **host machine**, +not inside the container: + +1. Install [QGroundControl](http://qgroundcontrol.com/) on your host +2. If your host runs a firewall (e.g. `ufw`), allow inbound UDP 14550 - + SITL's telemetry arrives from the container as normal LAN traffic, and + a default-deny incoming policy silently drops it with no error on + either side: + ```bash title="Terminal (on your host, not the container)" + sudo ufw allow 14550/udp comment "QGroundControl / ArduPilot SITL MAVLink" + ``` +3. Plug the Flysky i6X USB receiver into your host - QGroundControl reads + it directly through its own joystick support, so no container/USB + passthrough configuration is needed +4. Start `sim gazebo drone` +5. QGroundControl auto-connects over UDP - `drone.launch.py` reads the + container's own default gateway (from `/proc/net/route`) at launch + time and points SITL's MAVLink output directly at it, since that's + where the host - and QGroundControl - is reachable from inside the + container. There's no port published/forwarded on the container; it + isn't needed, since SITL is the one initiating the connection outbound + rather than QGroundControl connecting in. +6. In QGroundControl, go to **Application Settings → Joystick** to enable + and calibrate the Flysky i6X, then fly as normal + +## Troubleshooting + +**`ArduPilot SITL + ardupilot_gazebo plugin are not built yet`:** Run +`sim gazebo setup-drone` first. + +**QGroundControl shows "Disconnected" with no errors on either side:** +The most common cause is a host firewall silently dropping the inbound +UDP packets - there's no error message anywhere for this, since dropped +packets aren't reported back. Check your firewall status +(`sudo ufw status verbose` if using `ufw`) and allow UDP 14550 in per the +setup step above. + +**QGroundControl fails to bind port 14550 ("address already in use"):** +Make sure nothing else on your host is already bound to UDP 14550 - +notably, do NOT add a `ports:` entry for it in `docker-compose.yml`. +Docker's own port-publish mechanism (`docker-proxy`) binds the host port +itself, which blocks QGroundControl from ever binding it and intercepts +SITL's outbound packets instead of letting them reach QGroundControl. +Check with `sudo lsof -i :14550` on the host - it should show only +QGroundControl, nothing else. + +**No image in Gazebo / model missing:** `ardupilot_gazebo`'s `models/` and +`worlds/` directories are resolved via `GZ_SIM_RESOURCE_PATH`, which `sim +gazebo drone` points at the checkout from `sim gazebo setup-drone`. If +that checkout was moved or deleted, re-run setup. diff --git a/docs/gazebo/robot-packages.mdx b/docs/gazebo/robot-packages.mdx index 0526bd6..12a3e01 100644 --- a/docs/gazebo/robot-packages.mdx +++ b/docs/gazebo/robot-packages.mdx @@ -64,6 +64,22 @@ Only geometry - everything authored by hand is preserved: ] ``` +Entries without a `type` are OnShape-CAD robots (the default, implied by +the presence of `url`/`world_base_link`). A `"type"` field distinguishes +robots that don't go through the OnShape pipeline at all - for example the +drone, which is entirely hand-authored and flown via ArduPilot SITL (see +[Drone](../drone/)): + +```json +{ + "name": "drone", + "type": "ardupilot" +} +``` + +`cli/gazebo/launch.py` checks this field to decide whether a robot needs +special launch-time environment setup before handing off to `ros2 launch`. + ## Debugging raw OnShape output If the generated packages look wrong, inspect what OnShape actually produced before post-processing: diff --git a/gazebo/drone_bringup/CMakeLists.txt b/gazebo/drone_bringup/CMakeLists.txt new file mode 100644 index 0000000..f2ef172 --- /dev/null +++ b/gazebo/drone_bringup/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.14) +project(drone_bringup) + +find_package(ament_cmake REQUIRED) +find_package(drone_description REQUIRED) + +install( + DIRECTORY + launch/ + DESTINATION share/${PROJECT_NAME}/launch +) + +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + set(ament_cmake_copyright_FOUND TRUE) + set(ament_cmake_cpplint_FOUND TRUE) + ament_lint_auto_find_test_dependencies() +endif() + +ament_package() diff --git a/gazebo/drone_bringup/launch/drone.launch.py b/gazebo/drone_bringup/launch/drone.launch.py new file mode 100644 index 0000000..169194e --- /dev/null +++ b/gazebo/drone_bringup/launch/drone.launch.py @@ -0,0 +1,85 @@ +""" +Launch script for the ArduPilot SITL + Gazebo drone simulation. + +Unlike the URDF/ros2_control robots (arm, chassis), the quadcopter is an SDF +model embedded directly in the world file (see drone_description) and is +flown by ArduCopter SITL, not ROS 2 controllers - there's no robot_state_publisher, +controller spawners, or RViz here. +""" + +import os +import socket +import struct + +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, ExecuteProcess, TimerAction +from sim_common.launch_utils import err, gazebo_launch_actions, get_asset + +# UW campus, arbitrary - override by editing --home below for a real flying field. +SITL_HOME = "47.6521,-122.3037,0.0,0" + + +def _default_gateway() -> str: + """The default gateway of *this* container's actual network namespace. + + Docker's `extra_hosts: host.docker.internal:host-gateway` isn't reliably + scoped to whichever network a compose-created container lands on - it + can resolve to a different bridge's gateway than the one this container + is actually attached to. Reading /proc/net/route is the ground truth. + """ + with open("/proc/net/route") as f: + for line in f.readlines()[1:]: + fields = line.split() + if fields[1] == "00000000": # destination 0.0.0.0 = default route + return socket.inet_ntoa(struct.pack(" + + + drone_bringup + 0.0.0 + Launch files for the ArduPilot SITL + Gazebo drone simulation + Trickfire Robotics + Apache-2.0 + + drone_description + sim_common + + ament_cmake + + ament_lint_auto + + + ament_cmake + + diff --git a/gazebo/drone_description/CMakeLists.txt b/gazebo/drone_description/CMakeLists.txt new file mode 100644 index 0000000..8d50463 --- /dev/null +++ b/gazebo/drone_description/CMakeLists.txt @@ -0,0 +1,19 @@ +cmake_minimum_required(VERSION 3.14) +project(drone_description) + +find_package(ament_cmake REQUIRED) + +install( + DIRECTORY + worlds/ + DESTINATION share/${PROJECT_NAME}/worlds +) + +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + set(ament_cmake_copyright_FOUND TRUE) + set(ament_cmake_cpplint_FOUND TRUE) + ament_lint_auto_find_test_dependencies() +endif() + +ament_package() diff --git a/gazebo/drone_description/package.xml b/gazebo/drone_description/package.xml new file mode 100644 index 0000000..9b8252a --- /dev/null +++ b/gazebo/drone_description/package.xml @@ -0,0 +1,17 @@ + + + + drone_description + 0.0.0 + Drone simulation world for the ArduPilot SITL + Gazebo quadcopter sim + Trickfire Robotics + Apache-2.0 + + ament_cmake + + ament_lint_auto + + + ament_cmake + + diff --git a/gazebo/drone_description/worlds/drone.world.sdf b/gazebo/drone_description/worlds/drone.world.sdf new file mode 100644 index 0000000..a305652 --- /dev/null +++ b/gazebo/drone_description/worlds/drone.world.sdf @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + ogre2 + + + + + + + + + 0.004 + 1.0 + + + + + 0.4 0.4 0.4 + 0.7 0.7 0.7 + true + false + + + + + false + 0 0 10 0 0 0 + 0.8 0.8 0.8 1 + 0.2 0.2 0.2 1 + + 1000 + 0.9 + 0.01 + 0.001 + + -0.5 0.1 -0.9 + + + + + true + + + + + 0 0 1 + 100 100 + + + + + + + 0 0 1 + 100 100 + + + + 0.8 0.8 0.8 1 + 0.8 0.8 0.8 1 + 0.8 0.8 0.8 1 + + + + + + + + model://iris_with_ardupilot + drone + 0 0 0.2 0 0 0 + + + + diff --git a/gazebo/sim_common/sim_common/launch_utils.py b/gazebo/sim_common/sim_common/launch_utils.py index 09e3e2e..28ec126 100644 --- a/gazebo/sim_common/sim_common/launch_utils.py +++ b/gazebo/sim_common/sim_common/launch_utils.py @@ -74,13 +74,21 @@ def process_robot_description(urdf_file, controller_config): ).toxml() -def gazebo_launch_actions(world_file, gz_gui_config, gui_launch_arg="gui"): +def gazebo_launch_actions( + world_file, gz_gui_config, gui_launch_arg="gui", gui_delay=2.0, combined_gui=False +): """ Bring up the gz sim server, plus its GUI if the installed `gz` supports launching them as separate processes (falls back to the server's built-in GUI otherwise). + `gui_delay` gives the server a head start before the GUI client connects - + worlds with heavier meshes/plugins to load may need more than the default. + `combined_gui` forces server+GUI into a single process even when split mode + is available: worlds with rendering-dependent sensors (e.g. IMU/cameras) need + this, since a server running separately from the GUI has no render context of + its own for its Sensors system to use, and never produces any sensor data. Returns a list of launch actions to splice into a LaunchDescription. """ - use_split_gui = gz_supports_sim_command() + use_split_gui = gz_supports_sim_command() and not combined_gui gz_server_args = ( ["-r", "-s", world_file] if use_split_gui @@ -102,7 +110,7 @@ def gazebo_launch_actions(world_file, gz_gui_config, gui_launch_arg="gui"): output="screen", condition=IfCondition(LaunchConfiguration(gui_launch_arg)), ) - return [gz_server, TimerAction(period=2.0, actions=[gz_gui])] + return [gz_server, TimerAction(period=gui_delay, actions=[gz_gui])] def spawn_robot_node(robot_name, robot_desc): diff --git a/pyproject.toml b/pyproject.toml index 3844134..26a0580 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ version = "1.0.0" description = "A simulation environment manager" readme = "README.md" requires-python = ">=3.9" -dependencies = ["onshape-to-robot"] +dependencies = ["onshape-to-robot", "pyfqmr", "pypresence", "requests", "trimesh"] [project.optional-dependencies] reduce = ["open3d"] diff --git a/robots.json b/robots.json index e8eec2f..b74e52e 100644 --- a/robots.json +++ b/robots.json @@ -8,5 +8,9 @@ "name": "chassis", "url": "https://trickfire.onshape.com/documents/fc2c9f06a678801d77ce900d/w/3174861c9ae36101feac1882/e/51aaca767a20d2161d5ff539", "world_base_link": false + }, + { + "name": "drone", + "type": "ardupilot" } ] From 0de93b51fae0ea8c68d5d8d5637e72708d390d6b Mon Sep 17 00:00:00 2001 From: RadAlpaca11 Date: Mon, 3 Aug 2026 14:36:59 +0000 Subject: [PATCH 2/2] adding note about controller usage --- docs/gazebo/drone.mdx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/gazebo/drone.mdx b/docs/gazebo/drone.mdx index ecc62c3..e66ad91 100644 --- a/docs/gazebo/drone.mdx +++ b/docs/gazebo/drone.mdx @@ -46,6 +46,11 @@ This builds `drone_bringup`/`drone_description` like any other robot, then: ## Connecting QGroundControl +> [!NOTE] +> You can use a standard game controller, but the controls +> will not be identical to the Flysky controller. + + QGroundControl and the Flysky i6X controller run on your **host machine**, not inside the container: @@ -69,7 +74,7 @@ not inside the container: isn't needed, since SITL is the one initiating the connection outbound rather than QGroundControl connecting in. 6. In QGroundControl, go to **Application Settings → Joystick** to enable - and calibrate the Flysky i6X, then fly as normal + and calibrate the Flysky i6X, then fly as normal (Use mode 2 when calibrating) ## Troubleshooting