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.
282 lines
12 KiB
Markdown
282 lines
12 KiB
Markdown
# 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.
|