# 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 <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](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>/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.

```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.
