cduss Claude Opus 4.5 commited on
Commit
9366b67
·
1 Parent(s): 8730258

Add WebRTC dashboard test page

Browse files

- Signaling server connection test
- Producer discovery
- Basic WebRTC peer connection
- Video stream display
- Head control via data channel

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

Files changed (1) hide show
  1. index.html +489 -16
index.html CHANGED
@@ -1,19 +1,492 @@
1
  <!doctype html>
2
  <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  </html>
 
1
  <!doctype html>
2
  <html>
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
+ .log {
66
+ background: #0a0a1a;
67
+ border-radius: 8px;
68
+ padding: 12px;
69
+ font-family: monospace;
70
+ font-size: 0.85em;
71
+ max-height: 200px;
72
+ overflow-y: auto;
73
+ white-space: pre-wrap;
74
+ word-break: break-all;
75
+ }
76
+ .log-entry { margin-bottom: 4px; }
77
+ .log-entry.error { color: #ff5252; }
78
+ .log-entry.success { color: #00c853; }
79
+ .log-entry.info { color: #00d4ff; }
80
+
81
+ video {
82
+ width: 100%;
83
+ background: #000;
84
+ border-radius: 8px;
85
+ aspect-ratio: 16/9;
86
+ }
87
+
88
+ .controls { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 15px; }
89
+ .controls button { flex: 1; min-width: 120px; }
90
+
91
+ .producer-list { margin: 10px 0; }
92
+ .producer-item {
93
+ background: #0f3460;
94
+ padding: 10px;
95
+ border-radius: 8px;
96
+ margin-bottom: 8px;
97
+ cursor: pointer;
98
+ transition: background 0.2s;
99
+ }
100
+ .producer-item:hover { background: #1a4a80; }
101
+ .producer-item.selected { border: 2px solid #00d4ff; }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <div class="container">
106
+ <h1>Reachy Mini WebRTC Dashboard</h1>
107
+ <p class="subtitle">Test WebRTC connection to robot</p>
108
+
109
+ <div class="grid">
110
+ <!-- Connection Panel -->
111
+ <div class="card">
112
+ <h2>1. Signaling Server</h2>
113
+ <label>Robot IP Address:</label>
114
+ <input type="text" id="robotIp" value="192.168.1.95" placeholder="192.168.1.95">
115
+ <label>Signaling Port:</label>
116
+ <input type="text" id="signalingPort" value="8443" placeholder="8443">
117
+
118
+ <div class="controls">
119
+ <button id="connectBtn" onclick="connectSignaling()">Connect</button>
120
+ <button id="disconnectBtn" onclick="disconnectSignaling()" disabled>Disconnect</button>
121
+ </div>
122
+
123
+ <div style="margin-top: 15px;">
124
+ <span>Status: </span>
125
+ <span id="signalingStatus" class="status disconnected">Disconnected</span>
126
+ </div>
127
+
128
+ <h3 style="margin-top: 20px; font-size: 1em;">Available Producers:</h3>
129
+ <div id="producerList" class="producer-list">
130
+ <em style="color: #666;">Connect to signaling server first</em>
131
+ </div>
132
+ </div>
133
+
134
+ <!-- Video Panel -->
135
+ <div class="card">
136
+ <h2>2. Video Stream</h2>
137
+ <video id="remoteVideo" autoplay playsinline muted></video>
138
+ <div class="controls">
139
+ <button id="startStreamBtn" onclick="startStream()" disabled>Start Stream</button>
140
+ <button id="stopStreamBtn" onclick="stopStream()" disabled>Stop Stream</button>
141
+ </div>
142
+ <div style="margin-top: 15px;">
143
+ <span>WebRTC: </span>
144
+ <span id="webrtcStatus" class="status disconnected">Not Connected</span>
145
+ </div>
146
+ </div>
147
+
148
+ <!-- Control Panel -->
149
+ <div class="card">
150
+ <h2>3. Head Control</h2>
151
+ <p style="color: #888; font-size: 0.9em;">Send head pose commands via data channel</p>
152
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
153
+ <div>
154
+ <label>Yaw (deg):</label>
155
+ <input type="number" id="yawInput" value="0" min="-45" max="45">
156
+ </div>
157
+ <div>
158
+ <label>Pitch (deg):</label>
159
+ <input type="number" id="pitchInput" value="0" min="-30" max="30">
160
+ </div>
161
+ </div>
162
+ <div class="controls">
163
+ <button id="sendPoseBtn" onclick="sendHeadPose()" disabled>Send Pose</button>
164
+ <button id="centerBtn" onclick="centerHead()" disabled>Center</button>
165
+ </div>
166
+ </div>
167
+
168
+ <!-- Log Panel -->
169
+ <div class="card">
170
+ <h2>Debug Log</h2>
171
+ <div id="logArea" class="log"></div>
172
+ <button onclick="clearLog()" style="margin-top: 10px; width: 100%;">Clear Log</button>
173
+ </div>
174
+ </div>
175
+ </div>
176
+
177
+ <script>
178
+ // State
179
+ let signalingWs = null;
180
+ let peerConnection = null;
181
+ let dataChannel = null;
182
+ let selectedProducerId = null;
183
+ let sessionId = null;
184
+
185
+ // Logging
186
+ function log(message, type = 'info') {
187
+ const logArea = document.getElementById('logArea');
188
+ const entry = document.createElement('div');
189
+ entry.className = `log-entry ${type}`;
190
+ entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
191
+ logArea.appendChild(entry);
192
+ logArea.scrollTop = logArea.scrollHeight;
193
+ console.log(`[${type}] ${message}`);
194
+ }
195
+
196
+ function clearLog() {
197
+ document.getElementById('logArea').innerHTML = '';
198
+ }
199
+
200
+ // Signaling
201
+ function connectSignaling() {
202
+ const ip = document.getElementById('robotIp').value;
203
+ const port = document.getElementById('signalingPort').value;
204
+ const url = `ws://${ip}:${port}`;
205
+
206
+ log(`Connecting to signaling server: ${url}`);
207
+ updateSignalingStatus('connecting');
208
+
209
+ try {
210
+ signalingWs = new WebSocket(url);
211
+
212
+ signalingWs.onopen = () => {
213
+ log('Signaling WebSocket connected!', 'success');
214
+ updateSignalingStatus('connected');
215
+ document.getElementById('connectBtn').disabled = true;
216
+ document.getElementById('disconnectBtn').disabled = false;
217
+
218
+ // Request producer list
219
+ setTimeout(() => {
220
+ log('Requesting producer list...');
221
+ signalingWs.send(JSON.stringify({ type: 'list' }));
222
+ }, 500);
223
+ };
224
+
225
+ signalingWs.onmessage = (event) => {
226
+ log(`Signaling message: ${event.data.substring(0, 200)}...`);
227
+ handleSignalingMessage(JSON.parse(event.data));
228
+ };
229
+
230
+ signalingWs.onerror = (error) => {
231
+ log(`Signaling WebSocket error: ${error.message || 'Unknown error'}`, 'error');
232
+ };
233
+
234
+ signalingWs.onclose = (event) => {
235
+ log(`Signaling WebSocket closed: code=${event.code}, reason=${event.reason}`);
236
+ updateSignalingStatus('disconnected');
237
+ document.getElementById('connectBtn').disabled = false;
238
+ document.getElementById('disconnectBtn').disabled = true;
239
+ document.getElementById('startStreamBtn').disabled = true;
240
+ };
241
+ } catch (e) {
242
+ log(`Failed to create WebSocket: ${e.message}`, 'error');
243
+ updateSignalingStatus('disconnected');
244
+ }
245
+ }
246
+
247
+ function disconnectSignaling() {
248
+ if (signalingWs) {
249
+ signalingWs.close();
250
+ signalingWs = null;
251
+ }
252
+ stopStream();
253
+ }
254
+
255
+ function updateSignalingStatus(status) {
256
+ const el = document.getElementById('signalingStatus');
257
+ el.className = `status ${status}`;
258
+ el.textContent = status.charAt(0).toUpperCase() + status.slice(1);
259
+ }
260
+
261
+ function handleSignalingMessage(msg) {
262
+ if (msg.type === 'welcome') {
263
+ sessionId = msg.peerId;
264
+ log(`Got session ID: ${sessionId}`, 'success');
265
+ } else if (msg.type === 'list') {
266
+ displayProducers(msg.producers);
267
+ } else if (msg.type === 'sessionStarted') {
268
+ log(`Session started with peer: ${msg.peerId}`, 'success');
269
+ } else if (msg.type === 'peer') {
270
+ handlePeerMessage(msg);
271
+ } else if (msg.type === 'error') {
272
+ log(`Signaling error: ${msg.details}`, 'error');
273
+ }
274
+ }
275
+
276
+ function displayProducers(producers) {
277
+ const container = document.getElementById('producerList');
278
+ container.innerHTML = '';
279
+
280
+ if (!producers || Object.keys(producers).length === 0) {
281
+ container.innerHTML = '<em style="color: #666;">No producers available</em>';
282
+ return;
283
+ }
284
+
285
+ for (const [peerId, meta] of Object.entries(producers)) {
286
+ const div = document.createElement('div');
287
+ div.className = 'producer-item';
288
+ div.innerHTML = `<strong>${meta.name || 'Unknown'}</strong><br><small>${peerId}</small>`;
289
+ div.onclick = () => selectProducer(peerId, div);
290
+ container.appendChild(div);
291
+ }
292
+
293
+ log(`Found ${Object.keys(producers).length} producer(s)`, 'success');
294
+ }
295
+
296
+ function selectProducer(peerId, element) {
297
+ document.querySelectorAll('.producer-item').forEach(el => el.classList.remove('selected'));
298
+ element.classList.add('selected');
299
+ selectedProducerId = peerId;
300
+ document.getElementById('startStreamBtn').disabled = false;
301
+ log(`Selected producer: ${peerId}`);
302
+ }
303
+
304
+ // WebRTC
305
+ async function startStream() {
306
+ if (!selectedProducerId) {
307
+ log('No producer selected', 'error');
308
+ return;
309
+ }
310
+
311
+ log('Creating peer connection...');
312
+ updateWebrtcStatus('connecting');
313
+
314
+ const config = {
315
+ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
316
+ };
317
+
318
+ peerConnection = new RTCPeerConnection(config);
319
+
320
+ peerConnection.ontrack = (event) => {
321
+ log(`Received track: ${event.track.kind}`, 'success');
322
+ if (event.track.kind === 'video') {
323
+ document.getElementById('remoteVideo').srcObject = event.streams[0];
324
+ }
325
+ };
326
+
327
+ peerConnection.onicecandidate = (event) => {
328
+ if (event.candidate) {
329
+ log(`Sending ICE candidate`);
330
+ signalingWs.send(JSON.stringify({
331
+ type: 'peer',
332
+ sessionId: selectedProducerId,
333
+ ice: {
334
+ candidate: event.candidate.candidate,
335
+ sdpMLineIndex: event.candidate.sdpMLineIndex,
336
+ sdpMid: event.candidate.sdpMid
337
+ }
338
+ }));
339
+ }
340
+ };
341
+
342
+ peerConnection.oniceconnectionstatechange = () => {
343
+ log(`ICE connection state: ${peerConnection.iceConnectionState}`);
344
+ if (peerConnection.iceConnectionState === 'connected') {
345
+ updateWebrtcStatus('connected');
346
+ document.getElementById('stopStreamBtn').disabled = false;
347
+ document.getElementById('sendPoseBtn').disabled = false;
348
+ document.getElementById('centerBtn').disabled = false;
349
+ } else if (peerConnection.iceConnectionState === 'disconnected' ||
350
+ peerConnection.iceConnectionState === 'failed') {
351
+ updateWebrtcStatus('disconnected');
352
+ }
353
+ };
354
+
355
+ peerConnection.ondatachannel = (event) => {
356
+ log(`Data channel received: ${event.channel.label}`, 'success');
357
+ dataChannel = event.channel;
358
+ setupDataChannel(dataChannel);
359
+ };
360
+
361
+ // Add transceivers for receiving
362
+ peerConnection.addTransceiver('video', { direction: 'recvonly' });
363
+ peerConnection.addTransceiver('audio', { direction: 'recvonly' });
364
+
365
+ // Create and send offer
366
+ try {
367
+ const offer = await peerConnection.createOffer();
368
+ await peerConnection.setLocalDescription(offer);
369
+
370
+ log('Sending SDP offer to signaling server...');
371
+ signalingWs.send(JSON.stringify({
372
+ type: 'startSession',
373
+ peerId: selectedProducerId
374
+ }));
375
+
376
+ // Send SDP
377
+ signalingWs.send(JSON.stringify({
378
+ type: 'peer',
379
+ sessionId: selectedProducerId,
380
+ sdp: {
381
+ type: 'offer',
382
+ sdp: offer.sdp
383
+ }
384
+ }));
385
+ } catch (e) {
386
+ log(`Failed to create offer: ${e.message}`, 'error');
387
+ }
388
+ }
389
+
390
+ async function handlePeerMessage(msg) {
391
+ if (msg.sdp) {
392
+ log(`Received SDP ${msg.sdp.type}`);
393
+ await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp));
394
+
395
+ if (msg.sdp.type === 'offer') {
396
+ const answer = await peerConnection.createAnswer();
397
+ await peerConnection.setLocalDescription(answer);
398
+ signalingWs.send(JSON.stringify({
399
+ type: 'peer',
400
+ sessionId: selectedProducerId,
401
+ sdp: { type: 'answer', sdp: answer.sdp }
402
+ }));
403
+ }
404
+ }
405
+
406
+ if (msg.ice) {
407
+ log('Received ICE candidate');
408
+ await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice));
409
+ }
410
+ }
411
+
412
+ function stopStream() {
413
+ if (peerConnection) {
414
+ peerConnection.close();
415
+ peerConnection = null;
416
+ }
417
+ if (dataChannel) {
418
+ dataChannel.close();
419
+ dataChannel = null;
420
+ }
421
+ document.getElementById('remoteVideo').srcObject = null;
422
+ updateWebrtcStatus('disconnected');
423
+ document.getElementById('stopStreamBtn').disabled = true;
424
+ document.getElementById('sendPoseBtn').disabled = true;
425
+ document.getElementById('centerBtn').disabled = true;
426
+ log('Stream stopped');
427
+ }
428
+
429
+ function updateWebrtcStatus(status) {
430
+ const el = document.getElementById('webrtcStatus');
431
+ el.className = `status ${status}`;
432
+ const labels = { connected: 'Connected', disconnected: 'Not Connected', connecting: 'Connecting...' };
433
+ el.textContent = labels[status] || status;
434
+ }
435
+
436
+ // Data Channel
437
+ function setupDataChannel(channel) {
438
+ channel.onopen = () => log('Data channel opened', 'success');
439
+ channel.onclose = () => log('Data channel closed');
440
+ channel.onerror = (e) => log(`Data channel error: ${e}`, 'error');
441
+ channel.onmessage = (event) => log(`Data channel message: ${event.data}`);
442
+ }
443
+
444
+ // Head Control
445
+ function degToRad(deg) {
446
+ return deg * Math.PI / 180;
447
+ }
448
+
449
+ function createRotationMatrix(yawDeg, pitchDeg) {
450
+ // Create a 4x4 transformation matrix from yaw and pitch
451
+ const yaw = degToRad(yawDeg);
452
+ const pitch = degToRad(pitchDeg);
453
+
454
+ const cy = Math.cos(yaw), sy = Math.sin(yaw);
455
+ const cp = Math.cos(pitch), sp = Math.sin(pitch);
456
+
457
+ // Combined rotation: Rz(yaw) * Ry(pitch)
458
+ return [
459
+ cy * cp, -sy, cy * sp, 0,
460
+ sy * cp, cy, sy * sp, 0,
461
+ -sp, 0, cp, 0,
462
+ 0, 0, 0, 1
463
+ ];
464
+ }
465
+
466
+ function sendHeadPose() {
467
+ if (!dataChannel || dataChannel.readyState !== 'open') {
468
+ log('Data channel not ready', 'error');
469
+ return;
470
+ }
471
+
472
+ const yaw = parseFloat(document.getElementById('yawInput').value) || 0;
473
+ const pitch = parseFloat(document.getElementById('pitchInput').value) || 0;
474
+
475
+ const matrix = createRotationMatrix(yaw, pitch);
476
+ const msg = JSON.stringify({ set_target: matrix });
477
+
478
+ dataChannel.send(msg);
479
+ log(`Sent head pose: yaw=${yaw}, pitch=${pitch}`);
480
+ }
481
+
482
+ function centerHead() {
483
+ document.getElementById('yawInput').value = 0;
484
+ document.getElementById('pitchInput').value = 0;
485
+ sendHeadPose();
486
+ }
487
+
488
+ // Init
489
+ log('Dashboard loaded. Enter robot IP and connect to signaling server.', 'info');
490
+ </script>
491
+ </body>
492
  </html>