Skill: afalk42/reachy-mini (version 1.1.0)

--- name: reachy-mini description: Control a Reachy Mini robot (by Pollen Robotics / Hugging Face) via its REST API and SSH. Use for any request involving the Reachy Mini robot — moving the head, body, or antennas; playing emotions or dances; capturing camera snapshots; adjusting volume; managing apps; checking robot status; or any physical robot interaction. The robot has a 6-DoF head, 360° body rotation, two animated antennas, a wide-angle camera (with non-disruptive WebRTC snapshot), 4-mic array, and speaker. --- # Reachy Mini Robot Control ## Quick Start Use the CLI script or `curl` to control the robot. The script lives at: ``` ~/clawd/skills/reachy-mini/scripts/reachy.sh ``` Set the robot IP via `REACHY_HOST` env var or `--host` flag. Default: `192.168.8.17`. ### Common Commands ```bash reachy.sh status # Daemon status, version, IP reachy.sh state # Full robot state reachy.sh wake-up # Wake the robot reachy.sh sleep # Put to sleep reachy.sh snap # Camera snapshot → /tmp/reachy_snap.jpg reachy.sh snap /path/to/photo.jpg # Snapshot to custom path reachy.sh play-emotion cheerful1 # Play an emotion reachy.sh play-dance groovy_sway_and_roll # Play a dance reachy.sh goto --head 0.2,0,0 --duration 1.5 # Nod down reachy.sh volume-set 70 # Set speaker volume reachy.sh emotions # List all emotions reachy.sh dances # List all dances ``` ## Environment | Variable | Default | Description | |----------|---------|-------------| | `REACHY_HOST` | `192.168.8.17` | Robot IP address | | `REACHY_PORT` | `8000` | REST API port | | `REACHY_SSH_USER` | `pollen` | SSH username (for `snap` command) | | `REACHY_SSH_PASS` | `root` | SSH password (for `snap` command, uses `sshpass`) | ## Movement Guide ### Head Control (6 DoF) The head accepts pitch, yaw, roll in **radians**: - **Pitch** (look up/down): -0.5 (up) to 0.5 (down) - **Yaw** (look left/right): -0.8 (right) to 0.8 (left) - **Roll** (tilt sideways): -0.5 to 0.5 ```bash # Look up reachy.sh goto --head -0.3,0,0 --duration 1.0 # Look left reachy.sh goto --head 0,0.4,0 --duration 1.0 # Tilt head right, look slightly up reachy.sh goto --head -0.1,0,-0.3 --duration 1.5 # Return to neutral reachy.sh goto --head 0,0,0 --duration 1.0 ``` ### Body Rotation (360°) Body yaw in radians. 0 = forward, positive = left, negative = right. ```bash reachy.sh goto --body 1.57 --duration 2.0 # Turn 90° left reachy.sh goto --body -1.57 --duration 2.0 # Turn 90° right reachy.sh goto --body 0 --duration 2.0 # Face forward ``` ### Antennas Two antennas [left, right] in radians. Range ~-0.5 to 0.5. ```bash reachy.sh goto --antennas 0.4,0.4 --duration 0.5 # Both up reachy.sh goto --antennas -0.3,-0.3 --duration 0.5 # Both down reachy.sh goto --antennas 0.4,-0.4 --duration 0.5 # Asymmetric ``` ### Combined Movements ```bash # Look left and turn body left with antennas up reachy.sh goto --head 0,0.3,0 --body 0.5 --antennas 0.4,0.4 --duration 2.0 ``` ### Interpolation Modes Use `--interp` with goto: - `minjerk` — Smooth, natural (default) - `linear` — Constant speed - `ease` — Ease in/out - `cartoon` — Bouncy, exaggerated ## Emotions & Dances ### Playing Emotions 80+ pre-recorded expressive animations. Select contextually appropriate ones: ```bash reachy.sh play-emotion curious1 # Curious look reachy.sh play-emotion cheerful1 # Happy expression reachy.sh play-emotion surprised1 # Surprise reaction reachy.sh play-emotion thoughtful1 # Thinking pose reachy.sh play-emotion welcoming1 # Greeting gesture reachy.sh play-emotion yes1 # Nodding yes reachy.sh play-emotion no1 # Shaking no ``` ### Playing Dances 19 dance moves, great for fun or celebration: ```bash reachy.sh play-dance groovy_sway_and_roll reachy.sh play-dance chicken_peck reachy.sh play-dance dizzy_spin ``` ### Full Lists Run `reachy.sh emotions` or `reachy.sh dances` to see all available moves. ## Motor Modes Before movement, motors must be `enabled`. Check with `reachy.sh motors`. ```bash reachy.sh motors-enable # Enable (needed for movement commands) reachy.sh motors-disable # Disable (robot goes limp) reachy.sh motors-gravity # Gravity compensation (manually pose the robot) ``` ## Volume Control ```bash reachy.sh volume # Current speaker volume reachy.sh volume-set 50 # Set speaker to 50% reachy.sh volume-test # Play test sound reachy.sh mic-volume # Microphone level reachy.sh mic-volume-set 80 # Set microphone to 80% ``` ## App Management Reachy Mini runs HuggingFace Space apps. Manage them via: ```bash reachy.sh apps # List all available apps reachy.sh apps-installed # Installed apps only reachy.sh app-status # What's running now reachy.sh app-start NAME # Start an app reachy.sh app-stop # Stop current app ``` **Important**: Only one app runs at a time. Starting a new app stops the current one. Apps may take exclusive control of the robot — stop the running app before sending manual movement commands if the robot doesn't respond. ## Camera Snapshots Capture JPEG photos from the robot's camera (IMX708 wide-angle) via WebRTC — **non-disruptive** to the running daemon. ```bash reachy.sh snap # Save to /tmp/reachy_snap.jpg reachy.sh snap /path/to/output.jpg # Custom output path ``` **Requirements**: SSH access to the robot (uses `sshpass` + `REACHY_SSH_PASS` env var, default: `root`). **How it works**: Connects to the daemon's WebRTC signalling server (port 8443) using GStreamer's `webrtcsrc` plugin on the robot, captures one H264-decoded frame, and saves as JPEG. No daemon restart, no motor disruption. **Note**: The robot must be **awake** (head up) for a useful image. If asleep, the camera faces into the body. Run `reachy.sh wake-up` first. ## Audio Sensing ```bash reachy.sh doa # Direction of Arrival from mic array ``` Returns angle in radians (0=left, π/2=front, π=right) and speech detection boolean. ## Contextual Reactions (Clawdbot Integration) Use `reachy-react.sh` to trigger contextual robot behaviors from heartbeats, cron jobs, or session responses. ``` ~/clawd/skills/reachy-mini/scripts/reachy-react.sh ``` ### Reactions ```bash reachy-react.sh ack # Nod acknowledgment (received a request) reachy-react.sh success # Cheerful emotion (task done) reachy-react.sh alert # Surprised + antennas up (urgent email, alert) reachy-react.sh remind # Welcoming/curious (meeting reminder, to-do) reachy-react.sh idle # Subtle animation (heartbeat presence) reachy-react.sh morning # Wake up + greeting (morning briefing) reachy-react.sh goodnight # Sleepy emotion + sleep (night mode) reachy-react.sh patrol # Camera snapshot, prints image path reachy-react.sh doa-track # Turn head toward detected sound source reachy-react.sh celebrate # Random dance (fun moments) ``` Pass `--bg` to run in background (non-blocking). ### Built-in Behaviors - **Quiet hours** (22:00–06:29 ET): All reactions except `morning`, `goodnight`, and `patrol` are silently skipped. - **Auto-wake**: Reactions ensure the robot is awake before acting (starts daemon + wakes if needed). - **Fault-tolerant**: If robot is unreachable, reactions exit cleanly without errors. ### Integration Points | Trigger | Reaction | Notes | |---------|----------|-------| | Morning briefing cron (6:30 AM) | `morning` | Robot wakes up and greets | | Goodnight cron (10:00 PM) | `goodnight` | Robot plays sleepy emotion, goes to sleep | | Heartbeat (periodic) | `idle` | Subtle head tilt, antenna wave, or look-around | | Heartbeat (~1 in 4) | `doa-track` | Checks for nearby speech, turns toward it | | Heartbeat (~1 in 6) | `patrol` | Camera snapshot for room awareness | | Important unread email | `alert` | Antennas up + surprised emotion | | Meeting <2h away | `remind` | Welcoming/curious emotion | | Request from Alexander | `ack` | Quick head nod | | Task completed | `success` | Random cheerful/happy emotion | | Good news or celebration | `celebrate` | Random dance move | ### DOA (Direction of Arrival) Tracking The `doa-track` reaction uses the robot's 4-mic array to detect speech direction and turn the head toward the speaker. The DOA angle (0=left, π/2=front, π=right) is mapped to head yaw. Only triggers when speech is actively detected. ### Camera Patrol The `patrol` reaction captures a snapshot and prints the image path. Use this during heartbeats to check the room periodically. Combine with image analysis to detect activity or changes. ## Direct API Access For anything not covered by the CLI, use `curl` or the `raw` command: ```bash # Via raw command reachy.sh raw GET /api/state/full reachy.sh raw POST /api/move/goto '{"duration":1.0,"head_pose":{"pitch":0.2,"yaw":0,"roll":0}}' # Via curl directly curl -s http://192.168.8.17:8000/api/state/full | jq curl -s -X POST -H "Content-Type: application/json" \ -d '{"duration":1.5,"head_pose":{"pitch":0,"yaw":0.3,"roll":0}}' \ http://192.168.8.17:8000/api/move/goto ``` ## Reference For the complete API endpoint list, schemas (GotoModelRequest, FullBodyTarget, XYZRPYPose), and full emotion/dance catalogs, see [references/api-reference.md](references/api-reference.md). ## Troubleshooting - **Robot doesn't move**: Check `reachy.sh motors` — must be `enabled`. Run `reachy.sh motors-enable`. - **No response**: Check `reachy.sh status`. State should be `running`. If not, run `reachy.sh reboot-daemon`. - **Movements ignored**: An app may have exclusive control. Run `reachy.sh app-stop` first. - **Network unreachable**: Verify the robot IP with `ping $REACHY_HOST`. Check `reachy.sh wifi-status`. - **Snap shows black image**: Robot is likely asleep (head down). Run `reachy.sh wake-up` first. - **Snap fails with SSH error**: Ensure `sshpass` is installed and `REACHY_SSH_PASS` is set correctly.

path: scripts/reachy-gst-snap.py
content: #!/usr/bin/env python3
"""Capture a single frame from Reachy Mini via GStreamer WebRTC (local).

Must run ON the robot with GST_PLUGIN_PATH set to /opt/gst-plugins-rs/lib/aarch64-linux-gnu/gstreamer-1.0
"""

import os
import sys
import time
from threading import Thread

import gi
gi.require_version("Gst", "1.0")
gi.require_version("GstApp", "1.0")
from gi.repository import Gst, GstApp, GLib
import numpy as np
from PIL import Image

OUTPUT = sys.argv[1] if len(sys.argv) > 1 else "/tmp/reachy_snap.jpg"
PRODUCER_ID = sys.argv[2] if len(sys.argv) > 2 else ""
SIGNAL_URI = sys.argv[3] if len(sys.argv) > 3 else "ws://127.0.0.1:8443"
TIMEOUT = 15


def main():
    Gst.init(None)

    loop = GLib.MainLoop()
    thread = Thread(target=loop.run, daemon=True)
    thread.start()

    pipeline = Gst.Pipeline.new("snap")
    bus = pipeline.get_bus()
    bus.add_watch(GLib.PRIORITY_DEFAULT, on_bus_message, loop)

    # Create webrtcsrc
    webrtcsrc = Gst.ElementFactory.make("webrtcsrc")
    if not webrtcsrc:
        print("ERROR: webrtcsrc not found. Set GST_PLUGIN_PATH=/opt/gst-plugins-rs/lib/aarch64-linux-gnu/gstreamer-1.0")
        return 1

    signaller = webrtcsrc.get_property("signaller")
    signaller.set_property("uri", SIGNAL_URI)
    if PRODUCER_ID:
        signaller.set_property("producer-peer-id", PRODUCER_ID)

    # Create appsink for video frames
    appsink = Gst.ElementFactory.make("appsink")
    appsink.set_property("drop", True)
    appsink.set_property("max-buffers", 1)
    caps = Gst.Caps.from_string("video/x-raw,format=BGR")
    appsink.set_property("caps", caps)

    pipeline.add(webrtcsrc)
    pipeline.add(appsink)

    # Handle dynamic pads from webrtcsrc
    def on_pad_added(src, pad):
        name = pad.get_name()
        print(f"Pad added: {name}")

        if name.startswith("video"):
            queue = Gst.ElementFactory.make("queue")
            videoconvert = Gst.ElementFactory.make("videoconvert")
            videoscale = Gst.ElementFactory.make("videoscale")

            pipeline.add(queue)
            pipeline.add(videoconvert)
            pipeline.add(videoscale)

            pad.link(queue.get_static_pad("sink"))
            queue.link(videoconvert)
            videoconvert.link(videoscale)
            videoscale.link(appsink)

            queue.sync_state_with_parent()
            videoconvert.sync_state_with_parent()
            videoscale.sync_state_with_parent()
            appsink.sync_state_with_parent()
            print("Video pipeline linked")

        elif name.startswith("audio"):
            # Discard audio - link to fakesink
            fakesink = Gst.ElementFactory.make("fakesink")
            pipeline.add(fakesink)
            pad.link(fakesink.get_static_pad("sink"))
            fakesink.sync_state_with_parent()

    webrtcsrc.connect("pad-added", on_pad_added)

    # Start pipeline
    pipeline.set_state(Gst.State.PLAYING)
    print("Pipeline playing, waiting for frame...")

    # Pull frames
    frame_captured = False
    for i in range(TIMEOUT * 5):  # 5 attempts per second
        sample = appsink.try_pull_sample(200_000_000)  # 200ms timeout
        if sample:
            buf = sample.get_buffer()
            caps_struct = sample.get_caps().get_structure(0)
            width = caps_struct.get_int("width")[1]
            height = caps_struct.get_int("height")[1]
            
            data = buf.extract_dup(0, buf.get_size())
            frame = np.frombuffer(data, dtype=np.uint8).reshape((height, width, 3))
            
            img = Image.fromarray(frame[..., ::-1])  # BGR -> RGB
            img.save(OUTPUT, quality=85)
            size = os.path.getsize(OUTPUT)
            print(f"Captured: {width}x{height}, saved {OUTPUT} ({size:,} bytes)")
            frame_captured = True
            break

    pipeline.set_state(Gst.State.NULL)
    loop.quit()

    if frame_captured:
        print("SUCCESS")
        return 0
    print("FAILED - no frame captured")
    return 1


def on_bus_message(bus, msg, loop):
    t = msg.type
    if t == Gst.MessageType.ERROR:
        err, debug = msg.parse_error()
        print(f"BUS ERROR: {err} / {debug}")
    elif t == Gst.MessageType.WARNING:
        warn, debug = msg.parse_warning()
        print(f"BUS WARN: {warn}")
    return True


if __name__ == "__main__":
    sys.exit(main())


path: scripts/reachy-react.sh
content: #!/usr/bin/env bash
# reachy-react.sh — Contextual robot reactions for Clawdbot integration
# Usage: reachy-react.sh <reaction> [--bg]
# Reactions run in foreground by default. Pass --bg to background them.
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REACHY="${SCRIPT_DIR}/reachy.sh"
REACHY_HOST="${REACHY_HOST:-192.168.8.17}"

# Quiet hours: robot sleeps, no reactions (22:00 - 06:29 ET)
QUIET_START=22
QUIET_END=7

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
die()  { echo "ERROR: $*" >&2; exit 1; }

current_hour() {
  TZ="America/New_York" date +%H | sed 's/^0//'
}

is_quiet_hours() {
  local h
  h=$(current_hour)
  [[ $h -ge $QUIET_START || $h -lt $QUIET_END ]]
}

# Quick connectivity check (200ms timeout)
is_reachable() {
  curl -sf --max-time 1 "http://${REACHY_HOST}:8000/" >/dev/null 2>&1
}

# Get robot state (not_initialized, starting, running, etc.)
robot_state() {
  curl -sf --max-time 3 "http://${REACHY_HOST}:8000/api/daemon/status" 2>/dev/null \
    | python3 -c "import sys,json; print(json.load(sys.stdin).get('state','unknown'))" 2>/dev/null \
    || echo "unreachable"
}

# Ensure robot is awake. Returns 0 if awake, 1 if can't wake.
ensure_awake() {
  local state
  state=$(robot_state)
  case "$state" in
    running) return 0 ;;
    not_initialized)
      # Start daemon + wake up
      curl -sf --max-time 5 -X POST "http://${REACHY_HOST}:8000/api/daemon/start?wake_up=true" >/dev/null 2>&1
      sleep 5
      "$REACHY" wake-up >/dev/null 2>&1 || true
      sleep 3
      return 0
      ;;
    starting)
      sleep 5
      "$REACHY" wake-up >/dev/null 2>&1 || true
      sleep 3
      return 0
      ;;
    unreachable) return 1 ;;
    *) return 0 ;;  # try anyway
  esac
}

# Fire-and-forget emotion (ignore errors)
play() {
  "$REACHY" play-emotion "$1" >/dev/null 2>&1 || true
}

# Fire-and-forget move
move() {
  "$REACHY" goto "$@" >/dev/null 2>&1 || true
}

# Pick a random item from arguments
pick_random() {
  local arr=("$@")
  echo "${arr[$((RANDOM % ${#arr[@]}))]}"
}

usage() {
  cat <<'EOF'
Usage: reachy-react.sh <reaction> [--bg]

REACTIONS
  ack           Nod acknowledgment (received a request)
  success       Cheerful completion (task done)
  alert         Surprised/important (urgent email, alert)
  remind        Welcoming/helpful (meeting reminder, to-do)
  idle          Subtle heartbeat animation (head tilt or antenna wave)
  morning       Wake up + greeting (morning briefing)
  goodnight     Put robot to sleep (night mode)
  patrol        Camera snapshot + return image path
  doa-track     Check DOA and turn head toward sound source
  celebrate     Random dance (fun moments)

OPTIONS
  --bg          Run in background (non-blocking)

ENVIRONMENT
  REACHY_HOST   Robot IP (default: 192.168.8.17)
EOF
  exit 0
}

# ---------------------------------------------------------------------------
# Reactions
# ---------------------------------------------------------------------------
do_ack() {
  # Quick nod: head down then back up
  move --head 0.25,0,0 --duration 0.4
  sleep 0.5
  move --head 0,0,0 --duration 0.4
}

do_success() {
  local emotion
  emotion=$(pick_random cheerful1 cheerful2 cheerful3 happy1 happy2 yes1)
  play "$emotion"
}

do_alert() {
  # Antennas up + surprised look
  move --antennas 0.4,0.4 --duration 0.3
  sleep 0.3
  local emotion
  emotion=$(pick_random surprised1 surprised2 surprised3)
  play "$emotion"
}

do_remind() {
  local emotion
  emotion=$(pick_random welcoming1 welcoming2 curious1 curious2 attentive1)
  play "$emotion"
}

do_idle() {
  # Subtle random animation: head tilt, antenna wave, or small look-around
  local choice
  choice=$((RANDOM % 4))
  case $choice in
    0)
      # Gentle head tilt right then back
      move --head 0,0,-0.15 --duration 1.0
      sleep 1.5
      move --head 0,0,0 --duration 1.0
      ;;
    1)
      # Gentle head tilt left then back
      move --head 0,0,0.15 --duration 1.0
      sleep 1.5
      move --head 0,0,0 --duration 1.0
      ;;
    2)
      # Antenna wave: alternating up/down
      move --antennas 0.3,-0.2 --duration 0.6
      sleep 0.8
      move --antennas -0.2,0.3 --duration 0.6
      sleep 0.8
      move --antennas 0,0 --duration 0.6
      ;;
    3)
      # Small look around (glance left then right then center)
      move --head 0,0.2,0 --duration 0.8
      sleep 1.0
      move --head 0,-0.2,0 --duration 0.8
      sleep 1.0
      move --head 0,0,0 --duration 0.8
      ;;
  esac
}

do_morning() {
  # Wake up the robot and greet
  "$REACHY" wake-up >/dev/null 2>&1 || true
  sleep 3
  local emotion
  emotion=$(pick_random welcoming1 welcoming2 welcoming3 cheerful1)
  play "$emotion"
}

do_goodnight() {
  # Play a sleepy emotion then sleep
  play sleepy1 2>/dev/null || true
  sleep 2
  "$REACHY" sleep >/dev/null 2>&1 || true
}

do_patrol() {
  # Camera snapshot — prints the output path
  local output="${1:-/tmp/reachy_patrol.jpg}"
  "$REACHY" snap "$output" 2>/dev/null
  echo "$output"
}

do_doa_track() {
  # Get Direction of Arrival and turn head toward sound
  local doa_json
  doa_json=$("$REACHY" doa 2>/dev/null) || return 1

  local angle speech
  angle=$(echo "$doa_json" | python3 -c "
import sys, json, math
d = json.load(sys.stdin)
# DOA angle: 0=left, pi/2=front, pi=right
# Convert to head yaw: positive=look left, negative=look right
# Map: 0 -> +0.6, pi/2 -> 0, pi -> -0.6
angle = d.get('angle', 1.5708)  # default to front
yaw = (1.5708 - angle) / 1.5708 * 0.6
print(f'{yaw:.3f}')
" 2>/dev/null) || return 1

  speech=$(echo "$doa_json" | python3 -c "
import sys, json
d = json.load(sys.stdin)
print('true' if d.get('is_speech', False) else 'false')
" 2>/dev/null) || return 1

  if [[ "$speech" == "true" ]]; then
    # Turn toward the speaker
    move --head 0,"${angle}",0 --duration 0.8
    echo "Speech detected at yaw=${angle}, turning head"
  else
    echo "No speech detected"
  fi
}

do_celebrate() {
  local dance
  dance=$(pick_random groovy_sway_and_roll chicken_peck dizzy_spin head_bop wiggle_and_groove funky_shimmy robot_wave sassy_strut)
  "$REACHY" play-dance "$dance" >/dev/null 2>&1 || true
  echo "Dancing: $dance"
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
[[ $# -ge 1 ]] || usage

REACTION="$1"; shift
BG=false
for arg in "$@"; do
  [[ "$arg" == "--bg" ]] && BG=true
done

# Skip if unreachable (except for status-only commands)
if ! is_reachable; then
  echo "Robot unreachable at ${REACHY_HOST}" >&2
  exit 1
fi

# Quiet hours guard (except morning/goodnight/patrol which are intentional)
if is_quiet_hours && [[ "$REACTION" != "morning" && "$REACTION" != "goodnight" && "$REACTION" != "patrol" ]]; then
  echo "Quiet hours — skipping reaction: $REACTION"
  exit 0
fi

# Ensure awake for active reactions (not goodnight)
if [[ "$REACTION" != "goodnight" && "$REACTION" != "patrol" ]]; then
  ensure_awake || { echo "Cannot wake robot" >&2; exit 1; }
fi

# Dispatch
run_reaction() {
  case "$REACTION" in
    ack)        do_ack ;;
    success)    do_success ;;
    alert)      do_alert ;;
    remind)     do_remind ;;
    idle)       do_idle ;;
    morning)    do_morning ;;
    goodnight)  do_goodnight ;;
    patrol)     do_patrol "$@" ;;
    doa-track|doa)  do_doa_track ;;
    celebrate|dance) do_celebrate ;;
    *)          die "Unknown reaction: $REACTION" ;;
  esac
}

if $BG; then
  run_reaction "$@" &
  disown
  echo "Reaction '$REACTION' running in background"
else
  run_reaction "$@"
fi


path: scripts/reachy.sh
content: #!/usr/bin/env bash
# reachy.sh — Reachy Mini robot control CLI
# Usage: reachy.sh [--host HOST] <command> [args...]
# Requires: curl, jq
set -euo pipefail

# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
REACHY_HOST="${REACHY_HOST:-192.168.8.17}"
REACHY_PORT="${REACHY_PORT:-8000}"
BASE=""  # set after arg parsing

usage() {
  cat <<'EOF'
Usage: reachy.sh [--host HOST] <command> [args...]

CONNECTION
  --host HOST          Override robot IP (default: $REACHY_HOST or 192.168.8.17)

STATUS & INFO
  status               Daemon status (name, state, version, IP)
  state                Full robot state (head pose, body yaw, antennas)
  motors               Motor control mode
  health               Send health-check ping

MOVEMENT
  wake-up              Wake the robot up (head rises, eyes open)
  sleep                Put the robot to sleep
  goto [options]       Move to a target pose
    --head PITCH,YAW,ROLL    Head orientation in radians (pitch,yaw,roll)
    --body YAW               Body rotation in radians
    --antennas LEFT,RIGHT    Antenna positions in radians
    --duration SECS          Movement duration (default: 1.5)
    --interp MODE            Interpolation: linear|minjerk|ease|cartoon (default: minjerk)
  set-target [options] Set target pose directly (continuous control)
    (same options as goto, except no duration/interp)
  stop-move UUID       Stop a running move by UUID
  running              List currently running moves

HEAD
  head-pose            Get current head pose (x,y,z,roll,pitch,yaw)

BODY
  body-yaw             Get current body yaw (radians)

ANTENNAS
  antennas             Get current antenna positions [left, right]

MOTORS
  motors-enable        Enable motor control
  motors-disable       Disable motor control
  motors-gravity       Set gravity compensation mode

EMOTIONS & DANCES (Recorded Moves)
  emotions             List available emotion moves
  dances               List available dance moves
  play-emotion NAME    Play a named emotion (e.g., cheerful1, curious1)
  play-dance NAME      Play a named dance (e.g., groovy_sway_and_roll)

VOLUME
  volume               Get current speaker volume
  volume-set LEVEL     Set speaker volume (0-100)
  volume-test          Play test sound
  mic-volume           Get current microphone volume
  mic-volume-set LVL   Set microphone volume (0-100)

AUDIO SENSING
  doa                  Get Direction of Arrival (microphone array)

APPS
  apps                 List all available apps
  apps-installed       List installed apps only
  app-status           Get status of currently running app
  app-start NAME       Start an app by name
  app-stop             Stop the currently running app
  app-restart          Restart the currently running app
  app-install JSON     Install an app (pass JSON body)
  app-remove NAME      Remove an installed app

CAMERA
  snap [OUTPUT]        Capture a JPEG snapshot from the camera (via WebRTC)
                       Default output: /tmp/reachy_snap.jpg
                       Requires SSH access (REACHY_SSH_PASS or ssh key)

SYSTEM
  wifi-status          Get WiFi connection status
  update-check         Check if firmware update is available
  version              Show firmware version
  reboot-daemon        Restart the robot daemon

ADVANCED
  raw METHOD PATH [BODY]   Raw API call (GET/POST, path, optional JSON body)
EOF
  exit 0
}

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
die()  { echo "ERROR: $*" >&2; exit 1; }

api_get() {
  local path="$1"; shift
  local query="${1:-}"
  local url="${BASE}${path}"
  [[ -n "$query" ]] && url="${url}?${query}"
  curl -sf --max-time 10 "$url" 2>/dev/null || die "Request failed: GET $path"
}

api_post() {
  local path="$1"; shift
  local body="${1:-}"
  local query="${2:-}"
  local url="${BASE}${path}"
  [[ -n "$query" ]] && url="${url}?${query}"
  if [[ -n "$body" ]]; then
    curl -sf --max-time 15 -X POST -H "Content-Type: application/json" -d "$body" "$url" 2>/dev/null \
      || die "Request failed: POST $path"
  else
    curl -sf --max-time 15 -X POST "$url" 2>/dev/null \
      || die "Request failed: POST $path"
  fi
}

# Pretty-print JSON if jq is available
pp() {
  if command -v jq &>/dev/null; then
    jq .
  else
    cat
  fi
}

# SSH into the robot
REACHY_SSH_USER="${REACHY_SSH_USER:-pollen}"
REACHY_SSH_PASS="${REACHY_SSH_PASS:-root}"

ssh_cmd() {
  if command -v sshpass &>/dev/null && [[ -n "$REACHY_SSH_PASS" ]]; then
    sshpass -p "$REACHY_SSH_PASS" ssh -F /dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
      "${REACHY_SSH_USER}@${REACHY_HOST}" "$@"
  else
    ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
      "${REACHY_SSH_USER}@${REACHY_HOST}" "$@"
  fi
}

scp_from() {
  local remote="$1" local="$2"
  if command -v sshpass &>/dev/null && [[ -n "$REACHY_SSH_PASS" ]]; then
    sshpass -p "$REACHY_SSH_PASS" scp -F /dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
      "${REACHY_SSH_USER}@${REACHY_HOST}:${remote}" "${local}"
  else
    scp -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
      "${REACHY_SSH_USER}@${REACHY_HOST}:${remote}" "${local}"
  fi
}

scp_to() {
  local localfile="$1" remote="$2"
  if command -v sshpass &>/dev/null && [[ -n "$REACHY_SSH_PASS" ]]; then
    sshpass -p "$REACHY_SSH_PASS" scp -F /dev/null -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
      "${localfile}" "${REACHY_SSH_USER}@${REACHY_HOST}:${remote}"
  else
    scp -o StrictHostKeyChecking=no -o ConnectTimeout=5 \
      "${localfile}" "${REACHY_SSH_USER}@${REACHY_HOST}:${remote}"
  fi
}

# ---------------------------------------------------------------------------
# Parse global flags
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
  case "$1" in
    --host) REACHY_HOST="$2"; shift 2 ;;
    -h|--help) usage ;;
    *) break ;;
  esac
done

BASE="http://${REACHY_HOST}:${REACHY_PORT}"
CMD="${1:-help}"
shift || true

# ---------------------------------------------------------------------------
# Command dispatch
# ---------------------------------------------------------------------------
case "$CMD" in

  # --- STATUS & INFO ---
  status)
    api_get "/api/daemon/status" | pp
    ;;
  state)
    api_get "/api/state/full" | pp
    ;;
  motors)
    api_get "/api/motors/status" | pp
    ;;
  health)
    api_post "/health-check" | pp
    ;;
  version)
    api_get "/api/daemon/status" | jq -r '.version // "unknown"'
    ;;

  # --- MOVEMENT ---
  wake-up|wakeup)
    api_post "/api/move/play/wake_up" | pp
    ;;
  sleep|goto-sleep)
    api_post "/api/move/play/goto_sleep" | pp
    ;;
  goto)
    # Parse goto sub-options
    head="" body="" ant="" dur="1.5" interp="minjerk"
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --head)     head="$2"; shift 2 ;;
        --body)     body="$2"; shift 2 ;;
        --antennas) ant="$2"; shift 2 ;;
        --duration) dur="$2"; shift 2 ;;
        --interp)   interp="$2"; shift 2 ;;
        *) die "Unknown goto option: $1" ;;
      esac
    done
    # Build JSON
    json="{\"duration\":${dur},\"interpolation\":\"${interp}\""
    if [[ -n "$head" ]]; then
      IFS=',' read -r p y r <<< "$head"
      json+=",\"head_pose\":{\"x\":0,\"y\":0,\"z\":0,\"pitch\":${p},\"yaw\":${y},\"roll\":${r}}"
    fi
    if [[ -n "$body" ]]; then
      json+=",\"body_yaw\":${body}"
    fi
    if [[ -n "$ant" ]]; then
      IFS=',' read -r l ri <<< "$ant"
      json+=",\"antennas\":[${l},${ri}]"
    fi
    json+="}"
    api_post "/api/move/goto" "$json" | pp
    ;;
  set-target)
    head="" body="" ant=""
    while [[ $# -gt 0 ]]; do
      case "$1" in
        --head)     head="$2"; shift 2 ;;
        --body)     body="$2"; shift 2 ;;
        --antennas) ant="$2"; shift 2 ;;
        *) die "Unknown set-target option: $1" ;;
      esac
    done
    json="{"
    first=true
    if [[ -n "$head" ]]; then
      IFS=',' read -r p y r <<< "$head"
      json+="\"target_head_pose\":{\"x\":0,\"y\":0,\"z\":0,\"pitch\":${p},\"yaw\":${y},\"roll\":${r}}"
      first=false
    fi
    if [[ -n "$body" ]]; then
      $first || json+=","
      json+="\"target_body_yaw\":${body}"
      first=false
    fi
    if [[ -n "$ant" ]]; then
      $first || json+=","
      IFS=',' read -r l ri <<< "$ant"
      json+="\"target_antennas\":[${l},${ri}]"
    fi
    json+="}"
    api_post "/api/move/set_target" "$json" | pp
    ;;
  stop-move)
    [[ $# -ge 1 ]] || die "Usage: stop-move UUID"
    api_post "/api/move/stop" "{\"uuid\":\"$1\"}" | pp
    ;;
  running)
    api_get "/api/move/running" | pp
    ;;

  # --- HEAD / BODY / ANTENNAS ---
  head-pose|head)
    api_get "/api/state/present_head_pose" | pp
    ;;
  body-yaw|body)
    api_get "/api/state/present_body_yaw"
    echo  # newline after bare number
    ;;
  antennas)
    api_get "/api/state/present_antenna_joint_positions" | pp
    ;;

  # --- MOTOR MODES ---
  motors-enable)
    api_post "/api/motors/set_mode/enabled" | pp
    ;;
  motors-disable)
    api_post "/api/motors/set_mode/disabled" | pp
    ;;
  motors-gravity)
    api_post "/api/motors/set_mode/gravity_compensation" | pp
    ;;

  # --- EMOTIONS & DANCES ---
  emotions)
    api_get "/api/move/recorded-move-datasets/list/pollen-robotics/reachy-mini-emotions-library" | pp
    ;;
  dances)
    api_get "/api/move/recorded-move-datasets/list/pollen-robotics/reachy-mini-dances-library" | pp
    ;;
  play-emotion)
    [[ $# -ge 1 ]] || die "Usage: play-emotion NAME"
    api_post "/api/move/play/recorded-move-dataset/pollen-robotics/reachy-mini-emotions-library/$1" | pp
    ;;
  play-dance)
    [[ $# -ge 1 ]] || die "Usage: play-dance NAME"
    api_post "/api/move/play/recorded-move-dataset/pollen-robotics/reachy-mini-dances-library/$1" | pp
    ;;

  # --- VOLUME ---
  volume)
    api_get "/api/volume/current" | pp
    ;;
  volume-set)
    [[ $# -ge 1 ]] || die "Usage: volume-set LEVEL (0-100)"
    api_post "/api/volume/set" "{\"volume\":$1}" | pp
    ;;
  volume-test)
    api_post "/api/volume/test-sound" | pp
    ;;
  mic-volume)
    api_get "/api/volume/microphone/current" | pp
    ;;
  mic-volume-set)
    [[ $# -ge 1 ]] || die "Usage: mic-volume-set LEVEL (0-100)"
    api_post "/api/volume/microphone/set" "{\"volume\":$1}" | pp
    ;;

  # --- AUDIO SENSING ---
  doa)
    api_get "/api/state/doa" | pp
    ;;

  # --- APPS ---
  apps)
    api_get "/api/apps/list-available" | jq '[.[] | {name, source_kind, description}]'
    ;;
  apps-installed)
    api_get "/api/apps/list-available/installed" | jq '[.[] | {name, description}]'
    ;;
  app-status)
    api_get "/api/apps/current-app-status" | pp
    ;;
  app-start)
    [[ $# -ge 1 ]] || die "Usage: app-start NAME"
    api_post "/api/apps/start-app/$1" | pp
    ;;
  app-stop)
    api_post "/api/apps/stop-current-app" | pp
    ;;
  app-restart)
    api_post "/api/apps/restart-current-app" | pp
    ;;
  app-install)
    [[ $# -ge 1 ]] || die "Usage: app-install JSON"
    api_post "/api/apps/install" "$1" | pp
    ;;
  app-remove)
    [[ $# -ge 1 ]] || die "Usage: app-remove NAME"
    api_post "/api/apps/remove/$1" | pp
    ;;

  # --- CAMERA ---
  snap|snapshot|photo|camera)
    OUTPUT="${1:-/tmp/reachy_snap.jpg}"
    REMOTE_SNAP="/tmp/reachy_snap_$$.jpg"
    REMOTE_SCRIPT="/tmp/reachy_gst_snap.py"
    SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

    # Deploy snapshot script if not present or outdated
    ssh_cmd "test -f ${REMOTE_SCRIPT}" 2>/dev/null || {
      echo "Deploying snapshot script..." >&2
      scp_to "${SCRIPT_DIR}/reachy-gst-snap.py" "${REMOTE_SCRIPT}" 2>/dev/null \
        || die "Failed to deploy snapshot script via SCP"
    }

    # Run capture on the robot
    ssh_cmd "
      export GST_PLUGIN_PATH=/opt/gst-plugins-rs/lib/aarch64-linux-gnu/gstreamer-1.0

      # Get producer ID from WebRTC signalling server
      PRODUCER=\$(/venvs/apps_venv/bin/python3 -c \"
import asyncio, json, websockets
async def p():
    async with websockets.connect('ws://127.0.0.1:8443') as ws:
        await ws.recv()
        await ws.send(json.dumps({'type': 'setPeerStatus', 'roles': ['listener'], 'meta': {}}))
        await ws.recv()
        await ws.send(json.dumps({'type': 'list'}))
        listing = json.loads(await ws.recv())
        if listing.get('producers'): print(listing['producers'][0]['id'])
asyncio.run(p())
\" 2>/dev/null)

      if [ -z \"\$PRODUCER\" ]; then
        echo 'ERROR: No WebRTC producer found. Is the daemon running?' >&2
        exit 1
      fi

      rm -f ${REMOTE_SNAP}
      timeout 20 /venvs/apps_venv/bin/python3 ${REMOTE_SCRIPT} ${REMOTE_SNAP} \"\$PRODUCER\" 2>&1 \
        | grep -v 'BUS ERROR\|Signalling error\|receiver is gone'
    " || die "Capture failed on robot"

    # Pull the image back
    scp_from "${REMOTE_SNAP}" "${OUTPUT}" 2>/dev/null \
      || die "Failed to copy snapshot from robot"

    # Cleanup remote temp file
    ssh_cmd "rm -f ${REMOTE_SNAP}" 2>/dev/null

    SIZE=$(stat -c%s "$OUTPUT" 2>/dev/null || stat -f%z "$OUTPUT" 2>/dev/null)
    echo "Snapshot saved: ${OUTPUT} (${SIZE} bytes)"
    ;;

  # --- SYSTEM ---
  wifi-status|wifi)
    api_get "/wifi/status" | pp
    ;;
  update-check)
    api_get "/update/available" | pp
    ;;
  reboot-daemon|restart)
    api_post "/api/daemon/restart" | pp
    ;;

  # --- RAW ---
  raw)
    [[ $# -ge 2 ]] || die "Usage: raw METHOD PATH [BODY]"
    method="$1"; path="$2"; body="${3:-}"
    case "${method^^}" in
      GET)  api_get "$path" ;;
      POST) api_post "$path" "$body" ;;
      *)    die "Unsupported method: $method (use GET or POST)" ;;
    esac | pp
    ;;

  help|-h|--help) usage ;;
  *) die "Unknown command: $CMD (run with --help for usage)" ;;
esac


path: references/api-reference.md
content: # Reachy Mini REST API Reference

Base URL: `http://<REACHY_HOST>:8000`

## Robot Anatomy & Coordinate System

Reachy Mini is a desktop robot (28cm tall, 1.5kg) with:
- **Head**: 6 DoF movement (x, y, z translation in meters; roll, pitch, yaw rotation in radians)
- **Body**: 360° rotation around vertical axis (yaw in radians)
- **Antennas**: 2 independently animated antennas (position in radians, ~-0.5 to 0.5 range)
- **Camera**: Wide-angle camera (in the head/eyes)
- **Microphones**: 4-mic array with Direction of Arrival detection
- **Speaker**: 5W speaker

### Head Pose (XYZRPYPose)
All values in radians unless noted. Typical useful ranges:
- `pitch`: -0.5 to 0.5 (negative = look up, positive = look down)
- `yaw`: -0.8 to 0.8 (negative = look right, positive = look left)
- `roll`: -0.5 to 0.5 (tilt head sideways)
- `x`, `y`, `z`: Translation in meters (small adjustments, typically ±0.02)

### Body Yaw
- Range: approximately -3.14 to 3.14 (full 360°)
- 0 = forward, positive = turn left, negative = turn right

### Antenna Positions
- Tuple of [left, right] in radians
- Typical range: -0.5 to 0.5
- 0 = neutral, negative/positive = different angles

## Interpolation Modes
Used in goto commands:
- `linear` — Constant speed
- `minjerk` — Smooth acceleration/deceleration (most natural, default)
- `ease` — Ease-in/ease-out
- `cartoon` — Exaggerated, bouncy motion

## Motor Control Modes
- `enabled` — Motors actively holding position and accepting commands
- `disabled` — Motors off, robot limp
- `gravity_compensation` — Motors compensate for gravity but allow manual manipulation

## Recorded Move Datasets

### Emotions (pollen-robotics/reachy-mini-emotions-library)
80+ expressive animations. Key emotions:
- **Positive**: cheerful1, enthusiastic1/2, loving1, grateful1, proud1/2/3, success1/2, relief1/2, serenity1
- **Negative**: sad1/2, angry (rage1, furious1), fear1, scared1, anxiety1, frustrated1, lonely1, exhausted1
- **Social**: welcoming1/2, attentive1/2, helpful1/2, understanding1/2, calming1, inquiring1/2/3
- **Reactive**: surprised1/2, amazed1, confused1, curious1, thoughtful1/2, uncertain1
- **Playful**: laughing1/2, oops1/2, dance1/2/3, electric1
- **Dismissive**: boredom1/2, contempt1, indifferent1, go_away1, irritated1/2, displeased1/2
- **Communication**: yes1, yes_sad1, no1, no_excited1, no_sad1, come1, reprimand1/2/3
- **States**: sleep1, tired1, dying1, shy1, lost1, uncomfortable1, resigned1, downcast1

### Dances (pollen-robotics/reachy-mini-dances-library)
19 dance moves:
side_glance_flick, jackson_square, side_peekaboo, groovy_sway_and_roll, chin_lead,
side_to_side_sway, neck_recoil, head_tilt_roll, simple_nod, uh_huh_tilt,
interwoven_spirals, pendulum_swing, chicken_peck, yeah_nod, stumble_and_recover,
dizzy_spin, grid_snap, polyrhythm_combo, sharp_side_tilt

## API Endpoints

### Daemon
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/daemon/status` | Robot name, state, version, IP, backend status |
| POST | `/api/daemon/start?wake_up=BOOL` | Start daemon |
| POST | `/api/daemon/stop?goto_sleep=BOOL` | Stop daemon |
| POST | `/api/daemon/restart` | Restart daemon |

### State
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/state/full` | Full state (head pose, body yaw, antennas, control mode) |
| GET | `/api/state/present_head_pose` | Current head pose (XYZRPYPose) |
| GET | `/api/state/present_body_yaw` | Current body yaw (radians) |
| GET | `/api/state/present_antenna_joint_positions` | Antenna positions [left, right] |
| GET | `/api/state/doa` | Direction of Arrival from mic array |

### Motors
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/motors/status` | Current motor mode |
| POST | `/api/motors/set_mode/{mode}` | Set mode: enabled, disabled, gravity_compensation |

### Movement
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/move/goto` | Animated move to target (body: GotoModelRequest) |
| POST | `/api/move/set_target` | Set target directly (body: FullBodyTarget) |
| POST | `/api/move/play/wake_up` | Wake up animation |
| POST | `/api/move/play/goto_sleep` | Sleep animation |
| POST | `/api/move/play/recorded-move-dataset/{dataset}/{move}` | Play recorded move |
| GET | `/api/move/recorded-move-datasets/list/{dataset}` | List moves in dataset |
| GET | `/api/move/running` | List running move tasks |
| POST | `/api/move/stop` | Stop a move (body: {"uuid": "..."}) |

#### GotoModelRequest Body
```json
{
  "head_pose": {"x": 0, "y": 0, "z": 0, "pitch": 0.0, "yaw": 0.0, "roll": 0.0},
  "body_yaw": 0.0,
  "antennas": [0.0, 0.0],
  "duration": 1.5,
  "interpolation": "minjerk"
}
```
All fields except `duration` are optional. Omitted fields keep current position.

#### FullBodyTarget Body
```json
{
  "target_head_pose": {"x": 0, "y": 0, "z": 0, "pitch": 0.0, "yaw": 0.0, "roll": 0.0},
  "target_body_yaw": 0.0,
  "target_antennas": [0.0, 0.0]
}
```

### Volume
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/volume/current` | Speaker volume (0-100) |
| POST | `/api/volume/set` | Set volume (body: {"volume": N}) |
| POST | `/api/volume/test-sound` | Play test sound |
| GET | `/api/volume/microphone/current` | Microphone volume |
| POST | `/api/volume/microphone/set` | Set mic volume (body: {"volume": N}) |

### Apps
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/apps/list-available` | All available apps |
| GET | `/api/apps/list-available/{source}` | Apps by source: hf_space, installed, local |
| POST | `/api/apps/install` | Install app (body: AppInfo JSON) |
| POST | `/api/apps/remove/{name}` | Remove installed app |
| POST | `/api/apps/start-app/{name}` | Start app by name |
| POST | `/api/apps/stop-current-app` | Stop running app |
| POST | `/api/apps/restart-current-app` | Restart running app |
| GET | `/api/apps/current-app-status` | Status of running app |
| GET | `/api/apps/job-status/{job_id}` | Install/remove job progress |

### WiFi
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/wifi/status` | Mode, connected network, known networks |
| POST | `/api/wifi/connect?ssid=X&password=Y` | Connect to network |
| POST | `/api/wifi/scan_and_list` | Scan available networks |
| POST | `/api/wifi/forget?ssid=X` | Forget a network |

### System
| Method | Path | Description |
|--------|------|-------------|
| GET | `/update/available` | Check for firmware updates |
| POST | `/update/start` | Start firmware update |
| POST | `/health-check` | Health check ping |
| GET | `/api/kinematics/info` | Kinematics engine info |
| GET | `/api/kinematics/urdf` | URDF model of the robot |


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (CRITICAL)