cduss commited on
Commit
04560b1
·
1 Parent(s): aa9f47b
Files changed (2) hide show
  1. README.md +9 -4
  2. index.html +105 -126
README.md CHANGED
@@ -5,16 +5,21 @@ colorFrom: blue
5
  colorTo: indigo
6
  sdk: static
7
  pinned: false
8
- hf_oauth: true
9
- hf_oauth_expiration_minutes: 480
10
  ---
11
 
12
  # Reachy Mini WebRTC Demo
13
 
14
- WebRTC dashboard to connect to your Reachy Mini robot via the central signaling server.
15
 
16
  ## Features
17
 
18
  - Video streaming from robot camera
19
  - Head control via data channel
20
- - HuggingFace OAuth authentication
 
 
 
 
 
 
 
 
5
  colorTo: indigo
6
  sdk: static
7
  pinned: false
 
 
8
  ---
9
 
10
  # Reachy Mini WebRTC Demo
11
 
12
+ WebRTC dashboard to connect directly to your Reachy Mini robot.
13
 
14
  ## Features
15
 
16
  - Video streaming from robot camera
17
  - Head control via data channel
18
+ - Direct WebSocket connection to robot daemon
19
+
20
+ ## Usage
21
+
22
+ 1. Enter your robot's IP address (e.g., 192.168.1.95)
23
+ 2. Click Connect to establish signaling connection
24
+ 3. Select the robot stream and click Start Stream
25
+ 4. Use the head controls to move the robot's head
index.html CHANGED
@@ -52,9 +52,6 @@
52
  width: 100%;
53
  margin-bottom: 10px;
54
  }
55
- input[type="password"] {
56
- font-family: monospace;
57
- }
58
  button {
59
  background: #00d4ff;
60
  color: #000;
@@ -102,25 +99,20 @@
102
  }
103
  .producer-item:hover { background: #1a4a80; }
104
  .producer-item.selected { border: 2px solid #00d4ff; }
105
-
106
- .token-info {
107
- font-size: 0.85em;
108
- color: #888;
109
- margin-top: 5px;
110
- }
111
- .token-info a { color: #00d4ff; }
112
  </style>
113
  </head>
114
  <body>
115
  <div class="container">
116
  <h1>Reachy Mini WebRTC Dashboard</h1>
117
- <p class="subtitle">Connect to your robot via central signaling server</p>
118
 
119
  <div class="grid">
120
  <!-- Connection Panel -->
121
  <div class="card">
122
  <h2>1. Connection</h2>
123
- <p style="color: #888; font-size: 0.9em;">You should be automatically authenticated via your HuggingFace login.</p>
 
 
124
 
125
  <div class="controls">
126
  <button id="connectBtn" onclick="connectSignaling()">Connect</button>
@@ -128,21 +120,13 @@
128
  </div>
129
 
130
  <div style="margin-top: 15px;">
131
- <span>Status: </span>
132
  <span id="signalingStatus" class="status disconnected">Disconnected</span>
133
  </div>
134
 
135
- <!-- Fallback token input (hidden by default, shown if auto-auth fails) -->
136
- <div id="tokenSection" style="display: none; margin-top: 15px; padding-top: 15px; border-top: 1px solid #0f3460;">
137
- <label>HuggingFace Token (fallback):</label>
138
- <input type="password" id="hfToken" placeholder="hf_...">
139
- <p class="token-info">Get your token from <a href="https://huggingface.co/settings/tokens" target="_blank">huggingface.co/settings/tokens</a></p>
140
- <button onclick="connectWithToken()" style="width: 100%;">Connect with Token</button>
141
- </div>
142
-
143
- <h3 style="margin-top: 20px; font-size: 1em;">Available Robots:</h3>
144
  <div id="producerList" class="producer-list">
145
- <em style="color: #666;">Connect to signaling server first</em>
146
  </div>
147
  </div>
148
 
@@ -190,19 +174,18 @@
190
  </div>
191
 
192
  <script>
193
- // Central signaling server
194
- const SIGNALING_SERVER = 'wss://cduss-reachy-mini-central.hf.space/ws';
195
-
196
  // State
197
  let signalingWs = null;
198
  let peerConnection = null;
199
  let dataChannel = null;
200
  let selectedProducerId = null;
201
  let myPeerId = null;
 
202
 
203
  // Logging
204
  function log(message, type = 'info') {
205
  const logArea = document.getElementById('logArea');
 
206
  const entry = document.createElement('div');
207
  entry.className = `log-entry ${type}`;
208
  entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
@@ -215,35 +198,33 @@
215
  document.getElementById('logArea').innerHTML = '';
216
  }
217
 
218
- // Signaling - try without token first (HF proxy auth)
219
  function connectSignaling() {
220
- doConnect(SIGNALING_SERVER);
221
- }
222
-
223
- // Fallback: connect with explicit token
224
- function connectWithToken() {
225
- const token = document.getElementById('hfToken').value.trim();
226
- if (!token) {
227
- log('Please enter your HuggingFace token', 'error');
228
  return;
229
  }
230
- localStorage.setItem('hf_token', token);
231
- doConnect(`${SIGNALING_SERVER}?token=${encodeURIComponent(token)}`);
232
- }
233
 
234
- function doConnect(url) {
235
- log(`Connecting to signaling server...`);
 
236
  updateSignalingStatus('connecting');
237
 
238
  try {
239
  signalingWs = new WebSocket(url);
240
 
241
  signalingWs.onopen = () => {
242
- log('Connected to signaling server!', 'success');
243
  updateSignalingStatus('connected');
244
  document.getElementById('connectBtn').disabled = true;
245
  document.getElementById('disconnectBtn').disabled = false;
246
- document.getElementById('tokenSection').style.display = 'none';
 
 
 
 
 
247
  };
248
 
249
  signalingWs.onmessage = (event) => {
@@ -253,24 +234,17 @@
253
  };
254
 
255
  signalingWs.onerror = (error) => {
256
- log(`WebSocket error`, 'error');
257
  };
258
 
259
  signalingWs.onclose = (event) => {
260
  log(`Disconnected: code=${event.code}`);
261
- if (event.code === 4001) {
262
- log('Auto-auth failed. Please enter your HF token below.', 'error');
263
- document.getElementById('tokenSection').style.display = 'block';
264
- // Try loading saved token
265
- const savedToken = localStorage.getItem('hf_token');
266
- if (savedToken) {
267
- document.getElementById('hfToken').value = savedToken;
268
- }
269
- }
270
  updateSignalingStatus('disconnected');
271
  document.getElementById('connectBtn').disabled = false;
272
  document.getElementById('disconnectBtn').disabled = true;
273
  document.getElementById('startStreamBtn').disabled = true;
 
 
274
  };
275
  } catch (e) {
276
  log(`Failed to connect: ${e.message}`, 'error');
@@ -288,6 +262,7 @@
288
 
289
  function updateSignalingStatus(status) {
290
  const el = document.getElementById('signalingStatus');
 
291
  el.className = `status ${status}`;
292
  el.textContent = status.charAt(0).toUpperCase() + status.slice(1);
293
  }
@@ -296,9 +271,7 @@
296
  switch (msg.type) {
297
  case 'welcome':
298
  myPeerId = msg.peerId;
299
- log(`My peer ID: ${myPeerId}`, 'success');
300
- // Request producer list
301
- signalingWs.send(JSON.stringify({ type: 'list' }));
302
  break;
303
 
304
  case 'list':
@@ -306,13 +279,22 @@
306
  break;
307
 
308
  case 'peerStatusChanged':
309
- log(`Producer ${msg.peerId} ${msg.roles?.length ? 'available' : 'left'}`);
310
- // Refresh list
311
- signalingWs.send(JSON.stringify({ type: 'list' }));
 
 
312
  break;
313
 
314
  case 'sessionStarted':
315
- log(`Session started with producer: ${msg.peerId}`, 'success');
 
 
 
 
 
 
 
316
  break;
317
 
318
  case 'peer':
@@ -325,7 +307,7 @@
325
  break;
326
 
327
  case 'error':
328
- log(`Server error: ${msg.details}`, 'error');
329
  break;
330
  }
331
  }
@@ -335,20 +317,38 @@
335
  container.innerHTML = '';
336
 
337
  if (!producers || !Array.isArray(producers) || producers.length === 0) {
338
- container.innerHTML = '<em style="color: #666;">No robots available. Make sure your robot is connected.</em>';
339
  return;
340
  }
341
 
342
  for (const producer of producers) {
343
- const div = document.createElement('div');
344
- div.className = 'producer-item';
345
- const name = producer.meta?.name || 'Unknown Robot';
346
- div.innerHTML = `<strong>${name}</strong><br><small style="color: #888;">${producer.id.substring(0, 8)}...</small>`;
347
- div.onclick = () => selectProducer(producer.id, div);
348
- container.appendChild(div);
349
  }
350
 
351
- log(`Found ${producers.length} robot(s)`, 'success');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
352
  }
353
 
354
  function selectProducer(peerId, element) {
@@ -356,38 +356,35 @@
356
  element.classList.add('selected');
357
  selectedProducerId = peerId;
358
  document.getElementById('startStreamBtn').disabled = false;
359
- log(`Selected robot: ${peerId.substring(0, 8)}...`);
360
  }
361
 
362
  // WebRTC
363
  async function startStream() {
364
  if (!selectedProducerId) {
365
- log('No robot selected', 'error');
366
  return;
367
  }
368
 
369
  log('Creating peer connection...');
370
  updateWebrtcStatus('connecting');
371
 
372
- const config = {
373
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
374
- };
375
-
376
- peerConnection = new RTCPeerConnection(config);
377
 
378
  peerConnection.ontrack = (event) => {
379
- log(`Received track: ${event.track.kind}`, 'success');
380
  if (event.track.kind === 'video') {
381
  document.getElementById('remoteVideo').srcObject = event.streams[0];
382
  }
383
  };
384
 
385
  peerConnection.onicecandidate = (event) => {
386
- if (event.candidate) {
387
- log(`Sending ICE candidate`);
388
  signalingWs.send(JSON.stringify({
389
  type: 'peer',
390
- sessionId: selectedProducerId,
391
  ice: {
392
  candidate: event.candidate.candidate,
393
  sdpMLineIndex: event.candidate.sdpMLineIndex,
@@ -398,27 +395,31 @@
398
  };
399
 
400
  peerConnection.oniceconnectionstatechange = () => {
401
- log(`ICE state: ${peerConnection.iceConnectionState}`);
402
  if (peerConnection.iceConnectionState === 'connected' ||
403
  peerConnection.iceConnectionState === 'completed') {
404
  updateWebrtcStatus('connected');
405
  document.getElementById('stopStreamBtn').disabled = false;
406
  document.getElementById('sendPoseBtn').disabled = false;
407
  document.getElementById('centerBtn').disabled = false;
408
- } else if (peerConnection.iceConnectionState === 'disconnected' ||
409
- peerConnection.iceConnectionState === 'failed') {
410
  updateWebrtcStatus('disconnected');
 
 
 
411
  }
412
  };
413
 
414
  peerConnection.ondatachannel = (event) => {
415
- log(`Data channel received: ${event.channel.label}`, 'success');
416
  dataChannel = event.channel;
417
- setupDataChannel(dataChannel);
 
 
418
  };
419
 
420
- // Request session with producer - producer will send the SDP offer
421
- log('Requesting session with robot...');
422
  signalingWs.send(JSON.stringify({
423
  type: 'startSession',
424
  peerId: selectedProducerId
@@ -426,10 +427,7 @@
426
  }
427
 
428
  async function handlePeerMessage(msg) {
429
- if (!peerConnection) {
430
- log('No peer connection', 'error');
431
- return;
432
- }
433
 
434
  try {
435
  if (msg.sdp) {
@@ -437,20 +435,18 @@
437
  await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp));
438
 
439
  if (msg.sdp.type === 'offer') {
440
- log('Creating answer...');
441
  const answer = await peerConnection.createAnswer();
442
  await peerConnection.setLocalDescription(answer);
443
- log('Sending SDP answer');
444
  signalingWs.send(JSON.stringify({
445
  type: 'peer',
446
- sessionId: selectedProducerId,
447
  sdp: { type: 'answer', sdp: answer.sdp }
448
  }));
 
449
  }
450
  }
451
 
452
  if (msg.ice) {
453
- log('Received ICE candidate');
454
  await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice));
455
  }
456
  } catch (e) {
@@ -467,49 +463,23 @@
467
  dataChannel.close();
468
  dataChannel = null;
469
  }
 
470
  document.getElementById('remoteVideo').srcObject = null;
471
  updateWebrtcStatus('disconnected');
472
  document.getElementById('stopStreamBtn').disabled = true;
473
  document.getElementById('sendPoseBtn').disabled = true;
474
  document.getElementById('centerBtn').disabled = true;
475
- log('Stream stopped');
476
  }
477
 
478
  function updateWebrtcStatus(status) {
479
  const el = document.getElementById('webrtcStatus');
 
480
  el.className = `status ${status}`;
481
  const labels = { connected: 'Connected', disconnected: 'Not Connected', connecting: 'Connecting...' };
482
  el.textContent = labels[status] || status;
483
  }
484
 
485
- // Data Channel
486
- function setupDataChannel(channel) {
487
- channel.onopen = () => log('Data channel opened', 'success');
488
- channel.onclose = () => log('Data channel closed');
489
- channel.onerror = (e) => log(`Data channel error`, 'error');
490
- channel.onmessage = (event) => log(`Received: ${event.data}`);
491
- }
492
-
493
  // Head Control
494
- function degToRad(deg) {
495
- return deg * Math.PI / 180;
496
- }
497
-
498
- function createRotationMatrix(yawDeg, pitchDeg) {
499
- const yaw = degToRad(yawDeg);
500
- const pitch = degToRad(pitchDeg);
501
-
502
- const cy = Math.cos(yaw), sy = Math.sin(yaw);
503
- const cp = Math.cos(pitch), sp = Math.sin(pitch);
504
-
505
- return [
506
- cy * cp, -sy, cy * sp, 0,
507
- sy * cp, cy, sy * sp, 0,
508
- -sp, 0, cp, 0,
509
- 0, 0, 0, 1
510
- ];
511
- }
512
-
513
  function sendHeadPose() {
514
  if (!dataChannel || dataChannel.readyState !== 'open') {
515
  log('Data channel not ready', 'error');
@@ -518,11 +488,20 @@
518
 
519
  const yaw = parseFloat(document.getElementById('yawInput').value) || 0;
520
  const pitch = parseFloat(document.getElementById('pitchInput').value) || 0;
 
 
521
 
522
- const matrix = createRotationMatrix(yaw, pitch);
523
- const msg = JSON.stringify({ set_target: matrix });
 
 
 
 
 
 
 
524
 
525
- dataChannel.send(msg);
526
  log(`Sent pose: yaw=${yaw}, pitch=${pitch}`);
527
  }
528
 
@@ -532,8 +511,8 @@
532
  sendHeadPose();
533
  }
534
 
535
- // Init
536
- log('Ready. Enter your HuggingFace token and connect.', 'info');
537
  </script>
538
  </body>
539
  </html>
 
52
  width: 100%;
53
  margin-bottom: 10px;
54
  }
 
 
 
55
  button {
56
  background: #00d4ff;
57
  color: #000;
 
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">Connect directly to your robot</p>
108
 
109
  <div class="grid">
110
  <!-- Connection Panel -->
111
  <div class="card">
112
  <h2>1. Connection</h2>
113
+
114
+ <label>Robot Address:</label>
115
+ <input type="text" id="robotAddress" value="192.168.1.95" placeholder="e.g., 192.168.1.95 or reachy-mini.local">
116
 
117
  <div class="controls">
118
  <button id="connectBtn" onclick="connectSignaling()">Connect</button>
 
120
  </div>
121
 
122
  <div style="margin-top: 15px;">
123
+ <span>Signaling: </span>
124
  <span id="signalingStatus" class="status disconnected">Disconnected</span>
125
  </div>
126
 
127
+ <h3 style="margin-top: 20px; font-size: 1em;">Available Streams:</h3>
 
 
 
 
 
 
 
 
128
  <div id="producerList" class="producer-list">
129
+ <em style="color: #666;">Connect to robot first</em>
130
  </div>
131
  </div>
132
 
 
174
  </div>
175
 
176
  <script>
 
 
 
177
  // State
178
  let signalingWs = null;
179
  let peerConnection = null;
180
  let dataChannel = null;
181
  let selectedProducerId = null;
182
  let myPeerId = null;
183
+ let currentSessionId = null;
184
 
185
  // Logging
186
  function log(message, type = 'info') {
187
  const logArea = document.getElementById('logArea');
188
+ if (!logArea) return;
189
  const entry = document.createElement('div');
190
  entry.className = `log-entry ${type}`;
191
  entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
 
198
  document.getElementById('logArea').innerHTML = '';
199
  }
200
 
201
+ // Signaling - connect to robot's daemon WebSocket proxy
202
  function connectSignaling() {
203
+ const robotAddress = document.getElementById('robotAddress').value.trim();
204
+ if (!robotAddress) {
205
+ log('Please enter robot address', 'error');
 
 
 
 
 
206
  return;
207
  }
 
 
 
208
 
209
+ // Connect to daemon's WebRTC signaling proxy endpoint
210
+ const url = `ws://${robotAddress}:8000/webrtc/ws`;
211
+ log(`Connecting to ${url}...`);
212
  updateSignalingStatus('connecting');
213
 
214
  try {
215
  signalingWs = new WebSocket(url);
216
 
217
  signalingWs.onopen = () => {
218
+ log('Connected to signaling!', 'success');
219
  updateSignalingStatus('connected');
220
  document.getElementById('connectBtn').disabled = true;
221
  document.getElementById('disconnectBtn').disabled = false;
222
+ // Request listener role to get producer list
223
+ signalingWs.send(JSON.stringify({
224
+ type: 'setPeerStatus',
225
+ roles: ['listener'],
226
+ meta: { name: 'WebRTC Dashboard' }
227
+ }));
228
  };
229
 
230
  signalingWs.onmessage = (event) => {
 
234
  };
235
 
236
  signalingWs.onerror = (error) => {
237
+ log('WebSocket error - check robot address', 'error');
238
  };
239
 
240
  signalingWs.onclose = (event) => {
241
  log(`Disconnected: code=${event.code}`);
 
 
 
 
 
 
 
 
 
242
  updateSignalingStatus('disconnected');
243
  document.getElementById('connectBtn').disabled = false;
244
  document.getElementById('disconnectBtn').disabled = true;
245
  document.getElementById('startStreamBtn').disabled = true;
246
+ selectedProducerId = null;
247
+ myPeerId = null;
248
  };
249
  } catch (e) {
250
  log(`Failed to connect: ${e.message}`, 'error');
 
262
 
263
  function updateSignalingStatus(status) {
264
  const el = document.getElementById('signalingStatus');
265
+ if (!el) return;
266
  el.className = `status ${status}`;
267
  el.textContent = status.charAt(0).toUpperCase() + status.slice(1);
268
  }
 
271
  switch (msg.type) {
272
  case 'welcome':
273
  myPeerId = msg.peerId;
274
+ log(`My peer ID: ${myPeerId.substring(0, 8)}...`, 'success');
 
 
275
  break;
276
 
277
  case 'list':
 
279
  break;
280
 
281
  case 'peerStatusChanged':
282
+ log(`Producer ${msg.peerId.substring(0, 8)}... ${msg.roles?.length ? 'available' : 'gone'}`);
283
+ if (msg.roles && msg.roles.includes('producer')) {
284
+ // Add/update producer
285
+ addProducer(msg.peerId, msg.meta);
286
+ }
287
  break;
288
 
289
  case 'sessionStarted':
290
+ currentSessionId = msg.sessionId;
291
+ log(`Session started: ${msg.sessionId.substring(0, 8)}...`, 'success');
292
+ break;
293
+
294
+ case 'startSession':
295
+ // Robot is starting session with us (we're consumer)
296
+ currentSessionId = msg.sessionId;
297
+ log(`Session from robot: ${msg.sessionId.substring(0, 8)}...`, 'success');
298
  break;
299
 
300
  case 'peer':
 
307
  break;
308
 
309
  case 'error':
310
+ log(`Error: ${msg.details}`, 'error');
311
  break;
312
  }
313
  }
 
317
  container.innerHTML = '';
318
 
319
  if (!producers || !Array.isArray(producers) || producers.length === 0) {
320
+ container.innerHTML = '<em style="color: #666;">No streams available</em>';
321
  return;
322
  }
323
 
324
  for (const producer of producers) {
325
+ addProducerElement(producer.id, producer.meta);
 
 
 
 
 
326
  }
327
 
328
+ log(`Found ${producers.length} stream(s)`, 'success');
329
+ }
330
+
331
+ function addProducer(peerId, meta) {
332
+ const container = document.getElementById('producerList');
333
+ // Check if already exists
334
+ if (document.getElementById(`producer-${peerId}`)) return;
335
+
336
+ // Clear "no streams" message if present
337
+ if (container.querySelector('em')) {
338
+ container.innerHTML = '';
339
+ }
340
+ addProducerElement(peerId, meta);
341
+ }
342
+
343
+ function addProducerElement(peerId, meta) {
344
+ const container = document.getElementById('producerList');
345
+ const div = document.createElement('div');
346
+ div.id = `producer-${peerId}`;
347
+ div.className = 'producer-item';
348
+ const name = meta?.name || 'Reachy Mini';
349
+ div.innerHTML = `<strong>${name}</strong><br><small style="color: #888;">${peerId.substring(0, 8)}...</small>`;
350
+ div.onclick = () => selectProducer(peerId, div);
351
+ container.appendChild(div);
352
  }
353
 
354
  function selectProducer(peerId, element) {
 
356
  element.classList.add('selected');
357
  selectedProducerId = peerId;
358
  document.getElementById('startStreamBtn').disabled = false;
359
+ log(`Selected: ${peerId.substring(0, 8)}...`);
360
  }
361
 
362
  // WebRTC
363
  async function startStream() {
364
  if (!selectedProducerId) {
365
+ log('No stream selected', 'error');
366
  return;
367
  }
368
 
369
  log('Creating peer connection...');
370
  updateWebrtcStatus('connecting');
371
 
372
+ peerConnection = new RTCPeerConnection({
373
  iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
374
+ });
 
 
375
 
376
  peerConnection.ontrack = (event) => {
377
+ log(`Received ${event.track.kind} track`, 'success');
378
  if (event.track.kind === 'video') {
379
  document.getElementById('remoteVideo').srcObject = event.streams[0];
380
  }
381
  };
382
 
383
  peerConnection.onicecandidate = (event) => {
384
+ if (event.candidate && currentSessionId) {
 
385
  signalingWs.send(JSON.stringify({
386
  type: 'peer',
387
+ sessionId: currentSessionId,
388
  ice: {
389
  candidate: event.candidate.candidate,
390
  sdpMLineIndex: event.candidate.sdpMLineIndex,
 
395
  };
396
 
397
  peerConnection.oniceconnectionstatechange = () => {
398
+ log(`ICE: ${peerConnection.iceConnectionState}`);
399
  if (peerConnection.iceConnectionState === 'connected' ||
400
  peerConnection.iceConnectionState === 'completed') {
401
  updateWebrtcStatus('connected');
402
  document.getElementById('stopStreamBtn').disabled = false;
403
  document.getElementById('sendPoseBtn').disabled = false;
404
  document.getElementById('centerBtn').disabled = false;
405
+ } else if (peerConnection.iceConnectionState === 'failed' ||
406
+ peerConnection.iceConnectionState === 'disconnected') {
407
  updateWebrtcStatus('disconnected');
408
+ if (peerConnection.iceConnectionState === 'failed') {
409
+ log('Connection failed', 'error');
410
+ }
411
  }
412
  };
413
 
414
  peerConnection.ondatachannel = (event) => {
415
+ log(`Data channel: ${event.channel.label}`, 'success');
416
  dataChannel = event.channel;
417
+ dataChannel.onopen = () => log('Data channel open', 'success');
418
+ dataChannel.onclose = () => log('Data channel closed');
419
+ dataChannel.onmessage = (e) => log(`DC message: ${e.data}`);
420
  };
421
 
422
+ log('Requesting session...');
 
423
  signalingWs.send(JSON.stringify({
424
  type: 'startSession',
425
  peerId: selectedProducerId
 
427
  }
428
 
429
  async function handlePeerMessage(msg) {
430
+ if (!peerConnection) return;
 
 
 
431
 
432
  try {
433
  if (msg.sdp) {
 
435
  await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp));
436
 
437
  if (msg.sdp.type === 'offer') {
 
438
  const answer = await peerConnection.createAnswer();
439
  await peerConnection.setLocalDescription(answer);
 
440
  signalingWs.send(JSON.stringify({
441
  type: 'peer',
442
+ sessionId: currentSessionId,
443
  sdp: { type: 'answer', sdp: answer.sdp }
444
  }));
445
+ log('Sent SDP answer');
446
  }
447
  }
448
 
449
  if (msg.ice) {
 
450
  await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice));
451
  }
452
  } catch (e) {
 
463
  dataChannel.close();
464
  dataChannel = null;
465
  }
466
+ currentSessionId = null;
467
  document.getElementById('remoteVideo').srcObject = null;
468
  updateWebrtcStatus('disconnected');
469
  document.getElementById('stopStreamBtn').disabled = true;
470
  document.getElementById('sendPoseBtn').disabled = true;
471
  document.getElementById('centerBtn').disabled = true;
 
472
  }
473
 
474
  function updateWebrtcStatus(status) {
475
  const el = document.getElementById('webrtcStatus');
476
+ if (!el) return;
477
  el.className = `status ${status}`;
478
  const labels = { connected: 'Connected', disconnected: 'Not Connected', connecting: 'Connecting...' };
479
  el.textContent = labels[status] || status;
480
  }
481
 
 
 
 
 
 
 
 
 
482
  // Head Control
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  function sendHeadPose() {
484
  if (!dataChannel || dataChannel.readyState !== 'open') {
485
  log('Data channel not ready', 'error');
 
488
 
489
  const yaw = parseFloat(document.getElementById('yawInput').value) || 0;
490
  const pitch = parseFloat(document.getElementById('pitchInput').value) || 0;
491
+ const yawRad = yaw * Math.PI / 180;
492
+ const pitchRad = pitch * Math.PI / 180;
493
 
494
+ const cy = Math.cos(yawRad), sy = Math.sin(yawRad);
495
+ const cp = Math.cos(pitchRad), sp = Math.sin(pitchRad);
496
+
497
+ const matrix = [
498
+ cy * cp, -sy, cy * sp, 0,
499
+ sy * cp, cy, sy * sp, 0,
500
+ -sp, 0, cp, 0,
501
+ 0, 0, 0, 1
502
+ ];
503
 
504
+ dataChannel.send(JSON.stringify({ set_target: matrix }));
505
  log(`Sent pose: yaw=${yaw}, pitch=${pitch}`);
506
  }
507
 
 
511
  sendHeadPose();
512
  }
513
 
514
+ // Initialize
515
+ log('Ready. Enter robot address and click Connect.', 'info');
516
  </script>
517
  </body>
518
  </html>