Skip to content
Merged
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
2 changes: 2 additions & 0 deletions docs/developer_lucy_packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ Standard path: `./install.sh` then `python3 Lucy.py`.

**Wayland:** RViz/Gazebo may need `xhost +local:` or an X11 session.

**Jetson (Orin / AGX / Nano / Thor):** Pixi ships conda Mesa/GLVND ahead of the Tegra driver, which breaks Gazebo ogre2 (missing plugin, EGL segfaults). [`scripts/nix_gl_env.sh`](../scripts/nix_gl_env.sh) auto-detects Jetson and prepends `/usr/lib/aarch64-linux-gnu/nvidia` + `tegra`, sets the NVIDIA EGL vendor; [`scripts/gz_rendering_env.sh`](../scripts/gz_rendering_env.sh) discovers ogre2 plugin/resource paths on Linux. Use **`sim-headless`** or Control Center **Simulator → … headless** for camera sim without a display; GUI **`sim`** needs X11/Wayland and keeps your session `XDG_RUNTIME_DIR`. Do not set `LUCY_NIX_GL=0` on Jetson. Override: `LUCY_GPU_MODE=jetson`.

**NixOS:** Pixi/RoboStack needs host GL libraries prepended **and** Mesa EGL — both are applied by [`scripts/nix_gl_env.sh`](../scripts/nix_gl_env.sh) (launcher and `pixi run sim*`). Install **`nixGLIntel`** (or another nixGL wrapper) on PATH, or rely on the `/run/opengl-driver/lib` fallback. **Do not set `LUCY_NIX_GL=0`** — EGL/`GZ_IP` alone is not enough; sim will hang on "requesting world names". Optional overrides: `LUCY_NIX_GL_WRAPPER`, `__EGL_VENDOR_LIBRARY_FILENAMES`, `GZ_IP` — see `.env.example`.

### macOS
Expand Down
4 changes: 3 additions & 1 deletion docs/pixi_setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ See [`docs/developer_lucy_packages.md`](developer_lucy_packages.md#launch) for C

Colcon uses `--symlink-install` on Linux/macOS and `--merge-install` on Windows (`pixi.toml` `[feature.build]` tasks).

Activation scripts (`install/setup.bash` or `install/setup.bat`) are wired via `[target.unix.activation]` / `[target.win.activation]` in `pixi.toml`, so `pixi run` and `pixi shell` automatically overlay the workspace. `GZ_SIM_SYSTEM_PLUGIN_PATH` points at conda Gazebo plugins (`lib` on Unix, `Library/bin` on Windows).
Activation scripts (`install/setup.bash` or `install/setup.bat`) are wired via `[target.unix.activation]` / `[target.win.activation]` in `pixi.toml`, so `pixi run` and `pixi shell` automatically overlay the workspace. `GZ_SIM_SYSTEM_PLUGIN_PATH` points at conda Gazebo plugins. On **Linux**, `scripts/gz_rendering_env.sh` discovers ogre2 plugin/resource paths (version-agnostic).

On **Jetson**, also source [`scripts/nix_gl_env.sh`](../scripts/nix_gl_env.sh) (automatic for `pixi run sim*`, `rqt`, `lucy-cli`, and the launcher) so Tegra/NVIDIA GL libraries precede conda Mesa. Jetson detection is implemented in [`launcher/platform.py`](../launcher/platform.py); bash scripts source [`scripts/detect_jetson.sh`](../scripts/detect_jetson.sh).

## Lock refresh checklist

Expand Down
5 changes: 5 additions & 0 deletions launcher/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ def _norm_path(s: str) -> str:
"LD_LIBRARY_PATH",
"LD_PRELOAD",
"__EGL_VENDOR_LIBRARY_FILENAMES",
"__GLX_VENDOR_LIBRARY_NAME",
"GZ_IP",
"LUCY_GPU_MODE",
"LUCY_HEADLESS_RUNTIME_DIR",
"GZ_RENDERING_PLUGIN_PATH",
"GZ_RENDERING_RESOURCE_PATH",
)

# Back-compat aliases used by tests and internal modules.
Expand Down
39 changes: 38 additions & 1 deletion launcher/platform.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"""Platform-specific process introspection (Linux, macOS, Windows)."""
"""Platform helpers: process introspection and Jetson GPU detection."""

import os
import subprocess
import sys
from pathlib import Path

from .constants import (
LUCY_WS_MARKER,
Expand Down Expand Up @@ -179,3 +180,39 @@ def process_workspace_markers(pid: int) -> bool:

def _process_workspace_markers(pid: int) -> bool:
return process_workspace_markers(pid)


_TEGRA_RELEASE = Path("/etc/nv_tegra_release")
_DEVICE_TREE_MODEL = Path("/proc/device-tree/model")
_DEFAULT_HEADLESS_RUNTIME_DIR = "/tmp/runtime-root"


def is_jetson() -> bool:
"""True on NVIDIA Jetson / Tegra hosts (or when LUCY_GPU_MODE forces it)."""
mode = os.environ.get("LUCY_GPU_MODE", "")
if mode in ("jetson", "tegra"):
return True
if mode in ("0", "false", "no", "off", "disable"):
return False
if _TEGRA_RELEASE.is_file():
return True
if _DEVICE_TREE_MODEL.is_file():
try:
model = _DEVICE_TREE_MODEL.read_text(encoding="utf-8", errors="replace")
model = model.replace("\0", "").lower()
return "jetson" in model or "tegra" in model
except OSError:
return False
return False


def headless_runtime_dir() -> str:
return os.environ.get("LUCY_HEADLESS_RUNTIME_DIR", _DEFAULT_HEADLESS_RUNTIME_DIR)


def ensure_headless_runtime_dir() -> str:
runtime = Path(headless_runtime_dir())
runtime.mkdir(parents=True, exist_ok=True)
runtime.chmod(0o700)
os.environ["XDG_RUNTIME_DIR"] = str(runtime)
return str(runtime)
2 changes: 1 addition & 1 deletion launcher/shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def _gui_env_exports() -> str:


def _nix_gl_source() -> str:
"""Source hook for NixOS: prepend host Mesa before Pixi conda GL (no-op elsewhere)."""
"""Source hook for host GL (Jetson Tegra / NixOS Mesa) before Pixi conda GL."""
if os.environ.get("LUCY_NIX_GL", "auto").lower() in ("0", "false", "no", "off"):
return ""
if not NIX_GL_ENV_SCRIPT.is_file():
Expand Down
9 changes: 7 additions & 2 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ rviz = { cmd = "bash scripts/pixi_lucy_launch.sh rviz:=true" }

# Standalone components (run in separate terminals alongside core / sim-*)
control-panel = { cmd = "yarn dev", cwd = "src/lucy_control_panel" }
rqt = "rqt"
lucy-cli = "ros2 run lucy_cli tui"
rqt = { cmd = "bash scripts/pixi_gui_launch.sh rqt" }
lucy-cli = { cmd = "bash scripts/pixi_gui_launch.sh ros2 run lucy_cli tui" }

# --- platform-specific host packages ---
[target.linux.dependencies]
Expand All @@ -108,6 +108,11 @@ gstreamer = "*"
scripts = ["install/setup.bash"]
env = { GZ_SIM_SYSTEM_PLUGIN_PATH = "$CONDA_PREFIX/lib" }

# Linux overrides unix activation (does not merge) — repeat setup.bash here.
[target.linux.activation]
scripts = ["install/setup.bash", "scripts/gz_rendering_env.sh"]
env = { GZ_SIM_SYSTEM_PLUGIN_PATH = "$CONDA_PREFIX/lib" }

[target.win.activation]
scripts = ["install/setup.bat"]
env = { GZ_SIM_SYSTEM_PLUGIN_PATH = "$CONDA_PREFIX/Library/bin" }
Expand Down
34 changes: 34 additions & 0 deletions scripts/detect_jetson.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Bash helpers for Jetson detection. Logic lives in launcher/platform.py.

_LUCY_WS_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"

_lucy_jetson_python() {
if [[ -x "${_LUCY_WS_ROOT}/.pixi/envs/default/bin/python" ]]; then
printf '%s\n' "${_LUCY_WS_ROOT}/.pixi/envs/default/bin/python"
elif command -v python &>/dev/null; then
command -v python
else
command -v python3
fi
}

_lucy_jetson_py() {
local py
py="$(_lucy_jetson_python)"
PYTHONPATH="${_LUCY_WS_ROOT}${PYTHONPATH:+:${PYTHONPATH}}" "${py}" "$@"
}

lucy_is_jetson() {
_lucy_jetson_py -c "from launcher.platform import is_jetson; import sys; sys.exit(0 if is_jetson() else 1)"
}

lucy_headless_runtime_dir() {
_lucy_jetson_py -c "from launcher.platform import headless_runtime_dir; print(headless_runtime_dir(), end='')"
}

lucy_ensure_headless_runtime_dir() {
local dir
dir="$(_lucy_jetson_py -c "from launcher.platform import ensure_headless_runtime_dir; print(ensure_headless_runtime_dir(), end='')")"
export XDG_RUNTIME_DIR="$dir"
}
23 changes: 23 additions & 0 deletions scripts/gz_rendering_env.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Version-agnostic gz-rendering plugin + ogre2 resource paths for conda/RoboStack.
# Sourced from pixi [target.linux.activation] on Linux hosts.

[[ -n "${CONDA_PREFIX:-}" ]] || return 0 2>/dev/null || exit 0

if [[ -z "${GZ_RENDERING_PLUGIN_PATH:-}" ]]; then
for plugin_dir in "$CONDA_PREFIX"/lib/gz-rendering-*/engine-plugins; do
if [[ -d "$plugin_dir" ]]; then
export GZ_RENDERING_PLUGIN_PATH="$plugin_dir"
break
fi
done
fi

if [[ -z "${GZ_RENDERING_RESOURCE_PATH:-}" ]]; then
for resource_root in "$CONDA_PREFIX"/share/gz/gz-rendering*; do
if [[ -d "$resource_root/ogre2" ]]; then
export GZ_RENDERING_RESOURCE_PATH="$resource_root"
break
fi
done
fi
67 changes: 57 additions & 10 deletions scripts/nix_gl_env.sh
Original file line number Diff line number Diff line change
@@ -1,22 +1,65 @@
#!/usr/bin/env bash
# NixOS GL env for Pixi/RoboStack (Gazebo, RViz, rqt).
# Host GL env for Pixi/RoboStack (Gazebo, RViz, rqt) on NixOS and Jetson.
#
# Pixi activation puts conda Mesa/GLVND first on LD_LIBRARY_PATH; on NixOS +
# Wayland that breaks gz sim (GLX errors, "requesting world names", hung spawners).
# This script runs *inside* pixi run and:
# 1. Prepends host GL libs (nixGL wrapper, or /run/opengl-driver/lib)
# 2. Sets Mesa EGL vendor + GZ_IP for OGRE / Gazebo transport
# Wayland or Jetson Tegra that breaks gz sim (ogre2 plugin load failures, EGL
# segfaults, hung spawners). This script runs *inside* pixi run and:
# 1. Prepends host GL libs (NixOS nixGL / opengl-driver, or Jetson tegra/nvidia)
# 2. Sets EGL vendor + GZ_IP for OGRE / Gazebo transport
#
# Usage (via scripts/pixi_lucy_launch.sh or python -m launcher):
# Usage (via scripts/pixi_lucy_launch.sh, scripts/pixi_gui_launch.sh, launcher):
# pixi run sim
#
# Do not set LUCY_NIX_GL=0 on NixOS — EGL vars alone are not enough.
# Override wrapper (AMD/NVIDIA): LUCY_NIX_GL_WRAPPER=nixGLIntel
# Do not set LUCY_NIX_GL=0 on NixOS or Jetson — EGL vars alone are not enough.
# Override wrapper (NixOS AMD/NVIDIA): LUCY_NIX_GL_WRAPPER=nixGLIntel
# Force Jetson mode elsewhere: LUCY_GPU_MODE=jetson

ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=scripts/detect_jetson.sh
source "${ROOT}/scripts/detect_jetson.sh"
# shellcheck source=scripts/gz_rendering_env.sh
source "${ROOT}/scripts/gz_rendering_env.sh"

_lucy_on_nixos() {
[[ -d /run/opengl-driver ]] || [[ -n "${LUCY_NIXOS:-}" ]]
}

_lucy_prepend_jetson_gl() {
case "${LUCY_NIX_GL:-auto}" in
0|false|no|off|disable)
if lucy_is_jetson; then
echo "nix_gl_env.sh: LUCY_NIX_GL=0 — Jetson GL libs not prepended; Gazebo rendering may fail." >&2
fi
return 0
;;
esac

local jetson_ld="" candidate
for candidate in \
/usr/lib/aarch64-linux-gnu/nvidia:/usr/lib/aarch64-linux-gnu/tegra \
/usr/lib/aarch64-linux-gnu/tegra:/usr/lib/aarch64-linux-gnu/nvidia; do
IFS=':' read -r -a parts <<<"$candidate"
if [[ -d "${parts[0]}" ]]; then
jetson_ld="$candidate"
break
fi
done

if [[ -z "${jetson_ld}" ]]; then
echo "nix_gl_env.sh: Jetson detected but tegra/nvidia GL library dirs are missing — Gazebo rendering may fail." >&2
return 0
fi

export LD_LIBRARY_PATH="${jetson_ld}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
export LUCY_GPU_MODE="${LUCY_GPU_MODE:-jetson}"

local nvidia_egl=/usr/share/glvnd/egl_vendor.d/10_nvidia.json
if [[ -z "${__EGL_VENDOR_LIBRARY_FILENAMES:-}" ]] && [[ -f "$nvidia_egl" ]]; then
export __EGL_VENDOR_LIBRARY_FILENAMES="$nvidia_egl"
fi
export __GLX_VENDOR_LIBRARY_NAME="${__GLX_VENDOR_LIBRARY_NAME:-nvidia}"
}

_lucy_prepend_nix_gl() {
case "${LUCY_NIX_GL:-auto}" in
0|false|no|off|disable)
Expand Down Expand Up @@ -61,11 +104,15 @@ _lucy_prepend_nix_gl() {

_lucy_nixos_gazebo_env() {
local vendor=/run/opengl-driver/share/glvnd/egl_vendor.d/50_mesa.json
if [[ -f "$vendor" ]]; then
if [[ -z "${__EGL_VENDOR_LIBRARY_FILENAMES:-}" ]] && [[ -f "$vendor" ]]; then
export __EGL_VENDOR_LIBRARY_FILENAMES="$vendor"
fi
export GZ_IP="${GZ_IP:-127.0.0.1}"
}

_lucy_prepend_nix_gl
if lucy_is_jetson; then
_lucy_prepend_jetson_gl
else
_lucy_prepend_nix_gl
fi
_lucy_nixos_gazebo_env
13 changes: 13 additions & 0 deletions scripts/pixi_gui_launch.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
# Run a GUI ROS tool inside Pixi with host GL env (Jetson / NixOS).
#
# Usage:
# bash scripts/pixi_gui_launch.sh rqt
# bash scripts/pixi_gui_launch.sh ros2 run lucy_cli tui

set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=scripts/nix_gl_env.sh
source "${ROOT}/scripts/nix_gl_env.sh"
cd "${ROOT}"
exec "$@"
2 changes: 1 addition & 1 deletion scripts/pixi_lucy_launch.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Run lucy_bringup inside Pixi with NixOS GL env (scripts/nix_gl_env.sh).
# Run lucy_bringup inside Pixi with host GL env (scripts/nix_gl_env.sh).
#
# Usage:
# pixi run core
Expand Down
92 changes: 92 additions & 0 deletions tests/test_jetson_platform.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
"""Tests for Jetson platform detection (Python API + bash wrapper)."""

import os
import subprocess
from pathlib import Path

import pytest

from launcher.platform import (
ensure_headless_runtime_dir,
headless_runtime_dir,
is_jetson,
)

WORKSPACE_ROOT = Path(__file__).resolve().parent.parent
DETECT_JETSON_SH = WORKSPACE_ROOT / "scripts" / "detect_jetson.sh"


def _run_detect_jetson(env: dict | None = None) -> int:
script = (
f"source {DETECT_JETSON_SH}; "
"if lucy_is_jetson; then exit 0; else exit 1; fi"
)
merged = os.environ.copy()
if env:
merged.update(env)
return subprocess.run(
["bash", "-lc", script],
cwd=WORKSPACE_ROOT,
env=merged,
check=False,
).returncode


def test_is_jetson_respects_explicit_mode(monkeypatch):
monkeypatch.delenv("LUCY_GPU_MODE", raising=False)
monkeypatch.setattr(
"launcher.platform.Path.is_file",
lambda self: False,
)
monkeypatch.setenv("LUCY_GPU_MODE", "jetson")
assert is_jetson() is True
monkeypatch.setenv("LUCY_GPU_MODE", "0")
assert is_jetson() is False


def test_is_jetson_does_not_treat_nvidia_as_jetson(monkeypatch):
monkeypatch.setenv("LUCY_GPU_MODE", "nvidia")
monkeypatch.setattr(
"launcher.platform.Path.is_file",
lambda self: False,
)
monkeypatch.setattr(
"launcher.platform.Path.read_text",
lambda self, **kwargs: (_ for _ in ()).throw(OSError("mock")),
)
assert is_jetson() is False


def test_is_jetson_from_tegra_release(monkeypatch, tmp_path):
monkeypatch.delenv("LUCY_GPU_MODE", raising=False)

def fake_is_file(self):
return str(self) == "/etc/nv_tegra_release"

monkeypatch.setattr(
"launcher.platform.Path.is_file", fake_is_file
)
assert is_jetson() is True


def test_headless_runtime_dir_override(monkeypatch):
monkeypatch.setenv("LUCY_HEADLESS_RUNTIME_DIR", "/tmp/lucy-test-runtime")
assert headless_runtime_dir() == "/tmp/lucy-test-runtime"


def test_ensure_headless_runtime_dir_creates_private_dir(monkeypatch, tmp_path):
runtime = tmp_path / "runtime"
monkeypatch.setenv("LUCY_HEADLESS_RUNTIME_DIR", str(runtime))
created = ensure_headless_runtime_dir()
assert created == str(runtime)
assert runtime.is_dir()
assert oct(runtime.stat().st_mode & 0o777) == oct(0o700)


def test_shell_detect_jetson_matches_python_for_jetson_mode():
assert _run_detect_jetson({"LUCY_GPU_MODE": "jetson"}) == 0


@pytest.mark.skipif(not DETECT_JETSON_SH.is_file(), reason="detect_jetson.sh missing")
def test_shell_detect_jetson_rejects_disabled_mode():
assert _run_detect_jetson({"LUCY_GPU_MODE": "0"}) != 0
1 change: 1 addition & 0 deletions tests/test_launcher_pixi.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def test_pixi_workspace_script_wraps_ros2():
body = _pixi_workspace_script("ros2 doctor --report")
assert f"cd {WORKSPACE_ROOT}" in body
assert "pixi run -- bash -lc" in body
assert "nix_gl_env.sh" in body
assert "ros2 doctor --report" in body


Expand Down