An autonomy stack for the Unitree G1 humanoid, built on ROS 2.
59
stars
524
commits
C++
primary language
Aug 26, 2026
updated
The earlier Humble line, where Unitree's own internal leg policy walks the robot and this stack only does arm manipulation on top, is kept on
humble-unitree.
An autonomy stack for the Unitree G1 humanoid, built on ROS 2 Jazzy
and developed simulation-first against unitree_mujoco.
The simulator speaks the same DDS channels as the real robot, so the hardware interface, the navigation stack and the control-authority logic all carry over to hardware without code changes. Moving to the physical G1 is a domain-ID and interface change, not a rewrite.
The robot maps a facility with SLAM Toolbox, localizes against the saved map, and drives itself to
a goal pose under Nav2. Balance is ours: a learned locomotion policy runs at 50 Hz inside a
ros2_control controller and commands all 29 body motors over rt/lowcmd, with no onboard
controller underneath. Arm trajectories are an ordinary JointTrajectoryController claiming the
14 arm joints on the same component, so MoveIt executes while the policy keeps the robot up.
MoveIt plans for either arm or both together, collision-checked against a live octomap built from
the LiDAR, and each Dex3-1 hand is its own planning group with open and closed postures.
On top of that, pick and place are served as actions, and a BehaviorTree.CPP behaviour tree sequences them with navigation into a mission that runs end to end in the facility world: drive to a workbench, walk the last half metre under closed-loop control, pick a cube up, carry it across the building, and put it down on a bench. Object poses come from a source that refuses to run on hardware, because there is no object-detection pipeline yet; a real one replaces it without the skills changing.
Nav2 parks within 0.5 m of a goal and the arm's usable window is about 0.2 m wide, so a base approach skill closes the gap against the measured object rather than against the map. The tree is editable in Groot2 against a generated node palette.
Learned manipulation is wired up on top of that: a vision-language-action policy proposes joint targets and every chunk is checked against the planning scene before it runs. The pipeline works; the pretrained policy does not grasp reliably, which needs fine-tuning on demonstrations from this robot.



On hardware the simulation card becomes the physical G1 and the LiDAR front end becomes
livox_ros_driver2. Everything above the DDS rail is unchanged.
Two rules shape the design, and both apply in simulation so the habits transfer:
rt/lowcmd. Control-mode ownership is explicit, and the
hardware component leaves any joint no controller claims unpowered, so "who owns this joint" is
also "does this joint hold".rt/lowcmd means owning balance. There is no onboard controller left underneath to
catch a mistake.| Package | What it does |
|---|---|
g1_bringup | The entry point. Launch files, scenes and config that compose everything below. |
g1_description | Vendored G1 URDF plus the ros2_control xacro wrapper. |
g1_hand_interface | ros2_control plugin for one Dex3-1 hand, over the hand's own SDK channels. |
g1_controllers | The locomotion policy, its chained safety controller and the freeze controllers. |
g1_hardware_interface | ros2_control plugin owning all 29 body motors over rt/lowcmd. |
g1_locomotion | Walks the base into arm's reach of a measured object, and backs it out again. |
g1_manipulation | Pick and place as actions, and the object-pose source behind them. |
g1_moveit_config | MoveIt config: arm and hand planning groups, kinematics, the octomap. |
g1_msgs | The mission's own interfaces: pick, place, approach, retreat, arm posture, grasp. |
g1_navigation | SLAM Toolbox mapping, AMCL localization and Nav2. |
g1_orchestration | The behaviour tree that sequences navigation and manipulation into a mission. |
g1_sensor_relay | Publishes LiDAR and depth frames sampled inside the simulator. |
g1_state_estimation | Publishes odom to base_footprint and the TF chain Nav2 needs. |
g1_vla | Learned grasping: a policy's action chunks, checked against the planning scene before they run. |
There is no demonstration recorder yet, so the learned-grasp path runs a pretrained policy and cannot be fine-tuned on this robot; a recorder, and the LeRobot dataset and policy tooling that would sit beside it, are both still open.
The ROS build and runtime commands run inside the dev container, which pins the ROS 2 Jazzy and Ubuntu 24.04 toolchain the stack is built and tested against.
Install Docker Engine with Docker Compose v2, the NVIDIA driver and the NVIDIA Container Toolkit
on the host. The simulator uses the GPU exposed by docker-compose.yml. For the GUI modes, run
from an X11 desktop session; manage.sh start grants the container local X11 access.
Install vcstool on the host as well, because the
import script uses its vcs command before the development container exists:
sudo apt install python3-vcstool
From the repository root, on the host:
cp .env.example .env
./scripts/import-externals.sh
./scripts/manage.sh start
./scripts/manage.sh exec
import-externals.sh pulls the third-party packages listed in workspace.repos into
workspace/src and puts the two that ship a non-standard layout into a buildable one. Run it
again whenever workspace.repos changes.
Inside the container:
cd /root/workspace
colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
source install/setup.bash
Then pick a demo. The run commands live in their own guides so this page stays short:
| Guide | What it covers |
|---|---|
| Navigation and arm planning | Mapping, localization, Nav2 goals, and MoveIt planning against the LiDAR octomap. |
| Pick and place | The manipulation skills and the behaviour tree that sequences them with navigation. |
| Learned grasping | A vision-language-action policy behind the planning-scene gate. Runs; does not grasp yet. |
Each guide lists the launch arguments it uses and why, and
ros2 launch g1_bringup bringup.launch.py --show-args prints the full set.
Docker Compose is the runtime source of truth; .devcontainer/devcontainer.json is the VS Code
layer on top. In VS Code, use Dev Containers: Reopen in Container.
| Setting | Value |
|---|---|
| ROS distro | Jazzy, pinned. |
| Middleware | rmw_fastrtps_cpp image-wide. The SDK carries its own CycloneDDS, pinned to loopback, and ROS must not load a second one. |
ROS_DOMAIN_ID | 1 |
| Robot override | GROVE_G1_ROS_DOMAIN_ID, GROVE_G1_CYCLONEDDS_URI, GROVE_G1_ROBOT_NIC |
| C++ standard | C++20 on GCC 13.3 |
| Workspace | /root/workspace |
| Shared data | /root/data |
The container runs privileged with network_mode: host and a /dev bind mount. That is
deliberate for local robotics development: DDS discovery between the bare-DDS simulator and the
ROS graph happens over loopback, and device access has to work.
Pointing the container at a real G1 is three environment variables, not an image rebuild:
GROVE_G1_CYCLONEDDS_URI=file:///etc/cyclonedds/cyclonedds.hardware.xml (baked in beside the
loopback one, differing only in the interface), GROVE_G1_ROBOT_NIC for the NIC that reaches
the robot, and GROVE_G1_ROS_DOMAIN_ID for its domain. They are prefixed because the base
image's own /etc/profile.d/10-ros-env.sh rewrites the unprefixed names. sim.launch.py
refuses to start unless CYCLONEDDS_URI names a profile that pins lo, so the simulator
cannot be brought up pointing at a robot.
Lifecycle:
./scripts/manage.sh start | stop | restart | recreate | logs | exec
Use exec-as-me instead of exec for anything that rewrites source files in place, such as
clang-tidy --fix or clang-format -i. It runs as your host user, so the files do not come back
owned by root.
Project dependencies belong in .devcontainer/Dockerfile, followed by
./scripts/manage.sh recreate. Do not install into a running container and forget about it.
colcon test --packages-select g1_description g1_locomotion g1_navigation
colcon test-result --all
Leftover nodes from a previous run are the most productive source of phantom failures here: several copies of the stack on one DDS graph look like bugs everywhere except where they are. Clear them first, and note it verifies the graph rather than the process table:
./scripts/clean-stack.sh
Suites that launch a simulator are timing-sensitive and serialize on a shared ctest resource lock.
Run them one package at a time, and check nothing is left over from a previous run
(pgrep -x unitree_mujoco) before trusting a result: a stray simulator is the usual explanation
for a batch of failures that all pass on a clean rerun. Each package README says which of its
tests need a simulator.
Those suites carry the ctest label simulator, so the rest can be run on their own:
colcon test --ctest-args -LE simulator # everything that needs no simulator
colcon test --ctest-args -L simulator # only the simulator suites
Every pull request, and every push to main, builds the workspace and runs the tests that need
no simulator, in the image built by .github/ci.Dockerfile. Lint runs as part of colcon test,
not separately.
The simulator suites are excluded: they are CPU-time-sensitive and measure a shared runner rather than the stack. Run them locally before merging anything that touches locomotion, navigation or the sensor path.
A per-package C++ coverage table is printed to each run's summary. It covers only the tests CI runs, so the node and launch layer reads low there by construction. It is a signal on the pure logic, not a figure for the repository, which is why there is no badge for it.
.devcontainer/ derived dev image
workspace/src/ ROS 2 packages
workspace/patches/ patches applied to vendored sources at image build
workspace/vendor/ our source compiled into the vendored simulator
scripts/ container lifecycle, and stack teardown
523 commits
1 commits
C++
61.6%
Python
29.8%
CMake
4.9%
Shell
2.5%
Dockerfile
1.1%
An autonomy stack for the Unitree G1 humanoid, built on ROS 2.
59
stars
524
commits
C++
primary language
Aug 26, 2026
updated
The earlier Humble line, where Unitree's own internal leg policy walks the robot and this stack only does arm manipulation on top, is kept on
humble-unitree.
An autonomy stack for the Unitree G1 humanoid, built on ROS 2 Jazzy
and developed simulation-first against unitree_mujoco.
The simulator speaks the same DDS channels as the real robot, so the hardware interface, the navigation stack and the control-authority logic all carry over to hardware without code changes. Moving to the physical G1 is a domain-ID and interface change, not a rewrite.
The robot maps a facility with SLAM Toolbox, localizes against the saved map, and drives itself to
a goal pose under Nav2. Balance is ours: a learned locomotion policy runs at 50 Hz inside a
ros2_control controller and commands all 29 body motors over rt/lowcmd, with no onboard
controller underneath. Arm trajectories are an ordinary JointTrajectoryController claiming the
14 arm joints on the same component, so MoveIt executes while the policy keeps the robot up.
MoveIt plans for either arm or both together, collision-checked against a live octomap built from
the LiDAR, and each Dex3-1 hand is its own planning group with open and closed postures.
On top of that, pick and place are served as actions, and a BehaviorTree.CPP behaviour tree sequences them with navigation into a mission that runs end to end in the facility world: drive to a workbench, walk the last half metre under closed-loop control, pick a cube up, carry it across the building, and put it down on a bench. Object poses come from a source that refuses to run on hardware, because there is no object-detection pipeline yet; a real one replaces it without the skills changing.
Nav2 parks within 0.5 m of a goal and the arm's usable window is about 0.2 m wide, so a base approach skill closes the gap against the measured object rather than against the map. The tree is editable in Groot2 against a generated node palette.
Learned manipulation is wired up on top of that: a vision-language-action policy proposes joint targets and every chunk is checked against the planning scene before it runs. The pipeline works; the pretrained policy does not grasp reliably, which needs fine-tuning on demonstrations from this robot.



On hardware the simulation card becomes the physical G1 and the LiDAR front end becomes
livox_ros_driver2. Everything above the DDS rail is unchanged.
Two rules shape the design, and both apply in simulation so the habits transfer:
rt/lowcmd. Control-mode ownership is explicit, and the
hardware component leaves any joint no controller claims unpowered, so "who owns this joint" is
also "does this joint hold".rt/lowcmd means owning balance. There is no onboard controller left underneath to
catch a mistake.| Package | What it does |
|---|---|
g1_bringup | The entry point. Launch files, scenes and config that compose everything below. |
g1_description | Vendored G1 URDF plus the ros2_control xacro wrapper. |
g1_hand_interface | ros2_control plugin for one Dex3-1 hand, over the hand's own SDK channels. |
g1_controllers | The locomotion policy, its chained safety controller and the freeze controllers. |
g1_hardware_interface | ros2_control plugin owning all 29 body motors over rt/lowcmd. |
g1_locomotion | Walks the base into arm's reach of a measured object, and backs it out again. |
g1_manipulation | Pick and place as actions, and the object-pose source behind them. |
g1_moveit_config | MoveIt config: arm and hand planning groups, kinematics, the octomap. |
g1_msgs | The mission's own interfaces: pick, place, approach, retreat, arm posture, grasp. |
g1_navigation | SLAM Toolbox mapping, AMCL localization and Nav2. |
g1_orchestration | The behaviour tree that sequences navigation and manipulation into a mission. |
g1_sensor_relay | Publishes LiDAR and depth frames sampled inside the simulator. |
g1_state_estimation | Publishes odom to base_footprint and the TF chain Nav2 needs. |
g1_vla | Learned grasping: a policy's action chunks, checked against the planning scene before they run. |
There is no demonstration recorder yet, so the learned-grasp path runs a pretrained policy and cannot be fine-tuned on this robot; a recorder, and the LeRobot dataset and policy tooling that would sit beside it, are both still open.
The ROS build and runtime commands run inside the dev container, which pins the ROS 2 Jazzy and Ubuntu 24.04 toolchain the stack is built and tested against.
Install Docker Engine with Docker Compose v2, the NVIDIA driver and the NVIDIA Container Toolkit
on the host. The simulator uses the GPU exposed by docker-compose.yml. For the GUI modes, run
from an X11 desktop session; manage.sh start grants the container local X11 access.
Install vcstool on the host as well, because the
import script uses its vcs command before the development container exists:
sudo apt install python3-vcstool
From the repository root, on the host:
cp .env.example .env
./scripts/import-externals.sh
./scripts/manage.sh start
./scripts/manage.sh exec
import-externals.sh pulls the third-party packages listed in workspace.repos into
workspace/src and puts the two that ship a non-standard layout into a buildable one. Run it
again whenever workspace.repos changes.
Inside the container:
cd /root/workspace
colcon build --symlink-install --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
source install/setup.bash
Then pick a demo. The run commands live in their own guides so this page stays short:
| Guide | What it covers |
|---|---|
| Navigation and arm planning | Mapping, localization, Nav2 goals, and MoveIt planning against the LiDAR octomap. |
| Pick and place | The manipulation skills and the behaviour tree that sequences them with navigation. |
| Learned grasping | A vision-language-action policy behind the planning-scene gate. Runs; does not grasp yet. |
Each guide lists the launch arguments it uses and why, and
ros2 launch g1_bringup bringup.launch.py --show-args prints the full set.
Docker Compose is the runtime source of truth; .devcontainer/devcontainer.json is the VS Code
layer on top. In VS Code, use Dev Containers: Reopen in Container.
| Setting | Value |
|---|---|
| ROS distro | Jazzy, pinned. |
| Middleware | rmw_fastrtps_cpp image-wide. The SDK carries its own CycloneDDS, pinned to loopback, and ROS must not load a second one. |
ROS_DOMAIN_ID | 1 |
| Robot override | GROVE_G1_ROS_DOMAIN_ID, GROVE_G1_CYCLONEDDS_URI, GROVE_G1_ROBOT_NIC |
| C++ standard | C++20 on GCC 13.3 |
| Workspace | /root/workspace |
| Shared data | /root/data |
The container runs privileged with network_mode: host and a /dev bind mount. That is
deliberate for local robotics development: DDS discovery between the bare-DDS simulator and the
ROS graph happens over loopback, and device access has to work.
Pointing the container at a real G1 is three environment variables, not an image rebuild:
GROVE_G1_CYCLONEDDS_URI=file:///etc/cyclonedds/cyclonedds.hardware.xml (baked in beside the
loopback one, differing only in the interface), GROVE_G1_ROBOT_NIC for the NIC that reaches
the robot, and GROVE_G1_ROS_DOMAIN_ID for its domain. They are prefixed because the base
image's own /etc/profile.d/10-ros-env.sh rewrites the unprefixed names. sim.launch.py
refuses to start unless CYCLONEDDS_URI names a profile that pins lo, so the simulator
cannot be brought up pointing at a robot.
Lifecycle:
./scripts/manage.sh start | stop | restart | recreate | logs | exec
Use exec-as-me instead of exec for anything that rewrites source files in place, such as
clang-tidy --fix or clang-format -i. It runs as your host user, so the files do not come back
owned by root.
Project dependencies belong in .devcontainer/Dockerfile, followed by
./scripts/manage.sh recreate. Do not install into a running container and forget about it.
colcon test --packages-select g1_description g1_locomotion g1_navigation
colcon test-result --all
Leftover nodes from a previous run are the most productive source of phantom failures here: several copies of the stack on one DDS graph look like bugs everywhere except where they are. Clear them first, and note it verifies the graph rather than the process table:
./scripts/clean-stack.sh
Suites that launch a simulator are timing-sensitive and serialize on a shared ctest resource lock.
Run them one package at a time, and check nothing is left over from a previous run
(pgrep -x unitree_mujoco) before trusting a result: a stray simulator is the usual explanation
for a batch of failures that all pass on a clean rerun. Each package README says which of its
tests need a simulator.
Those suites carry the ctest label simulator, so the rest can be run on their own:
colcon test --ctest-args -LE simulator # everything that needs no simulator
colcon test --ctest-args -L simulator # only the simulator suites
Every pull request, and every push to main, builds the workspace and runs the tests that need
no simulator, in the image built by .github/ci.Dockerfile. Lint runs as part of colcon test,
not separately.
The simulator suites are excluded: they are CPU-time-sensitive and measure a shared runner rather than the stack. Run them locally before merging anything that touches locomotion, navigation or the sensor path.
A per-package C++ coverage table is printed to each run's summary. It covers only the tests CI runs, so the node and launch layer reads low there by construction. It is a signal on the pure logic, not a figure for the repository, which is why there is no badge for it.
.devcontainer/ derived dev image
workspace/src/ ROS 2 packages
workspace/patches/ patches applied to vendored sources at image build
workspace/vendor/ our source compiled into the vendored simulator
scripts/ container lifecycle, and stack teardown
523 commits
1 commits
C++
61.6%
Python
29.8%
CMake
4.9%
Shell
2.5%
Dockerfile
1.1%