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/.github/workflows/qgroundcontrol.yml b/.github/workflows/qgroundcontrol.yml new file mode 100644 index 0000000..236d5b8 --- /dev/null +++ b/.github/workflows/qgroundcontrol.yml @@ -0,0 +1,97 @@ +name: Build QGroundControl Image + +on: + push: + branches: [main] + paths: + - "docker/qgroundcontrol.Dockerfile" + workflow_dispatch: + +env: + IMAGE_NAME: ghcr.io/trickfirerobotics/simulations-qgroundcontrol + +jobs: + build: + strategy: + matrix: + include: + - platform: amd64 + runner: ubuntu-24.04 + - platform: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v7 + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build and push by digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: docker/qgroundcontrol.Dockerfile + platforms: linux/${{ matrix.platform }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: type=gha,scope=qgroundcontrol-${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=qgroundcontrol-${{ matrix.platform }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.build.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest + uses: actions/upload-artifact@v7 + with: + name: digests-${{ matrix.platform }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + merge: + needs: build + runs-on: ubuntu-24.04 + permissions: + packages: write + steps: + - name: Download digests + uses: actions/download-artifact@v8 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Log in to GHCR + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Create manifest list and push + working-directory: /tmp/digests + run: | + docker buildx imagetools create \ + -t ${{ env.IMAGE_NAME }}:latest \ + -t ${{ env.IMAGE_NAME }}:${{ github.sha }} \ + $(printf '${{ env.IMAGE_NAME }}@sha256:%s ' *) + + - name: Inspect image + run: docker buildx imagetools inspect ${{ env.IMAGE_NAME }}:latest 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/cli/gazebo/launch.py b/cli/gazebo/launch.py index 9c99913..8f9c6bd 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"): @@ -268,8 +327,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 +344,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" 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..a32251d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,6 +1,9 @@ ARG VSG_CHRONO_TAG=latest FROM ghcr.io/trickfirerobotics/simulations-vsg-chrono:${VSG_CHRONO_TAG} AS vsg-chrono +ARG QGROUNDCONTROL_TAG=latest +FROM ghcr.io/trickfirerobotics/simulations-qgroundcontrol:${QGROUNDCONTROL_TAG} AS qgroundcontrol + FROM ubuntu:24.04 AS sim ARG DEBIAN_FRONTEND=noninteractive @@ -159,6 +162,71 @@ 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 ---------------" + +COPY --from=qgroundcontrol /opt/qgroundcontrol /opt/qgroundcontrol + +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 + +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/docker/qgroundcontrol.Dockerfile b/docker/qgroundcontrol.Dockerfile new file mode 100644 index 0000000..c510910 --- /dev/null +++ b/docker/qgroundcontrol.Dockerfile @@ -0,0 +1,59 @@ +# Prebuilt QGroundControl base image, built from source. + +FROM ubuntu:24.04 + +ARG DEBIAN_FRONTEND=noninteractive +ARG BUILD_JOBS +ARG QGC_REF=v5.1.0 +ARG TARGETARCH + +ENV LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8 + +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 \ + ca-certificates git python3 curl locales && \ + sed -i 's/^# *\(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen && \ + locale-gen && \ + update-locale LANG=en_US.UTF-8 + +RUN git clone -c advice.detachedHead=false --depth 1 --recurse-submodules \ + --branch "${QGC_REF}" \ + https://github.com/mavlink/qgroundcontrol.git /opt/qgc-src + +RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh && \ + uv venv --seed /opt/qgc-venv --python python3 && \ + VIRTUAL_ENV=/opt/qgc-venv uv sync \ + --project /opt/qgc-src/tools --extra scripts --extra qt \ + --no-install-project --frozen --active + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) qt_host=linux; qt_arch=linux_gcc_64; qt_dir=gcc_64 ;; \ + arm64) qt_host=linux_arm64; qt_arch=linux_gcc_arm64; qt_dir=gcc_arm64 ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + qt_version=$(python3 /opt/qgc-src/tools/setup/read_config.py --get qt.version); \ + qt_modules=$(python3 /opt/qgc-src/tools/setup/read_config.py --get qt.modules); \ + mkdir -p /opt/Qt; \ + /opt/qgc-venv/bin/aqt install-qt "${qt_host}" desktop "${qt_version}" "${qt_arch}" \ + -O /opt/Qt -m ${qt_modules}; \ + ln -s "/opt/Qt/${qt_version}/${qt_dir}" /opt/Qt/current + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + python3 /opt/qgc-src/tools/setup/install_dependencies --platform debian + +RUN /opt/Qt/current/bin/qt-cmake -S /opt/qgc-src -B /opt/qgc-src/build -G Ninja \ + -DCMAKE_BUILD_TYPE=Release \ + -DPython3_EXECUTABLE=/opt/qgc-venv/bin/python && \ + cmake --build /opt/qgc-src/build --target all --parallel "${BUILD_JOBS:-$(nproc)}" && \ + cmake --install /opt/qgc-src/build --config Release + +# extract AppImage +RUN set -eux; \ + appimage=$(find /opt/qgc-src/build -maxdepth 1 -name 'QGroundControl-*.AppImage'); \ + chmod +x "${appimage}"; \ + cd /opt && "${appimage}" --appimage-extract; \ + mv squashfs-root /opt/qgroundcontrol; \ + rm -rf /opt/qgc-src /opt/qgc-venv /opt/Qt 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..c6a28d9 --- /dev/null +++ b/docs/gazebo/drone.mdx @@ -0,0 +1,70 @@ +--- +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. ArduPilot SITL, `ardupilot_gazebo`, and +QGroundControl are all built from source into the Docker image (see +`docker/Dockerfile` and `docker/qgroundcontrol.Dockerfile`), so there's no +one-time setup step - `sim gazebo drone` is enough. + +## 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 +3. QGroundControl is available inside the container as `qgroundcontrol` + (run it from a shell in the container, or add it as its own launch + action if you want it to start automatically) + +## Flying with QGroundControl + +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: + +> [!NOTE] +> You can use a standard game controller, but the controls +> will not be identical to the Flysky controller. + +1. Plug the Flysky i6X USB receiver into your host +2. `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) +3. Start `sim gazebo drone`, then run `qgroundcontrol` in a shell in the + container +4. In QGroundControl, go to **Application Settings → Joystick** to enable + and calibrate the Flysky i6X, then fly as normal (Use mode 2 when calibrating) + +## Troubleshooting + +**QGroundControl can't see the joystick:** 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. + +**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 `/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..9518785 --- /dev/null +++ b/gazebo/drone_bringup/launch/drone.launch.py @@ -0,0 +1,66 @@ +""" +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 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.\n" + " Run: sim gazebo setup-drone" + ) + + # ------------------------------------------------ + # 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", + ) + + return LaunchDescription( + [ + *gazebo_launch_actions(world_file, gz_gui_config, combined_gui=True), + DeclareLaunchArgument("gui", default_value="true", description="Open Gazebo GUI."), + # 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 starts hammering it. + TimerAction(period=15.0, actions=[sitl]), + ] + ) 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" } ]