Spaces:
Running
Running
RemiFabre commited on
Commit ·
8adddec
1
Parent(s): 1f09aa2
Add smoke test to verify full playback pipeline on robot
Browse filessmoke_test_on_robot.py starts Marionette with the real dataset root,
connects to the robot, waits for startup, lists existing recordings,
picks one with audio, and plays it back via the API. Verifies the
entire flow works locally on the robot (motors + audio).
run_smoke_on_robot.py launches it from the laptop via SSH.
- tests/run_smoke_on_robot.py +75 -0
- tests/smoke_test_on_robot.py +155 -0
tests/run_smoke_on_robot.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Run the smoke test on the robot via SSH.
|
| 3 |
+
|
| 4 |
+
Syncs code, then runs smoke_test_on_robot.py on the robot.
|
| 5 |
+
Streams output in real-time so you can see if the robot moves
|
| 6 |
+
and audio plays.
|
| 7 |
+
|
| 8 |
+
Usage:
|
| 9 |
+
cd marionette
|
| 10 |
+
python tests/run_smoke_on_robot.py
|
| 11 |
+
python tests/run_smoke_on_robot.py --host 192.168.1.42
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import argparse
|
| 15 |
+
import subprocess
|
| 16 |
+
import sys
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
|
| 19 |
+
# Reuse sync logic from run_on_robot
|
| 20 |
+
sys.path.insert(0, str(Path(__file__).parent))
|
| 21 |
+
from run_on_robot import sync_to_robot, ensure_deps, ssh_cmd, REMOTE_DIR, ROBOT_PYTHON
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def main():
|
| 25 |
+
parser = argparse.ArgumentParser(
|
| 26 |
+
description="Run smoke test on the Reachy Mini robot via SSH.",
|
| 27 |
+
)
|
| 28 |
+
parser.add_argument("--host", default="reachy-mini.local")
|
| 29 |
+
parser.add_argument("--user", default="pollen")
|
| 30 |
+
parser.add_argument("--dry-run", action="store_true")
|
| 31 |
+
args = parser.parse_args()
|
| 32 |
+
|
| 33 |
+
# Sync code
|
| 34 |
+
print(f"Syncing code to {args.user}@{args.host}...")
|
| 35 |
+
ok = sync_to_robot(args.host, args.user, dry_run=args.dry_run)
|
| 36 |
+
if not ok:
|
| 37 |
+
print("Failed to sync.")
|
| 38 |
+
sys.exit(1)
|
| 39 |
+
if args.dry_run:
|
| 40 |
+
print("Dry run — not running test.")
|
| 41 |
+
sys.exit(0)
|
| 42 |
+
|
| 43 |
+
# Ensure deps
|
| 44 |
+
if not ensure_deps(args.host, args.user):
|
| 45 |
+
print("Failed to install deps.")
|
| 46 |
+
sys.exit(1)
|
| 47 |
+
|
| 48 |
+
# Run smoke test
|
| 49 |
+
remote_cmd = (
|
| 50 |
+
f"cd {REMOTE_DIR} && "
|
| 51 |
+
f"PYTHONPATH={REMOTE_DIR} "
|
| 52 |
+
f"{ROBOT_PYTHON} tests/smoke_test_on_robot.py"
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
print(f"\n{'=' * 60}")
|
| 56 |
+
print(f"Running smoke test on {args.user}@{args.host}")
|
| 57 |
+
print(f"{'=' * 60}\n")
|
| 58 |
+
|
| 59 |
+
proc = subprocess.Popen(
|
| 60 |
+
ssh_cmd(args.host, args.user, remote_cmd),
|
| 61 |
+
stdout=subprocess.PIPE,
|
| 62 |
+
stderr=subprocess.STDOUT,
|
| 63 |
+
text=True,
|
| 64 |
+
bufsize=1,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
for line in proc.stdout:
|
| 68 |
+
print(line, end="")
|
| 69 |
+
|
| 70 |
+
proc.wait()
|
| 71 |
+
sys.exit(proc.returncode)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
if __name__ == "__main__":
|
| 75 |
+
main()
|
tests/smoke_test_on_robot.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Smoke test: start Marionette on the robot and play back an existing move.
|
| 3 |
+
|
| 4 |
+
This script is meant to be run ON the robot (after rsync) to verify
|
| 5 |
+
that the full pipeline works locally: server starts, finds existing
|
| 6 |
+
recordings, plays one back with audio and motion.
|
| 7 |
+
|
| 8 |
+
Usage (on the robot):
|
| 9 |
+
cd /tmp/marionette_test
|
| 10 |
+
PYTHONPATH=/tmp/marionette_test /venvs/apps_venv/bin/python tests/smoke_test_on_robot.py
|
| 11 |
+
|
| 12 |
+
Or from the laptop via run_on_robot.py flow:
|
| 13 |
+
python tests/run_smoke_on_robot.py
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import sys
|
| 18 |
+
import threading
|
| 19 |
+
import time
|
| 20 |
+
|
| 21 |
+
import httpx
|
| 22 |
+
import uvicorn
|
| 23 |
+
|
| 24 |
+
from marionette.main import create_app
|
| 25 |
+
|
| 26 |
+
PORT = 18044
|
| 27 |
+
BASE_URL = f"http://127.0.0.1:{PORT}"
|
| 28 |
+
STARTUP_TIMEOUT = 45 # seconds — startup animation can be slow
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def wait_for_mode(target_mode: str, timeout: float) -> dict:
|
| 32 |
+
deadline = time.time() + timeout
|
| 33 |
+
last = {}
|
| 34 |
+
while time.time() < deadline:
|
| 35 |
+
try:
|
| 36 |
+
resp = httpx.get(f"{BASE_URL}/api/state", timeout=5)
|
| 37 |
+
last = resp.json()
|
| 38 |
+
if last.get("mode") == target_mode:
|
| 39 |
+
return last
|
| 40 |
+
except Exception:
|
| 41 |
+
pass
|
| 42 |
+
time.sleep(0.3)
|
| 43 |
+
raise TimeoutError(
|
| 44 |
+
f"Timed out waiting for mode={target_mode!r} "
|
| 45 |
+
f"(last mode={last.get('mode')!r})"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def main():
|
| 50 |
+
# Use the REAL default dataset root (not a temp dir) so we see
|
| 51 |
+
# existing recordings on the robot.
|
| 52 |
+
# Pass registry_path=None and dataset_root=None to use defaults.
|
| 53 |
+
print("Creating Marionette app with default (real) dataset root...")
|
| 54 |
+
app, marionette = create_app()
|
| 55 |
+
|
| 56 |
+
# Connect to robot
|
| 57 |
+
print("Connecting to robot...")
|
| 58 |
+
from reachy_mini import ReachyMini
|
| 59 |
+
reachy = ReachyMini()
|
| 60 |
+
print(" Connected!")
|
| 61 |
+
|
| 62 |
+
stop_event = threading.Event()
|
| 63 |
+
|
| 64 |
+
# Start the run loop (startup animation, then idle)
|
| 65 |
+
run_thread = threading.Thread(
|
| 66 |
+
target=marionette.run, args=(reachy, stop_event), daemon=True,
|
| 67 |
+
)
|
| 68 |
+
run_thread.start()
|
| 69 |
+
|
| 70 |
+
# Start web server
|
| 71 |
+
config = uvicorn.Config(app, host="127.0.0.1", port=PORT, log_level="warning")
|
| 72 |
+
server = uvicorn.Server(config)
|
| 73 |
+
srv_thread = threading.Thread(target=server.run, daemon=True)
|
| 74 |
+
srv_thread.start()
|
| 75 |
+
|
| 76 |
+
# Wait for startup animation to finish
|
| 77 |
+
print(f"Waiting for startup animation (up to {STARTUP_TIMEOUT}s)...")
|
| 78 |
+
try:
|
| 79 |
+
wait_for_mode("idle", STARTUP_TIMEOUT)
|
| 80 |
+
except TimeoutError as e:
|
| 81 |
+
print(f" FAILED: {e}")
|
| 82 |
+
stop_event.set()
|
| 83 |
+
server.should_exit = True
|
| 84 |
+
sys.exit(1)
|
| 85 |
+
print(" Startup complete — server is idle.")
|
| 86 |
+
|
| 87 |
+
# List available moves
|
| 88 |
+
state = httpx.get(f"{BASE_URL}/api/state", timeout=5).json()
|
| 89 |
+
moves = state.get("moves", [])
|
| 90 |
+
print(f"\nFound {len(moves)} moves:")
|
| 91 |
+
for i, m in enumerate(moves):
|
| 92 |
+
audio = "with audio" if m.get("has_audio") else "no audio"
|
| 93 |
+
print(f" [{i}] {m['id']} — {m.get('duration', '?'):.1f}s, {audio}")
|
| 94 |
+
|
| 95 |
+
if not moves:
|
| 96 |
+
print("\nNo moves found! Make sure the robot has existing recordings.")
|
| 97 |
+
print(f"Dataset path: {state['config']['active_dataset_path']}")
|
| 98 |
+
stop_event.set()
|
| 99 |
+
server.should_exit = True
|
| 100 |
+
sys.exit(1)
|
| 101 |
+
|
| 102 |
+
# Pick the first move that has audio, or fall back to any move
|
| 103 |
+
move = next((m for m in moves if m.get("has_audio")), moves[0])
|
| 104 |
+
print(f"\nPlaying: {move['id']} ({move.get('duration', '?'):.1f}s, "
|
| 105 |
+
f"{'with' if move.get('has_audio') else 'no'} audio)")
|
| 106 |
+
|
| 107 |
+
# Play it!
|
| 108 |
+
resp = httpx.post(
|
| 109 |
+
f"{BASE_URL}/api/play",
|
| 110 |
+
json={"move_id": move["id"]},
|
| 111 |
+
timeout=5,
|
| 112 |
+
)
|
| 113 |
+
if resp.status_code != 200:
|
| 114 |
+
print(f" FAILED to start playback: {resp.status_code} {resp.text}")
|
| 115 |
+
stop_event.set()
|
| 116 |
+
server.should_exit = True
|
| 117 |
+
sys.exit(1)
|
| 118 |
+
|
| 119 |
+
print(" Playback started...")
|
| 120 |
+
|
| 121 |
+
# Wait for playback to finish, printing status
|
| 122 |
+
t0 = time.time()
|
| 123 |
+
timeout = move.get("duration", 30) + 20
|
| 124 |
+
while time.time() - t0 < timeout:
|
| 125 |
+
try:
|
| 126 |
+
st = httpx.get(f"{BASE_URL}/api/state", timeout=5).json()
|
| 127 |
+
mode = st["mode"]
|
| 128 |
+
if mode == "idle":
|
| 129 |
+
elapsed = time.time() - t0
|
| 130 |
+
print(f" Playback finished in {elapsed:.1f}s")
|
| 131 |
+
break
|
| 132 |
+
elif mode == "playing":
|
| 133 |
+
# Show progress
|
| 134 |
+
pass
|
| 135 |
+
except Exception:
|
| 136 |
+
pass
|
| 137 |
+
time.sleep(0.5)
|
| 138 |
+
else:
|
| 139 |
+
print(" WARNING: playback did not return to idle in time")
|
| 140 |
+
|
| 141 |
+
# Final state
|
| 142 |
+
final = httpx.get(f"{BASE_URL}/api/state", timeout=5).json()
|
| 143 |
+
print(f"\nFinal mode: {final['mode']}")
|
| 144 |
+
print("\nSMOKE TEST PASSED" if final["mode"] == "idle" else "\nSMOKE TEST FAILED")
|
| 145 |
+
|
| 146 |
+
# Shutdown
|
| 147 |
+
stop_event.set()
|
| 148 |
+
server.should_exit = True
|
| 149 |
+
run_thread.join(timeout=5)
|
| 150 |
+
|
| 151 |
+
sys.exit(0 if final["mode"] == "idle" else 1)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
if __name__ == "__main__":
|
| 155 |
+
main()
|