Dashboard (web/hand_web.py) - Record/replay panel driving g1_record_replay.py as a pty child: take library (replay/download/duplicate/rename/delete/upload/delete-all), pause & resume, and in-take key buttons that grey out in the --fingers modes the recorder ignores (measured: in touch mode the keys change nothing at all). - /api/restart is container-aware: it kills inspire_g1 and lets the supervisor relaunch it. It used to run manage.sh, which started a SECOND inspire_g1 beside the supervised one - two writers on one RS-485 bus - and never returned. - Shape/combo libraries take a .bak on every write, with an undo button. Both files are rewritten in full, so deleting the last entry was unrecoverable. recorder/ - The recorder lives in this project now: one source of truth for the CLI and the dashboard, with pause/resume added to replay. - record.sh picks a runtime by itself (a python with the SDK, the vendored SDK, or the inspire-hand container). bundle.sh packs a ~340KB portable kit. tools/ - preflight.sh: read-only readiness report for a new robot (hardware, docker, build prerequisites, per-robot settings) ending in an install-path verdict. - fetch_deps.sh: stage build dependencies, verifying the libs are aarch64. - export_ui.py: regenerate an embedding app's vendored copy of the UI. docker/ - build_image.sh resolves its dependencies from several layouts: deps/ inside the project, /usr/local, a source install prefix, or a ROS2 colcon workspace (where the idl headers live when /usr/local has none). - web/ is copied in the last layer, so dashboard edits skip the C++ rebuild. - restart=always, and start.sh always builds so an edit cannot silently run a stale image. deps/unitree_sdk2 is vendored so a robot that has never seen the SDK can build. Docs: README quickstart + embedding notes, SETUP_G1 corrected (that udev rule stopped creating /dev/inspire_* symlinks a while ago), ROBOT_README describing a live install.
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
On a NEW robot, check first
./tools/preflight.sh
Changes nothing; prints PASS/MISS for hardware, Docker, the build prerequisites and the per-robot settings that misbehave silently, then tells you which install path is open. Two paths:
- Bring the image (no SDK, no internet, no compile on the target):
docker save inspire-hand:latest | gzip > inspire-hand.tar.gz→ copy →gunzip -c inspire-hand.tar.gz | docker load - Build there — needs
~/unitree_sdk2, CycloneDDS in/usr/local(including theidlheaders) and internet.build_image.shpulls those from the host.
Then, once per robot, give the LEFT hand RS-485 id 2 —
docker exec inspire-hand ./build/hand_setid <left-port> 1 2. After that the hands are
identified by id rather than USB path, so no pin can swap them. See SETUP_G1.md.
| command | does |
|---|---|
./tools/preflight.sh |
can this robot run it, and what's missing |
./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 |
It does not start by itself. There is no restart policy and no systemd unit — a rebooted
robot comes up with the hands free, and this stack takes them only when you run ./start.sh.
Stop it with ./start.sh stop.
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.
Without the dashboard
The same script runs from a terminal — see recorder/README.md:
cd recorder
./record.sh doctor # which runtime it will use
./record.sh record --output wave --seconds 20
./bundle.sh # pack it to carry elsewhere (~2 MB)
record.sh picks a runtime for you (a python with the SDK, the vendored SDK, or the
inspire-hand container) so the CLI still works when Docker or the dashboard is down.
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. Harmless in
itself: inspire_g1 pins each hand by USB path, not by ttyUSB number, so left/right never
swap. The churn comes from a card reader (05e3:0749) sharing the hands' hub and
power-cycling ~851×/hour with no card in it. Stop it for good (needs root, once):
sudo cp udev/99-inspire-hub-cardreader.rules /etc/udev/rules.d/ && sudo udevadm control --reload
for d in /sys/bus/usb/devices/*/; do \
[ "$(cat $d/idVendor 2>/dev/null)" = "05e3" ] && [ "$(cat $d/idProduct 2>/dev/null)" = "0749" ] \
&& echo 0 | sudo tee $d/authorized; done
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.
Embedding the dashboard elsewhere
Sanad's Hands tab serves a code copy of this UI with every /api/ rewritten to
/api/hands/, and proxies those calls back to :8088. A hand-extracted copy goes stale
silently, so regenerate it from the source instead:
# after any dashboard change
python3 tools/export_ui.py --prefix hands --out <sanad>/Sanad/dashboard/static/hands.html
# or just check whether a copy is current (exit 1 if stale)
python3 tools/export_ui.py --prefix hands --check <sanad>/Sanad/dashboard/static/hands.html
The embedding app needs network_mode: host (or an explicit SANAD_HANDS_BASE) to reach
127.0.0.1:8088, and this stack must be running — see the start note above.
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.