Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .devcontainer/host-env.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
97 changes: 97 additions & 0 deletions .github/workflows/qgroundcontrol.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/vsg-chrono.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Build VSG + Chrono Base Image
name: Build VSG + Chrono Image

on:
push:
Expand Down
13 changes: 13 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
!.vscode/launch.json
!.vscode/extensions.json

# pixi
.pixi/

# native builds
.native/

# python
.mypy_cache
__pycache__
Expand All @@ -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

Expand Down
103 changes: 85 additions & 18 deletions cli/gazebo/launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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}", ""]
Expand All @@ -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)

Expand Down Expand Up @@ -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")
Expand All @@ -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"):
Expand Down Expand Up @@ -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:
Expand All @@ -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"

Expand Down
7 changes: 7 additions & 0 deletions cli/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading