andito HF Staff commited on
Commit
e3b40cc
·
verified ·
1 Parent(s): 53b2a93

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +427 -634
app.py CHANGED
@@ -1,34 +1,116 @@
1
- """Reachy Mini Controller - Fun, Queue-Based Control Interface"""
 
 
 
2
 
3
  import asyncio
4
- import subprocess
5
- import sys
6
- import threading
7
  import json
 
8
  import time
 
9
  from dataclasses import dataclass
10
  from typing import List, Optional
11
 
12
  import cv2
13
  import gradio as gr
14
  import numpy as np
15
- from fastapi import FastAPI, WebSocket
16
  from fastapi.responses import StreamingResponse
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
- from reachy_mini.utils import create_head_pose
 
19
 
20
- robot_ws = None # type: Optional[WebSocket]
21
- robot_loop = None # type: Optional[asyncio.AbstractEventLoop]
 
22
 
23
- _black_frame = np.zeros((640, 640, 3), dtype=np.uint8)
24
- _, _buffer = cv2.imencode('.jpg', _black_frame)
 
 
25
 
26
- frame_lock = threading.Lock()
27
- latest_frame_data = {
28
- "bytes": _buffer.tobytes(),
29
- "timestamp": time.time()
30
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
 
 
 
 
 
32
  @dataclass
33
  class Movement:
34
  name: str
@@ -38,675 +120,386 @@ class Movement:
38
  roll: float = 0
39
  pitch: float = 0
40
  yaw: float = 0
 
41
  left_antenna: Optional[float] = None
42
  right_antenna: Optional[float] = None
43
  duration: float = 1.0
44
 
45
-
46
- # Preset movements library
47
- PRESET_MOVEMENTS = {
48
- "Home": Movement("Home", 0, 0, 0, 0, 0, 0, 0, 0),
49
- "Look Left": Movement("Look Left", 0, 0, 0, 0, 0, -30),
50
- "Look Right": Movement("Look Right", 0, 0, 0, 0, 0, 30),
51
- "Look Up": Movement("Look Up", 0, 0, 0, 0, -20, 0),
52
- "Look Down": Movement("Look Down", 0, 0, 0, 0, 15, 0),
53
- "Tilt Left": Movement("Tilt Left", 0, 0, 0, -20, 0, 0),
54
- "Tilt Right": Movement("Tilt Right", 0, 0, 0, 20, 0, 0),
55
- "Curious": Movement("Curious", 10, 0, 10, 15, -10, -15, 45, -45),
56
- "Excited": Movement("Excited", 0, 0, 20, 0, -15, 0, 90, 90),
57
- "Shy": Movement("Shy", -10, 0, -10, 10, 10, 20, -30, 30),
58
  }
59
 
60
- # Preset sequences
61
- PRESET_SEQUENCES = {
62
  "Wave": ["Home", "Look Left", "Look Right", "Look Left", "Look Right", "Home"],
63
  "Nod": ["Home", "Look Down", "Look Up", "Look Down", "Home"],
64
- "Excited Dance": ["Home", "Excited", "Tilt Left", "Tilt Right", "Tilt Left", "Home"],
65
- "Look Around": ["Home", "Look Left", "Look Up", "Look Right", "Look Down", "Home"],
66
- "Curious Peek": ["Home", "Curious", "Look Right", "Look Left", "Home"],
67
  }
68
 
69
 
70
- class ReachyController:
 
71
  def __init__(self):
72
- self.daemon_process = None
73
- self.running = False
74
-
75
- self.movement_queue: List[Movement] = []
76
  self.is_playing = False
 
77
  self.playback_speed = 1.0
78
- self.play_thread = None
79
- self.auto_play = True # Auto-play mode enabled by default
80
-
81
- # Connection settings
82
- self.connection_mode = "external" # "local" or "external"
83
- self.external_ip = "192.168.1.100" # Default external IP
84
-
85
- def start_daemon(self):
86
- """Start the Reachy Mini daemon (local mode only)"""
87
- try:
88
- if self.connection_mode != "local":
89
- return "ℹ️ External mode: daemon should be running on remote host"
90
-
91
- if self.daemon_process is not None:
92
- return "⚠️ Daemon already running"
93
-
94
- python_cmd = "mjpython" if sys.platform == "darwin" else sys.executable
95
- self.daemon_process = subprocess.Popen(
96
- [python_cmd, "-m", "reachy_mini.daemon.app.main", "--sim",
97
- "--scene", "minimal", "--headless", "--websocket-uri", "ws://localhost:7860",
98
- "--log-file", "daemon.log", "--deactivate-audio"],
99
- stdout=subprocess.PIPE,
100
- stderr=subprocess.PIPE,
101
- text=True
102
- )
103
-
104
- frame_received = False # TODO: check if video is streaming
105
-
106
- if self.daemon_process.poll() is not None:
107
- return "❌ Daemon failed to start"
108
-
109
- return "✅ Daemon started" if frame_received else "⚠️ Daemon started (no video)"
110
- except Exception as e:
111
- return f"❌ Daemon error: {str(e)}"
112
-
113
- def generate_mjpeg_stream(self):
114
- global latest_frame_data, frame_lock
115
- last_timestamp = 0.0
116
-
117
- while True:
118
- # 1. Check if frame has changed
119
- with frame_lock:
120
- current_bytes = latest_frame_data["bytes"]
121
- current_timestamp = latest_frame_data["timestamp"]
122
-
123
- # 2. Only yield if this is a new frame
124
- if current_timestamp > last_timestamp:
125
- last_timestamp = current_timestamp
126
- if current_bytes is not None:
127
- yield (b'--frame\r\n'
128
- b'Content-Type: image/jpeg\r\n\r\n' + current_bytes + b'\r\n')
129
- else:
130
- # If no new frame, sleep a bit longer to save CPU
131
- time.sleep(0.02)
132
- continue
133
-
134
- # Cap FPS slightly to prevent saturation
135
- time.sleep(0.02)
136
 
137
- def auto_start(self):
138
- """Auto-start daemon and robot connection"""
139
- status_msgs = []
140
-
141
- # Start daemon (only for local mode)
142
- if self.connection_mode == "local":
143
- msg = self.start_daemon()
144
- status_msgs.append(msg)
145
- yield "\n".join(status_msgs)
146
- else:
147
- # For external mode, just start frame listener
148
- self.start_frame_listener()
149
- status_msgs.append(f"ℹ️ External mode: connecting to {self.external_ip}")
150
- yield "\n".join(status_msgs)
151
-
152
- def restart_system(self):
153
- """Restart daemon and robot connection"""
154
- yield from self.stop_all()
155
- yield "🔄 Restarting..."
156
- yield from self.auto_start()
157
-
158
- def stop_all(self):
159
- """Stop everything"""
160
- self.is_playing = False
161
-
162
- if self.daemon_process:
163
- self.daemon_process.terminate()
164
- self.daemon_process.wait(timeout=5)
165
- self.daemon_process = None
166
-
167
- self.running = False
168
-
169
- return "✅ Stopped"
170
-
171
- def add_to_queue(self, movement_name, x, y, z, roll, pitch, yaw,
172
- left_ant, right_ant, duration):
173
- """Add a movement to the queue"""
174
- movement = Movement(
175
- name=movement_name or f"Custom {len(self.movement_queue) + 1}",
176
- x=x, y=y, z=z,
177
- roll=roll, pitch=pitch, yaw=yaw,
178
- left_antenna=left_ant,
179
- right_antenna=right_ant,
180
- duration=duration
181
- )
182
- self.movement_queue.append(movement)
183
-
184
- # Auto-play if enabled and not already playing
185
  if self.auto_play and not self.is_playing:
186
- self._start_auto_play()
187
-
188
- return self.format_queue(), f"✅ Added: {movement.name}"
189
-
190
- def add_preset(self, preset_name):
191
- """Add a preset movement to queue"""
192
- if preset_name not in PRESET_MOVEMENTS:
193
- return self.format_queue(), f"❌ Unknown preset: {preset_name}"
194
-
195
- self.movement_queue.append(PRESET_MOVEMENTS[preset_name])
196
-
197
- # Auto-play if enabled and not already playing
198
- if self.auto_play and not self.is_playing:
199
- self._start_auto_play()
200
-
201
- return self.format_queue(), f" Added: {preset_name}"
202
-
203
- def add_sequence(self, sequence_name):
204
- """Add a preset sequence to queue"""
205
- if sequence_name not in PRESET_SEQUENCES:
206
- return self.format_queue(), "❌ Unknown sequence"
207
-
208
- for preset_name in PRESET_SEQUENCES[sequence_name]:
209
- self.movement_queue.append(PRESET_MOVEMENTS[preset_name])
210
-
211
- # Auto-play if enabled and not already playing
212
- if self.auto_play and not self.is_playing:
213
- self._start_auto_play()
214
-
215
- return self.format_queue(), f"✅ Added sequence: {sequence_name}"
216
-
217
  def clear_queue(self):
218
- """Clear the movement queue"""
219
- self.movement_queue.clear()
220
- self.is_playing = False # Stop playback when clearing
221
- return self.format_queue(), "🗑️ Queue cleared"
222
-
223
  def remove_last(self):
224
- """Remove last movement from queue"""
225
- if self.movement_queue:
226
- removed = self.movement_queue.pop()
227
- return self.format_queue(), f"🗑️ Removed: {removed.name}"
228
- return self.format_queue(), "⚠️ Queue is empty"
229
-
230
- def format_queue(self):
231
- """Format queue for display"""
232
- if not self.movement_queue:
233
- return "📋 Queue is empty\n\nAdd movements using presets or custom controls"
234
-
235
- lines = ["📋 Movement Queue:\n"]
236
- total_duration = 0
237
 
238
- for i, mov in enumerate(self.movement_queue, 1):
239
- total_duration += mov.duration
240
- emoji = "▶️" if i == 1 else "⏸️"
241
-
242
- # Format head position
243
- head_str = f"Head: x={mov.x:.0f} y={mov.y:.0f} z={mov.z:.0f} r={mov.roll:.0f}° p={mov.pitch:.0f}° y={mov.yaw:.0f}°"
244
-
245
- # Format antennas if present
246
- ant_str = ""
247
- if mov.left_antenna is not None and mov.right_antenna is not None:
248
- ant_str = f"\n Antennas: L={mov.left_antenna:.0f}° R={mov.right_antenna:.0f}°"
249
-
250
- lines.append(
251
- f"{emoji} {i}. {mov.name} ({mov.duration}s)\n"
252
- f" {head_str}{ant_str}"
253
- )
254
 
255
- lines.append(f"\n⏱️ Total duration: {total_duration:.1f}s")
256
- lines.append(f"{'🔄 Auto-play: ON' if self.auto_play else '⏸️ Auto-play: OFF'}")
257
  return "\n".join(lines)
258
-
259
- def play_queue(self, speed):
260
- """Execute the movement queue"""
261
- if not self.movement_queue:
262
- return self.format_queue(), "⚠️ Queue is empty"
263
-
264
- if self.is_playing:
265
- return self.format_queue(), "⚠️ Already playing"
266
-
267
- self.playback_speed = speed
268
  self.is_playing = True
269
- self.play_thread = threading.Thread(target=self._play_loop, daemon=True)
270
  self.play_thread.start()
271
-
272
- return self.format_queue(), f"▶️ Playing at {speed}x speed..."
273
-
274
- def _play_loop(self):
275
- """Background thread to execute movements"""
 
 
 
 
 
 
276
  try:
277
- current_index = 0
278
- while self.is_playing:
279
- # Check if there are movements to play
280
- if current_index < len(self.movement_queue):
281
- print("[Space] Playing movement:", current_index)
282
- movement = self.movement_queue[current_index]
283
-
284
- # Create pose
285
- pose = create_head_pose(
286
- x=movement.x, y=movement.y, z=movement.z,
287
- roll=movement.roll, pitch=movement.pitch, yaw=movement.yaw,
288
- degrees=True, mm=True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
  )
290
-
291
- # Adjust duration by playback speed
292
- actual_duration = movement.duration / self.playback_speed
293
-
294
-
295
- if movement.left_antenna is not None and movement.right_antenna is not None:
296
- send_robot_command({
297
- "type": "movement",
298
- "movement": {
299
- "head": pose.tolist(),
300
- "antennas": [np.deg2rad(movement.right_antenna), np.deg2rad(movement.left_antenna)],
301
- "duration": actual_duration
302
- }
303
- })
304
- else:
305
- send_robot_command({
306
- "type": "movement",
307
- "movement": {
308
- "head": pose.tolist(),
309
- "duration": actual_duration
310
- }
311
- })
312
-
313
- current_index += 1
314
  else:
315
- # No more movements, stop if not in auto-play mode
316
- if not self.auto_play:
317
- break
318
- # In auto-play mode, wait for new movements
319
- time.sleep(0.1)
320
-
 
 
 
 
 
 
 
321
  except Exception as e:
322
- print(f"Error during playback: {e}")
323
- finally:
324
  self.is_playing = False
325
-
326
- def _start_auto_play(self):
327
- """Start auto-play mode"""
328
- if not self.is_playing:
329
- self.is_playing = True
330
- self.play_thread = threading.Thread(target=self._play_loop, daemon=True)
331
- self.play_thread.start()
332
-
333
- def toggle_auto_play(self, enabled):
334
- """Toggle auto-play mode"""
335
- self.auto_play = enabled
336
-
337
- if self.auto_play and self.movement_queue and not self.is_playing:
338
- self._start_auto_play()
339
-
340
- return self.format_queue(), f"{'🔄 Auto-play enabled' if enabled else '⏸️ Auto-play disabled'}"
341
-
342
- def update_speed(self, speed):
343
- """Update playback speed in real-time"""
344
- self.playback_speed = speed
345
- return f"⚡ Speed: {speed}x"
346
-
347
- def stop_playback(self):
348
- """Stop current playback"""
349
- self.is_playing = False
350
- if self.play_thread:
351
- self.play_thread.join(timeout=2)
352
-
353
- # If auto-play is still enabled, inform user
354
- msg = "⏹️ Stopped"
355
- if self.auto_play:
356
- msg += " (auto-play still enabled)"
357
-
358
- return self.format_queue(), msg
359
-
360
- def set_connection_mode(self, mode, external_ip=None):
361
- """Set connection mode and optionally external IP"""
362
- self.connection_mode = mode
363
- print(f"External IP: {external_ip}")
364
- if external_ip:
365
- self.external_ip = external_ip
366
-
367
- yield f"🔄 Connection mode set to: {mode}\n🔄 Restarting..."
368
- yield from self.restart_system()
369
 
 
370
 
371
- # Create manager
372
- manager = ReachyController()
373
 
374
- # Build Gradio interface with improved layout
375
- with gr.Blocks(title="Reachy Controller", theme=gr.themes.Soft()) as demo:
376
- gr.Markdown("# 🤖 Reachy Mini Controller")
377
- gr.Markdown("Create fun movement sequences for your robot!")
 
 
 
 
 
378
 
379
- with gr.Row():
380
- # Left panel - Controls (narrower)
381
- with gr.Column(scale=2):
382
- # Connection mode section
383
- gr.Markdown("### 🔌 Connection Settings")
384
- connection_mode = gr.Radio(
385
- choices=["local", "external"],
386
- value="external",
387
- label="Connection Mode",
388
- info="Local: Launch daemon locally | External: Connect to remote daemon"
389
- )
390
- external_ip_input = gr.Textbox(
391
- label="External IP or Hostname",
392
- value="192.168.1.100",
393
- placeholder="192.168.1.100 or 8.tcp.ngrok.io:18951",
394
- visible=True,
395
- info="IP/hostname of remote daemon. Supports custom ports (e.g., ngrok.io:18951)"
396
- )
397
-
398
- def toggle_external_ip_visibility(mode):
399
- return gr.update(visible=(mode == "external"))
400
-
401
- connection_mode.change(
402
- fn=toggle_external_ip_visibility,
403
- inputs=[connection_mode],
404
- outputs=[external_ip_input]
405
- )
406
-
407
- apply_connection_btn = gr.Button("✅ Apply Connection Settings", variant="primary", size="sm")
408
-
409
- # Status section
410
- gr.Markdown("### 📡 System Status")
411
- status = gr.Textbox(
412
- label="Status",
413
- lines=3,
414
- interactive=False,
415
- value="🔄 Initializing system..."
416
- )
417
- restart_btn = gr.Button("🔄 Restart System", variant="secondary", size="sm")
 
 
 
 
 
 
418
 
419
- test_cmd_btn = gr.Button("📡 Send test command", size="sm")
420
 
421
- def send_test_command():
422
- send_robot_command({"type": "test", "message": "hello from space"})
423
- return "Sent test command to robot"
 
 
 
 
424
 
 
 
 
 
 
425
 
426
- test_cmd_btn.click(
427
- fn=send_test_command,
428
- outputs=[status], # reuse your status textbox
429
- )
430
-
431
- gr.Markdown("### 🎮 Playback Controls")
432
-
433
- auto_play_toggle = gr.Checkbox(
434
- label="🔄 Auto-play",
435
- value=True,
436
- info="Execute movements automatically when added"
437
- )
438
-
439
- speed_slider = gr.Slider(
440
- 0.25, 3.0, 1.0,
441
- label="⚡ Speed Multiplier",
442
- info="Adjust playback speed"
443
- )
444
-
445
- with gr.Row():
446
- play_btn = gr.Button("▶️ Play All", variant="primary", scale=2)
447
- stop_play_btn = gr.Button("⏹️ Stop", scale=1)
448
-
449
- with gr.Row():
450
- clear_btn = gr.Button("🗑️ Clear All")
451
- remove_btn = gr.Button("↶ Remove Last")
452
-
453
- # Queue display
454
- queue_display = gr.Textbox(
455
- label="📋 Movement Queue",
456
- lines=20,
457
- interactive=False,
458
- value=manager.format_queue()
459
- )
460
-
461
- # Right panel - Simulation view (larger and more square)
462
- with gr.Column(scale=3):
463
- # sim_view = gr.Image(
464
- # label="🎬 Robot Simulation",
465
- # type="numpy",
466
- # height=1080,
467
- # width=1080,
468
- # show_label=True,
469
- # streaming=True
470
- # )
471
- html_code = """
472
- <html>
473
- <body>
474
- <img src="/video_feed" style="width: 100%; max-width: 1080px; border-radius: 8px;">
475
- </body>
476
- </html>
477
- """
478
- sim_view = gr.HTML(value=html_code, label="🎬 Robot Simulation")
479
 
 
 
 
480
 
481
- # Movement builder section below
482
  with gr.Row():
483
- with gr.Column():
484
- gr.Markdown("### 🎨 Quick Presets")
485
-
486
- with gr.Row():
487
- preset_btns = []
488
- for preset in list(PRESET_MOVEMENTS.keys())[:5]:
489
- btn = gr.Button(preset, size="sm")
490
- preset_btns.append((btn, preset))
491
 
492
- with gr.Row():
493
- for preset in list(PRESET_MOVEMENTS.keys())[5:]:
494
- btn = gr.Button(preset, size="sm")
495
- preset_btns.append((btn, preset))
496
-
497
- with gr.Column():
498
- gr.Markdown("### 🎬 Sequences")
499
- with gr.Row():
500
- sequence_dropdown = gr.Dropdown(
501
- choices=list(PRESET_SEQUENCES.keys()),
502
- label="Select Sequence",
503
- value=None,
504
- scale=3
 
 
505
  )
506
- add_seq_btn = gr.Button("➕ Add", scale=1)
507
-
508
- # Custom movement controls in accordion
509
- with gr.Accordion("🎯 Custom Movement Builder", open=False):
510
- custom_name = gr.Textbox(label="Movement Name", placeholder="My Move")
511
-
512
- with gr.Row():
513
- x = gr.Slider(-50, 50, 0, label="X (mm)", step=5)
514
- y = gr.Slider(-50, 50, 0, label="Y (mm)", step=5)
515
- z = gr.Slider(-20, 50, 0, label="Z (mm)", step=5)
516
-
517
- with gr.Row():
518
- roll = gr.Slider(-30, 30, 0, label="Roll (°)", step=5)
519
- pitch = gr.Slider(-30, 30, 0, label="Pitch (°)", step=5)
520
- yaw = gr.Slider(-45, 45, 0, label="Yaw (°)", step=5)
521
-
522
- with gr.Row():
523
- left_ant = gr.Slider(-180, 180, 0, label="Left Antenna (°)", step=15)
524
- right_ant = gr.Slider(-180, 180, 0, label="Right Antenna (°)", step=15)
525
-
526
- duration = gr.Slider(0.3, 3.0, 1.0, label="Duration (s)", step=0.1)
527
-
528
- add_custom_btn = gr.Button("➕ Add to Queue", variant="primary")
529
-
530
- # Connect events - Connection settings
531
- apply_connection_btn.click(
532
- fn=manager.set_connection_mode,
533
- inputs=[connection_mode, external_ip_input],
534
- outputs=[status]
535
- )
536
-
537
- # Connect events - System control
538
- restart_btn.click(fn=manager.restart_system, outputs=[status])
539
-
540
- # Connect events - Playback control
541
- auto_play_toggle.change(
542
- fn=manager.toggle_auto_play,
543
- inputs=[auto_play_toggle],
544
- outputs=[queue_display, status]
545
- )
546
-
547
- speed_slider.change(
548
- fn=manager.update_speed,
549
- inputs=[speed_slider],
550
- outputs=[status]
551
- )
552
-
553
- play_btn.click(
554
- fn=manager.play_queue,
555
- inputs=[speed_slider],
556
- outputs=[queue_display, status]
557
- )
558
- stop_play_btn.click(fn=manager.stop_playback, outputs=[queue_display, status])
559
- clear_btn.click(fn=manager.clear_queue, outputs=[queue_display, status])
560
- remove_btn.click(fn=manager.remove_last, outputs=[queue_display, status])
561
-
562
- # Connect preset buttons
563
- for btn, preset_name in preset_btns:
564
- btn.click(
565
- fn=lambda p=preset_name: manager.add_preset(p),
566
- outputs=[queue_display, status]
567
- )
568
-
569
- # Connect sequence dropdown
570
- add_seq_btn.click(
571
- fn=manager.add_sequence,
572
- inputs=[sequence_dropdown],
573
- outputs=[queue_display, status]
574
- )
575
-
576
- # Connect custom movement
577
- add_custom_btn.click(
578
- fn=manager.add_to_queue,
579
- inputs=[custom_name, x, y, z, roll, pitch, yaw, left_ant, right_ant, duration],
580
- outputs=[queue_display, status]
581
- )
582
- app = FastAPI()
583
 
584
- def send_robot_command(cmd: dict):
585
- global robot_ws, robot_loop
586
- send_start_time = time.perf_counter()
587
- print("[Space] Sending command to robot:", cmd)
588
-
589
- if robot_ws is None or robot_loop is None:
590
- print("[Space] Cannot send command, robot not connected")
591
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
592
 
593
- async def _send():
594
- global robot_ws
595
- try:
596
- send_ws_start = time.perf_counter()
597
- await robot_ws.send_json(cmd) # or send_text(json.dumps(cmd)) if you prefer
598
- send_ws_time = time.perf_counter() - send_ws_start
599
- print(f"[WebSocket /robot] Send time: {send_ws_time*1000:.2f}ms")
600
- except Exception as e:
601
- print("[Space] Error sending command to robot:", e)
602
- robot_ws = None
603
 
604
- # Schedule coroutine on the main event loop from this worker thread
605
- fut = asyncio.run_coroutine_threadsafe(_send(), robot_loop)
 
 
 
 
 
 
 
 
 
 
 
606
 
607
- # Optionally wait for completion (and surface errors)
608
- try:
609
- fut.result(timeout=2)
610
- total_send_time = time.perf_counter() - send_start_time
611
- print(f"[WebSocket /robot] Total send time (including scheduling): {total_send_time*1000:.2f}ms")
612
- except Exception as e:
613
- print("[Space] Command failed:", e)
614
- robot_ws = None
615
-
616
- @app.websocket("/mujoco_stream")
617
- async def robot_socket(ws: WebSocket):
618
- global latest_frame_data, frame_lock
619
- await ws.accept()
620
- print("[Space] MuJoCo stream connected")
621
-
622
- try:
623
- while True:
624
- msg = await ws.receive()
625
-
626
- if msg["type"] == "websocket.receive":
627
- # 1. Safe retrieval using .get() to avoid KeyError
628
- payload_bytes = msg.get("bytes")
629
- payload_text = msg.get("text")
630
-
631
- # 2. Handle Binary Frame
632
- if payload_bytes is not None:
633
- with frame_lock:
634
- latest_frame_data["bytes"] = payload_bytes
635
- latest_frame_data["timestamp"] = time.time()
636
-
637
- # 3. Handle Text Message (The Ping)
638
- elif payload_text is not None:
639
- try:
640
- data = json.loads(payload_text)
641
- if data.get("type") == "ping":
642
- # Optional: Print debug only if you need to verify it's working
643
- print("[Space] Frame keep-alive received")
644
- pass
645
- except json.JSONDecodeError:
646
- print(f"[Space] Received invalid JSON: {payload_text}")
647
-
648
- elif msg["type"] == "websocket.disconnect":
649
- print("[Space] MuJoCo stream disconnected")
650
- break
651
-
652
- except Exception as e:
653
- print(f"[Space] MuJoCo stream disconnected error: {e}")
654
 
655
- @app.get("/video_feed")
656
- def video_feed():
657
- return StreamingResponse(
658
- manager.generate_mjpeg_stream(),
659
- media_type="multipart/x-mixed-replace; boundary=frame"
660
- )
661
 
662
- @app.websocket("/robot")
663
- async def robot_socket(ws: WebSocket):
664
- global robot_ws, robot_loop
665
- await ws.accept()
666
- robot_ws = ws
667
- robot_loop = asyncio.get_running_loop()
668
- print("[Space] Robot connected")
669
-
670
- # --- HEARTBEAT MECHANISM ---
671
- # Define a keep-alive task that runs in the background
672
- async def keep_alive():
673
- try:
674
- while True:
675
- await asyncio.sleep(30) # Send ping every 30s
676
- if robot_ws:
677
- # Send a lightweight ping
678
- await robot_ws.send_json({"type": "ping"})
679
- except Exception as e:
680
- print(f"[Space] Heartbeat stopped: {e}")
681
 
682
- # Start the heartbeat
683
- heartbeat_task = asyncio.create_task(keep_alive())
 
 
 
 
684
 
685
- try:
686
- while True:
687
- msg = await ws.receive()
688
- if msg["type"] == "websocket.receive":
689
- if msg["text"] is not None:
690
- # Handle robot messages here
691
- pass
692
- elif msg["type"] == "websocket.disconnect":
693
- print("[Space] Robot disconnected")
694
- break
695
- except Exception as e:
696
- print("[Space] Robot disconnected (Error)", e)
697
- finally:
698
- heartbeat_task.cancel() # Stop the heartbeat when connection dies
699
- if robot_ws is ws:
700
- robot_ws = None
701
 
 
702
  app = gr.mount_gradio_app(app, demo, path="/")
703
 
704
  if __name__ == "__main__":
705
- import uvicorn
706
- uvicorn.run(
707
- app,
708
- host="0.0.0.0",
709
- port=7860,
710
- proxy_headers=True, # Tell Uvicorn to trust the proxy's headers
711
- forwarded_allow_ips="*" # Trust headers from any IP (the internal HF proxy)
712
- )
 
1
+ """
2
+ Reachy Mini Controller - Cleaned Version
3
+ A centralized server that listens for Robot connections and hosts a Gradio control interface.
4
+ """
5
 
6
  import asyncio
7
+ import io
 
 
8
  import json
9
+ import threading
10
  import time
11
+ import queue
12
  from dataclasses import dataclass
13
  from typing import List, Optional
14
 
15
  import cv2
16
  import gradio as gr
17
  import numpy as np
18
+ from fastapi import FastAPI, WebSocket, WebSocketDisconnect
19
  from fastapi.responses import StreamingResponse
20
+ import uvicorn
21
+ from fastrtc import WebRTC
22
+
23
+ # Try to import the utility, handle error if running in standalone test without library
24
+ try:
25
+ from reachy_mini.utils import create_head_pose
26
+ except ImportError:
27
+ print("⚠️ Warning: reachy_mini module not found. Mocking create_head_pose for testing.")
28
+ def create_head_pose(**kwargs): return np.array([0,0,0])
29
+
30
+ AUDIO_SAMPLE_RATE = 16000 # respeaker samplerate
31
+
32
+ import struct
33
+
34
+ def gen_wav_header(sample_rate, bits_per_sample, channels):
35
+ """
36
+ Generates a generic WAV header.
37
+ We set the file size to 0xFFFFFFFF (max) to trick the browser into
38
+ thinking it's a very long file for streaming purposes.
39
+ """
40
+ datasize = 0xFFFFFFFF
41
+ o = bytes("RIFF", 'ascii') # (4byte) Marks file as RIFF
42
+ o += struct.pack('<I', datasize + 36) # (4byte) File size in bytes excluding "RIFF" and size
43
+ o += bytes("WAVE", 'ascii') # (4byte) File type
44
+ o += bytes("fmt ", 'ascii') # (4byte) Format Chunk Marker
45
+ o += struct.pack('<I', 16) # (4byte) Length of above format data
46
+ o += struct.pack('<H', 1) # (2byte) Format type (1 - PCM)
47
+ o += struct.pack('<H', channels) # (2byte)
48
+ o += struct.pack('<I', sample_rate) # (4byte)
49
+ o += struct.pack('<I', sample_rate * channels * bits_per_sample // 8) # (4byte)
50
+ o += struct.pack('<H', channels * bits_per_sample // 8) # (2byte)
51
+ o += struct.pack('<H', bits_per_sample) # (2byte)
52
+ o += bytes("data", 'ascii') # (4byte) Data Chunk Marker
53
+ o += struct.pack('<I', datasize) # (4byte) Data size in bytes
54
+ return o
55
+
56
+ # --- 1. Global State Management ---
57
+ class GlobalState:
58
+ """
59
+ Singleton-style class to manage shared state between FastAPI (WebSockets)
60
+ and Gradio (UI Thread).
61
+ """
62
+ def __init__(self):
63
+ # Connection handles
64
+ self.robot_ws: Optional[WebSocket] = None
65
+ self.robot_loop: Optional[asyncio.AbstractEventLoop] = None
66
+
67
+ # Video Stream Data
68
+ self.frame_lock = threading.Lock()
69
+ self.black_frame = np.zeros((640, 640, 3), dtype=np.uint8)
70
+ _, buffer = cv2.imencode('.jpg', self.black_frame)
71
+ self.latest_frame_bytes = buffer.tobytes()
72
+ self.latest_frame_ts = time.time()
73
 
74
+ # Audio Stream Data
75
+ self.audio_queue = queue.Queue()
76
 
77
+ def set_robot_connection(self, ws: WebSocket, loop: asyncio.AbstractEventLoop):
78
+ self.robot_ws = ws
79
+ self.robot_loop = loop
80
 
81
+ def update_frame(self, frame_bytes: bytes):
82
+ with self.frame_lock:
83
+ self.latest_frame_bytes = frame_bytes
84
+ self.latest_frame_ts = time.time()
85
 
86
+ def push_audio(self, audio_bytes: bytes):
87
+ """
88
+ Pushes raw audio bytes.
89
+
90
+ If the queue is full (meaning we are lagging), throw away the OLDEST audio.
91
+ """
92
+ # Limit queue to ~0.5 seconds of audio (approx 5-6 chunks of 4096 bytes)
93
+ MAX_QUEUE_SIZE = 6
94
+
95
+ while self.audio_queue.qsize() >= MAX_QUEUE_SIZE:
96
+ try:
97
+ # Drop the oldest chunk to make room for the new one (Keep 'Now', drop 'Past')
98
+ print("Dropping oldest audio, queue size is", self.audio_queue.qsize())
99
+ self.audio_queue.get_nowait()
100
+ except queue.Empty:
101
+ pass
102
+
103
+ self.audio_queue.put(audio_bytes)
104
+
105
+ def get_connection_status(self) -> str:
106
+ if self.robot_ws:
107
+ return "✅ Robot Connected"
108
+ return "🔴 Waiting for Robot..."
109
 
110
+ state = GlobalState()
111
+
112
+
113
+ # --- 2. Data Models & Presets ---
114
  @dataclass
115
  class Movement:
116
  name: str
 
120
  roll: float = 0
121
  pitch: float = 0
122
  yaw: float = 0
123
+ body_yaw: float = 0
124
  left_antenna: Optional[float] = None
125
  right_antenna: Optional[float] = None
126
  duration: float = 1.0
127
 
128
+ PRESETS = {
129
+ "Home": Movement("Home", 0, 0, 0, 0, 0, 0, 0, 0, 0),
130
+ "Look Left": Movement("Look Left", 0, 0, 0, 0, 0, 30, 1, 0, 0),
131
+ "Look Right": Movement("Look Right", 0, 0, 0, 0, 0, -30, -1, 0, 0),
132
+ "Look Up": Movement("Look Up", 0, 0, 0, 0, -20, 0, 0, 0, 0),
133
+ "Look Down": Movement("Look Down", 0, 0, 0, 0, 15, 0, 0, 0, 0),
134
+ "Curious": Movement("Curious", 10, 0, 10, 15, -10, -15, 0, 45, -45),
135
+ "Excited": Movement("Excited", 0, 0, 20, 0, -15, 0, 0, 90, 90),
136
+ "Shy": Movement("Shy", -10, 0, -10, 10, 10, 20, 0, -30, 30),
 
 
 
 
137
  }
138
 
139
+ SEQUENCES = {
 
140
  "Wave": ["Home", "Look Left", "Look Right", "Look Left", "Look Right", "Home"],
141
  "Nod": ["Home", "Look Down", "Look Up", "Look Down", "Home"],
142
+ "Excited Dance": ["Home", "Excited", "Look Left", "Look Right", "Home"],
 
 
143
  }
144
 
145
 
146
+ # --- 3. Controller Logic ---
147
+ class MovementManager:
148
  def __init__(self):
149
+ self.queue: List[Movement] = []
 
 
 
150
  self.is_playing = False
151
+ self.auto_play = True
152
  self.playback_speed = 1.0
153
+ self.play_thread: Optional[threading.Thread] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
+ def add_movement(self, mov: Movement):
156
+ self.queue.append(mov)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  if self.auto_play and not self.is_playing:
158
+ self.play_queue()
159
+ return self.get_queue_text(), f"✅ Added {mov.name}"
160
+
161
+ def add_preset(self, name: str):
162
+ if name in PRESETS:
163
+ return self.add_movement(PRESETS[name])
164
+ return self.get_queue_text(), "❌ Unknown Preset"
165
+
166
+ def add_sequence(self, name: str):
167
+ if name in SEQUENCES:
168
+ for move_name in SEQUENCES[name]:
169
+ self.queue.append(PRESETS[move_name])
170
+ if self.auto_play and not self.is_playing:
171
+ self.play_queue()
172
+ return self.get_queue_text(), f"✅ Added Sequence: {name}"
173
+ return self.get_queue_text(), " Unknown Sequence"
174
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  def clear_queue(self):
176
+ self.queue.clear()
177
+ self.is_playing = False
178
+ return self.get_queue_text(), "🗑️ Queue Cleared"
179
+
 
180
  def remove_last(self):
181
+ if self.queue:
182
+ self.queue.pop()
183
+ return self.get_queue_text(), "🗑️ Removed Last"
184
+
185
+ def get_queue_text(self):
186
+ if not self.queue:
187
+ return "📋 Queue Empty"
 
 
 
 
 
 
188
 
189
+ lines = ["📋 Current Queue:"]
190
+ for i, m in enumerate(self.queue, 1):
191
+ indicator = "▶️" if (i==1 and self.is_playing) else " "
192
+ lines.append(f"{indicator} {i}. {m.name} ({m.duration}s)")
 
 
 
 
 
 
 
 
 
 
 
 
193
 
 
 
194
  return "\n".join(lines)
195
+
196
+ def play_queue(self, speed=None):
197
+ if speed: self.playback_speed = speed
198
+ if self.is_playing: return self.get_queue_text(), "⚠️ Already Playing"
199
+ if not self.queue: return self.get_queue_text(), "⚠️ Queue Empty"
200
+
 
 
 
 
201
  self.is_playing = True
202
+ self.play_thread = threading.Thread(target=self._worker, daemon=True)
203
  self.play_thread.start()
204
+ return self.get_queue_text(), "▶️ Playing..."
205
+
206
+ def stop_playback(self):
207
+ self.is_playing = False
208
+ if self.play_thread:
209
+ self.play_thread.join(timeout=1.0)
210
+ return self.get_queue_text(), "⏹️ Stopped"
211
+
212
+ def _worker(self):
213
+ """Background thread that processes the queue."""
214
+ idx = 0
215
  try:
216
+ while self.is_playing and idx < len(self.queue):
217
+ move = self.queue[idx]
218
+
219
+ # 1. Build Payload
220
+ pose = create_head_pose(
221
+ x=move.x, y=move.y, z=move.z,
222
+ roll=move.roll, pitch=move.pitch, yaw=move.yaw,
223
+ degrees=True, mm=True
224
+ )
225
+
226
+ payload = {
227
+ "type": "movement",
228
+ "movement": {
229
+ "head": pose.tolist(),
230
+ "body_yaw": move.body_yaw,
231
+ "duration": move.duration / self.playback_speed
232
+ }
233
+ }
234
+
235
+ # Add antennas if specified
236
+ if move.left_antenna is not None and move.right_antenna is not None:
237
+ payload["movement"]["antennas"] = [
238
+ np.deg2rad(move.right_antenna),
239
+ np.deg2rad(move.left_antenna)
240
+ ]
241
+
242
+ # 2. Send to Robot (Async safe)
243
+ if state.robot_ws and state.robot_loop:
244
+ asyncio.run_coroutine_threadsafe(
245
+ state.robot_ws.send_json(payload),
246
+ state.robot_loop
247
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
248
  else:
249
+ print("⚠️ Robot not connected, skipping command.")
250
+
251
+ # 3. Wait for move to finish (blocking thread, not async loop)
252
+ time.sleep(move.duration / self.playback_speed)
253
+ idx += 1
254
+
255
+ # Loop finished
256
+ if not self.auto_play:
257
+ self.is_playing = False
258
+ else:
259
+ # In auto-play, we stay "playing" but wait for new items
260
+ self.is_playing = False
261
+
262
  except Exception as e:
263
+ print(f"Playback Error: {e}")
 
264
  self.is_playing = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
 
266
+ manager = MovementManager()
267
 
 
 
268
 
269
+ # --- 4. FastAPI & WebSocket Logic ---
270
+ app = FastAPI()
271
+
272
+ @app.websocket("/robot")
273
+ async def robot_endpoint(ws: WebSocket):
274
+ """Endpoint for the Robot to connect to (Control Channel)."""
275
+ await ws.accept()
276
+ state.set_robot_connection(ws, asyncio.get_running_loop())
277
+ print("[System] Robot Connected!")
278
 
279
+ try:
280
+ # Heartbeat loop
281
+ while True:
282
+ # We wait for messages, but mostly we just hold the connection open
283
+ # and send commands via the state.robot_ws handle.
284
+ msg = await ws.receive()
285
+ if msg["type"] == "websocket.disconnect":
286
+ break
287
+ except (WebSocketDisconnect, Exception):
288
+ print("[System] Robot Disconnected")
289
+ finally:
290
+ state.robot_ws = None
291
+
292
+ @app.websocket("/video_stream")
293
+ async def stream_endpoint(ws: WebSocket):
294
+ """Endpoint for Robot/Sim to send video frames."""
295
+ await ws.accept()
296
+ try:
297
+ while True:
298
+ msg = await ws.receive()
299
+ if "bytes" in msg and msg["bytes"]:
300
+ state.update_frame(msg["bytes"])
301
+ except Exception:
302
+ pass
303
+
304
+ @app.websocket("/audio_stream")
305
+ async def audio_endpoint(ws: WebSocket):
306
+ """Endpoint for Robot/Sim to send raw Audio bytes (int16)."""
307
+ await ws.accept()
308
+ print("[Audio] Stream Connected")
309
+ try:
310
+ while True:
311
+ # Receive bytes directly
312
+ data = await ws.receive()
313
+ if data.get('type') == 'websocket.receive' and data.get('bytes'):
314
+ state.push_audio(data.get('bytes'))
315
+ elif data.get('type') == 'websocket.receive' and data.get('text') == "ping":
316
+ print("[Audio] Received ping")
317
+ elif data.get('type') == 'websocket.disconnect':
318
+ print("[Audio] Disconnected")
319
+ break
320
+ else:
321
+ print(f"[Audio] Received unknown message: {data}")
322
+ except Exception as e:
323
+ print(f"[Audio] Disconnected: {e}")
324
 
325
+ # --- 5. Gradio Interface ---
326
 
327
+ def webrtc_audio_generator():
328
+ """
329
+ Generator for FastRTC.
330
+ """
331
+ # Clear old data to start fresh
332
+ with state.audio_queue.mutex:
333
+ state.audio_queue.queue.clear()
334
 
335
+ # OPTIMIZATION: Reduce target samples.
336
+ # 4096 samples @ 16kHz is 256ms of latency built-in!
337
+ # Try 1024 (64ms) or 512 (32ms) for lower latency.
338
+ TARGET_SAMPLES = 1024
339
+ byte_buffer = bytearray()
340
 
341
+ while True:
342
+ try:
343
+ # Wait up to 1 second for data. If no data, loop again.
344
+ # Do NOT use a short timeout combined with silence generation.
345
+ chunk_bytes = state.audio_queue.get(timeout=1.0)
346
+ if chunk_bytes:
347
+ byte_buffer.extend(chunk_bytes)
348
+ except queue.Empty:
349
+ # If we really have no data for a long time, just continue waiting.
350
+ # Do NOT yield silence here.
351
+ continue
352
+
353
+ # Only yield when we have enough data
354
+ while len(byte_buffer) >= TARGET_SAMPLES * 2: # int16 = 2 bytes
355
+ read_size = TARGET_SAMPLES * 2
356
+ out_bytes = byte_buffer[:read_size]
357
+ byte_buffer = byte_buffer[read_size:]
358
+
359
+ audio_int16 = np.frombuffer(out_bytes, dtype=np.int16)
360
+ audio_int16 = audio_int16.reshape(1, -1)
361
+
362
+ yield (AUDIO_SAMPLE_RATE, audio_int16)
363
+
364
+ def webrtc_video_generator():
365
+ """
366
+ Generator for FastRTC WebRTC (mode='receive', modality='video').
367
+
368
+ It reads JPEG bytes from state.latest_frame_bytes, decodes them with OpenCV,
369
+ and yields HxWx3 uint8 frames as expected by FastRTC.
370
+ """
371
+ last_ts = 0.0
372
+ frame = state.black_frame.copy()
373
+
374
+ while True:
375
+ with state.frame_lock:
376
+ ts = state.latest_frame_ts
377
+ frame_bytes = state.latest_frame_bytes
378
+
379
+ if ts > last_ts and frame_bytes:
380
+ last_ts = ts
381
+ np_bytes = np.frombuffer(frame_bytes, dtype=np.uint8)
382
+ frame = cv2.imdecode(np_bytes, cv2.IMREAD_COLOR)
383
+ if frame is None:
384
+ frame = state.black_frame.copy()
385
+ # Shape (H, W, 3), dtype uint8
386
+ yield frame
 
 
 
 
 
 
 
387
 
388
+ with gr.Blocks(title="Reachy Controller", theme=gr.themes.Soft()) as demo:
389
+
390
+ gr.Markdown("## 🤖 Reachy Mini Controller")
391
 
 
392
  with gr.Row():
393
+ # --- LEFT COLUMN: Controls ---
394
+ with gr.Column(scale=1):
395
+ status_box = gr.Textbox(label="System Status", value=state.get_connection_status, every=2)
 
 
 
 
 
396
 
397
+ with gr.Group():
398
+ gr.Markdown("### 🎧 Audio Listen")
399
+
400
+ # Start button for the WebRTC stream
401
+ listen_btn = gr.Button("🔊 Start Listening", variant="secondary")
402
+
403
+ # FastRTC WebRTC component in receive mode, audio only
404
+ robot_audio = WebRTC(
405
+ label="Robot Audio",
406
+ modality="audio",
407
+ mode="receive",
408
+ # Optional niceties:
409
+ # icon="phone-solid.svg",
410
+ # icon_button_color="black",
411
+ # pulse_color="black",
412
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
 
414
+ # Wire generator to WebRTC component
415
+ robot_audio.stream(
416
+ fn=lambda: webrtc_audio_generator(),
417
+ inputs=None,
418
+ outputs=[robot_audio],
419
+ trigger=listen_btn.click,
420
+ )
421
+
422
+
423
+ with gr.Group():
424
+ gr.Markdown("### 🎮 Playback")
425
+ auto_play = gr.Checkbox(label="Auto-play", value=True)
426
+ speed = gr.Slider(0.5, 2.0, 1.0, label="Speed")
427
+
428
+ with gr.Row():
429
+ play_btn = gr.Button("▶️ Play", variant="primary")
430
+ stop_btn = gr.Button("⏹️ Stop")
431
+
432
+ with gr.Row():
433
+ clear_btn = gr.Button("🗑️ Clear")
434
+ undo_btn = gr.Button("↶ Undo")
435
 
436
+ queue_display = gr.Textbox(label="Queue", value=manager.get_queue_text, lines=10)
 
 
 
 
 
 
 
 
 
437
 
438
+ # --- RIGHT COLUMN: View ---
439
+ with gr.Column(scale=2):
440
+ robot_video = WebRTC(
441
+ label="Robot Video",
442
+ modality="video",
443
+ mode="receive",
444
+ )
445
+ robot_video.stream(
446
+ fn=lambda: webrtc_video_generator(),
447
+ inputs=None,
448
+ outputs=[robot_video],
449
+ trigger=listen_btn.click,
450
+ )
451
 
452
+ # --- Movement Builders ---
453
+ with gr.Tabs():
454
+ with gr.Tab("✨ Presets & Sequences"):
455
+ gr.Markdown("### Quick Actions")
456
+ with gr.Row(variant="panel"):
457
+ for name in PRESETS:
458
+ btn = gr.Button(name, size="sm")
459
+ btn.click(manager.add_preset, inputs=[gr.State(name)], outputs=[queue_display, status_box])
460
+
461
+ gr.Markdown("### Sequences")
462
+ with gr.Row():
463
+ for seq in SEQUENCES:
464
+ btn = gr.Button(f"🎬 {seq}", size="sm")
465
+ btn.click(manager.add_sequence, inputs=[gr.State(seq)], outputs=[queue_display, status_box])
466
+
467
+ with gr.Tab("🛠️ Custom Move"):
468
+ with gr.Row():
469
+ c_x = gr.Slider(-50, 50, 0, label="X")
470
+ c_y = gr.Slider(-50, 50, 0, label="Y")
471
+ c_z = gr.Slider(-20, 50, 0, label="Z")
472
+ with gr.Row():
473
+ c_r = gr.Slider(-30, 30, 0, label="Roll")
474
+ c_p = gr.Slider(-30, 30, 0, label="Pitch")
475
+ c_y_aw = gr.Slider(-45, 45, 0, label="Yaw")
476
+ with gr.Row():
477
+ c_la = gr.Slider(-180, 180, 0, label="Left Ant")
478
+ c_ra = gr.Slider(-180, 180, 0, label="Right Ant")
479
+
480
+ c_dur = gr.Slider(0.1, 5.0, 1.0, label="Duration")
481
+ c_add = gr.Button("➕ Add Custom Move", variant="primary")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
 
483
+ def _add_custom(x,y,z,r,p,yw,la,ra,d):
484
+ m = Movement("Custom", x,y,z,r,p,yw,la,ra,d)
485
+ return manager.add_movement(m)
 
 
 
486
 
487
+ c_add.click(_add_custom,
488
+ inputs=[c_x, c_y, c_z, c_r, c_p, c_y_aw, c_la, c_ra, c_dur],
489
+ outputs=[queue_display, status_box])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
 
491
+ # --- Event Wiring ---
492
+ auto_play.change(lambda x: setattr(manager, 'auto_play', x), inputs=[auto_play])
493
+ play_btn.click(manager.play_queue, inputs=[speed], outputs=[queue_display, status_box])
494
+ stop_btn.click(manager.stop_playback, outputs=[queue_display, status_box])
495
+ clear_btn.click(manager.clear_queue, outputs=[queue_display, status_box])
496
+ undo_btn.click(manager.remove_last, outputs=[queue_display, status_box])
497
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
498
 
499
+ # --- 6. Mount & Run ---
500
  app = gr.mount_gradio_app(app, demo, path="/")
501
 
502
  if __name__ == "__main__":
503
+ print("🚀 Server starting on http://0.0.0.0:7860")
504
+ print("ℹ️ Point your Robot/Sim to: ws://<YOUR_PC_IP>:7860/robot")
505
+ uvicorn.run(app, host="0.0.0.0", port=7860, proxy_headers=True, forwarded_allow_ips="*")