File size: 14,763 Bytes
65da30d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c2174e8
 
65da30d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563f5e2
65da30d
c2174e8
 
65da30d
 
563f5e2
 
65da30d
 
 
 
 
 
563f5e2
65da30d
 
 
 
 
 
 
 
 
563f5e2
 
 
 
 
 
 
65da30d
563f5e2
 
 
65da30d
c2174e8
 
 
 
 
65da30d
c2174e8
563f5e2
c2174e8
 
563f5e2
c2174e8
 
563f5e2
308f53f
 
 
563f5e2
 
 
 
 
308f53f
 
 
 
563f5e2
308f53f
563f5e2
308f53f
 
 
c2174e8
65da30d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
// 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 = '<span><span class="status-dot green"></span>Connected</span>';
        elements.executeBtn.disabled = false;
    } else {
        elements.robotStatus.className = 'status disconnected';
        elements.robotStatus.innerHTML = '<span><span class="status-dot red"></span>Disconnected</span>';
        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 = `<span><span class="status-dot ${dot}"></span>${message}</span>`;
}

// 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 = '<div style="color: #999; text-align: center; padding: 20px;">No IR yet. Verify a script first!</div>';
    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 = '<div style="color: #999; text-align: center; padding: 20px;">No actions</div>';
        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 `
            <div class="ir-action">
                <div class="ir-action-type">${idx + 1}. ${action.type.toUpperCase()}</div>
                <div class="ir-action-details">${details}</div>
            </div>
        `;
    }).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();
}