diff --git a/.devcontainer/host-env.sh b/.devcontainer/host-env.sh index 0fc0504..bbe13bb 100755 --- a/.devcontainer/host-env.sh +++ b/.devcontainer/host-env.sh @@ -22,6 +22,10 @@ gpu_available() { gpu_runtime="" gpu_available && gpu_runtime="nvidia" +# joystick passthrough +input_gid="" +[ "$uname_s" = "Linux" ] && input_gid="$(getent group input | cut -d: -f3 || true)" + write_display_env() { case "$uname_s" in Darwin) @@ -64,6 +68,7 @@ write_env() { echo "# --- host-detected (generated, do not edit) ---" write_display_env echo "SIM_GPU_RUNTIME=$gpu_runtime" + [ -n "$input_gid" ] && echo "INPUT_GID=$input_gid" if [ -n "$gpu_runtime" ]; then echo "NVIDIA_VISIBLE_DEVICES=all" diff --git a/.devcontainer/x_server.sh b/.devcontainer/x_server.sh index 411a7df..3281811 100755 --- a/.devcontainer/x_server.sh +++ b/.devcontainer/x_server.sh @@ -195,8 +195,19 @@ start_services() { log "[MAIN] All services started" } +fix_config_ownership() { + # docker-compose.yml only bind-mounts ~/.config/trickfire, so Docker auto-creates + # the ~/.config parent as root before the container's user takes over - leaving it + # unwritable by trickfire (breaks anything that wants to create its own directory + # there, e.g. QGroundControl's settings dir). + if [ -d "$HOME/.config" ] && [ "$(stat -c %U "$HOME/.config" 2>/dev/null)" != "$(whoami)" ]; then + sudo chown "$(whoami)" "$HOME/.config" + fi +} + main() { parse_args "$@" + fix_config_ownership try_display_passthrough if [[ $DISPLAY != :* ]]; then diff --git a/.github/workflows/vsg-chrono.yml b/.github/workflows/vsg-chrono.yml index 67358d3..2534f7e 100644 --- a/.github/workflows/vsg-chrono.yml +++ b/.github/workflows/vsg-chrono.yml @@ -1,4 +1,4 @@ -name: Build VSG + Chrono Base Image +name: Build VSG + Chrono Image on: push: diff --git a/.gitignore b/.gitignore index f27adf1..b59b1c6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,12 @@ !.vscode/launch.json !.vscode/extensions.json +# pixi +.pixi/ + +# native builds +.native/ + # python .mypy_cache __pycache__ @@ -19,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 tests cli/gazebo/create/tests diff --git a/.prettierrc b/.prettierrc index 86f917e..581d896 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,5 +1,13 @@ { "tabWidth": 4, "trailingComma": "none", - "printWidth": 100 + "printWidth": 100, + "overrides": [ + { + "files": "*.mdx", + "options": { + "proseWrap": "never" + } + } + ] } diff --git a/cli/cli.py b/cli/cli.py index e9bb82c..acf8cd2 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -123,6 +123,12 @@ def _launch_gazebo(robot: str, remaining: list[str]) -> None: p = argparse.ArgumentParser(prog="sim gazebo") p.add_argument("--build-only", action="store_true") p.add_argument("--no-build", action="store_true") + p.add_argument( + "-lq", + "--local-qgc", + action="store_true", + help="Don't launch QGroundControl in-container (drone only) - use your own local one", + ) args = p.parse_args(remaining) from .gazebo.launch import build_and_launch, in_pixi @@ -132,7 +138,9 @@ def _launch_gazebo(robot: str, remaining: list[str]) -> None: kwargs={"is_docker": not in_pixi(), "robot_name": robot}, daemon=True, ).start() - build_and_launch(robot, build_only=args.build_only, no_build=args.no_build) + build_and_launch( + robot, build_only=args.build_only, no_build=args.no_build, local_qgc=args.local_qgc + ) def _dispath_chrono(args) -> None: diff --git a/cli/gazebo/launch.py b/cli/gazebo/launch.py index 9c99913..1c80c4e 100644 --- a/cli/gazebo/launch.py +++ b/cli/gazebo/launch.py @@ -10,12 +10,42 @@ from datetime import datetime from pathlib import Path +from .. import paths from ..output import die, info, warn from ..paths import REPO_DIR, WORKSPACE_DIR _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_type: str | None, env: dict[str, str]) -> None: + """Point Gazebo at the plugin/models built into the Docker image""" + if robot_type != "ardupilot": + return + + if not paths.ARDUCOPTER_BIN.is_file() or not paths.ARDUPILOT_GAZEBO_PLUGIN.is_file(): + die("ArduPilot SITL + ardupilot_gazebo plugin are missing!") + + for var, plugin_paths in ( + ( + "GZ_SIM_RESOURCE_PATH", + [paths.ARDUPILOT_GAZEBO_DIR / "models", paths.ARDUPILOT_GAZEBO_DIR / "worlds"], + ), + ("GZ_SIM_SYSTEM_PLUGIN_PATH", [paths.ARDUPILOT_GAZEBO_BUILD_DIR]), + ): + existing = [p for p in env.get(var, "").split(":") if p] + additions = [str(p) for p in plugin_paths if str(p) not in existing] + env[var] = ":".join(additions + existing) + + env["ARDUCOPTER_BIN"] = str(paths.ARDUCOPTER_BIN) + + def in_pixi() -> bool: return bool(os.environ.get("PIXI_PROJECT_ROOT") or os.environ.get("CONDA_PREFIX")) @@ -33,14 +63,14 @@ def _validate_robot_layout(robot_name: str) -> tuple[str, str, str]: if not bringup_dir.is_dir(): die( f"Package '{bringup_pkg}' not found in {WORKSPACE_DIR}\n" - f" Expected directory: {bringup_dir}" + f"Expected directory: {bringup_dir}" ) description_dir = WORKSPACE_DIR / description_pkg if not description_dir.is_dir(): die( f"Package '{description_pkg}' not found in {WORKSPACE_DIR}\n" - f" Expected directory: {description_dir}" + f"Expected directory: {description_dir}" ) launch_file = bringup_dir / "launch" / launch_file_name @@ -75,7 +105,7 @@ def _run_logged_command( log_file.write(f"$ {command_display}\n") log_file.flush() - process = subprocess.Popen( # pylint: disable=consider-using-with + process = subprocess.Popen( command, cwd=cwd, env=env, @@ -117,9 +147,6 @@ def _x11_port_for(display: str) -> int: return 6000 -_DOCKER_DOCS = "https://docs.trickfirerobotics.com/simulations/setup/docker" - - def _diagnose_display(display: str, xdpyinfo_stderr: str) -> str: """Pin down which layer (local socket, DNS, TCP, or X11 auth) is broken.""" lines = [f"Cannot connect to display {display}", ""] @@ -134,26 +161,22 @@ def _diagnose_display(display: str, xdpyinfo_stderr: str) -> str: try: ip = socket.gethostbyname(host) - lines.append(f" [OK] DNS: '{host}' resolves to {ip}") + lines.append(f"[OK] DNS: '{host}' resolves to {ip}") except OSError as e: - lines += [f" [FAIL] DNS: '{host}' did not resolve ({e})", "", "Is Docker Desktop running?"] + lines += [f"[FAIL] DNS: '{host}' did not resolve ({e})", "", "Is Docker Desktop running?"] return "\n".join(lines) port = _x11_port_for(display) try: with socket.create_connection((host, port), timeout=3): - lines.append(f" [OK] TCP: port {port} on {host} is reachable") + lines.append(f"[OK] TCP: port {port} on {host} is reachable") except OSError as e: - lines += [ - f" [FAIL] TCP: could not connect to {host}:{port} ({e})", - f"See {_DOCKER_DOCS} for XQuartz/VcXsrv setup.", - ] + lines += f"[FAIL] TCP: could not connect to {host}:{port} ({e})" return "\n".join(lines) lines += [ - " [FAIL] X11: connected over TCP, but the X server rejected the session:", - f" {xdpyinfo_stderr.strip() or '(no error output captured)'}", - f"See {_DOCKER_DOCS} for X11 authorization (xhost) setup.", + "[FAIL] X11: connected over TCP, but the X server rejected the session:", + f"{xdpyinfo_stderr.strip() or '(no error output captured)'}", ] return "\n".join(lines) @@ -195,7 +218,7 @@ def _configure_virtualgl_rendering(env: dict[str, str]) -> list[str]: return [] if not shutil.which("vglrun"): - warn(f"vglrun not installed - GL rendering will fail. See {_DOCKER_DOCS}") + warn("vglrun not installed! GL rendering will fail.") return [] vgl_display = os.environ.get("VGL_DISPLAY", ":88") @@ -215,6 +238,42 @@ def _configure_virtualgl_rendering(env: dict[str, str]) -> list[str]: return ["vglrun"] +def _describe_display(env: dict[str, str], render_prefix: list[str]) -> str: + """Launch banner""" + display = env.get("DISPLAY", "?") + if in_pixi(): + return "native (pixi environment, no container)" + if env.get("FORCE_VNC"): + return f"VNC / noVNC, software rendering ({display})" + if render_prefix: + return f"VirtualGL ({env.get('VGL_DISPLAY', '?')} -> {display})" + return f"direct passthrough ({display})" + + +def _print_launch_banner( + *, + robot_name: str, + robot_type: str | None, + env: dict[str, str], + render_prefix: list[str], + log_path: Path, +) -> None: + simulator = "gazebo + ArduPilot SITL" if robot_type == "ardupilot" else "gazebo" + rows = [ + ("Robot", robot_name), + ("Simulator", simulator), + ("Display", _describe_display(env, render_prefix)), + ("Log", str(log_path)), + ] + + divider = "-" * 64 + label_width = max(len(label) for label, _ in rows) + 1 + print(divider) + for label, value in rows: + print(f"{label + ':':<{label_width}} {value}") + print(divider) + + def _configure_rendering(env: dict[str, str]) -> list[str]: """Pick how Gazebo/OGRE2 should get its GL context, based on where it's being displayed.""" if os.environ.get("FORCE_VNC"): @@ -256,7 +315,13 @@ def _setup_pixi_env() -> None: os.environ["QT_QPA_PLATFORM"] = "xcb" -def build_and_launch(robot_name: str, *, build_only: bool = False, no_build: bool = False) -> None: +def build_and_launch( + robot_name: str, + *, + build_only: bool = False, + no_build: bool = False, + local_qgc: bool = False, +) -> None: """Build the ROS 2 workspace and launch a robot simulation.""" if build_only and no_build: die("Use either --build-only or --no-build, not both") @@ -268,8 +333,10 @@ def build_and_launch(robot_name: str, *, build_only: bool = False, no_build: boo build = not no_build launch = not build_only + robot_type = _robot_type(robot_name) env = os.environ.copy() render_prefix = _configure_rendering(env) + _configure_ardupilot_env(robot_type, env) bringup_pkg, description_pkg, launch_file_name = _validate_robot_layout(robot_name) if build: @@ -283,7 +350,13 @@ def build_and_launch(robot_name: str, *, build_only: bool = False, no_build: boo log_dir.mkdir(parents=True, exist_ok=True) log_path = log_dir / f"{robot_name}-gazebo-{datetime.now():%Y-%m-%d_%H-%M}.log" # noqa: DTZ005 - info(f"Launching {robot_name} - log: {log_path}") + _print_launch_banner( + robot_name=robot_name, + robot_type=robot_type, + env=env, + render_prefix=render_prefix, + log_path=log_path, + ) setup_bash = WORKSPACE_DIR / "install" / "setup.bash" @@ -315,6 +388,9 @@ def build_and_launch(robot_name: str, *, build_only: bool = False, no_build: boo return info("Sourcing ROS2 environment and launching simulation...") + launch_args = "gui:=true" + if robot_type == "ardupilot" and local_qgc: + launch_args += " qgc:=false" launch_script = f""" set -e source install/setup.bash @@ -328,7 +404,7 @@ def build_and_launch(robot_name: str, *, build_only: bool = False, no_build: boo echo \" Expected: $SIM_WORLDS_SHARE/worlds\" fi -exec {" ".join(render_prefix)} ros2 launch \"{bringup_pkg}\" \"{launch_file_name}\" gui:=true +exec {" ".join(render_prefix)} ros2 launch \"{bringup_pkg}\" \"{launch_file_name}\" {launch_args} """.strip() try: diff --git a/cli/paths.py b/cli/paths.py index 5471d47..408aa2d 100644 --- a/cli/paths.py +++ b/cli/paths.py @@ -33,6 +33,13 @@ 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 +ARDUPILOT_DIR = Path("/opt/ardupilot") +ARDUPILOT_GAZEBO_DIR = Path("/opt/ardupilot_gazebo") +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" + 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/Dockerfile b/docker/Dockerfile index 935c22e..0fd39e6 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -159,6 +159,86 @@ RUN ARCH="$(dpkg --print-architecture)" && \ dpkg -i /tmp/virtualgl.deb && \ rm -f /tmp/virtualgl.deb +# ---------------------------------------------------------------------------- # +# ARDUPILOT # +# ---------------------------------------------------------------------------- # + +RUN echo "---------------- ARDUPILOT -----------------" + +ARG ARDUPILOT_REF=Copter-4.3.7 +ARG ARDUPILOT_WAF_COMMIT=35eadbb64e2052099a853b571e507c33032b392c + +ENV ARDUPILOT_DIR=/opt/ardupilot +ENV ARDUPILOT_GAZEBO_DIR=/opt/ardupilot_gazebo + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + build-essential ccache gawk python3-dev python-is-python3 libtool-bin rsync \ + libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev + +RUN --mount=type=cache,target=/root/.cache/pip \ + pip3 install --break-system-packages \ + future lxml pymavlink pyserial ptyprocess pexpect catkin_pkg \ + "setuptools<81" "empy==3.3.4" + +RUN usermod -a -G dialout trickfire + +RUN git clone --recurse-submodules --branch "${ARDUPILOT_REF}" \ + https://github.com/ArduPilot/ardupilot.git "${ARDUPILOT_DIR}" + +RUN cd "${ARDUPILOT_DIR}/modules/waf" && \ + git fetch --depth 1 origin "${ARDUPILOT_WAF_COMMIT}" && \ + git checkout "${ARDUPILOT_WAF_COMMIT}" + +RUN sed -i '/#include /a #include ' \ + "${ARDUPILOT_DIR}/libraries/AP_HAL_SITL/CANSocketIface.cpp" + +RUN cd "${ARDUPILOT_DIR}" && \ + ./waf configure --board sitl && \ + ./waf copter + +RUN git clone https://github.com/ArduPilot/ardupilot_gazebo.git "${ARDUPILOT_GAZEBO_DIR}" + +RUN cd "${ARDUPILOT_GAZEBO_DIR}" && \ + GZ_VERSION=harmonic cmake -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo && \ + cmake --build build --parallel "${BUILD_JOBS:-$(nproc)}" + +RUN chown -R trickfire:trickfire "${ARDUPILOT_DIR}" "${ARDUPILOT_GAZEBO_DIR}" + +# ---------------------------------------------------------------------------- # +# QGROUNDCONTROL # +# ---------------------------------------------------------------------------- # + +RUN echo "-------------- QGROUNDCONTROL ---------------" + +ARG QGC_VERSION=v5.1.0 +RUN ARCH="$(dpkg --print-architecture)" && \ + case "${ARCH}" in \ + arm64) QGC_FILE=QGroundControl-aarch64.AppImage; \ + QGC_SHA256=a782d9ed66bae3345ad32752bb18762d194cfadfe2b1d6359bfc31ef9a99989e ;; \ + amd64) QGC_FILE=QGroundControl-x86_64.AppImage; \ + QGC_SHA256=6844d46dadc7228a164662f66be9e02bc763b6d993f574083a265b26f03c307f ;; \ + *) echo "No QGroundControl build for architecture '${ARCH}'" >&2; exit 1 ;; \ + esac && \ + curl -L -s -f -o /tmp/qgc.AppImage \ + "https://github.com/mavlink/qgroundcontrol/releases/download/${QGC_VERSION}/${QGC_FILE}" && \ + echo "${QGC_SHA256} */tmp/qgc.AppImage" | sha256sum --strict --check && \ + chmod +x /tmp/qgc.AppImage && \ + cd /opt && /tmp/qgc.AppImage --appimage-extract && \ + mv squashfs-root /opt/qgroundcontrol && \ + rm -f /tmp/qgc.AppImage + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + libxcb1 libxcb-cursor0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 \ + libxcb-randr0 libxcb-render0 libxcb-render-util0 libxcb-shape0 libxcb-shm0 \ + libxcb-sync1 libxcb-util1 libxcb-xfixes0 libxcb-xinerama0 libxcb-xkb1 \ + libx11-xcb1 libdbus-1-3 libpulse0 libsdl2-2.0-0 libspeechd2 libpipewire-0.3-0t64 + +RUN ln -s /opt/qgroundcontrol/AppRun /usr/local/bin/qgroundcontrol + # ---------------------------------------------------------------------------- # # DEV TOOLING # # ---------------------------------------------------------------------------- # diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index bdbac3e..6d4bfa0 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -16,6 +16,14 @@ services: runtime: "${SIM_GPU_RUNTIME:-}" + # Lets QGroundControl (running in-container) read the Flysky i6X receiver. + # /dev/input/eventN is host-owned root:input mode 660 - trickfire needs to be + # in a group with the same numeric GID as the host's "input" group to read it. + # INPUT_GID is auto-detected by .devcontainer/host-env.sh; the fallback only + # matters if docker/.env wasn't regenerated (host-env.sh's usual Ubuntu GID). + group_add: + - "${INPUT_GID:-994}" + environment: VNC_PORT: "${VNC_PORT:?VNC_PORT must be set, see docker/.env}" NOVNC_PORT: "${NOVNC_PORT:?NOVNC_PORT must be set, see docker/.env}" @@ -30,6 +38,7 @@ services: GDK_DPI_SCALE: "${GDK_DPI_SCALE:-}" FORCE_VNC: "${FORCE_VNC:-}" DISPLAY: "${DISPLAY:-}" + XAUTHORITY: "${XAUTHORITY:-}" VGL_DISPLAY: "${VGL_DISPLAY:-}" VGL_COMPRESS: "${VGL_COMPRESS:-}" NVIDIA_VISIBLE_DEVICES: "${NVIDIA_VISIBLE_DEVICES:-}" @@ -50,6 +59,11 @@ services: target: /tmp/.X11-unix bind: create_host_path: true + - type: bind + source: /dev/input + target: /dev/input + bind: + create_host_path: true - type: bind source: ${HOME}/.config/trickfire target: /home/trickfire/.config/trickfire diff --git a/docs.config.json b/docs.config.json index 421a11b..304e575 100644 --- a/docs.config.json +++ b/docs.config.json @@ -24,7 +24,8 @@ "label": "Adding a New Robot", "slug": "gazebo/adding-robots" }, - { "label": "Architecture", "slug": "gazebo/architecture" } + { "label": "Architecture", "slug": "gazebo/architecture" }, + { "label": "Drone (ArduPilot SITL)", "slug": "gazebo/drone" } ] }, { diff --git a/docs/gazebo/architecture.mdx b/docs/gazebo/architecture.mdx index 095d0b5..6122dc3 100644 --- a/docs/gazebo/architecture.mdx +++ b/docs/gazebo/architecture.mdx @@ -72,6 +72,17 @@ The repo-root `robots.json` is the CLI's registry of available robots. For each New entries are added automatically by `sim gazebo create` - see [Adding a New Robot](./adding-robots). +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`. + ## Building `sim gazebo ` (see [Running Gazebo](./gazebo)) drives colcon for you, under the hood it uses a command like this: diff --git a/docs/gazebo/drone.mdx b/docs/gazebo/drone.mdx new file mode 100644 index 0000000..e6c30e7 --- /dev/null +++ b/docs/gazebo/drone.mdx @@ -0,0 +1,73 @@ +--- +title: Drone +description: Drone simulation using Ardupilot +--- + +`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. ArduPilot SITL is built from source and QGroundControl is downloaded as a prebuilt AppImage, both baked into the Docker image (see `docker/Dockerfile`), so there's no one-time setup step - `sim gazebo drone` is enough. + +## Running the simulation + +```bash +sim gazebo drone +``` + +This builds `drone_bringup`/`drone_description` like any other robot, then: + + + + Starts Gazebo Harmonic with a world containing the quadcopter model (`ardupilot_gazebo`'s + stock `iris_with_ardupilot` model, wired to the `ArduPilotPlugin`) + + + Starts ArduCopter SITL, connected to the Gazebo plugin via its JSON backend, with its + primary MAVLink output on UDP port 14550 + + Starts QGroundControl (`qgroundcontrol` on `PATH`) automatically, alongside SITL + + +Pass `-lq`/`--local-qgc` to skip step 3 if you'd rather run your own QGroundControl (e.g. on your host) instead of the in-container one: + +```bash title="Terminal" +sim gazebo drone -lq +``` + +## Flying with QGroundControl + +By default QGroundControl runs **inside the container**, alongside SITL, so it auto-connects over `127.0.0.1:14550` with no firewall rules or port forwarding to configure - there's nothing host-side to set up for the MAVLink link itself. + +The Flysky i6X controller is different: it's a physical USB device, so it has to be plugged into whatever machine is running the container, and the container needs permission to read it. + + + You can use a standard game controller, but the controls will not be identical to the Flysky + controller. + + + + Plug the Flysky i6X USB receiver into your host + + `docker-compose.yml` bind-mounts `/dev/input` into the container and joins it to your host's + `input` group (`INPUT_GID`, auto-detected by `.devcontainer/host-env.sh` into `docker/.env` + - re-run that script if the container can't see the device) + + Start `sim gazebo drone` - QGroundControl launches automatically + + In QGroundControl, go to **Application Settings → Joystick** to enable and calibrate the + Flysky i6X, then fly as normal (Use mode 2 when calibrating) + + + +## Troubleshooting + + + + Check that `/dev/input` has device nodes for it (`ls -la /dev/input/by-id/` on the host) and + that `INPUT_GID` in `docker/.env` matches your host's `input` group (`getent group input`) - + a stale `docker/.env` from before the receiver was set up is the most common cause; + regenerate it with `.devcontainer/host-env.sh` and recreate the container. + + + `ardupilot_gazebo`'s `models/` and `worlds/` directories are resolved via + `GZ_SIM_RESOURCE_PATH`, which `sim gazebo drone` points at `/opt/ardupilot_gazebo` (built + into the image). If that's missing, rebuild the image. + + 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..f01ab05 --- /dev/null +++ b/gazebo/drone_bringup/launch/drone.launch.py @@ -0,0 +1,93 @@ +""" +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 + +from launch import LaunchDescription +from launch.actions import DeclareLaunchArgument, ExecuteProcess, TimerAction +from launch.conditions import IfCondition +from launch.substitutions import LaunchConfiguration +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 generate_launch_description(): + """ROS launch method, must have this name""" + + world_file = get_asset("drone_description", "worlds", "drone.world.sdf") + gz_gui_config = get_asset("sim_worlds", "gui", "gui.config") + + arducopter_bin = os.environ.get("ARDUCOPTER_BIN") + if not arducopter_bin or not os.path.isfile(arducopter_bin): + err("ARDUCOPTER_BIN not set or missing - ArduPilot SITL is not built into this image.") + + # ------------------------------------------------ + # ArduCopter SITL + # ------------------------------------------------ + # --model JSON talks to the ardupilot_gazebo plugin's JSON backend on the + # model's configured fdm ports. ArduPilot's serial-device parser has no + # listening/server UDP mode - only udpclient: (see AP_HAL_SITL/UARTDriver.cpp) - + # so SITL sends telemetry out to the GCS's address instead of waiting for + # one. QGroundControl runs in this same container, so that's just loopback. + sitl = ExecuteProcess( + cmd=[ + arducopter_bin, + "--model", + "JSON", + "--home", + SITL_HOME, + "--speedup", + "1", + "-I0", + "--serial0=udpclient:127.0.0.1:14550", + ], + output="screen", + ) + + # ------------------------------------------------ + # QGroundControl + # ------------------------------------------------ + qgc = ExecuteProcess( + cmd=["qgroundcontrol"], + output="screen", + condition=IfCondition(LaunchConfiguration("qgc")), + additional_env={ + # QGC's single-instance lock file lives under $TMPDIR (QStandardPaths:: + # TempLocation). docker-compose.yml bind-mounts the host's XDG_RUNTIME_DIR + # read-only at /run/host-runtime, and some shells set TMPDIR from that - if + # this process inherits it, the lock file can't be created and QGC misreports + # that as "a second instance is already running" instead of a permission error. + "TMPDIR": "/tmp", + # Gazebo's GUI renders fine over X11/XWayland passthrough (DISPLAY=:0), but + # WAYLAND_DISPLAY is also set in this container (from the host compositor), + # and Qt prefers wayland when it's present. QGC can't get an EGL context over + # it here and aborts (SIGABRT) - pin it to the platform that actually works. + "QT_QPA_PLATFORM": "xcb", + }, + ) + + return LaunchDescription( + [ + *gazebo_launch_actions(world_file, gz_gui_config, combined_gui=True), + DeclareLaunchArgument("gui", default_value="true", description="Open Gazebo GUI."), + DeclareLaunchArgument( + "qgc", + default_value="true", + description="Launch QGroundControl in-container. Pass qgc:=false (sim gazebo " + "drone -lq) to use your own QGroundControl instead.", + ), + # SITL's JSON backend retries aggressively (and noisily) until the Gazebo + # plugin is up, which competes for CPU with the GUI's mesh import right + # when it needs it most. Give Gazebo (server + GUI + meshes) a real head + # start before SITL/QGroundControl starts hammering it. + TimerAction(period=15.0, actions=[sitl, qgc]), + ] + ) diff --git a/gazebo/drone_bringup/package.xml b/gazebo/drone_bringup/package.xml new file mode 100644 index 0000000..7b2cab7 --- /dev/null +++ b/gazebo/drone_bringup/package.xml @@ -0,0 +1,20 @@ + + + + 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/pixi.lock b/pixi.lock index f6026a1..476f2b9 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,27 +1,9 @@ version: 7 platforms: - name: linux-64 - virtual-packages: - - __unix=0=0 - - __linux=4.18 - - __glibc=2.28 - - __archspec=0=x86_64 - name: linux-aarch64 - virtual-packages: - - __unix=0=0 - - __linux=4.18 - - __glibc=2.28 - - __archspec=0=aarch64 - name: osx-64 - virtual-packages: - - __unix=0=0 - - __osx=13.0 - - __archspec=0=x86_64 - name: osx-arm64 - virtual-packages: - - __unix=0=0 - - __osx=13.0 - - __archspec=0=m1 environments: default: channels: @@ -968,8 +950,8 @@ environments: - conda: https://conda.anaconda.org/robostack-jazzy/linux-64/ros-jazzy-zstd-image-transport-4.0.6-np2py312h2ed9cc7_16.conda - conda: https://conda.anaconda.org/robostack-jazzy/linux-64/ros-jazzy-zstd-vendor-0.26.9-np2py312h2ed9cc7_16.conda - conda: https://conda.anaconda.org/robostack-jazzy/linux-64/ros2-distro-mutex-0.14.0-jazzy_16.conda - - pypi: ./ - - pypi: ./gazebo/sim_common + - pypi: . + - pypi: gazebo/sim_common - pypi: https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/05/98/716a473cfb24750858ddd5d14e6527539dd206583a46408d08eeb2844a75/trimesh-4.12.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0a/62/fa3452cc1734a05c16fd143b5d0ff3cddfee836e7222a624477ca08eadae/pyfqmr-0.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl @@ -1919,8 +1901,8 @@ environments: - conda: https://conda.anaconda.org/robostack-jazzy/linux-aarch64/ros-jazzy-zstd-image-transport-4.0.6-np2py312h61f2ce4_16.conda - conda: https://conda.anaconda.org/robostack-jazzy/linux-aarch64/ros-jazzy-zstd-vendor-0.26.9-np2py312h61f2ce4_16.conda - conda: https://conda.anaconda.org/robostack-jazzy/linux-aarch64/ros2-distro-mutex-0.14.0-jazzy_16.conda - - pypi: ./ - - pypi: ./gazebo/sim_common + - pypi: . + - pypi: gazebo/sim_common - pypi: https://files.pythonhosted.org/packages/05/98/716a473cfb24750858ddd5d14e6527539dd206583a46408d08eeb2844a75/trimesh-4.12.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/39/5a/8ba33c034a5031a2ca3fb6998b5d00683d923347f472890dfb671a9b38e6/onshape_to_robot-1.8.2.tar.gz @@ -2798,8 +2780,8 @@ environments: - conda: https://conda.anaconda.org/robostack-jazzy/osx-64/ros-jazzy-zstd-image-transport-4.0.6-np2py312hb847498_16.conda - conda: https://conda.anaconda.org/robostack-jazzy/osx-64/ros-jazzy-zstd-vendor-0.26.9-np2py312hb847498_16.conda - conda: https://conda.anaconda.org/robostack-jazzy/osx-64/ros2-distro-mutex-0.14.0-jazzy_16.conda - - pypi: ./ - - pypi: ./gazebo/sim_common + - pypi: . + - pypi: gazebo/sim_common - pypi: https://files.pythonhosted.org/packages/05/98/716a473cfb24750858ddd5d14e6527539dd206583a46408d08eeb2844a75/trimesh-4.12.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/39/5a/8ba33c034a5031a2ca3fb6998b5d00683d923347f472890dfb671a9b38e6/onshape_to_robot-1.8.2.tar.gz @@ -3677,8 +3659,8 @@ environments: - conda: https://conda.anaconda.org/robostack-jazzy/osx-arm64/ros-jazzy-zstd-image-transport-4.0.6-np2py312h50b1e4c_16.conda - conda: https://conda.anaconda.org/robostack-jazzy/osx-arm64/ros-jazzy-zstd-vendor-0.26.9-np2py312h50b1e4c_16.conda - conda: https://conda.anaconda.org/robostack-jazzy/osx-arm64/ros2-distro-mutex-0.14.0-jazzy_16.conda - - pypi: ./ - - pypi: ./gazebo/sim_common + - pypi: . + - pypi: gazebo/sim_common - pypi: https://files.pythonhosted.org/packages/05/98/716a473cfb24750858ddd5d14e6527539dd206583a46408d08eeb2844a75/trimesh-4.12.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/31/84/4cff76b969c419be71eb4dd5804db48423bb7429fb0386a4840d439e26fb/pyfqmr-0.5.0-cp312-cp312-macosx_11_0_arm64.whl @@ -62114,13 +62096,17 @@ packages: license: BSD-3-Clause size: 2310 timestamp: 1771181386340 -- pypi: ./ +- pypi: . name: sim requires_dist: - onshape-to-robot + - pyfqmr + - pypresence + - requests + - trimesh - open3d ; extra == 'reduce' requires_python: '>=3.9' -- pypi: ./gazebo/sim_common +- pypi: gazebo/sim_common name: sim-common requires_dist: - setuptools 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" } ]