cduss Claude Opus 4.5 commited on
Commit
bdbd6d6
·
1 Parent(s): 9dcd4eb

Redesign as full telepresence app

Browse files

Features:
- Modern dark UI with video as main focus
- Live state bar showing all robot values
- Motor control with visual active state
- Head/body control with sliders
- Antenna control with sliders
- Animations (wake up, sleep)
- Sound playback with presets
- Recording controls
- Auto-refresh state every 2s
- Activity log

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Files changed (1) hide show
  1. index.html +738 -613
index.html CHANGED
@@ -3,328 +3,592 @@
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width" />
6
- <title>Reachy Mini WebRTC Dashboard</title>
7
  <style>
8
- * { box-sizing: border-box; }
9
  body {
10
  font-family: system-ui, -apple-system, sans-serif;
11
- margin: 0;
12
- padding: 20px;
13
- background: #1a1a2e;
14
  color: #eee;
15
  min-height: 100vh;
16
  }
17
- .container { max-width: 1200px; margin: 0 auto; }
18
- h1 { color: #00d4ff; margin-bottom: 10px; }
19
- .subtitle { color: #888; margin-bottom: 30px; }
20
-
21
- .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
22
- @media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
23
 
24
- .card {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
  background: #16213e;
26
- border-radius: 12px;
27
- padding: 20px;
28
- border: 1px solid #0f3460;
29
  }
30
- .card h2 { margin-top: 0; color: #00d4ff; font-size: 1.2em; }
31
-
32
- .status {
33
- display: inline-block;
 
34
  padding: 4px 12px;
35
- border-radius: 20px;
 
36
  font-size: 0.85em;
37
- font-weight: 500;
38
  }
39
- .status.connected { background: #00c853; color: #000; }
40
- .status.disconnected { background: #ff5252; color: #fff; }
41
- .status.connecting { background: #ffc107; color: #000; }
42
 
43
- input, button {
44
- padding: 10px 16px;
45
- border-radius: 8px;
46
- border: 1px solid #0f3460;
47
- font-size: 1em;
 
 
 
48
  }
49
- input {
50
- background: #0f3460;
51
- color: #fff;
 
 
 
 
 
 
 
 
 
52
  width: 100%;
53
- margin-bottom: 10px;
 
 
54
  }
55
- button {
56
- background: #00d4ff;
57
- color: #000;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  cursor: pointer;
59
- font-weight: 600;
60
- transition: background 0.2s;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  }
62
- button:hover { background: #00a8cc; }
63
- button:disabled { background: #555; cursor: not-allowed; }
64
 
65
- .btn-hf {
66
- background: #ff9d00;
67
- color: #000;
68
- width: 100%;
69
- padding: 12px 20px;
70
- font-size: 1.1em;
71
  }
72
- .btn-hf:hover { background: #ffb340; }
73
 
74
- .log {
75
- background: #0a0a1a;
76
- border-radius: 8px;
77
- padding: 12px;
78
- font-family: monospace;
79
- font-size: 0.85em;
80
- max-height: 200px;
81
- overflow-y: auto;
82
- white-space: pre-wrap;
83
- word-break: break-all;
 
 
 
 
 
 
84
  }
85
- .log-entry { margin-bottom: 4px; }
86
- .log-entry.error { color: #ff5252; }
87
- .log-entry.success { color: #00c853; }
88
- .log-entry.info { color: #00d4ff; }
89
 
90
- video {
91
- width: 100%;
92
- background: #000;
 
 
 
 
 
 
93
  border-radius: 8px;
94
- aspect-ratio: 16/9;
 
 
 
95
  }
96
-
97
- .controls { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 15px; }
98
- .controls button { flex: 1; min-width: 100px; }
99
- .control-btn { min-width: 80px; }
100
-
101
- input[type="range"] {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  -webkit-appearance: none;
103
- height: 8px;
104
  background: #0f3460;
105
- border-radius: 4px;
106
- margin-top: 8px;
107
  }
108
- input[type="range"]::-webkit-slider-thumb {
109
  -webkit-appearance: none;
110
- width: 18px;
111
- height: 18px;
112
  background: #00d4ff;
113
  border-radius: 50%;
114
  cursor: pointer;
115
  }
 
 
 
 
 
 
116
 
117
- input[type="checkbox"] {
118
- width: 18px;
119
- height: 18px;
120
- vertical-align: middle;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  }
 
 
 
122
 
123
- .producer-list { margin: 10px 0; }
124
- .producer-item {
 
 
 
 
 
 
125
  background: #0f3460;
126
- padding: 10px;
127
  border-radius: 8px;
128
- margin-bottom: 8px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  cursor: pointer;
130
- transition: background 0.2s;
 
 
 
 
 
 
 
 
 
 
 
 
131
  }
132
- .producer-item:hover { background: #1a4a80; }
133
- .producer-item.selected { border: 2px solid #00d4ff; }
 
 
134
 
135
- .user-info {
 
136
  display: flex;
137
  align-items: center;
138
- gap: 10px;
139
- padding: 10px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  background: #0f3460;
141
  border-radius: 8px;
142
- margin-bottom: 15px;
 
 
143
  }
144
- .user-info .username { flex: 1; font-weight: 500; }
145
- .user-info button { padding: 6px 12px; font-size: 0.9em; }
 
 
146
 
147
- #login-view, #main-view { display: none; }
148
  </style>
149
  </head>
150
  <body>
151
- <div class="container">
152
- <h1>Reachy Mini WebRTC Dashboard</h1>
153
- <p class="subtitle">Connect to your robot via central signaling server</p>
154
-
155
- <!-- Login View -->
156
- <div id="login-view">
157
- <div class="card" style="max-width: 400px; margin: 50px auto; text-align: center;">
158
- <h2>Sign In Required</h2>
159
- <p style="color: #888; margin-bottom: 20px;">Sign in with your HuggingFace account to connect to your robot.</p>
160
- <button class="btn-hf" onclick="loginToHuggingFace()">
161
- Sign in with Hugging Face
162
- </button>
163
- </div>
164
  </div>
 
165
 
166
- <!-- Main View (after login) -->
167
- <div id="main-view">
168
- <div class="grid">
169
- <!-- Connection Panel -->
170
- <div class="card">
171
- <h2>1. Connection</h2>
172
-
173
- <div class="user-info">
174
- <span>Signed in as</span>
175
- <span class="username" id="username">@user</span>
176
- <button onclick="logout()">Sign out</button>
177
- </div>
178
 
179
- <div class="controls">
180
- <button id="connectBtn" onclick="connectSignaling()">Connect to Server</button>
181
- <button id="disconnectBtn" onclick="disconnectSignaling()" disabled>Disconnect</button>
182
- </div>
 
183
 
184
- <div style="margin-top: 15px;">
185
- <span>Status: </span>
186
- <span id="signalingStatus" class="status disconnected">Disconnected</span>
 
 
 
187
  </div>
188
 
189
- <h3 style="margin-top: 20px; font-size: 1em;">Available Robots:</h3>
190
- <div id="producerList" class="producer-list">
191
- <em style="color: #666;">Connect to signaling server first</em>
 
192
  </div>
193
  </div>
194
 
195
- <!-- Video Panel -->
196
- <div class="card">
197
- <h2>2. Video Stream</h2>
198
- <video id="remoteVideo" autoplay playsinline muted></video>
199
- <div class="controls">
200
- <button id="startStreamBtn" onclick="startStream()" disabled>Start Stream</button>
201
- <button id="stopStreamBtn" onclick="stopStream()" disabled>Stop Stream</button>
202
  </div>
203
- <div style="margin-top: 15px;">
204
- <span>WebRTC: </span>
205
- <span id="webrtcStatus" class="status disconnected">Not Connected</span>
206
  </div>
207
- </div>
208
-
209
- <!-- Control Panel -->
210
- <div class="card">
211
- <h2>3. Motor Control</h2>
212
-
213
- <p style="color: #888; font-size: 0.9em; margin-bottom: 10px;">Motor control (must enable before moving)</p>
214
- <div class="controls" style="margin-bottom: 15px;">
215
- <button id="enableMotorsBtn" onclick="setMotorMode('enabled')" disabled style="background: #00c853;">Enable</button>
216
- <button id="disableMotorsBtn" onclick="setMotorMode('disabled')" disabled style="background: #ff5252;">Disable</button>
217
- <button id="gravityBtn" onclick="setMotorMode('gravity_compensation')" disabled style="background: #ffc107; color: #000;">Gravity Comp</button>
218
  </div>
219
- <div style="margin-bottom: 15px;">
220
- <span>Motors: </span>
221
- <span id="motorStatus" class="status disconnected">Unknown</span>
222
- <button onclick="getState()" style="margin-left: 10px; padding: 4px 8px; font-size: 0.8em;">Refresh State</button>
 
 
 
 
 
 
 
223
  </div>
224
  </div>
225
 
226
- <!-- Head & Body Control -->
227
- <div class="card">
228
- <h2>4. Head & Body Control</h2>
229
-
230
- <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; margin-bottom: 15px;">
231
- <div>
232
- <label>Yaw (deg):</label>
233
- <input type="number" id="yawInput" value="0" min="-45" max="45">
234
- </div>
235
- <div>
236
- <label>Pitch (deg):</label>
237
- <input type="number" id="pitchInput" value="0" min="-30" max="30">
238
- </div>
239
- <div>
240
- <label>Body Yaw (deg):</label>
241
- <input type="number" id="bodyYawInput" value="0" min="-45" max="45">
242
  </div>
243
  </div>
 
244
 
245
- <div style="margin-bottom: 15px;">
246
- <label>Duration (s): </label>
247
- <input type="number" id="durationInput" value="0.5" min="0.1" max="5" step="0.1" style="width: 80px;">
248
- <label style="margin-left: 10px;">
249
- <input type="checkbox" id="smoothMoveCheck"> Smooth move (goto)
250
- </label>
251
  </div>
252
-
253
- <div class="controls">
254
- <button class="control-btn" onclick="sendHeadPose()" disabled>Send Pose</button>
255
- <button class="control-btn" onclick="centerHead()" disabled>Center</button>
256
- <button class="control-btn" onclick="setBodyYaw()" disabled>Set Body Yaw</button>
257
  </div>
258
  </div>
 
259
 
260
- <!-- Antennas Control -->
261
- <div class="card">
262
- <h2>5. Antennas</h2>
 
 
 
 
 
 
 
 
 
 
263
 
264
- <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
265
- <div>
266
- <label>Right Antenna: <span id="rightAntennaVal">0</span>°</label>
267
- <input type="range" id="rightAntennaInput" value="0" min="-175" max="175" style="width: 100%;" oninput="document.getElementById('rightAntennaVal').textContent = this.value">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  </div>
269
- <div>
270
- <label>Left Antenna: <span id="leftAntennaVal">0</span>°</label>
271
- <input type="range" id="leftAntennaInput" value="0" min="-175" max="175" style="width: 100%;" oninput="document.getElementById('leftAntennaVal').textContent = this.value">
 
 
 
 
272
  </div>
273
  </div>
 
274
 
275
- <div class="controls">
276
- <button class="control-btn" onclick="sendAntennas()" disabled>Set Antennas</button>
277
- <button class="control-btn" onclick="resetAntennas()" disabled>Reset (0°)</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  </div>
279
  </div>
280
 
281
- <!-- Animations & Audio -->
282
- <div class="card">
283
- <h2>6. Animations & Audio</h2>
284
-
285
- <p style="color: #888; font-size: 0.9em; margin-bottom: 10px;">Pre-built animations</p>
286
- <div class="controls" style="margin-bottom: 15px;">
287
- <button class="control-btn" onclick="wakeUp()" disabled style="background: #00c853;">Wake Up</button>
288
- <button class="control-btn" onclick="goToSleep()" disabled style="background: #9c27b0;">Go to Sleep</button>
289
  </div>
 
290
 
291
- <p style="color: #888; font-size: 0.9em; margin-bottom: 10px;">Play sound</p>
292
- <div style="display: flex; gap: 10px;">
293
- <input type="text" id="soundFileInput" placeholder="Sound file (e.g., wake_up.wav)" style="flex: 1;">
294
- <button class="control-btn" onclick="playSound()" disabled>Play</button>
 
 
 
 
 
 
 
 
295
  </div>
296
  </div>
297
 
298
  <!-- Recording -->
299
- <div class="card">
300
- <h2>7. Recording</h2>
301
-
302
- <div style="margin-bottom: 15px;">
303
- <span>Recording: </span>
304
- <span id="recordingStatus" class="status disconnected">Stopped</span>
305
- </div>
306
-
307
- <div class="controls">
308
- <button class="control-btn" onclick="startRecording()" disabled style="background: #f44336;">Start Recording</button>
309
- <button class="control-btn" onclick="stopRecording()" disabled>Stop Recording</button>
310
  </div>
311
  </div>
312
-
313
- <!-- Log Panel -->
314
- <div class="card">
315
- <h2>Debug Log</h2>
316
- <div id="logArea" class="log"></div>
317
- <button onclick="clearLog()" style="margin-top: 10px; width: 100%;">Clear Log</button>
318
- </div>
319
  </div>
320
  </div>
321
  </div>
322
 
323
- <!-- HuggingFace Hub library for OAuth -->
324
  <script type="module">
325
  import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "https://cdn.jsdelivr.net/npm/@huggingface/hub@0.15.2/+esm";
326
 
327
- // Central signaling server (HTTP/SSE instead of WebSocket)
328
  const SIGNALING_SERVER = 'https://cduss-reachy-mini-central.hf.space';
329
 
330
  // State
@@ -335,180 +599,155 @@
335
  let currentSessionId = null;
336
  let userToken = null;
337
  let currentUser = null;
 
 
 
338
 
339
- // Make functions available globally
340
  window.loginToHuggingFace = loginToHuggingFace;
341
  window.logout = logout;
342
  window.connectSignaling = connectSignaling;
343
- window.disconnectSignaling = disconnectSignaling;
344
  window.startStream = startStream;
345
  window.stopStream = stopStream;
 
346
  window.sendHeadPose = sendHeadPose;
347
  window.centerHead = centerHead;
348
- window.setBodyYaw = setBodyYaw;
349
- window.setMotorMode = setMotorMode;
350
  window.sendAntennas = sendAntennas;
351
  window.resetAntennas = resetAntennas;
352
  window.wakeUp = wakeUp;
353
  window.goToSleep = goToSleep;
354
  window.playSound = playSound;
355
- window.getState = getState;
356
  window.startRecording = startRecording;
357
  window.stopRecording = stopRecording;
 
358
  window.clearLog = clearLog;
359
 
360
- // Initialize on page load
361
  document.addEventListener('DOMContentLoaded', initAuth);
362
 
363
  async function initAuth() {
364
  try {
365
- // Check if returning from OAuth redirect
366
  const oauthResult = await oauthHandleRedirectIfPresent();
367
-
368
  if (oauthResult) {
369
- currentUser = oauthResult.userInfo.name || oauthResult.userInfo.fullname || oauthResult.userInfo.preferred_username;
370
  userToken = oauthResult.accessToken;
371
-
372
  sessionStorage.setItem('hf_token', userToken);
373
  sessionStorage.setItem('hf_username', currentUser);
374
  sessionStorage.setItem('hf_token_expires', oauthResult.accessTokenExpiresAt);
375
-
376
- log('OAuth login successful: ' + currentUser, 'success');
377
- showMainView();
378
  } else {
379
- // Check stored session
380
  const storedToken = sessionStorage.getItem('hf_token');
381
  const storedUser = sessionStorage.getItem('hf_username');
382
- const tokenExpires = sessionStorage.getItem('hf_token_expires');
383
-
384
- if (storedToken && storedUser && tokenExpires && new Date(tokenExpires) > new Date()) {
385
  userToken = storedToken;
386
  currentUser = storedUser;
387
- showMainView();
388
  } else {
389
- showLoginView();
390
  }
391
  }
392
- } catch (error) {
393
- console.error('Auth error:', error);
394
- log('Auth error: ' + error.message, 'error');
395
- showLoginView();
396
  }
397
  }
398
 
399
  async function loginToHuggingFace() {
400
- try {
401
- const loginUrl = await oauthLoginUrl();
402
- window.location.href = loginUrl;
403
- } catch (error) {
404
- console.error('Login error:', error);
405
- alert('Failed to initiate login: ' + error.message);
406
- }
407
  }
408
 
409
  function logout() {
410
- sessionStorage.removeItem('hf_token');
411
- sessionStorage.removeItem('hf_username');
412
- sessionStorage.removeItem('hf_token_expires');
413
  userToken = null;
414
  currentUser = null;
415
- disconnectSignaling();
416
- showLoginView();
417
  }
418
 
419
- function showLoginView() {
420
- document.getElementById('login-view').style.display = 'block';
421
- document.getElementById('main-view').style.display = 'none';
422
  }
423
 
424
- function showMainView() {
425
- document.getElementById('login-view').style.display = 'none';
426
- document.getElementById('main-view').style.display = 'block';
427
  document.getElementById('username').textContent = '@' + currentUser;
428
- log('Ready. Click "Connect to Server" to find your robot.', 'info');
429
  }
430
 
431
  // Logging
432
- function log(message, type = 'info') {
433
- const logArea = document.getElementById('logArea');
434
- if (!logArea) return;
435
  const entry = document.createElement('div');
436
- entry.className = `log-entry ${type}`;
437
- entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
438
- logArea.appendChild(entry);
439
- logArea.scrollTop = logArea.scrollHeight;
440
- console.log(`[${type}] ${message}`);
441
  }
442
 
443
  function clearLog() {
444
  document.getElementById('logArea').innerHTML = '';
445
  }
446
 
447
- // Send message to server via HTTP POST
448
- async function sendToServer(message) {
449
- if (!userToken) {
450
- log('Not authenticated', 'error');
451
- return null;
452
- }
 
453
 
 
 
454
  try {
455
- const response = await fetch(`${SIGNALING_SERVER}/send?token=${encodeURIComponent(userToken)}`, {
456
  method: 'POST',
457
- headers: {
458
- 'Content-Type': 'application/json',
459
- 'Authorization': `Bearer ${userToken}`,
460
- },
461
  body: JSON.stringify(message)
462
  });
463
-
464
- if (!response.ok) {
465
- log(`Server error: ${response.status}`, 'error');
466
- return null;
467
- }
468
-
469
- return await response.json();
470
  } catch (e) {
471
- log(`Failed to send: ${e.message}`, 'error');
472
  return null;
473
  }
474
  }
475
 
476
- // Signaling via SSE (using fetch for header support)
477
- let sseAbortController = null;
 
 
 
 
 
 
 
478
 
 
479
  async function connectSignaling() {
480
- if (!userToken) {
481
- log('Not authenticated', 'error');
482
- return;
483
- }
484
 
485
- const url = `${SIGNALING_SERVER}/events?token=${encodeURIComponent(userToken)}`;
486
- log('Connecting to signaling server (SSE)...');
487
- updateSignalingStatus('connecting');
488
 
489
  sseAbortController = new AbortController();
490
 
491
  try {
492
- const response = await fetch(url, {
493
- method: 'GET',
494
- headers: {
495
- 'Authorization': `Bearer ${userToken}`,
496
- 'Accept': 'text/event-stream',
497
- },
498
- signal: sseAbortController.signal,
499
  });
500
 
501
- if (!response.ok) {
502
- throw new Error(`HTTP ${response.status}: ${response.statusText}`);
503
- }
504
 
505
- log('Connected to signaling server!', 'success');
506
- updateSignalingStatus('connected');
507
- document.getElementById('connectBtn').disabled = true;
508
- document.getElementById('disconnectBtn').disabled = false;
509
 
510
- // Read the SSE stream
511
- const reader = response.body.getReader();
512
  const decoder = new TextDecoder();
513
  let buffer = '';
514
 
@@ -518,451 +757,337 @@
518
 
519
  buffer += decoder.decode(value, { stream: true });
520
  const lines = buffer.split('\n');
521
- buffer = lines.pop(); // Keep incomplete line in buffer
522
 
523
  for (const line of lines) {
524
  if (line.startsWith('data:')) {
525
  const data = line.slice(5).trim();
526
  if (data) {
527
  try {
528
- const msg = JSON.parse(data);
529
- log(`Received: ${msg.type}`);
530
- handleSignalingMessage(msg);
531
- } catch (e) {
532
- console.error('Failed to parse:', data, e);
533
- }
534
  }
535
  }
536
  }
537
  }
538
-
539
  } catch (e) {
540
- if (e.name === 'AbortError') {
541
- log('Disconnected', 'info');
542
- } else {
543
- log(`Connection error: ${e.message}`, 'error');
544
  }
545
- updateSignalingStatus('disconnected');
546
  document.getElementById('connectBtn').disabled = false;
547
- document.getElementById('disconnectBtn').disabled = true;
548
- document.getElementById('startStreamBtn').disabled = true;
549
- selectedProducerId = null;
550
- myPeerId = null;
551
  }
552
  }
553
 
554
- function disconnectSignaling() {
555
- if (sseAbortController) {
556
- sseAbortController.abort();
557
- sseAbortController = null;
558
- }
559
- updateSignalingStatus('disconnected');
560
- document.getElementById('connectBtn').disabled = false;
561
- document.getElementById('disconnectBtn').disabled = true;
562
  stopStream();
563
- }
564
-
565
- function updateSignalingStatus(status) {
566
- const el = document.getElementById('signalingStatus');
567
- if (!el) return;
568
- el.className = `status ${status}`;
569
- el.textContent = status.charAt(0).toUpperCase() + status.slice(1);
570
  }
571
 
572
  async function handleSignalingMessage(msg) {
573
  switch (msg.type) {
574
  case 'welcome':
575
  myPeerId = msg.peerId;
576
- log(`Connected as: ${myPeerId.substring(0, 8)}...`, 'success');
577
- // Register as listener
578
- await sendToServer({
579
- type: 'setPeerStatus',
580
- roles: ['listener'],
581
- meta: { name: 'WebRTC Dashboard' }
582
- });
583
  break;
584
-
585
  case 'list':
586
- displayProducers(msg.producers);
587
  break;
588
-
589
  case 'peerStatusChanged':
590
- log(`Robot ${msg.peerId.substring(0, 8)}... ${msg.roles?.length ? 'connected' : 'disconnected'}`);
591
- // Refresh producer list
592
- const listResponse = await sendToServer({ type: 'list' });
593
- if (listResponse && listResponse.producers) {
594
- displayProducers(listResponse.producers);
595
- }
596
  break;
597
-
598
  case 'sessionStarted':
599
  currentSessionId = msg.sessionId;
600
- log(`Session started: ${msg.sessionId.substring(0, 8)}...`, 'success');
601
  break;
602
-
603
  case 'peer':
604
  handlePeerMessage(msg);
605
  break;
606
-
607
- case 'endSession':
608
- log('Session ended');
609
- stopStream();
610
- break;
611
-
612
  case 'error':
613
  log(`Error: ${msg.details}`, 'error');
614
  break;
615
  }
616
  }
617
 
618
- function displayProducers(producers) {
619
- const container = document.getElementById('producerList');
620
- container.innerHTML = '';
621
 
622
- if (!producers || !Array.isArray(producers) || producers.length === 0) {
623
- container.innerHTML = '<em style="color: #666;">No robots connected. Make sure your robot is online and connected to central server.</em>';
 
624
  return;
625
  }
626
 
627
- for (const producer of producers) {
628
  const div = document.createElement('div');
629
- div.className = 'producer-item';
630
- const name = producer.meta?.name || 'Reachy Mini';
631
- div.innerHTML = `<strong>${name}</strong><br><small style="color: #888;">${producer.id.substring(0, 8)}...</small>`;
632
- div.onclick = () => selectProducer(producer.id, div);
633
- container.appendChild(div);
 
 
634
  }
635
-
636
- log(`Found ${producers.length} robot(s)`, 'success');
637
  }
638
 
639
- function selectProducer(peerId, element) {
640
- document.querySelectorAll('.producer-item').forEach(el => el.classList.remove('selected'));
641
- element.classList.add('selected');
642
- selectedProducerId = peerId;
643
- document.getElementById('startStreamBtn').disabled = false;
644
- log(`Selected: ${peerId.substring(0, 8)}...`);
 
645
  }
646
 
647
  // WebRTC
648
  async function startStream() {
649
- if (!selectedProducerId) {
650
- log('No robot selected', 'error');
651
- return;
652
- }
653
 
654
- log('Creating peer connection...');
655
- updateWebrtcStatus('connecting');
656
 
657
  peerConnection = new RTCPeerConnection({
658
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
659
  });
660
 
661
- peerConnection.ontrack = (event) => {
662
- log(`Received ${event.track.kind} track`, 'success');
663
- if (event.track.kind === 'video') {
664
- document.getElementById('remoteVideo').srcObject = event.streams[0];
665
  }
666
  };
667
 
668
- peerConnection.onicecandidate = async (event) => {
669
- if (event.candidate && currentSessionId) {
670
  await sendToServer({
671
  type: 'peer',
672
  sessionId: currentSessionId,
673
- ice: {
674
- candidate: event.candidate.candidate,
675
- sdpMLineIndex: event.candidate.sdpMLineIndex,
676
- sdpMid: event.candidate.sdpMid
677
- }
678
  });
679
  }
680
  };
681
 
682
  peerConnection.oniceconnectionstatechange = () => {
683
- log(`ICE: ${peerConnection.iceConnectionState}`);
684
- if (peerConnection.iceConnectionState === 'connected' ||
685
- peerConnection.iceConnectionState === 'completed') {
686
- updateWebrtcStatus('connected');
687
- enableAllControls(true);
688
- } else if (peerConnection.iceConnectionState === 'failed') {
689
- updateWebrtcStatus('disconnected');
690
- log('Connection failed', 'error');
 
 
691
  }
692
  };
693
 
694
- peerConnection.ondatachannel = (event) => {
695
- log(`Data channel: ${event.channel.label}`, 'success');
696
- dataChannel = event.channel;
697
  dataChannel.onopen = () => {
698
  log('Data channel open', 'success');
699
- // Query full state when channel opens
700
- dataChannel.send(JSON.stringify({ get_state: true }));
701
- };
702
- dataChannel.onclose = () => log('Data channel closed');
703
- dataChannel.onmessage = (e) => {
704
- try {
705
- const data = JSON.parse(e.data);
706
- handleRobotResponse(data);
707
- } catch {
708
- log(`Received: ${e.data}`);
709
- }
710
  };
 
711
  };
712
 
713
- log('Requesting session with robot...');
714
- const response = await sendToServer({
715
- type: 'startSession',
716
- peerId: selectedProducerId
717
- });
718
 
719
- if (response && response.sessionId) {
720
- currentSessionId = response.sessionId;
721
- log(`Session started: ${response.sessionId.substring(0, 8)}...`, 'success');
722
- }
723
  }
724
 
725
  async function handlePeerMessage(msg) {
726
  if (!peerConnection) return;
727
-
728
  try {
729
  if (msg.sdp) {
730
- log(`Received SDP ${msg.sdp.type}`);
731
  await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp));
732
-
733
  if (msg.sdp.type === 'offer') {
734
  const answer = await peerConnection.createAnswer();
735
  await peerConnection.setLocalDescription(answer);
736
- await sendToServer({
737
- type: 'peer',
738
- sessionId: currentSessionId,
739
- sdp: { type: 'answer', sdp: answer.sdp }
740
- });
741
- log('Sent SDP answer');
742
  }
743
  }
744
-
745
  if (msg.ice) {
746
  await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice));
747
  }
748
  } catch (e) {
749
- log(`Error: ${e.message}`, 'error');
750
  }
751
  }
752
 
753
  function stopStream() {
754
- if (peerConnection) {
755
- peerConnection.close();
756
- peerConnection = null;
757
- }
758
- if (dataChannel) {
759
- dataChannel.close();
760
- dataChannel = null;
761
- }
762
  currentSessionId = null;
763
  document.getElementById('remoteVideo').srcObject = null;
764
- updateWebrtcStatus('disconnected');
765
- enableAllControls(false);
766
- updateMotorStatus('unknown');
767
- updateRecordingStatus(false);
768
- }
769
-
770
- function enableAllControls(enabled) {
771
- // All control buttons
772
- const buttons = document.querySelectorAll('.control-btn');
773
- buttons.forEach(btn => btn.disabled = !enabled);
774
-
775
- // Specific buttons
776
- document.getElementById('stopStreamBtn').disabled = !enabled;
777
- document.getElementById('enableMotorsBtn').disabled = !enabled;
778
- document.getElementById('disableMotorsBtn').disabled = !enabled;
779
- document.getElementById('gravityBtn').disabled = !enabled;
780
  }
781
 
782
- function updateWebrtcStatus(status) {
783
- const el = document.getElementById('webrtcStatus');
784
- if (!el) return;
785
- el.className = `status ${status}`;
786
- const labels = { connected: 'Connected', disconnected: 'Not Connected', connecting: 'Connecting...' };
787
- el.textContent = labels[status] || status;
788
  }
789
 
790
- // === RESPONSE HANDLER ===
791
- function handleRobotResponse(data) {
792
  if (data.state) {
793
- // Full state response
794
- if (data.state.motor_mode) updateMotorStatus(data.state.motor_mode);
795
- if (data.state.is_recording !== undefined) updateRecordingStatus(data.state.is_recording);
796
- log(`State: motors=${data.state.motor_mode}, recording=${data.state.is_recording}`, 'info');
797
  } else if (data.motor_mode) {
798
- updateMotorStatus(data.motor_mode);
799
- log(`Motor mode: ${data.motor_mode}`, 'info');
800
  } else if (data.error) {
801
- log(`Error: ${data.error}`, 'error');
802
- } else if (data.status === 'ok') {
803
- const cmd = data.command || 'command';
804
- if (data.completed) {
805
- log(`${cmd} completed`, 'success');
806
- } else {
807
- log(`${cmd} sent`, 'info');
808
- }
809
- if (data.is_recording !== undefined) updateRecordingStatus(data.is_recording);
810
- } else {
811
- log(`Response: ${JSON.stringify(data)}`);
812
  }
813
  }
814
 
815
- // === HELPER: Send command via data channel ===
816
- function sendCommand(cmd) {
817
- if (!dataChannel || dataChannel.readyState !== 'open') {
818
- log('Data channel not ready', 'error');
819
- return false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
820
  }
821
- dataChannel.send(JSON.stringify(cmd));
822
- return true;
823
  }
824
 
825
- // === HEAD CONTROL ===
826
- function buildPoseMatrix(yawDeg, pitchDeg) {
827
- const yawRad = yawDeg * Math.PI / 180;
828
- const pitchRad = pitchDeg * Math.PI / 180;
829
- const cy = Math.cos(yawRad), sy = Math.sin(yawRad);
830
- const cp = Math.cos(pitchRad), sp = Math.sin(pitchRad);
831
- return [
832
- [cy * cp, -sy, cy * sp, 0],
833
- [sy * cp, cy, sy * sp, 0],
834
- [-sp, 0, cp, 0],
835
- [0, 0, 0, 1]
836
- ];
837
  }
838
 
839
- function sendHeadPose() {
840
- const yaw = parseFloat(document.getElementById('yawInput').value) || 0;
841
- const pitch = parseFloat(document.getElementById('pitchInput').value) || 0;
842
- const smooth = document.getElementById('smoothMoveCheck').checked;
843
- const duration = parseFloat(document.getElementById('durationInput').value) || 0.5;
844
-
845
- const matrix = buildPoseMatrix(yaw, pitch);
846
 
847
- if (smooth) {
848
- // Smooth goto movement
849
- sendCommand({
850
- goto_target: {
851
- head: matrix,
852
- duration: duration
853
- }
854
- });
855
- log(`Goto pose: yaw=${yaw}, pitch=${pitch}, duration=${duration}s`);
856
- } else {
857
- // Instant set_target
858
- sendCommand({ set_target: matrix });
859
- log(`Set pose: yaw=${yaw}, pitch=${pitch}`);
860
- }
861
  }
862
 
863
- function centerHead() {
864
- document.getElementById('yawInput').value = 0;
865
- document.getElementById('pitchInput').value = 0;
866
- document.getElementById('bodyYawInput').value = 0;
867
- sendHeadPose();
868
  }
869
 
870
- // === BODY YAW ===
871
- function setBodyYaw() {
872
- const yaw = parseFloat(document.getElementById('bodyYawInput').value) || 0;
873
- const yawRad = yaw * Math.PI / 180;
874
- sendCommand({ set_body_yaw: yawRad });
875
- log(`Set body yaw: ${yaw}°`);
876
  }
877
 
878
- // === MOTOR CONTROL ===
879
- function setMotorMode(mode) {
880
- sendCommand({ set_motor_mode: mode });
881
- log(`Setting motor mode: ${mode}`);
882
- }
883
-
884
- function updateMotorStatus(mode) {
885
- const el = document.getElementById('motorStatus');
886
- if (!el) return;
887
- const label = mode.replace('_', ' ');
888
- el.textContent = label.charAt(0).toUpperCase() + label.slice(1);
889
- if (mode === 'enabled') {
890
- el.className = 'status connected';
891
- } else if (mode === 'disabled') {
892
- el.className = 'status disconnected';
893
  } else {
894
- el.className = 'status connecting';
 
 
895
  }
896
  }
897
 
898
- // === ANTENNAS ===
 
 
 
 
 
 
899
  function sendAntennas() {
900
- const right = parseFloat(document.getElementById('rightAntennaInput').value) || 0;
901
- const left = parseFloat(document.getElementById('leftAntennaInput').value) || 0;
902
- const rightRad = right * Math.PI / 180;
903
- const leftRad = left * Math.PI / 180;
904
- sendCommand({ set_antennas: [rightRad, leftRad] });
905
- log(`Set antennas: right=${right}°, left=${left}°`);
906
  }
907
 
908
  function resetAntennas() {
909
- document.getElementById('rightAntennaInput').value = 0;
910
- document.getElementById('leftAntennaInput').value = 0;
911
- document.getElementById('rightAntennaVal').textContent = '0';
912
- document.getElementById('leftAntennaVal').textContent = '0';
913
  sendCommand({ set_antennas: [0, 0] });
914
- log('Reset antennas to 0°');
915
  }
916
 
917
- // === ANIMATIONS ===
918
  function wakeUp() {
919
  sendCommand({ wake_up: true });
920
- log('Starting wake up animation...', 'info');
921
  }
922
 
923
  function goToSleep() {
924
  sendCommand({ goto_sleep: true });
925
- log('Starting sleep animation...', 'info');
926
  }
927
 
928
- // === AUDIO ===
929
  function playSound() {
930
- const file = document.getElementById('soundFileInput').value.trim();
931
- if (!file) {
932
- log('Please enter a sound file name', 'error');
933
- return;
934
  }
935
- sendCommand({ play_sound: file });
936
- log(`Playing sound: ${file}`);
937
  }
938
 
939
- // === STATE ===
940
- function getState() {
941
- sendCommand({ get_state: true });
942
- log('Requesting state...');
943
  }
944
 
945
- // === RECORDING ===
946
  function startRecording() {
947
  sendCommand({ start_recording: true });
948
- log('Starting recording...', 'info');
949
  }
950
 
951
  function stopRecording() {
952
  sendCommand({ stop_recording: true });
953
- log('Stopping recording...', 'info');
954
- }
955
-
956
- function updateRecordingStatus(isRecording) {
957
- const el = document.getElementById('recordingStatus');
958
- if (!el) return;
959
- if (isRecording) {
960
- el.textContent = 'Recording';
961
- el.className = 'status connected';
962
- } else {
963
- el.textContent = 'Stopped';
964
- el.className = 'status disconnected';
965
- }
966
  }
967
  </script>
968
  </body>
 
3
  <head>
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width" />
6
+ <title>Reachy Mini Telepresence</title>
7
  <style>
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
  body {
10
  font-family: system-ui, -apple-system, sans-serif;
11
+ background: linear-gradient(135deg, #0f0f1a 0%, #1a1a2e 100%);
 
 
12
  color: #eee;
13
  min-height: 100vh;
14
  }
 
 
 
 
 
 
15
 
16
+ /* Header */
17
+ .header {
18
+ background: rgba(0,0,0,0.3);
19
+ padding: 12px 24px;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: space-between;
23
+ border-bottom: 1px solid #333;
24
+ }
25
+ .header h1 {
26
+ font-size: 1.4em;
27
+ color: #00d4ff;
28
+ display: flex;
29
+ align-items: center;
30
+ gap: 10px;
31
+ }
32
+ .header h1::before { content: "🤖"; }
33
+ .user-badge {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 12px;
37
  background: #16213e;
38
+ padding: 8px 16px;
39
+ border-radius: 24px;
 
40
  }
41
+ .user-badge .name { font-weight: 500; }
42
+ .user-badge button {
43
+ background: transparent;
44
+ border: 1px solid #555;
45
+ color: #aaa;
46
  padding: 4px 12px;
47
+ border-radius: 12px;
48
+ cursor: pointer;
49
  font-size: 0.85em;
 
50
  }
51
+ .user-badge button:hover { border-color: #888; color: #fff; }
 
 
52
 
53
+ /* Main Layout */
54
+ .main-container {
55
+ display: grid;
56
+ grid-template-columns: 1fr 380px;
57
+ gap: 20px;
58
+ padding: 20px;
59
+ max-width: 1600px;
60
+ margin: 0 auto;
61
  }
62
+ @media (max-width: 1100px) {
63
+ .main-container { grid-template-columns: 1fr; }
64
+ }
65
+
66
+ /* Video Section */
67
+ .video-section {
68
+ background: #000;
69
+ border-radius: 16px;
70
+ overflow: hidden;
71
+ position: relative;
72
+ }
73
+ video {
74
  width: 100%;
75
+ aspect-ratio: 16/9;
76
+ display: block;
77
+ background: #111;
78
  }
79
+ .video-overlay {
80
+ position: absolute;
81
+ top: 0;
82
+ left: 0;
83
+ right: 0;
84
+ padding: 16px;
85
+ background: linear-gradient(to bottom, rgba(0,0,0,0.7), transparent);
86
+ display: flex;
87
+ justify-content: space-between;
88
+ align-items: flex-start;
89
+ }
90
+ .connection-status {
91
+ display: flex;
92
+ align-items: center;
93
+ gap: 8px;
94
+ font-size: 0.9em;
95
+ }
96
+ .status-dot {
97
+ width: 10px;
98
+ height: 10px;
99
+ border-radius: 50%;
100
+ background: #ff5252;
101
+ }
102
+ .status-dot.connected { background: #00c853; animation: pulse 2s infinite; }
103
+ .status-dot.connecting { background: #ffc107; animation: pulse 0.5s infinite; }
104
+ @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
105
+
106
+ .video-controls {
107
+ position: absolute;
108
+ bottom: 0;
109
+ left: 0;
110
+ right: 0;
111
+ padding: 16px;
112
+ background: linear-gradient(to top, rgba(0,0,0,0.8), transparent);
113
+ display: flex;
114
+ justify-content: center;
115
+ gap: 12px;
116
+ }
117
+ .video-controls button {
118
+ background: rgba(255,255,255,0.15);
119
+ border: none;
120
+ color: #fff;
121
+ padding: 12px 24px;
122
+ border-radius: 8px;
123
  cursor: pointer;
124
+ font-size: 1em;
125
+ font-weight: 500;
126
+ transition: all 0.2s;
127
+ }
128
+ .video-controls button:hover { background: rgba(255,255,255,0.25); }
129
+ .video-controls button.primary { background: #00d4ff; color: #000; }
130
+ .video-controls button.primary:hover { background: #00a8cc; }
131
+ .video-controls button.danger { background: #ff5252; }
132
+ .video-controls button:disabled { opacity: 0.4; cursor: not-allowed; }
133
+
134
+ /* State Display */
135
+ .state-bar {
136
+ display: flex;
137
+ gap: 20px;
138
+ padding: 16px 20px;
139
+ background: #16213e;
140
+ border-radius: 0 0 16px 16px;
141
+ flex-wrap: wrap;
142
+ }
143
+ .state-item {
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: 4px;
147
+ }
148
+ .state-item label {
149
+ font-size: 0.75em;
150
+ color: #888;
151
+ text-transform: uppercase;
152
+ letter-spacing: 0.5px;
153
+ }
154
+ .state-item .value {
155
+ font-family: 'SF Mono', monospace;
156
+ font-size: 0.95em;
157
+ color: #00d4ff;
158
  }
 
 
159
 
160
+ /* Control Panel */
161
+ .control-panel {
162
+ display: flex;
163
+ flex-direction: column;
164
+ gap: 16px;
 
165
  }
 
166
 
167
+ .panel {
168
+ background: #16213e;
169
+ border-radius: 12px;
170
+ overflow: hidden;
171
+ }
172
+ .panel-header {
173
+ padding: 12px 16px;
174
+ background: rgba(0,0,0,0.2);
175
+ font-weight: 600;
176
+ font-size: 0.9em;
177
+ display: flex;
178
+ align-items: center;
179
+ gap: 8px;
180
+ }
181
+ .panel-content {
182
+ padding: 16px;
183
  }
 
 
 
 
184
 
185
+ /* Motor Control */
186
+ .motor-buttons {
187
+ display: grid;
188
+ grid-template-columns: repeat(3, 1fr);
189
+ gap: 8px;
190
+ }
191
+ .motor-btn {
192
+ padding: 10px;
193
+ border: 2px solid transparent;
194
  border-radius: 8px;
195
+ font-weight: 600;
196
+ cursor: pointer;
197
+ transition: all 0.2s;
198
+ font-size: 0.85em;
199
  }
200
+ .motor-btn.enable { background: #1b5e20; color: #fff; }
201
+ .motor-btn.enable:hover { background: #2e7d32; }
202
+ .motor-btn.enable.active { border-color: #00c853; box-shadow: 0 0 12px rgba(0,200,83,0.4); }
203
+ .motor-btn.disable { background: #b71c1c; color: #fff; }
204
+ .motor-btn.disable:hover { background: #c62828; }
205
+ .motor-btn.disable.active { border-color: #ff5252; box-shadow: 0 0 12px rgba(255,82,82,0.4); }
206
+ .motor-btn.gravity { background: #f57c00; color: #fff; }
207
+ .motor-btn.gravity:hover { background: #ff9800; }
208
+ .motor-btn.gravity.active { border-color: #ffc107; box-shadow: 0 0 12px rgba(255,193,7,0.4); }
209
+ .motor-btn:disabled { opacity: 0.4; cursor: not-allowed; }
210
+
211
+ /* Joystick / Head Control */
212
+ .head-control {
213
+ display: grid;
214
+ grid-template-columns: 1fr 1fr;
215
+ gap: 16px;
216
+ }
217
+ .control-group label {
218
+ display: block;
219
+ font-size: 0.8em;
220
+ color: #888;
221
+ margin-bottom: 6px;
222
+ }
223
+ .control-group input[type="range"] {
224
+ width: 100%;
225
  -webkit-appearance: none;
226
+ height: 6px;
227
  background: #0f3460;
228
+ border-radius: 3px;
229
+ margin-bottom: 4px;
230
  }
231
+ .control-group input[type="range"]::-webkit-slider-thumb {
232
  -webkit-appearance: none;
233
+ width: 16px;
234
+ height: 16px;
235
  background: #00d4ff;
236
  border-radius: 50%;
237
  cursor: pointer;
238
  }
239
+ .control-group .value-display {
240
+ font-family: 'SF Mono', monospace;
241
+ font-size: 0.85em;
242
+ color: #00d4ff;
243
+ text-align: center;
244
+ }
245
 
246
+ /* Quick Actions */
247
+ .quick-actions {
248
+ display: grid;
249
+ grid-template-columns: repeat(2, 1fr);
250
+ gap: 8px;
251
+ }
252
+ .action-btn {
253
+ padding: 12px;
254
+ border: 1px solid #0f3460;
255
+ background: #0f3460;
256
+ color: #fff;
257
+ border-radius: 8px;
258
+ cursor: pointer;
259
+ font-size: 0.9em;
260
+ transition: all 0.2s;
261
+ display: flex;
262
+ align-items: center;
263
+ justify-content: center;
264
+ gap: 8px;
265
  }
266
+ .action-btn:hover { background: #1a4a80; border-color: #00d4ff; }
267
+ .action-btn:disabled { opacity: 0.4; cursor: not-allowed; }
268
+ .action-btn.recording { background: #c62828; animation: pulse 1s infinite; }
269
 
270
+ /* Sound Section */
271
+ .sound-input {
272
+ display: flex;
273
+ gap: 8px;
274
+ }
275
+ .sound-input input {
276
+ flex: 1;
277
+ padding: 10px 12px;
278
  background: #0f3460;
279
+ border: 1px solid #0f3460;
280
  border-radius: 8px;
281
+ color: #fff;
282
+ font-size: 0.9em;
283
+ }
284
+ .sound-input input:focus {
285
+ outline: none;
286
+ border-color: #00d4ff;
287
+ }
288
+ .sound-presets {
289
+ display: flex;
290
+ flex-wrap: wrap;
291
+ gap: 6px;
292
+ margin-top: 10px;
293
+ }
294
+ .sound-preset {
295
+ padding: 6px 12px;
296
+ background: #0a0a1a;
297
+ border: 1px solid #333;
298
+ border-radius: 16px;
299
+ color: #aaa;
300
+ font-size: 0.8em;
301
  cursor: pointer;
302
+ transition: all 0.2s;
303
+ }
304
+ .sound-preset:hover { border-color: #00d4ff; color: #fff; }
305
+
306
+ /* Log */
307
+ .log-panel {
308
+ max-height: 150px;
309
+ overflow-y: auto;
310
+ background: #0a0a1a;
311
+ border-radius: 8px;
312
+ padding: 12px;
313
+ font-family: 'SF Mono', monospace;
314
+ font-size: 0.8em;
315
  }
316
+ .log-entry { margin-bottom: 4px; color: #888; }
317
+ .log-entry.error { color: #ff5252; }
318
+ .log-entry.success { color: #00c853; }
319
+ .log-entry.info { color: #00d4ff; }
320
 
321
+ /* Login View */
322
+ .login-view {
323
  display: flex;
324
  align-items: center;
325
+ justify-content: center;
326
+ min-height: 100vh;
327
+ padding: 20px;
328
+ }
329
+ .login-card {
330
+ background: #16213e;
331
+ padding: 40px;
332
+ border-radius: 16px;
333
+ text-align: center;
334
+ max-width: 400px;
335
+ }
336
+ .login-card h2 {
337
+ margin-bottom: 16px;
338
+ color: #00d4ff;
339
+ }
340
+ .login-card p {
341
+ color: #888;
342
+ margin-bottom: 24px;
343
+ }
344
+ .btn-hf {
345
+ background: #ff9d00;
346
+ color: #000;
347
+ border: none;
348
+ padding: 14px 32px;
349
+ border-radius: 8px;
350
+ font-size: 1.1em;
351
+ font-weight: 600;
352
+ cursor: pointer;
353
+ transition: background 0.2s;
354
+ }
355
+ .btn-hf:hover { background: #ffb340; }
356
+
357
+ /* Robot selector */
358
+ .robot-list {
359
+ display: flex;
360
+ flex-direction: column;
361
+ gap: 8px;
362
+ }
363
+ .robot-item {
364
+ padding: 12px 16px;
365
  background: #0f3460;
366
  border-radius: 8px;
367
+ cursor: pointer;
368
+ transition: all 0.2s;
369
+ border: 2px solid transparent;
370
  }
371
+ .robot-item:hover { background: #1a4a80; }
372
+ .robot-item.selected { border-color: #00d4ff; background: #1a4a80; }
373
+ .robot-item .name { font-weight: 500; }
374
+ .robot-item .id { font-size: 0.8em; color: #888; margin-top: 4px; }
375
 
376
+ .hidden { display: none !important; }
377
  </style>
378
  </head>
379
  <body>
380
+ <!-- Login View -->
381
+ <div id="loginView" class="login-view">
382
+ <div class="login-card">
383
+ <h2>🤖 Reachy Mini Telepresence</h2>
384
+ <p>Sign in with your HuggingFace account to connect to your robot and start controlling it remotely.</p>
385
+ <button class="btn-hf" onclick="loginToHuggingFace()">Sign in with Hugging Face</button>
 
 
 
 
 
 
 
386
  </div>
387
+ </div>
388
 
389
+ <!-- Main App -->
390
+ <div id="mainApp" class="hidden">
391
+ <header class="header">
392
+ <h1>Reachy Mini Telepresence</h1>
393
+ <div class="user-badge">
394
+ <span class="name" id="username">@user</span>
395
+ <button onclick="logout()">Sign out</button>
396
+ </div>
397
+ </header>
 
 
 
398
 
399
+ <div class="main-container">
400
+ <!-- Video Section -->
401
+ <div>
402
+ <div class="video-section">
403
+ <video id="remoteVideo" autoplay playsinline muted></video>
404
 
405
+ <div class="video-overlay">
406
+ <div class="connection-status">
407
+ <div class="status-dot" id="statusDot"></div>
408
+ <span id="statusText">Disconnected</span>
409
+ </div>
410
+ <div id="robotName" style="font-weight: 500;"></div>
411
  </div>
412
 
413
+ <div class="video-controls">
414
+ <button id="connectBtn" onclick="connectSignaling()">Connect Server</button>
415
+ <button id="startBtn" class="primary" onclick="startStream()" disabled>Start Stream</button>
416
+ <button id="stopBtn" class="danger" onclick="stopStream()" disabled>Stop</button>
417
  </div>
418
  </div>
419
 
420
+ <!-- State Bar -->
421
+ <div class="state-bar" id="stateBar">
422
+ <div class="state-item">
423
+ <label>Motors</label>
424
+ <span class="value" id="stateMotors">--</span>
 
 
425
  </div>
426
+ <div class="state-item">
427
+ <label>Head Yaw</label>
428
+ <span class="value" id="stateYaw">--°</span>
429
  </div>
430
+ <div class="state-item">
431
+ <label>Head Pitch</label>
432
+ <span class="value" id="statePitch">--°</span>
433
+ </div>
434
+ <div class="state-item">
435
+ <label>Body Yaw</label>
436
+ <span class="value" id="stateBodyYaw">--°</span>
 
 
 
 
437
  </div>
438
+ <div class="state-item">
439
+ <label>Right Antenna</label>
440
+ <span class="value" id="stateRightAnt">--°</span>
441
+ </div>
442
+ <div class="state-item">
443
+ <label>Left Antenna</label>
444
+ <span class="value" id="stateLeftAnt">--°</span>
445
+ </div>
446
+ <div class="state-item">
447
+ <label>Recording</label>
448
+ <span class="value" id="stateRecording">--</span>
449
  </div>
450
  </div>
451
 
452
+ <!-- Robot Selector (shown when connected but no stream) -->
453
+ <div id="robotSelector" class="panel hidden" style="margin-top: 16px;">
454
+ <div class="panel-header">📡 Available Robots</div>
455
+ <div class="panel-content">
456
+ <div id="robotList" class="robot-list">
457
+ <em style="color: #666;">Searching for robots...</em>
 
 
 
 
 
 
 
 
 
 
458
  </div>
459
  </div>
460
+ </div>
461
 
462
+ <!-- Log -->
463
+ <div class="panel" style="margin-top: 16px;">
464
+ <div class="panel-header" style="justify-content: space-between;">
465
+ 📋 Activity Log
466
+ <button onclick="clearLog()" style="background: transparent; border: none; color: #888; cursor: pointer; font-size: 0.8em;">Clear</button>
 
467
  </div>
468
+ <div class="panel-content" style="padding: 0;">
469
+ <div id="logArea" class="log-panel"></div>
 
 
 
470
  </div>
471
  </div>
472
+ </div>
473
 
474
+ <!-- Control Panel -->
475
+ <div class="control-panel">
476
+ <!-- Motor Control -->
477
+ <div class="panel">
478
+ <div class="panel-header">⚡ Motor Control</div>
479
+ <div class="panel-content">
480
+ <div class="motor-buttons">
481
+ <button class="motor-btn enable" id="btnEnable" onclick="setMotorMode('enabled')" disabled>Enable</button>
482
+ <button class="motor-btn disable" id="btnDisable" onclick="setMotorMode('disabled')" disabled>Disable</button>
483
+ <button class="motor-btn gravity" id="btnGravity" onclick="setMotorMode('gravity_compensation')" disabled>Gravity</button>
484
+ </div>
485
+ </div>
486
+ </div>
487
 
488
+ <!-- Head Control -->
489
+ <div class="panel">
490
+ <div class="panel-header">🎯 Head Control</div>
491
+ <div class="panel-content">
492
+ <div class="head-control">
493
+ <div class="control-group">
494
+ <label>Yaw (left/right)</label>
495
+ <input type="range" id="yawSlider" min="-45" max="45" value="0" oninput="updateSliderValue('yaw')">
496
+ <div class="value-display"><span id="yawValue">0</span>°</div>
497
+ </div>
498
+ <div class="control-group">
499
+ <label>Pitch (up/down)</label>
500
+ <input type="range" id="pitchSlider" min="-30" max="30" value="0" oninput="updateSliderValue('pitch')">
501
+ <div class="value-display"><span id="pitchValue">0</span>°</div>
502
+ </div>
503
+ </div>
504
+ <div style="margin-top: 12px;">
505
+ <div class="control-group">
506
+ <label>Body Yaw</label>
507
+ <input type="range" id="bodyYawSlider" min="-45" max="45" value="0" oninput="updateSliderValue('bodyYaw')">
508
+ <div class="value-display"><span id="bodyYawValue">0</span>°</div>
509
+ </div>
510
+ </div>
511
+ <div class="quick-actions" style="margin-top: 12px;">
512
+ <button class="action-btn" id="btnSendPose" onclick="sendHeadPose()" disabled>📤 Send Pose</button>
513
+ <button class="action-btn" id="btnCenter" onclick="centerHead()" disabled>🎯 Center</button>
514
  </div>
515
+ <div style="margin-top: 10px; display: flex; align-items: center; gap: 12px;">
516
+ <label style="font-size: 0.85em; color: #888;">
517
+ <input type="checkbox" id="smoothCheck" style="margin-right: 6px;"> Smooth movement
518
+ </label>
519
+ <label style="font-size: 0.85em; color: #888;">
520
+ Duration: <input type="number" id="durationInput" value="0.5" min="0.1" max="3" step="0.1" style="width: 60px; padding: 4px; background: #0f3460; border: 1px solid #333; border-radius: 4px; color: #fff;">s
521
+ </label>
522
  </div>
523
  </div>
524
+ </div>
525
 
526
+ <!-- Antennas -->
527
+ <div class="panel">
528
+ <div class="panel-header">📡 Antennas</div>
529
+ <div class="panel-content">
530
+ <div class="head-control">
531
+ <div class="control-group">
532
+ <label>Right Antenna</label>
533
+ <input type="range" id="rightAntSlider" min="-175" max="175" value="0" oninput="updateSliderValue('rightAnt')">
534
+ <div class="value-display"><span id="rightAntValue">0</span>°</div>
535
+ </div>
536
+ <div class="control-group">
537
+ <label>Left Antenna</label>
538
+ <input type="range" id="leftAntSlider" min="-175" max="175" value="0" oninput="updateSliderValue('leftAnt')">
539
+ <div class="value-display"><span id="leftAntValue">0</span>°</div>
540
+ </div>
541
+ </div>
542
+ <div class="quick-actions" style="margin-top: 12px;">
543
+ <button class="action-btn" id="btnSendAnt" onclick="sendAntennas()" disabled>📤 Send</button>
544
+ <button class="action-btn" id="btnResetAnt" onclick="resetAntennas()" disabled>↩️ Reset</button>
545
+ </div>
546
  </div>
547
  </div>
548
 
549
+ <!-- Animations -->
550
+ <div class="panel">
551
+ <div class="panel-header"> Animations</div>
552
+ <div class="panel-content">
553
+ <div class="quick-actions">
554
+ <button class="action-btn" id="btnWakeUp" onclick="wakeUp()" disabled>☀️ Wake Up</button>
555
+ <button class="action-btn" id="btnSleep" onclick="goToSleep()" disabled>🌙 Sleep</button>
556
+ </div>
557
  </div>
558
+ </div>
559
 
560
+ <!-- Sound -->
561
+ <div class="panel">
562
+ <div class="panel-header">🔊 Sound</div>
563
+ <div class="panel-content">
564
+ <div class="sound-input">
565
+ <input type="text" id="soundInput" placeholder="Sound file name...">
566
+ <button class="action-btn" id="btnPlaySound" onclick="playSound()" disabled style="flex: 0;">▶️</button>
567
+ </div>
568
+ <div class="sound-presets">
569
+ <span class="sound-preset" onclick="playSoundPreset('wake_up.wav')">wake_up</span>
570
+ <span class="sound-preset" onclick="playSoundPreset('go_sleep.wav')">go_sleep</span>
571
+ </div>
572
  </div>
573
  </div>
574
 
575
  <!-- Recording -->
576
+ <div class="panel">
577
+ <div class="panel-header">⏺️ Recording</div>
578
+ <div class="panel-content">
579
+ <div class="quick-actions">
580
+ <button class="action-btn" id="btnStartRec" onclick="startRecording()" disabled>⏺️ Start</button>
581
+ <button class="action-btn" id="btnStopRec" onclick="stopRecording()" disabled>⏹️ Stop</button>
582
+ </div>
 
 
 
 
583
  </div>
584
  </div>
 
 
 
 
 
 
 
585
  </div>
586
  </div>
587
  </div>
588
 
 
589
  <script type="module">
590
  import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "https://cdn.jsdelivr.net/npm/@huggingface/hub@0.15.2/+esm";
591
 
 
592
  const SIGNALING_SERVER = 'https://cduss-reachy-mini-central.hf.space';
593
 
594
  // State
 
599
  let currentSessionId = null;
600
  let userToken = null;
601
  let currentUser = null;
602
+ let sseAbortController = null;
603
+ let currentMotorMode = null;
604
+ let stateRefreshInterval = null;
605
 
606
+ // Export functions
607
  window.loginToHuggingFace = loginToHuggingFace;
608
  window.logout = logout;
609
  window.connectSignaling = connectSignaling;
 
610
  window.startStream = startStream;
611
  window.stopStream = stopStream;
612
+ window.setMotorMode = setMotorMode;
613
  window.sendHeadPose = sendHeadPose;
614
  window.centerHead = centerHead;
 
 
615
  window.sendAntennas = sendAntennas;
616
  window.resetAntennas = resetAntennas;
617
  window.wakeUp = wakeUp;
618
  window.goToSleep = goToSleep;
619
  window.playSound = playSound;
620
+ window.playSoundPreset = playSoundPreset;
621
  window.startRecording = startRecording;
622
  window.stopRecording = stopRecording;
623
+ window.updateSliderValue = updateSliderValue;
624
  window.clearLog = clearLog;
625
 
626
+ // Init
627
  document.addEventListener('DOMContentLoaded', initAuth);
628
 
629
  async function initAuth() {
630
  try {
 
631
  const oauthResult = await oauthHandleRedirectIfPresent();
 
632
  if (oauthResult) {
633
+ currentUser = oauthResult.userInfo.name || oauthResult.userInfo.preferred_username;
634
  userToken = oauthResult.accessToken;
 
635
  sessionStorage.setItem('hf_token', userToken);
636
  sessionStorage.setItem('hf_username', currentUser);
637
  sessionStorage.setItem('hf_token_expires', oauthResult.accessTokenExpiresAt);
638
+ showMainApp();
 
 
639
  } else {
 
640
  const storedToken = sessionStorage.getItem('hf_token');
641
  const storedUser = sessionStorage.getItem('hf_username');
642
+ const expires = sessionStorage.getItem('hf_token_expires');
643
+ if (storedToken && storedUser && expires && new Date(expires) > new Date()) {
 
644
  userToken = storedToken;
645
  currentUser = storedUser;
646
+ showMainApp();
647
  } else {
648
+ showLogin();
649
  }
650
  }
651
+ } catch (e) {
652
+ console.error('Auth error:', e);
653
+ showLogin();
 
654
  }
655
  }
656
 
657
  async function loginToHuggingFace() {
658
+ const url = await oauthLoginUrl();
659
+ window.location.href = url;
 
 
 
 
 
660
  }
661
 
662
  function logout() {
663
+ sessionStorage.clear();
 
 
664
  userToken = null;
665
  currentUser = null;
666
+ disconnectAll();
667
+ showLogin();
668
  }
669
 
670
+ function showLogin() {
671
+ document.getElementById('loginView').classList.remove('hidden');
672
+ document.getElementById('mainApp').classList.add('hidden');
673
  }
674
 
675
+ function showMainApp() {
676
+ document.getElementById('loginView').classList.add('hidden');
677
+ document.getElementById('mainApp').classList.remove('hidden');
678
  document.getElementById('username').textContent = '@' + currentUser;
679
+ log('Ready to connect', 'info');
680
  }
681
 
682
  // Logging
683
+ function log(msg, type = '') {
684
+ const area = document.getElementById('logArea');
 
685
  const entry = document.createElement('div');
686
+ entry.className = 'log-entry ' + type;
687
+ entry.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
688
+ area.appendChild(entry);
689
+ area.scrollTop = area.scrollHeight;
 
690
  }
691
 
692
  function clearLog() {
693
  document.getElementById('logArea').innerHTML = '';
694
  }
695
 
696
+ // Connection status
697
+ function updateStatus(status, text) {
698
+ const dot = document.getElementById('statusDot');
699
+ const txt = document.getElementById('statusText');
700
+ dot.className = 'status-dot ' + status;
701
+ txt.textContent = text;
702
+ }
703
 
704
+ // Send to server
705
+ async function sendToServer(message) {
706
  try {
707
+ const res = await fetch(`${SIGNALING_SERVER}/send?token=${encodeURIComponent(userToken)}`, {
708
  method: 'POST',
709
+ headers: { 'Content-Type': 'application/json' },
 
 
 
710
  body: JSON.stringify(message)
711
  });
712
+ return await res.json();
 
 
 
 
 
 
713
  } catch (e) {
714
+ log(`Send error: ${e.message}`, 'error');
715
  return null;
716
  }
717
  }
718
 
719
+ // Send command via data channel
720
+ function sendCommand(cmd) {
721
+ if (!dataChannel || dataChannel.readyState !== 'open') {
722
+ log('Not connected', 'error');
723
+ return false;
724
+ }
725
+ dataChannel.send(JSON.stringify(cmd));
726
+ return true;
727
+ }
728
 
729
+ // SSE Connection
730
  async function connectSignaling() {
731
+ if (!userToken) return;
 
 
 
732
 
733
+ updateStatus('connecting', 'Connecting...');
734
+ log('Connecting to server...');
735
+ document.getElementById('connectBtn').disabled = true;
736
 
737
  sseAbortController = new AbortController();
738
 
739
  try {
740
+ const res = await fetch(`${SIGNALING_SERVER}/events?token=${encodeURIComponent(userToken)}`, {
741
+ signal: sseAbortController.signal
 
 
 
 
 
742
  });
743
 
744
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
 
 
745
 
746
+ updateStatus('connected', 'Server connected');
747
+ log('Connected to signaling server', 'success');
748
+ document.getElementById('robotSelector').classList.remove('hidden');
 
749
 
750
+ const reader = res.body.getReader();
 
751
  const decoder = new TextDecoder();
752
  let buffer = '';
753
 
 
757
 
758
  buffer += decoder.decode(value, { stream: true });
759
  const lines = buffer.split('\n');
760
+ buffer = lines.pop();
761
 
762
  for (const line of lines) {
763
  if (line.startsWith('data:')) {
764
  const data = line.slice(5).trim();
765
  if (data) {
766
  try {
767
+ handleSignalingMessage(JSON.parse(data));
768
+ } catch (e) {}
 
 
 
 
769
  }
770
  }
771
  }
772
  }
 
773
  } catch (e) {
774
+ if (e.name !== 'AbortError') {
775
+ log(`Connection failed: ${e.message}`, 'error');
 
 
776
  }
777
+ updateStatus('', 'Disconnected');
778
  document.getElementById('connectBtn').disabled = false;
779
+ document.getElementById('robotSelector').classList.add('hidden');
 
 
 
780
  }
781
  }
782
 
783
+ function disconnectAll() {
784
+ if (sseAbortController) sseAbortController.abort();
 
 
 
 
 
 
785
  stopStream();
786
+ document.getElementById('connectBtn').disabled = false;
 
 
 
 
 
 
787
  }
788
 
789
  async function handleSignalingMessage(msg) {
790
  switch (msg.type) {
791
  case 'welcome':
792
  myPeerId = msg.peerId;
793
+ await sendToServer({ type: 'setPeerStatus', roles: ['listener'], meta: { name: 'Telepresence App' } });
 
 
 
 
 
 
794
  break;
 
795
  case 'list':
796
+ displayRobots(msg.producers);
797
  break;
 
798
  case 'peerStatusChanged':
799
+ const list = await sendToServer({ type: 'list' });
800
+ if (list?.producers) displayRobots(list.producers);
 
 
 
 
801
  break;
 
802
  case 'sessionStarted':
803
  currentSessionId = msg.sessionId;
 
804
  break;
 
805
  case 'peer':
806
  handlePeerMessage(msg);
807
  break;
 
 
 
 
 
 
808
  case 'error':
809
  log(`Error: ${msg.details}`, 'error');
810
  break;
811
  }
812
  }
813
 
814
+ function displayRobots(robots) {
815
+ const list = document.getElementById('robotList');
816
+ list.innerHTML = '';
817
 
818
+ if (!robots?.length) {
819
+ list.innerHTML = '<em style="color: #666;">No robots online. Make sure your robot is connected.</em>';
820
+ document.getElementById('startBtn').disabled = true;
821
  return;
822
  }
823
 
824
+ for (const robot of robots) {
825
  const div = document.createElement('div');
826
+ div.className = 'robot-item' + (robot.id === selectedProducerId ? ' selected' : '');
827
+ div.innerHTML = `
828
+ <div class="name">${robot.meta?.name || 'Reachy Mini'}</div>
829
+ <div class="id">${robot.id.slice(0, 8)}...</div>
830
+ `;
831
+ div.onclick = () => selectRobot(robot, div);
832
+ list.appendChild(div);
833
  }
 
 
834
  }
835
 
836
+ function selectRobot(robot, el) {
837
+ document.querySelectorAll('.robot-item').forEach(e => e.classList.remove('selected'));
838
+ el.classList.add('selected');
839
+ selectedProducerId = robot.id;
840
+ document.getElementById('robotName').textContent = robot.meta?.name || 'Reachy Mini';
841
+ document.getElementById('startBtn').disabled = false;
842
+ log(`Selected: ${robot.meta?.name || robot.id.slice(0, 8)}`);
843
  }
844
 
845
  // WebRTC
846
  async function startStream() {
847
+ if (!selectedProducerId) return;
 
 
 
848
 
849
+ log('Starting WebRTC...');
850
+ updateStatus('connecting', 'Connecting to robot...');
851
 
852
  peerConnection = new RTCPeerConnection({
853
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
854
  });
855
 
856
+ peerConnection.ontrack = (e) => {
857
+ if (e.track.kind === 'video') {
858
+ document.getElementById('remoteVideo').srcObject = e.streams[0];
859
+ log('Video stream received', 'success');
860
  }
861
  };
862
 
863
+ peerConnection.onicecandidate = async (e) => {
864
+ if (e.candidate && currentSessionId) {
865
  await sendToServer({
866
  type: 'peer',
867
  sessionId: currentSessionId,
868
+ ice: { candidate: e.candidate.candidate, sdpMLineIndex: e.candidate.sdpMLineIndex, sdpMid: e.candidate.sdpMid }
 
 
 
 
869
  });
870
  }
871
  };
872
 
873
  peerConnection.oniceconnectionstatechange = () => {
874
+ const state = peerConnection.iceConnectionState;
875
+ if (state === 'connected' || state === 'completed') {
876
+ updateStatus('connected', 'Connected');
877
+ enableControls(true);
878
+ document.getElementById('robotSelector').classList.add('hidden');
879
+ // Start state refresh
880
+ stateRefreshInterval = setInterval(() => sendCommand({ get_state: true }), 2000);
881
+ } else if (state === 'failed' || state === 'disconnected') {
882
+ updateStatus('', 'Connection lost');
883
+ log('Connection lost', 'error');
884
  }
885
  };
886
 
887
+ peerConnection.ondatachannel = (e) => {
888
+ dataChannel = e.channel;
 
889
  dataChannel.onopen = () => {
890
  log('Data channel open', 'success');
891
+ sendCommand({ get_state: true });
 
 
 
 
 
 
 
 
 
 
892
  };
893
+ dataChannel.onmessage = (e) => handleRobotMessage(JSON.parse(e.data));
894
  };
895
 
896
+ document.getElementById('startBtn').disabled = true;
897
+ document.getElementById('stopBtn').disabled = false;
 
 
 
898
 
899
+ const res = await sendToServer({ type: 'startSession', peerId: selectedProducerId });
900
+ if (res?.sessionId) currentSessionId = res.sessionId;
 
 
901
  }
902
 
903
  async function handlePeerMessage(msg) {
904
  if (!peerConnection) return;
 
905
  try {
906
  if (msg.sdp) {
 
907
  await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp));
 
908
  if (msg.sdp.type === 'offer') {
909
  const answer = await peerConnection.createAnswer();
910
  await peerConnection.setLocalDescription(answer);
911
+ await sendToServer({ type: 'peer', sessionId: currentSessionId, sdp: { type: 'answer', sdp: answer.sdp } });
 
 
 
 
 
912
  }
913
  }
 
914
  if (msg.ice) {
915
  await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice));
916
  }
917
  } catch (e) {
918
+ log(`WebRTC error: ${e.message}`, 'error');
919
  }
920
  }
921
 
922
  function stopStream() {
923
+ if (stateRefreshInterval) clearInterval(stateRefreshInterval);
924
+ if (peerConnection) peerConnection.close();
925
+ if (dataChannel) dataChannel.close();
926
+ peerConnection = null;
927
+ dataChannel = null;
 
 
 
928
  currentSessionId = null;
929
  document.getElementById('remoteVideo').srcObject = null;
930
+ document.getElementById('startBtn').disabled = !selectedProducerId;
931
+ document.getElementById('stopBtn').disabled = true;
932
+ document.getElementById('robotSelector').classList.remove('hidden');
933
+ enableControls(false);
934
+ updateStatus('connected', 'Server connected');
935
+ resetStateDisplay();
 
 
 
 
 
 
 
 
 
 
936
  }
937
 
938
+ function enableControls(enabled) {
939
+ const btns = ['btnEnable', 'btnDisable', 'btnGravity', 'btnSendPose', 'btnCenter',
940
+ 'btnSendAnt', 'btnResetAnt', 'btnWakeUp', 'btnSleep', 'btnPlaySound',
941
+ 'btnStartRec', 'btnStopRec'];
942
+ btns.forEach(id => document.getElementById(id).disabled = !enabled);
 
943
  }
944
 
945
+ // Robot message handler
946
+ function handleRobotMessage(data) {
947
  if (data.state) {
948
+ updateStateDisplay(data.state);
 
 
 
949
  } else if (data.motor_mode) {
950
+ setMotorButtonActive(data.motor_mode);
951
+ document.getElementById('stateMotors').textContent = data.motor_mode;
952
  } else if (data.error) {
953
+ log(`Robot error: ${data.error}`, 'error');
954
+ } else if (data.status === 'ok' && data.completed) {
955
+ log(`${data.command} completed`, 'success');
 
 
 
 
 
 
 
 
956
  }
957
  }
958
 
959
+ function updateStateDisplay(state) {
960
+ if (state.motor_mode) {
961
+ document.getElementById('stateMotors').textContent = state.motor_mode;
962
+ setMotorButtonActive(state.motor_mode);
963
+ }
964
+ if (state.head_pose) {
965
+ // Extract angles from rotation matrix (simplified)
966
+ const m = state.head_pose;
967
+ const pitch = Math.asin(-m[2][0]) * 180 / Math.PI;
968
+ const yaw = Math.atan2(m[1][0], m[0][0]) * 180 / Math.PI;
969
+ document.getElementById('stateYaw').textContent = yaw.toFixed(1) + '°';
970
+ document.getElementById('statePitch').textContent = pitch.toFixed(1) + '°';
971
+ }
972
+ if (state.body_yaw !== undefined) {
973
+ document.getElementById('stateBodyYaw').textContent = (state.body_yaw * 180 / Math.PI).toFixed(1) + '°';
974
+ }
975
+ if (state.antennas) {
976
+ document.getElementById('stateRightAnt').textContent = (state.antennas[0] * 180 / Math.PI).toFixed(0) + '°';
977
+ document.getElementById('stateLeftAnt').textContent = (state.antennas[1] * 180 / Math.PI).toFixed(0) + '°';
978
+ }
979
+ if (state.is_recording !== undefined) {
980
+ document.getElementById('stateRecording').textContent = state.is_recording ? '🔴 REC' : 'OFF';
981
+ document.getElementById('btnStartRec').classList.toggle('recording', state.is_recording);
982
  }
 
 
983
  }
984
 
985
+ function resetStateDisplay() {
986
+ ['stateMotors', 'stateYaw', 'statePitch', 'stateBodyYaw', 'stateRightAnt', 'stateLeftAnt'].forEach(id => {
987
+ document.getElementById(id).textContent = '--';
988
+ });
989
+ document.getElementById('stateRecording').textContent = '--';
990
+ setMotorButtonActive(null);
 
 
 
 
 
 
991
  }
992
 
993
+ function setMotorButtonActive(mode) {
994
+ document.getElementById('btnEnable').classList.toggle('active', mode === 'enabled');
995
+ document.getElementById('btnDisable').classList.toggle('active', mode === 'disabled');
996
+ document.getElementById('btnGravity').classList.toggle('active', mode === 'gravity_compensation');
997
+ currentMotorMode = mode;
998
+ }
 
999
 
1000
+ // Slider updates
1001
+ function updateSliderValue(name) {
1002
+ const slider = document.getElementById(name + 'Slider');
1003
+ const display = document.getElementById(name + 'Value');
1004
+ display.textContent = slider.value;
 
 
 
 
 
 
 
 
 
1005
  }
1006
 
1007
+ // Controls
1008
+ function setMotorMode(mode) {
1009
+ sendCommand({ set_motor_mode: mode });
1010
+ log(`Motor: ${mode}`);
 
1011
  }
1012
 
1013
+ function buildMatrix(yaw, pitch) {
1014
+ const yr = yaw * Math.PI / 180, pr = pitch * Math.PI / 180;
1015
+ const cy = Math.cos(yr), sy = Math.sin(yr), cp = Math.cos(pr), sp = Math.sin(pr);
1016
+ return [[cy*cp, -sy, cy*sp, 0], [sy*cp, cy, sy*sp, 0], [-sp, 0, cp, 0], [0, 0, 0, 1]];
 
 
1017
  }
1018
 
1019
+ function sendHeadPose() {
1020
+ const yaw = parseInt(document.getElementById('yawSlider').value);
1021
+ const pitch = parseInt(document.getElementById('pitchSlider').value);
1022
+ const smooth = document.getElementById('smoothCheck').checked;
1023
+ const duration = parseFloat(document.getElementById('durationInput').value);
1024
+ const bodyYaw = parseInt(document.getElementById('bodyYawSlider').value) * Math.PI / 180;
1025
+
1026
+ if (smooth) {
1027
+ sendCommand({ goto_target: { head: buildMatrix(yaw, pitch), body_yaw: bodyYaw, duration } });
1028
+ log(`Goto: yaw=${yaw}°, pitch=${pitch}°, duration=${duration}s`);
 
 
 
 
 
1029
  } else {
1030
+ sendCommand({ set_target: buildMatrix(yaw, pitch) });
1031
+ if (bodyYaw !== 0) sendCommand({ set_body_yaw: bodyYaw });
1032
+ log(`Set: yaw=${yaw}°, pitch=${pitch}°`);
1033
  }
1034
  }
1035
 
1036
+ function centerHead() {
1037
+ ['yawSlider', 'pitchSlider', 'bodyYawSlider'].forEach(id => document.getElementById(id).value = 0);
1038
+ ['yawValue', 'pitchValue', 'bodyYawValue'].forEach(id => document.getElementById(id).textContent = '0');
1039
+ sendCommand({ goto_target: { head: buildMatrix(0, 0), body_yaw: 0, duration: 0.5 } });
1040
+ log('Centering head...');
1041
+ }
1042
+
1043
  function sendAntennas() {
1044
+ const r = parseInt(document.getElementById('rightAntSlider').value) * Math.PI / 180;
1045
+ const l = parseInt(document.getElementById('leftAntSlider').value) * Math.PI / 180;
1046
+ sendCommand({ set_antennas: [r, l] });
1047
+ log(`Antennas: R=${(r*180/Math.PI).toFixed(0)}°, L=${(l*180/Math.PI).toFixed(0)}°`);
 
 
1048
  }
1049
 
1050
  function resetAntennas() {
1051
+ document.getElementById('rightAntSlider').value = 0;
1052
+ document.getElementById('leftAntSlider').value = 0;
1053
+ document.getElementById('rightAntValue').textContent = '0';
1054
+ document.getElementById('leftAntValue').textContent = '0';
1055
  sendCommand({ set_antennas: [0, 0] });
1056
+ log('Antennas reset');
1057
  }
1058
 
 
1059
  function wakeUp() {
1060
  sendCommand({ wake_up: true });
1061
+ log('Wake up animation...', 'info');
1062
  }
1063
 
1064
  function goToSleep() {
1065
  sendCommand({ goto_sleep: true });
1066
+ log('Sleep animation...', 'info');
1067
  }
1068
 
 
1069
  function playSound() {
1070
+ const file = document.getElementById('soundInput').value.trim();
1071
+ if (file) {
1072
+ sendCommand({ play_sound: file });
1073
+ log(`Playing: ${file}`);
1074
  }
 
 
1075
  }
1076
 
1077
+ function playSoundPreset(file) {
1078
+ document.getElementById('soundInput').value = file;
1079
+ sendCommand({ play_sound: file });
1080
+ log(`Playing: ${file}`);
1081
  }
1082
 
 
1083
  function startRecording() {
1084
  sendCommand({ start_recording: true });
1085
+ log('Recording started', 'info');
1086
  }
1087
 
1088
  function stopRecording() {
1089
  sendCommand({ stop_recording: true });
1090
+ log('Recording stopped', 'info');
 
 
 
 
 
 
 
 
 
 
 
 
1091
  }
1092
  </script>
1093
  </body>