webrtc_example-dev / index.html
cduss's picture
Fix set_target matrix format: send 4x4 nested array
d3be31f
raw
history blame
26.7 kB
<!doctype html>
<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>