Spaces:
Running
Running
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width" /> | |
| <title>Reachy Mini WebRTC Dashboard</title> | |
| <style> | |
| * { box-sizing: border-box; } | |
| body { | |
| font-family: system-ui, -apple-system, sans-serif; | |
| margin: 0; | |
| padding: 20px; | |
| background: #1a1a2e; | |
| color: #eee; | |
| min-height: 100vh; | |
| } | |
| .container { max-width: 1200px; margin: 0 auto; } | |
| h1 { color: #00d4ff; margin-bottom: 10px; } | |
| .subtitle { color: #888; margin-bottom: 30px; } | |
| .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; } | |
| @media (max-width: 900px) { .grid { grid-template-columns: 1fr; } } | |
| .card { | |
| background: #16213e; | |
| border-radius: 12px; | |
| padding: 20px; | |
| border: 1px solid #0f3460; | |
| } | |
| .card h2 { margin-top: 0; color: #00d4ff; font-size: 1.2em; } | |
| .status { | |
| display: inline-block; | |
| padding: 4px 12px; | |
| border-radius: 20px; | |
| font-size: 0.85em; | |
| font-weight: 500; | |
| } | |
| .status.connected { background: #00c853; color: #000; } | |
| .status.disconnected { background: #ff5252; color: #fff; } | |
| .status.connecting { background: #ffc107; color: #000; } | |
| input, button { | |
| padding: 10px 16px; | |
| border-radius: 8px; | |
| border: 1px solid #0f3460; | |
| font-size: 1em; | |
| } | |
| input { | |
| background: #0f3460; | |
| color: #fff; | |
| width: 100%; | |
| margin-bottom: 10px; | |
| } | |
| button { | |
| background: #00d4ff; | |
| color: #000; | |
| cursor: pointer; | |
| font-weight: 600; | |
| transition: background 0.2s; | |
| } | |
| button:hover { background: #00a8cc; } | |
| button:disabled { background: #555; cursor: not-allowed; } | |
| .btn-hf { | |
| background: #ff9d00; | |
| color: #000; | |
| width: 100%; | |
| padding: 12px 20px; | |
| font-size: 1.1em; | |
| } | |
| .btn-hf:hover { background: #ffb340; } | |
| .log { | |
| background: #0a0a1a; | |
| border-radius: 8px; | |
| padding: 12px; | |
| font-family: monospace; | |
| font-size: 0.85em; | |
| max-height: 200px; | |
| overflow-y: auto; | |
| white-space: pre-wrap; | |
| word-break: break-all; | |
| } | |
| .log-entry { margin-bottom: 4px; } | |
| .log-entry.error { color: #ff5252; } | |
| .log-entry.success { color: #00c853; } | |
| .log-entry.info { color: #00d4ff; } | |
| video { | |
| width: 100%; | |
| background: #000; | |
| border-radius: 8px; | |
| aspect-ratio: 16/9; | |
| } | |
| .controls { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 15px; } | |
| .controls button { flex: 1; min-width: 120px; } | |
| .producer-list { margin: 10px 0; } | |
| .producer-item { | |
| background: #0f3460; | |
| padding: 10px; | |
| border-radius: 8px; | |
| margin-bottom: 8px; | |
| cursor: pointer; | |
| transition: background 0.2s; | |
| } | |
| .producer-item:hover { background: #1a4a80; } | |
| .producer-item.selected { border: 2px solid #00d4ff; } | |
| .user-info { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| padding: 10px; | |
| background: #0f3460; | |
| border-radius: 8px; | |
| margin-bottom: 15px; | |
| } | |
| .user-info .username { flex: 1; font-weight: 500; } | |
| .user-info button { padding: 6px 12px; font-size: 0.9em; } | |
| #login-view, #main-view { display: none; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Reachy Mini WebRTC Dashboard</h1> | |
| <p class="subtitle">Connect to your robot via central signaling server</p> | |
| <!-- Login View --> | |
| <div id="login-view"> | |
| <div class="card" style="max-width: 400px; margin: 50px auto; text-align: center;"> | |
| <h2>Sign In Required</h2> | |
| <p style="color: #888; margin-bottom: 20px;">Sign in with your HuggingFace account to connect to your robot.</p> | |
| <button class="btn-hf" onclick="loginToHuggingFace()"> | |
| Sign in with Hugging Face | |
| </button> | |
| </div> | |
| </div> | |
| <!-- Main View (after login) --> | |
| <div id="main-view"> | |
| <div class="grid"> | |
| <!-- Connection Panel --> | |
| <div class="card"> | |
| <h2>1. Connection</h2> | |
| <div class="user-info"> | |
| <span>Signed in as</span> | |
| <span class="username" id="username">@user</span> | |
| <button onclick="logout()">Sign out</button> | |
| </div> | |
| <div class="controls"> | |
| <button id="connectBtn" onclick="connectSignaling()">Connect to Server</button> | |
| <button id="disconnectBtn" onclick="disconnectSignaling()" disabled>Disconnect</button> | |
| </div> | |
| <div style="margin-top: 15px;"> | |
| <span>Status: </span> | |
| <span id="signalingStatus" class="status disconnected">Disconnected</span> | |
| </div> | |
| <h3 style="margin-top: 20px; font-size: 1em;">Available Robots:</h3> | |
| <div id="producerList" class="producer-list"> | |
| <em style="color: #666;">Connect to signaling server first</em> | |
| </div> | |
| </div> | |
| <!-- Video Panel --> | |
| <div class="card"> | |
| <h2>2. Video Stream</h2> | |
| <video id="remoteVideo" autoplay playsinline muted></video> | |
| <div class="controls"> | |
| <button id="startStreamBtn" onclick="startStream()" disabled>Start Stream</button> | |
| <button id="stopStreamBtn" onclick="stopStream()" disabled>Stop Stream</button> | |
| </div> | |
| <div style="margin-top: 15px;"> | |
| <span>WebRTC: </span> | |
| <span id="webrtcStatus" class="status disconnected">Not Connected</span> | |
| </div> | |
| </div> | |
| <!-- Control Panel --> | |
| <div class="card"> | |
| <h2>3. Head Control</h2> | |
| <p style="color: #888; font-size: 0.9em;">Send head pose commands via data channel</p> | |
| <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;"> | |
| <div> | |
| <label>Yaw (deg):</label> | |
| <input type="number" id="yawInput" value="0" min="-45" max="45"> | |
| </div> | |
| <div> | |
| <label>Pitch (deg):</label> | |
| <input type="number" id="pitchInput" value="0" min="-30" max="30"> | |
| </div> | |
| </div> | |
| <div class="controls"> | |
| <button id="sendPoseBtn" onclick="sendHeadPose()" disabled>Send Pose</button> | |
| <button id="centerBtn" onclick="centerHead()" disabled>Center</button> | |
| </div> | |
| </div> | |
| <!-- Log Panel --> | |
| <div class="card"> | |
| <h2>Debug Log</h2> | |
| <div id="logArea" class="log"></div> | |
| <button onclick="clearLog()" style="margin-top: 10px; width: 100%;">Clear Log</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- HuggingFace Hub library for OAuth --> | |
| <script type="module"> | |
| import { oauthLoginUrl, oauthHandleRedirectIfPresent } from "https://cdn.jsdelivr.net/npm/@huggingface/hub@0.15.2/+esm"; | |
| // Central signaling server (HTTP/SSE instead of WebSocket) | |
| const SIGNALING_SERVER = 'https://cduss-reachy-mini-central.hf.space'; | |
| // State | |
| let peerConnection = null; | |
| let dataChannel = null; | |
| let selectedProducerId = null; | |
| let myPeerId = null; | |
| let currentSessionId = null; | |
| let userToken = null; | |
| let currentUser = null; | |
| // Make functions available globally | |
| window.loginToHuggingFace = loginToHuggingFace; | |
| window.logout = logout; | |
| window.connectSignaling = connectSignaling; | |
| window.disconnectSignaling = disconnectSignaling; | |
| window.startStream = startStream; | |
| window.stopStream = stopStream; | |
| window.sendHeadPose = sendHeadPose; | |
| window.centerHead = centerHead; | |
| window.clearLog = clearLog; | |
| // Initialize on page load | |
| document.addEventListener('DOMContentLoaded', initAuth); | |
| async function initAuth() { | |
| try { | |
| // Check if returning from OAuth redirect | |
| const oauthResult = await oauthHandleRedirectIfPresent(); | |
| if (oauthResult) { | |
| currentUser = oauthResult.userInfo.name || oauthResult.userInfo.fullname || oauthResult.userInfo.preferred_username; | |
| userToken = oauthResult.accessToken; | |
| sessionStorage.setItem('hf_token', userToken); | |
| sessionStorage.setItem('hf_username', currentUser); | |
| sessionStorage.setItem('hf_token_expires', oauthResult.accessTokenExpiresAt); | |
| log('OAuth login successful: ' + currentUser, 'success'); | |
| showMainView(); | |
| } else { | |
| // Check stored session | |
| const storedToken = sessionStorage.getItem('hf_token'); | |
| const storedUser = sessionStorage.getItem('hf_username'); | |
| const tokenExpires = sessionStorage.getItem('hf_token_expires'); | |
| if (storedToken && storedUser && tokenExpires && new Date(tokenExpires) > new Date()) { | |
| userToken = storedToken; | |
| currentUser = storedUser; | |
| showMainView(); | |
| } else { | |
| showLoginView(); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Auth error:', error); | |
| log('Auth error: ' + error.message, 'error'); | |
| showLoginView(); | |
| } | |
| } | |
| async function loginToHuggingFace() { | |
| try { | |
| const loginUrl = await oauthLoginUrl(); | |
| window.location.href = loginUrl; | |
| } catch (error) { | |
| console.error('Login error:', error); | |
| alert('Failed to initiate login: ' + error.message); | |
| } | |
| } | |
| function logout() { | |
| sessionStorage.removeItem('hf_token'); | |
| sessionStorage.removeItem('hf_username'); | |
| sessionStorage.removeItem('hf_token_expires'); | |
| userToken = null; | |
| currentUser = null; | |
| disconnectSignaling(); | |
| showLoginView(); | |
| } | |
| function showLoginView() { | |
| document.getElementById('login-view').style.display = 'block'; | |
| document.getElementById('main-view').style.display = 'none'; | |
| } | |
| function showMainView() { | |
| document.getElementById('login-view').style.display = 'none'; | |
| document.getElementById('main-view').style.display = 'block'; | |
| document.getElementById('username').textContent = '@' + currentUser; | |
| log('Ready. Click "Connect to Server" to find your robot.', 'info'); | |
| } | |
| // Logging | |
| function log(message, type = 'info') { | |
| const logArea = document.getElementById('logArea'); | |
| if (!logArea) return; | |
| const entry = document.createElement('div'); | |
| entry.className = `log-entry ${type}`; | |
| entry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; | |
| logArea.appendChild(entry); | |
| logArea.scrollTop = logArea.scrollHeight; | |
| console.log(`[${type}] ${message}`); | |
| } | |
| function clearLog() { | |
| document.getElementById('logArea').innerHTML = ''; | |
| } | |
| // Send message to server via HTTP POST | |
| async function sendToServer(message) { | |
| if (!userToken) { | |
| log('Not authenticated', 'error'); | |
| return null; | |
| } | |
| try { | |
| const response = await fetch(`${SIGNALING_SERVER}/send?token=${encodeURIComponent(userToken)}`, { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json', | |
| 'Authorization': `Bearer ${userToken}`, | |
| }, | |
| body: JSON.stringify(message) | |
| }); | |
| if (!response.ok) { | |
| log(`Server error: ${response.status}`, 'error'); | |
| return null; | |
| } | |
| return await response.json(); | |
| } catch (e) { | |
| log(`Failed to send: ${e.message}`, 'error'); | |
| return null; | |
| } | |
| } | |
| // Signaling via SSE (using fetch for header support) | |
| let sseAbortController = null; | |
| async function connectSignaling() { | |
| if (!userToken) { | |
| log('Not authenticated', 'error'); | |
| return; | |
| } | |
| const url = `${SIGNALING_SERVER}/events?token=${encodeURIComponent(userToken)}`; | |
| log('Connecting to signaling server (SSE)...'); | |
| updateSignalingStatus('connecting'); | |
| sseAbortController = new AbortController(); | |
| try { | |
| const response = await fetch(url, { | |
| method: 'GET', | |
| headers: { | |
| 'Authorization': `Bearer ${userToken}`, | |
| 'Accept': 'text/event-stream', | |
| }, | |
| signal: sseAbortController.signal, | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| log('Connected to signaling server!', 'success'); | |
| updateSignalingStatus('connected'); | |
| document.getElementById('connectBtn').disabled = true; | |
| document.getElementById('disconnectBtn').disabled = false; | |
| // Read the SSE stream | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, { stream: true }); | |
| const lines = buffer.split('\n'); | |
| buffer = lines.pop(); // Keep incomplete line in buffer | |
| for (const line of lines) { | |
| if (line.startsWith('data:')) { | |
| const data = line.slice(5).trim(); | |
| if (data) { | |
| try { | |
| const msg = JSON.parse(data); | |
| log(`Received: ${msg.type}`); | |
| handleSignalingMessage(msg); | |
| } catch (e) { | |
| console.error('Failed to parse:', data, e); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| if (e.name === 'AbortError') { | |
| log('Disconnected', 'info'); | |
| } else { | |
| log(`Connection error: ${e.message}`, 'error'); | |
| } | |
| updateSignalingStatus('disconnected'); | |
| document.getElementById('connectBtn').disabled = false; | |
| document.getElementById('disconnectBtn').disabled = true; | |
| document.getElementById('startStreamBtn').disabled = true; | |
| selectedProducerId = null; | |
| myPeerId = null; | |
| } | |
| } | |
| function disconnectSignaling() { | |
| if (sseAbortController) { | |
| sseAbortController.abort(); | |
| sseAbortController = null; | |
| } | |
| updateSignalingStatus('disconnected'); | |
| document.getElementById('connectBtn').disabled = false; | |
| document.getElementById('disconnectBtn').disabled = true; | |
| stopStream(); | |
| } | |
| function updateSignalingStatus(status) { | |
| const el = document.getElementById('signalingStatus'); | |
| if (!el) return; | |
| el.className = `status ${status}`; | |
| el.textContent = status.charAt(0).toUpperCase() + status.slice(1); | |
| } | |
| async function handleSignalingMessage(msg) { | |
| switch (msg.type) { | |
| case 'welcome': | |
| myPeerId = msg.peerId; | |
| log(`Connected as: ${myPeerId.substring(0, 8)}...`, 'success'); | |
| // Register as listener | |
| await sendToServer({ | |
| type: 'setPeerStatus', | |
| roles: ['listener'], | |
| meta: { name: 'WebRTC Dashboard' } | |
| }); | |
| break; | |
| case 'list': | |
| displayProducers(msg.producers); | |
| break; | |
| case 'peerStatusChanged': | |
| log(`Robot ${msg.peerId.substring(0, 8)}... ${msg.roles?.length ? 'connected' : 'disconnected'}`); | |
| // Refresh producer list | |
| const listResponse = await sendToServer({ type: 'list' }); | |
| if (listResponse && listResponse.producers) { | |
| displayProducers(listResponse.producers); | |
| } | |
| break; | |
| case 'sessionStarted': | |
| currentSessionId = msg.sessionId; | |
| log(`Session started: ${msg.sessionId.substring(0, 8)}...`, 'success'); | |
| break; | |
| case 'peer': | |
| handlePeerMessage(msg); | |
| break; | |
| case 'endSession': | |
| log('Session ended'); | |
| stopStream(); | |
| break; | |
| case 'error': | |
| log(`Error: ${msg.details}`, 'error'); | |
| break; | |
| } | |
| } | |
| function displayProducers(producers) { | |
| const container = document.getElementById('producerList'); | |
| container.innerHTML = ''; | |
| if (!producers || !Array.isArray(producers) || producers.length === 0) { | |
| container.innerHTML = '<em style="color: #666;">No robots connected. Make sure your robot is online and connected to central server.</em>'; | |
| return; | |
| } | |
| for (const producer of producers) { | |
| const div = document.createElement('div'); | |
| div.className = 'producer-item'; | |
| const name = producer.meta?.name || 'Reachy Mini'; | |
| div.innerHTML = `<strong>${name}</strong><br><small style="color: #888;">${producer.id.substring(0, 8)}...</small>`; | |
| div.onclick = () => selectProducer(producer.id, div); | |
| container.appendChild(div); | |
| } | |
| log(`Found ${producers.length} robot(s)`, 'success'); | |
| } | |
| function selectProducer(peerId, element) { | |
| document.querySelectorAll('.producer-item').forEach(el => el.classList.remove('selected')); | |
| element.classList.add('selected'); | |
| selectedProducerId = peerId; | |
| document.getElementById('startStreamBtn').disabled = false; | |
| log(`Selected: ${peerId.substring(0, 8)}...`); | |
| } | |
| // WebRTC | |
| async function startStream() { | |
| if (!selectedProducerId) { | |
| log('No robot selected', 'error'); | |
| return; | |
| } | |
| log('Creating peer connection...'); | |
| updateWebrtcStatus('connecting'); | |
| peerConnection = new RTCPeerConnection({ | |
| iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] | |
| }); | |
| peerConnection.ontrack = (event) => { | |
| log(`Received ${event.track.kind} track`, 'success'); | |
| if (event.track.kind === 'video') { | |
| document.getElementById('remoteVideo').srcObject = event.streams[0]; | |
| } | |
| }; | |
| peerConnection.onicecandidate = async (event) => { | |
| if (event.candidate && currentSessionId) { | |
| await sendToServer({ | |
| type: 'peer', | |
| sessionId: currentSessionId, | |
| ice: { | |
| candidate: event.candidate.candidate, | |
| sdpMLineIndex: event.candidate.sdpMLineIndex, | |
| sdpMid: event.candidate.sdpMid | |
| } | |
| }); | |
| } | |
| }; | |
| peerConnection.oniceconnectionstatechange = () => { | |
| log(`ICE: ${peerConnection.iceConnectionState}`); | |
| if (peerConnection.iceConnectionState === 'connected' || | |
| peerConnection.iceConnectionState === 'completed') { | |
| updateWebrtcStatus('connected'); | |
| document.getElementById('stopStreamBtn').disabled = false; | |
| document.getElementById('sendPoseBtn').disabled = false; | |
| document.getElementById('centerBtn').disabled = false; | |
| } else if (peerConnection.iceConnectionState === 'failed') { | |
| updateWebrtcStatus('disconnected'); | |
| log('Connection failed', 'error'); | |
| } | |
| }; | |
| peerConnection.ondatachannel = (event) => { | |
| log(`Data channel: ${event.channel.label}`, 'success'); | |
| dataChannel = event.channel; | |
| dataChannel.onopen = () => log('Data channel open', 'success'); | |
| dataChannel.onclose = () => log('Data channel closed'); | |
| dataChannel.onmessage = (e) => log(`Received: ${e.data}`); | |
| }; | |
| log('Requesting session with robot...'); | |
| const response = await sendToServer({ | |
| type: 'startSession', | |
| peerId: selectedProducerId | |
| }); | |
| if (response && response.sessionId) { | |
| currentSessionId = response.sessionId; | |
| log(`Session started: ${response.sessionId.substring(0, 8)}...`, 'success'); | |
| } | |
| } | |
| async function handlePeerMessage(msg) { | |
| if (!peerConnection) return; | |
| try { | |
| if (msg.sdp) { | |
| log(`Received SDP ${msg.sdp.type}`); | |
| await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp)); | |
| if (msg.sdp.type === 'offer') { | |
| const answer = await peerConnection.createAnswer(); | |
| await peerConnection.setLocalDescription(answer); | |
| await sendToServer({ | |
| type: 'peer', | |
| sessionId: currentSessionId, | |
| sdp: { type: 'answer', sdp: answer.sdp } | |
| }); | |
| log('Sent SDP answer'); | |
| } | |
| } | |
| if (msg.ice) { | |
| await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice)); | |
| } | |
| } catch (e) { | |
| log(`Error: ${e.message}`, 'error'); | |
| } | |
| } | |
| function stopStream() { | |
| if (peerConnection) { | |
| peerConnection.close(); | |
| peerConnection = null; | |
| } | |
| if (dataChannel) { | |
| dataChannel.close(); | |
| dataChannel = null; | |
| } | |
| currentSessionId = null; | |
| document.getElementById('remoteVideo').srcObject = null; | |
| updateWebrtcStatus('disconnected'); | |
| document.getElementById('stopStreamBtn').disabled = true; | |
| document.getElementById('sendPoseBtn').disabled = true; | |
| document.getElementById('centerBtn').disabled = true; | |
| } | |
| function updateWebrtcStatus(status) { | |
| const el = document.getElementById('webrtcStatus'); | |
| if (!el) return; | |
| el.className = `status ${status}`; | |
| const labels = { connected: 'Connected', disconnected: 'Not Connected', connecting: 'Connecting...' }; | |
| el.textContent = labels[status] || status; | |
| } | |
| // Head Control | |
| function sendHeadPose() { | |
| if (!dataChannel || dataChannel.readyState !== 'open') { | |
| log('Data channel not ready', 'error'); | |
| return; | |
| } | |
| const yaw = parseFloat(document.getElementById('yawInput').value) || 0; | |
| const pitch = parseFloat(document.getElementById('pitchInput').value) || 0; | |
| const yawRad = yaw * Math.PI / 180; | |
| const pitchRad = pitch * Math.PI / 180; | |
| const cy = Math.cos(yawRad), sy = Math.sin(yawRad); | |
| const cp = Math.cos(pitchRad), sp = Math.sin(pitchRad); | |
| // 4x4 transformation matrix (nested array for numpy compatibility) | |
| const matrix = [ | |
| [cy * cp, -sy, cy * sp, 0], | |
| [sy * cp, cy, sy * sp, 0], | |
| [-sp, 0, cp, 0], | |
| [0, 0, 0, 1] | |
| ]; | |
| dataChannel.send(JSON.stringify({ set_target: matrix })); | |
| log(`Sent pose: yaw=${yaw}, pitch=${pitch}`); | |
| } | |
| function centerHead() { | |
| document.getElementById('yawInput').value = 0; | |
| document.getElementById('pitchInput').value = 0; | |
| sendHeadPose(); | |
| } | |
| </script> | |
| </body> | |
| </html> | |