kassam 2a78f1b609 Record/replay studio, manual recorder kit, and new-robot install tooling
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.
2026-08-28 20:27:28 +04:00

12 KiB
Raw Blame History

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 the idl headers) and internet. build_image.sh pulls 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_datahand_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 Python cyclonedds wheel compiles a _idlpy extension 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.