mirror of
https://github.com/huggingface/lerobot.git
synced 2026-05-24 21:19:53 +00:00
feat(robots): add Unitree G1 humanoid support with ZMQ bridge
- Use JSON + base64 serialization for secure communication instead of pickle - Add documentation section - Rename robot_server to run_g1_server - Add dependecies to pyproject.toml
This commit is contained in:
@@ -79,6 +79,8 @@
|
|||||||
title: Hope Jr
|
title: Hope Jr
|
||||||
- local: reachy2
|
- local: reachy2
|
||||||
title: Reachy 2
|
title: Reachy 2
|
||||||
|
- local: unitree_g1
|
||||||
|
title: Unitree G1
|
||||||
title: "Robots"
|
title: "Robots"
|
||||||
- sections:
|
- sections:
|
||||||
- local: phone_teleop
|
- local: phone_teleop
|
||||||
|
|||||||
+29
-19
@@ -5,6 +5,7 @@ This guide covers the complete setup process for the Unitree G1 humanoid robot,
|
|||||||
## 🤖 About the Unitree G1
|
## 🤖 About the Unitree G1
|
||||||
|
|
||||||
The Unitree G1 humanoid comes in two flavors: 29-DOF and 23-DOF humanoid robot capable of whole-body control, manipulation, and locomotion. In this first PR we introduce:
|
The Unitree G1 humanoid comes in two flavors: 29-DOF and 23-DOF humanoid robot capable of whole-body control, manipulation, and locomotion. In this first PR we introduce:
|
||||||
|
|
||||||
- **Low-level motor control** via DDS (Data Distribution Service)
|
- **Low-level motor control** via DDS (Data Distribution Service)
|
||||||
- **ZMQ socket bridge** for remote communication over WiFi, allowing one to deploy policies remotely instead of over ethernet or directly on the Orin
|
- **ZMQ socket bridge** for remote communication over WiFi, allowing one to deploy policies remotely instead of over ethernet or directly on the Orin
|
||||||
- **GR00T locomotion policiey** for bipedal walking and balance
|
- **GR00T locomotion policiey** for bipedal walking and balance
|
||||||
@@ -58,7 +59,9 @@ sudo systemctl restart NetworkManager
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Enable Internet Forwarding
|
### Step 2: Enable Internet Forwarding
|
||||||
|
|
||||||
**On your laptop:**
|
**On your laptop:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Enable IP forwarding
|
# Enable IP forwarding
|
||||||
sudo sysctl -w net.ipv4.ip_forward=1
|
sudo sysctl -w net.ipv4.ip_forward=1
|
||||||
@@ -70,6 +73,7 @@ sudo iptables -A FORWARD -i enp131s0 -o wlp132s0f0 -j ACCEPT
|
|||||||
```
|
```
|
||||||
|
|
||||||
**On the robot:**
|
**On the robot:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Add laptop as default gateway
|
# Add laptop as default gateway
|
||||||
sudo ip route del default 2>/dev/null || true
|
sudo ip route del default 2>/dev/null || true
|
||||||
@@ -117,10 +121,8 @@ The robot server introduced here acts as a DDS-to-ZMQ bridge, allowing your one
|
|||||||
From your laptop, copy the robot server script:
|
From your laptop, copy the robot server script:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Copy the server script
|
# Copy the server script and its dependencies
|
||||||
scp src/lerobot/robots/unitree_g1/robot_server.py unitree@172.18.129.215:~/robot_server.py
|
scp src/lerobot/robots/unitree_g1/run_g1_server.py unitree@172.18.129.215:~/run_g1_server.py
|
||||||
|
|
||||||
# Copy required dependencies if needed
|
|
||||||
scp src/lerobot/robots/unitree_g1/g1_utils.py unitree@172.18.129.215:~/g1_utils.py
|
scp src/lerobot/robots/unitree_g1/g1_utils.py unitree@172.18.129.215:~/g1_utils.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -135,25 +137,23 @@ ssh unitree@172.18.129.215
|
|||||||
sudo apt update
|
sudo apt update
|
||||||
sudo apt install -y build-essential python3-dev python3-pip
|
sudo apt install -y build-essential python3-dev python3-pip
|
||||||
|
|
||||||
# Install Python packages
|
# Install Python packages (pyzmq and Unitree SDK)
|
||||||
pip3 install pyzmq pickle5
|
pip3 install pyzmq
|
||||||
|
pip3 install git+https://github.com/unitreerobotics/unitree_sdk2_python.git
|
||||||
# Install Unitree SDK (if not already installed)
|
|
||||||
git clone https://github.com/unitreerobotics/unitree_sdk2_python.git
|
|
||||||
cd unitree_sdk2_python
|
|
||||||
pip3 install -e .
|
|
||||||
```
|
```
|
||||||
NOTE: unitreesdk requires dds v 10.2 to be installed
|
|
||||||
|
> **Note**: The Unitree SDK requires CycloneDDS v0.10.2 to be installed. See the [Unitree SDK documentation](https://github.com/unitreerobotics/unitree_sdk2_python) for details.
|
||||||
|
|
||||||
### Step 3: Run the Robot Server
|
### Step 3: Run the Robot Server
|
||||||
|
|
||||||
On the robot:
|
On the robot:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 ~/robot_server.py
|
python3 ~/run_g1_server.py
|
||||||
```
|
```
|
||||||
|
|
||||||
You should see output like:
|
You should see output like:
|
||||||
|
|
||||||
```
|
```
|
||||||
Robot server listening on:
|
Robot server listening on:
|
||||||
Commands: tcp://*:6000 (PULL)
|
Commands: tcp://*:6000 (PULL)
|
||||||
@@ -169,7 +169,17 @@ DDS initialized, forwarding started...
|
|||||||
|
|
||||||
With the robot server running, you can now control the robot from your laptop.
|
With the robot server running, you can now control the robot from your laptop.
|
||||||
|
|
||||||
### Step 1: Update Robot IP in Config
|
### Step 1: Install LeRobot with Unitree G1 Support (on your laptop)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install lerobot with unitree_g1 extras
|
||||||
|
pip install 'lerobot[unitree_g1]'
|
||||||
|
|
||||||
|
# Or if installing from source:
|
||||||
|
pip install -e '.[unitree_g1]'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Update Robot IP in Config
|
||||||
|
|
||||||
Edit the config file to match your robot's WiFi IP:
|
Edit the config file to match your robot's WiFi IP:
|
||||||
|
|
||||||
@@ -178,7 +188,7 @@ Edit the config file to match your robot's WiFi IP:
|
|||||||
robot_ip: str = "172.18.129.215" # Your robot's WiFi IP
|
robot_ip: str = "172.18.129.215" # Your robot's WiFi IP
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 2: Run the Locomotion Policy
|
### Step 3: Run the Locomotion Policy
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run GR00T locomotion controller (downloads policies from HuggingFace)
|
# Run GR00T locomotion controller (downloads policies from HuggingFace)
|
||||||
@@ -189,6 +199,7 @@ python examples/unitree_g1/gr00t_locomotion.py
|
|||||||
```
|
```
|
||||||
|
|
||||||
The script will:
|
The script will:
|
||||||
|
|
||||||
1. Download Balance and Walk policies from the Hub (cached locally after first run)
|
1. Download Balance and Walk policies from the Hub (cached locally after first run)
|
||||||
2. Connect to the robot server over WiFi/ZMQ
|
2. Connect to the robot server over WiFi/ZMQ
|
||||||
3. Initialize the robot and locomotion controller
|
3. Initialize the robot and locomotion controller
|
||||||
@@ -197,6 +208,7 @@ The script will:
|
|||||||
6. Accept commands from the wireless remote controller
|
6. Accept commands from the wireless remote controller
|
||||||
|
|
||||||
**Expected output:**
|
**Expected output:**
|
||||||
|
|
||||||
```
|
```
|
||||||
INFO - Loading GR00T Balance policy...
|
INFO - Loading GR00T Balance policy...
|
||||||
INFO - Loading GR00T Walk policy...
|
INFO - Loading GR00T Walk policy...
|
||||||
@@ -209,7 +221,7 @@ INFO - Locomotion controller running in background thread
|
|||||||
INFO - Press Ctrl+C to stop
|
INFO - Press Ctrl+C to stop
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 3: Control with Remote
|
### Step 4: Control with Remote
|
||||||
|
|
||||||
- **Left stick**: Forward/backward and left/right movement
|
- **Left stick**: Forward/backward and left/right movement
|
||||||
- **Right stick**: Rotation
|
- **Right stick**: Rotation
|
||||||
@@ -229,6 +241,4 @@ To stop, press `Ctrl+C` in the terminal.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
_Last updated: November 2025_
|
||||||
*Last updated: November 2025*
|
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -107,6 +107,10 @@ dynamixel = ["dynamixel-sdk>=3.7.31,<3.9.0"]
|
|||||||
gamepad = ["lerobot[pygame-dep]", "hidapi>=0.14.0,<0.15.0"]
|
gamepad = ["lerobot[pygame-dep]", "hidapi>=0.14.0,<0.15.0"]
|
||||||
hopejr = ["lerobot[feetech]", "lerobot[pygame-dep]"]
|
hopejr = ["lerobot[feetech]", "lerobot[pygame-dep]"]
|
||||||
lekiwi = ["lerobot[feetech]", "pyzmq>=26.2.1,<28.0.0"]
|
lekiwi = ["lerobot[feetech]", "pyzmq>=26.2.1,<28.0.0"]
|
||||||
|
unitree_g1 = [
|
||||||
|
"pyzmq>=26.2.1,<28.0.0",
|
||||||
|
"unitree_sdk2py @ git+https://github.com/unitreerobotics/unitree_sdk2_python.git",
|
||||||
|
]
|
||||||
reachy2 = ["reachy2_sdk>=1.0.14,<1.1.0"]
|
reachy2 = ["reachy2_sdk>=1.0.14,<1.1.0"]
|
||||||
kinematics = ["lerobot[placo-dep]"]
|
kinematics = ["lerobot[placo-dep]"]
|
||||||
intelrealsense = [
|
intelrealsense = [
|
||||||
|
|||||||
@@ -2,6 +2,46 @@ from enum import IntEnum
|
|||||||
|
|
||||||
# ruff: noqa: N801, N815
|
# ruff: noqa: N801, N815
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Serialization Keys for ZMQ Communication Protocol
|
||||||
|
# =============================================================================
|
||||||
|
# These constants define the JSON keys used for robot state/command serialization.
|
||||||
|
# They are shared between robot_server.py (robot-side) and unitree_sdk2_socket.py (client-side).
|
||||||
|
|
||||||
|
# Top-level message keys
|
||||||
|
TOPIC = "topic"
|
||||||
|
DATA = "data"
|
||||||
|
|
||||||
|
# Motor state keys
|
||||||
|
MOTOR_STATE = "motor_state"
|
||||||
|
MOTOR_Q = "q" # position
|
||||||
|
MOTOR_DQ = "dq" # velocity
|
||||||
|
MOTOR_TAU_EST = "tau_est" # estimated torque
|
||||||
|
MOTOR_TEMPERATURE = "temperature"
|
||||||
|
|
||||||
|
# Motor command keys
|
||||||
|
MOTOR_CMD = "motor_cmd"
|
||||||
|
MOTOR_MODE = "mode"
|
||||||
|
MOTOR_KP = "kp"
|
||||||
|
MOTOR_KD = "kd"
|
||||||
|
MOTOR_TAU = "tau"
|
||||||
|
|
||||||
|
# IMU state keys
|
||||||
|
IMU_STATE = "imu_state"
|
||||||
|
IMU_QUATERNION = "quaternion"
|
||||||
|
IMU_GYROSCOPE = "gyroscope"
|
||||||
|
IMU_ACCELEROMETER = "accelerometer"
|
||||||
|
IMU_RPY = "rpy"
|
||||||
|
IMU_TEMPERATURE = "temperature"
|
||||||
|
|
||||||
|
# Other state keys
|
||||||
|
WIRELESS_REMOTE = "wireless_remote"
|
||||||
|
MODE_MACHINE = "mode_machine"
|
||||||
|
MODE_PR = "mode_pr"
|
||||||
|
|
||||||
|
# Number of motors
|
||||||
|
NUM_MOTORS = 35
|
||||||
|
|
||||||
|
|
||||||
class G1_29_JointArmIndex(IntEnum):
|
class G1_29_JointArmIndex(IntEnum):
|
||||||
# Left arm
|
# Left arm
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import contextlib
|
|
||||||
import pickle
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
import zmq
|
|
||||||
from unitree_sdk2py.comm.motion_switcher.motion_switcher_client import MotionSwitcherClient
|
|
||||||
from unitree_sdk2py.core.channel import ChannelFactoryInitialize, ChannelPublisher, ChannelSubscriber
|
|
||||||
from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowCmd_ as hg_LowCmd, LowState_ as hg_LowState
|
|
||||||
from unitree_sdk2py.utils.crc import CRC
|
|
||||||
|
|
||||||
# DDS topic names follow Unitree SDK naming conventions
|
|
||||||
# ruff: noqa: N816
|
|
||||||
kTopicLowCommand_Debug = "rt/lowcmd" # action to robot
|
|
||||||
kTopicLowState = "rt/lowstate" # observation from robot
|
|
||||||
|
|
||||||
LOWCMD_PORT = 6000
|
|
||||||
LOWSTATE_PORT = 6001
|
|
||||||
|
|
||||||
|
|
||||||
def state_forward_loop(
|
|
||||||
lowstate_sub, lowstate_sock, state_period: float
|
|
||||||
): # read observation from DDS and send to server
|
|
||||||
last_state_time = 0.0
|
|
||||||
|
|
||||||
while True:
|
|
||||||
# read from DDS
|
|
||||||
msg = lowstate_sub.Read()
|
|
||||||
if msg is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
now = time.time()
|
|
||||||
# optional downsampling (if robot dds rate > state_period)
|
|
||||||
if now - last_state_time >= state_period:
|
|
||||||
payload = pickle.dumps((kTopicLowState, msg), protocol=pickle.HIGHEST_PROTOCOL)
|
|
||||||
# if no subscribers / tx buffer full, just drop
|
|
||||||
with contextlib.suppress(zmq.Again):
|
|
||||||
lowstate_sock.send(payload, zmq.NOBLOCK)
|
|
||||||
last_state_time = now
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_forward_loop(lowcmd_sock, lowcmd_pub_debug, crc: CRC): # send action to robot
|
|
||||||
while True:
|
|
||||||
payload = lowcmd_sock.recv()
|
|
||||||
topic, cmd = pickle.loads(payload)
|
|
||||||
|
|
||||||
# recompute crc just in case
|
|
||||||
cmd.crc = crc.Crc(cmd)
|
|
||||||
|
|
||||||
if topic == kTopicLowCommand_Debug:
|
|
||||||
lowcmd_pub_debug.Write(cmd)
|
|
||||||
else:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
# initialize DDS
|
|
||||||
ChannelFactoryInitialize(0)
|
|
||||||
|
|
||||||
# stop all active publishers on the robot
|
|
||||||
msc = MotionSwitcherClient()
|
|
||||||
msc.SetTimeout(5.0)
|
|
||||||
msc.Init()
|
|
||||||
|
|
||||||
status, result = msc.CheckMode()
|
|
||||||
while result is not None and "name" in result and result["name"]:
|
|
||||||
msc.ReleaseMode()
|
|
||||||
status, result = msc.CheckMode()
|
|
||||||
time.sleep(1.0)
|
|
||||||
|
|
||||||
crc = CRC()
|
|
||||||
|
|
||||||
# initialize DDS publisher
|
|
||||||
lowcmd_pub_debug = ChannelPublisher(kTopicLowCommand_Debug, hg_LowCmd)
|
|
||||||
lowcmd_pub_debug.Init()
|
|
||||||
|
|
||||||
# initialize DDS subscriber
|
|
||||||
lowstate_sub = ChannelSubscriber(kTopicLowState, hg_LowState)
|
|
||||||
lowstate_sub.Init()
|
|
||||||
|
|
||||||
# initialize ZMQ
|
|
||||||
ctx = zmq.Context.instance()
|
|
||||||
|
|
||||||
# send action to robot
|
|
||||||
lowcmd_sock = ctx.socket(zmq.PULL)
|
|
||||||
lowcmd_sock.bind(f"tcp://0.0.0.0:{LOWCMD_PORT}")
|
|
||||||
|
|
||||||
# send observation to server
|
|
||||||
lowstate_sock = ctx.socket(zmq.PUB)
|
|
||||||
lowstate_sock.bind(f"tcp://0.0.0.0:{LOWSTATE_PORT}")
|
|
||||||
|
|
||||||
state_period = 0.002 # ~500 hz
|
|
||||||
|
|
||||||
# start observation forwarding thread
|
|
||||||
t_state = threading.Thread(
|
|
||||||
target=state_forward_loop,
|
|
||||||
args=(lowstate_sub, lowstate_sock, state_period),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
t_state.start()
|
|
||||||
|
|
||||||
# start action forwarding thread
|
|
||||||
t_cmd = threading.Thread(
|
|
||||||
target=cmd_forward_loop,
|
|
||||||
args=(lowcmd_sock, lowcmd_pub_debug, crc),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
t_cmd.start()
|
|
||||||
|
|
||||||
print("bridge running (lowstate -> zmq, lowcmd -> dds)")
|
|
||||||
# keep main thread alive so daemon threads don’t exit
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
time.sleep(1.0)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("shutting down bridge...")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# Copyright 2025 The HuggingFace Inc. team. All rights reserved.
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""
|
||||||
|
DDS-to-ZMQ bridge server for Unitree G1 robot.
|
||||||
|
|
||||||
|
This server runs on the robot and forwards:
|
||||||
|
- Robot state (LowState) from DDS to ZMQ (for remote clients)
|
||||||
|
- Robot commands (LowCmd) from ZMQ to DDS (from remote clients)
|
||||||
|
|
||||||
|
Uses JSON for secure serialization instead of pickle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import zmq
|
||||||
|
from g1_utils import (
|
||||||
|
DATA,
|
||||||
|
IMU_ACCELEROMETER,
|
||||||
|
IMU_GYROSCOPE,
|
||||||
|
IMU_QUATERNION,
|
||||||
|
IMU_RPY,
|
||||||
|
IMU_STATE,
|
||||||
|
IMU_TEMPERATURE,
|
||||||
|
MODE_MACHINE,
|
||||||
|
MODE_PR,
|
||||||
|
MOTOR_CMD,
|
||||||
|
MOTOR_DQ,
|
||||||
|
MOTOR_KD,
|
||||||
|
MOTOR_KP,
|
||||||
|
MOTOR_MODE,
|
||||||
|
MOTOR_Q,
|
||||||
|
MOTOR_STATE,
|
||||||
|
MOTOR_TAU,
|
||||||
|
MOTOR_TAU_EST,
|
||||||
|
MOTOR_TEMPERATURE,
|
||||||
|
NUM_MOTORS,
|
||||||
|
TOPIC,
|
||||||
|
WIRELESS_REMOTE,
|
||||||
|
)
|
||||||
|
from unitree_sdk2py.comm.motion_switcher.motion_switcher_client import MotionSwitcherClient
|
||||||
|
from unitree_sdk2py.core.channel import ChannelFactoryInitialize, ChannelPublisher, ChannelSubscriber
|
||||||
|
from unitree_sdk2py.idl.default import unitree_hg_msg_dds__LowCmd_
|
||||||
|
from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowCmd_ as hg_LowCmd, LowState_ as hg_LowState
|
||||||
|
from unitree_sdk2py.utils.crc import CRC
|
||||||
|
|
||||||
|
# DDS topic names follow Unitree SDK naming conventions
|
||||||
|
# ruff: noqa: N816
|
||||||
|
kTopicLowCommand_Debug = "rt/lowcmd" # action to robot
|
||||||
|
kTopicLowState = "rt/lowstate" # observation from robot
|
||||||
|
|
||||||
|
LOWCMD_PORT = 6000
|
||||||
|
LOWSTATE_PORT = 6001
|
||||||
|
|
||||||
|
|
||||||
|
def lowstate_to_dict(msg: hg_LowState) -> dict[str, Any]:
|
||||||
|
"""Convert LowState SDK message to a JSON-serializable dictionary."""
|
||||||
|
motor_states = []
|
||||||
|
for i in range(NUM_MOTORS):
|
||||||
|
motor_states.append(
|
||||||
|
{
|
||||||
|
MOTOR_Q: msg.motor_state[i].q,
|
||||||
|
MOTOR_DQ: msg.motor_state[i].dq,
|
||||||
|
MOTOR_TAU_EST: msg.motor_state[i].tau_est,
|
||||||
|
MOTOR_TEMPERATURE: msg.motor_state[i].temperature,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
MOTOR_STATE: motor_states,
|
||||||
|
IMU_STATE: {
|
||||||
|
IMU_QUATERNION: list(msg.imu_state.quaternion),
|
||||||
|
IMU_GYROSCOPE: list(msg.imu_state.gyroscope),
|
||||||
|
IMU_ACCELEROMETER: list(msg.imu_state.accelerometer),
|
||||||
|
IMU_RPY: list(msg.imu_state.rpy),
|
||||||
|
IMU_TEMPERATURE: msg.imu_state.temperature,
|
||||||
|
},
|
||||||
|
# Encode bytes as base64 for JSON compatibility
|
||||||
|
WIRELESS_REMOTE: base64.b64encode(bytes(msg.wireless_remote)).decode("ascii"),
|
||||||
|
MODE_MACHINE: msg.mode_machine,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def dict_to_lowcmd(data: dict[str, Any]) -> hg_LowCmd:
|
||||||
|
"""Convert dictionary back to LowCmd SDK message."""
|
||||||
|
cmd = unitree_hg_msg_dds__LowCmd_()
|
||||||
|
cmd.mode_pr = data.get(MODE_PR, 0)
|
||||||
|
cmd.mode_machine = data.get(MODE_MACHINE, 0)
|
||||||
|
|
||||||
|
for i, motor_data in enumerate(data.get(MOTOR_CMD, [])):
|
||||||
|
cmd.motor_cmd[i].mode = motor_data.get(MOTOR_MODE, 0)
|
||||||
|
cmd.motor_cmd[i].q = motor_data.get(MOTOR_Q, 0.0)
|
||||||
|
cmd.motor_cmd[i].dq = motor_data.get(MOTOR_DQ, 0.0)
|
||||||
|
cmd.motor_cmd[i].kp = motor_data.get(MOTOR_KP, 0.0)
|
||||||
|
cmd.motor_cmd[i].kd = motor_data.get(MOTOR_KD, 0.0)
|
||||||
|
cmd.motor_cmd[i].tau = motor_data.get(MOTOR_TAU, 0.0)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
|
||||||
|
def state_forward_loop(
|
||||||
|
lowstate_sub: ChannelSubscriber,
|
||||||
|
lowstate_sock: zmq.Socket,
|
||||||
|
state_period: float,
|
||||||
|
) -> None:
|
||||||
|
"""Read observation from DDS and forward to ZMQ clients."""
|
||||||
|
last_state_time = 0.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# read from DDS
|
||||||
|
msg = lowstate_sub.Read()
|
||||||
|
if msg is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
now = time.time()
|
||||||
|
# optional downsampling (if robot dds rate > state_period)
|
||||||
|
if now - last_state_time >= state_period:
|
||||||
|
# Convert to dict and serialize with JSON
|
||||||
|
state_dict = lowstate_to_dict(msg)
|
||||||
|
payload = json.dumps({TOPIC: kTopicLowState, DATA: state_dict}).encode("utf-8")
|
||||||
|
# if no subscribers / tx buffer full, just drop
|
||||||
|
with contextlib.suppress(zmq.Again):
|
||||||
|
lowstate_sock.send(payload, zmq.NOBLOCK)
|
||||||
|
last_state_time = now
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_forward_loop(
|
||||||
|
lowcmd_sock: zmq.Socket,
|
||||||
|
lowcmd_pub_debug: ChannelPublisher,
|
||||||
|
crc: CRC,
|
||||||
|
) -> None:
|
||||||
|
"""Receive commands from ZMQ and forward to DDS."""
|
||||||
|
while True:
|
||||||
|
payload = lowcmd_sock.recv()
|
||||||
|
msg_dict = json.loads(payload.decode("utf-8"))
|
||||||
|
|
||||||
|
topic = msg_dict.get(TOPIC, "")
|
||||||
|
cmd_data = msg_dict.get(DATA, {})
|
||||||
|
|
||||||
|
# Reconstruct LowCmd object from dict
|
||||||
|
cmd = dict_to_lowcmd(cmd_data)
|
||||||
|
|
||||||
|
# recompute crc
|
||||||
|
cmd.crc = crc.Crc(cmd)
|
||||||
|
|
||||||
|
if topic == kTopicLowCommand_Debug:
|
||||||
|
lowcmd_pub_debug.Write(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Main entry point for the robot server bridge."""
|
||||||
|
# initialize DDS
|
||||||
|
ChannelFactoryInitialize(0)
|
||||||
|
|
||||||
|
# stop all active publishers on the robot
|
||||||
|
msc = MotionSwitcherClient()
|
||||||
|
msc.SetTimeout(5.0)
|
||||||
|
msc.Init()
|
||||||
|
|
||||||
|
status, result = msc.CheckMode()
|
||||||
|
while result is not None and "name" in result and result["name"]:
|
||||||
|
msc.ReleaseMode()
|
||||||
|
status, result = msc.CheckMode()
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
crc = CRC()
|
||||||
|
|
||||||
|
# initialize DDS publisher
|
||||||
|
lowcmd_pub_debug = ChannelPublisher(kTopicLowCommand_Debug, hg_LowCmd)
|
||||||
|
lowcmd_pub_debug.Init()
|
||||||
|
|
||||||
|
# initialize DDS subscriber
|
||||||
|
lowstate_sub = ChannelSubscriber(kTopicLowState, hg_LowState)
|
||||||
|
lowstate_sub.Init()
|
||||||
|
|
||||||
|
# initialize ZMQ
|
||||||
|
ctx = zmq.Context.instance()
|
||||||
|
|
||||||
|
# receive commands from remote client
|
||||||
|
lowcmd_sock = ctx.socket(zmq.PULL)
|
||||||
|
lowcmd_sock.bind(f"tcp://0.0.0.0:{LOWCMD_PORT}")
|
||||||
|
|
||||||
|
# publish state to remote clients
|
||||||
|
lowstate_sock = ctx.socket(zmq.PUB)
|
||||||
|
lowstate_sock.bind(f"tcp://0.0.0.0:{LOWSTATE_PORT}")
|
||||||
|
|
||||||
|
state_period = 0.002 # ~500 hz
|
||||||
|
|
||||||
|
# start observation forwarding thread
|
||||||
|
t_state = threading.Thread(
|
||||||
|
target=state_forward_loop,
|
||||||
|
args=(lowstate_sub, lowstate_sock, state_period),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t_state.start()
|
||||||
|
|
||||||
|
# start action forwarding thread
|
||||||
|
t_cmd = threading.Thread(
|
||||||
|
target=cmd_forward_loop,
|
||||||
|
args=(lowcmd_sock, lowcmd_pub_debug, crc),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
t_cmd.start()
|
||||||
|
|
||||||
|
print("bridge running (lowstate -> zmq, lowcmd -> dds)")
|
||||||
|
# keep main thread alive so daemon threads don't exit
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
time.sleep(1.0)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("shutting down bridge...")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -1,57 +1,185 @@
|
|||||||
import pickle
|
"""
|
||||||
|
ZMQ socket wrapper that mimics the Unitree SDK Channel interface.
|
||||||
|
|
||||||
|
This module provides a drop-in replacement for the Unitree SDK's DDS-based
|
||||||
|
ChannelPublisher and ChannelSubscriber, using ZMQ sockets instead. This allows
|
||||||
|
remote communication with the robot over WiFi via the robot_server bridge.
|
||||||
|
|
||||||
|
Uses JSON for secure serialization instead of pickle.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import zmq
|
import zmq
|
||||||
|
|
||||||
from lerobot.robots.unitree_g1.config_unitree_g1 import UnitreeG1Config
|
from lerobot.robots.unitree_g1.config_unitree_g1 import UnitreeG1Config
|
||||||
|
from lerobot.robots.unitree_g1.g1_utils import (
|
||||||
|
DATA,
|
||||||
|
IMU_ACCELEROMETER,
|
||||||
|
IMU_GYROSCOPE,
|
||||||
|
IMU_QUATERNION,
|
||||||
|
IMU_RPY,
|
||||||
|
IMU_STATE,
|
||||||
|
IMU_TEMPERATURE,
|
||||||
|
MODE_MACHINE,
|
||||||
|
MODE_PR,
|
||||||
|
MOTOR_CMD,
|
||||||
|
MOTOR_DQ,
|
||||||
|
MOTOR_KD,
|
||||||
|
MOTOR_KP,
|
||||||
|
MOTOR_MODE,
|
||||||
|
MOTOR_Q,
|
||||||
|
MOTOR_STATE,
|
||||||
|
MOTOR_TAU,
|
||||||
|
MOTOR_TAU_EST,
|
||||||
|
MOTOR_TEMPERATURE,
|
||||||
|
TOPIC,
|
||||||
|
WIRELESS_REMOTE,
|
||||||
|
)
|
||||||
|
|
||||||
_ctx = None
|
_ctx: zmq.Context | None = None
|
||||||
_lowcmd_sock = None
|
_lowcmd_sock: zmq.Socket | None = None
|
||||||
_lowstate_sock = None
|
_lowstate_sock: zmq.Socket | None = None
|
||||||
|
|
||||||
LOWCMD_PORT = 6000
|
LOWCMD_PORT = 6000
|
||||||
LOWSTATE_PORT = 6001
|
LOWSTATE_PORT = 6001
|
||||||
|
|
||||||
|
# DDS topic names follow Unitree SDK naming conventions
|
||||||
|
# ruff: noqa: N816
|
||||||
|
kTopicLowCommand_Debug = "rt/lowcmd"
|
||||||
|
|
||||||
def ChannelFactoryInitialize(*args, **kwargs): # noqa: N802
|
|
||||||
|
class LowStateMsg:
|
||||||
|
"""
|
||||||
|
Wrapper class that mimics the Unitree SDK LowState_ message structure.
|
||||||
|
|
||||||
|
Reconstructs the message from deserialized JSON data to maintain
|
||||||
|
compatibility with existing code that expects SDK message objects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class MotorState:
|
||||||
|
"""Motor state data for a single joint."""
|
||||||
|
|
||||||
|
def __init__(self, data: dict[str, Any]) -> None:
|
||||||
|
self.q: float = data.get(MOTOR_Q, 0.0)
|
||||||
|
self.dq: float = data.get(MOTOR_DQ, 0.0)
|
||||||
|
self.tau_est: float = data.get(MOTOR_TAU_EST, 0.0)
|
||||||
|
self.temperature: float = data.get(MOTOR_TEMPERATURE, 0.0)
|
||||||
|
|
||||||
|
class IMUState:
|
||||||
|
"""IMU sensor data."""
|
||||||
|
|
||||||
|
def __init__(self, data: dict[str, Any]) -> None:
|
||||||
|
self.quaternion: list[float] = data.get(IMU_QUATERNION, [1.0, 0.0, 0.0, 0.0])
|
||||||
|
self.gyroscope: list[float] = data.get(IMU_GYROSCOPE, [0.0, 0.0, 0.0])
|
||||||
|
self.accelerometer: list[float] = data.get(IMU_ACCELEROMETER, [0.0, 0.0, 0.0])
|
||||||
|
self.rpy: list[float] = data.get(IMU_RPY, [0.0, 0.0, 0.0])
|
||||||
|
self.temperature: float = data.get(IMU_TEMPERATURE, 0.0)
|
||||||
|
|
||||||
|
def __init__(self, data: dict[str, Any]) -> None:
|
||||||
|
"""Initialize from deserialized JSON data."""
|
||||||
|
self.motor_state = [self.MotorState(m) for m in data.get(MOTOR_STATE, [])]
|
||||||
|
self.imu_state = self.IMUState(data.get(IMU_STATE, {}))
|
||||||
|
# Decode base64-encoded wireless_remote bytes
|
||||||
|
wireless_b64 = data.get(WIRELESS_REMOTE, "")
|
||||||
|
self.wireless_remote: bytes = base64.b64decode(wireless_b64) if wireless_b64 else b""
|
||||||
|
self.mode_machine: int = data.get(MODE_MACHINE, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def lowcmd_to_dict(topic: str, msg: Any) -> dict[str, Any]:
|
||||||
|
"""Convert LowCmd message to a JSON-serializable dictionary."""
|
||||||
|
motor_cmds = []
|
||||||
|
# Iterate over all motor commands in the message
|
||||||
|
for i in range(len(msg.motor_cmd)):
|
||||||
|
motor_cmds.append(
|
||||||
|
{
|
||||||
|
MOTOR_MODE: msg.motor_cmd[i].mode,
|
||||||
|
MOTOR_Q: msg.motor_cmd[i].q,
|
||||||
|
MOTOR_DQ: msg.motor_cmd[i].dq,
|
||||||
|
MOTOR_KP: msg.motor_cmd[i].kp,
|
||||||
|
MOTOR_KD: msg.motor_cmd[i].kd,
|
||||||
|
MOTOR_TAU: msg.motor_cmd[i].tau,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
TOPIC: topic,
|
||||||
|
DATA: {
|
||||||
|
MODE_PR: msg.mode_pr,
|
||||||
|
MODE_MACHINE: msg.mode_machine,
|
||||||
|
MOTOR_CMD: motor_cmds,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ChannelFactoryInitialize(*args: Any, **kwargs: Any) -> None: # noqa: N802
|
||||||
|
"""
|
||||||
|
Initialize ZMQ sockets for robot communication.
|
||||||
|
|
||||||
|
This function mimics the Unitree SDK's ChannelFactoryInitialize but uses
|
||||||
|
ZMQ sockets to connect to the robot server bridge instead of DDS.
|
||||||
|
"""
|
||||||
global _ctx, _lowcmd_sock, _lowstate_sock
|
global _ctx, _lowcmd_sock, _lowstate_sock
|
||||||
|
|
||||||
# read socket config
|
# read socket config
|
||||||
config = UnitreeG1Config()
|
config = UnitreeG1Config()
|
||||||
robot_ip = config.robot_ip
|
robot_ip = config.robot_ip
|
||||||
|
|
||||||
_ctx = zmq.Context.instance()
|
ctx = zmq.Context.instance()
|
||||||
|
_ctx = ctx
|
||||||
|
|
||||||
# lowcmd: robot action
|
# lowcmd: send robot commands
|
||||||
_lowcmd_sock = _ctx.socket(zmq.PUSH)
|
lowcmd_sock = ctx.socket(zmq.PUSH)
|
||||||
_lowcmd_sock.setsockopt(zmq.CONFLATE, 1) # keep only last message
|
lowcmd_sock.setsockopt(zmq.CONFLATE, 1) # keep only last message
|
||||||
_lowcmd_sock.connect(f"tcp://{robot_ip}:{LOWCMD_PORT}")
|
lowcmd_sock.connect(f"tcp://{robot_ip}:{LOWCMD_PORT}")
|
||||||
|
_lowcmd_sock = lowcmd_sock
|
||||||
|
|
||||||
# lowstate: robot observation
|
# lowstate: receive robot observations
|
||||||
_lowstate_sock = _ctx.socket(zmq.SUB)
|
lowstate_sock = ctx.socket(zmq.SUB)
|
||||||
_lowstate_sock.setsockopt(zmq.CONFLATE, 1) # keep only last message
|
lowstate_sock.setsockopt(zmq.CONFLATE, 1) # keep only last message
|
||||||
_lowstate_sock.connect(f"tcp://{robot_ip}:{LOWSTATE_PORT}")
|
lowstate_sock.connect(f"tcp://{robot_ip}:{LOWSTATE_PORT}")
|
||||||
_lowstate_sock.setsockopt_string(zmq.SUBSCRIBE, "")
|
lowstate_sock.setsockopt_string(zmq.SUBSCRIBE, "")
|
||||||
|
_lowstate_sock = lowstate_sock
|
||||||
|
|
||||||
|
|
||||||
class ChannelPublisher: # send action to robot
|
class ChannelPublisher:
|
||||||
def __init__(self, topic, msg_type):
|
"""ZMQ-based publisher that sends commands to the robot server."""
|
||||||
|
|
||||||
|
def __init__(self, topic: str, msg_type: type) -> None:
|
||||||
self.topic = topic
|
self.topic = topic
|
||||||
self.msg_type = msg_type
|
self.msg_type = msg_type
|
||||||
|
|
||||||
def Init(self): # noqa: N802
|
def Init(self) -> None: # noqa: N802
|
||||||
|
"""Initialize the publisher (no-op for ZMQ)."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def Write(self, msg): # noqa: N802
|
def Write(self, msg: Any) -> None: # noqa: N802
|
||||||
_lowcmd_sock.send(pickle.dumps((self.topic, msg)))
|
"""Serialize and send a command message to the robot."""
|
||||||
|
if _lowcmd_sock is None:
|
||||||
|
raise RuntimeError("ChannelFactoryInitialize must be called first")
|
||||||
|
|
||||||
|
payload = json.dumps(lowcmd_to_dict(self.topic, msg)).encode("utf-8")
|
||||||
|
_lowcmd_sock.send(payload)
|
||||||
|
|
||||||
|
|
||||||
class ChannelSubscriber: # read observation from robot
|
class ChannelSubscriber:
|
||||||
def __init__(self, topic, msg_type):
|
"""ZMQ-based subscriber that receives state from the robot server."""
|
||||||
|
|
||||||
|
def __init__(self, topic: str, msg_type: type) -> None:
|
||||||
self.topic = topic
|
self.topic = topic
|
||||||
self.msg_type = msg_type
|
self.msg_type = msg_type
|
||||||
|
|
||||||
def Init(self): # noqa: N802
|
def Init(self) -> None: # noqa: N802
|
||||||
|
"""Initialize the subscriber (no-op for ZMQ)."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def Read(self): # noqa: N802
|
def Read(self) -> LowStateMsg: # noqa: N802
|
||||||
topic, msg = pickle.loads(_lowstate_sock.recv())
|
"""Receive and deserialize a state message from the robot."""
|
||||||
return msg
|
if _lowstate_sock is None:
|
||||||
|
raise RuntimeError("ChannelFactoryInitialize must be called first")
|
||||||
|
|
||||||
|
payload = _lowstate_sock.recv()
|
||||||
|
msg_dict = json.loads(payload.decode("utf-8"))
|
||||||
|
return LowStateMsg(msg_dict.get(DATA, {}))
|
||||||
|
|||||||
Reference in New Issue
Block a user