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

165 lines
6.2 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# start.sh — ONE command to bring up the whole hand stack.
#
# Starts the Docker container that runs all three pieces together — the RS-485
# service (inspire_g1), the TCP->DDS bridge (hand_bridge) and the web dashboard
# on :8088 — then waits until the dashboard actually answers before telling you
# it is up.
#
# ./start.sh start it (builds the image the first time)
# ./start.sh stop stop the container
# ./start.sh restart stop + start
# ./start.sh status is it up, what is it serving, are the hands alive
# ./start.sh logs follow the container log
# ./start.sh rebuild force an image rebuild, then start
#
# 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 interface and Docker are
# all on the robot, so there is nothing for it to do locally.
#
# Env: ROBOT=unitree@192.168.123.164 (override for the wifi IP)
# PORT=8088
# =============================================================================
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
ROBOT="${ROBOT:-unitree@192.168.123.164}"
REMOTE_DIR='~/DFX_inspire_service'
PORT="${PORT:-8088}"
IMG="${IMG:-inspire-hand:latest}"
CMD="${1:-start}"
say() { printf '\033[36m>>\033[0m %s\n' "$*"; }
ok() { printf '\033[32m>>\033[0m %s\n' "$*"; }
die() { printf '\033[31m>>\033[0m %s\n' "$*" >&2; exit 1; }
# The robot is the only place this can run: it owns the hands (/dev/ttyUSB*), the
# DDS interface to the arm, and Docker. aarch64 is the reliable tell — the
# workstation is x86_64 and the image is built for arm64.
is_robot() { [ "$(uname -m)" = "aarch64" ] && command -v docker >/dev/null 2>&1; }
remote() {
say "not the robot — deploying to $ROBOT and running there"
command -v rsync >/dev/null || die "rsync not installed"
# build/ is excluded: the image compiles its own binaries, and the host copy is
# 55MB of artifacts that only the (optional) native path uses.
rsync -az --delete --exclude='build/' --exclude='docker/.ctx/' \
-e "ssh -o ConnectTimeout=8" "$HERE/" "$ROBOT:$REMOTE_DIR/"
# shellcheck disable=SC2029
ssh -o ConnectTimeout=8 -t "$ROBOT" "cd $REMOTE_DIR && ./start.sh $CMD"
exit $?
}
have_image() { docker image inspect "$IMG" >/dev/null 2>&1; } # (kept for status checks)
# run.sh tails the container log, which spills the dashboard's HTTP lines over this
# script's own output. Keep it unless something actually fails.
run_quiet() {
local log="/tmp/hand_start.$$.log"
if ! "$HERE/docker/run.sh" >"$log" 2>&1; then
cat "$log"; rm -f "$log"; die "docker run failed"
fi
rm -f "$log"
}
build() {
say "building $IMG (compiles the binaries + python DDS in-image; a few minutes)"
"$HERE/docker/build_image.sh"
}
wait_up() {
# Poll the dashboard rather than trusting `docker run` — the container can be up
# while the service inside is still opening the serial ports, and reporting a URL
# that 404s is worse than waiting three more seconds for it.
say "waiting for the dashboard..."
for _ in $(seq 1 40); do
if curl -fsS "http://127.0.0.1:$PORT/api/status" >/dev/null 2>&1; then
return 0
fi
sleep 1
done
return 1
}
report() {
local s
s="$(curl -fsS "http://127.0.0.1:$PORT/api/status" 2>/dev/null || true)"
[ -n "$s" ] || { echo " dashboard not answering on :$PORT"; return 1; }
python3 - "$s" <<'PY' 2>/dev/null || echo " $s"
import json, sys
d = json.loads(sys.argv[1])
print(" service=%s bridge=%s iface=%s ports=%s"
% (d.get("service"), d.get("bridge"), d.get("iface"), ", ".join(d.get("ports") or []) or "none"))
if d.get("mismatch"):
print(" WARNING: port drift — the service opened different ttys than are live now")
PY
local r
r="$(curl -fsS "http://127.0.0.1:$PORT/api/rec/status" 2>/dev/null || true)"
case "$r" in
*'"ready":true'*) echo " recorder: ready (record/replay panel enabled)" ;;
*'"ready":false'*) echo " recorder: NOT found — rebuild with ./start.sh rebuild" ;;
esac
# Skip docker's own bridge addresses (172.17-19.x) — nothing outside the robot can
# reach those, and printing them as if they were the dashboard URL wastes a click.
for ip in $(hostname -I 2>/dev/null); do
case "$ip" in 172.1[789].*|127.*) continue ;; esac
echo " http://$ip:$PORT"
done
}
is_robot || remote
case "$CMD" in
start|restart)
# ALWAYS build, never "build only if the image is missing". Docker's layer cache makes
# this a second or two when nothing changed, and seconds when only web/ changed — but
# skipping it means an edited dashboard silently keeps running the old image, which is
# a very expensive minute of confusion when a fix "does not work".
build
run_quiet
wait_up || die "dashboard did not come up — ./start.sh logs"
ok "up"
report
;;
rebuild)
build
run_quiet
wait_up || die "dashboard did not come up — ./start.sh logs"
ok "rebuilt and up"
report
;;
stop)
"$HERE/docker/stop.sh"
;;
status)
docker ps --filter name=inspire-hand --format ' {{.Names}} · {{.Status}} · {{.Image}}' | grep . \
|| echo " inspire-hand is not running"
report || true
;;
logs)
docker logs -f --tail 60 inspire-hand
;;
usbfix)
# The card reader (05e3:0749) shares the hands' USB hub and power-cycles constantly with
# no card in it, re-enumerating the branch and renumbering /dev/ttyUSB*. Deauthorizing
# needs root, which this box asks a password for — hence a subcommand rather than
# something ./start.sh does silently.
say "installing the udev rule + deauthorizing the card reader (asks for your password)"
sudo cp "$HERE/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" >/dev/null
ok "deauthorized $(basename "$d")"
}
done
ls /dev/ttyUSB* 2>/dev/null | sed 's/^/ still present: /'
;;
*)
sed -n '2,26p' "$0" | sed 's/^# \{0,1\}//'
exit 1
;;
esac