Spaces:
Running
Running
wip
Browse files- README.md +9 -4
- 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
|
| 15 |
|
| 16 |
## Features
|
| 17 |
|
| 18 |
- Video streaming from robot camera
|
| 19 |
- Head control via data channel
|
| 20 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 118 |
|
| 119 |
<div class="grid">
|
| 120 |
<!-- Connection Panel -->
|
| 121 |
<div class="card">
|
| 122 |
<h2>1. Connection</h2>
|
| 123 |
-
|
|
|
|
|
|
|
| 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>
|
| 132 |
<span id="signalingStatus" class="status disconnected">Disconnected</span>
|
| 133 |
</div>
|
| 134 |
|
| 135 |
-
<
|
| 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
|
| 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 -
|
| 219 |
function connectSignaling() {
|
| 220 |
-
|
| 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 |
-
|
| 235 |
-
|
|
|
|
| 236 |
updateSignalingStatus('connecting');
|
| 237 |
|
| 238 |
try {
|
| 239 |
signalingWs = new WebSocket(url);
|
| 240 |
|
| 241 |
signalingWs.onopen = () => {
|
| 242 |
-
log('Connected to signaling
|
| 243 |
updateSignalingStatus('connected');
|
| 244 |
document.getElementById('connectBtn').disabled = true;
|
| 245 |
document.getElementById('disconnectBtn').disabled = false;
|
| 246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
};
|
| 248 |
|
| 249 |
signalingWs.onmessage = (event) => {
|
|
@@ -253,24 +234,17 @@
|
|
| 253 |
};
|
| 254 |
|
| 255 |
signalingWs.onerror = (error) => {
|
| 256 |
-
log(
|
| 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' : '
|
| 310 |
-
|
| 311 |
-
|
|
|
|
|
|
|
| 312 |
break;
|
| 313 |
|
| 314 |
case 'sessionStarted':
|
| 315 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 316 |
break;
|
| 317 |
|
| 318 |
case 'peer':
|
|
@@ -325,7 +307,7 @@
|
|
| 325 |
break;
|
| 326 |
|
| 327 |
case 'error':
|
| 328 |
-
log(`
|
| 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
|
| 339 |
return;
|
| 340 |
}
|
| 341 |
|
| 342 |
for (const producer of producers) {
|
| 343 |
-
|
| 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}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 360 |
}
|
| 361 |
|
| 362 |
// WebRTC
|
| 363 |
async function startStream() {
|
| 364 |
if (!selectedProducerId) {
|
| 365 |
-
log('No
|
| 366 |
return;
|
| 367 |
}
|
| 368 |
|
| 369 |
log('Creating peer connection...');
|
| 370 |
updateWebrtcStatus('connecting');
|
| 371 |
|
| 372 |
-
|
| 373 |
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
|
| 374 |
-
};
|
| 375 |
-
|
| 376 |
-
peerConnection = new RTCPeerConnection(config);
|
| 377 |
|
| 378 |
peerConnection.ontrack = (event) => {
|
| 379 |
-
log(`Received
|
| 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:
|
| 391 |
ice: {
|
| 392 |
candidate: event.candidate.candidate,
|
| 393 |
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
@@ -398,27 +395,31 @@
|
|
| 398 |
};
|
| 399 |
|
| 400 |
peerConnection.oniceconnectionstatechange = () => {
|
| 401 |
-
log(`ICE
|
| 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 === '
|
| 409 |
-
peerConnection.iceConnectionState === '
|
| 410 |
updateWebrtcStatus('disconnected');
|
|
|
|
|
|
|
|
|
|
| 411 |
}
|
| 412 |
};
|
| 413 |
|
| 414 |
peerConnection.ondatachannel = (event) => {
|
| 415 |
-
log(`Data channel
|
| 416 |
dataChannel = event.channel;
|
| 417 |
-
|
|
|
|
|
|
|
| 418 |
};
|
| 419 |
|
| 420 |
-
|
| 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:
|
| 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
|
| 523 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 524 |
|
| 525 |
-
dataChannel.send(
|
| 526 |
log(`Sent pose: yaw=${yaw}, pitch=${pitch}`);
|
| 527 |
}
|
| 528 |
|
|
@@ -532,8 +511,8 @@
|
|
| 532 |
sendHeadPose();
|
| 533 |
}
|
| 534 |
|
| 535 |
-
//
|
| 536 |
-
log('Ready. Enter
|
| 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>
|