FTH 4 · Operating a real humanoid|INEMA.CLUB-PRO

Lesson 6 · 20 min

The policy as a ROS 2 node

The current ROS 2 is Lyrical, but Unitree's official package asks for Humble. The way out is one container per robot: Humble inside, only to talk to the G1, and the rest of your system wherever you like. In this lesson the container sees the simulator's topics, measures the rate, and runs a policy node at 50 Hz.

Start the lesson
Two monitors on a dark desk. One shows blurred terminal windows, the other a humanoid robot in simulation. A small computer sits between them.

In this lesson you will

  1. 1Why Humble in a container, if the current ROS 2 is Lyrical
  2. 2rt/lowstate in DDS is /lowstate in ROS 2: no bridge
  3. 3ros2 topic hz against the manufacturer's simulator
  4. 4The skeleton of a policy node: subscribes to state, publishes a command

Goal: see the G1's topics in ROS 2 Humble in a container, measure the rate, and run a policy node that publishes at 50 Hz.

01One container per robot

Click each block.

Click each block in the diagram to see what it does.

Tested on Linux aarch64 with --network host and the lo interface. On Windows and macOS, Docker networking is different: not tested.

02Three container decisions

Click each one.

1. Humble isolated

Humble goes only in the container, to talk to the robot. The host stays free for the current ROS 2 and for other robots, each with its own container.

There is no official support for Jazzy or Lyrical in unitree_ros2 (there is a report of a project that bridged the G1 on Jazzy in 2026).

03Build the image and see the topics

The first time, the image takes a few minutes (apt and colcon). After that, the simulator starts in the background and the container lists the topics and measures the rate.

File · kit/ros2/Dockerfile
# Contêiner ROS 2 Humble só para falar com o robô Unitree (o unitree_ros2 suporta Humble/Foxy, não Jazzy/Lyrical).
# Testado em 19/10/2026: ros:humble-ros-base (arm64) + unitree_ros2 668d1ec (02/07/2026).
FROM ros:humble-ros-base
RUN apt-get update && apt-get install -y --no-install-recommends \
      ros-humble-rmw-cyclonedds-cpp ros-humble-rosidl-generator-dds-idl \
      ros-humble-rosbag2-storage-mcap git python3-colcon-common-extensions \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /unitree
RUN git clone -q https://github.com/unitreerobotics/unitree_ros2 && cd unitree_ros2 && git checkout -q 668d1ec
RUN . /opt/ros/humble/setup.sh && cd unitree_ros2/cyclonedds_ws && \
    colcon build --packages-select unitree_api unitree_go unitree_hg > /dev/null
# Mesmo domínio e mesma interface do simulador (unitree_mujoco: DOMAIN_ID=1, INTERFACE="lo")
ENV RMW_IMPLEMENTATION=rmw_cyclonedds_cpp ROS_DOMAIN_ID=1 \
    CYCLONEDDS_URI='<CycloneDDS><Domain><General><Interfaces><NetworkInterface name="lo"/></Interfaces></General></Domain></CycloneDDS>'
COPY entrada.sh /entrada.sh
ENTRYPOINT ["/bin/bash", "/entrada.sh"]
Terminal · build the image
F=${FTH:-$HOME/fth}; cd $F/operar
docker build -q -t fth-humble kit/ros2 > /dev/null && echo "imagem fth-humble pronta"
docker run --rm fth-humble bash -c 'echo "ROS 2 $ROS_DISTRO · RMW $RMW_IMPLEMENTATION · domínio $ROS_DOMAIN_ID"; ros2 pkg list | grep unitree'
Terminal · topics and rate
F=${FTH:-$HOME/fth}; cd $F/operar && . $F/.venv/bin/activate
python3 kit/sim.py --s 20 --suspenso > /dev/null &
until python3 kit/monitor.py --s 0.5 > /dev/null; do sleep 0.5; done
docker run --rm --network host fth-humble ros2 topic list 2>/dev/null
docker run --rm --network host fth-humble timeout -s INT 8 ros2 topic hz /lowstate --window 1000 2>/dev/null | awk '/average rate/ {r=$3} END {printf "average rate ≈ %d Hz\n", int((r+5)/10)*10}'
wait
Example from the repository
imagem fth-humble pronta
ROS 2 humble · RMW rmw_cyclonedds_cpp · domínio 1
unitree_api
unitree_go
unitree_hg
/lowcmd
/lowstate
/parameter_events
/rosout
/sportmodestate
/wirelesscontroller
average rate ≈ 200 Hz

Humble, CycloneDDS, domain 1, and the three Unitree message families. The simulator's topics show up without the rt/, and /lowstate arrives at about 200 Hz.

04A policy node at 50 Hz

The node subscribes to /lowstate, runs the "policy" every 20 ms, and publishes /lowcmd. The policy here is the safest possible one (damping); swap the core of passo() for your own network.

File · kit/ros2/no_politica.py
"""no_politica.py — o esqueleto de uma política como nó ROS 2 (Humble, dentro do contêiner fth-humble).

Assina /lowstate (unitree_hg/msg/LowState), roda a "política" a 50 Hz e publica /lowcmd (unitree_hg/msg/LowCmd).
A política daqui é a mais segura possível: amortecimento (kp = 0, kd = 2) em todos os motores. Troque o miolo
de passo() pela sua rede (ONNX, PyTorch) — o resto do nó fica igual.
Para o robô real faltam duas coisas que o simulador não cobra: o CRC do comando (os exemplos oficiais
calculam: motor_crc_hg.cpp no unitree_ros2, CRC().Crc no SDK Python; o simulador não confere) e o laço
de 2 ms do exemplo oficial entre a política e o motor.
"""
import math
import sys

import rclpy
from rclpy.node import Node
from unitree_hg.msg import LowCmd, LowState


class Politica(Node):
    def __init__(self, segundos):
        super().__init__("fth_politica")
        self.estado, self.recebidos, self.enviados, self.incl = None, 0, 0, 0.0
        self.create_subscription(LowState, "lowstate", self.chegou, 10)
        self.pub = self.create_publisher(LowCmd, "lowcmd", 10)
        self.create_timer(0.02, self.passo)  # 50 Hz
        self.fim = self.get_clock().now().nanoseconds + int(segundos * 1e9)

    def chegou(self, msg):
        self.estado, self.recebidos = msg, self.recebidos + 1
        w, x, y, z = msg.imu_state.quaternion
        self.incl = math.degrees(math.acos(max(-1.0, min(1.0, 1 - 2 * (x * x + y * y)))))

    def passo(self):
        if self.estado is None:
            return  # sem estado, sem comando
        cmd = LowCmd()
        cmd.mode_pr, cmd.mode_machine = 0, self.estado.mode_machine
        for i in range(29):
            m = cmd.motor_cmd[i]
            m.mode, m.q, m.dq, m.tau, m.kp, m.kd = 1, 0.0, 0.0, 0.0, 0.0, 2.0
        self.pub.publish(cmd)
        self.enviados += 1


def main():
    segundos = float(sys.argv[1]) if len(sys.argv) > 1 else 5.0
    rclpy.init()
    no = Politica(segundos)
    while rclpy.ok() and no.get_clock().now().nanoseconds < no.fim:
        rclpy.spin_once(no, timeout_sec=0.05)
    taxa = no.enviados / segundos
    print(f"nó fth_politica: {segundos:g} s · /lowstate recebido: {'sim' if no.recebidos else 'NÃO'}"
          f" · tronco em pé (inclinação < 20°): {'sim' if no.incl < 20 else 'não'}")
    print(f"/lowcmd publicado a ≈ {round(taxa):.0f} Hz (alvo 50 Hz) · comando: amortecimento")
    no.destroy_node()
    rclpy.shutdown()
    sys.exit(0 if no.recebidos and 45 <= taxa <= 55 else 1)


if __name__ == "__main__":
    main()
Terminal · run the node against the simulator
F=${FTH:-$HOME/fth}; cd $F/operar && . $F/.venv/bin/activate
python3 kit/sim.py --s 15 --suspenso > /dev/null &
until python3 kit/monitor.py --s 0.5 > /dev/null; do sleep 0.5; done
docker run --rm --network host -v $PWD/kit/ros2:/kit:ro fth-humble python3 /kit/no_politica.py 5 2>/dev/null; echo "saída: $?"
wait
Example from the repository
nó fth_politica: 5 s · /lowstate recebido: sim · tronco em pé (inclinação < 20°): sim
/lowcmd publicado a ≈ 50 Hz (alvo 50 Hz) · comando: amortecimento
saída: 0

The node received the state, published at 50 Hz, and the suspended robot kept its torso upright, damped.

05Practice

The robot's ROS 2, isolated and measured.

E1 · The image

10 min

Build the fth-humble image with the block and check the Unitree messages.

Done when: the output lists unitree_api, unitree_go and unitree_hg after ROS 2 humble · RMW rmw_cyclonedds_cpp · domínio 1.

E2 · Topics and rate

5 min

Run the topics block. Then run just ros2 topic list without --network host and compare.

Done when: /lowstate and /lowcmd in the list and average rate ≈ 200 Hz; without the host network, only /parameter_events and /rosout.

E3 · The policy node

10 min

Run the node against the simulator. Then change the timer to 0.01 s and predict the new rate before running.

Done when: python3 /kit/no_politica.py 5 in the container → /lowcmd publicado a ≈ 50 Hz (alvo 50 Hz) and saída: 0.

06Lesson quiz

The explanation appears after your answer.

Question 1 of 4

Why does the course use ROS 2 Humble if the current one is Lyrical?skill

07In three sentences

ContainerHumble only in the robot's container; the host stays on the current ROS 2.
Network--network host, CycloneDDS, domain and the robot's interface.
NodeSubscribes to /lowstate, publishes /lowcmd; the policy is the core.

Going further

Previous lessonNext lesson