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

282 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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