RemiFabre commited on
Commit
8adddec
·
1 Parent(s): 1f09aa2

Add smoke test to verify full playback pipeline on robot

Browse files

smoke_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 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()