// RMScript Web Demo - Frontend Application // Connects to: // - Backend API for compilation/verification // - Robot WebSocket (localhost:8000) for execution // Auto-detect backend URL based on environment // If opened as file:// (local development), use localhost:8001 // If served from web server (production/Space), use relative URLs const BACKEND_URL = window.location.protocol === 'file:' ? 'http://localhost:8001' : ''; // Use relative URLs when served from web server const ROBOT_WS_URL = 'ws://localhost:8000/api/move/ws/set_target'; // Global state const state = { robotWs: null, robotConnected: false, currentIR: null, isExecuting: false }; // DOM elements const elements = { editor: document.getElementById('editor'), compileStatus: document.getElementById('compileStatus'), robotStatus: document.getElementById('robotStatus'), console: document.getElementById('console'), irDisplay: document.getElementById('irDisplay'), executionInfo: document.getElementById('executionInfo'), executeBtn: document.getElementById('executeBtn') }; // Example scripts const examples = { basic: `DESCRIPTION Simple head movement look left wait 1s look right wait 1s look center`, complex: `DESCRIPTION Wave hello look left antenna both up wait 1s look right antenna both down wait 0.5s look center`, repeat: `DESCRIPTION Repeat demo REPEAT 3 look left wait 0.5s look right wait 0.5s END look center` }; // Initialize robot WebSocket connection (used for connection status only) // Actual movement commands are sent via REST API /goto endpoint function connectRobotWebSocket() { log('Connecting to robot...', 'info'); state.robotWs = new WebSocket(ROBOT_WS_URL); state.robotWs.onopen = () => { state.robotConnected = true; updateRobotStatus(true); log('Robot connected!', 'success'); }; state.robotWs.onclose = () => { state.robotConnected = false; updateRobotStatus(false); log('Robot disconnected. Reconnecting...', 'warning'); setTimeout(connectRobotWebSocket, 2000); }; state.robotWs.onerror = (error) => { log(`Robot connection error: ${error}`, 'error'); }; state.robotWs.onmessage = (event) => { try { const message = JSON.parse(event.data); if (message.status === 'error') { log(`Robot error: ${message.detail}`, 'error'); } } catch (e) { console.error('Failed to parse robot message:', e); } }; } // Update robot connection status UI function updateRobotStatus(connected) { if (connected) { elements.robotStatus.className = 'status connected'; elements.robotStatus.innerHTML = 'Connected'; elements.executeBtn.disabled = false; } else { elements.robotStatus.className = 'status disconnected'; elements.robotStatus.innerHTML = 'Disconnected'; elements.executeBtn.disabled = true; } } // Update compile status UI function updateCompileStatus(status, message) { const dot = status === 'success' ? 'green' : status === 'error' ? 'red' : 'gray'; elements.compileStatus.className = `status ${status}`; elements.compileStatus.innerHTML = `${message}`; } // Log to console function log(message, type = 'info') { const line = document.createElement('div'); line.className = `console-line ${type}`; line.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; elements.console.appendChild(line); elements.console.scrollTop = elements.console.scrollHeight; } // Clear console function clearConsole() { elements.console.innerHTML = ''; } // Load example script function loadExample(exampleName) { if (examples[exampleName]) { elements.editor.value = examples[exampleName]; log(`Loaded ${exampleName} example`, 'info'); } } // Clear editor function clearEditor() { elements.editor.value = ''; state.currentIR = null; elements.irDisplay.innerHTML = '
No IR yet. Verify a script first!
'; updateCompileStatus('idle', 'Ready'); } // Verify script (syntax and semantics only) async function verifyScript() { const source = elements.editor.value.trim(); if (!source) { log('Editor is empty', 'warning'); return; } clearConsole(); log('Verifying script...', 'info'); updateCompileStatus('idle', 'Verifying...'); try { const response = await fetch(`${BACKEND_URL}/api/verify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source }) }); const result = await response.json(); if (result.success) { log('✓ Verification successful!', 'success'); if (result.name) log(` Name: ${result.name}`, 'info'); if (result.description) log(` Description: ${result.description}`, 'info'); if (result.warnings.length > 0) { result.warnings.forEach(w => { log(` Warning (line ${w.line}): ${w.message}`, 'warning'); }); } updateCompileStatus('success', 'Valid script'); } else { log('✗ Verification failed', 'error'); result.errors.forEach(e => { log(` Error (line ${e.line}): ${e.message}`, 'error'); }); updateCompileStatus('error', 'Invalid script'); } } catch (error) { log(`Backend error: ${error.message}`, 'error'); updateCompileStatus('error', 'Backend error'); } } // Compile script to IR async function compileScript() { const source = elements.editor.value.trim(); if (!source) { log('Editor is empty', 'warning'); return null; } log('Compiling script to IR...', 'info'); updateCompileStatus('idle', 'Compiling...'); try { const response = await fetch(`${BACKEND_URL}/api/compile`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ source }) }); const result = await response.json(); if (result.success) { log(`✓ Compiled ${result.ir.length} actions`, 'success'); if (result.warnings.length > 0) { result.warnings.forEach(w => { log(` Warning (line ${w.line}): ${w.message}`, 'warning'); }); } state.currentIR = result.ir; displayIR(result.ir); updateCompileStatus('success', `${result.ir.length} actions ready`); return result.ir; } else { log('✗ Compilation failed', 'error'); result.errors.forEach(e => { log(` Error (line ${e.line}): ${e.message}`, 'error'); }); updateCompileStatus('error', 'Compilation failed'); return null; } } catch (error) { log(`Backend error: ${error.message}`, 'error'); updateCompileStatus('error', 'Backend error'); return null; } } // Display IR in the UI function displayIR(ir) { if (!ir || ir.length === 0) { elements.irDisplay.innerHTML = '
No actions
'; return; } const html = ir.map((action, idx) => { let details = ''; if (action.type === 'action') { details = `Duration: ${action.duration}s`; if (action.head_pose) details += ', Head movement'; if (action.antennas) details += `, Antennas: [${action.antennas.map(a => a.toFixed(2)).join(', ')}]`; if (action.body_yaw !== null) details += `, Body yaw: ${action.body_yaw.toFixed(2)}`; } else if (action.type === 'wait') { details = `Wait ${action.duration}s`; } else if (action.type === 'picture') { details = 'Take picture'; } else if (action.type === 'sound') { details = `Play "${action.sound_name}"`; if (action.blocking) details += ' (blocking)'; if (action.loop) details += ' (loop)'; } return `
${idx + 1}. ${action.type.toUpperCase()}
${details}
`; }).join(''); elements.irDisplay.innerHTML = html; } // Execute compiled script on robot async function executeScript() { if (!state.robotConnected) { log('Robot not connected!', 'error'); return; } if (state.isExecuting) { log('Already executing a script', 'warning'); return; } // Always compile the current editor content before executing const ir = await compileScript(); if (!ir) { log('Cannot execute - compilation failed', 'error'); return; } // Execute IR actions sequentially state.isExecuting = true; elements.executeBtn.disabled = true; clearExecutionInfo(); logExecution('Starting execution...', 'info'); try { for (let i = 0; i < ir.length; i++) { const action = ir[i]; logExecution(`Action ${i + 1}/${ir.length}: ${action.type}`, 'info'); if (action.type === 'action') { await executeIRAction(action); } else if (action.type === 'wait') { await sleep(action.duration * 1000); } else if (action.type === 'picture') { logExecution('Picture action skipped (not implemented)', 'warning'); } else if (action.type === 'sound') { logExecution('Sound action skipped (not implemented)', 'warning'); } } logExecution('✓ Execution complete!', 'success'); } catch (error) { logExecution(`✗ Execution error: ${error.message}`, 'error'); } finally { state.isExecuting = false; if (state.robotConnected) { elements.executeBtn.disabled = false; } } } // Extract euler angles (roll, pitch, yaw) from 3x3 rotation matrix // Uses XYZ euler convention to match scipy's R.from_matrix().as_euler("xyz") function matrixToEulerXYZ(rotMatrix) { // Extract 3x3 rotation matrix from 4x4 pose matrix if needed const R = rotMatrix; // For XYZ euler order: R = Rz(yaw) * Ry(pitch) * Rx(roll) // pitch = asin(-R[2][0]) // roll = atan2(R[2][1], R[2][2]) // yaw = atan2(R[1][0], R[0][0]) let pitch = Math.asin(-R[2][0]); // Check for gimbal lock if (Math.abs(Math.cos(pitch)) > 1e-6) { // Normal case const roll = Math.atan2(R[2][1], R[2][2]); const yaw = Math.atan2(R[1][0], R[0][0]); return { roll, pitch, yaw }; } else { // Gimbal lock case const roll = Math.atan2(-R[0][1], R[1][1]); const yaw = 0; return { roll, pitch, yaw }; } } // Execute a single IR action (send to robot) async function executeIRAction(action) { if (!state.robotConnected) { throw new Error('Robot not connected'); } // Extract pose data let head_pose = null; if (action.head_pose) { const x = action.head_pose[0][3]; const y = action.head_pose[1][3]; const z = action.head_pose[2][3]; const euler = matrixToEulerXYZ(action.head_pose); head_pose = { x: x, y: y, z: z, roll: euler.roll, pitch: euler.pitch, yaw: euler.yaw }; } // Try using /goto endpoint (works locally, respects duration) // Falls back to WebSocket if fetch fails (e.g., on HuggingFace Spaces HTTPS -> HTTP localhost) try { const gotoRequest = { duration: action.duration, interpolation: action.interpolation || 'minjerk' }; if (head_pose) gotoRequest.head_pose = head_pose; if (action.antennas) gotoRequest.antennas = action.antennas; if (action.body_yaw !== null) gotoRequest.body_yaw = action.body_yaw; const response = await fetch('http://localhost:8000/api/move/goto', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(gotoRequest) }); if (!response.ok) { throw new Error('goto endpoint failed'); } // Wait for movement to complete await sleep(action.duration * 1000 + 100); } catch (fetchError) { // Fallback to WebSocket (for Spaces deployment - HTTPS can't fetch localhost) // Note: This won't respect durations - robot moves as fast as possible console.warn('Using WebSocket fallback - durations not supported:', fetchError.message); if (!state.robotWs || state.robotWs.readyState !== WebSocket.OPEN) { throw new Error('Robot WebSocket not connected'); } const wsMessage = {}; if (head_pose) wsMessage.target_head_pose = head_pose; if (action.antennas) wsMessage.target_antennas = action.antennas; if (action.body_yaw !== null) wsMessage.target_body_yaw = action.body_yaw; state.robotWs.send(JSON.stringify(wsMessage)); // Wait for specified duration to maintain script timing // (robot moves fast, but at least script execution pauses correctly) await sleep(action.duration * 1000); } } // Log execution info function logExecution(message, type = 'info') { const line = document.createElement('div'); line.className = `console-line ${type}`; line.textContent = `[${new Date().toLocaleTimeString()}] ${message}`; elements.executionInfo.appendChild(line); elements.executionInfo.scrollTop = elements.executionInfo.scrollHeight; } // Clear execution info function clearExecutionInfo() { elements.executionInfo.innerHTML = ''; } // Sleep helper function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } // Initialize application function init() { console.log('Initializing RMScript Web Demo'); // Connect to robot connectRobotWebSocket(); // Load default example loadExample('basic'); console.log('RMScript Web Demo ready'); } // Start when DOM is loaded if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); }