# 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](https://support.unitree.com/home/en/H1_developer/Dexterous_hand); the service protocol section at the bottom is theirs. --- ## Run it ```bash ./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 ```bash ./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 1 2`. After that the hands are identified by id rather than USB path, so no pin can swap them. See [SETUP_G1.md](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`](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](recorder/README.md): ```bash 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 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): ```bash 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](SETUP_G1.md). Docker specifics: [docker/README.md](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: ```bash # after any dashboard change python3 tools/export_ui.py --prefix hands --out /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/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. ```mermaid 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.