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; } | |
| .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; } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="container"> | |
| <h1>Reachy Mini WebRTC Dashboard</h1> | |
| <p class="subtitle">Test WebRTC connection to robot</p> | |
| <div class="grid"> | |
| <!-- Connection Panel --> | |
| <div class="card"> | |
| <h2>1. Signaling Server</h2> | |
| <label>Robot IP Address:</label> | |
| <input type="text" id="robotIp" value="192.168.1.95" placeholder="192.168.1.95"> | |
| <label>Signaling Port:</label> | |
| <input type="text" id="signalingPort" value="8443" placeholder="8443"> | |
| <div class="controls"> | |
| <button id="connectBtn" onclick="connectSignaling()">Connect</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 Producers:</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> | |
| <script> | |
| // State | |
| let signalingWs = null; | |
| let peerConnection = null; | |
| let dataChannel = null; | |
| let selectedProducerId = null; | |
| let sessionId = null; | |
| // Logging | |
| function log(message, type = 'info') { | |
| const logArea = document.getElementById('logArea'); | |
| 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 = ''; | |
| } | |
| // Signaling | |
| function connectSignaling() { | |
| const ip = document.getElementById('robotIp').value; | |
| const port = document.getElementById('signalingPort').value; | |
| const url = `ws://${ip}:${port}`; | |
| log(`Connecting to signaling server: ${url}`); | |
| updateSignalingStatus('connecting'); | |
| try { | |
| signalingWs = new WebSocket(url); | |
| signalingWs.onopen = () => { | |
| log('Signaling WebSocket connected!', 'success'); | |
| updateSignalingStatus('connected'); | |
| document.getElementById('connectBtn').disabled = true; | |
| document.getElementById('disconnectBtn').disabled = false; | |
| // Request producer list | |
| setTimeout(() => { | |
| log('Requesting producer list...'); | |
| signalingWs.send(JSON.stringify({ type: 'list' })); | |
| }, 500); | |
| }; | |
| signalingWs.onmessage = (event) => { | |
| log(`Signaling message: ${event.data.substring(0, 200)}...`); | |
| handleSignalingMessage(JSON.parse(event.data)); | |
| }; | |
| signalingWs.onerror = (error) => { | |
| log(`Signaling WebSocket error: ${error.message || 'Unknown error'}`, 'error'); | |
| }; | |
| signalingWs.onclose = (event) => { | |
| log(`Signaling WebSocket closed: code=${event.code}, reason=${event.reason}`); | |
| updateSignalingStatus('disconnected'); | |
| document.getElementById('connectBtn').disabled = false; | |
| document.getElementById('disconnectBtn').disabled = true; | |
| document.getElementById('startStreamBtn').disabled = true; | |
| }; | |
| } catch (e) { | |
| log(`Failed to create WebSocket: ${e.message}`, 'error'); | |
| updateSignalingStatus('disconnected'); | |
| } | |
| } | |
| function disconnectSignaling() { | |
| if (signalingWs) { | |
| signalingWs.close(); | |
| signalingWs = null; | |
| } | |
| stopStream(); | |
| } | |
| function updateSignalingStatus(status) { | |
| const el = document.getElementById('signalingStatus'); | |
| el.className = `status ${status}`; | |
| el.textContent = status.charAt(0).toUpperCase() + status.slice(1); | |
| } | |
| function handleSignalingMessage(msg) { | |
| if (msg.type === 'welcome') { | |
| sessionId = msg.peerId; | |
| log(`Got session ID: ${sessionId}`, 'success'); | |
| } else if (msg.type === 'list') { | |
| displayProducers(msg.producers); | |
| } else if (msg.type === 'sessionStarted') { | |
| log(`Session started with peer: ${msg.peerId}`, 'success'); | |
| } else if (msg.type === 'peer') { | |
| handlePeerMessage(msg); | |
| } else if (msg.type === 'error') { | |
| log(`Signaling error: ${msg.details}`, 'error'); | |
| } | |
| } | |
| function displayProducers(producers) { | |
| const container = document.getElementById('producerList'); | |
| container.innerHTML = ''; | |
| // GStreamer returns array of {id, meta} objects | |
| if (!producers || !Array.isArray(producers) || producers.length === 0) { | |
| container.innerHTML = '<em style="color: #666;">No producers available</em>'; | |
| return; | |
| } | |
| for (const producer of producers) { | |
| const div = document.createElement('div'); | |
| div.className = 'producer-item'; | |
| div.innerHTML = `<strong>${producer.meta?.name || 'Unknown'}</strong><br><small>${producer.id}</small>`; | |
| div.onclick = () => selectProducer(producer.id, div); | |
| container.appendChild(div); | |
| } | |
| log(`Found ${producers.length} producer(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 producer: ${peerId}`); | |
| } | |
| // WebRTC | |
| async function startStream() { | |
| if (!selectedProducerId) { | |
| log('No producer selected', 'error'); | |
| return; | |
| } | |
| log('Creating peer connection...'); | |
| updateWebrtcStatus('connecting'); | |
| // LAN connection - no STUN needed for local network | |
| const config = { | |
| iceServers: [] | |
| }; | |
| peerConnection = new RTCPeerConnection(config); | |
| peerConnection.ontrack = (event) => { | |
| log(`Received track: ${event.track.kind}`, 'success'); | |
| if (event.track.kind === 'video') { | |
| document.getElementById('remoteVideo').srcObject = event.streams[0]; | |
| } | |
| }; | |
| peerConnection.onicecandidate = (event) => { | |
| if (event.candidate) { | |
| log(`Sending ICE candidate`); | |
| signalingWs.send(JSON.stringify({ | |
| type: 'peer', | |
| sessionId: selectedProducerId, | |
| ice: { | |
| candidate: event.candidate.candidate, | |
| sdpMLineIndex: event.candidate.sdpMLineIndex, | |
| sdpMid: event.candidate.sdpMid | |
| } | |
| })); | |
| } | |
| }; | |
| peerConnection.oniceconnectionstatechange = () => { | |
| log(`ICE connection state: ${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 === 'disconnected' || | |
| peerConnection.iceConnectionState === 'failed') { | |
| updateWebrtcStatus('disconnected'); | |
| } | |
| }; | |
| peerConnection.ondatachannel = (event) => { | |
| log(`Data channel received: ${event.channel.label}`, 'success'); | |
| dataChannel = event.channel; | |
| setupDataChannel(dataChannel); | |
| }; | |
| // GStreamer webrtcsink is the offerer, we are the answerer | |
| // Just request to start a session, then wait for SDP offer | |
| log('Requesting session with producer...'); | |
| signalingWs.send(JSON.stringify({ | |
| type: 'startSession', | |
| peerId: selectedProducerId | |
| })); | |
| // SDP offer will arrive via handlePeerMessage | |
| } | |
| async function handlePeerMessage(msg) { | |
| if (!peerConnection) { | |
| log('No peer connection, ignoring message', 'error'); | |
| return; | |
| } | |
| try { | |
| if (msg.sdp) { | |
| log(`Received SDP ${msg.sdp.type}`); | |
| await peerConnection.setRemoteDescription(new RTCSessionDescription(msg.sdp)); | |
| if (msg.sdp.type === 'offer') { | |
| log('Creating answer...'); | |
| const answer = await peerConnection.createAnswer(); | |
| await peerConnection.setLocalDescription(answer); | |
| log('Sending SDP answer'); | |
| signalingWs.send(JSON.stringify({ | |
| type: 'peer', | |
| sessionId: selectedProducerId, | |
| sdp: { type: 'answer', sdp: answer.sdp } | |
| })); | |
| } | |
| } | |
| if (msg.ice) { | |
| log('Received ICE candidate'); | |
| try { | |
| await peerConnection.addIceCandidate(new RTCIceCandidate(msg.ice)); | |
| } catch (e) { | |
| log(`Failed to add ICE candidate: ${e.message}`, 'error'); | |
| } | |
| } | |
| } catch (e) { | |
| log(`Error handling peer message: ${e.message}`, 'error'); | |
| } | |
| } | |
| function stopStream() { | |
| if (peerConnection) { | |
| peerConnection.close(); | |
| peerConnection = null; | |
| } | |
| if (dataChannel) { | |
| dataChannel.close(); | |
| dataChannel = null; | |
| } | |
| document.getElementById('remoteVideo').srcObject = null; | |
| updateWebrtcStatus('disconnected'); | |
| document.getElementById('stopStreamBtn').disabled = true; | |
| document.getElementById('sendPoseBtn').disabled = true; | |
| document.getElementById('centerBtn').disabled = true; | |
| log('Stream stopped'); | |
| } | |
| function updateWebrtcStatus(status) { | |
| const el = document.getElementById('webrtcStatus'); | |
| el.className = `status ${status}`; | |
| const labels = { connected: 'Connected', disconnected: 'Not Connected', connecting: 'Connecting...' }; | |
| el.textContent = labels[status] || status; | |
| } | |
| // Data Channel | |
| function setupDataChannel(channel) { | |
| channel.onopen = () => log('Data channel opened', 'success'); | |
| channel.onclose = () => log('Data channel closed'); | |
| channel.onerror = (e) => log(`Data channel error: ${e}`, 'error'); | |
| channel.onmessage = (event) => log(`Data channel message: ${event.data}`); | |
| } | |
| // Head Control | |
| function degToRad(deg) { | |
| return deg * Math.PI / 180; | |
| } | |
| function createRotationMatrix(yawDeg, pitchDeg) { | |
| // Create a 4x4 transformation matrix from yaw and pitch | |
| const yaw = degToRad(yawDeg); | |
| const pitch = degToRad(pitchDeg); | |
| const cy = Math.cos(yaw), sy = Math.sin(yaw); | |
| const cp = Math.cos(pitch), sp = Math.sin(pitch); | |
| // Combined rotation: Rz(yaw) * Ry(pitch) | |
| return [ | |
| cy * cp, -sy, cy * sp, 0, | |
| sy * cp, cy, sy * sp, 0, | |
| -sp, 0, cp, 0, | |
| 0, 0, 0, 1 | |
| ]; | |
| } | |
| 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 matrix = createRotationMatrix(yaw, pitch); | |
| const msg = JSON.stringify({ set_target: matrix }); | |
| dataChannel.send(msg); | |
| log(`Sent head pose: yaw=${yaw}, pitch=${pitch}`); | |
| } | |
| function centerHead() { | |
| document.getElementById('yawInput').value = 0; | |
| document.getElementById('pitchInput').value = 0; | |
| sendHeadPose(); | |
| } | |
| // Init | |
| log('Dashboard loaded. Enter robot IP and connect to signaling server.', 'info'); | |
| </script> | |
| </body> | |
| </html> | |