9.2 KiB
Inspire RH56 dual-hand stack for the Unitree G1
Runs the two Inspire RH56DFX hands on the G1's PC2 and puts a web dashboard in front of them: finger sliders, saved shapes, force-follow, live diagnostics, arm actions, and arm + hand record / replay.
Everything runs in one Docker container on the robot. The workstation is only where you edit the source and push it.
Forked from Unitree's DFX inspire hand service; the service protocol section at the bottom is theirs.
Run it
./start.sh
That is the whole thing. It builds the image the first time (a few minutes), starts the container, waits until the dashboard actually answers, and prints the URL:
>> up
service=True bridge=True iface=eth0 ports=/dev/ttyUSB1, /dev/ttyUSB2
recorder: ready (record/replay panel enabled)
http://192.168.123.164:8088
http://10.255.254.88:8088
| command | does |
|---|---|
./start.sh |
start (builds the image if missing) |
./start.sh stop |
stop the container |
./start.sh restart |
stop + start |
./start.sh status |
running? serving what? hands alive? |
./start.sh logs |
follow the container log |
./start.sh rebuild |
force an image rebuild, then start |
Run it on the robot. Run it on the workstation and it rsyncs this folder to the robot
and runs itself there — the hands, the DDS link to the arm and Docker are all on the robot,
so there is nothing to run locally. Override the target with ROBOT=unitree@10.255.254.88.
Only ./start.sh is meant to be run by hand. docker/start.sh is the container's internal
entrypoint; running it directly starts the services natively, outside Docker.
What you get at :8088
| card | what it does |
|---|---|
| Fingers | 12 sliders, presets, save/load named shapes |
| Force follow | press a fingertip and that finger closes under your hand — the RH56 cannot be back-driven, so this admittance loop is the only way to shape it by hand |
| Joint tracker | angle traces + per-finger force, current, force limit, temperature, ERROR/STATUS |
| Diagnostics | live read-back and a close→open self-test, reported per hand |
| Read back | copy the hand's current pose into the sliders |
| Arm + hand combo | built-in arm actions fused with a hand pose ⚠ arm moves |
| Record / replay | record the arm + hand, replay it, manage the library ⚠ arm moves |
| Library | manage saved shapes and combos |
Record / replay
Runs recorder/g1_record_replay.py as a child process over a
pty — the same script you would run from a terminal, so a take made in the browser and one
made from the CLI are the same file in DataG1/.
A take: arms hold for ~3 s, then go limp so you pose them by hand. Fingers cannot be posed by hand, so they are driven by the in-take keys or your saved shapes. At the end the recorder offers a preview (⚠ replays the take — the arm moves) and then a save; both are answered in the panel.
Stop sends SIGINT, so the recorder walks the arm home and releases it. It does not kill.
Replay has ⏸ Pause / ▶ Resume. Pause freezes the playback clock but keeps publishing the
frame it stopped on — the arm holds the pose rather than dropping, because rt/arm_sdk needs
a continuous command.
In-take keys — and when they do nothing
o both to rest · c both closed · [ ] right open/close · ; ' left open/close ·
f arm/disarm follow · 1-9 your saved shapes.
Which of them the recorder acts on depends on --fingers. Measured against the real recorder
while watching rt/inspire/cmd:
--fingers |
what the keys do | records |
|---|---|---|
touch (default) |
nothing — the recorder holds the rest pose all take | how far a finger closes, from how hard you press it |
position |
drive the hand for real | the finger's measured angle |
external |
nothing — the recorder publishes no hand commands at all | the angle reached by the :8088 sliders |
follow |
only f and the shape keys reach the hand |
the angle you push a finger into |
The panel greys out keys the current mode ignores and says why, so a dead button is never a
mystery. Note [ and ; also un-park thumb_rot, while o returns it to tucked.
The take library
Per take: ▶ replay · ⬇ download · ⧉ duplicate · ✎ rename · 🗑 delete, plus ⬆ Upload
(validated: the first line must be the recorder's own meta header) and 🗑 Delete all.
arm_home.jsonl is hidden and protected — it is the home pose every take returns through,
not a recording.
Takes live on the host at ~/hand_data/DataG1, bind-mounted into the container.
docker/run.sh begins with docker rm -f, so anything kept inside the image would be
destroyed on every restart.
Where things are
| Workstation (canonical source) | ~/Robotics_workspace/yslootahtech/Project/G1/DFX_inspire_service |
| Robot | ~/DFX_inspire_service |
| In the container | /opt/hand (service + dashboard) · /opt/recorder (recorder + DataG1) |
| Host data (survives rebuilds) | ~/hand_data — hand_poses.txt, hand_combos.json, DataG1/ |
inspire_g1 RS-485 -> both hands, publishes rt/inspire/state
hand_bridge TCP 127.0.0.1:7799 -> rt/inspire/cmd, plus the force-follow loop at 30Hz
hand_web.py the dashboard on :8088, and it spawns the recorder
All three run inside the single inspire-hand container (--network host --privileged -v /dev:/dev — DDS needs the host network, the CH340 adapters re-enumerate).
What is in the image
Self-contained from a clean checkout: recorder/ (the recorder + home pose),
vendor/unitree_sdk2_python/, web/, example/, include/.
The build still reads three things from the robot's own system install, because they are compiled libraries rather than source:
~/unitree_sdk2— the C++ static lib the binaries link against/usr/local/{include,lib}— CycloneDDS headers and runtime~/cyclonedds/install/include/idl— IDL headers; the Pythoncycloneddswheel compiles a_idlpyextension against them, and a normal CycloneDDS install does not put them in/usr/local
So: runtime is 100% Docker; building the image needs the robot's SDK. Override the
locations with UNITREE_SDK2_DIR, CYCLONEDDS_SRC, RECORDER_SRC, UNITREE_SDK2PY_DIR.
web/ is copied in the last layer, so editing the dashboard rebuilds in seconds instead of
recompiling every binary.
Troubleshooting
recorder: NOT found — the image was built without recorder/ or vendor/. Rebuild:
./start.sh rebuild.
Stop does nothing during a take — the dashboard was started with SIGINT ignored (any
process backgrounded by a non-interactive shell inherits SIG_IGN, and it survives exec).
hand_web.py repairs this at startup; if the repair fails the panel says so. Restart it.
Fingers don't move (thumb might) — usually a latched actuator fault from two inspire_g1
running at once. ./start.sh restart re-runs ClearError. Still stuck: power-cycle the
hands (24 V).
"Port drift" warning — the service opened different ttys than are live now; the CH340 adapters re-enumerate on the hub. Restart.
A finger reads as dead (0 current, no movement) — its force-sensor zero has drifted past
the grip-force limit, so the RH56 refuses to drive it. inspire_g1 measures the resting
offset at startup and raises that hand's limit to clear it; the log names the fingers. The
real fix is re-zeroing the sensor with the hand unloaded.
rt/inspire/state reads all zeros — cosmetic; the CH340 adapters echo their own TX, so
position read-back is unreliable. Commands are unaffected.
Two inspire_g1 instances — they collide on the RS-485 bus. docker/run.sh kills
host-native copies before starting the container. Kill with pkill -x inspire_g1, never
pkill -f.
Native (non-Docker) path
Kept for bring-up and debugging; manage.sh runs the services directly on the robot
(./manage.sh status|start|restart|dashboard|...). It needs ./build.sh first, since the
compiled build/ is not shipped. Do not run it while the container is up — two writers on
the RS-485 bus corrupt the hands.
Serial setup, udev rules and hand-side wiring: SETUP_G1.md. Docker specifics: docker/README.md.
Service protocol (upstream)
Send unitree_go::msg::dds::MotorCmds_ to rt/inspire/cmd; read
unitree_go::msg::dds::MotorStates_ from rt/inspire/state. Only q is meaningful —
1.00 = finger open, 0.00 = closed.
graph LR
A(user) --rt/inspire/cmd--> B(H1 or G1)
B --rt/inspire/state--> A
| id | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| hand | right | right | right | right | right | right | left | left | left | left | left | left |
| joint | pinky | ring | middle | index | thumb-bend | thumb-rot | pinky | ring | middle | index | thumb-bend | thumb-rot |
hand_bridge also serves this over TCP on 127.0.0.1:7799 (S = state+force, R = state,
F = force-follow), which is how the dashboard talks to the hands without a DDS stack of its
own.