#!/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