webrtc_example-dev / index.html
cduss's picture
Fix GStreamer signaling protocol handling
d1a2d70
raw
history blame
19 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; }
.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>